Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ BASE_PATH=/MyBlogDoc/ pnpm preview

离线搜索在页面加载后利用空闲时间自动准备完整索引与所需静态资源,不显示下载按钮。后台任务使用低优先级请求,搜索输入、隐藏标签页、节省流量和慢速网络会让任务延后。准备完成后,可以断网搜索新关键词、筛选和分页;已访问的文章可离线阅读。后台更新失败保留原完整缓存,缓存只属于当前博客路径。[方案比较、构建与验收说明](docs/offline-search.md)。

## 公众号与文章评论

中文界面的首页、文章末尾和联系页提供「泯泷ML」公众号关注卡;社交栏、页脚和手机菜单提供入口。二维码使用原图,支持微信扫码、保存图片和复制公众号名称。公众号名称与图片路径统一维护在 `src/config/site.ts`。

公开且非空的文章使用 [Giscus](https://giscus.app/) 接入本仓库的 GitHub Discussions,访客使用 GitHub 登录留言,无需配置 Token。中英文界面按文章源路径共享讨论,评论区按需加载并跟随明暗主题;评论服务失败时可以重试或前往 GitHub 交流。[配置和映射说明](docs/comments.md)。

## SEO 与 GEO

站点输出 Blog、Person、BlogPosting 和可见面包屑对应的 JSON-LD,补齐 OG/Twitter 分享图、可信发布时间与更新时间、原文作者与来源信息,以及 sitemap 的 lastmod。未知的发布时间不会用构建时间补造。英文界面共享原文,因此文章语言标注遵循正文语言。
Expand Down
25 changes: 25 additions & 0 deletions docs/comments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# GitHub Discussions 评论

文章评论使用 [Giscus](https://giscus.app/),数据保存在公开仓库 `MoMeak9/MyBlogDoc` 的 GitHub Discussions 中。访客通过 GitHub 登录留言,不需要在博客中配置 GitHub Token。

## 启用

1. 在仓库 **Settings → General → Features** 开启 **Discussions**。
2. 安装 [Giscus GitHub App](https://github.com/apps/giscus),允许它访问 `MyBlogDoc`。
3. 选择 **Announcements** 类型的讨论分类,并将真实的分类名和 GraphQL 分类 ID 写入 `src/config/site.ts` 的 `comments` 配置。仓库 ID 已填入真实值;分类 ID 为空时保留 GitHub 交流入口,不请求 Giscus。

读取仓库与分类 ID:

```sh
gh api graphql -f query='query { repository(owner:"MoMeak9", name:"MyBlogDoc") { id discussionCategories(first:25) { nodes { id name slug } } } }'
```

启用后运行 `pnpm check`、`pnpm build`、`pnpm test:build`,将构建部署到站点。浏览器需要访问 `giscus.app`,登录需要 GitHub;博客本身没有评论服务器。

## 映射与行为

- 只在公开且非空的文章页面显示评论,位置在正文和公众号关注卡之后,不进入文章搜索索引。
- 使用 `specific` 映射,讨论标识为 Markdown 文章的 `post.id`;中英文界面、项目部署前缀与自定义域名共享同一讨论。修改文章标题不会改变讨论;移动文章源文件会改变标识,需要单独迁移已有讨论。
- `strict` 开启,避免相似文章名匹配到其他讨论。尚无评论时的 `Discussion not found` 是正常状态,首次留言或表情会由 Giscus 创建讨论。
- 接近评论区时才加载;明暗主题切换时同步 iframe。加载失败提供重试与 GitHub 交流入口,收到当前 iframe 的讨论元数据后将入口指向对应线程。
- GitHub App 安装状态不能仅凭博客构建判断;需在部署后的页面核验评论显示与 GitHub 登录,且不要把未实际提交的评论描述为已验证。
Binary file added public/images/wechat-ml-qr.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions src/components/ArticlePage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import BaseLayout from "../layouts/BaseLayout.astro";
import PostCard from "./PostCard.astro";
import Icon from "./Icon.astro";
import Diagrams from "./Diagrams.astro";
import WeChatFollow from "./WeChatFollow.astro";
import Comments from "./Comments.astro";
import { ui, dateLabel, type Locale } from "../i18n";
import { getPosts, renderPost, type Post } from "../lib/content";
import { localePath, normalizeBase, postPath } from "../lib/paths.mjs";
Expand Down Expand Up @@ -163,6 +165,8 @@ const editUrl = `https://github.com/MoMeak9/MyBlogDoc/edit/main/src/${post.id.sp
</ul>
</aside>
)}
{locale === "zh" && <WeChatFollow compact />}
{post.indexable && !post.empty && <Comments postId={post.id} locale={locale} />}
</div>
{toc.length > 0 && (
<aside class="toc">
Expand Down
171 changes: 171 additions & 0 deletions src/components/Comments.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
import { site } from "../config/site";
import { type Locale } from "../i18n";
import Icon from "./Icon.astro";

interface Props {
postId: string;
locale?: Locale;
}
const { postId, locale = "zh" } = Astro.props;
const english = locale === "en";
const enabled = Boolean(site.comments.repoId && site.comments.categoryId);
const t = english
? {
heading: "Join the conversation",
description: "Sign in with GitHub to comment. Both language versions share this conversation.",
github: "Discuss on GitHub",
load: "Load comments",
loading: "Loading comments…",
failed: "Comments could not be loaded. Retry or continue on GitHub.",
retry: "Retry loading comments",
unavailable: "Comments are not available yet.",
}
: {
heading: "一起聊聊",
description: "使用 GitHub 登录后即可留言,中英文界面共享同一篇文章的讨论。",
github: "前往 GitHub 交流",
load: "加载评论",
loading: "正在加载评论…",
failed: "评论暂时无法加载,可重试或前往 GitHub 交流。",
retry: "重试加载评论",
unavailable: "评论区暂未开放。",
};
const discussionUrl = enabled
? `${site.repositoryUrl}/discussions?discussions_q=${encodeURIComponent(`"${postId}"`)}`
: site.repositoryUrl;
---

<section
id="comments"
class="article-comments"
aria-labelledby="comments-heading"
data-pagefind-ignore
data-comments-enabled={String(enabled)}
data-repo={site.comments.repo}
data-repo-id={site.comments.repoId}
data-category={site.comments.category}
data-category-id={site.comments.categoryId}
data-term={postId}
data-lang={english ? "en" : "zh-CN"}
data-loading={t.loading}
data-failed={t.failed}
data-retry={t.retry}
>
<div class="comments-heading">
<h2 id="comments-heading">{t.heading}</h2>
<a href={discussionUrl} target="_blank" rel="noopener noreferrer">
{t.github}<Icon name="github" size={16} />
</a>
</div>
<p class="comments-description">{t.description}</p>
<p class="comments-status" role="status" aria-live="polite">
{enabled ? "" : t.unavailable}
</p>
{enabled && <button class="button button-outline" type="button" data-load-comments>{t.load}</button>}
<div class="giscus"></div>
</section>

<script>
const section = document.querySelector<HTMLElement>('[data-comments-enabled="true"]');
if (section) {
const container = section.querySelector<HTMLElement>(".giscus")!;
const status = section.querySelector<HTMLElement>(".comments-status")!;
const button = section.querySelector<HTMLButtonElement>("[data-load-comments]")!;
const origin = "https://giscus.app";
let state: "idle" | "loading" | "ready" | "failed" = "idle";
let timeout: ReturnType<typeof setTimeout>;
let script: HTMLScriptElement | undefined;
const frame = () => container.querySelector<HTMLIFrameElement>("iframe.giscus-frame");
const theme = () => document.documentElement.classList.contains("dark") ? "dark" : "light";
const syncTheme = () => frame()?.contentWindow?.postMessage({ giscus: { setConfig: { theme: theme() } } }, origin);

const fail = () => {
clearTimeout(timeout);
state = "failed";
script?.remove();
container.replaceChildren();
status.textContent = section.dataset.failed!;
button.textContent = section.dataset.retry!;
button.hidden = false;
};
const load = () => {
if (state === "loading" || state === "ready") return;
state = "loading";
status.textContent = section.dataset.loading!;
button.hidden = true;
script = document.createElement("script");
script.src = `${origin}/client.js`;
script.async = true;
script.crossOrigin = "anonymous";
Object.assign(script.dataset, {
repo: section.dataset.repo,
repoId: section.dataset.repoId,
category: section.dataset.category,
categoryId: section.dataset.categoryId,
mapping: "specific",
term: section.dataset.term,
strict: "1",
reactionsEnabled: "1",
emitMetadata: "1",
inputPosition: "top",
theme: theme(),
lang: section.dataset.lang,
loading: "lazy",
});
script.addEventListener("error", fail, { once: true });
timeout = setTimeout(fail, 15000);
container.appendChild(script);
};
button.addEventListener("click", load);
if ("IntersectionObserver" in window) {
const observer = new IntersectionObserver((entries) => {
if (!entries.some((entry) => entry.isIntersecting)) return;
observer.disconnect();
load();
}, { rootMargin: "200px 0px" });
observer.observe(section);
} else {
load();
}
new MutationObserver(syncTheme).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
new MutationObserver(() => frame()?.addEventListener("load", syncTheme, { once: true })).observe(container, { childList: true });
window.addEventListener("message", (event) => {
if (event.origin !== origin || !frame() || event.source !== frame()!.contentWindow) return;
const payload = event.data?.giscus;
if (!payload || typeof payload !== "object") return;
if (typeof payload.error === "string" && !payload.error.includes("Discussion not found")) {
fail();
return;
}
if (typeof payload.resizeHeight === "number" && payload.resizeHeight > 0) {
clearTimeout(timeout);
state = "ready";
status.textContent = "";
button.hidden = true;
}
if (payload.discussion?.url) {
try {
const url = new URL(payload.discussion.url);
if (url.origin === "https://github.com" && url.pathname.startsWith(`/${section.dataset.repo}/discussions/`)) {
section.querySelector<HTMLAnchorElement>(".comments-heading a")!.href = url.href;
}
} catch {}
}
});
}
</script>

<style>
.article-comments { margin-top: 48px; border-top: 1px solid var(--border); padding-top: 32px; min-width: 0; }
.comments-heading { display: flex; align-items: center; justify-content: space-between; gap: 20px; flex-wrap: wrap; }
.comments-heading h2 { font-size: 28px; }
.comments-heading a { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; color: var(--muted); font-size: 13px; }
.comments-heading a:focus-visible, .article-comments button:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
.comments-description, .comments-status { color: var(--muted); font-size: 13px; margin-top: 12px; }
.comments-status:empty { display: none; }
.article-comments button { margin-top: 16px; }
.article-comments button[hidden] { display: none; }
.giscus { margin-top: 24px; }
@media (max-width: 767px) { .comments-heading { gap: 8px; } .comments-heading h2 { font-size: 24px; } }
</style>
10 changes: 10 additions & 0 deletions src/components/ContactPage.astro
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
---
import BaseLayout from "../layouts/BaseLayout.astro";
import Icon from "./Icon.astro";
import WeChatFollow from "./WeChatFollow.astro";
import { site } from "../config/site";
import { ui, type Locale } from "../i18n";
interface Props {
Expand All @@ -19,6 +20,11 @@ const t = ui(locale);
</div>
</section>
<div class="container contact-grid">
{locale === "zh" && (
<div class="wechat-contact">
<WeChatFollow id="wechat" />
</div>
)}
<div class="contact-card">
<span class="section-label">01 / {t.email}</span>
<h2>{t.emailTitle}</h2>
Expand Down Expand Up @@ -78,3 +84,7 @@ const t = ui(locale);
</div>
</div>
</BaseLayout>

<style>
.wechat-contact { grid-column: 1 / -1; min-width: 0; }
</style>
8 changes: 8 additions & 0 deletions src/components/HomePage.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import PostCard from "./PostCard.astro";
import Icon from "./Icon.astro";
import HeroArtwork from "./HeroArtwork.astro";
import SocialLinks from "./SocialLinks.astro";
import WeChatFollow from "./WeChatFollow.astro";
import { ui, type Locale } from "../i18n";
import { getPosts } from "../lib/content";
import { localePath, normalizeBase } from "../lib/paths.mjs";
Expand Down Expand Up @@ -223,6 +224,13 @@ const principles = [
</div>
</div>
</section>
{locale === "zh" && (
<section class="section wechat-section">
<div class="container">
<WeChatFollow />
</div>
</section>
)}
<section class="cta-section">
<div class="container cta-inner" data-animate="fade-up">
<div>
Expand Down
3 changes: 3 additions & 0 deletions src/components/Icon.astro
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ interface Props {
| "rss"
| "github"
| "email"
| "wechat"
| "bilibili";
size?: number;
}
Expand All @@ -26,6 +27,8 @@ const paths = {
"M9 19c-4 1-4-2-6-2m12 5v-3.4a3 3 0 0 0-.8-2.3c2.7-.3 5.5-1.3 5.5-6A4.7 4.7 0 0 0 18.4 7a4.3 4.3 0 0 0-.1-3.2s-1-.3-3.3 1.2a11.6 11.6 0 0 0-6 0C6.7 3.5 5.7 3.8 5.7 3.8A4.3 4.3 0 0 0 5.6 7a4.7 4.7 0 0 0-1.3 3.3c0 4.7 2.8 5.7 5.5 6a3 3 0 0 0-.8 2.3V22",
email:
"M3 7l9 6 9-6M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Z",
wechat:
"M14 13.5c0 3-2.7 5.5-6 5.5H5l-3 2 1-3c-1.2-1-2-2.6-2-4.5C1 10.5 3.7 8 7 8s7 2.5 7 5.5ZM11 8c.6-2.3 2.9-4 5.5-4C20.1 4 23 6.5 23 9.5c0 1.6-.7 3.1-1.9 4.1L22 17l-3-2h-2",
bilibili:
"m8 3 3 3m5-3-3 3M5 6h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2ZM8 11v3m8-3v3m-6 3h4",
};
Expand Down
24 changes: 24 additions & 0 deletions src/components/SocialLinks.astro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import Icon from "./Icon.astro";
import { site } from "../config/site";
import { ui, type Locale } from "../i18n";
import { localePath, normalizeBase } from "../lib/paths.mjs";

interface Props {
locale?: Locale;
Expand All @@ -10,13 +11,25 @@ interface Props {
}
const { locale = "zh", variant = "icons", class: className } = Astro.props;
const t = ui(locale);
const wechatHref = `${localePath("contact", "zh", normalizeBase(import.meta.env.BASE_URL))}#wechat`;
---

<div
class:list={["social-links", `social-links--${variant}`, className]}
role="group"
aria-label={t.socialLinks}
>
{locale === "zh" && (
<a
class="social-link social-link--wechat"
href={wechatHref}
aria-label={`关注微信公众号:${site.wechat.name}`}
title={`关注微信公众号:${site.wechat.name}`}
>
<Icon name="wechat" size={18} />
<span>公众号</span>
</a>
)}
{Object.values(site.socials).map((social) => (
<a
class="social-link"
Expand Down Expand Up @@ -65,6 +78,14 @@ const t = ui(locale);
width: 40px;
height: 40px;
}
.social-links--icons .social-link--wechat {
width: auto;
min-width: 96px;
gap: 8px;
padding-inline: 12px;
font-size: 13px;
color: var(--text);
}
.social-links--text {
gap: 10px;
}
Expand All @@ -88,6 +109,9 @@ const t = ui(locale);
width: 44px;
height: 44px;
}
.social-links--icons .social-link--wechat {
width: auto;
}
.social-links--text .social-link {
min-height: 44px;
}
Expand Down
Loading
Loading