From 91c0816303198719f8876c72cb14f002332fe93e Mon Sep 17 00:00:00 2001 From: Ricardo Date: Thu, 5 Mar 2026 13:46:40 +0100 Subject: [PATCH] feat: convert #hashtags in post content to category links Adds a markdown-it inline rule that transforms #tag text into links to /categories/tag/ on-site. Syndication targets (Bluesky, Mastodon, Bridgy) continue to receive raw #tag text, which their native facet/hashtag detection handles automatically. Edge cases handled: headings, hex colors, URL fragments, code blocks, pure numbers are all excluded from conversion. Confab-Link: http://localhost:8080/sessions/0ec83454-d346-4329-8aaf-6b12139bf596 --- css/tailwind.css | 10 +++++++++- eleventy.config.js | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/css/tailwind.css b/css/tailwind.css index d185f86..c21f98b 100644 --- a/css/tailwind.css +++ b/css/tailwind.css @@ -330,11 +330,19 @@ @apply text-sm text-surface-600 dark:text-surface-400 flex flex-wrap gap-2 items-center; } - /* Category tags */ + /* Category tags (post metadata pills) */ .p-category { @apply inline-block px-2 py-0.5 text-xs bg-surface-100 dark:bg-surface-800 text-surface-600 dark:text-surface-300 rounded border border-surface-200 dark:border-surface-700 hover:border-surface-400 dark:hover:border-surface-500 transition-colors; } + /* Inline hashtags in post content — styled as subtle links, not pills */ + .e-content a.hashtag, + .prose a.hashtag { + @apply text-accent-600 dark:text-accent-400 no-underline hover:underline font-medium; + /* Override prose default link styling (no border-bottom, no color shift) */ + text-decoration: none; + } + /* Webmention facepile - overlapping avatar display */ .facepile { @apply flex flex-wrap items-center; diff --git a/eleventy.config.js b/eleventy.config.js index 4a30de0..7efb978 100644 --- a/eleventy.config.js +++ b/eleventy.config.js @@ -77,6 +77,45 @@ export default function (eleventyConfig) { slugify: (s) => s.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""), level: [2, 3, 4], }); + + // Hashtag plugin: converts #tag to category links on-site + // Syndication targets (Bluesky, Mastodon) handle raw #tag natively via facet detection + md.inline.ruler.push("hashtag", (state, silent) => { + const pos = state.pos; + if (state.src.charCodeAt(pos) !== 0x23 /* # */) return false; + + // Must be at start of string or preceded by whitespace/punctuation (not part of a URL fragment or hex color) + if (pos > 0) { + const prevChar = state.src.charAt(pos - 1); + if (!/[\s()\[\]{},;:!?"'«»""'']/.test(prevChar)) return false; + } + + // Match hashtag: # followed by letter/underscore, then word chars (letters, digits, underscores) + const tail = state.src.slice(pos + 1); + const match = tail.match(/^([a-zA-Z_]\w*)/); + if (!match) return false; + + const tag = match[1]; + + // Skip pure hex color codes (3, 4, 6, or 8 hex digits with nothing else) + if (/^[0-9a-fA-F]{3,8}$/.test(tag)) return false; + + if (!silent) { + const slug = tag.toLowerCase().replace(/[^\w\s-]/g, "").replace(/[\s_-]+/g, "-").replace(/^-+|-+$/g, ""); + const tokenOpen = state.push("link_open", "a", 1); + tokenOpen.attrSet("href", `/categories/${slug}/`); + tokenOpen.attrSet("class", "p-category hashtag"); + + const tokenText = state.push("text", "", 0); + tokenText.content = `#${tag}`; + + state.push("link_close", "a", -1); + } + + state.pos = pos + 1 + tag.length; + return true; + }); + eleventyConfig.setLibrary("md", md); // Syntax highlighting for fenced code blocks (```lang)