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
This commit is contained in:
Ricardo
2026-03-05 13:46:40 +01:00
parent 0c6229088c
commit 91c0816303
2 changed files with 48 additions and 1 deletions

View File

@@ -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;

View File

@@ -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)