feat: nested tags (Obsidian-style) for categories system

Adds hierarchical tag support using "/" separator (e.g. "tech/programming/js").
- New filters: nestedSlugify, categoryMatches, categoryBreadcrumb,
  categoryGroupByRoot, categoryDirectChildren
- categories collection auto-generates ancestor pages for nested tags
- categories.njk: breadcrumb nav, sub-tags section, ancestor-aware post matching
- categories-index.njk: grouped tree view (root + indented children)
- categories widget: shows root tags only with child count badge
- All category links updated from slugify → nestedSlugify (backward-compatible)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
svemagie
2026-03-15 10:56:22 +01:00
parent 6572d87715
commit e8ba3b9ae6
18 changed files with 205 additions and 79 deletions

View File

@@ -64,10 +64,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -104,10 +104,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -149,10 +149,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -189,10 +189,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -230,10 +230,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -275,10 +275,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -306,10 +306,10 @@
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories ml-2"> <span class="post-categories ml-2">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -1,12 +1,13 @@
{# Categories/Tags Widget #} {# Categories/Tags Widget — shows root-level tags; nested children visible on the category page #}
{% if categories and categories.length %} {% if categories and categories.length %}
<is-land on:visible> <is-land on:visible>
<div class="widget"> <div class="widget">
<h3 class="widget-title">Categories</h3> <h3 class="widget-title">Categories</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{% for category in categories %} {% set grouped = categories | categoryGroupByRoot %}
<a href="/categories/{{ category | slugify }}/" class="p-category hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"> {% for group in grouped %}
{{ category }} <a href="/categories/{{ group.root | nestedSlugify }}/" class="p-category hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors">
{{ group.root }}{% if group.children.length > 0 %}<span class="ml-1 text-xs opacity-60">({{ group.children.length }})</span>{% endif %}
</a> </a>
{% endfor %} {% endfor %}
</div> </div>

View File

@@ -5,12 +5,12 @@
<h3 class="widget-title">Categories</h3> <h3 class="widget-title">Categories</h3>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{% if category is string %} {% if category is string %}
<a href="/categories/{{ category | slugify }}/" class="p-category text-xs px-2 py-1 bg-accent-100 dark:bg-accent-900/30 text-accent-800 dark:text-accent-300 rounded-full hover:bg-accent-200 dark:hover:bg-accent-800 transition-colors"> <a href="/categories/{{ category | nestedSlugify }}/" class="p-category text-xs px-2 py-1 bg-accent-100 dark:bg-accent-900/30 text-accent-800 dark:text-accent-300 rounded-full hover:bg-accent-200 dark:hover:bg-accent-800 transition-colors">
{{ category }} {{ category }}
</a> </a>
{% else %} {% else %}
{% for cat in category %} {% for cat in category %}
<a href="/categories/{{ cat | slugify }}/" class="p-category text-xs px-2 py-1 bg-accent-100 dark:bg-accent-900/30 text-accent-800 dark:text-accent-300 rounded-full hover:bg-accent-200 dark:hover:bg-accent-800 transition-colors"> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category text-xs px-2 py-1 bg-accent-100 dark:bg-accent-900/30 text-accent-800 dark:text-accent-300 rounded-full hover:bg-accent-200 dark:hover:bg-accent-800 transition-colors">
{{ cat }} {{ cat }}
</a> </a>
{% endfor %} {% endfor %}

View File

@@ -103,8 +103,8 @@
<link rel="alternate" type="text/markdown" href="{{ page.url | stripTrailingSlash }}.md" title="Markdown version"> <link rel="alternate" type="text/markdown" href="{{ page.url | stripTrailingSlash }}.md" title="Markdown version">
{% endif %} {% endif %}
{% if category and page.url and page.url.startsWith('/categories/') and page.url != '/categories/' %} {% if category and page.url and page.url.startsWith('/categories/') and page.url != '/categories/' %}
<link rel="alternate" type="application/rss+xml" href="/categories/{{ category | slugify }}/feed.xml" title="{{ category }} — RSS Feed"> <link rel="alternate" type="application/rss+xml" href="/categories/{{ category | nestedSlugify }}/feed.xml" title="{{ category }} — RSS Feed">
<link rel="alternate" type="application/json" href="/categories/{{ category | slugify }}/feed.json" title="{{ category }} — JSON Feed"> <link rel="alternate" type="application/json" href="/categories/{{ category | nestedSlugify }}/feed.json" title="{{ category }} — JSON Feed">
{% endif %} {% endif %}
<link rel="authorization_endpoint" href="{{ site.url }}/auth"> <link rel="authorization_endpoint" href="{{ site.url }}/auth">
<link rel="token_endpoint" href="{{ site.url }}/auth/token"> <link rel="token_endpoint" href="{{ site.url }}/auth/token">

View File

@@ -88,12 +88,12 @@ withSidebar: true
<footer class="mt-8 pt-6 border-t border-surface-200 dark:border-surface-700"> <footer class="mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
{% if category is string %} {% if category is string %}
<a href="/categories/{{ category | slugify }}/" class="p-category text-sm px-3 py-1 bg-surface-100 dark:bg-surface-800 rounded-full hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"> <a href="/categories/{{ category | nestedSlugify }}/" class="p-category text-sm px-3 py-1 bg-surface-100 dark:bg-surface-800 rounded-full hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors">
{{ category }} {{ category }}
</a> </a>
{% else %} {% else %}
{% for cat in category %} {% for cat in category %}
<a href="/categories/{{ cat | slugify }}/" class="p-category text-sm px-3 py-1 bg-surface-100 dark:bg-surface-800 rounded-full hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors"> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category text-sm px-3 py-1 bg-surface-100 dark:bg-surface-800 rounded-full hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors">
{{ cat }} {{ cat }}
</a> </a>
{% endfor %} {% endfor %}

View File

@@ -35,10 +35,10 @@ withBlogSidebar: true
<ul class="post-categories flex flex-wrap gap-2 list-none p-0 m-0" role="list" aria-label="Categories"> <ul class="post-categories flex flex-wrap gap-2 list-none p-0 m-0" role="list" aria-label="Categories">
{# Handle both string and array categories #} {# Handle both string and array categories #}
{% if category is string %} {% if category is string %}
<li><a href="/categories/{{ category | slugify }}/" class="p-category">{{ category }}</a></li> <li><a href="/categories/{{ category | nestedSlugify }}/" class="p-category">{{ category }}</a></li>
{% else %} {% else %}
{% for cat in (category | withoutGardenTags) %} {% for cat in (category | withoutGardenTags) %}
<li><a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a></li> <li><a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a></li>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</ul> </ul>

View File

@@ -40,10 +40,10 @@ permalink: "articles/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNu
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -72,10 +72,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -112,10 +112,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -157,10 +157,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -197,10 +197,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -236,10 +236,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -281,10 +281,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>
@@ -310,10 +310,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories ml-2"> <span class="post-categories ml-2">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -42,10 +42,10 @@ permalink: "bookmarks/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageN
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -16,12 +16,34 @@ eleventyImport:
</p> </p>
{% if collections.categories.length > 0 %} {% if collections.categories.length > 0 %}
<ul class="flex flex-wrap gap-3"> {% set grouped = collections.categories | categoryGroupByRoot %}
{% for cat in collections.categories %} <ul class="space-y-4">
{% for group in grouped %}
<li> <li>
<a href="/categories/{{ cat | slugify }}/" class="inline-block px-4 py-2 bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-accent-100 dark:hover:bg-accent-900 hover:text-accent-700 dark:hover:text-accent-300 transition-colors"> {# Root tag #}
{{ cat }} <div class="flex flex-wrap items-center gap-2">
</a> <a href="/categories/{{ group.root | nestedSlugify }}/"
class="inline-block px-4 py-2 bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-surface-100 rounded-lg font-medium hover:bg-accent-100 dark:hover:bg-accent-900 hover:text-accent-700 dark:hover:text-accent-300 transition-colors">
{{ group.root }}
</a>
{% if group.children.length > 0 %}
<span class="text-xs text-surface-400 dark:text-surface-500">{{ group.children.length }} sub-tag{% if group.children.length != 1 %}s{% endif %}</span>
{% endif %}
</div>
{# Child tags indented beneath root #}
{% if group.children.length > 0 %}
<ul class="mt-2 ml-4 pl-4 border-l border-surface-200 dark:border-surface-700 flex flex-wrap gap-2">
{% for child in group.children %}
<li>
<a href="/categories/{{ child | nestedSlugify }}/"
class="inline-block px-3 py-1 bg-surface-50 dark:bg-surface-800/60 text-surface-700 dark:text-surface-300 rounded-md text-sm hover:bg-accent-100 dark:hover:bg-accent-900 hover:text-accent-700 dark:hover:text-accent-300 transition-colors">
{{ child }}
</a>
</li>
{% endfor %}
</ul>
{% endif %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>

View File

@@ -6,28 +6,54 @@ pagination:
data: collections.categories data: collections.categories
size: 1 size: 1
alias: category alias: category
permalink: "categories/{{ category | slugify }}/" permalink: "categories/{{ category | nestedSlugify }}/"
eleventyComputed: eleventyComputed:
title: "{{ category }}" title: "{{ category }}"
--- ---
<div class="h-feed"> <div class="h-feed">
{# Breadcrumb — only shown for nested paths like "tech/programming" #}
{% set breadcrumb = category | categoryBreadcrumb %}
{% if breadcrumb.length > 1 %}
<nav class="text-sm text-surface-500 dark:text-surface-400 mb-4 flex flex-wrap gap-1 items-center" aria-label="Category breadcrumb">
<a href="/categories/" class="hover:text-accent-700 dark:hover:text-accent-300 transition-colors">Categories</a>
{% for crumb in breadcrumb %}
<span aria-hidden="true">/</span>
{% if crumb.isLast %}
<span class="text-surface-900 dark:text-surface-100 font-medium">{{ crumb.label }}</span>
{% else %}
<a href="/categories/{{ crumb.path | nestedSlugify }}/" class="hover:text-accent-700 dark:hover:text-accent-300 transition-colors">{{ crumb.label }}</a>
{% endif %}
{% endfor %}
</nav>
{% endif %}
<h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ category }}</h1> <h1 class="text-2xl sm:text-3xl font-bold text-surface-900 dark:text-surface-100 mb-2">{{ category }}</h1>
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8"> <p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
Posts tagged with "{{ category }}". Posts tagged with "{{ category }}".
</p> </p>
{# Direct child tags #}
{% set childCats = collections.categories | categoryDirectChildren(category) %}
{% if childCats.length > 0 %}
<div class="mb-8">
<h2 class="text-sm font-semibold text-surface-500 dark:text-surface-400 uppercase tracking-wide mb-3">Sub-tags</h2>
<ul class="flex flex-wrap gap-2">
{% for child in childCats %}
<li>
<a href="/categories/{{ child | nestedSlugify }}/" class="inline-block px-3 py-1 bg-surface-100 dark:bg-surface-800 text-surface-900 dark:text-surface-100 rounded-lg hover:bg-accent-100 dark:hover:bg-accent-900 hover:text-accent-700 dark:hover:text-accent-300 transition-colors text-sm">
{{ child }}
</a>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% set categoryPosts = [] %} {% set categoryPosts = [] %}
{% for post in collections.posts %} {% for post in collections.posts %}
{% if post.data.category %} {% if post.data.category | categoryMatches(category) %}
{% if post.data.category is string %} {% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
{% 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 %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}

View File

@@ -23,6 +23,23 @@ const postGraph = esmRequire("@rknightuk/eleventy-plugin-post-graph");
const __dirname = dirname(fileURLToPath(import.meta.url)); const __dirname = dirname(fileURLToPath(import.meta.url));
const siteUrl = process.env.SITE_URL || "https://example.com"; 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) { export default function (eleventyConfig) {
// Don't use .gitignore for determining what to process // Don't use .gitignore for determining what to process
// (content/ is in .gitignore because it's a symlink, but we need to process it) // (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, ""); .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) => { eleventyConfig.addFilter("stripTrailingSlash", (url) => {
if (!url || typeof url !== "string") return url || ""; if (!url || typeof url !== "string") return url || "";
return url.endsWith("/") ? url.slice(0, -1) : 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 // Categories collection - deduplicated by slug to avoid duplicate permalinks
eleventyConfig.addCollection("categories", function (collectionApi) { eleventyConfig.addCollection("categories", function (collectionApi) {
const categoryMap = new Map(); // slug -> original name (first seen) const categoryMap = new Map(); // nestedSlug -> display name (first seen)
const slugify = (str) => str.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, "");
collectionApi.getAll().filter(isPublished).forEach((item) => { collectionApi.getAll().filter(isPublished).forEach((item) => {
if (item.data.category) { if (item.data.category) {
const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category]; const cats = Array.isArray(item.data.category) ? item.data.category : [item.data.category];
cats.forEach((cat) => { 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 // Exclude garden/* tags — they're rendered as garden badges, not categories
if (cat.replace(/^#/, "").startsWith("garden/")) return; if (cat.replace(/^#/, "").startsWith("garden/")) return;
const slug = slugify(cat.trim()); const trimmed = cat.trim().replace(/^#/, "");
if (slug && !categoryMap.has(slug)) { const slug = nestedSlugify(trimmed);
categoryMap.set(slug, cat.trim()); 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 // Category feeds — pre-grouped posts for per-category RSS/JSON feeds
eleventyConfig.addCollection("categoryFeeds", function (collectionApi) { 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[] } const grouped = new Map(); // slug -> { name, slug, posts[] }
collectionApi collectionApi

View File

@@ -40,10 +40,10 @@ permalink: "likes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -35,10 +35,10 @@ permalink: "notes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories ml-2"> <span class="post-categories ml-2">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -33,10 +33,10 @@ permalink: "photos/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumb
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -45,10 +45,10 @@ permalink: "replies/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -45,10 +45,10 @@ permalink: "reposts/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
{% if post.data.category | withoutGardenTags %} {% if post.data.category | withoutGardenTags %}
<span class="post-categories"> <span class="post-categories">
{% if post.data.category is string %} {% if post.data.category is string %}
<a href="/categories/{{ post.data.category | slugify }}/" class="p-category">{{ post.data.category }}</a> <a href="/categories/{{ post.data.category | nestedSlugify }}/" class="p-category">{{ post.data.category }}</a>
{% else %} {% else %}
{% for cat in (post.data.category | withoutGardenTags) %} {% for cat in (post.data.category | withoutGardenTags) %}
<a href="/categories/{{ cat | slugify }}/" class="p-category">{{ cat }}</a> <a href="/categories/{{ cat | nestedSlugify }}/" class="p-category">{{ cat }}</a>
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</span> </span>

View File

@@ -55,7 +55,7 @@ eleventyImport:
{% endif %} {% endif %}
{% for cat in cats %} {% for cat in cats %}
{% if cat != "til" %} {% if cat != "til" %}
<a href="/categories/{{ cat | slugify }}/" class="text-amber-600 dark:text-amber-400 hover:underline">#{{ cat }}</a>{% if not loop.last %} {% endif %} <a href="/categories/{{ cat | nestedSlugify }}/" class="text-amber-600 dark:text-amber-400 hover:underline">#{{ cat }}</a>{% if not loop.last %} {% endif %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}