From 9906b3e03c2b21f6e8e01d50e3e515fc696c76f2 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Mon, 16 Feb 2026 18:18:29 -0500 Subject: [PATCH 1/3] [WIP] floating right menu --- config/toc.js | 255 ++++++++++++++++++++++++ src/_includes/components/local-toc.webc | 17 ++ src/_includes/index.css | 6 +- src/_includes/layouts/main.njk | 8 +- src/docs/docs.11tydata.js | 8 + src/docs/languages/markdown.md | 2 - 6 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 config/toc.js create mode 100644 src/_includes/components/local-toc.webc diff --git a/config/toc.js b/config/toc.js new file mode 100644 index 0000000000..e0d1ef6028 --- /dev/null +++ b/config/toc.js @@ -0,0 +1,255 @@ +import MarkdownIt from "markdown-it"; + +//@ts-check + +// Vendored from https://github.com/cmaas/markdown-it-table-of-contents +// MIT License +// Copyright (c) 2015-2020 Oktavilla, 2021+ Chris Maas + +/* + * markdown-it-table-of-contents + * + * The algorithm works as follows: + * Step 1: Gather all headline tokens from a Markdown document and put them in an array. + * Step 2: Turn the flat array into a nested tree, respecting the correct headline level. + * Step 3: Turn the nested tree into HTML code. + */ + +// --- Default helpers and options --- + +/** + * Slugify a string to be used as anchor + * @param {string} text Text to slugify + * @param {string} rawToken Raw token to extract text from + * @returns {string} Slugified anchor string + */ +function slugify(text, rawToken) { + return encodeURIComponent( + String(text).trim().toLowerCase().replace(/\s+/g, "-"), + ); +} + +/** + * Helper to extract text from tokens, same function as in markdown-it-anchor + * @param {Array} tokens Tokens + * @param {string} rawToken Raw token to extract text from + * @returns {string} + */ +function getTokensText(tokens, rawToken) { + return tokens + .filter((t) => ["text", "code_inline"].includes(t.type)) + .map((t) => t.content) + .join("") + .trim(); +} + +const defaultOptions = { + includeLevel: [1, 2], + containerClass: "table-of-contents", + slugify, + markerPattern: /^\[\[toc\]\]/im, + omitTag: "", + listType: "ul", + containerHeaderHtml: undefined, + containerFooterHtml: undefined, + transformLink: undefined, + getTokensText, +}; + +// --- Types --- + +/** + * @typedef {Object} HeadlineItem + * @property {number} level Headline level + * @property {string | null} anchor Anchor target + * @property {string} text Text of headline + * @property {any | null} token Raw token of headline + */ + +/** + * @typedef {Object} TocItem + * @property {number} level Item level + * @property {string} text Text of link + * @property {string | null} anchor Target of link + * @property {Array} children Sub-items for this list item + * @property {TocItem | null} parent Parent this item belongs to + */ + +// --- TOC builder --- + +/** + * Finds all headline items for the defined levels in a Markdown document. + * @param {Array} levels includeLevels like `[1, 2, 3]` + * @param {*} tokens Tokens gathered by the plugin + * @param {*} options Plugin options + * @returns {Array} + */ +function findHeadlineElements(levels, tokens, options) { + /** @type {HeadlineItem[]} */ + const headings = []; + + /** @type {HeadlineItem | null} */ + let currentHeading = null; + + tokens.forEach((/** @type {*} */ token, /** @type {number} */ index) => { + if (token.type === "heading_open") { + const prev = index > 0 ? tokens[index - 1] : null; + if ( + prev && + prev.type === "html_block" && + prev.content.trim().toLowerCase().replace("\n", "") === options.omitTag + ) { + return; + } + const id = findExistingIdAttr(token); + const level = parseInt(token.tag.toLowerCase().replace("h", ""), 10); + if (levels.indexOf(level) >= 0) { + currentHeading = { + level: level, + text: "", + anchor: id || null, + token: null, + }; + } + } else if (currentHeading && token.type === "inline") { + const textContent = options.getTokensText(token.children, token); + currentHeading.text = textContent; + currentHeading.token = token; + if (!currentHeading.anchor) { + currentHeading.anchor = options.slugify(textContent, token); + } + } else if (token.type === "heading_close") { + if (currentHeading) { + headings.push(currentHeading); + } + currentHeading = null; + } + }); + + return headings; +} + +/** + * Helper to find an existing id attr on a token. Should be a heading_open token, but could be anything really + * Provided by markdown-it-anchor or markdown-it-attrs + * @param {any} token Token + * @returns {string | null} Id attribute to use as anchor + */ +function findExistingIdAttr(token) { + if (token && token.attrs && token.attrs.length > 0) { + const idAttr = token.attrs.find((/** @type {string | any[]} */ attr) => { + if (Array.isArray(attr) && attr.length >= 2) { + return attr[0] === "id"; + } + return false; + }); + if (idAttr && Array.isArray(idAttr) && idAttr.length >= 2) { + const [_, val] = idAttr; + return val; + } + } + return null; +} + +/** + * Helper to get minimum headline level so that the TOC is nested correctly + * @param {Array} headlineItems Search these + * @returns {number} Minimum level + */ +function getMinLevel(headlineItems) { + return Math.min(...headlineItems.map((item) => item.level)); +} + +/** + * Helper that creates a TOCItem + * @param {number} level + * @param {string} text + * @param {string | null} anchor + * @param {TocItem} rootNode + * @returns {TocItem} + */ +function addListItem(level, text, anchor, rootNode) { + const listItem = { level, text, anchor, children: [], parent: rootNode }; + rootNode.children.push(listItem); + return listItem; +} + +/** + * Turns a list of flat headline items into a nested tree object representing the TOC + * @param {Array} headlineItems + * @returns {TocItem} Tree of TOC items + */ +function flatHeadlineItemsToNestedTree(headlineItems) { + // create a root node with no text that holds the entire TOC. this won't be rendered, but only its children + /** @type {TocItem} */ + const toc = { + level: getMinLevel(headlineItems) - 1, + anchor: null, + text: "", + children: [], + parent: null, + }; + // pointer that tracks the last root item of the current list + let currentRootNode = toc; + // pointer that tracks the last item (to turn it into a new root node if necessary) + let prevListItem = currentRootNode; + + headlineItems.forEach((headlineItem) => { + // if level is bigger, take the previous node, add a child list, set current list to this new child list + if (headlineItem.level > prevListItem.level) { + // eslint-disable-next-line no-unused-vars + Array.from({ length: headlineItem.level - prevListItem.level }).forEach( + (_) => { + currentRootNode = prevListItem; + prevListItem = addListItem( + headlineItem.level, + "", + null, + currentRootNode, + ); + }, + ); + prevListItem.text = headlineItem.text; + prevListItem.anchor = headlineItem.anchor; + } + // if level is same, add to the current list + else if (headlineItem.level === prevListItem.level) { + prevListItem = addListItem( + headlineItem.level, + headlineItem.text, + headlineItem.anchor, + currentRootNode, + ); + } + // if level is smaller, set current list to currentlist.parent + else if (headlineItem.level < prevListItem.level) { + for (let i = 0; i < prevListItem.level - headlineItem.level; i++) { + if (currentRootNode.parent) { + currentRootNode = currentRootNode.parent; + } + } + prevListItem = addListItem( + headlineItem.level, + headlineItem.text, + headlineItem.anchor, + currentRootNode, + ); + } + }); + + return toc; +} + +const markdownIt = MarkdownIt({ html: true, breaks: true, linkify: true }); + +export const createToc = (/** @type {any} */ md, /** @type {any} */ opts) => { + const options = Object.assign({}, defaultOptions, opts); + const tokens = markdownIt.parse(md, {}); + const headlineItems = findHeadlineElements( + options.includeLevel, + tokens, + options, + ); + + return flatHeadlineItemsToNestedTree(headlineItems); +}; diff --git a/src/_includes/components/local-toc.webc b/src/_includes/components/local-toc.webc new file mode 100644 index 0000000000..a6a257c1a3 --- /dev/null +++ b/src/_includes/components/local-toc.webc @@ -0,0 +1,17 @@ + + +
+
+ +
\ No newline at end of file diff --git a/src/_includes/index.css b/src/_includes/index.css index 6c918ae8bb..f849c4750b 100644 --- a/src/_includes/index.css +++ b/src/_includes/index.css @@ -306,12 +306,16 @@ footer.elv-layout { .elv-layout-toc { padding-block-start: 0; } +.elv-local-toc { + position: sticky; + top: 3.5rem; +} /* #2col */ @media (min-width: 41.4375em) { .elv-layout-toc { display: grid; gap: 2rem; - grid-template-columns: 12.5rem minmax(1px, 1fr); + grid-template-columns: 12.5rem minmax(1px, 1fr) 12.5rem; padding: 0 1rem 2rem 1rem; } } diff --git a/src/_includes/layouts/main.njk b/src/_includes/layouts/main.njk index eeabe8175b..2b5a7c0a10 100644 --- a/src/_includes/layouts/main.njk +++ b/src/_includes/layouts/main.njk @@ -24,6 +24,12 @@ headerClass: elv-header-default {%- endif %} {{ content | safe }}
+ {# Just here to create a context for elv-local-toc #} +
+
+ {% renderTemplate "webc", { tocData: tocData } %}{% endrenderTemplate %} +
+
@@ -35,4 +41,4 @@ headerClass: elv-header-default {% include "footer-nav.njk" %} -
+ \ No newline at end of file diff --git a/src/docs/docs.11tydata.js b/src/docs/docs.11tydata.js index fb2a55c57a..aeb410251f 100644 --- a/src/docs/docs.11tydata.js +++ b/src/docs/docs.11tydata.js @@ -1,8 +1,16 @@ +import { createToc } from "../../config/toc.js"; + let data = { layout: "layouts/docs.njk", headerTitle: "Eleventy Documentation", feedTitle: "Eleventy Documentation", feedUrl: "/docs/feed.xml", + eleventyComputed: { + tocData: (data) => { + const toc = createToc(data.page.rawInput, {}); + return toc; + }, + }, }; if (process.env.NODE_ENV === "production") { diff --git a/src/docs/languages/markdown.md b/src/docs/languages/markdown.md index 80ba7ba53b..ea64c9a1a2 100644 --- a/src/docs/languages/markdown.md +++ b/src/docs/languages/markdown.md @@ -22,8 +22,6 @@ tables, and more. {% endset %} {{ codeBlock | highlight("markdown") | safe }} -{% tableofcontents "open" %} - | Eleventy Short Name | File Extension | npm Package | | ------------------- | -------------- | ---------------------------------------------------------- | | `md` | `.md` | [`markdown-it`](https://www.npmjs.com/package/markdown-it) | From f49b405cff1c82eb4c93c41304ea2178d421495e Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Sun, 22 Feb 2026 12:05:32 -0500 Subject: [PATCH 2/3] Progress --- src/_includes/components/docs-toc.njk | 4 +- src/_includes/components/local-toc-item.webc | 3 + src/_includes/components/local-toc.webc | 87 +++++++++++++++++--- src/_includes/index.css | 49 ++++++++++- src/_includes/layouts/base.njk | 1 + src/_includes/layouts/main.njk | 4 +- src/docs/docs.11tydata.js | 59 ++++++++++++- 7 files changed, 187 insertions(+), 20 deletions(-) create mode 100644 src/_includes/components/local-toc-item.webc diff --git a/src/_includes/components/docs-toc.njk b/src/_includes/components/docs-toc.njk index 968ef920fc..69d6cb8a04 100644 --- a/src/_includes/components/docs-toc.njk +++ b/src/_includes/components/docs-toc.njk @@ -1,4 +1,4 @@ -
+
{%- set calloutTitle %}Blog{% endset %} {%- for post in collections.sidebarHighlight %} {%- callout "", "html", calloutTitle %} @@ -30,4 +30,4 @@ {{ "Eleventy Documentation,Ecosystem" | navFiltered | eleventyNavigationToHtml({ activeKey: navKey, listClass: 'elv-toc-list', activeListItemClass: 'elv-toc-active', useTopLevelDetails: true, anchorElementWithoutHref: "span" }) | safe }}
-
+ \ No newline at end of file diff --git a/src/_includes/components/local-toc-item.webc b/src/_includes/components/local-toc-item.webc new file mode 100644 index 0000000000..a4e52822d7 --- /dev/null +++ b/src/_includes/components/local-toc-item.webc @@ -0,0 +1,3 @@ +
  • + +
  • \ No newline at end of file diff --git a/src/_includes/components/local-toc.webc b/src/_includes/components/local-toc.webc index a6a257c1a3..b61f617d93 100644 --- a/src/_includes/components/local-toc.webc +++ b/src/_includes/components/local-toc.webc @@ -1,17 +1,78 @@ - +
      + +
    + \ No newline at end of file diff --git a/src/_includes/index.css b/src/_includes/index.css index f849c4750b..80ad09be45 100644 --- a/src/_includes/index.css +++ b/src/_includes/index.css @@ -310,7 +310,45 @@ footer.elv-layout { position: sticky; top: 3.5rem; } -/* #2col */ + +/** #1col */ +.elv-layout-toc { + display: grid; + gap: 2rem; + grid-template-columns: minmax(1px, 1fr); + padding: 0 1rem 2rem 1rem; +} + +.elv-layout-toc #toc { + order: 0; +} +.elv-layout-toc #local-toc { + order: 1; +} +.elv-layout-toc #skip-content { + order: 2; +} + +/* #3col */ +@media (min-width: 36em) { + .elv-layout-toc { + display: grid; + gap: 2rem; + grid-template-columns: 12.5rem minmax(1px, 1fr); + padding: 0 1rem 2rem 1rem; + } + .elv-layout-toc #toc { + order: 0; + } + .elv-layout-toc #local-toc { + order: 1; + } + .elv-layout-toc #skip-content { + order: 2; + } +} + +/* #3col */ @media (min-width: 41.4375em) { .elv-layout-toc { display: grid; @@ -318,6 +356,15 @@ footer.elv-layout { grid-template-columns: 12.5rem minmax(1px, 1fr) 12.5rem; padding: 0 1rem 2rem 1rem; } + .elv-layout-toc #toc { + order: 0; + } + .elv-layout-toc #skip-content { + order: 1; + } + .elv-layout-toc #local-toc { + order: 2; + } } /* Footer */ diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index 8ab4168f7a..e760734184 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -167,6 +167,7 @@ let eleventyComputed = { {%- else %} + {%- endif %} diff --git a/src/_includes/layouts/main.njk b/src/_includes/layouts/main.njk index 2b5a7c0a10..5edf55e0d3 100644 --- a/src/_includes/layouts/main.njk +++ b/src/_includes/layouts/main.njk @@ -24,8 +24,8 @@ headerClass: elv-header-default {%- endif %} {{ content | safe }} - {# Just here to create a context for elv-local-toc #} -
    + {# Just here to create a context for elv-local-toc, so it can be position: sticky #} +
    {% renderTemplate "webc", { tocData: tocData } %}{% endrenderTemplate %}
    diff --git a/src/docs/docs.11tydata.js b/src/docs/docs.11tydata.js index aeb410251f..6a335aed45 100644 --- a/src/docs/docs.11tydata.js +++ b/src/docs/docs.11tydata.js @@ -1,5 +1,24 @@ +import slugify from "@sindresorhus/slugify"; +import memoize from "memoize"; import { createToc } from "../../config/toc.js"; +function removeExtraText(s) { + let newStr = String(s); + newStr = newStr.replace(/\-(alpha|beta|canary)\.\d+/, ""); + newStr = newStr.replace( + /(New\ in|Added\ in|Pre-release\ only)\ v\d+\.\d+\.\d+/, + "", + ); + newStr = newStr.replace(/[!?!]/g, ""); + newStr = newStr.replace(/[\=\":''`,]/g, ""); + newStr = newStr.replace(/<[^>]*>/g, ""); + return newStr; +} + +const markdownItSlugify = memoize((s) => { + return slugify(removeExtraText(s)); +}); + let data = { layout: "layouts/docs.njk", headerTitle: "Eleventy Documentation", @@ -7,8 +26,44 @@ let data = { feedUrl: "/docs/feed.xml", eleventyComputed: { tocData: (data) => { - const toc = createToc(data.page.rawInput, {}); - return toc; + const cleaned = data.page.rawInput + .replace(/\{%.*?%\}/gs, "") + .replace(/\{\{.*?\}\}/gs, ""); + const toc = createToc(cleaned, { + includeLevel: [2, 3], + slugify: markdownItSlugify, + getTokensText(tokens) { + return removeExtraText( + tokens + .filter((t) => ["text", "code_inline"].includes(t.type)) + .map((t) => t.content) + .join("") + .trim(), + ); + }, + }); + + /** + * Recursive webc is not supported, workaround is to flatten. + * https://github.com/11ty/webc/issues/184 + */ + function flatten(items, result = []) { + for (const item of items) { + if (item.anchor) { + result.push({ + text: item.text, + anchor: item.anchor, + level: item.level, + }); + } + if (item.children?.length) { + flatten(item.children, result); + } + } + return result; + } + + return flatten(toc.children); }, }, }; From c9617e53656097c56d3f648813a98fb5c53411d0 Mon Sep 17 00:00:00 2001 From: Tom MacWright Date: Sun, 22 Feb 2026 12:15:20 -0500 Subject: [PATCH 3/3] Mobile tweaks --- src/_includes/components/local-toc.webc | 35 ++++++++++++++++++++----- src/_includes/index.css | 2 ++ src/docs/docs.11tydata.js | 5 ++++ 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/_includes/components/local-toc.webc b/src/_includes/components/local-toc.webc index b61f617d93..7cbebd32b7 100644 --- a/src/_includes/components/local-toc.webc +++ b/src/_includes/components/local-toc.webc @@ -1,18 +1,33 @@ -
      - -
    +
    + On this page +
      + +
    +