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