feat: 文章正文图片支持灯箱;删除未使用的 GitHub Card 插件

灯箱
- 相册页原本已有一套无依赖灯箱(事件委托 + ClientRouter 友好),
  但它写在 gallery/[album].astro 里,只有相册页能用。
  现抽成 src/components/ImageLightbox.astro,挂到 BaseLayout,全站可用。
- 新增 src/plugins/rehype-image-lightbox.mjs:给正文里的 <img> 注入
  data-lightbox-src。跳过链接内的图片(点击应跳转)和显式标记
  data-lightbox-ignore 的图片。
- 顺带让灯箱大图继承原图的 alt 文本(原先固定为空)。
- 正文图片加 cursor: zoom-in 提示。

清理
- 删除 src/plugins/rehype-component-github-card.mjs(165 行)及其在
  rehypeComponents 里的 github 映射:内容里从未使用过 ::github 指令。

验证:构建 41 页通过;文章页正文图片 4/4 带上 data-lightbox-src,
相册页 5 个触发元素不受影响;灯箱脚本按页内联(2.8KB 压缩后);
正文渲染文本与线上基线一致,无回归。
This commit is contained in:
2026-09-17 17:29:23 +08:00
parent 738fe3175e
commit c1643ae95d
6 changed files with 181 additions and 279 deletions
+2 -2
View File
@@ -15,13 +15,13 @@ import remarkMath from "remark-math"
import remarkSectionize from "remark-sectionize" import remarkSectionize from "remark-sectionize"
import { remarkShiftHeadings } from "./src/plugins/remark-shift-headings.mjs" import { remarkShiftHeadings } from "./src/plugins/remark-shift-headings.mjs"
import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs" import { AdmonitionComponent } from "./src/plugins/rehype-component-admonition.mjs"
import { GithubCardComponent } from "./src/plugins/rehype-component-github-card.mjs"
import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js" import { parseDirectiveNode } from "./src/plugins/remark-directive-rehype.js"
import { remarkExcerpt } from "./src/plugins/remark-excerpt.js" import { remarkExcerpt } from "./src/plugins/remark-excerpt.js"
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs" import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs"
import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge" import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge"
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button" import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button"
import { unified } from "@astrojs/markdown-remark" import { unified } from "@astrojs/markdown-remark"
import { rehypeImageLightbox } from "./src/plugins/rehype-image-lightbox.mjs"
import { site } from "./src/data/site" import { site } from "./src/data/site"
const createAdmonitionComponent = (type) => (properties = {}, children = []) => { const createAdmonitionComponent = (type) => (properties = {}, children = []) => {
@@ -106,7 +106,6 @@ export default defineConfig({
rehypeComponents, rehypeComponents,
{ {
components: { components: {
github: GithubCardComponent,
note: createAdmonitionComponent("note"), note: createAdmonitionComponent("note"),
tip: createAdmonitionComponent("tip"), tip: createAdmonitionComponent("tip"),
important: createAdmonitionComponent("important"), important: createAdmonitionComponent("important"),
@@ -138,6 +137,7 @@ export default defineConfig({
}, },
}, },
], ],
rehypeImageLightbox,
], ],
}), }),
}, },
+132
View File
@@ -0,0 +1,132 @@
---
/**
* 无依赖灯箱。
*
* 任何带 `data-lightbox-src` 的元素点击即可放大:
* - 相册页的 `<button data-lightbox-src>`
* - 文章正文里的 `<img data-lightbox-src>`(由 src/plugins/rehype-image-lightbox.mjs 注入)
*
* 用事件委托 + 每次打开时重新收集当前页的图片,所以 ClientRouter 切页后依然有效;
* 遮罩只在第一次点击时才创建,没点过就不占 DOM。
*/
---
<style is:global>
/* 正文里可放大的图片给出「可点击」提示 */
.prose img[data-lightbox-src] {
cursor: zoom-in;
}
</style>
<script>
const LB_ID = "vv-lightbox"
const ensureOverlay = (): HTMLElement => {
let overlay = document.getElementById(LB_ID)
if (overlay) return overlay
overlay = document.createElement("div")
overlay.id = LB_ID
overlay.setAttribute("role", "dialog")
overlay.setAttribute("aria-modal", "true")
overlay.className =
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
overlay.innerHTML = `
<button type="button" data-lb-close aria-label="关闭"
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
<button type="button" data-lb-prev aria-label="上一张"
class="absolute left-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:left-6">‹</button>
<button type="button" data-lb-next aria-label="下一张"
class="absolute right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:right-6">›</button>
<img data-lb-img src="" alt="" class="max-h-[88vh] max-w-[92vw] object-contain" />
<div data-lb-count
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 font-mono text-xs text-white/80"></div>
`
document.body.appendChild(overlay)
return overlay
}
let sources: string[] = []
let alts: string[] = []
let current = 0
const render = (overlay: HTMLElement) => {
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
const counter = overlay.querySelector<HTMLElement>("[data-lb-count]")
if (img) {
img.src = sources[current] ?? ""
img.alt = alts[current] ?? ""
}
if (counter) counter.textContent = `${current + 1} / ${sources.length}`
const single = sources.length < 2
for (const sel of ["[data-lb-prev]", "[data-lb-next]"]) {
const el = overlay.querySelector<HTMLElement>(sel)
if (el) el.style.display = single ? "none" : ""
}
}
const openAt = (index: number) => {
const overlay = ensureOverlay()
current = index
render(overlay)
overlay.classList.remove("hidden")
overlay.classList.add("flex")
document.body.style.overflow = "hidden"
}
const close = () => {
const overlay = document.getElementById(LB_ID)
if (!overlay) return
overlay.classList.add("hidden")
overlay.classList.remove("flex")
document.body.style.overflow = ""
}
const step = (delta: number) => {
if (sources.length < 2) return
current = (current + delta + sources.length) % sources.length
const overlay = document.getElementById(LB_ID)
if (overlay) render(overlay)
}
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) {
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true
document.addEventListener("click", (event) => {
const target = event.target as Element | null
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
if (trigger) {
// 每次打开都重新收集当前页的图片
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
alts = all.map((el) => (el instanceof HTMLImageElement ? el.alt : ""))
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
return
}
if (target?.closest("[data-lb-close]")) return close()
if (target?.closest("[data-lb-prev]")) return step(-1)
if (target?.closest("[data-lb-next]")) return step(1)
// 点背景关闭(点图片本身不关)
const overlay = document.getElementById(LB_ID)
if (overlay && target === overlay) close()
})
document.addEventListener("keydown", (event) => {
const overlay = document.getElementById(LB_ID)
if (!overlay || overlay.classList.contains("hidden")) return
if (event.key === "Escape") close()
if (event.key === "ArrowLeft") step(-1)
if (event.key === "ArrowRight") step(1)
})
// 切页时收拾干净,避免残留遮罩锁住滚动
document.addEventListener("astro:page-load", () => {
const overlay = document.getElementById(LB_ID)
if (overlay) overlay.remove()
document.body.style.overflow = ""
})
}
</script>
+2
View File
@@ -6,6 +6,7 @@ import "katex/dist/katex.min.css"
import "@/styles/global.css" import "@/styles/global.css"
import { ClientRouter } from "astro:transitions" import { ClientRouter } from "astro:transitions"
import { analytics, homeTitle, site } from "@/data/site" import { analytics, homeTitle, site } from "@/data/site"
import ImageLightbox from "@/components/ImageLightbox.astro"
interface Props { interface Props {
title?: string title?: string
@@ -213,6 +214,7 @@ const resolvedTwitterDescription = twitterDescription || description
</head> </head>
<body class:list={["bg-background text-foreground", { "font-profile-interface": useHarmonySans }]}> <body class:list={["bg-background text-foreground", { "font-profile-interface": useHarmonySans }]}>
<slot /> <slot />
<ImageLightbox />
<script is:inline> <script is:inline>
// View Transitions 页面切换后重应用主题 // View Transitions 页面切换后重应用主题
(function() { (function() {
-112
View File
@@ -122,115 +122,3 @@ const description = album.description ?? "相册"
break-inside: avoid; break-inside: avoid;
} }
</style> </style>
<script>
/**
* 无依赖灯箱。
* 用事件委托 + 每次打开时重新收集当前页的照片,因此 ClientRouter 切页后依然有效。
*/
const LB_ID = "vv-lightbox"
const ensureOverlay = (): HTMLElement => {
let overlay = document.getElementById(LB_ID)
if (overlay) return overlay
overlay = document.createElement("div")
overlay.id = LB_ID
overlay.setAttribute("role", "dialog")
overlay.setAttribute("aria-modal", "true")
overlay.className =
"fixed inset-0 z-[100] hidden items-center justify-center bg-black/90 backdrop-blur-sm"
overlay.innerHTML = `
<button type="button" data-lb-close aria-label="关闭"
class="absolute right-4 top-4 z-10 flex h-10 w-10 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20">×</button>
<button type="button" data-lb-prev aria-label="上一张"
class="absolute left-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:left-6">‹</button>
<button type="button" data-lb-next aria-label="下一张"
class="absolute right-3 z-10 flex h-11 w-11 items-center justify-center rounded-full bg-white/10 text-2xl leading-none text-white/90 transition-colors hover:bg-white/20 sm:right-6">›</button>
<img data-lb-img src="" alt="" class="max-h-[88vh] max-w-[92vw] object-contain" />
<div data-lb-count
class="absolute bottom-5 left-1/2 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 font-mono text-xs text-white/80"></div>
`
document.body.appendChild(overlay)
return overlay
}
let sources: string[] = []
let current = 0
const render = (overlay: HTMLElement) => {
const img = overlay.querySelector<HTMLImageElement>("[data-lb-img]")
const counter = overlay.querySelector<HTMLElement>("[data-lb-count]")
if (img) img.src = sources[current] ?? ""
if (counter) counter.textContent = `${current + 1} / ${sources.length}`
const single = sources.length < 2
for (const sel of ["[data-lb-prev]", "[data-lb-next]"]) {
const el = overlay.querySelector<HTMLElement>(sel)
if (el) el.style.display = single ? "none" : ""
}
}
const openAt = (index: number) => {
const overlay = ensureOverlay()
current = index
render(overlay)
overlay.classList.remove("hidden")
overlay.classList.add("flex")
document.body.style.overflow = "hidden"
}
const close = () => {
const overlay = document.getElementById(LB_ID)
if (!overlay) return
overlay.classList.add("hidden")
overlay.classList.remove("flex")
document.body.style.overflow = ""
}
const step = (delta: number) => {
if (sources.length < 2) return
current = (current + delta + sources.length) % sources.length
const overlay = document.getElementById(LB_ID)
if (overlay) render(overlay)
}
if (!(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound) {
;(window as unknown as { __vvLightboxBound?: boolean }).__vvLightboxBound = true
document.addEventListener("click", (event) => {
const target = event.target as Element | null
const trigger = target?.closest<HTMLElement>("[data-lightbox-src]")
if (trigger) {
// 每次打开都重新收集当前页的照片
const all = Array.from(document.querySelectorAll<HTMLElement>("[data-lightbox-src]"))
sources = all.map((el) => el.dataset.lightboxSrc ?? "")
openAt(Number(trigger.dataset.lightboxIndex ?? all.indexOf(trigger)))
return
}
if (target?.closest("[data-lb-close]")) return close()
if (target?.closest("[data-lb-prev]")) return step(-1)
if (target?.closest("[data-lb-next]")) return step(1)
// 点背景关闭(点图片本身不关)
const overlay = document.getElementById(LB_ID)
if (overlay && target === overlay) close()
})
document.addEventListener("keydown", (event) => {
const overlay = document.getElementById(LB_ID)
if (!overlay || overlay.classList.contains("hidden")) return
if (event.key === "Escape") close()
if (event.key === "ArrowLeft") step(-1)
if (event.key === "ArrowRight") step(1)
})
// 切页时收拾干净,避免残留遮罩锁住滚动
document.addEventListener("astro:page-load", () => {
const overlay = document.getElementById(LB_ID)
if (overlay) overlay.remove()
document.body.style.overflow = ""
})
}
</script>
@@ -1,165 +0,0 @@
/// <reference types="mdast" />
// @ts-nocheck
import { h } from "hastscript";
// GitHub language colors (subset of most common languages)
const LANGUAGE_COLORS = {
JavaScript: "#f1e05a",
TypeScript: "#3178c6",
Python: "#3572A5",
Java: "#b07219",
"C++": "#f34b7d",
C: "#555555",
"C#": "#178600",
Go: "#00ADD8",
Rust: "#dea584",
Ruby: "#701516",
PHP: "#4F5D95",
Swift: "#F05138",
Kotlin: "#A97BFF",
Dart: "#00B4AB",
Scala: "#c22d40",
Shell: "#89e051",
HTML: "#e34c26",
CSS: "#563d7c",
SCSS: "#c6538c",
Vue: "#41b883",
Svelte: "#ff3e00",
Astro: "#ff5a03",
MDX: "#fcb32c",
Markdown: "#083fa1",
JSON: "#292929",
YAML: "#cb171e",
Dockerfile: "#384d54",
Makefile: "#427819",
Lua: "#000080",
Perl: "#0298c3",
R: "#198CE7",
Julia: "#a270ba",
Haskell: "#5e5086",
Elixir: "#6e4a7e",
Clojure: "#db5855",
Objective_C: "#438eff",
"Objective-C": "#438eff",
Jupyter_Notebook: "#DA5B0B",
"Jupyter Notebook": "#DA5B0B",
Vim_Script: "#199f4b",
TeX: "#3D6117",
PowerShell: "#012456",
};
/**
* Creates a GitHub Card component.
*
* @param {Object} properties - The properties of the component.
* @param {string} properties.repo - The GitHub repository in the format "owner/repo".
* @param {import('mdast').RootContent[]} children - The children elements of the component.
* @returns {import('mdast').Parent} The created GitHub Card component.
*/
export function GithubCardComponent(properties, children) {
if (Array.isArray(children) && children.length !== 0)
return h("div", { class: "hidden" }, [
'Invalid directive. ("github" directive must be leaf type "::github{repo="owner/repo"}")',
]);
if (!properties.repo || !properties.repo.includes("/"))
return h(
"div",
{ class: "hidden" },
'Invalid repository. ("repo" attributte must be in the format "owner/repo")',
);
const repo = properties.repo;
const cardUuid = `GC${Math.random().toString(36).slice(-6)}`; // Collisions are not important
const nAvatar = h(`div#${cardUuid}-avatar`, { class: "gc-avatar" });
const nLanguage = h(
`span#${cardUuid}-language`,
{ class: "gc-language" },
"—",
);
const nTitle = h("div", { class: "gc-titlebar" }, [
h("div", { class: "gc-titlebar-left" }, [
h("div", { class: "gc-owner" }, [
nAvatar,
h("div", { class: "gc-user" }, repo.split("/")[0]),
]),
h("div", { class: "gc-divider" }, "/"),
h("div", { class: "gc-repo" }, repo.split("/")[1]),
]),
h("div", { class: "github-logo" }),
]);
const nDescription = h(
`div#${cardUuid}-description`,
{ class: "gc-description" },
"Loading...",
);
const nStars = h(`div#${cardUuid}-stars`, { class: "gc-stars" }, "—");
const nForks = h(`div#${cardUuid}-forks`, { class: "gc-forks" }, "—");
const nLicense = h(`div#${cardUuid}-license`, { class: "gc-license" }, "—");
const nScript = h(
`script#${cardUuid}-script`,
{ type: "text/javascript", defer: true, "data-astro-rerun": true },
`
(function() {
const __gcLangColorsMap = ${JSON.stringify(LANGUAGE_COLORS)};
const debugEnabled = () => localStorage.getItem("debugGithubCard") === "1";
const debug = (...args) => { if (debugEnabled()) console.log("[GITHUB-CARD]", ...args); };
const cardEl = document.getElementById('${cardUuid}-card');
if (cardEl && cardEl.dataset.githubLoaded === "true") {
// already loaded by fallback loader
debug("skip (already loaded)", "${repo}");
return;
}
if (cardEl) cardEl.dataset.githubLoaded = "loading";
debug("inline fetch", "${repo}");
fetch('https://api.github.com/repos/${repo}', { referrerPolicy: "no-referrer" }).then(response => response.json()).then(data => {
document.getElementById('${cardUuid}-description').innerText = data.description?.replace(/:[a-zA-Z0-9_]+:/g, '') || "No description provided";
const lang = data.language || "";
const langEl = document.getElementById('${cardUuid}-language');
langEl.innerText = lang || "—";
if (lang && __gcLangColorsMap[lang]) {
langEl.style.setProperty('--gc-lang-color', __gcLangColorsMap[lang]);
}
document.getElementById('${cardUuid}-forks').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.forks).replaceAll("\u202f", '');
document.getElementById('${cardUuid}-stars').innerText = Intl.NumberFormat('en-us', { notation: "compact", maximumFractionDigits: 1 }).format(data.stargazers_count).replaceAll("\u202f", '');
const avatarEl = document.getElementById('${cardUuid}-avatar');
avatarEl.style.backgroundImage = 'url(' + data.owner.avatar_url + ')';
avatarEl.style.backgroundColor = 'transparent';
document.getElementById('${cardUuid}-license').innerText = data.license?.spdx_id || "—";
document.getElementById('${cardUuid}-card').classList.remove("fetch-waiting");
if (cardEl) cardEl.dataset.githubLoaded = "true";
debug("inline loaded", "${repo}");
}).catch(() => {
const c = document.getElementById('${cardUuid}-card');
c?.classList.add("fetch-error");
if (cardEl) cardEl.dataset.githubLoaded = "error";
console.warn("[GITHUB-CARD] Error loading card for ${repo}");
});
})();
`,
);
return h(
`a#${cardUuid}-card`,
{
class: "card-github fetch-waiting not-prose",
href: `https://github.com/${repo}`,
target: "_blank",
rel: "noopener noreferrer",
"data-github-card": "",
"data-github-repo": repo,
repo,
},
[
nTitle,
nDescription,
h("div", { class: "gc-infobar" }, [nStars, nForks, nLicense, nLanguage]),
nScript,
],
);
}
+45
View File
@@ -0,0 +1,45 @@
/**
* 给正文里的图片注入 `data-lightbox-src`,交给全局灯箱(ImageLightbox.astro)处理。
*
* 两种情况跳过:
* - 图片外面包着 <a>:点击应该跳转,而不是放大
* - 图片上有 `data-lightbox-ignore`:明确表示不要放大
*
* 不用 unist-util-visit,是因为这里需要知道祖先里有没有 <a>,
* 手写一次递归比引入 visit-parents 更直白。
*/
/** @param {import('hast').Root} tree */
export function rehypeImageLightbox() {
/**
* @param {any} node
* @param {boolean} insideLink
*/
const walk = (node, insideLink) => {
if (!node || typeof node !== "object") return
if (node.type === "element") {
if (node.tagName === "img") {
// 链接里的图片保持跳转行为
if (insideLink) return
const props = node.properties || (node.properties = {})
if (props["data-lightbox-ignore"] !== undefined) return
const src = props.src
if (typeof src !== "string" || src.length === 0) return
if (props["data-lightbox-src"] === undefined) {
props["data-lightbox-src"] = src
}
return
}
if (node.tagName === "a") insideLink = true
}
if (Array.isArray(node.children)) {
for (const child of node.children) walk(child, insideLink)
}
}
return (tree) => {
walk(tree, false)
}
}