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>;
|
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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user