feat: backlinks on single posts

This commit is contained in:
2026-03-01 07:26:49 +01:00
parent 289535021a
commit 4c21b624f2
12 changed files with 191 additions and 1 deletions

View File

@@ -62,6 +62,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>; findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
getPost: (postId: string) => Promise<PostData | null>; getPost: (postId: string) => Promise<PostData | null>;
hasPublishedVersion: (postId: string) => Promise<boolean>; hasPublishedVersion: (postId: string) => Promise<boolean>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
setProjectContext: (projectId: string, dataDir?: string) => void; setProjectContext: (projectId: string, dataDir?: string) => void;
}; };
mediaEngine: { mediaEngine: {
@@ -176,6 +177,9 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
}, },
getPost: (postId: string) => params.engines.postEngine.getPost(postId), getPost: (postId: string) => params.engines.postEngine.getPost(postId),
hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId), hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId),
getLinkedBy: params.engines.postEngine.getLinkedBy
? (postId: string) => params.engines.postEngine.getLinkedBy!(postId)
: undefined,
setProjectContext: (projectId: string, dataDir?: string) => { setProjectContext: (projectId: string, dataDir?: string) => {
params.engines.postEngine.setProjectContext(projectId, dataDir); params.engines.postEngine.setProjectContext(projectId, dataDir);
}, },

View File

@@ -106,6 +106,12 @@ export interface PostListTemplateContext {
day_blocks: DayBlockContext[]; day_blocks: DayBlockContext[];
} }
export interface BacklinkEntry {
slug: string;
display_slug: string;
path: string;
}
export interface SinglePostTemplateContext { export interface SinglePostTemplateContext {
page_title: string; page_title: string;
language: string; language: string;
@@ -121,6 +127,7 @@ export interface SinglePostTemplateContext {
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>;
post_data_json_by_id: Record<string, string>; post_data_json_by_id: Record<string, string>;
backlinks: BacklinkEntry[];
} }
export interface NotFoundTemplateContext { export interface NotFoundTemplateContext {
@@ -1437,6 +1444,7 @@ export class PageRenderer {
tag_color_by_name?: Record<string, string>; tag_color_by_name?: Record<string, string>;
tagSettings?: Record<string, { postTemplateSlug?: string | null }>; tagSettings?: Record<string, { postTemplateSlug?: string | null }>;
categorySettings?: Record<string, { postTemplateSlug?: string | null }>; categorySettings?: Record<string, { postTemplateSlug?: string | null }>;
backlinks?: BacklinkEntry[];
}, },
postEngine?: PostEngineContract, postEngine?: PostEngineContract,
): Promise<string> { ): Promise<string> {
@@ -1451,6 +1459,8 @@ export class PageRenderer {
? Array.from(new Set(renderablePost.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0))) ? Array.from(new Set(renderablePost.tags.map((tag) => tag.trim()).filter((tag) => tag.length > 0)))
: []; : [];
const canonicalPostPathBySlug = mapToRecord(rewriteContext.canonicalPostPathBySlug);
const context: SinglePostTemplateContext = { const context: SinglePostTemplateContext = {
...pageContext, ...pageContext,
menu_items: pageContext.menu_items ?? [], menu_items: pageContext.menu_items ?? [],
@@ -1466,11 +1476,12 @@ export class PageRenderer {
tag_color_by_name: pageContext.tag_color_by_name ?? {}, tag_color_by_name: pageContext.tag_color_by_name ?? {},
calendar_initial_year: renderablePost.createdAt.getFullYear(), calendar_initial_year: renderablePost.createdAt.getFullYear(),
calendar_initial_month: renderablePost.createdAt.getMonth() + 1, calendar_initial_month: renderablePost.createdAt.getMonth() + 1,
canonical_post_path_by_slug: mapToRecord(rewriteContext.canonicalPostPathBySlug), canonical_post_path_by_slug: canonicalPostPathBySlug,
canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath), canonical_media_path_by_source_path: mapToRecord(rewriteContext.canonicalMediaPathBySourcePath),
post_data_json_by_id: { post_data_json_by_id: {
[renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)), [renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)),
}, },
backlinks: pageContext.backlinks ?? [],
}; };
const postTemplateName = resolvePostTemplateName( const postTemplateName = resolvePostTemplateName(

View File

@@ -43,6 +43,7 @@ interface PostEngineContract {
hasPublishedVersion: (id: string) => Promise<boolean>; hasPublishedVersion: (id: string) => Promise<boolean>;
getPublishedVersion: (id: string) => Promise<PostData | null>; getPublishedVersion: (id: string) => Promise<PostData | null>;
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>; findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
setProjectContext: (projectId: string, dataDir?: string) => void; setProjectContext: (projectId: string, dataDir?: string) => void;
} }
@@ -203,6 +204,7 @@ export class PreviewServer {
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination), loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination), loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter), findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
getLinkedBy: this.postEngine.getLinkedBy ? (postId) => this.postEngine.getLinkedBy!(postId) : undefined,
}); });
} }

View File

@@ -6,6 +6,7 @@ import {
clampMaxPostsPerPage, clampMaxPostsPerPage,
parseRoutePagination, parseRoutePagination,
resolvePageTitle, resolvePageTitle,
type BacklinkEntry,
type PostEngineContract, type PostEngineContract,
type CategoryRenderSettings, type CategoryRenderSettings,
type HtmlRewriteContext, type HtmlRewriteContext,
@@ -81,6 +82,36 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string }, singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
dateFilter?: { year: number; month: number; day?: number }, dateFilter?: { year: number; month: number; day?: number },
) => Promise<PostData | null>; ) => Promise<PostData | null>;
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
}
const MAX_BACKLINK_SLUG_LENGTH = 30;
function truncateSlug(slug: string): string {
if (slug.length <= MAX_BACKLINK_SLUG_LENGTH) return slug;
return slug.slice(0, MAX_BACKLINK_SLUG_LENGTH) + '...';
}
async function resolveBacklinks(
postId: string,
rewriteContext: HtmlRewriteContext,
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>,
): Promise<BacklinkEntry[]> {
if (!getLinkedBy) return [];
const linkedPosts = await getLinkedBy(postId);
if (linkedPosts.length === 0) return [];
return linkedPosts
.map((linked) => {
const canonical = rewriteContext.canonicalPostPathBySlug.get(linked.slug);
if (!canonical) return null;
return {
slug: linked.slug,
display_slug: truncateSlug(linked.slug),
path: canonical,
};
})
.filter((entry): entry is BacklinkEntry => entry !== null);
} }
async function resolveRouteWithSharedServices( async function resolveRouteWithSharedServices(
@@ -182,6 +213,7 @@ async function resolveRouteWithSharedServices(
const slug = daySlugMatch[4]; const slug = daySlugMatch[4];
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day }); const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
if (!post) return null; if (!post) return null;
const backlinks = await resolveBacklinks(post.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(post, rewriteContext, { return services.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
@@ -191,6 +223,7 @@ async function resolveRouteWithSharedServices(
tag_color_by_name: tagColorByName, tag_color_by_name: tagColorByName,
tagSettings: tagTemplateSettings, tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>, categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
}, services.postEngineForMacros); }, services.postEngineForMacros);
} }
@@ -267,6 +300,7 @@ async function resolveRouteWithSharedServices(
const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage }); const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
const pagePost = pages.find((candidate) => candidate.slug === slug) || null; const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
if (!pagePost) return null; if (!pagePost) return null;
const backlinks = await resolveBacklinks(pagePost.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, { return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
page_title: pageContext.pageTitle, page_title: pageContext.pageTitle,
language: pageContext.language, language: pageContext.language,
@@ -276,6 +310,7 @@ async function resolveRouteWithSharedServices(
tag_color_by_name: tagColorByName, tag_color_by_name: tagColorByName,
tagSettings: tagTemplateSettings, tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>, categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
}, services.postEngineForMacros); }, services.postEngineForMacros);
} }

View File

@@ -134,6 +134,9 @@
.single-post-taxonomy-bubble:focus-visible { text-decoration: underline; } .single-post-taxonomy-bubble:focus-visible { text-decoration: underline; }
.single-post-taxonomy-bubble-category { --bubble-accent: var(--pico-ins-color, rgb(53, 117, 56)); --bubble-bg: var(--pico-ins-color, rgb(53, 117, 56)); } .single-post-taxonomy-bubble-category { --bubble-accent: var(--pico-ins-color, rgb(53, 117, 56)); --bubble-bg: var(--pico-ins-color, rgb(53, 117, 56)); }
.single-post-taxonomy-bubble-tag { --bubble-accent: var(--pico-del-color, rgb(183, 72, 72)); --bubble-bg: var(--pico-del-color, rgb(183, 72, 72)); } .single-post-taxonomy-bubble-tag { --bubble-accent: var(--pico-del-color, rgb(183, 72, 72)); --bubble-bg: var(--pico-del-color, rgb(183, 72, 72)); }
.single-post-backlinks { display: flex; flex-wrap: wrap; gap: .4rem .45rem; align-items: center; margin-top: 1.5rem; }
.single-post-backlinks-label { font-size: .74rem; line-height: 1.35; color: var(--pico-muted-color, var(--muted-color)); margin-right: .15rem; }
.single-post-backlink-bubble { --bubble-accent: var(--pico-primary, rgb(16, 107, 193)); --bubble-bg: var(--pico-primary, rgb(16, 107, 193)); color: var(--pico-primary-inverse, #fff); }
.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

@@ -19,6 +19,14 @@
<article class="single-post" data-template="single-post"> <article class="single-post" data-template="single-post">
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div> <div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
</article> </article>
{% if backlinks.size > 0 %}
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">
<span class="single-post-backlinks-label">{{ 'render.backlinks.label' | i18n: language }}</span>
{% for backlink in backlinks %}
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="{{ backlink.path }}">{{ backlink.display_slug }}</a>
{% endfor %}
</div>
{% endif %}
</main> </main>
</body> </body>
</html> </html>

View File

@@ -63,6 +63,8 @@
"render.calendar.loading": "Kalender wird geladen …", "render.calendar.loading": "Kalender wird geladen …",
"render.calendar.error": "Kalenderdaten konnten nicht geladen werden.", "render.calendar.error": "Kalenderdaten konnten nicht geladen werden.",
"render.taxonomy.ariaLabel": "Taxonomie", "render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Verlinkt von",
"render.backlinks.ariaLabel": "Rückverweise",
"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

@@ -63,6 +63,8 @@
"render.calendar.loading": "Loading calendar…", "render.calendar.loading": "Loading calendar…",
"render.calendar.error": "Calendar data could not be loaded.", "render.calendar.error": "Calendar data could not be loaded.",
"render.taxonomy.ariaLabel": "Taxonomy", "render.taxonomy.ariaLabel": "Taxonomy",
"render.backlinks.label": "Linked from",
"render.backlinks.ariaLabel": "Backlinks",
"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

@@ -63,6 +63,8 @@
"render.calendar.loading": "Cargando calendario…", "render.calendar.loading": "Cargando calendario…",
"render.calendar.error": "No se pudieron cargar los datos del calendario.", "render.calendar.error": "No se pudieron cargar los datos del calendario.",
"render.taxonomy.ariaLabel": "Taxonomía", "render.taxonomy.ariaLabel": "Taxonomía",
"render.backlinks.label": "Enlazado desde",
"render.backlinks.ariaLabel": "Retroenlaces",
"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

@@ -63,6 +63,8 @@
"render.calendar.loading": "Chargement du calendrier…", "render.calendar.loading": "Chargement du calendrier…",
"render.calendar.error": "Impossible de charger les données du calendrier.", "render.calendar.error": "Impossible de charger les données du calendrier.",
"render.taxonomy.ariaLabel": "Taxonomie", "render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Lié depuis",
"render.backlinks.ariaLabel": "Rétroliens",
"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

@@ -63,6 +63,8 @@
"render.calendar.loading": "Caricamento calendario…", "render.calendar.loading": "Caricamento calendario…",
"render.calendar.error": "Impossibile caricare i dati del calendario.", "render.calendar.error": "Impossibile caricare i dati del calendario.",
"render.taxonomy.ariaLabel": "Tassonomia", "render.taxonomy.ariaLabel": "Tassonomia",
"render.backlinks.label": "Collegato da",
"render.backlinks.ariaLabel": "Retrocollegamenti",
"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

@@ -919,6 +919,123 @@ describe('PreviewServer', () => {
expect(categoryIndex).toBeLessThan(tagIndex); expect(categoryIndex).toBeLessThan(tagIndex);
}); });
it('renders backlinks section at the bottom of a single post with slug bubbles linking to source posts', async () => {
const sourcePost = makePost({
id: 'source-post',
title: 'Source Post',
slug: 'source-post',
createdAt: new Date('2025-03-10T10:00:00.000Z'),
content: 'Links to [target](/2025/2/14/target-post)',
});
const longSlugPost = makePost({
id: 'long-slug-post',
title: 'A Very Long Slug Post Title',
slug: 'a-very-long-slug-post-that-exceeds-thirty-characters',
createdAt: new Date('2025-03-12T10:00:00.000Z'),
content: 'Links to [target](/2025/2/14/target-post)',
});
const targetPost = makePost({
id: 'target-post',
title: 'Target Post',
slug: 'target-post',
createdAt: new Date('2025-02-14T10:00:00.000Z'),
content: 'This is the target post',
});
const engine = makeEngine([sourcePost, longSlugPost, targetPost]);
(engine as any).getLinkedBy = async (postId: string) => {
if (postId === 'target-post') {
return [
{ id: 'source-post', title: 'Source Post', slug: 'source-post' },
{ id: 'long-slug-post', title: 'A Very Long Slug Post Title', slug: 'a-very-long-slug-post-that-exceeds-thirty-characters' },
];
}
return [];
};
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/2025/2/14/target-post/`);
expect(response.status).toBe(200);
const html = await response.text();
// Backlinks section exists
expect(html).toContain('class="single-post-backlinks"');
// "Linked from" label
expect(html).toContain('Linked from');
// Backlink bubbles with correct class
expect(html).toContain('class="single-post-taxonomy-bubble single-post-backlink-bubble"');
// Slug text appears
expect(html).toContain('source-post');
// Long slugs are truncated to 30 chars + "..." in display text
expect(html).toContain('a-very-long-slug-post-that-exc...');
// The display text should NOT show the full slug (only the href contains it)
expect(html).toContain('>a-very-long-slug-post-that-exc...</a>');
expect(html).not.toContain('>a-very-long-slug-post-that-exceeds-thirty-characters</a>');
// Links point to the canonical post path
expect(html).toContain('href="/2025/03/10/source-post"');
expect(html).toContain('href="/2025/03/12/a-very-long-slug-post-that-exceeds-thirty-characters"');
// Backlinks use pico accent color
expect(html).toContain('.single-post-backlink-bubble');
expect(html).toContain('--pico-primary');
// Backlinks section is after the article
const articleEndIndex = html.indexOf('</article>');
const backlinksIndex = html.indexOf('class="single-post-backlinks"');
expect(articleEndIndex).toBeGreaterThan(-1);
expect(backlinksIndex).toBeGreaterThan(-1);
expect(backlinksIndex).toBeGreaterThan(articleEndIndex);
});
it('does not render backlinks section when no posts link to the current post', async () => {
const post = makePost({
id: 'lonely-post',
title: 'Lonely Post',
slug: 'lonely-post',
createdAt: new Date('2025-02-14T10:00:00.000Z'),
content: 'No one links to me',
});
const engine = makeEngine([post]);
(engine as any).getLinkedBy = async () => [];
server = new PreviewServer({
postEngine: engine,
settingsEngine: makeSettings(50),
mediaEngine: makeMediaEngine([]) as any,
postMediaEngine: makePostMediaEngine({}) as any,
menuEngine: makeMenuEngine({ items: [] }) as any,
getActiveProjectContext: async () => ({ projectId: 'default' }),
});
await server.start(0);
const response = await fetch(`${server.getBaseUrl()}/2025/2/14/lonely-post/`);
expect(response.status).toBe(200);
const html = await response.text();
expect(html).not.toContain('class="single-post-backlinks"');
expect(html).not.toContain('Linked from');
});
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({