--- 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 } from '../../lib/api/client'; import { formatReadTime, getI18n } from '../../lib/i18n'; import { buildArticleFaqs, buildArticleHighlights, 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; 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: 'featured digest', digestKicker: 'ai digest', digestTitle: 'AI / search summary', digestDescription: 'This block exposes a compact summary, key takeaways, and canonical follow-up paths for AI search and human skimming.', highlightsTitle: 'Key takeaways', faqTitle: 'Quick FAQ', sourceTitle: 'Canonical source signals', readTime: 'Read time', insightCount: 'Key points', faqCount: 'FAQ', updated: 'Updated', category: 'Category', canonical: 'Canonical', 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: 'Quick share', shareChannelsDescription: 'Push this article to social channels with a shorter path, so people and AI search tools can pick up the canonical link faster.', shareToX: 'Share to X', shareToTelegram: 'Share to Telegram', shareToWeChat: 'WeChat QR', qrModalTitle: 'WeChat scan share', qrModalDescription: 'Scan this local QR code in WeChat to open the canonical article URL on mobile.', qrModalHint: 'Prefer sharing the canonical link so users and AI engines can fold signals back to one source.', downloadQr: 'Download QR', downloadQrStarted: 'QR download started', qrOpened: 'WeChat QR ready', floatingToolsTitle: 'Digest tools', copyPermalinkSuccess: 'Permalink copied', copyPermalinkFailed: 'Permalink copy failed', toastSuccessTitle: 'Done', toastErrorTitle: 'Action failed', toastInfoTitle: 'Share ready', } : { digestBadge: '精选摘要', digestKicker: 'ai digest', digestTitle: 'AI / 搜索摘要', digestDescription: '这块内容会把页面结论、重点摘录和规范入口显式写出来,方便 AI 搜索和用户快速理解。', highlightsTitle: '关键信息', faqTitle: '快速问答', sourceTitle: '规范来源信号', readTime: '阅读时长', insightCount: '重点条数', faqCount: '问答条数', updated: '最近更新', category: '归档分类', canonical: '规范地址', keywords: '关键词', copySummary: '复制摘要', copySuccess: '摘要已复制', copyFailed: '复制失败', shareSummary: '分享摘要', shareSuccess: '已打开分享面板', shareFallback: '分享文案已复制', shareFailed: '分享失败', shareChannelsTitle: '快速分发', shareChannelsDescription: '用更短路径把这篇内容发到社交渠道,方便二次传播和 AI 引用回链。', shareToX: '分享到 X', shareToTelegram: '分享到 Telegram', shareToWeChat: '微信扫码', qrModalTitle: '微信扫码分享', qrModalDescription: '使用本地生成的二维码,在微信里扫一扫,就能直接打开这篇文章的规范链接。', qrModalHint: '尽量分享规范地址,方便用户回访,也方便 AI 搜索把信号聚合回同一篇内容。', 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 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.faqCount, value: String(articleFaqs.length), }, ]; 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: 220, color: { dark: '#111827', light: '#ffffff', }, }); wechatShareQrPngDataUrl = await QRCode.toDataURL(canonicalUrl, { margin: 1, width: 360, 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}

{articleSynopsis}

{articleCopy.shareChannelsTitle}

{articleCopy.shareChannelsDescription}

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

{articleCopy.highlightsTitle}

{articleHighlights.map((item, index) => (
{String(index + 1).padStart(2, '0')}

{item}

))}
)}

{articleCopy.sourceTitle}

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

{articleCopy.faqTitle}

{articleFaqs.map((item) => (

{item.question}

{item.answer}

))}
)}
{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 && (
)}
{wechatShareQrEnabled && wechatShareQrSvg && ( )}

{articleCopy.toastSuccessTitle}