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.
This commit is contained in:
@@ -294,6 +294,7 @@ export interface ApiSiteSettings {
|
||||
subscription_popup_delay_seconds: number | null;
|
||||
seo_default_og_image: string | null;
|
||||
seo_default_twitter_handle: string | null;
|
||||
seo_wechat_share_qr_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ContentAnalyticsInput {
|
||||
@@ -491,6 +492,7 @@ export const DEFAULT_SITE_SETTINGS: SiteSettings = {
|
||||
seo: {
|
||||
defaultOgImage: undefined,
|
||||
defaultTwitterHandle: undefined,
|
||||
wechatShareQrEnabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -509,6 +511,8 @@ const normalizePost = (post: ApiPost): UiPost => ({
|
||||
description: post.description,
|
||||
content: post.content,
|
||||
date: formatPostDate(post.created_at),
|
||||
createdAt: post.created_at,
|
||||
updatedAt: post.updated_at,
|
||||
readTime: estimateReadTime(post.content || post.description),
|
||||
type: post.post_type,
|
||||
tags: post.tags ?? [],
|
||||
@@ -662,6 +666,7 @@ const normalizeSiteSettings = (settings: ApiSiteSettings): SiteSettings => {
|
||||
seo: {
|
||||
defaultOgImage: settings.seo_default_og_image ?? undefined,
|
||||
defaultTwitterHandle: settings.seo_default_twitter_handle ?? undefined,
|
||||
wechatShareQrEnabled: Boolean(settings.seo_wechat_share_qr_enabled),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
276
frontend/src/lib/seo.ts
Normal file
276
frontend/src/lib/seo.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
import type { Post, SiteSettings } from './types';
|
||||
import { buildCategoryUrl, buildTagUrl } from './utils';
|
||||
|
||||
export interface ArticleFaqItem {
|
||||
question: string;
|
||||
answer: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryFaqOptions {
|
||||
locale: string;
|
||||
pageTitle: string;
|
||||
summary: string;
|
||||
primaryUrl: string;
|
||||
primaryLabel: string;
|
||||
relatedLinks?: Array<{
|
||||
label: string;
|
||||
url: string;
|
||||
}>;
|
||||
signals?: string[];
|
||||
}
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
export function stripMarkdown(value: string): string {
|
||||
return normalizeWhitespace(
|
||||
value
|
||||
.replace(/^---[\s\S]*?---/, ' ')
|
||||
.replace(/!\[[^\]]*]\([^)]*\)/g, ' ')
|
||||
.replace(/\[([^\]]+)]\([^)]*\)/g, '$1')
|
||||
.replace(/`{1,3}[^`]*`{1,3}/g, ' ')
|
||||
.replace(/^#{1,6}\s+/gm, '')
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*\d+\.\s+/gm, '')
|
||||
.replace(/[>*_~|]/g, ' ')
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(value: string, maxLength: number): string {
|
||||
if (value.length <= maxLength) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
|
||||
}
|
||||
|
||||
function uniqueNonEmpty(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
|
||||
return values.filter((value) => {
|
||||
const normalized = normalizeWhitespace(value).toLowerCase();
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
seen.add(normalized);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function splitMarkdownParagraphs(value: string): string[] {
|
||||
return value
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((item) => stripMarkdown(item))
|
||||
.map((item) => item.replace(/^#\s+/, '').trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function splitSentences(value: string): string[] {
|
||||
return value
|
||||
.split(/(?<=[。!?!?;;])\s*|(?<=\.)\s+/)
|
||||
.map((item) => normalizeWhitespace(item))
|
||||
.filter((item) => item.length >= 8);
|
||||
}
|
||||
|
||||
export function buildArticleSynopsis(
|
||||
post: Pick<Post, 'title' | 'description' | 'content'>,
|
||||
maxLength = 220,
|
||||
): string {
|
||||
const contentParagraphs = splitMarkdownParagraphs(post.content || '').filter(
|
||||
(item) => normalizeWhitespace(item) !== normalizeWhitespace(post.title),
|
||||
);
|
||||
const parts = uniqueNonEmpty([post.description, ...contentParagraphs].filter(Boolean));
|
||||
|
||||
return truncate(parts.join(' '), maxLength);
|
||||
}
|
||||
|
||||
export function buildArticleHighlights(
|
||||
post: Pick<Post, 'title' | 'description' | 'content'>,
|
||||
limit = 3,
|
||||
): string[] {
|
||||
const paragraphs = splitMarkdownParagraphs(post.content || '').filter(
|
||||
(item) => normalizeWhitespace(item) !== normalizeWhitespace(post.title),
|
||||
);
|
||||
const sentences = uniqueNonEmpty(
|
||||
[post.description, ...paragraphs]
|
||||
.filter(Boolean)
|
||||
.flatMap((item) => splitSentences(item || '')),
|
||||
);
|
||||
|
||||
return sentences.slice(0, limit).map((item) => truncate(item, 88));
|
||||
}
|
||||
|
||||
export function resolvePostUpdatedAt(post: Pick<Post, 'updatedAt' | 'publishAt' | 'createdAt' | 'date'>): string {
|
||||
return post.updatedAt || post.publishAt || post.createdAt || post.date;
|
||||
}
|
||||
|
||||
export function buildArticleFaqs(
|
||||
post: Pick<Post, 'title' | 'category' | 'tags'>,
|
||||
options: {
|
||||
locale: string;
|
||||
summary: string;
|
||||
readTimeMinutes: number;
|
||||
},
|
||||
): ArticleFaqItem[] {
|
||||
const isEnglish = options.locale.startsWith('en');
|
||||
const tags = post.tags.slice(0, 5);
|
||||
const keywordList = tags.length ? tags.join(isEnglish ? ', ' : '、') : post.category;
|
||||
const categoryUrl = buildCategoryUrl(post.category);
|
||||
const tagUrls = tags.slice(0, 2).map((tag) => buildTagUrl(tag));
|
||||
|
||||
const items = isEnglish
|
||||
? [
|
||||
{
|
||||
question: `What is "${post.title}" mainly about?`,
|
||||
answer: options.summary,
|
||||
},
|
||||
{
|
||||
question: 'What keywords or topics appear in this page?',
|
||||
answer: `This page belongs to ${post.category} and highlights ${keywordList}. Estimated reading time is about ${Math.max(
|
||||
options.readTimeMinutes,
|
||||
1,
|
||||
)} minute(s).`,
|
||||
},
|
||||
{
|
||||
question: 'Where should I continue if I want related content?',
|
||||
answer: `Start with the category page ${categoryUrl} and then continue with related tags ${tagUrls.join(
|
||||
', ',
|
||||
) || buildTagUrl('')}.`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
question: `《${post.title}》主要讲什么?`,
|
||||
answer: options.summary,
|
||||
},
|
||||
{
|
||||
question: '这页内容涉及哪些关键词?',
|
||||
answer: `这篇内容归档在「${post.category}」,重点关键词包括 ${keywordList},预计阅读时间约 ${Math.max(
|
||||
options.readTimeMinutes,
|
||||
1,
|
||||
)} 分钟。`,
|
||||
},
|
||||
{
|
||||
question: '如果想继续阅读相关内容,应该从哪里开始?',
|
||||
answer: `建议先看分类页 ${categoryUrl},再继续浏览相关标签 ${tagUrls.join('、') || buildTagUrl('')}。`,
|
||||
},
|
||||
];
|
||||
|
||||
return items.map((item) => ({
|
||||
question: truncate(item.question, 120),
|
||||
answer: truncate(item.answer, 320),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildDiscoveryHighlights(values: string[], limit = 3, maxLength = 96): string[] {
|
||||
return uniqueNonEmpty(values.map((item) => normalizeWhitespace(item)).filter(Boolean))
|
||||
.slice(0, limit)
|
||||
.map((item) => truncate(item, maxLength));
|
||||
}
|
||||
|
||||
export function buildPageFaqs(options: DiscoveryFaqOptions): ArticleFaqItem[] {
|
||||
const isEnglish = options.locale.startsWith('en');
|
||||
const relatedLinks = (options.relatedLinks || []).slice(0, 3);
|
||||
const relatedText = relatedLinks
|
||||
.map((item) => `${item.label}: ${item.url}`)
|
||||
.join(isEnglish ? '; ' : ';');
|
||||
const signalText = uniqueNonEmpty(options.signals || []).join(isEnglish ? ', ' : '、');
|
||||
|
||||
const items = isEnglish
|
||||
? [
|
||||
{
|
||||
question: `What is the main purpose of "${options.pageTitle}"?`,
|
||||
answer: options.summary,
|
||||
},
|
||||
{
|
||||
question: 'Which URL should be cited as the canonical source?',
|
||||
answer: `Use ${options.primaryLabel} as the canonical page: ${options.primaryUrl}.`,
|
||||
},
|
||||
{
|
||||
question: 'Where should I continue for related information?',
|
||||
answer: relatedText || signalText
|
||||
? `Continue with ${relatedText || signalText}.`
|
||||
: `Continue from the canonical page ${options.primaryUrl}.`,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
question: `「${options.pageTitle}」这一页的核心作用是什么?`,
|
||||
answer: options.summary,
|
||||
},
|
||||
{
|
||||
question: '这一页应该引用哪个规范地址?',
|
||||
answer: `优先引用 ${options.primaryLabel} 的规范地址:${options.primaryUrl}。`,
|
||||
},
|
||||
{
|
||||
question: '如果要继续浏览相关内容,应该看哪里?',
|
||||
answer: relatedText || signalText
|
||||
? `建议继续查看:${relatedText || signalText}。`
|
||||
: `建议从这个规范页继续展开:${options.primaryUrl}。`,
|
||||
},
|
||||
];
|
||||
|
||||
return items.map((item) => ({
|
||||
question: truncate(item.question, 120),
|
||||
answer: truncate(item.answer, 320),
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildFaqJsonLd(faqs: ArticleFaqItem[]) {
|
||||
if (!faqs.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'FAQPage',
|
||||
mainEntity: faqs.map((item) => ({
|
||||
'@type': 'Question',
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
'@type': 'Answer',
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildPostItemList(posts: Post[], siteUrl: string) {
|
||||
return posts.map((post, index) => ({
|
||||
'@type': 'ListItem',
|
||||
position: index + 1,
|
||||
url: new URL(`/articles/${post.slug}`, siteUrl).toString(),
|
||||
name: post.title,
|
||||
description: post.description,
|
||||
}));
|
||||
}
|
||||
|
||||
export function buildPageTrackerLabel(referrer: string | null | undefined): string {
|
||||
const source = normalizeWhitespace(referrer || '').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;
|
||||
}
|
||||
|
||||
export function buildSiteTopicSummary(siteSettings: SiteSettings): string[] {
|
||||
return uniqueNonEmpty([
|
||||
siteSettings.siteDescription,
|
||||
siteSettings.heroSubtitle,
|
||||
siteSettings.ownerBio,
|
||||
...siteSettings.techStack.slice(0, 6).map((item) => `${siteSettings.siteName} covers ${item}`),
|
||||
]).slice(0, 4);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export interface Post {
|
||||
description: string;
|
||||
content?: string;
|
||||
date: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
readTime: string;
|
||||
type: 'article' | 'tweet';
|
||||
tags: string[];
|
||||
@@ -105,6 +107,7 @@ export interface SiteSettings {
|
||||
seo: {
|
||||
defaultOgImage?: string;
|
||||
defaultTwitterHandle?: string;
|
||||
wechatShareQrEnabled: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user