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