171 lines
5.4 KiB
Plaintext
171 lines
5.4 KiB
Plaintext
---
|
|
import ArticleSidebar from '@/components/ArticleSidebar.astro';
|
|
import PostCopyrightCard from '@/components/PostCopyrightCard.astro';
|
|
import BaseLayout from '@/layouts/BaseLayout.astro';
|
|
import { getAllPosts } from '@/lib/posts';
|
|
|
|
export function getStaticPaths() {
|
|
return getAllPosts().map((post) => ({
|
|
params: { slug: post.slug },
|
|
props: { post },
|
|
}));
|
|
}
|
|
|
|
const { post } = Astro.props;
|
|
const { Content } = post;
|
|
const allPosts = getAllPosts();
|
|
const rawContent = post.rawContent?.() ?? '';
|
|
const textContent = rawContent
|
|
.replace(/```[\s\S]*?```/g, '')
|
|
.replace(/<[^>]+>/g, '')
|
|
.replace(/[#>*_`~\-\[\]\(\)!]/g, '')
|
|
.replace(/\s+/g, '');
|
|
const wordCount = textContent.length;
|
|
const wordCountText = wordCount >= 1000 ? `${(wordCount / 1000).toFixed(1)}k` : String(wordCount);
|
|
const readingMinutes = Math.max(1, Math.ceil(wordCount / 400));
|
|
|
|
function slugifyHeading(value: string): string {
|
|
return value
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/<[^>]+>/g, '')
|
|
.replace(/[`*_~[\]()]/g, '')
|
|
.replace(/[^\p{Letter}\p{Number}\s-]/gu, '')
|
|
.replace(/\s+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '');
|
|
}
|
|
|
|
function makeToc(raw: string) {
|
|
const used = new Map<string, number>();
|
|
let inFence = false;
|
|
|
|
return raw
|
|
.split(/\r?\n/)
|
|
.flatMap((line) => {
|
|
if (/^\s*```/.test(line)) {
|
|
inFence = !inFence;
|
|
return [];
|
|
}
|
|
|
|
if (inFence) return [];
|
|
|
|
const match = /^(#{1,3})\s+(.+?)\s*#*$/.exec(line);
|
|
if (!match) return [];
|
|
|
|
const text = match[2].replace(/<[^>]+>/g, '').trim();
|
|
if (!text) return [];
|
|
|
|
const baseId = slugifyHeading(text) || 'section';
|
|
const count = used.get(baseId) ?? 0;
|
|
used.set(baseId, count + 1);
|
|
|
|
return [{
|
|
depth: match[1].length,
|
|
text,
|
|
id: count === 0 ? baseId : `${baseId}-${count}`,
|
|
}];
|
|
});
|
|
}
|
|
|
|
const toc = makeToc(post.rawContent?.() ?? '');
|
|
const relatedPosts = allPosts
|
|
.filter((item) => item.slug !== post.slug)
|
|
.filter((item) => item.categories.some((category) => post.categories.includes(category)))
|
|
.slice(0, 8);
|
|
---
|
|
|
|
<BaseLayout title={post.title} description={post.summary}>
|
|
<section class="post-layout">
|
|
<article class="article post-article">
|
|
<header class={post.cover ? 'article-header article-header-cover' : 'article-header'}>
|
|
{post.cover && <img class="article-header-cover-img" src={post.cover} alt="" loading="eager" decoding="async" />}
|
|
<div class="article-header-content">
|
|
<time datetime={post.date.toISOString()}>{post.dateText}</time>
|
|
<h1>{post.title}</h1>
|
|
{post.cover ? (
|
|
<div class="article-hero-meta" aria-label="文章信息">
|
|
<span>发布于 {post.dateText}</span>
|
|
{post.categories[0] && <a href={`/categories/${encodeURIComponent(post.categories[0])}/`}>{post.categories[0]}</a>}
|
|
<span>总字数:{wordCountText}</span>
|
|
<span>阅读时长:{readingMinutes} 分钟</span>
|
|
</div>
|
|
) : (
|
|
<div class="meta-list">
|
|
{post.categories.map((category) => <a href={`/categories/${encodeURIComponent(category)}/`}>{category}</a>)}
|
|
{post.tags.map((tag) => <a href={`/tags/${encodeURIComponent(tag)}/`}>#{tag}</a>)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
{post.summary && (
|
|
<section class="article-summary-card" aria-label="AI 文章摘要">
|
|
<div class="article-summary-dots" aria-hidden="true">
|
|
<span></span>
|
|
<span></span>
|
|
<span></span>
|
|
</div>
|
|
<p class="article-summary-content">
|
|
<span class="article-summary-text" data-summary-text={post.summary}>{post.summary}</span>
|
|
<span class="article-summary-caret" aria-hidden="true"></span>
|
|
</p>
|
|
<div class="article-summary-footer">
|
|
<span class="article-summary-brand">
|
|
<span aria-hidden="true">✣</span>
|
|
清羽のAI摘要
|
|
</span>
|
|
<span class="article-summary-model">KIMI-K2.5</span>
|
|
</div>
|
|
</section>
|
|
)}
|
|
<div class="prose" data-toc={JSON.stringify(toc)}>
|
|
<Content />
|
|
</div>
|
|
<PostCopyrightCard post={post} />
|
|
</article>
|
|
<ArticleSidebar post={post} toc={toc} relatedPosts={relatedPosts} />
|
|
</section>
|
|
</BaseLayout>
|
|
|
|
<script is:inline>
|
|
(() => {
|
|
const prose = document.querySelector('.prose[data-toc]');
|
|
if (!prose) return;
|
|
|
|
let toc = [];
|
|
try {
|
|
toc = JSON.parse(prose.dataset.toc || '[]');
|
|
} catch {
|
|
toc = [];
|
|
}
|
|
|
|
prose.querySelectorAll('h1, h2, h3').forEach((heading, index) => {
|
|
const item = toc[index];
|
|
if (item?.id && !heading.id) heading.id = item.id;
|
|
});
|
|
|
|
const summaryText = document.querySelector('.article-summary-text');
|
|
if (!summaryText) return;
|
|
|
|
const fullText = summaryText.dataset.summaryText || summaryText.textContent || '';
|
|
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
if (reduceMotion) {
|
|
summaryText.textContent = fullText;
|
|
return;
|
|
}
|
|
|
|
summaryText.textContent = '';
|
|
let index = 0;
|
|
|
|
function typeSummary() {
|
|
index += 1;
|
|
summaryText.textContent = fullText.slice(0, index);
|
|
if (index < fullText.length) {
|
|
window.setTimeout(typeSummary, 34);
|
|
}
|
|
}
|
|
|
|
window.setTimeout(typeSummary, 280);
|
|
})();
|
|
</script>
|