feat: tags on posts

This commit is contained in:
2026-02-22 11:22:18 +01:00
parent 78a0f0f62f
commit 2f2d502ca9
12 changed files with 189 additions and 2 deletions

View File

@@ -85,6 +85,9 @@ export interface SinglePostTemplateContext {
pico_stylesheet_href?: string; pico_stylesheet_href?: string;
html_theme_attribute?: string; html_theme_attribute?: string;
post: TemplatePostEntry; post: TemplatePostEntry;
post_categories: string[];
post_tags: string[];
tag_color_by_name: Record<string, string>;
canonical_post_path_by_slug: Record<string, string>; canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>; canonical_media_path_by_source_path: Record<string, string>;
} }
@@ -1066,12 +1069,20 @@ export class PageRenderer {
async renderSinglePost( async renderSinglePost(
post: PostData, post: PostData,
rewriteContext: HtmlRewriteContext, rewriteContext: HtmlRewriteContext,
pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string; html_theme_attribute?: string }, pageContext: { page_title: string; language: string; menu_items?: TemplateMenuItem[]; pico_stylesheet_href?: string; html_theme_attribute?: string; tag_color_by_name?: Record<string, string> },
postEngine?: PostEngineContract, postEngine?: PostEngineContract,
): Promise<string> { ): Promise<string> {
const renderablePost = postEngine const renderablePost = postEngine
? await this.resolveRenderablePost(post, postEngine) ? await this.resolveRenderablePost(post, postEngine)
: post; : post;
const postCategories = Array.isArray(renderablePost.categories)
? Array.from(new Set(renderablePost.categories.map((category) => category.trim()).filter((category) => category.length > 0)))
: [];
const postTags = Array.isArray(renderablePost.tags)
? Array.from(new Set(renderablePost.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)))
: [];
const context: SinglePostTemplateContext = { const context: SinglePostTemplateContext = {
...pageContext, ...pageContext,
menu_items: pageContext.menu_items ?? [], menu_items: pageContext.menu_items ?? [],
@@ -1082,6 +1093,9 @@ export class PageRenderer {
content: renderablePost.content, content: renderablePost.content,
show_title: false, show_title: false,
}, },
post_categories: postCategories,
post_tags: postTags,
tag_color_by_name: pageContext.tag_color_by_name ?? {},
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug), canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug),
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath), canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
}; };

View File

@@ -66,6 +66,11 @@ interface PreviewServerDependencies {
getActiveProjectContext: () => Promise<ActiveProjectContext>; getActiveProjectContext: () => Promise<ActiveProjectContext>;
} }
interface SerializedTag {
name?: unknown;
color?: unknown;
}
export class PreviewServer { export class PreviewServer {
private readonly postEngine: PostEngineContract; private readonly postEngine: PostEngineContract;
private readonly mediaEngine: MediaEngineContract; private readonly mediaEngine: MediaEngineContract;
@@ -74,6 +79,7 @@ export class PreviewServer {
private readonly menuEngine: MenuEngineContract; private readonly menuEngine: MenuEngineContract;
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>; private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
private readonly pageRenderer: PageRenderer; private readonly pageRenderer: PageRenderer;
private readonly tagColorByNameCache = new Map<string, Promise<Record<string, string>>>();
private server: Server | null = null; private server: Server | null = null;
private port: number | null = null; private port: number | null = null;
@@ -184,6 +190,7 @@ export class PreviewServer {
resolveCategorySettings: (metadata) => this.resolveCategorySettings(metadata), resolveCategorySettings: (metadata) => this.resolveCategorySettings(metadata),
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings), resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(), buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
pageRenderer: this.pageRenderer, pageRenderer: this.pageRenderer,
postEngineForMacros: this.postEngine, postEngineForMacros: this.postEngine,
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination), loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
@@ -370,6 +377,49 @@ export class PreviewServer {
}; };
} }
private async resolveTagColorByName(projectContext: ActiveProjectContext): Promise<Record<string, string>> {
const cacheKey = `${projectContext.projectId}:${projectContext.dataDir ?? ''}`;
const cached = this.tagColorByNameCache.get(cacheKey);
if (cached) {
return cached;
}
const promise = this.loadTagColorByName(projectContext.dataDir);
this.tagColorByNameCache.set(cacheKey, promise);
return promise;
}
private async loadTagColorByName(dataDir?: string): Promise<Record<string, string>> {
if (!dataDir) {
return {};
}
const tagsPath = path.join(dataDir, 'meta', 'tags.json');
try {
const source = await readFile(tagsPath, 'utf-8');
const parsed = JSON.parse(source);
if (!Array.isArray(parsed)) {
return {};
}
const colors: Record<string, string> = {};
for (const rawEntry of parsed as SerializedTag[]) {
const name = typeof rawEntry?.name === 'string' ? rawEntry.name.trim() : '';
const color = typeof rawEntry?.color === 'string' ? rawEntry.color.trim() : '';
if (!name || !color) {
continue;
}
colors[name] = color;
}
return colors;
} catch {
return {};
}
}
private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> { private async resolveAsset(pathname: string): Promise<{ contentType: string; body: Buffer } | null> {
const match = pathname.match(/^\/assets\/([^/]+)$/); const match = pathname.match(/^\/assets\/([^/]+)$/);
if (!match) return null; if (!match) return null;

View File

@@ -57,6 +57,7 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
resolveCategorySettings: (metadata: ProjectMetadata | null) => Record<string, CategoryRenderSettings>; resolveCategorySettings: (metadata: ProjectMetadata | null) => Record<string, CategoryRenderSettings>;
resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[]; resolveListExcludedCategories: (settings: Record<string, CategoryRenderSettings>) => string[];
buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>; buildHtmlRewriteContext: () => Promise<HtmlRewriteContext>;
resolveTagColorByName: (projectContext: SharedActiveProjectContext) => Promise<Record<string, string>>;
pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>; pageRenderer: Pick<PageRenderer, 'renderPostList' | 'renderSinglePost'>;
postEngineForMacros?: PostEngineContract; postEngineForMacros?: PostEngineContract;
loadPublishedSnapshotsPage: ( loadPublishedSnapshotsPage: (
@@ -93,6 +94,7 @@ async function resolveRouteWithSharedServices(
}, },
categorySettings: Record<string, CategoryRenderSettings>, categorySettings: Record<string, CategoryRenderSettings>,
categoryMetadata: Record<string, CategoryMetadata>, categoryMetadata: Record<string, CategoryMetadata>,
tagColorByName: Record<string, string>,
listExcludedCategories: string[], listExcludedCategories: string[],
services: SharedRouteRenderServices<CategoryMetadata>, services: SharedRouteRenderServices<CategoryMetadata>,
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
@@ -179,6 +181,7 @@ async function resolveRouteWithSharedServices(
menu_items: pageContext.menuItems, menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute, html_theme_attribute: pageContext.htmlThemeAttribute,
tag_color_by_name: tagColorByName,
}, services.postEngineForMacros); }, services.postEngineForMacros);
} }
@@ -258,6 +261,7 @@ async function resolveRouteWithSharedServices(
menu_items: pageContext.menuItems, menu_items: pageContext.menuItems,
pico_stylesheet_href: pageContext.picoStylesheetHref, pico_stylesheet_href: pageContext.picoStylesheetHref,
html_theme_attribute: pageContext.htmlThemeAttribute, html_theme_attribute: pageContext.htmlThemeAttribute,
tag_color_by_name: tagColorByName,
}, services.postEngineForMacros); }, services.postEngineForMacros);
} }
@@ -297,6 +301,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme); ?? sanitizePicoTheme((metadata as { picoTheme?: unknown } | null)?.picoTheme);
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme); const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext(); const htmlRewriteContext = options.htmlRewriteContext ?? await services.buildHtmlRewriteContext();
const tagColorByName = await services.resolveTagColorByName(options.projectContext);
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/'); const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, { return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
@@ -305,5 +310,5 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
menuItems, menuItems,
picoStylesheetHref, picoStylesheetHref,
htmlThemeAttribute: options.htmlThemeAttribute, htmlThemeAttribute: options.htmlThemeAttribute,
}, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.singlePostOptions); }, categorySettings, categoryMetadata as Record<string, CategoryMetadata>, tagColorByName, listExcludedCategories, services as SharedRouteRenderServices<CategoryMetadata>, options.singlePostOptions);
} }

View File

@@ -44,6 +44,24 @@
.archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--pico-color, var(--color)); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; } .archive-day-separator { position: relative; height: 2px; width: 100%; color: var(--pico-color, var(--color)); border-top: 1px solid currentColor; opacity: .18; margin: .45rem 0 .65rem; }
.archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; } .archive-day-separator::before { content: ''; position: absolute; inset: 0; background: linear-gradient(to right, transparent 0%, transparent 18%, currentColor 58%, transparent 92%, transparent 100%); opacity: .85; }
.single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; } .single-post { margin: 0; padding: 0; background: transparent; border: 0; box-shadow: none; }
.single-post-taxonomy { display: flex; flex-wrap: wrap; gap: .4rem .45rem; margin: -.1rem 0 .2rem; }
.single-post-taxonomy-bubble {
--bubble-accent: var(--pico-primary, var(--primary));
display: inline-flex;
align-items: center;
border: 1px solid var(--bubble-accent);
border-radius: 999px;
padding: .1rem .5rem;
font-size: .74rem;
line-height: 1.35;
color: var(--bubble-accent);
background: transparent;
text-decoration: none;
}
.single-post-taxonomy-bubble:hover,
.single-post-taxonomy-bubble:focus-visible { text-decoration: underline; }
.single-post-taxonomy-bubble-category { --bubble-accent: var(--pico-primary, var(--primary)); }
.single-post-taxonomy-bubble-tag { --bubble-accent: var(--pico-secondary, var(--secondary)); }
.preview-pagination { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-top: .25rem; } .preview-pagination { display: flex; justify-content: space-between; align-items: center; gap: .75rem; margin-top: .25rem; }
.preview-pagination-link { color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .92rem; opacity: .72; transition: opacity .15s ease-in-out; } .preview-pagination-link { color: var(--pico-muted-color, var(--muted-color)); text-decoration: none; font-size: .92rem; opacity: .72; transition: opacity .15s ease-in-out; }
.preview-pagination-link:hover, .preview-pagination-link:hover,

View File

@@ -5,6 +5,17 @@
<main> <main>
<h1>{{ post.title }}</h1> <h1>{{ post.title }}</h1>
{% render 'partials/menu', menu_items: menu_items, language: language %} {% render 'partials/menu', menu_items: menu_items, language: language %}
{% if post_categories.size > 0 or post_tags.size > 0 %}
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
{% for category in post_categories %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
{% endfor %}
{% for tag in post_tags %}
{% assign tag_color = tag_color_by_name[tag] %}
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
{% endfor %}
</div>
{% endif %}
<article class="single-post" data-template="single-post"> <article class="single-post" data-template="single-post">
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div> <div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
</article> </article>

View File

@@ -53,6 +53,7 @@
"render.gallery.empty": "Keine verknüpften Bilder gefunden.", "render.gallery.empty": "Keine verknüpften Bilder gefunden.",
"render.tagCloud.empty": "Keine Tags gefunden.", "render.tagCloud.empty": "Keine Tags gefunden.",
"render.tagCloud.ariaLabel": "Tag-Wolke", "render.tagCloud.ariaLabel": "Tag-Wolke",
"render.taxonomy.ariaLabel": "Taxonomie",
"render.video.youtubeTitle": "YouTube-Video", "render.video.youtubeTitle": "YouTube-Video",
"render.video.vimeoTitle": "Vimeo-Video", "render.video.vimeoTitle": "Vimeo-Video",
"render.month.1": "Januar", "render.month.1": "Januar",

View File

@@ -53,6 +53,7 @@
"render.gallery.empty": "No linked images found.", "render.gallery.empty": "No linked images found.",
"render.tagCloud.empty": "No tags found.", "render.tagCloud.empty": "No tags found.",
"render.tagCloud.ariaLabel": "Tag cloud", "render.tagCloud.ariaLabel": "Tag cloud",
"render.taxonomy.ariaLabel": "Taxonomy",
"render.video.youtubeTitle": "YouTube video", "render.video.youtubeTitle": "YouTube video",
"render.video.vimeoTitle": "Vimeo video", "render.video.vimeoTitle": "Vimeo video",
"render.month.1": "January", "render.month.1": "January",

View File

@@ -53,6 +53,7 @@
"render.gallery.empty": "No se encontraron imágenes vinculadas.", "render.gallery.empty": "No se encontraron imágenes vinculadas.",
"render.tagCloud.empty": "No se encontraron etiquetas.", "render.tagCloud.empty": "No se encontraron etiquetas.",
"render.tagCloud.ariaLabel": "Nube de etiquetas", "render.tagCloud.ariaLabel": "Nube de etiquetas",
"render.taxonomy.ariaLabel": "Taxonomía",
"render.video.youtubeTitle": "Vídeo de YouTube", "render.video.youtubeTitle": "Vídeo de YouTube",
"render.video.vimeoTitle": "Vídeo de Vimeo", "render.video.vimeoTitle": "Vídeo de Vimeo",
"render.month.1": "enero", "render.month.1": "enero",

View File

@@ -53,6 +53,7 @@
"render.gallery.empty": "Aucune image liée trouvée.", "render.gallery.empty": "Aucune image liée trouvée.",
"render.tagCloud.empty": "Aucun tag trouvé.", "render.tagCloud.empty": "Aucun tag trouvé.",
"render.tagCloud.ariaLabel": "Nuage de tags", "render.tagCloud.ariaLabel": "Nuage de tags",
"render.taxonomy.ariaLabel": "Taxonomie",
"render.video.youtubeTitle": "Vidéo YouTube", "render.video.youtubeTitle": "Vidéo YouTube",
"render.video.vimeoTitle": "Vidéo Vimeo", "render.video.vimeoTitle": "Vidéo Vimeo",
"render.month.1": "janvier", "render.month.1": "janvier",

View File

@@ -53,6 +53,7 @@
"render.gallery.empty": "Nessuna immagine collegata trovata.", "render.gallery.empty": "Nessuna immagine collegata trovata.",
"render.tagCloud.empty": "Nessun tag trovato.", "render.tagCloud.empty": "Nessun tag trovato.",
"render.tagCloud.ariaLabel": "Nuvola di tag", "render.tagCloud.ariaLabel": "Nuvola di tag",
"render.taxonomy.ariaLabel": "Tassonomia",
"render.video.youtubeTitle": "Video YouTube", "render.video.youtubeTitle": "Video YouTube",
"render.video.vimeoTitle": "Video Vimeo", "render.video.vimeoTitle": "Video Vimeo",
"render.month.1": "gennaio", "render.month.1": "gennaio",

View File

@@ -385,6 +385,45 @@ describe('BlogGenerationEngine', () => {
expect(html).toContain('data-template="single-post"'); expect(html).toContain('data-template="single-post"');
}); });
it('renders taxonomy bubbles on generated single-post pages with category-first order and tag color override', async () => {
const posts = [
makePost({
id: '1',
slug: 'hello-world',
title: 'Hello World',
createdAt: new Date('2025-03-15T10:00:00Z'),
categories: ['article', 'news'],
tags: ['css-only', 'default-color'],
}),
];
await mkdir(path.join(tempDir, 'meta'), { recursive: true });
await writeFile(path.join(tempDir, 'meta', 'tags.json'), JSON.stringify([
{ name: 'css-only', color: '#22aa88' },
{ name: 'default-color' },
]), 'utf-8');
await generate(posts);
const postPath = path.join(tempDir, 'html', '2025', '03', '15', 'hello-world', 'index.html');
expect(await fileExists(postPath)).toBe(true);
const html = await readFile(postPath, 'utf-8');
expect(html).toContain('class="single-post-taxonomy"');
expect(html).toContain('aria-label="Taxonomy"');
expect(html).toContain('class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category"');
expect(html).toContain('class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag"');
expect(html).toContain('href="/category/article/"');
expect(html).toContain('href="/tag/css-only/"');
expect(html).toContain('style="--bubble-accent: #22aa88;"');
const categoryIndex = html.indexOf('single-post-taxonomy-bubble-category');
const tagIndex = html.indexOf('single-post-taxonomy-bubble-tag');
expect(categoryIndex).toBeGreaterThan(-1);
expect(tagIndex).toBeGreaterThan(-1);
expect(categoryIndex).toBeLessThan(tagIndex);
});
it('generates category pages with correct archive context', async () => { it('generates category pages with correct archive context', async () => {
const posts = [ const posts = [
makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }), makePost({ id: '1', slug: 'news-1', title: 'News 1', categories: ['news'] }),

View File

@@ -725,6 +725,51 @@ describe('PreviewServer', () => {
expect(h1Index).toBeLessThan(articleIndex); expect(h1Index).toBeLessThan(articleIndex);
}); });
it('renders categories and tags as small bubbles on single post pages with category-first order and tag color override', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'bds-preview-taxonomy-'));
await mkdir(path.join(tempDir, 'meta'), { recursive: true });
await writeFile(path.join(tempDir, 'meta', 'tags.json'), JSON.stringify([
{ name: 'css-only', color: '#22aa88' },
{ name: 'default-color' },
]), 'utf-8');
const post = makePost({
id: 'taxonomy-post',
title: 'Taxonomy Post',
slug: 'taxonomy-post',
createdAt: new Date('2025-02-14T10:00:00.000Z'),
categories: ['article', 'news'],
tags: ['css-only', 'default-color'],
content: 'Body',
});
server = new PreviewServer({
postEngine: makeEngine([post]),
settingsEngine: makeSettings(50),
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: tempDir || undefined }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/2025/2/14/taxonomy-post/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).toContain('class="single-post-taxonomy"');
expect(html).toContain('aria-label="Taxonomy"');
expect(html).toContain('class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category"');
expect(html).toContain('class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag"');
expect(html).toContain('href="/category/article/"');
expect(html).toContain('href="/tag/css-only/"');
expect(html).toContain('style="--bubble-accent: #22aa88;"');
const categoryIndex = html.indexOf('single-post-taxonomy-bubble-category');
const tagIndex = html.indexOf('single-post-taxonomy-bubble-tag');
expect(categoryIndex).toBeGreaterThan(-1);
expect(tagIndex).toBeGreaterThan(-1);
expect(categoryIndex).toBeLessThan(tagIndex);
});
it('uses blog description as h1 on first date archive page and date range h1 on later pages', async () => { it('uses blog description as h1 on first date archive page and date range h1 on later pages', async () => {
const posts = [ const posts = [
makePost({ makePost({