--- import { createMarkdownProcessor } from '@astrojs/markdown-remark'; import BaseLayout from '../../layouts/BaseLayout.astro'; import TerminalWindow from '../../components/ui/TerminalWindow.astro'; import CommandPrompt from '../../components/ui/CommandPrompt.astro'; import ResponsiveImage from '../../components/ui/ResponsiveImage.astro'; import TableOfContents from '../../components/TableOfContents.astro'; import RelatedPosts from '../../components/RelatedPosts.astro'; import ReadingProgress from '../../components/ReadingProgress.astro'; import Lightbox from '../../components/Lightbox.astro'; import CodeCopyButton from '../../components/CodeCopyButton.astro'; import Comments from '../../components/Comments.astro'; import ParagraphComments from '../../components/ParagraphComments.astro'; import QRCode from 'qrcode'; import { apiClient, DEFAULT_SITE_SETTINGS, resolvePublicApiBaseUrl, } from '../../lib/api/client'; import { formatReadTime, getI18n } from '../../lib/i18n'; import { buildArticleFaqs, buildArticleHighlights, buildArticlePreviewParagraphs, buildArticleSynopsis, resolvePostUpdatedAt, } from '../../lib/seo'; import type { PopularPostHighlight } from '../../lib/types'; import { buildCategoryUrl, buildTagUrl, getAccentVars, getCategoryTheme, getPostTypeColor, getPostTypeTheme, getTagTheme, resolveFileRef, } from '../../lib/utils'; export const prerender = false; const { slug } = Astro.params; let post = null; let siteSettings = DEFAULT_SITE_SETTINGS; const analyticsEndpoint = `${resolvePublicApiBaseUrl(Astro.url)}/analytics/content`; let homeData: Awaited> | null = null; let postLookupFailed = false; const [postResult, siteSettingsResult, homeDataResult] = await Promise.allSettled([ apiClient.getPostBySlug(slug ?? ''), apiClient.getSiteSettings(), apiClient.getHomePageData(), ]); if (postResult.status === 'fulfilled') { post = postResult.value; } else { postLookupFailed = true; console.error('API Error:', postResult.reason); } if (siteSettingsResult.status === 'fulfilled') { siteSettings = siteSettingsResult.value; } else { console.error('Site settings API Error:', siteSettingsResult.reason); } if (homeDataResult.status === 'fulfilled') { homeData = homeDataResult.value; if (siteSettingsResult.status !== 'fulfilled') { siteSettings = homeData.siteSettings; } } else { console.error('Home data API Error:', homeDataResult.reason); } if (!post) { return new Response(null, { status: postLookupFailed ? 503 : 404, headers: postLookupFailed ? { 'Retry-After': '120' } : undefined, }); } if (slug && post.slug !== slug) { return Astro.redirect(`/articles/${post.slug}`, 301); } const typeColor = getPostTypeColor(post.type || 'article'); const typeTheme = getPostTypeTheme(post.type || 'article'); const categoryTheme = getCategoryTheme(post.category); const contentText = post.content || post.description || ''; const wordCount = contentText.length; const readTimeMinutes = Math.ceil(wordCount / 300); const { locale, t } = getI18n(Astro); const isEnglish = locale.startsWith('en'); const articleMarkdown = contentText.replace(/^#\s+.+\r?\n+/, ''); const paragraphCommentsEnabled = siteSettings.comments.paragraphsEnabled; const articleCopy = isEnglish ? { digestBadge: 'quick brief', digestKicker: 'reading preview', digestTitle: 'Read this first', digestDescription: 'A short overview of the article so readers can quickly grasp the key points before sharing or saving it.', highlightsTitle: 'Key takeaways', faqTitle: 'Quick FAQ', sourceTitle: 'Page details', readTime: 'Read time', insightCount: 'Key points', faqCount: 'FAQ', updated: 'Updated', category: 'Category', canonical: 'Permalink', keywords: 'Keywords', copySummary: 'Copy digest', copySuccess: 'Digest copied', copyFailed: 'Copy failed', shareSummary: 'Share digest', shareSuccess: 'Share panel opened', shareFallback: 'Share text copied', shareFailed: 'Share failed', shareChannelsTitle: 'Share options', shareChannelsDescription: 'Copy the overview or permalink, or continue sharing through the channels below.', shareToX: 'Share to X', shareToTelegram: 'Share to Telegram', shareToWeChat: 'WeChat scan', qrModalTitle: 'Scan with WeChat', qrModalDescription: 'Scan this QR code in WeChat to continue reading the article on mobile.', qrModalHint: 'When you want to send the article to someone else, copying the permalink below is usually the easiest option.', downloadQr: 'Download QR', downloadQrStarted: 'QR download started', qrOpened: 'WeChat QR ready', floatingToolsTitle: 'Quick actions', copyPermalinkSuccess: 'Permalink copied', copyPermalinkFailed: 'Permalink copy failed', toastSuccessTitle: 'Done', toastErrorTitle: 'Action failed', toastInfoTitle: 'Ready', } : { digestBadge: '文章导读', digestKicker: '阅读前速览', digestTitle: '先看重点', digestDescription: '先用几句话帮你抓住这篇文章的重点,方便快速浏览、收藏或转发。', highlightsTitle: '关键信息', faqTitle: '快速问答', sourceTitle: '页面信息', readTime: '阅读时长', insightCount: '重点条数', faqCount: '问答条数', updated: '最近更新', category: '归档分类', canonical: '固定链接', keywords: '关键词', copySummary: '复制摘要', copySuccess: '摘要已复制', copyFailed: '复制失败', shareSummary: '分享摘要', shareSuccess: '已打开分享面板', shareFallback: '分享文案已复制', shareFailed: '分享失败', shareChannelsTitle: '分享方式', shareChannelsDescription: '可以直接复制摘要、固定链接,或通过常用渠道继续转发。', shareToX: '分享到 X', shareToTelegram: '分享到 Telegram', shareToWeChat: '微信扫一扫', qrModalTitle: '微信扫一扫', qrModalDescription: '用微信扫一扫,就能在手机上继续阅读这篇文章。', qrModalHint: '发给别人时,优先复制固定链接,对方打开会更方便。', downloadQr: '下载二维码', downloadQrStarted: '二维码开始下载', qrOpened: '微信二维码已打开', floatingToolsTitle: '快捷操作', copyPermalinkSuccess: '固定链接已复制', copyPermalinkFailed: '固定链接复制失败', toastSuccessTitle: '操作完成', toastErrorTitle: '操作失败', toastInfoTitle: '已准备好', }; const markdownProcessor = await createMarkdownProcessor(); const renderedContent = await markdownProcessor.render(articleMarkdown); const siteBaseUrl = (siteSettings.siteUrl || new URL(Astro.request.url).origin).replace(/\/$/, ''); const canonicalUrl = post.canonicalUrl || new URL(`/articles/${post.slug}`, siteBaseUrl).toString(); const ogImage = post.ogImage || `/og/${post.slug}.svg`; const wechatShareQrEnabled = Boolean(siteSettings.seo.wechatShareQrEnabled); const noindex = Boolean(post.noindex || post.visibility === 'unlisted'); const publishedAt = post.publishAt || post.createdAt || `${post.date}T00:00:00Z`; const updatedAt = resolvePostUpdatedAt(post); const updatedAtIso = new Date(updatedAt).toISOString(); const updatedAtLabel = Number.isNaN(new Date(updatedAt).valueOf()) ? updatedAt : new Date(updatedAt).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric', }); const articleSynopsis = buildArticleSynopsis(post, 220); const articlePreviewParagraphs = buildArticlePreviewParagraphs(post, 3, 110); const articleHighlights = buildArticleHighlights(post, 3); const articleFaqs = buildArticleFaqs(post, { locale, summary: articleSynopsis, readTimeMinutes, }); const digestStats = [ { label: articleCopy.readTime, value: `${Math.max(readTimeMinutes, 1)}m`, }, { label: articleCopy.insightCount, value: String(articleHighlights.length || 1), }, { label: articleCopy.keywords, value: String(post.tags?.length || 0), }, ]; const articleDigestClipboardText = [ post.title, articleSynopsis, articleHighlights.length ? `${articleCopy.highlightsTitle}:\n${articleHighlights.map((item, index) => `${index + 1}. ${item}`).join('\n')}` : '', `${articleCopy.canonical}: ${canonicalUrl}`, ] .filter(Boolean) .join('\n\n'); const articleDigestShareText = [post.title, articleSynopsis, canonicalUrl].filter(Boolean).join('\n\n'); const articleShareTeaser = [post.title, articleHighlights[0] || articleSynopsis] .filter(Boolean) .join(' — ') .slice(0, 220); const xShareUrl = `https://x.com/intent/tweet?text=${encodeURIComponent(articleShareTeaser)}&url=${encodeURIComponent(canonicalUrl)}`; const telegramShareUrl = `https://t.me/share/url?url=${encodeURIComponent(canonicalUrl)}&text=${encodeURIComponent(articleShareTeaser)}`; let wechatShareQrSvg = ''; let wechatShareQrPngDataUrl = ''; if (wechatShareQrEnabled) { try { wechatShareQrSvg = await QRCode.toString(canonicalUrl, { type: 'svg', margin: 1, width: 240, color: { dark: '#111827', light: '#ffffff', }, }); wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, { margin: 1, width: 420, color: { dark: '#111827', light: '#ffffff', }, }); } catch (error) { console.error('WeChat QR generation error:', error); } } const hotRelatedPosts = (homeData?.popularPosts ?? []) .filter((item): item is PopularPostHighlight & { post: NonNullable } => Boolean(item.post)) .filter((item) => item.slug !== post.slug) .map((item) => { const sharedTags = item.post.tags.filter((tag) => post.tags.includes(tag)); const sameCategory = item.post.category === post.category; const sameType = item.post.type === post.type; const relevance = (sameCategory ? 4 : 0) + sharedTags.length * 3 + (sameType ? 1 : 0); const popularityScore = item.pageViews + item.readCompletes * 2 + item.avgProgressPercent / 20; return { ...item, sharedTags, sameCategory, relevance, popularityScore, finalScore: relevance * 100 + popularityScore, }; }) .filter((item) => item.relevance > 0) .sort((left, right) => { return ( right.finalScore - left.finalScore || right.pageViews - left.pageViews || right.readCompletes - left.readCompletes ); }) .slice(0, 3); const articleJsonLd = { '@context': 'https://schema.org', '@type': post.type === 'article' ? 'BlogPosting' : 'Article', headline: post.title, description: post.description, abstract: articleSynopsis, image: [new URL(ogImage, siteBaseUrl).toString()], mainEntityOfPage: canonicalUrl, url: canonicalUrl, datePublished: publishedAt, dateModified: updatedAtIso, author: { '@type': 'Person', name: siteSettings.ownerName, }, publisher: { '@type': 'Organization', name: siteSettings.siteName, logo: siteSettings.ownerAvatarUrl ? { '@type': 'ImageObject', url: siteSettings.ownerAvatarUrl, } : undefined, }, articleSection: post.category, keywords: post.tags, inLanguage: locale, isAccessibleForFree: true, wordCount, timeRequired: `PT${Math.max(readTimeMinutes, 1)}M`, }; const faqJsonLd = articleFaqs.length ? { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: articleFaqs.map((item) => ({ '@type': 'Question', name: item.question, acceptedAnswer: { '@type': 'Answer', text: item.answer, }, })), } : undefined; const breadcrumbJsonLd = { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [ { '@type': 'ListItem', position: 1, name: siteSettings.siteName, item: siteBaseUrl, }, { '@type': 'ListItem', position: 2, name: 'Articles', item: new URL('/articles', siteBaseUrl).toString(), }, { '@type': 'ListItem', position: 3, name: post.title, item: canonicalUrl, }, ], }; --- {post.tags.map((tag) => )}
{t('article.backToArticles')}
{t('article.documentSession')} {post.type === 'article' ? t('common.article') : t('common.tweet')} {post.category}
{post.date} {formatReadTime(locale, readTimeMinutes, t)} {t('common.characters', { count: wordCount })}

{post.title}

{post.description}

{articleCopy.digestBadge} {articleCopy.digestKicker}

{articleCopy.digestTitle}

{articleCopy.digestDescription}

{articlePreviewParagraphs.map((paragraph) => (

{paragraph}

))}

{articleCopy.shareChannelsTitle}

{articleCopy.shareChannelsDescription}

{articleCopy.shareToX} {articleCopy.shareToTelegram} {wechatShareQrEnabled && wechatShareQrSvg && ( )}
{digestStats.map((item) => (
{item.label}
{item.value}
))}

{articleCopy.sourceTitle}

{articleCopy.updated}
{updatedAtLabel}
{articleCopy.category}
{post.category}
{articleCopy.canonical}
{canonicalUrl}
{articleCopy.keywords}
{post.tags.map((tag) => ( {tag} ))}
{articleHighlights.length > 0 && (

{articleCopy.highlightsTitle}

{articleHighlights.map((item, index) => (
{index + 1}

{item}

))}
)}
{post.tags?.length > 0 && (
{post.tags.map(tag => ( {tag} ))}
)}
{post.image && (
)} {post.images && post.images.length > 0 && (
{post.images.map((image, index) => (
))}
)} {paragraphCommentsEnabled && ( )}
{t('common.backToIndex')}
{hotRelatedPosts.length > 0 && (
)}
{articleCopy.floatingToolsTitle}
{wechatShareQrEnabled && wechatShareQrSvg && ( )}
{wechatShareQrEnabled && wechatShareQrSvg && ( )}

{articleCopy.toastSuccessTitle}