feat: tags on posts
This commit is contained in:
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user