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:
@@ -64,10 +64,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -104,10 +104,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -149,10 +149,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -189,10 +189,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -230,10 +230,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -275,10 +275,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -306,10 +306,10 @@
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories ml-2">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -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 %}
|
||||
<is-land on:visible>
|
||||
<div class="widget">
|
||||
<h3 class="widget-title">Categories</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% for category in categories %}
|
||||
<a href="/categories/{{ category | slugify }}/" class="p-category hover:bg-surface-200 dark:hover:bg-surface-700 transition-colors">
|
||||
{{ category }}
|
||||
{% set grouped = categories | categoryGroupByRoot %}
|
||||
{% for group in grouped %}
|
||||
<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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<h3 class="widget-title">Categories</h3>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% else %}
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -103,8 +103,8 @@
|
||||
<link rel="alternate" type="text/markdown" href="{{ page.url | stripTrailingSlash }}.md" title="Markdown version">
|
||||
{% endif %}
|
||||
{% 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/json" href="/categories/{{ category | slugify }}/feed.json" title="{{ category }} — JSON 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 | nestedSlugify }}/feed.json" title="{{ category }} — JSON Feed">
|
||||
{% endif %}
|
||||
<link rel="authorization_endpoint" href="{{ site.url }}/auth">
|
||||
<link rel="token_endpoint" href="{{ site.url }}/auth/token">
|
||||
|
||||
@@ -88,12 +88,12 @@ withSidebar: true
|
||||
<footer class="mt-8 pt-6 border-t border-surface-200 dark:border-surface-700">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% else %}
|
||||
{% 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 }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -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">
|
||||
{# Handle both string and array categories #}
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
@@ -40,10 +40,10 @@ permalink: "articles/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNu
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
28
blog.njk
28
blog.njk
@@ -72,10 +72,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -112,10 +112,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -157,10 +157,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -197,10 +197,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -236,10 +236,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -281,10 +281,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
@@ -310,10 +310,10 @@ permalink: "blog/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumber
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories ml-2">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -42,10 +42,10 @@ permalink: "bookmarks/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageN
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -16,12 +16,34 @@ eleventyImport:
|
||||
</p>
|
||||
|
||||
{% if collections.categories.length > 0 %}
|
||||
<ul class="flex flex-wrap gap-3">
|
||||
{% for cat in collections.categories %}
|
||||
{% set grouped = collections.categories | categoryGroupByRoot %}
|
||||
<ul class="space-y-4">
|
||||
{% for group in grouped %}
|
||||
<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">
|
||||
{{ cat }}
|
||||
{# Root tag #}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<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>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
|
||||
@@ -6,29 +6,55 @@ pagination:
|
||||
data: collections.categories
|
||||
size: 1
|
||||
alias: category
|
||||
permalink: "categories/{{ category | slugify }}/"
|
||||
permalink: "categories/{{ category | nestedSlugify }}/"
|
||||
eleventyComputed:
|
||||
title: "{{ category }}"
|
||||
---
|
||||
<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>
|
||||
<p class="text-surface-600 dark:text-surface-400 mb-6 sm:mb-8">
|
||||
Posts tagged with "{{ category }}".
|
||||
</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 = [] %}
|
||||
{% for post in collections.posts %}
|
||||
{% if post.data.category %}
|
||||
{% if post.data.category is string %}
|
||||
{% if post.data.category == category %}
|
||||
{% if post.data.category | categoryMatches(category) %}
|
||||
{% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if category in post.data.category %}
|
||||
{% set categoryPosts = (categoryPosts.push(post), categoryPosts) %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if categoryPosts.length > 0 %}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -40,10 +40,10 @@ permalink: "likes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -35,10 +35,10 @@ permalink: "notes/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumbe
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories ml-2">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -33,10 +33,10 @@ permalink: "photos/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNumb
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -45,10 +45,10 @@ permalink: "replies/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
@@ -45,10 +45,10 @@ permalink: "reposts/{% if pagination.pageNumber > 0 %}page/{{ pagination.pageNum
|
||||
{% if post.data.category | withoutGardenTags %}
|
||||
<span class="post-categories">
|
||||
{% 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 %}
|
||||
{% 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 %}
|
||||
{% endif %}
|
||||
</span>
|
||||
|
||||
2
til.njk
2
til.njk
@@ -55,7 +55,7 @@ eleventyImport:
|
||||
{% endif %}
|
||||
{% for cat in cats %}
|
||||
{% 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 %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
Reference in New Issue
Block a user