UNPKG

@11ty/eleventy-navigation

Version:

A plugin for creating hierarchical navigation in Eleventy projects. Supports breadcrumbs too!

277 lines (242 loc) 8.46 kB
const DepGraph = require("dependency-graph").DepGraph; function findNavigationEntries(nodes = [], key = "") { let keys = key.split(",").filter(k => Boolean(k)); let pages = {}; for(let entry of nodes) { let data = entry?.data || {}; if(data?.eleventyNavigation) { let {eleventyNavigation} = data || {}; let pageKey; if(!key && !eleventyNavigation.parent) { // top level (no parents) pageKey = "__default"; } else if(keys.includes(eleventyNavigation.parent)) { pageKey = eleventyNavigation.parent; } if(pageKey) { if(!pages[pageKey]) { pages[pageKey] = []; } let url = eleventyNavigation.url ?? data?.page?.url; pages[pageKey].push(Object.assign({ data }, eleventyNavigation, { ...(url ? { url } : {}), pluginType: "eleventy-navigation", ...(keys.length > 0 ? { parentKey: eleventyNavigation.parent } : {}), })); } } } return Object.values(pages).flat().sort(function(a, b) { if(a.pinned && b.pinned) { return (a.order || 0) - (b.order || 0); } let order = [a.order, b.order]; if(a.pinned) { order[0] = -Infinity; } if(b.pinned) { order[1] = -Infinity; } if(order[0] === undefined && order[1] === undefined) { return 0; } if(order[1] === undefined) { return -1; } if(order[0] === undefined) { return 1; } return order[0] - order[1]; }).map(function(entry) { if(!entry.title) { entry.title = entry.key; } if(entry.key) { entry.children = findNavigationEntries(nodes, entry.key); } return entry; }); } function findDependencies(pages, depGraph, parentKey) { for( let page of pages ) { depGraph.addNode(page.key, page); if(parentKey) { depGraph.addDependency(page.key, parentKey); } if(page.children) { findDependencies(page.children, depGraph, page.key); } } } function getDependencyGraph(nodes) { let pages = findNavigationEntries(nodes); let graph = new DepGraph(); findDependencies(pages, graph); return graph; } function isOptionMatch(options, name) { // Liquid.js issue #35 if(Array.isArray(options)) { return options[options.indexOf(name)] } return options[name]; } function findBreadcrumbEntries(nodes, activeKey, options = {}) { let graph = getDependencyGraph(nodes); if (isOptionMatch(options, "allowMissing") && !graph.hasNode(activeKey)) { // Fail gracefully if the key isn't in the graph return []; } let deps = graph.dependenciesOf(activeKey); if(isOptionMatch(options, "includeSelf")) { deps.push(activeKey); } return activeKey ? deps.map(key => { let data = Object.assign({}, graph.getNodeData(key)); delete data.children; data._isBreadcrumb = true; return data; }) : []; } function getUrlFilter(eleventyConfig) { // eleventyConfig.pathPrefix was first available in Eleventy 2.0.0-canary.15 // And in Eleventy 2.0.0-canary.15 we recommend the a built-in transform for pathPrefix if(eleventyConfig.pathPrefix !== undefined) { return function(url) { return url; }; } if("getFilter" in eleventyConfig) { // v0.10.0 and above return eleventyConfig.getFilter("url"); } else if("nunjucksFilters" in eleventyConfig) { // backwards compat, hardcoded key return eleventyConfig.nunjucksFilters.url; } else { // Theoretically we could just move on here with a `url => url` but then `pathPrefix` // would not work and it wouldn’t be obvious why—so let’s fail loudly to avoid that. throw new Error("Could not find a `url` filter for the eleventy-navigation plugin in eleventyNavigationToHtml filter."); } } function buildHtmlAttr(name, values) { // values could be array or string if (!values || !values.length) { return ''; } const valueStr = Array.isArray(values) ? values.join(" ") : values; return ` ${name}="${valueStr}"`; } function buildAllHtmlAttrs(attrs) { return attrs.reduce((acc, { name, values }) => acc + buildHtmlAttr(name, values), ''); } function navigationToHtml(pages, options = {}) { options = Object.assign({ listElement: "ul", listItemElement: "li", listClass: "", listItemClass: "", listItemHasChildrenClass: "", activeKey: "", activeListItemClass: "", anchorClass: "", activeAnchorClass: "", useAriaCurrentAttr: false, showExcerpt: false, isChildList: false, useTopLevelDetails: false, anchorElementWithoutHref: "a", // default, better to use span }, options); let isChildList = !!options.isChildList; options.isChildList = true; let urlFilter; if(pages.length && pages[0].pluginType !== "eleventy-navigation") { throw new Error("Incorrect argument passed to eleventyNavigationToHtml filter. You must call `eleventyNavigation` or `eleventyNavigationBreadcrumb` first, like: `collection.all | eleventyNavigation | eleventyNavigationToHtml | safe`"); } return pages.length ? `<${options.listElement}${!isChildList && options.listClass ? ` class="${options.listClass}"` : ''}>${pages.map(entry => { let liClass = []; let aClass = []; let aAttrs = []; if(options.listItemClass) { liClass.push(options.listItemClass); } if(options.anchorClass) { aClass.push(options.anchorClass); } if(entry.url) { if(!urlFilter) { // don’t get if not used urlFilter = getUrlFilter(this); } aAttrs.push({name: "href", values: urlFilter(entry.url)}) } if(options.activeKey === entry.key) { if(options.activeListItemClass) { liClass.push(options.activeListItemClass); } if(options.activeAnchorClass) { aClass.push(options.activeAnchorClass); } if(options.useAriaCurrentAttr) { aAttrs.push({ name: "aria-current", values: "page" }); } } if(options.listItemHasChildrenClass && entry.children && entry.children.length) { liClass.push(options.listItemHasChildrenClass); } if(aClass.length) { aAttrs.push({ name: "class", values: aClass }); } let postfix = ""; // Helper to show pin/order in text: // let hasOrder = entry.order || entry.order === 0; // if(process.env.ELEVENTY_RUN_MODE === "serve" && (hasOrder || entry.pinned)) { // postfix = ` (${entry.pinned ? "📌" : ""}${entry.order ?? ""})`; // } let aAttrsStr = buildAllHtmlAttrs(aAttrs); let hasLink = aAttrs.find(entry => entry.name === "href"); let itemTitle = entry.title + postfix; let titleHtmlStart = `<a${aAttrsStr}>${itemTitle}</a>`; // purely defensive use of `useTopLevelDetails` here if(options.anchorElementWithoutHref && !hasLink) { titleHtmlStart = `<${options.anchorElementWithoutHref}>${itemTitle}</${options.anchorElementWithoutHref}>`; } let titleHtmlEnd = ""; if(options.useTopLevelDetails && !isChildList && entry.children) { if(hasLink) { // `<a>` must be sibling: no other interactive elements in <summary> titleHtmlStart = `${titleHtmlStart}<details><summary>${itemTitle}</summary>`; } else { titleHtmlStart = `<details><summary>${itemTitle}</summary>`; } titleHtmlEnd = "</details>"; } let childContentStr = entry.children ? navigationToHtml.call(this, entry.children, options) : ""; return `<${options.listItemElement}${buildHtmlAttr("class", liClass)}>${titleHtmlStart}${options.showExcerpt && entry.excerpt ? `: ${entry.excerpt}` : ""}${childContentStr}${titleHtmlEnd}</${options.listItemElement}>`; }).join("\n")}</${options.listElement}>` : ""; } function navigationToMarkdown(pages, options = {}) { options = Object.assign({ showExcerpt: false, childDepth: 0 }, options); let childDepth = 1 + options.childDepth; options.childDepth++; let urlFilter; if(pages.length && pages[0].pluginType !== "eleventy-navigation") { throw new Error("Incorrect argument passed to eleventyNavigationToMarkdown filter. You must call `eleventyNavigation` or `eleventyNavigationBreadcrumb` first, like: `collection.all | eleventyNavigation | eleventyNavigationToMarkdown | safe`"); } let indent = (new Array(childDepth)).join(" ") || ""; return pages.length ? `${pages.map(entry => { if(entry.url && !urlFilter) { // don’t get if not used urlFilter = getUrlFilter(this); } return `${indent}* ${entry.url ? `[` : ""}${entry.title}${entry.url ? `](${urlFilter(entry.url)})` : ""}${options.showExcerpt && entry.excerpt ? `: ${entry.excerpt}` : ""}\n${entry.children ? navigationToMarkdown.call(this, entry.children, options) : ""}`; }).join("")}` : ""; } module.exports = { getDependencyGraph, findNavigationEntries, findBreadcrumbEntries, toHtml: navigationToHtml, toMarkdown: navigationToMarkdown };