标题后的 # 锚点:补上缺失的样式,并修跳转定位与无障碍

问题
- 插件 src/plugins/satteri/autolink-headings.mjs 会在每个标题末尾追加
  <a class="anchor"><span class="anchor-icon">#</span></a>,但项目里从来
  没给 .anchor / .anchor-icon 写过任何样式(源码和产物 CSS 都查过,零条规则)
- 结果是那个 # 以纯文本形式常驻显示在每一个标题后面,看起来像 Markdown 语法错误
- 另外标题有 id 却没有 scroll-margin-top,点 # 跳过去标题顶部紧贴视口上沿,
  正好藏在 69px 高的 sticky 导航栏下面

改法
- global.css 补样式:默认 opacity:0 隐藏,鼠标悬停标题(或键盘聚焦锚点)才浮现,
  悬停锚点本身转朱红;触屏没有 hover,直接 display:none(跨标题跳转用目录即可)
- 补 :is(h1..h6)[id] { scroll-margin-top: calc(var(--header-h,69px) + 1.25rem) }
- 插件里给锚点补无障碍名称:aria-label="链接到「标题文字」",图标加 aria-hidden。
  否则读屏会连着念二十几个「#,链接」,完全不知道跳到哪

踩到的坑(已写进操作指南)
Astro 的文章缓存只在 markdown 内容变化时失效,改插件代码不会重新渲染,
必须先 rm -rf node_modules/.astro .astro。第一次改完插件产物没变就是这个原因。

验证:清缓存重建后,产物 HTML 里锚点带上了 aria-label 与 aria-hidden;
产物 CSS 里 .anchor 的 5 条规则 + scroll-margin-top 均正确,
@media(hover:none) 的作用域也确认无误
This commit is contained in:
dsh
2026-09-17 18:37:20 +08:00
parent 93362376f8
commit ef72d52de4
4 changed files with 85 additions and 3 deletions
+13
View File
@@ -299,6 +299,19 @@ sudo sysctl fs.inotify.max_user_watches=524288
如果连空态提示都看不到,检查 `src/data/reading.json` 是否存在(这个文件要提交进 git)。
**改了 markdown 插件却不生效**
Astro 会把渲染好的文章缓存到 `node_modules/.astro`,而这个缓存**只按 markdown
文件内容算 key,不看插件代码**。所以改完 `src/plugins/satteri/*.mjs` 之后,
已经渲染过的文章不会重新跑插件 —— 你会以为改动没生效,其实是吃了缓存。
```bash
rm -rf node_modules/.astro .astro && pnpm build
```
(这个坑是给标题锚点补 `aria-label` 时踩到的:插件明明改了,
产物里的 HTML 却还是老样子。)
**`pnpm dev` 启动时满屏刷红**
历史上踩过两个独立的原因,都已修好,这里记下来免得再犯:
+1 -1
View File
@@ -1,5 +1,5 @@
{
"updatedAt": "2026-09-17T08:50:20.126Z",
"updatedAt": "2026-09-17T10:36:53.871Z",
"source": "weread",
"books": [
{
+24 -2
View File
@@ -32,6 +32,18 @@ export const autolinkHeadings = defineHastPlugin({
if (typeof id !== "string" || id.length === 0) return
if (hasAnchor(node)) return
// 取标题自身的文字,用作锚点的无障碍名称(递归收集,标题里可能有 code/strong)
const collectText = (children) =>
(children || [])
.map((child) => {
if (child.type === "text") return child.value
if (Array.isArray(child.children)) return collectText(child.children)
return ""
})
.join("")
.trim()
const headingText = collectText(node.children)
// 脚注区那个视觉隐藏的 "脚注" 标题不需要 # 锚点
let parent = ctx.parent(node)
while (parent) {
@@ -44,12 +56,22 @@ export const autolinkHeadings = defineHastPlugin({
ctx.appendChild(node, {
type: "element",
tagName: "a",
properties: { className: ["anchor"], href: `#${id}` },
properties: {
className: ["anchor"],
href: `#${id}`,
// 锚点内容只有一个 #,不补名称的话读屏会念「#,链接」,
// 连着二十几个标题听下来完全不知道跳到哪
...(headingText ? { "aria-label": `链接到「${headingText}」` } : {}),
},
children: [
{
type: "element",
tagName: "span",
properties: { className: ["anchor-icon"], "data-pagefind-ignore": "" },
properties: {
className: ["anchor-icon"],
"data-pagefind-ignore": "",
"aria-hidden": "true",
},
children: [{ type: "text", value: "#" }],
},
],
+47
View File
@@ -805,6 +805,53 @@ a.card-github:hover .github-logo {
}
}
/* ——— 文章标题末尾的 # 锚点 ———
*
* 由 src/plugins/satteri/autolink-headings.mjs 生成:
* <h2 id="slug">标题<a class="anchor" href="#slug"><span class="anchor-icon">#</span></a></h2>
*
* 插件一直在追加这个锚点,但项目里从来没给它写过样式,所以那个 # 是
* 以纯文本形式常驻显示在每一个标题后面的。这里按通行做法改成「悬停才浮现」。
*/
.anchor {
margin-left: 0.35em;
color: var(--muted-foreground);
text-decoration: none;
opacity: 0;
transition: opacity 200ms ease, color 200ms ease;
}
.anchor-icon {
font-weight: 400;
}
/* 鼠标悬停标题、或键盘 Tab 聚焦到锚点时浮现 */
:is(h1, h2, h3, h4, h5, h6):hover > .anchor,
:is(h1, h2, h3, h4, h5, h6):focus-within > .anchor,
.anchor:focus-visible {
opacity: 1;
}
.anchor:hover {
color: var(--primary);
}
/* 触屏没有 hover,常驻显示就是原来那副样子,所以也藏起来。
手机上跨标题跳转用右侧目录就够。 */
@media (hover: none) {
.anchor {
display: none;
}
}
/* 点 # 跳转时标题别被 sticky 导航栏盖住。
原来完全没有这个偏移,跳过去标题顶部紧贴视口上沿,
正好藏在 69px 高的导航栏下面。 */
:is(h1, h2, h3, h4, h5, h6)[id] {
scroll-margin-top: calc(var(--header-h, 69px) + 1.25rem);
}
/* ——— 首屏彩蛋:那句话出自王小波《万寿寺》 ———
*
* 那句话本身不做任何变化(不加下划线、不变色、不可点),保持纯排版。