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;
html_theme_attribute?: string;
post: TemplatePostEntry;
post_categories: string[];
post_tags: string[];
tag_color_by_name: Record<string, string>;
canonical_post_path_by_slug: Record<string, string>;
canonical_media_path_by_source_path: Record<string, string>;
}
@@ -1066,12 +1069,20 @@ export class PageRenderer {
async renderSinglePost(
post: PostData,
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,
): Promise<string> {
const renderablePost = postEngine
? await this.resolveRenderablePost(post, postEngine)
: 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 = {
...pageContext,
menu_items: pageContext.menu_items ?? [],
@@ -1082,6 +1093,9 @@ export class PageRenderer {
content: renderablePost.content,
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_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
};

View File

@@ -66,6 +66,11 @@ interface PreviewServerDependencies {
getActiveProjectContext: () => Promise<ActiveProjectContext>;
}
interface SerializedTag {
name?: unknown;
color?: unknown;
}
export class PreviewServer {
private readonly postEngine: PostEngineContract;
private readonly mediaEngine: MediaEngineContract;
@@ -74,6 +79,7 @@ export class PreviewServer {
private readonly menuEngine: MenuEngineContract;
private readonly getActiveProjectContext: () => Promise<ActiveProjectContext>;
private readonly pageRenderer: PageRenderer;
private readonly tagColorByNameCache = new Map<string, Promise<Record<string, string>>>();
private server: Server | null = null;
private port: number | null = null;
@@ -184,6 +190,7 @@ export class PreviewServer {
resolveCategorySettings: (metadata) => this.resolveCategorySettings(metadata),
resolveListExcludedCategories: (settings) => this.resolveListExcludedCategories(settings),
buildHtmlRewriteContext: () => this.buildHtmlRewriteContext(),
resolveTagColorByName: (projectContext) => this.resolveTagColorByName(projectContext),
pageRenderer: this.pageRenderer,
postEngineForMacros: this.postEngine,
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> {
const match = pathname.match(/^\/assets\/([^/]+)$/);
if (!match) return null;

View File

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

View File

@@ -5,6 +5,17 @@
<main>
<h1>{{ post.title }}</h1>
{% 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">
<div class="post">{{ post.content | markdown: post.id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
</article>

View File

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

View File

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

View File

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

View File

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

View File

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