Files
termi-blog/frontend/src/pages/articles/[slug].astro
limitcool 3628a46ed1
Some checks failed
docker-images / resolve-build-targets (push) Successful in 7s
ui-regression / playwright-regression (push) Failing after 13m47s
docker-images / build-and-push (push) Failing after 7s
docker-images / submit-indexnow (push) Has been skipped
feat: add SharePanel component for social sharing with QR code support
- Implemented SharePanel component in `SharePanel.astro` for sharing content on social media platforms.
- Integrated QR code generation for WeChat sharing using the `qrcode` library.
- Added localization support for English and Chinese languages.
- Created utility functions in `seo.ts` for building article summaries and FAQs.
- Introduced API routes for serving IndexNow key and generating full LLM catalog and summaries.
- Enhanced SEO capabilities with structured data for articles and pages.
2026-04-02 14:15:21 +08:00

1405 lines
58 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 } 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<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: '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<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="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8" data-article-slug={post.slug}>
<div class="flex flex-col gap-8 lg:flex-row">
<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="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="pointer-events-none absolute right-4 top-4 z-10 hidden xl:block">
<div class="pointer-events-auto rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/88 px-2 py-2 shadow-[0_10px_28px_rgba(15,23,42,0.08)] backdrop-blur">
<div class="mb-2 px-2 text-[10px] font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.floatingToolsTitle}
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="flex h-10 w-10 items-center justify-center rounded-xl 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-10 w-10 items-center justify-center rounded-xl 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-10 w-10 items-center justify-center rounded-xl 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-10 w-10 items-center justify-center rounded-xl 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>
</div>
<div class="relative grid gap-5 lg:grid-cols-[minmax(0,1.4fr)_minmax(19rem,0.95fr)]">
<div class="space-y-5">
<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-robot"></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)]">
<p class="text-base leading-8 text-[var(--title-color)]">{articleSynopsis}</p>
</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="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">
{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>
{articleHighlights.length > 0 && (
<div class="space-y-3">
<h3 class="text-sm font-semibold uppercase tracking-[0.22em] text-[var(--text-tertiary)]">
{articleCopy.highlightsTitle}
</h3>
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{articleHighlights.map((item, index) => (
<div class="rounded-2xl border border-[var(--border-color)]/80 bg-[var(--terminal-bg)]/80 p-4 shadow-[0_10px_30px_rgba(15,23,42,0.04)]">
<div class="flex items-start gap-3">
<span class="inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-xl bg-[var(--primary)]/12 text-sm font-semibold text-[var(--primary)]">
{String(index + 1).padStart(2, '0')}
</span>
<p class="text-sm leading-7 text-[var(--text-secondary)]">{item}</p>
</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>
{articleFaqs.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.faqTitle}
</h3>
<div class="mt-4 space-y-3">
{articleFaqs.map((item) => (
<div class="rounded-2xl border border-[var(--border-color)]/80 bg-[var(--bg)]/58 px-4 py-3">
<p class="font-semibold text-[var(--title-color)]">{item.question}</p>
<p class="mt-2 text-sm leading-7 text-[var(--text-secondary)]">{item.answer}</p>
</div>
))}
</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>
</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-3xl rounded-[30px] border border-[var(--border-color)] bg-[linear-gradient(180deg,rgba(var(--bg-rgb),0.98),rgba(var(--bg-rgb),0.92))] p-5 shadow-[0_24px_80px_rgba(15,23,42,0.28)] sm:p-6">
<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-[240px_minmax(0,1fr)]">
<div class="mx-auto w-full max-w-[240px] rounded-[28px] border border-[var(--border-color)]/80 bg-white p-4 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(--terminal-bg)]/82 p-4">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.canonical}
</div>
<p class="mt-2 break-all font-mono text-xs leading-6 text-[var(--title-color)]">{canonicalUrl}</p>
</div>
<div class="rounded-2xl border border-[var(--border-color)]/70 bg-[var(--terminal-bg)]/72 p-4">
<div class="text-[11px] uppercase tracking-[0.2em] text-[var(--text-tertiary)]">
{articleCopy.digestTitle}
</div>
<p class="mt-2 text-sm font-semibold leading-7 text-[var(--title-color)]">{post.title}</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={{
postSlug: post.slug,
articleDigestClipboardText,
articleDigestShareText,
articleCanonicalUrl: canonicalUrl,
articleTitle: post.title,
articleCopyQrOpened: articleCopy.qrOpened,
articleCopyDownloadQrStarted: articleCopy.downloadQrStarted,
}}
>
(() => {
const endpoint = '/api/analytics/content';
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>