1407 lines
57 KiB
Plaintext
1407 lines
57 KiB
Plaintext
---
|
|
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<ReturnType<typeof apiClient.getHomePageData>> | 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<PopularPostHighlight['post']> } => 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,
|
|
},
|
|
],
|
|
};
|
|
---
|
|
|
|
<BaseLayout
|
|
title={post.title}
|
|
description={post.description}
|
|
siteSettings={siteSettings}
|
|
canonical={canonicalUrl}
|
|
noindex={noindex}
|
|
ogImage={ogImage}
|
|
ogType="article"
|
|
twitterCard="summary_large_image"
|
|
jsonLd={[articleJsonLd, breadcrumbJsonLd, faqJsonLd].filter(Boolean)}
|
|
>
|
|
<Fragment slot="head">
|
|
<meta property="article:published_time" content={publishedAt} />
|
|
<meta property="article:modified_time" content={updatedAtIso} />
|
|
<meta property="article:section" content={post.category} />
|
|
{post.tags.map((tag) => <meta property="article:tag" content={tag} />)}
|
|
</Fragment>
|
|
<ReadingProgress />
|
|
<Lightbox />
|
|
<CodeCopyButton />
|
|
|
|
<div class="mx-auto max-w-[1660px] px-4 py-8 sm:px-6 lg:px-8" data-article-slug={post.slug}>
|
|
<div class="grid gap-8 xl:grid-cols-[minmax(0,1fr)_17.5rem] 2xl:grid-cols-[minmax(0,1fr)_18.5rem]">
|
|
<div class="min-w-0 flex-1">
|
|
<TerminalWindow title={`~/articles/${post.slug}`} class="w-full">
|
|
<div class="px-4 pb-2">
|
|
<div class="terminal-panel ml-4 mt-4 space-y-5">
|
|
<div class="flex flex-wrap items-start justify-between gap-4">
|
|
<div class="space-y-4">
|
|
<a href="/articles" class="terminal-link-arrow">
|
|
<i class="fas fa-arrow-left"></i>
|
|
<span>{t('article.backToArticles')}</span>
|
|
</a>
|
|
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="terminal-kicker">
|
|
<i class="fas fa-file-code"></i>
|
|
{t('article.documentSession')}
|
|
</span>
|
|
<span class="terminal-chip terminal-chip--accent" style={getAccentVars(typeTheme)}>
|
|
<span class="h-2.5 w-2.5 rounded-full" style={`background-color: ${typeColor}`}></span>
|
|
{post.type === 'article' ? t('common.article') : t('common.tweet')}
|
|
</span>
|
|
<a
|
|
href={buildCategoryUrl(post.category)}
|
|
class="terminal-chip terminal-chip--accent"
|
|
style={getAccentVars(categoryTheme)}
|
|
>
|
|
<i class="fas fa-folder-tree"></i>
|
|
{post.category}
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<span class="terminal-stat-pill">
|
|
<i class="far fa-calendar text-[var(--primary)]"></i>
|
|
{post.date}
|
|
</span>
|
|
<span class="terminal-stat-pill">
|
|
<i class="far fa-clock text-[var(--primary)]"></i>
|
|
{formatReadTime(locale, readTimeMinutes, t)}
|
|
</span>
|
|
<span class="terminal-stat-pill">
|
|
<i class="fas fa-font text-[var(--primary)]"></i>
|
|
{t('common.characters', { count: wordCount })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<h1 class="text-3xl font-bold tracking-tight text-[var(--title-color)] sm:text-4xl">{post.title}</h1>
|
|
<p class="max-w-3xl text-base leading-8 text-[var(--text-secondary)]">{post.description}</p>
|
|
</div>
|
|
|
|
<section class="grid items-start gap-5 xl:grid-cols-[minmax(0,1.62fr)_minmax(16.5rem,0.78fr)] 2xl:grid-cols-[minmax(0,1.8fr)_minmax(17rem,0.82fr)]">
|
|
<div class="relative overflow-hidden rounded-[28px] border border-[var(--border-color)] bg-[linear-gradient(135deg,rgba(var(--primary-rgb),0.12),rgba(var(--secondary-rgb),0.05)_46%,rgba(var(--bg-rgb),0.92))] p-5 sm:p-6">
|
|
<div class="absolute inset-y-0 left-0 w-1 rounded-full bg-[var(--primary)]/80"></div>
|
|
<div class="absolute right-0 top-0 h-36 w-36 rounded-full bg-[var(--primary)]/10 blur-3xl"></div>
|
|
<div class="absolute bottom-0 right-10 h-24 w-24 rounded-full bg-[var(--secondary)]/10 blur-3xl"></div>
|
|
<div class="relative space-y-4">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="inline-flex items-center gap-2 rounded-full border border-[var(--primary)]/20 bg-[var(--primary)]/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.24em] text-[var(--primary)]">
|
|
<i class="fas fa-sparkles text-[10px]"></i>
|
|
{articleCopy.digestBadge}
|
|
</span>
|
|
<span class="terminal-kicker">
|
|
<i class="fas fa-book-open"></i>
|
|
{articleCopy.digestKicker}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<h2 class="text-xl font-semibold text-[var(--title-color)] sm:text-2xl">{articleCopy.digestTitle}</h2>
|
|
<p class="max-w-3xl text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.digestDescription}</p>
|
|
</div>
|
|
|
|
<div class="rounded-[24px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 p-5 shadow-[0_20px_55px_rgba(15,23,42,0.08)]">
|
|
<div class="space-y-3">
|
|
{articlePreviewParagraphs.map((paragraph) => (
|
|
<p class="text-[15px] leading-8 text-[var(--title-color)]">{paragraph}</p>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-center">
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
class="terminal-action-button"
|
|
data-article-digest-copy
|
|
data-default-label={articleCopy.copySummary}
|
|
data-success-label={articleCopy.copySuccess}
|
|
data-failed-label={articleCopy.copyFailed}
|
|
>
|
|
<i class="fas fa-copy"></i>
|
|
<span>{articleCopy.copySummary}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="terminal-action-button terminal-action-button-primary"
|
|
data-article-digest-share
|
|
data-default-label={articleCopy.shareSummary}
|
|
data-success-label={articleCopy.shareSuccess}
|
|
data-fallback-label={articleCopy.shareFallback}
|
|
data-failed-label={articleCopy.shareFailed}
|
|
>
|
|
<i class="fas fa-share-nodes"></i>
|
|
<span>{articleCopy.shareSummary}</span>
|
|
</button>
|
|
</div>
|
|
<p
|
|
class="min-h-[1.25rem] text-xs text-[var(--text-tertiary)]"
|
|
data-article-digest-status
|
|
aria-live="polite"
|
|
></p>
|
|
</div>
|
|
|
|
<div class="grid gap-3 xl:grid-cols-[minmax(0,1.18fr)_minmax(13rem,0.82fr)]">
|
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 px-4 py-3">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div class="space-y-1">
|
|
<p class="text-[11px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
|
{articleCopy.shareChannelsTitle}
|
|
</p>
|
|
<p class="text-xs leading-6 text-[var(--text-secondary)]">
|
|
{articleCopy.shareChannelsDescription}
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap gap-2">
|
|
<a
|
|
href={xShareUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
class="terminal-action-button"
|
|
data-article-share-link
|
|
>
|
|
<i class="fab fa-twitter"></i>
|
|
<span>{articleCopy.shareToX}</span>
|
|
</a>
|
|
<a
|
|
href={telegramShareUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer nofollow"
|
|
class="terminal-action-button"
|
|
data-article-share-link
|
|
>
|
|
<i class="fab fa-telegram-plane"></i>
|
|
<span>{articleCopy.shareToTelegram}</span>
|
|
</a>
|
|
{wechatShareQrEnabled && wechatShareQrSvg && (
|
|
<button
|
|
type="button"
|
|
class="terminal-action-button"
|
|
data-article-wechat-qr-open
|
|
>
|
|
<i class="fab fa-weixin"></i>
|
|
<span>{articleCopy.shareToWeChat}</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-3 sm:grid-cols-3 xl:grid-cols-1">
|
|
{digestStats.map((item) => (
|
|
<div class="rounded-2xl border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/78 px-4 py-3">
|
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">{item.label}</div>
|
|
<div class="mt-2 text-2xl font-semibold text-[var(--title-color)]">{item.value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
|
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
|
{articleCopy.sourceTitle}
|
|
</h3>
|
|
<dl class="mt-4 space-y-4 text-sm">
|
|
<div>
|
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.updated}</dt>
|
|
<dd class="mt-1 text-[var(--title-color)]">{updatedAtLabel}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.category}</dt>
|
|
<dd class="mt-1">
|
|
<a
|
|
href={buildCategoryUrl(post.category)}
|
|
class="text-[var(--title-color)] underline decoration-dotted underline-offset-4 hover:text-[var(--primary)]"
|
|
>
|
|
{post.category}
|
|
</a>
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.canonical}</dt>
|
|
<dd class="mt-1 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[var(--text-tertiary)]">{articleCopy.keywords}</dt>
|
|
<dd class="mt-2 flex flex-wrap gap-2">
|
|
{post.tags.map((tag) => (
|
|
<a
|
|
href={buildTagUrl(tag)}
|
|
class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs"
|
|
style={getAccentVars(getTagTheme(tag))}
|
|
>
|
|
<i class="fas fa-hashtag text-[10px]"></i>
|
|
{tag}
|
|
</a>
|
|
))}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
{articleHighlights.length > 0 && (
|
|
<div class="rounded-[24px] border border-[var(--border-color)]/75 bg-[var(--terminal-bg)]/86 p-5 shadow-[0_18px_42px_rgba(15,23,42,0.06)]">
|
|
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
|
|
{articleCopy.highlightsTitle}
|
|
</h3>
|
|
<div class="mt-4 space-y-3">
|
|
{articleHighlights.map((item, index) => (
|
|
<div class="flex items-start gap-3 rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3">
|
|
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-[var(--primary)]/10 text-[11px] font-semibold text-[var(--primary)]">
|
|
{index + 1}
|
|
</span>
|
|
<p class="text-sm leading-7 text-[var(--title-color)]">{item}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
{post.tags?.length > 0 && (
|
|
<div class="flex flex-wrap gap-2">
|
|
{post.tags.map(tag => (
|
|
<a
|
|
href={buildTagUrl(tag)}
|
|
class="terminal-filter"
|
|
style={getAccentVars(getTagTheme(tag))}
|
|
>
|
|
<i class="fas fa-hashtag"></i>
|
|
<span>{tag}</span>
|
|
</a>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-4 pb-2">
|
|
<CommandPrompt command={`preview article --slug ${post.slug}`} />
|
|
|
|
<div class="ml-4 mt-4 space-y-6">
|
|
{post.image && (
|
|
<div class="terminal-panel-muted overflow-hidden">
|
|
<ResponsiveImage
|
|
src={resolveFileRef(post.image)}
|
|
alt={post.title}
|
|
pictureClass="block"
|
|
imgClass="w-full h-auto rounded-xl border border-[var(--border-color)] cursor-zoom-in"
|
|
widths={[640, 960, 1280, 1600, 1920]}
|
|
sizes="(min-width: 1280px) 60rem, 100vw"
|
|
loading="eager"
|
|
fetchpriority="high"
|
|
lightbox={true}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{post.images && post.images.length > 0 && (
|
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
|
{post.images.map((image, index) => (
|
|
<div class="terminal-panel-muted overflow-hidden">
|
|
<ResponsiveImage
|
|
src={resolveFileRef(image)}
|
|
alt={`${post.title} 图片 ${index + 1}`}
|
|
pictureClass="block h-full w-full"
|
|
imgClass="h-full w-full rounded-xl border border-[var(--border-color)] object-cover cursor-zoom-in"
|
|
widths={[480, 720, 960, 1280]}
|
|
sizes="(min-width: 1280px) 30vw, (min-width: 640px) 45vw, 100vw"
|
|
lightbox={true}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{paragraphCommentsEnabled && (
|
|
<ParagraphComments postSlug={post.slug} class="mb-4" siteSettings={siteSettings} />
|
|
)}
|
|
|
|
<div class="terminal-document article-content" set:html={renderedContent.code}></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="px-4 py-6">
|
|
<div class="terminal-panel-muted ml-4 mt-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-end">
|
|
<div class="flex flex-wrap gap-2">
|
|
<a href="/articles" class="terminal-action-button">
|
|
<i class="fas fa-list"></i>
|
|
<span>{t('common.backToIndex')}</span>
|
|
</a>
|
|
<button
|
|
class="terminal-action-button terminal-action-button-primary"
|
|
data-article-permalink-copy
|
|
data-default-label={t('common.copyPermalink')}
|
|
data-success-label={articleCopy.copyPermalinkSuccess}
|
|
data-failed-label={articleCopy.copyPermalinkFailed}
|
|
>
|
|
<i class="fas fa-link"></i>
|
|
<span>{t('common.copyPermalink')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</TerminalWindow>
|
|
|
|
<RelatedPosts
|
|
currentSlug={post.slug}
|
|
currentCategory={post.category}
|
|
currentTags={post.tags}
|
|
/>
|
|
|
|
{hotRelatedPosts.length > 0 && (
|
|
<section class="terminal-panel mt-8">
|
|
<div class="space-y-5">
|
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
<div class="space-y-3">
|
|
<span class="terminal-kicker">
|
|
<i class="fas fa-fire"></i>
|
|
{t('relatedPosts.hotKicker')}
|
|
</span>
|
|
<div class="terminal-section-title">
|
|
<span class="terminal-section-icon">
|
|
<i class="fas fa-chart-line"></i>
|
|
</span>
|
|
<div>
|
|
<h3 class="text-xl font-semibold text-[var(--title-color)]">{t('relatedPosts.hotTitle')}</h3>
|
|
<p class="text-sm text-[var(--text-secondary)]">
|
|
{t('relatedPosts.hotDescription')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<span class="terminal-stat-pill">
|
|
<i class="fas fa-signal text-[var(--primary)]"></i>
|
|
{t('relatedPosts.linked', { count: hotRelatedPosts.length })}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="grid gap-4 md:grid-cols-3">
|
|
{hotRelatedPosts.map((item) => (
|
|
<a
|
|
href={`/articles/${item.slug}`}
|
|
class="terminal-panel-muted terminal-panel-accent terminal-interactive-card group flex h-full flex-col gap-4 p-4"
|
|
style={getAccentVars(getPostTypeTheme(item.post.type))}
|
|
>
|
|
{item.post.image ? (
|
|
<div class="overflow-hidden rounded-xl border border-[var(--border-color)]">
|
|
<ResponsiveImage
|
|
src={resolveFileRef(item.post.image)}
|
|
alt={item.post.title}
|
|
pictureClass="block"
|
|
imgClass="aspect-[16/9] w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
|
widths={[320, 480, 640, 960]}
|
|
sizes="(min-width: 1280px) 18rem, (min-width: 768px) 30vw, 100vw"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<div class="space-y-3">
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getPostTypeTheme(item.post.type))}>
|
|
{item.post.type === 'article' ? t('common.article') : t('common.tweet')}
|
|
</span>
|
|
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getCategoryTheme(item.post.category))}>
|
|
<i class="fas fa-folder-tree text-[11px]"></i>
|
|
{item.post.category}
|
|
</span>
|
|
</div>
|
|
|
|
<h4 class="text-base font-semibold text-[var(--title-color)] group-hover:text-[var(--primary)]">
|
|
{item.post.title}
|
|
</h4>
|
|
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item.post.description}</p>
|
|
</div>
|
|
|
|
<div class="mt-auto flex flex-wrap gap-2 pt-1">
|
|
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
|
<i class="fas fa-eye text-[var(--primary)]"></i>
|
|
{t('home.views')}: {item.pageViews}
|
|
</span>
|
|
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
|
<i class="fas fa-check-double text-[var(--primary)]"></i>
|
|
{t('home.completes')}: {item.readCompletes}
|
|
</span>
|
|
<span class="terminal-stat-pill px-2.5 py-1 text-xs">
|
|
<i class="fas fa-chart-line text-[var(--primary)]"></i>
|
|
{t('home.avgProgress')}: {Math.round(item.avgProgressPercent)}%
|
|
</span>
|
|
</div>
|
|
|
|
{item.sharedTags.length > 0 && (
|
|
<div class="flex flex-wrap gap-2">
|
|
{item.sharedTags.slice(0, 3).map((tag) => (
|
|
<span class="terminal-chip terminal-chip--accent px-2.5 py-1 text-xs" style={getAccentVars(getTagTheme(tag))}>
|
|
<i class="fas fa-hashtag text-[11px]"></i>
|
|
{tag}
|
|
</span>
|
|
))}
|
|
</div>
|
|
)}
|
|
</a>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)}
|
|
|
|
<section class="mt-8">
|
|
<Comments postSlug={post.slug} class="terminal-panel" siteSettings={siteSettings} />
|
|
</section>
|
|
</div>
|
|
|
|
<TableOfContents>
|
|
<div slot="before-nav" class="terminal-panel-muted space-y-3">
|
|
<span class="terminal-kicker">
|
|
<i class="fas fa-bolt"></i>
|
|
{articleCopy.floatingToolsTitle}
|
|
</span>
|
|
|
|
<div
|
|
class:list={[
|
|
'grid gap-2',
|
|
wechatShareQrEnabled && wechatShareQrSvg ? 'grid-cols-4' : 'grid-cols-3',
|
|
]}
|
|
>
|
|
<button
|
|
type="button"
|
|
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
|
data-article-floating-action="digest-copy"
|
|
title={articleCopy.copySummary}
|
|
aria-label={articleCopy.copySummary}
|
|
>
|
|
<i class="fas fa-copy text-sm"></i>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
|
data-article-floating-action="digest-share"
|
|
title={articleCopy.shareSummary}
|
|
aria-label={articleCopy.shareSummary}
|
|
>
|
|
<i class="fas fa-share-nodes text-sm"></i>
|
|
</button>
|
|
{wechatShareQrEnabled && wechatShareQrSvg && (
|
|
<button
|
|
type="button"
|
|
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
|
data-article-floating-action="wechat-qr"
|
|
title={articleCopy.shareToWeChat}
|
|
aria-label={articleCopy.shareToWeChat}
|
|
>
|
|
<i class="fab fa-weixin text-sm"></i>
|
|
</button>
|
|
)}
|
|
<button
|
|
type="button"
|
|
class="flex h-11 w-full items-center justify-center rounded-2xl border border-[var(--border-color)]/70 bg-[var(--bg)]/75 text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
|
data-article-floating-action="permalink-copy"
|
|
title={t('common.copyPermalink')}
|
|
aria-label={t('common.copyPermalink')}
|
|
>
|
|
<i class="fas fa-link text-sm"></i>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</TableOfContents>
|
|
</div>
|
|
</div>
|
|
|
|
{wechatShareQrEnabled && wechatShareQrSvg && (
|
|
<div
|
|
class="fixed inset-0 z-[160] hidden bg-black/70 backdrop-blur-sm"
|
|
data-article-wechat-qr-modal
|
|
aria-hidden="true"
|
|
>
|
|
<div class="flex min-h-screen items-center justify-center p-4">
|
|
<div class="w-full max-w-4xl rounded-[32px] border border-[var(--border-color)]/70 bg-[var(--terminal-bg)] p-5 shadow-[0_30px_90px_rgba(15,23,42,0.36)] sm:p-7">
|
|
<div class="flex items-start justify-between gap-4">
|
|
<div class="space-y-2">
|
|
<span class="terminal-kicker">
|
|
<i class="fab fa-weixin"></i>
|
|
{articleCopy.shareToWeChat}
|
|
</span>
|
|
<div>
|
|
<h3 class="text-2xl font-semibold text-[var(--title-color)]">{articleCopy.qrModalTitle}</h3>
|
|
<p class="mt-2 max-w-2xl text-sm leading-7 text-[var(--text-secondary)]">
|
|
{articleCopy.qrModalDescription}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="flex h-11 w-11 items-center justify-center rounded-2xl border border-[var(--border-color)] bg-[var(--terminal-bg)] text-[var(--text-secondary)] transition hover:border-[var(--primary)] hover:text-[var(--primary)]"
|
|
data-article-wechat-qr-close
|
|
aria-label={t('common.close')}
|
|
>
|
|
<i class="fas fa-xmark text-base"></i>
|
|
</button>
|
|
</div>
|
|
|
|
<div class="mt-6 grid gap-6 lg:grid-cols-[260px_minmax(0,1fr)]">
|
|
<div class="mx-auto w-full max-w-[260px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-5 shadow-[0_18px_45px_rgba(15,23,42,0.12)]">
|
|
<div class="overflow-hidden rounded-2xl" set:html={wechatShareQrSvg}></div>
|
|
</div>
|
|
|
|
<div class="space-y-4">
|
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
|
{articleCopy.canonical}
|
|
</div>
|
|
<p class="mt-3 break-all font-mono text-sm leading-7 text-[var(--title-color)]">{canonicalUrl}</p>
|
|
</div>
|
|
|
|
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--header-bg)] p-5">
|
|
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
|
|
{articleCopy.digestTitle}
|
|
</div>
|
|
<p class="mt-3 text-base font-semibold leading-7 text-[var(--title-color)]">{post.title}</p>
|
|
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleSynopsis}</p>
|
|
<p class="mt-3 text-sm leading-7 text-[var(--text-secondary)]">{articleCopy.qrModalHint}</p>
|
|
</div>
|
|
|
|
<div class="flex flex-wrap gap-2">
|
|
<button
|
|
type="button"
|
|
class="terminal-action-button terminal-action-button-primary"
|
|
data-article-permalink-copy
|
|
data-default-label={t('common.copyPermalink')}
|
|
data-success-label={articleCopy.copyPermalinkSuccess}
|
|
data-failed-label={articleCopy.copyPermalinkFailed}
|
|
>
|
|
<i class="fas fa-link"></i>
|
|
<span>{t('common.copyPermalink')}</span>
|
|
</button>
|
|
<a
|
|
href={wechatShareQrPngDataUrl}
|
|
download={`${post.slug}-wechat-share-qr.png`}
|
|
class="terminal-action-button"
|
|
data-article-qr-download
|
|
>
|
|
<i class="fas fa-download"></i>
|
|
<span>{articleCopy.downloadQr}</span>
|
|
</a>
|
|
<button
|
|
type="button"
|
|
class="terminal-action-button"
|
|
data-article-wechat-qr-close
|
|
>
|
|
<i class="fas fa-xmark"></i>
|
|
<span>{t('common.close')}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div
|
|
class="pointer-events-none fixed bottom-5 right-5 z-[70] w-[min(22rem,calc(100vw-1.5rem))] translate-y-4 opacity-0 transition-all duration-300"
|
|
data-article-digest-toast
|
|
data-title-success={articleCopy.toastSuccessTitle}
|
|
data-title-error={articleCopy.toastErrorTitle}
|
|
data-title-info={articleCopy.toastInfoTitle}
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
>
|
|
<div class="rounded-2xl border border-emerald-500/25 bg-[var(--terminal-bg)]/96 p-4 shadow-[0_18px_50px_rgba(15,23,42,0.18)] backdrop-blur">
|
|
<div class="flex items-start gap-3">
|
|
<span
|
|
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-emerald-500/12 text-emerald-400"
|
|
data-article-digest-toast-icon
|
|
>
|
|
<i class="fas fa-check"></i>
|
|
</span>
|
|
<div class="min-w-0">
|
|
<p class="text-sm font-semibold text-[var(--title-color)]" data-article-digest-toast-title>
|
|
{articleCopy.toastSuccessTitle}
|
|
</p>
|
|
<p class="mt-1 text-sm leading-6 text-[var(--text-secondary)]" data-article-digest-toast-message></p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</BaseLayout>
|
|
|
|
<script
|
|
is:inline
|
|
define:vars={{
|
|
analyticsEndpoint,
|
|
postSlug: post.slug,
|
|
articleDigestClipboardText,
|
|
articleDigestShareText,
|
|
articleCanonicalUrl: canonicalUrl,
|
|
articleTitle: post.title,
|
|
articleCopyQrOpened: articleCopy.qrOpened,
|
|
articleCopyDownloadQrStarted: articleCopy.downloadQrStarted,
|
|
}}
|
|
>
|
|
(() => {
|
|
const endpoint = analyticsEndpoint;
|
|
const sessionStorageKey = `termi:content-session:${postSlug}`;
|
|
const startedAt = Date.now();
|
|
let sentPageView = false;
|
|
let lastReportedProgress = 0;
|
|
const digestCopyButton = document.querySelector('[data-article-digest-copy]');
|
|
const digestShareButton = document.querySelector('[data-article-digest-share]');
|
|
const permalinkCopyButtons = document.querySelectorAll('[data-article-permalink-copy]');
|
|
const floatingActionButtons = document.querySelectorAll('[data-article-floating-action]');
|
|
const wechatQrModal = document.querySelector('[data-article-wechat-qr-modal]');
|
|
const wechatQrOpenButtons = document.querySelectorAll('[data-article-wechat-qr-open]');
|
|
const wechatQrCloseButtons = document.querySelectorAll('[data-article-wechat-qr-close]');
|
|
const wechatQrDownloadButtons = document.querySelectorAll('[data-article-qr-download]');
|
|
const digestStatus = document.querySelector('[data-article-digest-status]');
|
|
const digestToast = document.querySelector('[data-article-digest-toast]');
|
|
const digestToastIcon = document.querySelector('[data-article-digest-toast-icon]');
|
|
const digestToastTitle = document.querySelector('[data-article-digest-toast-title]');
|
|
const digestToastMessage = document.querySelector('[data-article-digest-toast-message]');
|
|
let digestToastTimer = 0;
|
|
|
|
function ensureSessionId() {
|
|
try {
|
|
const existing = window.sessionStorage.getItem(sessionStorageKey);
|
|
if (existing) return existing;
|
|
const nextId = crypto.randomUUID();
|
|
window.sessionStorage.setItem(sessionStorageKey, nextId);
|
|
return nextId;
|
|
} catch {
|
|
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
}
|
|
}
|
|
|
|
function getProgressPercent() {
|
|
const doc = document.documentElement;
|
|
const scrollTop = window.scrollY || doc.scrollTop || 0;
|
|
const scrollHeight = Math.max(doc.scrollHeight - window.innerHeight, 1);
|
|
return Math.max(0, Math.min(100, Math.round((scrollTop / scrollHeight) * 100)));
|
|
}
|
|
|
|
function getReferrerHost() {
|
|
try {
|
|
return document.referrer ? new URL(document.referrer).host : '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function normalizeSource(value) {
|
|
const source = String(value || '').trim().toLowerCase();
|
|
if (!source) return 'direct';
|
|
if (source.includes('chatgpt') || source.includes('openai')) return 'chatgpt-search';
|
|
if (source.includes('perplexity')) return 'perplexity';
|
|
if (source.includes('copilot') || source.includes('bing')) return 'copilot-bing';
|
|
if (source.includes('gemini')) return 'gemini';
|
|
if (source.includes('google')) return 'google';
|
|
if (source.includes('claude')) return 'claude';
|
|
if (source.includes('duckduckgo')) return 'duckduckgo';
|
|
if (source.includes('kagi')) return 'kagi';
|
|
return source;
|
|
}
|
|
|
|
function buildTrackingMetadata() {
|
|
const currentUrl = new URL(window.location.href);
|
|
const utmSource = currentUrl.searchParams.get('utm_source')?.trim() || '';
|
|
const utmMedium = currentUrl.searchParams.get('utm_medium')?.trim() || '';
|
|
const utmCampaign = currentUrl.searchParams.get('utm_campaign')?.trim() || '';
|
|
const utmTerm = currentUrl.searchParams.get('utm_term')?.trim() || '';
|
|
const utmContent = currentUrl.searchParams.get('utm_content')?.trim() || '';
|
|
const referrerHost = getReferrerHost();
|
|
|
|
return {
|
|
pageType: 'article',
|
|
referrerHost: referrerHost || undefined,
|
|
utmSource: utmSource || undefined,
|
|
utmMedium: utmMedium || undefined,
|
|
utmCampaign: utmCampaign || undefined,
|
|
utmTerm: utmTerm || undefined,
|
|
utmContent: utmContent || undefined,
|
|
landingSource: normalizeSource(utmSource || referrerHost),
|
|
};
|
|
}
|
|
|
|
function setDigestStatus(message) {
|
|
if (!digestStatus) return;
|
|
digestStatus.textContent = message || '';
|
|
}
|
|
|
|
function showDigestToast(message, type = 'success') {
|
|
if (!digestToast || !digestToastIcon || !digestToastTitle || !digestToastMessage) return;
|
|
|
|
const title =
|
|
digestToast.getAttribute(`data-title-${type}`) ||
|
|
digestToast.getAttribute('data-title-success') ||
|
|
'';
|
|
|
|
digestToastTitle.textContent = title;
|
|
digestToastMessage.textContent = message || '';
|
|
digestToast.classList.remove('opacity-0', 'translate-y-4');
|
|
|
|
digestToastIcon.className = 'flex h-9 w-9 shrink-0 items-center justify-center rounded-xl';
|
|
const iconElement = digestToastIcon.querySelector('i');
|
|
if (iconElement) {
|
|
iconElement.className =
|
|
type === 'error'
|
|
? 'fas fa-triangle-exclamation'
|
|
: type === 'info'
|
|
? 'fas fa-share-nodes'
|
|
: 'fas fa-check';
|
|
}
|
|
|
|
const toastCard = digestToast.firstElementChild;
|
|
toastCard?.classList.remove('border-emerald-500/25', 'border-rose-500/25', 'border-sky-500/25');
|
|
digestToastIcon.classList.remove(
|
|
'bg-emerald-500/12',
|
|
'text-emerald-400',
|
|
'bg-rose-500/12',
|
|
'text-rose-400',
|
|
'bg-sky-500/12',
|
|
'text-sky-400',
|
|
);
|
|
|
|
if (type === 'error') {
|
|
toastCard?.classList.add('border-rose-500/25');
|
|
digestToastIcon.classList.add('bg-rose-500/12', 'text-rose-400');
|
|
} else if (type === 'info') {
|
|
toastCard?.classList.add('border-sky-500/25');
|
|
digestToastIcon.classList.add('bg-sky-500/12', 'text-sky-400');
|
|
} else {
|
|
toastCard?.classList.add('border-emerald-500/25');
|
|
digestToastIcon.classList.add('bg-emerald-500/12', 'text-emerald-400');
|
|
}
|
|
|
|
window.clearTimeout(digestToastTimer);
|
|
digestToastTimer = window.setTimeout(() => {
|
|
digestToast.classList.add('opacity-0', 'translate-y-4');
|
|
}, 2200);
|
|
}
|
|
|
|
function setDigestButtonState(button, iconClass, label) {
|
|
if (!button) return;
|
|
button.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
|
|
}
|
|
|
|
function resetDigestButton(button, fallbackIconClass) {
|
|
if (!button) return;
|
|
const defaultLabel = button.getAttribute('data-default-label') || '';
|
|
setDigestButtonState(button, fallbackIconClass, defaultLabel);
|
|
}
|
|
|
|
async function writeClipboardText(value) {
|
|
await navigator.clipboard.writeText(value);
|
|
}
|
|
|
|
async function handleDigestCopy(button = digestCopyButton) {
|
|
const successLabel = button?.getAttribute('data-success-label') || '';
|
|
const failedLabel = button?.getAttribute('data-failed-label') || '';
|
|
try {
|
|
await writeClipboardText(articleDigestClipboardText);
|
|
setDigestButtonState(button, 'fas fa-check', successLabel);
|
|
setDigestStatus(successLabel);
|
|
showDigestToast(successLabel, 'success');
|
|
} catch {
|
|
setDigestButtonState(button, 'fas fa-triangle-exclamation', failedLabel);
|
|
setDigestStatus(failedLabel);
|
|
showDigestToast(failedLabel, 'error');
|
|
} finally {
|
|
window.setTimeout(() => {
|
|
resetDigestButton(button, 'fas fa-copy');
|
|
}, 1800);
|
|
}
|
|
}
|
|
|
|
async function handleDigestShare(button = digestShareButton) {
|
|
const successLabel = button?.getAttribute('data-success-label') || '';
|
|
const fallbackLabel = button?.getAttribute('data-fallback-label') || '';
|
|
const failedLabel = button?.getAttribute('data-failed-label') || '';
|
|
try {
|
|
if (navigator.share) {
|
|
await navigator.share({
|
|
title: articleTitle,
|
|
text: articleDigestShareText,
|
|
url: articleCanonicalUrl,
|
|
});
|
|
setDigestButtonState(button, 'fas fa-check', successLabel);
|
|
setDigestStatus(successLabel);
|
|
showDigestToast(successLabel, 'success');
|
|
} else {
|
|
await writeClipboardText(articleDigestShareText);
|
|
setDigestButtonState(button, 'fas fa-copy', fallbackLabel);
|
|
setDigestStatus(fallbackLabel);
|
|
showDigestToast(fallbackLabel, 'info');
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
resetDigestButton(button, 'fas fa-share-nodes');
|
|
return;
|
|
}
|
|
|
|
setDigestButtonState(button, 'fas fa-triangle-exclamation', failedLabel);
|
|
setDigestStatus(failedLabel);
|
|
showDigestToast(failedLabel, 'error');
|
|
} finally {
|
|
window.setTimeout(() => {
|
|
resetDigestButton(button, 'fas fa-share-nodes');
|
|
}, 1800);
|
|
}
|
|
}
|
|
|
|
async function handlePermalinkCopy(button = permalinkCopyButtons[0]) {
|
|
const successLabel = button?.getAttribute('data-success-label') || '';
|
|
const failedLabel = button?.getAttribute('data-failed-label') || '';
|
|
|
|
try {
|
|
await writeClipboardText(window.location.href);
|
|
setDigestButtonState(button, 'fas fa-check', successLabel);
|
|
setDigestStatus(successLabel);
|
|
showDigestToast(successLabel, 'success');
|
|
} catch {
|
|
setDigestButtonState(button, 'fas fa-triangle-exclamation', failedLabel);
|
|
setDigestStatus(failedLabel);
|
|
showDigestToast(failedLabel, 'error');
|
|
} finally {
|
|
window.setTimeout(() => {
|
|
resetDigestButton(button, 'fas fa-link');
|
|
}, 1800);
|
|
}
|
|
}
|
|
|
|
function openWechatQrModal() {
|
|
if (!wechatQrModal) return;
|
|
wechatQrModal.classList.remove('hidden');
|
|
wechatQrModal.setAttribute('aria-hidden', 'false');
|
|
document.body.style.overflow = 'hidden';
|
|
setDigestStatus(articleCopyQrOpened);
|
|
showDigestToast(articleCopyQrOpened, 'info');
|
|
}
|
|
|
|
function closeWechatQrModal() {
|
|
if (!wechatQrModal) return;
|
|
wechatQrModal.classList.add('hidden');
|
|
wechatQrModal.setAttribute('aria-hidden', 'true');
|
|
document.body.style.overflow = '';
|
|
}
|
|
|
|
digestCopyButton?.addEventListener('click', async () => {
|
|
await handleDigestCopy();
|
|
});
|
|
|
|
digestShareButton?.addEventListener('click', async () => {
|
|
await handleDigestShare();
|
|
});
|
|
|
|
permalinkCopyButtons.forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
await handlePermalinkCopy(button);
|
|
});
|
|
});
|
|
|
|
document.querySelectorAll('[data-article-share-link]').forEach((link) => {
|
|
link.addEventListener('click', () => {
|
|
const label = link.textContent?.trim() || '';
|
|
if (!label) return;
|
|
setDigestStatus(label);
|
|
showDigestToast(label, 'info');
|
|
});
|
|
});
|
|
|
|
wechatQrOpenButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
openWechatQrModal();
|
|
});
|
|
});
|
|
|
|
wechatQrCloseButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
closeWechatQrModal();
|
|
});
|
|
});
|
|
|
|
wechatQrDownloadButtons.forEach((button) => {
|
|
button.addEventListener('click', () => {
|
|
setDigestStatus(articleCopyDownloadQrStarted);
|
|
showDigestToast(articleCopyDownloadQrStarted, 'info');
|
|
});
|
|
});
|
|
|
|
wechatQrModal?.addEventListener('click', (event) => {
|
|
if (event.target === wechatQrModal) {
|
|
closeWechatQrModal();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' && wechatQrModal && !wechatQrModal.classList.contains('hidden')) {
|
|
closeWechatQrModal();
|
|
}
|
|
});
|
|
|
|
floatingActionButtons.forEach((button) => {
|
|
button.addEventListener('click', async () => {
|
|
const action = button.getAttribute('data-article-floating-action');
|
|
button.classList.add('border-[var(--primary)]', 'text-[var(--primary)]');
|
|
window.setTimeout(() => {
|
|
button.classList.remove('border-[var(--primary)]', 'text-[var(--primary)]');
|
|
}, 1400);
|
|
|
|
if (action === 'digest-copy') {
|
|
await handleDigestCopy();
|
|
return;
|
|
}
|
|
|
|
if (action === 'digest-share') {
|
|
await handleDigestShare();
|
|
return;
|
|
}
|
|
|
|
if (action === 'wechat-qr') {
|
|
openWechatQrModal();
|
|
return;
|
|
}
|
|
|
|
if (action === 'permalink-copy') {
|
|
await handlePermalinkCopy(permalinkCopyButtons[0]);
|
|
}
|
|
});
|
|
});
|
|
|
|
function sendEvent(eventType, extras = {}, useBeacon = false) {
|
|
const payload = JSON.stringify({
|
|
event_type: eventType,
|
|
path: `${window.location.pathname}${window.location.search}`,
|
|
post_slug: postSlug,
|
|
session_id: ensureSessionId(),
|
|
referrer: document.referrer || undefined,
|
|
metadata: buildTrackingMetadata(),
|
|
...extras,
|
|
});
|
|
|
|
if (useBeacon && navigator.sendBeacon) {
|
|
navigator.sendBeacon(endpoint, new Blob([payload], { type: 'application/json' }));
|
|
return;
|
|
}
|
|
|
|
fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: payload,
|
|
keepalive: true,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
function reportProgress(forceComplete = false) {
|
|
const progress = forceComplete ? 100 : getProgressPercent();
|
|
const durationMs = Date.now() - startedAt;
|
|
const eventType = forceComplete || progress >= 95 ? 'read_complete' : 'read_progress';
|
|
|
|
if (!forceComplete && progress <= lastReportedProgress + 9) {
|
|
return;
|
|
}
|
|
|
|
lastReportedProgress = Math.max(lastReportedProgress, progress);
|
|
sendEvent(
|
|
eventType,
|
|
{
|
|
progress_percent: progress,
|
|
duration_ms: durationMs,
|
|
metadata: {
|
|
...buildTrackingMetadata(),
|
|
viewportHeight: window.innerHeight,
|
|
},
|
|
},
|
|
eventType === 'read_complete',
|
|
);
|
|
}
|
|
|
|
if (!sentPageView) {
|
|
sentPageView = true;
|
|
sendEvent('page_view', {
|
|
metadata: {
|
|
...buildTrackingMetadata(),
|
|
title: document.title,
|
|
},
|
|
});
|
|
}
|
|
|
|
window.addEventListener('scroll', () => {
|
|
reportProgress(false);
|
|
}, { passive: true });
|
|
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'hidden') {
|
|
reportProgress(getProgressPercent() >= 95);
|
|
}
|
|
});
|
|
|
|
window.addEventListener('pagehide', () => {
|
|
reportProgress(getProgressPercent() >= 95);
|
|
});
|
|
})();
|
|
</script>
|