feat: add SharePanel component for social sharing with QR code support
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

- 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:
2026-04-02 14:15:21 +08:00
parent a516be2e91
commit 3628a46ed1
53 changed files with 4390 additions and 91 deletions

View File

@@ -1,5 +1,8 @@
FROM rust:1.94-trixie AS chef
RUN cargo install cargo-chef --locked
# syntax=docker/dockerfile:1.7
FROM rust:1.94.1-trixie AS chef
RUN rustup component add rustfmt clippy \
&& cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
@@ -7,11 +10,20 @@ COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
ENV CARGO_HOME=/usr/local/cargo \
CARGO_TARGET_DIR=/app/.cargo-target
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --locked --recipe-path recipe.json
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
cargo chef cook --release --locked --recipe-path recipe.json
COPY . .
RUN cargo build --release --locked --bin termi_api-cli
RUN --mount=type=cache,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,target=/usr/local/cargo/git/db,sharing=locked \
--mount=type=cache,target=/app/.cargo-target,sharing=locked \
cargo build --release --locked --bin termi_api-cli \
&& install -Dm755 /app/.cargo-target/release/termi_api-cli /tmp/termi_api-cli
FROM debian:trixie-slim AS runtime
RUN apt-get update \
@@ -19,7 +31,7 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=builder /app/target/release/termi_api-cli /usr/local/bin/termi_api-cli
COPY --from=builder /tmp/termi_api-cli /usr/local/bin/termi_api-cli
COPY --from=builder /app/config ./config
COPY --from=builder /app/assets ./assets
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh

View File

@@ -44,6 +44,7 @@ mod m20260401_000033_add_taxonomy_metadata_and_media_assets;
mod m20260401_000034_add_source_markdown_to_posts;
mod m20260401_000035_add_human_verification_modes_to_site_settings;
mod m20260402_000036_create_worker_jobs;
mod m20260402_000037_add_wechat_share_qr_setting_to_site_settings;
pub struct Migrator;
#[async_trait::async_trait]
@@ -92,6 +93,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260401_000034_add_source_markdown_to_posts::Migration),
Box::new(m20260401_000035_add_human_verification_modes_to_site_settings::Migration),
Box::new(m20260402_000036_create_worker_jobs::Migration),
Box::new(m20260402_000037_add_wechat_share_qr_setting_to_site_settings::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,52 @@
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if !manager
.has_column("site_settings", "seo_wechat_share_qr_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table.clone())
.add_column(
ColumnDef::new(Alias::new("seo_wechat_share_qr_enabled"))
.boolean()
.null()
.default(false),
)
.to_owned(),
)
.await?;
}
Ok(())
}
async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
let table = Alias::new("site_settings");
if manager
.has_column("site_settings", "seo_wechat_share_qr_enabled")
.await?
{
manager
.alter_table(
Table::alter()
.table(table)
.drop_column(Alias::new("seo_wechat_share_qr_enabled"))
.to_owned(),
)
.await?;
}
Ok(())
}
}

View File

@@ -208,6 +208,7 @@ pub struct AdminSiteSettingsResponse {
pub media_r2_secret_access_key: Option<String>,
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: bool,
pub notification_webhook_url: Option<String>,
pub notification_channel_type: String,
pub notification_comment_enabled: bool,
@@ -827,6 +828,7 @@ fn build_settings_response(
media_r2_secret_access_key: item.media_r2_secret_access_key,
seo_default_og_image: item.seo_default_og_image,
seo_default_twitter_handle: item.seo_default_twitter_handle,
seo_wechat_share_qr_enabled: item.seo_wechat_share_qr_enabled.unwrap_or(false),
notification_webhook_url: item.notification_webhook_url,
notification_channel_type: item
.notification_channel_type

View File

@@ -157,6 +157,8 @@ pub struct SiteSettingsPayload {
pub seo_default_og_image: Option<String>,
#[serde(default, alias = "seoDefaultTwitterHandle")]
pub seo_default_twitter_handle: Option<String>,
#[serde(default, alias = "seoWechatShareQrEnabled")]
pub seo_wechat_share_qr_enabled: Option<bool>,
#[serde(default, alias = "notificationWebhookUrl")]
pub notification_webhook_url: Option<String>,
#[serde(default, alias = "notificationChannelType")]
@@ -212,6 +214,7 @@ pub struct PublicSiteSettingsResponse {
pub subscription_popup_delay_seconds: i32,
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: bool,
}
#[derive(Clone, Debug, Serialize)]
@@ -693,6 +696,9 @@ impl SiteSettingsPayload {
item.seo_default_twitter_handle =
normalize_optional_string(Some(seo_default_twitter_handle));
}
if let Some(seo_wechat_share_qr_enabled) = self.seo_wechat_share_qr_enabled {
item.seo_wechat_share_qr_enabled = Some(seo_wechat_share_qr_enabled);
}
if let Some(notification_webhook_url) = self.notification_webhook_url {
item.notification_webhook_url =
normalize_optional_string(Some(notification_webhook_url));
@@ -848,6 +854,7 @@ fn default_payload() -> SiteSettingsPayload {
media_r2_secret_access_key: None,
seo_default_og_image: None,
seo_default_twitter_handle: None,
seo_wechat_share_qr_enabled: Some(false),
notification_webhook_url: None,
notification_channel_type: Some("webhook".to_string()),
notification_comment_enabled: Some(false),
@@ -945,6 +952,7 @@ fn public_response(model: Model) -> PublicSiteSettingsResponse {
.unwrap_or_else(default_subscription_popup_delay_seconds),
seo_default_og_image: model.seo_default_og_image,
seo_default_twitter_handle: model.seo_default_twitter_handle,
seo_wechat_share_qr_enabled: model.seo_wechat_share_qr_enabled.unwrap_or(false),
}
}

View File

@@ -76,6 +76,7 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub seo_default_og_image: Option<String>,
pub seo_default_twitter_handle: Option<String>,
pub seo_wechat_share_qr_enabled: Option<bool>,
#[sea_orm(column_type = "Text", nullable)]
pub notification_webhook_url: Option<String>,
pub notification_channel_type: Option<String>,

View File

@@ -140,6 +140,8 @@ pub struct AdminAnalyticsResponse {
pub recent_events: Vec<AnalyticsRecentEvent>,
pub providers_last_7d: Vec<AnalyticsProviderBucket>,
pub top_referrers: Vec<AnalyticsReferrerBucket>,
pub ai_referrers_last_7d: Vec<AnalyticsReferrerBucket>,
pub ai_discovery_page_views_last_7d: u64,
pub popular_posts: Vec<AnalyticsPopularPost>,
pub daily_activity: Vec<AnalyticsDailyBucket>,
}
@@ -197,16 +199,112 @@ fn format_timestamp(value: DateTime<Utc>) -> String {
value.format("%Y-%m-%d %H:%M").to_string()
}
fn normalize_referrer_source(value: Option<String>) -> String {
fn metadata_string(metadata: Option<&serde_json::Value>, key: &str) -> Option<String> {
metadata
.and_then(|value| value.get(key))
.and_then(|value| value.as_str())
.map(ToString::to_string)
.and_then(|value| trim_to_option(Some(value)))
}
fn parse_path_query_value(path: Option<&str>, key: &str) -> Option<String> {
let path = path.and_then(|value| trim_to_option(Some(value.to_string())))?;
let synthetic_url = if path.starts_with("http://") || path.starts_with("https://") {
path
} else if path.starts_with('/') {
format!("https://local.test{path}")
} else {
format!("https://local.test/{path}")
};
reqwest::Url::parse(&synthetic_url)
.ok()
.and_then(|url| {
url.query_pairs()
.find(|(item_key, _)| item_key == key)
.map(|(_, value)| value.to_string())
})
.and_then(|value| trim_to_option(Some(value)))
}
fn normalize_tracking_source_token(value: Option<String>) -> String {
let Some(value) = trim_to_option(value) else {
return "direct".to_string();
};
reqwest::Url::parse(&value)
let normalized = reqwest::Url::parse(&value)
.ok()
.and_then(|url| url.host_str().map(ToString::to_string))
.filter(|item| !item.trim().is_empty())
.unwrap_or(value)
.trim()
.to_ascii_lowercase();
match normalized.as_str() {
"direct" => "direct".to_string(),
value if value.contains("chatgpt") || value.contains("openai") => {
"chatgpt-search".to_string()
}
value if value.contains("perplexity") => "perplexity".to_string(),
value if value.contains("copilot") || value.contains("bing") => {
"copilot-bing".to_string()
}
value if value.contains("gemini") => "gemini".to_string(),
value if value.contains("google") => "google".to_string(),
value if value.contains("claude") => "claude".to_string(),
value if value.contains("duckduckgo") => "duckduckgo".to_string(),
value if value.contains("kagi") => "kagi".to_string(),
_ => normalized,
}
}
fn normalize_tracking_source(
path: Option<&str>,
referrer: Option<String>,
metadata: Option<&serde_json::Value>,
) -> String {
let preferred = metadata_string(metadata, "landingSource")
.or_else(|| metadata_string(metadata, "landing_source"))
.or_else(|| metadata_string(metadata, "utmSource"))
.or_else(|| metadata_string(metadata, "utm_source"))
.or_else(|| parse_path_query_value(path, "utm_source"))
.or_else(|| metadata_string(metadata, "referrerHost"))
.or_else(|| referrer);
normalize_tracking_source_token(preferred)
}
fn is_ai_discovery_source(value: &str) -> bool {
matches!(
value,
"chatgpt-search" | "perplexity" | "copilot-bing" | "gemini" | "claude"
)
}
fn sorted_referrer_buckets(
breakdown: &HashMap<String, u64>,
predicate: impl Fn(&str) -> bool,
limit: usize,
) -> Vec<AnalyticsReferrerBucket> {
let mut items = breakdown
.iter()
.filter_map(|(referrer, count)| {
predicate(referrer)
.then(|| AnalyticsReferrerBucket {
referrer: referrer.clone(),
count: *count,
})
})
.collect::<Vec<_>>();
items.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.referrer.cmp(&right.referrer))
});
items.truncate(limit);
items
}
fn header_value(headers: &HeaderMap, key: &str) -> Option<String> {
@@ -550,7 +648,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
page_views_last_24h += 1;
}
let referrer = normalize_referrer_source(event.referrer.clone());
let referrer =
normalize_tracking_source(Some(&event.path), event.referrer.clone(), event.metadata.as_ref());
*referrer_breakdown.entry(referrer).or_insert(0) += 1;
}
@@ -637,17 +736,13 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
});
providers_last_7d.truncate(6);
let mut top_referrers = referrer_breakdown
.into_iter()
.map(|(referrer, count)| AnalyticsReferrerBucket { referrer, count })
.collect::<Vec<_>>();
top_referrers.sort_by(|left, right| {
right
.count
.cmp(&left.count)
.then_with(|| left.referrer.cmp(&right.referrer))
});
top_referrers.truncate(8);
let top_referrers = sorted_referrer_buckets(&referrer_breakdown, |_| true, 8);
let ai_referrers_last_7d = sorted_referrer_buckets(&referrer_breakdown, is_ai_discovery_source, 6);
let ai_discovery_page_views_last_7d = referrer_breakdown
.iter()
.filter(|(referrer, _)| is_ai_discovery_source(referrer))
.map(|(_, count)| *count)
.sum::<u64>();
let mut popular_posts = post_breakdown
.into_iter()
@@ -748,6 +843,8 @@ pub async fn build_admin_analytics(ctx: &AppContext) -> Result<AdminAnalyticsRes
recent_events,
providers_last_7d,
top_referrers,
ai_referrers_last_7d,
ai_discovery_page_views_last_7d,
popular_posts,
daily_activity,
})