From 59dcaaff83cde147c9feb10ff3a22bead2eb05f5 Mon Sep 17 00:00:00 2001 From: Lea Verou Date: Fri, 21 Mar 2025 16:30:06 -0400 Subject: [PATCH] Content hierarchy bugfixes & improvements (#821) - Sidebar, overview listings, breadcrumbs now based on actual parent-child relationships, rather than increasingly outdated heuristics - parent properties are now generated automatically from the URL structure, and need only be specified to override that default - Ability to group by page hierarchy in overview pages, where pages that have >= 2 children become categories Smaller improvements: - More flexible syntax for specifying the params of overview pages - [Overviews] Hide group heading if only one group is present - parentItem and parentUrl properties that can be used on any page - Alias a collection as the children of a page (useful for "virtual" parents like Layout) - Do not error if a page card icon is missing --- docs/_includes/breadcrumbs.njk | 11 +- docs/_includes/grouped-pages.njk | 20 ++- docs/_includes/page-card.njk | 2 +- docs/_includes/sidebar-group.njk | 9 +- docs/_includes/sidebar-link.njk | 2 +- docs/_layouts/overview.njk | 5 +- docs/_layouts/theme.njk | 2 +- docs/_utils/filters.js | 254 ++++++++++++++++++++++--------- docs/docs/components/index.njk | 13 +- docs/docs/docs.11tydata.js | 78 +++++++++- docs/docs/layout.njk | 3 +- docs/docs/palettes/index.njk | 2 +- docs/docs/patterns/index.njk | 2 - docs/docs/themes/index.njk | 2 +- docs/docs/tokens/index.njk | 1 + 15 files changed, 297 insertions(+), 109 deletions(-) diff --git a/docs/_includes/breadcrumbs.njk b/docs/_includes/breadcrumbs.njk index 5f9fa145b..96d5563e8 100644 --- a/docs/_includes/breadcrumbs.njk +++ b/docs/_includes/breadcrumbs.njk @@ -1,8 +1,11 @@ -{% set breadcrumbs = page.url | breadcrumbs %} -{% if breadcrumbs.length > 0 %} +{% set ancestors = page.url | ancestors %} + +{% if ancestors.length > 0 %} - {% for crumb in breadcrumbs %} - {{ crumb.title }} + {% for ancestor in ancestors %} + {% if ancestor.page.url != "/" %} + {{ ancestor.data.title }} + {% endif %} {% endfor %} {# Current page #} diff --git a/docs/_includes/grouped-pages.njk b/docs/_includes/grouped-pages.njk index 84cf846f5..7c06d0b3a 100644 --- a/docs/_includes/grouped-pages.njk +++ b/docs/_includes/grouped-pages.njk @@ -1,12 +1,18 @@ {# Cards for pages listed by category #}
-{% for category, pages in allPages | groupByTags(categories) -%} -

{{ category | getCategoryTitle(categories) }}

- {%- for page in pages -%} - {%- if not page.data.parent or listChildren -%} - {% include "page-card.njk" %} - {%- endif -%} - {%- endfor -%} +{% set groupedPages = allPages | groupPages(categories, page) %} +{% for category, pages in groupedPages -%} + {% if groupedPages.meta.groupCount > 1 %} +

+ {% if pages.meta.url %}{{ pages.meta.title }} + {% else %} + {{ pages.meta.title }} + {% endif %} +

+ {% endif %} + {%- for page in pages -%} + {% include "page-card.njk" %} + {%- endfor -%} {%- endfor -%}
diff --git a/docs/_includes/page-card.njk b/docs/_includes/page-card.njk index f63d813ed..b684a3487 100644 --- a/docs/_includes/page-card.njk +++ b/docs/_includes/page-card.njk @@ -2,7 +2,7 @@
- {% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" %} + {% include "svgs/" + (page.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
{{ page.data.title }} {% if pageSubtitle -%} diff --git a/docs/_includes/sidebar-group.njk b/docs/_includes/sidebar-group.njk index c49ef48da..b723d22ab 100644 --- a/docs/_includes/sidebar-group.njk +++ b/docs/_includes/sidebar-group.njk @@ -1,9 +1,12 @@ {# Some collections (like "patterns") will not have any items in the alpha build for example. So this checks to make sure the collection exists. #} {% if collections[tag] -%} {% set groupUrl %}/docs/{{ tag }}/{% endset %} + {% set groupItem = groupUrl | getCollectionItemFromUrl %} + {% set children = groupItem.data.children if groupItem.data.children.length > 0 else (collections[tag] | sort) %} +

- {% if groupUrl | getCollectionItemFromUrl %} + {% if groupItem %} {{ title or (tag | capitalize) }} @@ -12,10 +15,8 @@ {% endif %}

diff --git a/docs/_includes/sidebar-link.njk b/docs/_includes/sidebar-link.njk index dab3e32a0..261a272ec 100644 --- a/docs/_includes/sidebar-link.njk +++ b/docs/_includes/sidebar-link.njk @@ -1,4 +1,4 @@ -{% if not (isAlpha and page.data.noAlpha) and page.fileSlug != tag and not page.data.unlisted -%} +{% if not (isAlpha and page.data.noAlpha) and not page.data.unlisted -%}
  • {{ page.data.title }} {% if page.data.status == 'experimental' %}{% endif %} diff --git a/docs/_layouts/overview.njk b/docs/_layouts/overview.njk index fbc99b73d..48645d379 100644 --- a/docs/_layouts/overview.njk +++ b/docs/_layouts/overview.njk @@ -1,6 +1,5 @@ --- layout: page-outline -tags: ["overview"] --- {% set forTag = forTag or (page.url | split('/') | last) %} {% if description %} @@ -13,8 +12,10 @@ tags: ["overview"] -{% set allPages = collections[forTag] %} +{% set allPages = allPages or collections[forTag] %} +{% if allPages and allPages.length > 0 %} {% include "grouped-pages.njk" %} +{% endif %} diff --git a/docs/_layouts/theme.njk b/docs/_layouts/theme.njk index 97c737f41..63693354c 100644 --- a/docs/_layouts/theme.njk +++ b/docs/_layouts/theme.njk @@ -68,7 +68,7 @@ wa_data.palettes = {
    - {% include "svgs/" + (palette.data.icon or "thumbnail-placeholder") + ".njk" %} + {% include "svgs/" + (palette.data.icon or "thumbnail-placeholder") + ".njk" ignore missing %}
    {{ palette.data.title }} diff --git a/docs/_utils/filters.js b/docs/_utils/filters.js index 912b5dbb1..f81e5b909 100644 --- a/docs/_utils/filters.js +++ b/docs/_utils/filters.js @@ -29,6 +29,9 @@ function getCollection(name) { } export function getCollectionItemFromUrl(url, collection) { + if (!url) { + return null; + } collection ??= getCollection.call(this, 'all') || []; return collection.find(item => item.url === url); } @@ -42,35 +45,33 @@ export function split(text, separator) { return (text + '').split(separator).filter(Boolean); } -export function breadcrumbs(url, { withCurrent = false } = {}) { - const parts = split(url, '/'); - const ret = []; +export function ancestors(url, { withCurrent = false, withRoot = false } = {}) { + let ret = []; + let currentUrl = url; + let currentItem = getCollectionItemFromUrl.call(this, url); - while (parts.length) { - let partialUrl = '/' + parts.join('/') + '/'; - let item = getCollectionItemFromUrl.call(this, partialUrl); - - if (item && (partialUrl !== url || withCurrent)) { - let title = item.data.title; - if (title) { - ret.unshift({ url: partialUrl, title }); - } - } - - parts.pop(); - - if (item?.data.parent) { - let parentURL = item.data.parent; - if (!item.data.parent.startsWith('/')) { - // Parent is in the same directory - parts.push(item.data.parent); - parentURL = '/' + parts.join('/') + '/'; - } - - let parentBreadcrumbs = breadcrumbs.call(this, parentURL, { withCurrent: true }); - return [...parentBreadcrumbs, ...ret]; + if (!currentItem) { + // Might have eleventyExcludeFromCollections, jump to parent + let parentUrl = this.ctx.parentUrl; + if (parentUrl) { + url = parentUrl; } } + + for (let item; (item = getCollectionItemFromUrl.call(this, url)); url = item.data.parentUrl) { + ret.unshift(item); + } + + if (!withRoot && ret[0]?.page.url === '/') { + // Remove root + ret.shift(); + } + + if (!withCurrent && ret.at(-1)?.page.url === currentUrl) { + // Remove current page + ret.pop(); + } + return ret; } @@ -180,69 +181,178 @@ export function sort(arr, by = { 'data.order': 1, 'data.title': '' }) { /** * Group an 11ty collection (or any array of objects with a `data.tags` property) by certain tags. * @param {object[]} collection - * @param { Object | (string | Object)[]} [tags] The tags to group by. If not provided/empty, defaults to grouping by all tags. - * @returns { Object. } An object with keys for each tag, and an array of items for each tag. + * @param { Object | string[]} [options] Options object or array of tags to group by. + * @param {string[] | true} [options.tags] Tags to group by. If true, groups by all tags. + * If not provided/empty, defaults to grouping by page hierarchy, with any pages with more than 1 children becoming groups. + * @param {string[]} [options.groups] The groups to use if only a subset or a specific order is desired. Defaults to `options.tags`. + * @param {string[]} [options.titles] Any title overrides for groups. + * @param {string | false} [options.other="Other"] The title to use for the "Other" group. If `false`, the "Other" group is removed.. + * @returns { Object. } An object of group ids to arrays of page objects. */ -export function groupByTags(collection, tags) { +export function groupPages(collection, options = {}, page) { if (!collection) { - console.error(`Empty collection passed to groupByTags() to group by ${JSON.stringify(tags)}`); - } - if (!tags) { - // Default to grouping by union of all tags - tags = Array.from(new Set(collection.flatMap(item => item.data.tags))); - } else if (Array.isArray(tags)) { - // May contain objects of one-off tag -> label mappings - tags = tags.map(tag => (typeof tag === 'object' ? Object.keys(tag)[0] : tag)); - } else if (typeof tags === 'object') { - // tags is an object of tags to labels, so we just want the keys - tags = Object.keys(tags); + console.error(`Empty collection passed to groupPages() to group by ${JSON.stringify(options)}`); } - let ret = Object.fromEntries(tags.map(tag => [tag, []])); - ret.other = []; + if (Array.isArray(options)) { + options = { tags: options }; + } + + let { tags, groups, titles = {}, other = 'Other' } = options; + + if (groups === undefined && Array.isArray(tags)) { + groups = tags; + } + + let grouping; + + if (tags) { + grouping = { + isGroup: item => undefined, + getCandidateGroups: item => item.data.tags, + getGroupMeta: group => ({}), + }; + } else { + grouping = { + isGroup: item => (item.data.children.length >= 2 ? item.page.url : undefined), + getCandidateGroups: item => { + let parentUrl = item.data.parentUrl; + if (page?.url === parentUrl) { + return []; + } + return [parentUrl]; + }, + getGroupMeta: group => { + let item = byUrl[group] || getCollectionItemFromUrl.call(this, group); + return { + title: item?.data.title, + url: group, + item, + }; + }, + sortGroups: groups => sort(groups.map(url => byUrl[url]).filter(Boolean)).map(item => item.page.url), + }; + } + + let byUrl = {}; + let byParentUrl = {}; for (let item of collection) { - let categorized = false; + let url = item.page.url; + let parentUrl = item.data.parentUrl; - for (let tag of tags) { - if (item.data.tags.includes(tag)) { - ret[tag].push(item); - categorized = true; - } - } + byUrl[url] = item; - if (!categorized) { - ret.other.push(item); + if (parentUrl) { + byParentUrl[parentUrl] ??= []; + byParentUrl[parentUrl].push(item); } } - // Remove empty categories - for (let category in ret) { - if (ret[category].length === 0) { - delete ret[category]; + let urlToGroups = {}; + + for (let item of collection) { + let url = item.page.url; + let parentUrl = item.data.parentUrl; + + if (grouping.isGroup(item)) { + continue; + } + + let parentItem = byUrl[parentUrl]; + if (parentItem && !grouping.isGroup(parentItem)) { + // Their parent is also here and is not a group + continue; + } + + let candidateGroups = grouping.getCandidateGroups(item); + + if (groups) { + candidateGroups = candidateGroups.filter(group => groups.includes(group)); + } + + urlToGroups[url] ??= []; + + for (let group of candidateGroups) { + urlToGroups[url].push(group); + } + } + + let ret = {}; + + for (let url in urlToGroups) { + let groups = urlToGroups[url]; + let item = byUrl[url]; + + if (groups.length === 0) { + // Not filtered out but also not categorized + groups = ['other']; + } + + for (let group of groups) { + ret[group] ??= []; + ret[group].push(item); + + if (!ret[group].meta) { + if (group === 'other') { + ret[group].meta = { title: other }; + } else { + ret[group].meta = grouping.getGroupMeta(group); + ret[group].meta.title = titles[group] ?? ret[group].meta.title ?? capitalize(group); + } + } + } + } + + if (other === false) { + delete ret.other; + } + + // Sort + let sortedGroups = groups ?? grouping.sortGroups?.(Object.keys(ret)); + + if (sortedGroups) { + ret = sortObject(ret, sortedGroups); + } + + Object.defineProperty(ret, 'meta', { + value: { + groupCount: Object.keys(ret).length, + }, + enumerable: false, + }); + + return ret; +} + +/** + * Sort an object by its keys + * @param {*} obj + * @param {function | string[]} order + */ +function sortObject(obj, order) { + let ret = {}; + let sortedKeys = Array.isArray(order) ? order : Object.keys(obj).sort(order); + + for (let key of sortedKeys) { + if (key in obj) { + ret[key] = obj[key]; + } + } + + // Add any keys that weren't in the order + for (let key in obj) { + if (!(key in ret)) { + ret[key] = obj[key]; } } return ret; } -export function getCategoryTitle(category, categories) { - let title; - if (Array.isArray(categories)) { - // Find relevant entry - // [{id: "Title"}, id2, ...] - title = categories.find(entry => typeof entry === 'object' && entry?.[category])?.[category]; - } else if (typeof categories === 'object') { - // {id: "Title", id2: "Title 2", ...} - title = categories[category]; - } - - if (title) { - return title; - } - - // Capitalized - return category.charAt(0).toUpperCase() + category.slice(1); +function capitalize(str) { + str += ''; + return str.charAt(0).toUpperCase() + str.slice(1); } const IDENTITY = x => x; diff --git a/docs/docs/components/index.njk b/docs/docs/components/index.njk index f5d19614d..64ddf6e40 100644 --- a/docs/docs/components/index.njk +++ b/docs/docs/components/index.njk @@ -2,13 +2,10 @@ title: Components description: Components are the essential building blocks to create intuitive, cohesive experiences. Browse the library of customizable, framework-friendly web components included in Web Awesome. layout: overview -categories: - - actions - - feedback: 'Feedback & Status' - - imagery - - inputs - - navigation - - organization - - helpers: 'Utilities' override:tags: [] +categories: + tags: [actions, feedback, imagery, inputs, navigation, organization, helpers] + titles: + feedback: 'Feedback & Status' + helpers: 'Utilities' --- diff --git a/docs/docs/docs.11tydata.js b/docs/docs/docs.11tydata.js index 7ed1011e1..2232f7e53 100644 --- a/docs/docs/docs.11tydata.js +++ b/docs/docs/docs.11tydata.js @@ -1,10 +1,80 @@ +/** + * Global data for all pages + */ +import { sort } from '../_utils/filters.js'; + export default { eleventyComputed: { - children(data) { - let mainTag = data.tags?.[0]; - let collection = data.collections[mainTag] ?? []; + // Default parent. Can be overridden by explicitly setting parent in the data. + // parent can refer to either an ancestor page in the URL or another page in the same directory + parent(data) { + let { parent, page } = data; - return collection.filter(item => item.data.parent === data.page.fileSlug); + if (parent) { + return parent; + } + + return page.url.split('/').filter(Boolean).at(-2); + }, + + parentUrl(data) { + let { parent, page } = data; + return getParentUrl(page.url, parent); + }, + + parentItem(data) { + let { parentUrl } = data; + return data.collections.all.find(item => item.url === parentUrl); + }, + + children(data) { + let { collections, page, parentOf } = data; + + if (parentOf) { + return collections[parentOf]; + } + + let collection = collections.all ?? []; + let url = page.url; + + let ret = collection.filter(item => { + return item.data.parentUrl === url; + }); + + sort(ret); + + return ret; }, }, }; + +function getParentUrl(url, parent) { + let parts = url.split('/').filter(Boolean); + let ancestorIndex = parts.findLastIndex(part => part === parent); + let retParts = parts.slice(); + + if (ancestorIndex > -1) { + // parent is an ancestor + retParts.splice(ancestorIndex + 1); + } else { + // parent is a sibling in the same directory + retParts.splice(-1, 1, parent); + } + + let ret = retParts.join('/'); + + if (url.startsWith('/')) { + ret = '/' + ret; + } + + if (!retParts.at(-1).includes('.') && !ret.endsWith('/')) { + // If no extension, make sure to end with a slash + ret += '/'; + } + + if (ret === '/docs/') { + ret = '/'; + } + + return ret; +} diff --git a/docs/docs/layout.njk b/docs/docs/layout.njk index 67d8f8778..57af8bc94 100644 --- a/docs/docs/layout.njk +++ b/docs/docs/layout.njk @@ -2,6 +2,7 @@ title: Layout description: Layout components and utility classes help you organize content that can adapt to any device or screen size. See the [installation instructions](#installation) to use Web Awesome's layout tools in your project. layout: overview +parentOf: layout categories: ["components", "utilities"] override:tags: [] --- @@ -22,4 +23,4 @@ Or, you can choose to import _only_ the utilities: ```html ``` -{% endmarkdown %} \ No newline at end of file +{% endmarkdown %} diff --git a/docs/docs/palettes/index.njk b/docs/docs/palettes/index.njk index 9f3eb51f4..36a41a6c3 100644 --- a/docs/docs/palettes/index.njk +++ b/docs/docs/palettes/index.njk @@ -5,6 +5,6 @@ layout: overview override:tags: [] forTag: palette categories: + tags: [other, pro] other: Free - pro: Pro --- diff --git a/docs/docs/patterns/index.njk b/docs/docs/patterns/index.njk index fee915547..e51a1f6ce 100644 --- a/docs/docs/patterns/index.njk +++ b/docs/docs/patterns/index.njk @@ -2,7 +2,5 @@ title: Patterns description: Patterns are reusable solutions to common design problems. layout: overview -categories: ["e-commerce"] -listChildren: true override:tags: [] --- diff --git a/docs/docs/themes/index.njk b/docs/docs/themes/index.njk index fa89792a5..71331c483 100644 --- a/docs/docs/themes/index.njk +++ b/docs/docs/themes/index.njk @@ -6,8 +6,8 @@ layout: overview override:tags: [] forTag: theme categories: + tags: [other, pro] other: Free - pro: Pro ---
    diff --git a/docs/docs/tokens/index.njk b/docs/docs/tokens/index.njk index 74337da3f..578b6524f 100644 --- a/docs/docs/tokens/index.njk +++ b/docs/docs/tokens/index.njk @@ -3,4 +3,5 @@ title: Design Tokens description: A theme is a collection of predefined CSS custom properties that control global styles from color to shadows. These custom properties thread through all Web Awesome components for a consistent look and feel. layout: overview override:tags: [] +categories: {tags: true} ---