+
+ {# Breadcrumb — only shown for nested paths like "tech/programming" #}
+ {% set breadcrumb = category | categoryBreadcrumb %}
+ {% if breadcrumb.length > 1 %}
+
+ {% endif %}
+
{{ category }}
Posts tagged with "{{ category }}".
+ {# Direct child tags #}
+ {% set childCats = collections.categories | categoryDirectChildren(category) %}
+ {% if childCats.length > 0 %}
+
+ {% endif %}
+
{% set categoryPosts = [] %}
{% for post in collections.posts %}
- {% if post.data.category %}
- {% if post.data.category is string %}
- {% if post.data.category == category %}
- {% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
- {% endif %}
- {% else %}
- {% if category in post.data.category %}
- {% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
- {% endif %}
- {% endif %}
+ {% if post.data.category | categoryMatches(category) %}
+ {% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
{% endif %}
{% endfor %}
diff --git a/eleventy.config.js b/eleventy.config.js
index 8ee491a..44c4c15 100644
--- a/eleventy.config.js
+++ b/eleventy.config.js
@@ -23,6 +23,23 @@ const postGraph = esmRequire("@rknightuk/eleventy-plugin-post-graph");
const __dirname = dirname(fileURLToPath(import.meta.url));
const siteUrl = process.env.SITE_URL || "https://example.com";
+// Slugify each path segment, preserving "/" separators for nested tags (e.g. "tech/programming")
+const nestedSlugify = (str) => {
+ if (!str) return "";
+ return str
+ .split("/")
+ .map((s) =>
+ s
+ .trim()
+ .toLowerCase()
+ .replace(/[^\w\s-]/g, "")
+ .replace(/[\s_-]+/g, "-")
+ .replace(/^-+|-+$/g, ""),
+ )
+ .filter(Boolean)
+ .join("/");
+};
+
export default function (eleventyConfig) {
// Don't use .gitignore for determining what to process
// (content/ is in .gitignore because it's a symlink, but we need to process it)
@@ -618,6 +635,61 @@ export default function (eleventyConfig) {
.replace(/^-+|-+$/g, "");
});
+ // Nested tag filters (Obsidian-style hierarchical tags using "/" separator)
+
+ // Like slugify but preserves "/" so "tech/programming" → "tech/programming" (not "techprogramming")
+ eleventyConfig.addFilter("nestedSlugify", nestedSlugify);
+
+ // Returns true if postCategories (string or array) contains an exact or ancestor match for category.
+ // e.g. post tagged "tech/js" matches category page "tech" (ancestor) and "tech/js" (exact).
+ eleventyConfig.addFilter("categoryMatches", (postCategories, category) => {
+ if (!postCategories || !category) return false;
+ const cats = Array.isArray(postCategories) ? postCategories : [postCategories];
+ const target = String(category).replace(/^#/, "").trim();
+ return cats.some((cat) => {
+ const clean = String(cat).replace(/^#/, "").trim();
+ return clean === target || clean.startsWith(target + "/");
+ });
+ });
+
+ // Returns breadcrumb array for a nested category path.
+ // "tech/programming/js" → [{ label:"tech", path:"tech", isLast:false }, ...]
+ eleventyConfig.addFilter("categoryBreadcrumb", (category) => {
+ if (!category) return [];
+ const parts = String(category).split("/");
+ return parts.map((part, i) => ({
+ label: part,
+ path: parts.slice(0, i + 1).join("/"),
+ isLast: i === parts.length - 1,
+ }));
+ });
+
+ // Groups a flat sorted categories array by root for the index tree view.
+ // Returns [{ root, children: ["tech/js", "tech/python", ...] }, ...]
+ eleventyConfig.addFilter("categoryGroupByRoot", (categories) => {
+ if (!categories) return [];
+ const groups = new Map();
+ for (const cat of categories) {
+ const root = cat.split("/")[0];
+ if (!groups.has(root)) groups.set(root, { root, children: [] });
+ if (cat !== root) groups.get(root).children.push(cat);
+ }
+ return [...groups.values()].sort((a, b) => a.root.localeCompare(b.root));
+ });
+
+ // Returns direct children of a parent category from the full categories array.
+ // Parent "tech" + ["tech", "tech/js", "tech/python", "tech/js/react"] → ["tech/js", "tech/python"]
+ eleventyConfig.addFilter("categoryDirectChildren", (allCategories, parent) => {
+ if (!allCategories || !parent) return [];
+ const parentSlug = nestedSlugify(parent);
+ return allCategories.filter((cat) => {
+ const catSlug = nestedSlugify(cat);
+ if (!catSlug.startsWith(parentSlug + "/")) return false;
+ const remainder = catSlug.slice(parentSlug.length + 1);
+ return !remainder.includes("/");
+ });
+ });
+
eleventyConfig.addFilter("stripTrailingSlash", (url) => {
if (!url || typeof url !== "string") return url || "";
return url.endsWith("/") ? url.slice(0, -1) : url;
@@ -1061,30 +1133,35 @@ export default function (eleventyConfig) {
// Categories collection - deduplicated by slug to avoid duplicate permalinks
eleventyConfig.addCollection("categories", function (collectionApi) {
- const categoryMap = new Map(); // slug -> original name (first seen)
- const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
+ const categoryMap = new Map(); // nestedSlug -> display name (first seen)
collectionApi.getAll().filter(isPublished).forEach((item) => {
if (item.data.category) {
const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
cats.forEach((cat) => {
- if (cat && typeof cat === 'string' && cat.trim()) {
+ if (cat && typeof cat === "string" && cat.trim()) {
// Exclude garden/* tags — they're rendered as garden badges, not categories
if (cat.replace(/^#/, "").startsWith("garden/")) return;
- const slug = slugify(cat.trim());
- if (slug && !categoryMap.has(slug)) {
- categoryMap.set(slug, cat.trim());
+ const trimmed = cat.trim().replace(/^#/, "");
+ const slug = nestedSlugify(trimmed);
+ if (slug && !categoryMap.has(slug)) categoryMap.set(slug, trimmed);
+ // Auto-create ancestor pages for nested tags (e.g. "tech/js" → also register "tech")
+ const parts = trimmed.split("/");
+ for (let i = 1; i < parts.length; i++) {
+ const parentPath = parts.slice(0, i).join("/");
+ const parentSlug = nestedSlugify(parentPath);
+ if (parentSlug && !categoryMap.has(parentSlug)) categoryMap.set(parentSlug, parentPath);
}
}
});
}
});
- return [...categoryMap.values()].sort();
+ return [...categoryMap.values()].sort((a, b) => a.localeCompare(b));
});
// Category feeds — pre-grouped posts for per-category RSS/JSON feeds
eleventyConfig.addCollection("categoryFeeds", function (collectionApi) {
- const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
+ const slugify = nestedSlugify;
const grouped = new Map(); // slug -> { name, slug, posts[] }
collectionApi
diff --git a/likes.njk b/likes.njk
index f9ef35f..d5ee27a 100644
--- a/likes.njk
+++ b/likes.njk
@@ -40,10 +40,10 @@ permalink: "likes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
{% if post.data.category | withoutGardenTags %}
{% if post.data.category is string %}
- {{ post.data.category }}
+ {{ post.data.category }}
{% else %}
{% for cat in (post.data.category | withoutGardenTags) %}
- {{ cat }}
+ {{ cat }}
{% endfor %}
{% endif %}
diff --git a/notes.njk b/notes.njk
index e4da5d2..74144a0 100644
--- a/notes.njk
+++ b/notes.njk
@@ -35,10 +35,10 @@ permalink: "notes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
{% if post.data.category | withoutGardenTags %}
{% if post.data.category is string %}
- {{ post.data.category }}
+ {{ post.data.category }}
{% else %}
{% for cat in (post.data.category | withoutGardenTags) %}
- {{ cat }}
+ {{ cat }}
{% endfor %}
{% endif %}
diff --git a/photos.njk b/photos.njk
index 48fd666..f6af5ee 100644
--- a/photos.njk
+++ b/photos.njk
@@ -33,10 +33,10 @@ permalink: "photos/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumb
{% if post.data.category | withoutGardenTags %}
{% if post.data.category is string %}
- {{ post.data.category }}
+ {{ post.data.category }}
{% else %}
{% for cat in (post.data.category | withoutGardenTags) %}
- {{ cat }}
+ {{ cat }}
{% endfor %}
{% endif %}
diff --git a/replies.njk b/replies.njk
index 6553335..88e076e 100644
--- a/replies.njk
+++ b/replies.njk
@@ -45,10 +45,10 @@ permalink: "replies/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
{% if post.data.category | withoutGardenTags %}
{% if post.data.category is string %}
- {{ post.data.category }}
+ {{ post.data.category }}
{% else %}
{% for cat in (post.data.category | withoutGardenTags) %}
- {{ cat }}
+ {{ cat }}
{% endfor %}
{% endif %}
diff --git a/reposts.njk b/reposts.njk
index f9e9cb4..8b9a4fa 100644
--- a/reposts.njk
+++ b/reposts.njk
@@ -45,10 +45,10 @@ permalink: "reposts/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
{% if post.data.category | withoutGardenTags %}
{% if post.data.category is string %}
- {{ post.data.category }}
+ {{ post.data.category }}
{% else %}
{% for cat in (post.data.category | withoutGardenTags) %}
- {{ cat }}
+ {{ cat }}
{% endfor %}
{% endif %}
diff --git a/til.njk b/til.njk
index a34e8f4..541e915 100644
--- a/til.njk
+++ b/til.njk
@@ -55,7 +55,7 @@ eleventyImport:
{% endif %}
{% for cat in cats %}
{% if cat != "til" %}
-
#{{ cat }}{% if not loop.last %} {% endif %}
+
#{{ cat }}{% if not loop.last %} {% endif %}
{% endif %}
{% endfor %}
{% endif %}