claude
This commit is contained in:
parent
fcb0e25ebd
commit
3441a7e4af
108 changed files with 641 additions and 869 deletions
120
src/lib/site.ts
120
src/lib/site.ts
|
|
@ -74,77 +74,109 @@ export function tagPath(tag: string) {
|
|||
return `/tags/${tagSlug(tag)}/`;
|
||||
}
|
||||
|
||||
export function getAllTags(posts: { data: { tags: readonly string[] } }[]) {
|
||||
return [...new Set(posts.flatMap((post) => post.data.tags))].sort((a, b) =>
|
||||
a.localeCompare(b)
|
||||
);
|
||||
export function getAllTags(articles: CollectionEntry<'work'>[]) {
|
||||
return [
|
||||
...new Set(articles.flatMap((article) => article.data.article?.tags ?? [])),
|
||||
].sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
// Memoized published-posts loader. Build steps call `getPublishedPosts()`
|
||||
// from many pages (index, articles, RSS, sitemap, tag pages, post layouts).
|
||||
// Caching the promise means `getCollection('posts')` runs once per build.
|
||||
let publishedPostsPromise: Promise<CollectionEntry<'posts'>[]> | undefined;
|
||||
// Memoized article loader. Build steps call `getArticles()` from many pages
|
||||
// (index, articles, RSS, sitemap, tag pages, article layout). Caching the
|
||||
// promise means `getCollection('work')` runs once per build. An entry is an
|
||||
// article when it carries an `article` facet and isn't a draft.
|
||||
let articlesPromise: Promise<CollectionEntry<'work'>[]> | undefined;
|
||||
|
||||
export function getPublishedPosts(): Promise<CollectionEntry<'posts'>[]> {
|
||||
if (!publishedPostsPromise) {
|
||||
publishedPostsPromise = getCollection('posts').then((posts) =>
|
||||
posts
|
||||
.filter((post) => !post.data.draft)
|
||||
export function getArticles(): Promise<CollectionEntry<'work'>[]> {
|
||||
if (!articlesPromise) {
|
||||
articlesPromise = getCollection('work').then((entries) =>
|
||||
entries
|
||||
.filter((entry) => entry.data.article && !entry.data.article.draft)
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
|
||||
);
|
||||
}
|
||||
return publishedPostsPromise;
|
||||
return articlesPromise;
|
||||
}
|
||||
|
||||
export async function getProjects(): Promise<CollectionEntry<'projects'>[]> {
|
||||
return (await getCollection('projects')).sort(
|
||||
(a, b) => b.data.sortDate.valueOf() - a.data.sortDate.valueOf()
|
||||
);
|
||||
// Entries shown in the projects index: those carrying a `project` facet,
|
||||
// newest first.
|
||||
export async function getProjects(): Promise<CollectionEntry<'work'>[]> {
|
||||
return (await getCollection('work'))
|
||||
.filter((entry) => entry.data.project)
|
||||
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
|
||||
}
|
||||
|
||||
type PostMediaItem = CollectionEntry<'posts'>['data']['media'][number];
|
||||
export type HeaderVideo = Extract<PostMediaItem, { type: 'video' }>;
|
||||
export interface ProjectCard {
|
||||
title: string;
|
||||
description: string;
|
||||
thumbnail: { src: ImageMetadata; alt: string };
|
||||
technologies: string[];
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
// A post has a "header video" when one of its media items is a video whose
|
||||
// poster is the same image as the post thumbnail. That video can stand in for
|
||||
// the static banner: the header shows the poster, then plays inline on click.
|
||||
// Posts without such a video keep the plain image header.
|
||||
export function getHeaderVideo(post: CollectionEntry<'posts'>): HeaderVideo | undefined {
|
||||
const thumbnailSrc = post.data.thumbnail.src.src;
|
||||
return post.data.media.find(
|
||||
// Resolves a project card's presentation. Shared identity lives at the top
|
||||
// level; the `project` facet overrides only the fields where the card
|
||||
// deliberately differs from the article. Technologies fall back to the
|
||||
// article's stack.
|
||||
export function projectCard(entry: CollectionEntry<'work'>): ProjectCard {
|
||||
const { data } = entry;
|
||||
const project = data.project;
|
||||
return {
|
||||
title: project?.title ?? data.title,
|
||||
description: project?.description ?? data.description,
|
||||
thumbnail: {
|
||||
src: project?.thumbnail?.src ?? data.thumbnail.src,
|
||||
alt: project?.thumbnail?.alt ?? data.thumbnail.alt,
|
||||
},
|
||||
technologies: project?.technologies ?? data.article?.stack ?? [],
|
||||
selected: project?.selected ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
type ArticleData = NonNullable<CollectionEntry<'work'>['data']['article']>;
|
||||
export type WorkMedia = ArticleData['media'][number];
|
||||
export type HeaderVideo = Extract<WorkMedia, { type: 'video' }>;
|
||||
|
||||
// An entry has a "header video" when one of its article media items is a video
|
||||
// whose poster is the same image as the top-level thumbnail. That video can
|
||||
// stand in for the static banner: the header shows the poster, then plays
|
||||
// inline on click. Entries without such a video keep the plain image header.
|
||||
export function getHeaderVideo(entry: CollectionEntry<'work'>): HeaderVideo | undefined {
|
||||
const thumbnailSrc = entry.data.thumbnail.src.src;
|
||||
return (entry.data.article?.media ?? []).find(
|
||||
(item): item is HeaderVideo =>
|
||||
item.type === 'video' && item.poster?.src === thumbnailSrc
|
||||
);
|
||||
}
|
||||
|
||||
export function adjacentPosts(
|
||||
posts: CollectionEntry<'posts'>[],
|
||||
current: CollectionEntry<'posts'>
|
||||
export function adjacentArticles(
|
||||
articles: CollectionEntry<'work'>[],
|
||||
current: CollectionEntry<'work'>
|
||||
) {
|
||||
const index = posts.findIndex((post) => post.id === current.id);
|
||||
const index = articles.findIndex((article) => article.id === current.id);
|
||||
if (index === -1) return { previous: undefined, next: undefined };
|
||||
return {
|
||||
previous: index < posts.length - 1 ? posts[index + 1] : undefined,
|
||||
next: index > 0 ? posts[index - 1] : undefined,
|
||||
previous: index < articles.length - 1 ? articles[index + 1] : undefined,
|
||||
next: index > 0 ? articles[index - 1] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function getRelatedPosts(
|
||||
posts: CollectionEntry<'posts'>[],
|
||||
current: CollectionEntry<'posts'>,
|
||||
export function getRelatedArticles(
|
||||
articles: CollectionEntry<'work'>[],
|
||||
current: CollectionEntry<'work'>,
|
||||
limit = 3
|
||||
) {
|
||||
const currentTags = new Set(current.data.tags);
|
||||
return posts
|
||||
.filter((post) => post.id !== current.id)
|
||||
.map((post) => ({
|
||||
post,
|
||||
overlap: post.data.tags.filter((tag) => currentTags.has(tag)).length,
|
||||
const currentTags = new Set(current.data.article?.tags ?? []);
|
||||
return articles
|
||||
.filter((article) => article.id !== current.id)
|
||||
.map((article) => ({
|
||||
article,
|
||||
overlap: (article.data.article?.tags ?? []).filter((tag) => currentTags.has(tag))
|
||||
.length,
|
||||
}))
|
||||
.filter(({ overlap }) => overlap > 0)
|
||||
.sort((a, b) => b.overlap - a.overlap)
|
||||
.slice(0, limit)
|
||||
.map(({ post }) => post);
|
||||
.map(({ article }) => article);
|
||||
}
|
||||
|
||||
export function absoluteUrl(path: string) {
|
||||
|
|
@ -203,7 +235,7 @@ interface BreadcrumbInput {
|
|||
projects?: boolean;
|
||||
tagsIndex?: boolean;
|
||||
tag?: string;
|
||||
post?: CollectionEntry<'posts'>;
|
||||
post?: CollectionEntry<'work'>;
|
||||
}
|
||||
|
||||
// Builds the breadcrumb trail shared by JSON-LD (BreadcrumbList) and the
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue