Files
blog/src/pages/posts/[...slug].astro
2026-01-07 16:24:34 +00:00

394 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
import { render } from "astro:content";
import * as path from "node:path";
import Comment from "@components/comment/index.astro";
import License from "@components/misc/License.astro";
import Markdown from "@components/misc/Markdown.astro";
import I18nKey from "@i18n/i18nKey";
import { i18n } from "@i18n/translation";
import MainGridLayout from "@layouts/MainGridLayout.astro";
import { getSortedPosts } from "@utils/content-utils";
import {
getFileDirFromPath,
getPostUrlBySlug,
removeFileExtension,
} from "@utils/url-utils";
import { Icon } from "astro-icon/components";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
import PostMetadata from "@/components/content/PostMeta.astro";
import RandomCoverImage from "@/components/misc/RandomCoverImage.astro";
import SharePoster from "@/components/misc/SharePoster.svelte";
import { coverImageConfig } from "@/config/coverImageConfig";
import { licenseConfig } from "@/config/licenseConfig";
import { profileConfig } from "@/config/profileConfig";
import { siteConfig } from "@/config/siteConfig";
import { sponsorConfig } from "@/config/sponsorConfig";
import { formatDateToYYYYMMDD } from "@/utils/date-utils";
import { processCoverImageSync } from "@/utils/image-utils";
import { url } from "@/utils/url-utils";
export async function getStaticPaths() {
const blogEntries = await getSortedPosts();
return blogEntries.map((entry) => {
// 将 id 转换为 slug移除扩展名以匹配路由参数
const slug = removeFileExtension(entry.id);
return {
params: { slug },
props: { entry },
};
});
}
const { entry } = Astro.props;
const { Content, headings } = await render(entry);
const { remarkPluginFrontmatter } = await render(entry);
// 处理随机图如果image为"api"则从配置的API获取随机图
const processedImage = processCoverImageSync(entry.data.image, entry.id);
let posterCoverUrl = processedImage;
if (processedImage) {
const isLocal = !(
processedImage.startsWith("/") ||
processedImage.startsWith("http") ||
processedImage.startsWith("https") ||
processedImage.startsWith("data:")
);
if (isLocal) {
const basePath = getFileDirFromPath(entry.filePath || "");
const files = import.meta.glob<ImageMetadata>("../../**", {
import: "default",
});
let normalizedPath = path
.normalize(path.join("../../", basePath, processedImage))
.replace(/\\/g, "/");
const file = files[normalizedPath];
if (file) {
const img = await file();
posterCoverUrl = img.src;
}
}
}
dayjs.extend(utc);
const jsonLd = {
"@context": "https://schema.org",
"@type": "BlogPosting",
headline: entry.data.title,
description: entry.data.description || entry.data.title,
keywords: entry.data.tags,
author: {
"@type": "Person",
name: profileConfig.name,
url: Astro.site,
},
datePublished: formatDateToYYYYMMDD(entry.data.published),
inLanguage: entry.data.lang
? entry.data.lang.replace("_", "-")
: siteConfig.lang.replace("_", "-"),
// TODO include cover image here
};
---
<MainGridLayout
banner={processedImage}
title={entry.data.title}
description={entry.data.description}
lang={entry.data.lang}
setOGTypeArticle={true}
postSlug={entry.id}
headings={headings}
>
<script
is:inline
slot="head"
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
/>
<div
class="flex w-full rounded-[var(--radius-large)] overflow-hidden relative mb-4"
>
<div
id="post-container"
class:list={[
"card-base z-10 px-6 md:px-9 pt-6 pb-4 relative w-full ",
{},
]}
>
<!-- word count and reading time -->
<div
class="flex flex-row text-black/30 dark:text-white/30 gap-5 mb-3 transition onload-animation"
>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:notes-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.words}
{" " + i18n(I18nKey.wordsCount)}
</div>
</div>
<div class="flex flex-row items-center">
<div
class="transition h-6 w-6 rounded-md bg-black/5 dark:bg-white/10 text-black/50 dark:text-white/50 flex items-center justify-center mr-2"
>
<Icon name="material-symbols:schedule-outline-rounded" />
</div>
<div class="text-sm">
{remarkPluginFrontmatter.minutes}
{
" " +
i18n(
remarkPluginFrontmatter.minutes === 1
? I18nKey.minuteCount
: I18nKey.minutesCount
)
}
</div>
</div>
</div>
<!-- title -->
<div class="relative onload-animation">
<h1
data-pagefind-body
data-pagefind-weight="10"
data-pagefind-meta="title"
class="transition w-full block font-bold mb-3
text-3xl md:text-[2.25rem]/[2.75rem]
text-black/90 dark:text-white/90
md:before:w-1 before:h-5 before:rounded-md before:bg-[var(--primary)]
before:absolute before:top-[0.75rem] before:left-[-1.125rem]"
>
{entry.data.title}
</h1>
</div>
<!-- metadata -->
<div class="onload-animation">
<PostMetadata
className="mb-5"
published={entry.data.published}
updated={entry.data.updated}
tags={entry.data.tags}
category={entry.data.category || undefined}
id={entry.id}
/>
{
!processedImage && (
<div class="border-[var(--line-divider)] border-dashed border-b-[1px] mt-3 mb-5" />
)
}
</div>
<!-- always show cover as long as it has one -->
{
processedImage && coverImageConfig.enableInPost && (
<div style="margin-top:1rem;">
<RandomCoverImage
id="post-cover"
src={processedImage}
basePath={getFileDirFromPath(entry.filePath || '')}
class="mb-8 rounded-xl banner-container onload-animation"
seed={entry.id}
preview={false}
/>
</div>
)
}
<Markdown class="mb-6 markdown-content onload-animation">
<Content />
</Markdown>
{/* 赞助按钮 & 分享按钮 */}
{
(siteConfig.sharePoster || (sponsorConfig.showButtonInPost && siteConfig.pages.sponsor)) && (
<div class="mb-6 rounded-xl onload-animation">
<div class="p-6 bg-[var(--license-block-bg)] rounded-xl">
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-3 flex-1">
<div class="h-12 w-12 rounded-lg bg-[var(--primary)] flex items-center justify-center text-white dark:text-black/70 flex-shrink-0">
<Icon
name={sponsorConfig.showButtonInPost &&
siteConfig.pages.sponsor
? "material-symbols:favorite"
: "material-symbols:share"}
class="text-2xl"
/>
</div>
<div>
<h3 class="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
{
sponsorConfig.showButtonInPost && siteConfig.pages.sponsor
? i18n(I18nKey.sponsorButton)
: i18n(I18nKey.shareOnSocial)
}
</h3>
<p class="text-sm text-neutral-600 dark:text-neutral-400">
{
sponsorConfig.showButtonInPost && siteConfig.pages.sponsor
? i18n(I18nKey.sponsorButtonText)
: i18n(I18nKey.shareOnSocialDescription)
}
</p>
</div>
</div>
<div class="flex items-center gap-3">
{
siteConfig.sharePoster && (
<SharePoster
client:load
title={entry.data.title}
author={profileConfig.name}
description={entry.data.description || entry.data.title}
pubDate={formatDateToYYYYMMDD(entry.data.published)}
coverImage={posterCoverUrl}
url={Astro.url.href}
siteTitle={siteConfig.title}
avatar={profileConfig.avatar}
/>
)
}
{
sponsorConfig.showButtonInPost && siteConfig.pages.sponsor && (
<a
href={url("/sponsor/")}
class="inline-flex items-center gap-2 px-6 py-3 bg-[var(--primary)] text-white dark:text-black/70 rounded-lg font-medium hover:bg-[var(--primary)]/80 hover:scale-105 active:scale-95 transition-all whitespace-nowrap"
>
<span>{i18n(I18nKey.sponsor)}</span>
<Icon name="fa6-solid:arrow-right" class="text-sm" />
</a>
)
}
</div>
</div>
</div>
</div>
)
}
{
licenseConfig.enable && (
<License
title={entry.data.title}
id={entry.id}
pubDate={entry.data.published}
author={entry.data.author}
sourceLink={entry.data.sourceLink}
licenseName={entry.data.licenseName}
licenseUrl={entry.data.licenseUrl}
class="mb-6 rounded-xl license-container onload-animation"
/>
)
}
</div>
</div>
<!-- 上次编辑时间 -->
{
siteConfig.showLastModified && (() => {
const lastModified = dayjs(
entry.data.updated || entry.data.published
);
const now = dayjs();
const daysDiff = now.diff(lastModified, "day");
// 使用用户定义的阈值如果没有定义则默认为1天
const outdatedThreshold = siteConfig.outdatedThreshold ?? 1;
const shouldShowOutdatedCard = daysDiff >= outdatedThreshold;
return shouldShowOutdatedCard ? (
<div class="card-base p-6 mb-4">
<div class="flex items-center gap-2">
<div class="transition h-9 w-9 rounded-lg overflow-hidden relative flex items-center justify-center mr-0">
<Icon
name="material-symbols:history-rounded"
class="text-4xl text-[var(--primary)] transition-transform group-hover:translate-x-0.5 bg-[var(--enter-btn-bg)] p-2 rounded-md"
/>
</div>
{(() => {
const dateStr = lastModified.format("YYYY-MM-DD");
const isOutdated = daysDiff >= 1;
return (
<div class="flex flex-col gap-0.1">
<div class="text-[1.0rem] leading-tight text-black/75 dark:text-white/75">
{`${i18n(I18nKey.lastModifiedPrefix)}${dateStr}${
isOutdated
? `${i18n(I18nKey.lastModifiedDaysAgo).replace("{days}", daysDiff.toString())}`
: ""
}`}
</div>
{isOutdated && (
<p class="text-[0.8rem] leading-tight text-black/75 dark:text-white/75">
{i18n(I18nKey.lastModifiedOutdated)}
</p>
)}
</div>
);
})()}
</div>
</div>
) : null;
})()
}
<div
class="flex flex-col md:flex-row justify-between mb-4 gap-4 overflow-hidden w-full"
>
<a
href={entry.data.nextSlug ? getPostUrlBySlug(entry.data.nextSlug) : "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.nextSlug },
]}
>
{
entry.data.nextSlug && (
<div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-start gap-4">
<Icon
name="material-symbols:chevron-left-rounded"
class="text-[2rem] text-[var(--primary)]"
/>
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.nextTitle}
</div>
</div>
)
}
</a>
<a
href={entry.data.prevSlug ? getPostUrlBySlug(entry.data.prevSlug) : "#"}
class:list={[
"w-full font-bold overflow-hidden active:scale-95",
{ "pointer-events-none": !entry.data.prevSlug },
]}
>
{
entry.data.prevSlug && (
<div class="btn-card rounded-2xl w-full h-[3.75rem] max-w-full px-4 flex items-center !justify-end gap-4">
<div class="overflow-hidden transition overflow-ellipsis whitespace-nowrap max-w-[calc(100%_-_3rem)] text-black/75 dark:text-white/75">
{entry.data.prevTitle}
</div>
<Icon
name="material-symbols:chevron-right-rounded"
class="text-[2rem] text-[var(--primary)]"
/>
</div>
)
}
</a>
</div>
<!-- 评论 -->
{entry.data.comment && <Comment post={entry} />}
</MainGridLayout>