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

View File

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

View File

@@ -43,6 +43,7 @@ interface PostEngineContract {
hasPublishedVersion: (id: string) => Promise<boolean>;
getPublishedVersion: (id: string) => 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;
}
@@ -203,6 +204,7 @@ export class PreviewServer {
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
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,
parseRoutePagination,
resolvePageTitle,
type BacklinkEntry,
type PostEngineContract,
type CategoryRenderSettings,
type HtmlRewriteContext,
@@ -81,6 +82,36 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
dateFilter?: { year: number; month: number; day?: number },
) => 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(
@@ -182,6 +213,7 @@ async function resolveRouteWithSharedServices(
const slug = daySlugMatch[4];
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
if (!post) return null;
const backlinks = await resolveBacklinks(post.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
@@ -191,6 +223,7 @@ async function resolveRouteWithSharedServices(
tag_color_by_name: tagColorByName,
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
}, services.postEngineForMacros);
}
@@ -267,6 +300,7 @@ async function resolveRouteWithSharedServices(
const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
if (!pagePost) return null;
const backlinks = await resolveBacklinks(pagePost.id, rewriteContext, services.getLinkedBy);
return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
page_title: pageContext.pageTitle,
language: pageContext.language,
@@ -276,6 +310,7 @@ async function resolveRouteWithSharedServices(
tag_color_by_name: tagColorByName,
tagSettings: tagTemplateSettings,
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
backlinks,
}, services.postEngineForMacros);
}

View File

@@ -134,6 +134,9 @@
.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-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-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

@@ -19,6 +19,14 @@
<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>
</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>
</body>
</html>

View File

@@ -63,6 +63,8 @@
"render.calendar.loading": "Kalender wird geladen …",
"render.calendar.error": "Kalenderdaten konnten nicht geladen werden.",
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Verlinkt von",
"render.backlinks.ariaLabel": "Rückverweise",
"render.video.youtubeTitle": "YouTube-Video",
"render.video.vimeoTitle": "Vimeo-Video",
"render.month.1": "Januar",

View File

@@ -63,6 +63,8 @@
"render.calendar.loading": "Loading calendar…",
"render.calendar.error": "Calendar data could not be loaded.",
"render.taxonomy.ariaLabel": "Taxonomy",
"render.backlinks.label": "Linked from",
"render.backlinks.ariaLabel": "Backlinks",
"render.video.youtubeTitle": "YouTube video",
"render.video.vimeoTitle": "Vimeo video",
"render.month.1": "January",

View File

@@ -63,6 +63,8 @@
"render.calendar.loading": "Cargando calendario…",
"render.calendar.error": "No se pudieron cargar los datos del calendario.",
"render.taxonomy.ariaLabel": "Taxonomía",
"render.backlinks.label": "Enlazado desde",
"render.backlinks.ariaLabel": "Retroenlaces",
"render.video.youtubeTitle": "Vídeo de YouTube",
"render.video.vimeoTitle": "Vídeo de Vimeo",
"render.month.1": "enero",

View File

@@ -63,6 +63,8 @@
"render.calendar.loading": "Chargement du calendrier…",
"render.calendar.error": "Impossible de charger les données du calendrier.",
"render.taxonomy.ariaLabel": "Taxonomie",
"render.backlinks.label": "Lié depuis",
"render.backlinks.ariaLabel": "Rétroliens",
"render.video.youtubeTitle": "Vidéo YouTube",
"render.video.vimeoTitle": "Vidéo Vimeo",
"render.month.1": "janvier",

View File

@@ -63,6 +63,8 @@
"render.calendar.loading": "Caricamento calendario…",
"render.calendar.error": "Impossibile caricare i dati del calendario.",
"render.taxonomy.ariaLabel": "Tassonomia",
"render.backlinks.label": "Collegato da",
"render.backlinks.ariaLabel": "Retrocollegamenti",
"render.video.youtubeTitle": "Video YouTube",
"render.video.vimeoTitle": "Video Vimeo",
"render.month.1": "gennaio",