Compare commits
7
Commits
fe9adb246f
...
ef72d52de4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef72d52de4 | ||
|
|
93362376f8 | ||
|
|
f4a49ef94c | ||
|
|
5e808a9cd4 | ||
|
|
26e873b9f6 | ||
|
|
c1643ae95d | ||
|
|
738fe3175e |
+42
-65
@@ -5,29 +5,19 @@ import mdx from "@astrojs/mdx"
|
||||
import { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections"
|
||||
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers"
|
||||
import expressiveCode from "astro-expressive-code"
|
||||
import rehypeAutolinkHeadings from "rehype-autolink-headings"
|
||||
import rehypeComponents from "rehype-components"
|
||||
import rehypeKatex from "rehype-katex"
|
||||
import rehypeSlug from "rehype-slug"
|
||||
import remarkDirective from "remark-directive"
|
||||
import remarkGithubAdmonitionsToDirectives from "remark-github-admonitions-to-directives"
|
||||
import remarkMath from "remark-math"
|
||||
import remarkSectionize from "remark-sectionize"
|
||||
import { remarkShiftHeadings } from "./src/plugins/remark-shift-headings.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 { remarkExcerpt } from "./src/plugins/remark-excerpt.js"
|
||||
import { remarkReadingTime } from "./src/plugins/remark-reading-time.mjs"
|
||||
import { pluginLanguageBadge } from "./src/plugins/expressive-code/language-badge"
|
||||
import { pluginCustomCopyButton } from "./src/plugins/expressive-code/custom-copy-button"
|
||||
import { satteri, satteriHeadingIdsPlugin } from "@astrojs/markdown-satteri"
|
||||
import { excerpt } from "./src/plugins/satteri/excerpt.mjs"
|
||||
import { readingTime } from "./src/plugins/satteri/reading-time.mjs"
|
||||
import { sectionize } from "./src/plugins/satteri/sectionize.mjs"
|
||||
import { shiftHeadings } from "./src/plugins/satteri/shift-headings.mjs"
|
||||
import { autolinkHeadings } from "./src/plugins/satteri/autolink-headings.mjs"
|
||||
import { imageLightbox } from "./src/plugins/satteri/image-lightbox.mjs"
|
||||
import { satteriKatex } from "satteri-katex"
|
||||
import satteriCallouts from "satteri-callouts"
|
||||
import { site } from "./src/data/site"
|
||||
|
||||
const createAdmonitionComponent = (type) => (properties = {}, children = []) => {
|
||||
const normalizedChildren = Array.isArray(children) ? children : []
|
||||
return AdmonitionComponent(properties, normalizedChildren, type)
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
srcDir: "./src",
|
||||
output: "static",
|
||||
@@ -85,57 +75,44 @@ export default defineConfig({
|
||||
sitemap(),
|
||||
mdx(),
|
||||
],
|
||||
compressHTML: true,
|
||||
markdown: {
|
||||
remarkPlugins: [
|
||||
remarkMath,
|
||||
remarkReadingTime,
|
||||
remarkExcerpt,
|
||||
remarkShiftHeadings,
|
||||
remarkGithubAdmonitionsToDirectives,
|
||||
remarkDirective,
|
||||
remarkSectionize,
|
||||
parseDirectiveNode,
|
||||
],
|
||||
rehypePlugins: [
|
||||
rehypeKatex,
|
||||
rehypeSlug,
|
||||
[
|
||||
rehypeComponents,
|
||||
{
|
||||
components: {
|
||||
github: GithubCardComponent,
|
||||
note: createAdmonitionComponent("note"),
|
||||
tip: createAdmonitionComponent("tip"),
|
||||
important: createAdmonitionComponent("important"),
|
||||
caution: createAdmonitionComponent("caution"),
|
||||
warning: createAdmonitionComponent("warning"),
|
||||
// Astro 7 的 Sätteri 管线。以下插件是 unified 时期 remark/rehype 插件的对应移植,
|
||||
// 见 src/plugins/satteri/。
|
||||
//
|
||||
// 注意:math 只负责「解析」,渲染成 KaTeX 需要额外的 HAST 插件 —— 尚未迁移。
|
||||
processor: satteri({
|
||||
// dashes: false 是为了和迁移前的输出逐字一致:Astro 的 unified 管线
|
||||
// 会把直引号排成弯引号,但不转换 --- (Sätteri 默认会转成破折号)。
|
||||
features: {
|
||||
math: true,
|
||||
smartPunctuation: { dashes: false },
|
||||
// 脚注文案中文化(默认是 "Footnotes" / "Back to reference 1")
|
||||
gfm: {
|
||||
footnotes: {
|
||||
label: "脚注",
|
||||
backLabel: "返回第 {reference} 条",
|
||||
backContent: "↑",
|
||||
},
|
||||
},
|
||||
},
|
||||
mdastPlugins: [
|
||||
readingTime,
|
||||
excerpt,
|
||||
shiftHeadings(),
|
||||
sectionize(),
|
||||
// KaTeX 必须排在 reading-time / excerpt 之后:它把 math 节点换成 KaTeX 的
|
||||
// HTML,先换的话 ctx.textContent() 会把 KaTeX 标记也算进字数。
|
||||
satteriKatex(),
|
||||
],
|
||||
[
|
||||
rehypeAutolinkHeadings,
|
||||
{
|
||||
behavior: "append",
|
||||
properties: {
|
||||
className: ["anchor"],
|
||||
},
|
||||
content: {
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: ["anchor-icon"],
|
||||
"data-pagefind-ignore": true,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "text",
|
||||
value: "#",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
hastPlugins: [
|
||||
satteriHeadingIdsPlugin(),
|
||||
autolinkHeadings,
|
||||
imageLightbox,
|
||||
// Obsidian 语法的 callout:> [!tip] 标题
|
||||
satteriCallouts(),
|
||||
],
|
||||
],
|
||||
}),
|
||||
},
|
||||
vite: {
|
||||
server: {
|
||||
|
||||
@@ -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` 启动时满屏刷红**
|
||||
|
||||
历史上踩过两个独立的原因,都已修好,这里记下来免得再犯:
|
||||
|
||||
+14
-20
@@ -14,14 +14,15 @@
|
||||
"reading": "node scripts/fetch-reading.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/mdx": "^5.0.6",
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@astrojs/rss": "^4.0.12",
|
||||
"@astrojs/sitemap": "^3.5.1",
|
||||
"@astrojs/markdown-satteri": "^0.4.1",
|
||||
"@astrojs/mdx": "^8.0.1",
|
||||
"@astrojs/react": "^6.0.6",
|
||||
"@astrojs/rss": "^4.0.19",
|
||||
"@astrojs/sitemap": "^3.7.4",
|
||||
"@aws-sdk/client-s3": "^3.1037.0",
|
||||
"@expressive-code/core": "^0.41.5",
|
||||
"@expressive-code/plugin-collapsible-sections": "^0.41.5",
|
||||
"@expressive-code/plugin-line-numbers": "^0.41.5",
|
||||
"@expressive-code/core": "^0.44.2",
|
||||
"@expressive-code/plugin-collapsible-sections": "^0.44.2",
|
||||
"@expressive-code/plugin-line-numbers": "^0.44.2",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "1.2.2",
|
||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||
@@ -50,9 +51,9 @@
|
||||
"@radix-ui/react-toggle": "1.1.1",
|
||||
"@radix-ui/react-toggle-group": "1.1.1",
|
||||
"@radix-ui/react-tooltip": "1.1.6",
|
||||
"@swup/astro": "^1.7.0",
|
||||
"@swup/astro": "^1.8.0",
|
||||
"@tabler/icons-react": "^3.40.0",
|
||||
"astro-expressive-code": "^0.41.5",
|
||||
"astro-expressive-code": "^0.44.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"bcryptjs": "^3.0.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -62,7 +63,6 @@
|
||||
"date-fns": "4.1.0",
|
||||
"embla-carousel-react": "8.5.1",
|
||||
"gray-matter": "^4.0.3",
|
||||
"hastscript": "^9.0.1",
|
||||
"input-otp": "1.4.1",
|
||||
"katex": "^0.16.22",
|
||||
"lucide-react": "^0.454.0",
|
||||
@@ -76,19 +76,13 @@
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"reading-time": "^1.5.0",
|
||||
"recharts": "2.15.4",
|
||||
"rehype-autolink-headings": "^7.1.0",
|
||||
"rehype-components": "^0.3.0",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-slug": "^6.0.0",
|
||||
"remark-directive": "^3.0.1",
|
||||
"remark-github-admonitions-to-directives": "^1.0.5",
|
||||
"remark-math": "^6.0.0",
|
||||
"remark-sectionize": "^2.1.0",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"satteri": "^0.10.5",
|
||||
"satteri-callouts": "^1.0.1",
|
||||
"satteri-katex": "^0.1.1",
|
||||
"sonner": "^1.7.4",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "3.25.76"
|
||||
},
|
||||
@@ -102,7 +96,7 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@types/sanitize-html": "^2.16.0",
|
||||
"astro": "^5.16.5",
|
||||
"astro": "^7.3.3",
|
||||
"postcss": "^8.5",
|
||||
"tailwindcss": "^4.1.9",
|
||||
"tw-animate-css": "1.3.3",
|
||||
|
||||
Generated
+3856
-3663
File diff suppressed because it is too large
Load Diff
@@ -5,3 +5,7 @@ allowBuilds:
|
||||
esbuild: true
|
||||
sharp: true
|
||||
swup: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@astrojs/react@6.0.6'
|
||||
- astro@7.3.3
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
---
|
||||
/**
|
||||
* 无依赖灯箱。
|
||||
*
|
||||
* 任何带 `data-lightbox-src` 的元素点击即可放大:
|
||||
* - 相册页的 `<button data-lightbox-src>`
|
||||
* - 文章正文里的 `<img data-lightbox-src>`(由 src/plugins/satteri/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>
|
||||
@@ -163,9 +163,7 @@ const recommendBadge = (status?: string) => {
|
||||
{/* ========================= Main ========================= */}
|
||||
<section class="flex-1 min-w-0" data-bookshelf-main>
|
||||
|
||||
{/* ========================= 最近在读(微信读书) =========================
|
||||
数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json,
|
||||
这里只负责渲染,页面本身不发任何请求。 */}
|
||||
{/* ========================= 最近在读(微信读书) ========================= 数据在构建期由 scripts/fetch-reading.mjs 抓到 src/data/reading.json, 这里只负责渲染,页面本身不发任何请求。 */}
|
||||
{isReadingView && (
|
||||
<div id="reading" class="mb-16 scroll-mt-24">
|
||||
<div class="flex flex-wrap items-end justify-between gap-x-6 gap-y-2 mb-6">
|
||||
@@ -205,9 +203,7 @@ const recommendBadge = (status?: string) => {
|
||||
<div class="text-sm italic font-serif text-muted-foreground">博学而笃志,切问而近思</div>
|
||||
</div>
|
||||
|
||||
{/* ---------- Stats + Search + filters ----------
|
||||
只有存在手工书单时才显示。books.json 为空时这三行都是「0 本已读 /
|
||||
0 本推荐 / 0 个领域」加一排没东西可筛的按钮,纯占地方。 */}
|
||||
{/* ---------- Stats + Search + filters ---------- 只有存在手工书单时才显示。books.json 为空时这三行都是「0 本已读 / 0 本推荐 / 0 个领域」加一排没东西可筛的按钮,纯占地方。 */}
|
||||
{hasManualBooks && (
|
||||
<>
|
||||
<div class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7">
|
||||
@@ -340,9 +336,7 @@ const recommendBadge = (status?: string) => {
|
||||
</section>
|
||||
))}
|
||||
|
||||
{/* 微信读书里已读完的书,并入「书籍」区。
|
||||
卡片用和「最近在读」同一个 ReadingCard 组件,只是底部换成「开始/读完」两行、没有进度条。
|
||||
data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
|
||||
{/* 微信读书里已读完的书,并入「书籍」区。 卡片用和「最近在读」同一个 ReadingCard 组件,只是底部换成「开始/读完」两行、没有进度条。 data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
|
||||
{finishedReading.length > 0 && (
|
||||
<section data-books-section={WEREAD_CATEGORY}>
|
||||
<div class="flex items-center gap-3 mb-5">
|
||||
@@ -363,7 +357,6 @@ const recommendBadge = (status?: string) => {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -285,10 +285,20 @@ export function TableOfContents({ showHeader = true }: TableOfContentsProps) {
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
if (headings.length === 0) return null
|
||||
|
||||
// 注意:这里不能 `return null`。
|
||||
//
|
||||
// Astro 靠「渲染器探测」决定交给哪个框架渲染:@astrojs/react 的 check 会试渲染一次,
|
||||
// 拿到 null 就认不出这是 React 组件;接着轮到 @astrojs/mdx 的 check,而它是把组件
|
||||
// 当普通函数**裸调**(不在 React 渲染树里),useState 找不到 dispatcher,于是每次
|
||||
// SSR 都往控制台打一条 "Invalid hook call"。页面本身没错,但那行日志很误导人。
|
||||
//
|
||||
// 始终返回 <nav>,没有标题时用 hidden 收起来 —— 视觉结果和 return null 一样。
|
||||
return (
|
||||
<nav ref={navRef} aria-label="目录" className="toc-nav">
|
||||
<nav
|
||||
ref={navRef}
|
||||
aria-label="目录"
|
||||
className={cn("toc-nav", headings.length === 0 && "hidden")}
|
||||
>
|
||||
{showHeader ? (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<div className="w-1 h-4 bg-primary rounded-full" />
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { defineCollection, z } from "astro:content"
|
||||
import { defineCollection } from "astro:content"
|
||||
import { glob } from "astro/loaders"
|
||||
import { z } from "astro/zod"
|
||||
|
||||
const posts = defineCollection({
|
||||
type: "content",
|
||||
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
published: z.date(),
|
||||
@@ -26,7 +28,7 @@ const posts = defineCollection({
|
||||
})
|
||||
|
||||
const thoughts = defineCollection({
|
||||
type: "content",
|
||||
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/thoughts" }),
|
||||
schema: z.object({
|
||||
title: z.string().optional(),
|
||||
published: z.date(),
|
||||
@@ -43,9 +45,9 @@ const thoughts = defineCollection({
|
||||
})
|
||||
|
||||
const studies = defineCollection({
|
||||
type: "content",
|
||||
// 注意:frontmatter 里的 `slug` 字段被 Astro 当 reserved key 处理(用作 URL slug),
|
||||
// 不会出现在 schema 校验的 data 里。所以这里不声明 slug,直接读 entry.slug。
|
||||
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/studies" }),
|
||||
// 注意:`id` 由 glob loader 从文件路径生成(Content Layer API),
|
||||
// 所以这里不声明 slug,直接读 entry.id。
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
status: z.enum(["在读", "沉淀中", "暂搁", "已结"]),
|
||||
@@ -17,13 +17,10 @@ draft: false
|
||||
其实现在看来,一个有趣的网站固然重要,但是毕竟是一个内容输出的平台,没有足够多的原创内容支持,也只能是一个测试站。
|
||||
另外要想清楚一点,持续的创作和更新其实才是最难的工作。
|
||||
|
||||
::: tip 下周计划
|
||||
|
||||
- [ ] 阅读累计4小时
|
||||
- [ ] 汇总整理近期拍摄的照片
|
||||
- [ ] 写一篇原创文章
|
||||
|
||||
:::
|
||||
> [!tip] 下周计划
|
||||
> - [ ] 阅读累计4小时
|
||||
> - [ ] 汇总整理近期拍摄的照片
|
||||
> - [ ] 写一篇原创文章
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"updatedAt": "2026-09-17T08:50:20.126Z",
|
||||
"updatedAt": "2026-09-17T10:36:53.871Z",
|
||||
"source": "weread",
|
||||
"books": [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import "katex/dist/katex.min.css"
|
||||
import "@/styles/global.css"
|
||||
import { ClientRouter } from "astro:transitions"
|
||||
import { analytics, homeTitle, site } from "@/data/site"
|
||||
import ImageLightbox from "@/components/ImageLightbox.astro"
|
||||
|
||||
interface Props {
|
||||
title?: string
|
||||
@@ -213,6 +214,7 @@ const resolvedTwitterDescription = twitterDescription || description
|
||||
</head>
|
||||
<body class:list={["bg-background text-foreground", { "font-profile-interface": useHarmonySans }]}>
|
||||
<slot />
|
||||
<ImageLightbox />
|
||||
<script is:inline>
|
||||
// View Transitions 页面切换后重应用主题
|
||||
(function() {
|
||||
|
||||
+3
-3
@@ -41,11 +41,11 @@ export async function getSortedPosts(ignorePinned = false): Promise<PostEntry[]>
|
||||
export async function getPostsWithNav(): Promise<PostEntry[]> {
|
||||
const sorted = await getSortedPosts()
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
sorted[i].data.nextSlug = sorted[i - 1].slug
|
||||
sorted[i].data.nextSlug = sorted[i - 1].id
|
||||
sorted[i].data.nextTitle = sorted[i - 1].data.title
|
||||
}
|
||||
for (let i = 0; i < sorted.length - 1; i += 1) {
|
||||
sorted[i].data.prevSlug = sorted[i + 1].slug
|
||||
sorted[i].data.prevSlug = sorted[i + 1].id
|
||||
sorted[i].data.prevTitle = sorted[i + 1].data.title
|
||||
}
|
||||
return sorted
|
||||
@@ -63,7 +63,7 @@ export const toPostMeta = (post: PostEntry) => {
|
||||
const category = (post.data.category ?? "").toString().trim() || "未分类"
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
slug: post.id,
|
||||
title: post.data.title,
|
||||
excerpt,
|
||||
category,
|
||||
|
||||
@@ -62,7 +62,7 @@ export async function getSiteStats(latestCount = 4): Promise<SiteStats> {
|
||||
.slice(0, latestCount)
|
||||
.map((post) => ({
|
||||
title: post.data.title,
|
||||
href: `/posts/${post.slug}/`,
|
||||
href: `/posts/${post.id}/`,
|
||||
date: new Date(post.data.published).toISOString().slice(0, 10),
|
||||
}))
|
||||
|
||||
|
||||
+2
-2
@@ -307,8 +307,8 @@ function shape(entry: StudyEntry, idx: number): StudyData {
|
||||
const no = String(idx + 1).padStart(2, "0")
|
||||
|
||||
return {
|
||||
id: entry.slug,
|
||||
slug: entry.slug,
|
||||
id: entry.id,
|
||||
slug: entry.id,
|
||||
no,
|
||||
title: entry.data.title,
|
||||
subtitle: entry.data.subtitle,
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ export const toThoughtMeta = (thought: ThoughtEntry) => {
|
||||
const content = md.render(body)
|
||||
|
||||
return {
|
||||
slug: thought.slug,
|
||||
slug: thought.id,
|
||||
title: thought.data.title,
|
||||
content,
|
||||
date: formatDate(thought.data.published),
|
||||
@@ -75,7 +75,7 @@ export const toThoughtPreview = (thought: ThoughtEntry, maxLength = 96) => {
|
||||
const text = stripHtml(content)
|
||||
|
||||
return {
|
||||
slug: thought.slug,
|
||||
slug: thought.id,
|
||||
title: thought.data.title,
|
||||
date: formatDate(thought.data.published),
|
||||
excerpt: truncateText(text, maxLength),
|
||||
|
||||
@@ -122,115 +122,3 @@ const description = album.description ?? "相册"
|
||||
break-inside: avoid;
|
||||
}
|
||||
</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>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { getSortedPosts } from "@/lib/posts"
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getSortedPosts(true)
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
params: { slug: post.id },
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import ArticleContent from "@/components/ArticleContent.astro"
|
||||
import SiteHeader from "@/components/site-header.astro"
|
||||
import Footer from "@/components/footer.astro"
|
||||
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
|
||||
import { render } from "astro:content"
|
||||
import Disclaimer from "@/components/Disclaimer.astro"
|
||||
import PasswordProtection from "@/components/PasswordProtection.astro"
|
||||
import Comments from "@/components/Comments.astro"
|
||||
@@ -15,7 +16,7 @@ import { site, siteTitleSuffix } from "@/data/site"
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getPostsWithNav()
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
params: { slug: post.id },
|
||||
props: { post },
|
||||
}))
|
||||
}
|
||||
@@ -26,7 +27,7 @@ if (!post) {
|
||||
return Astro.redirect("/")
|
||||
}
|
||||
|
||||
const { Content, remarkPluginFrontmatter } = await post.render()
|
||||
const { Content, remarkPluginFrontmatter } = await render(post)
|
||||
const words = remarkPluginFrontmatter?.words
|
||||
const minutes = remarkPluginFrontmatter?.minutes
|
||||
|
||||
@@ -113,8 +114,8 @@ if (post.data.encrypted && post.data.password) {
|
||||
</div>
|
||||
{encryptedData ? (
|
||||
<div class="mb-6">
|
||||
<PasswordProtection encryptedData={encryptedData} slug={post.slug} class="mb-6 rounded-xl" />
|
||||
<div id={`article-content-${post.slug}`} class="mb-6 hidden encrypted-content">
|
||||
<PasswordProtection encryptedData={encryptedData} slug={post.id} class="mb-6 rounded-xl" />
|
||||
<div id={`article-content-${post.id}`} class="mb-6 hidden encrypted-content">
|
||||
<!-- 内容将在验证密码后动态插入 -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,7 +30,7 @@ export async function GET(context: APIContext) {
|
||||
title: post.data.title,
|
||||
pubDate: post.data.published,
|
||||
description: post.data.description || "",
|
||||
link: `/posts/${post.slug}/`,
|
||||
link: `/posts/${post.id}/`,
|
||||
}
|
||||
|
||||
if (index >= FULL_CONTENT_LIMIT) return item
|
||||
|
||||
@@ -12,7 +12,7 @@ const posts = await getSortedPosts(true)
|
||||
|
||||
// 转换为时间线所需的简化数据
|
||||
const timelineItems = posts.map((post) => ({
|
||||
slug: post.slug,
|
||||
slug: post.id,
|
||||
title: post.data.title,
|
||||
date: post.data.published.toISOString().slice(0, 10),
|
||||
}))
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
// @ts-nocheck
|
||||
/// <reference types="mdast" />
|
||||
import { h } from "hastscript";
|
||||
|
||||
/**
|
||||
* Creates an admonition component.
|
||||
*
|
||||
* @param {Object} properties - The properties of the component.
|
||||
* @param {string} [properties.title] - An optional title.
|
||||
* @param {('tip'|'note'|'important'|'caution'|'warning')} type - The admonition type.
|
||||
* @param {import('mdast').RootContent[]} children - The children elements of the component.
|
||||
* @returns {import('mdast').Parent} The created admonition component.
|
||||
*/
|
||||
export function AdmonitionComponent(properties, children, type) {
|
||||
if (!Array.isArray(children) || children.length === 0)
|
||||
return h(
|
||||
"div",
|
||||
{ class: "hidden" },
|
||||
'Invalid admonition directive. (Admonition directives must be of block type ":::note{name="name"} <content> :::")',
|
||||
);
|
||||
|
||||
let label = null;
|
||||
if (properties?.["has-directive-label"] || properties?.hasDirectiveLabel) {
|
||||
label = children[0]; // The first child is the label
|
||||
// biome-ignore lint/style/noParameterAssign: <check later>
|
||||
children = children.slice(1);
|
||||
label.tagName = "div"; // Change the tag <p> to <div>
|
||||
}
|
||||
|
||||
return h("blockquote", { class: `admonition bdm-${type}` }, [
|
||||
h("span", { class: "bdm-title" }, label ? label : type.toUpperCase()),
|
||||
...children,
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
// @ts-nocheck
|
||||
import { h } from "hastscript";
|
||||
import { visit } from "unist-util-visit";
|
||||
|
||||
/**
|
||||
* @returns {import('unified').Transformer<import('mdast').Root>}
|
||||
*/
|
||||
export function parseDirectiveNode() {
|
||||
return (tree) => {
|
||||
visit(tree, (node) => {
|
||||
if (
|
||||
node.type === "containerDirective" ||
|
||||
node.type === "leafDirective" ||
|
||||
node.type === "textDirective"
|
||||
) {
|
||||
// biome-ignore lint/suspicious/noAssignInExpressions: <check later>
|
||||
const data = node.data || (node.data = {});
|
||||
node.attributes = node.attributes || {};
|
||||
if (
|
||||
node.children.length > 0 &&
|
||||
node.children[0].data &&
|
||||
node.children[0].data.directiveLabel
|
||||
) {
|
||||
node.attributes["has-directive-label"] = true;
|
||||
}
|
||||
const hast = h(node.name, node.attributes);
|
||||
|
||||
data.hName = hast.tagName;
|
||||
data.hProperties = hast.properties;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
|
||||
import { toString } from "mdast-util-to-string";
|
||||
|
||||
/* Use the post's first paragraph as the excerpt */
|
||||
export function remarkExcerpt() {
|
||||
return (tree, { data }) => {
|
||||
let excerpt = "";
|
||||
for (const node of tree.children) {
|
||||
if (node.type !== "paragraph") {
|
||||
continue;
|
||||
}
|
||||
excerpt = toString(node);
|
||||
break;
|
||||
}
|
||||
data.astro.frontmatter.excerpt = excerpt;
|
||||
};
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// @ts-nocheck
|
||||
// biome-ignore lint/suspicious/noShadowRestrictedNames: <toString from mdast-util-to-string>
|
||||
import { toString } from "mdast-util-to-string";
|
||||
import getReadingTime from "reading-time";
|
||||
|
||||
export function remarkReadingTime() {
|
||||
return (tree, { data }) => {
|
||||
const textOnPage = toString(tree);
|
||||
const readingTime = getReadingTime(textOnPage);
|
||||
data.astro.frontmatter.minutes = Math.max(
|
||||
1,
|
||||
Math.round(readingTime.minutes),
|
||||
);
|
||||
data.astro.frontmatter.words = readingTime.words;
|
||||
};
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { visit } from "unist-util-visit"
|
||||
|
||||
export function remarkShiftHeadings(options = {}) {
|
||||
const {
|
||||
shift = 1,
|
||||
onlyIn = ["/src/content/posts/"]
|
||||
} = options
|
||||
|
||||
return (tree, file) => {
|
||||
const rawPath = typeof file?.path === "string" ? file.path : ""
|
||||
const normalizedPath = rawPath.replace(/\\/g, "/")
|
||||
const shouldShift = onlyIn.some((segment) => normalizedPath.includes(segment))
|
||||
|
||||
if (!shouldShift || shift <= 0) return
|
||||
|
||||
visit(tree, "heading", (node) => {
|
||||
if (typeof node.depth !== "number") return
|
||||
node.depth = Math.min(node.depth + shift, 6)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { defineHastPlugin } from "satteri"
|
||||
|
||||
/**
|
||||
* 在标题末尾追加一个 # 锚点链接(替代 rehype-autolink-headings)。
|
||||
*
|
||||
* 依赖 satteriHeadingIdsPlugin() 先生成 id,所以本插件必须排在它之后。
|
||||
*
|
||||
* 目标输出(与 unified 时期逐字一致):
|
||||
* <h3 id="slug">标题<a class="anchor" href="#slug"><span class="anchor-icon" data-pagefind-ignore="">#</span></a></h3>
|
||||
*/
|
||||
|
||||
const HEADING_TAGS = ["h1", "h2", "h3", "h4", "h5", "h6"]
|
||||
|
||||
/** @param {any} node */
|
||||
const hasAnchor = (node) => {
|
||||
if (!Array.isArray(node.children)) return false
|
||||
return node.children.some(
|
||||
(child) =>
|
||||
child?.type === "element" &&
|
||||
child.tagName === "a" &&
|
||||
Array.isArray(child.properties?.className) &&
|
||||
child.properties.className.includes("anchor"),
|
||||
)
|
||||
}
|
||||
|
||||
export const autolinkHeadings = defineHastPlugin({
|
||||
name: "autolink-headings",
|
||||
element: {
|
||||
filter: HEADING_TAGS,
|
||||
visit(node, ctx) {
|
||||
const id = node.properties?.id
|
||||
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) {
|
||||
if (parent.type === "element" && parent.properties?.["data-footnotes"] !== undefined) {
|
||||
return
|
||||
}
|
||||
parent = ctx.parent(parent)
|
||||
}
|
||||
|
||||
ctx.appendChild(node, {
|
||||
type: "element",
|
||||
tagName: "a",
|
||||
properties: {
|
||||
className: ["anchor"],
|
||||
href: `#${id}`,
|
||||
// 锚点内容只有一个 #,不补名称的话读屏会念「#,链接」,
|
||||
// 连着二十几个标题听下来完全不知道跳到哪
|
||||
...(headingText ? { "aria-label": `链接到「${headingText}」` } : {}),
|
||||
},
|
||||
children: [
|
||||
{
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: ["anchor-icon"],
|
||||
"data-pagefind-ignore": "",
|
||||
"aria-hidden": "true",
|
||||
},
|
||||
children: [{ type: "text", value: "#" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { defineMdastPlugin } from "satteri"
|
||||
import { toString } from "mdast-util-to-string"
|
||||
|
||||
/**
|
||||
* 取正文第一个段落作为摘要,写进 frontmatter.excerpt。
|
||||
*
|
||||
* 文章页会用它兜底 <meta name="description">(见 posts/[...slug].astro)。
|
||||
*/
|
||||
export const excerpt = defineMdastPlugin({
|
||||
name: "excerpt",
|
||||
after(root, ctx) {
|
||||
let text = ""
|
||||
for (const node of root.children) {
|
||||
if (node.type !== "paragraph") continue
|
||||
// 同 reading-time:ctx.textContent 不含行内代码/公式,用 toString 保持一致
|
||||
text = toString(node)
|
||||
break
|
||||
}
|
||||
ctx.data.astro.frontmatter.excerpt = text
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { defineHastPlugin } from "satteri"
|
||||
|
||||
/**
|
||||
* 给正文里的图片注入 `data-lightbox-src`,交给全局灯箱(ImageLightbox.astro)处理。
|
||||
*
|
||||
* 原实现是 rehype 插件(src/plugins/rehype-image-lightbox.mjs),这里是 Sätteri HAST 版。
|
||||
*
|
||||
* 两种情况跳过:
|
||||
* - 图片外面包着 <a>:点击应该跳转,而不是放大
|
||||
* - 图片上有 `data-lightbox-ignore`:明确表示不要放大
|
||||
*/
|
||||
export const imageLightbox = defineHastPlugin({
|
||||
name: "image-lightbox",
|
||||
element: {
|
||||
filter: ["img"],
|
||||
visit(node, ctx) {
|
||||
// 沿祖先链查有没有 <a>
|
||||
let parent = ctx.parent(node)
|
||||
while (parent) {
|
||||
if (parent.type === "element" && parent.tagName === "a") return
|
||||
parent = ctx.parent(parent)
|
||||
}
|
||||
|
||||
const properties = node.properties ?? {}
|
||||
if (properties["data-lightbox-ignore"] !== undefined) return
|
||||
|
||||
const src = properties.src
|
||||
if (typeof src !== "string" || src.length === 0) return
|
||||
if (properties["data-lightbox-src"] !== undefined) return
|
||||
|
||||
ctx.setProperty(node, "data-lightbox-src", src)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { defineMdastPlugin } from "satteri"
|
||||
import { toString } from "mdast-util-to-string"
|
||||
import getReadingTime from "reading-time"
|
||||
|
||||
/**
|
||||
* 把阅读时长写进 frontmatter(minutes / words)。
|
||||
*
|
||||
* 页面侧通过 render(entry).remarkPluginFrontmatter 读这两个值,
|
||||
* 与 unified 时期完全一致,所以 posts/[...slug].astro 不用改。
|
||||
*/
|
||||
export const readingTime = defineMdastPlugin({
|
||||
name: "reading-time",
|
||||
after(root, ctx) {
|
||||
// 用 mdast-util-to-string 而不是 ctx.textContent:
|
||||
// 后者只拼接纯 text 节点,会漏掉代码块、行内代码、公式、图片 alt,
|
||||
// 导致字数比迁移前少一截(实测 LaTeX 那篇 830 → 488 字)。
|
||||
const text = toString(root)
|
||||
const stats = getReadingTime(text)
|
||||
const frontmatter = ctx.data.astro.frontmatter
|
||||
frontmatter.minutes = Math.max(1, Math.round(stats.minutes))
|
||||
frontmatter.words = stats.words
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
import { defineMdastPlugin } from "satteri"
|
||||
|
||||
/**
|
||||
* 把每个标题连同它后面的内容包进一个 <section>,标题层级决定嵌套。
|
||||
*
|
||||
* 原实现是 remark-sectionize(unified 插件)。它按 6→1 的深度逐层包,
|
||||
* 这里改成一次递归完成,结果相同但只需要一趟。
|
||||
*
|
||||
* 注意:必须在 `before` 里做,不能用 heading visitor —— 重建 children 之后
|
||||
* 新生成的 section 节点还会被遍历到,heading visitor 会被重复触发,包出多余层级。
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {readonly any[]} children
|
||||
* @returns {any[]}
|
||||
*/
|
||||
const groupIntoSections = (children) => {
|
||||
/** @type {any[]} */
|
||||
const out = []
|
||||
let i = 0
|
||||
|
||||
while (i < children.length) {
|
||||
const node = children[i]
|
||||
|
||||
if (node?.type === "heading" && typeof node.depth === "number") {
|
||||
const depth = node.depth
|
||||
/** @type {any[]} */
|
||||
const group = [node]
|
||||
|
||||
let j = i + 1
|
||||
while (j < children.length) {
|
||||
const next = children[j]
|
||||
// 遇到同级或更高级的标题就收束;export 也当作边界(同 remark-sectionize)
|
||||
if (next?.type === "heading" && next.depth <= depth) break
|
||||
if (next?.type === "export") break
|
||||
group.push(next)
|
||||
j++
|
||||
}
|
||||
|
||||
out.push({
|
||||
type: "section",
|
||||
depth,
|
||||
// 标题本身留在原层,其余内容递归分组,嵌套关系才对
|
||||
children: [node, ...groupIntoSections(group.slice(1))],
|
||||
data: { hName: "section" },
|
||||
})
|
||||
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(node)
|
||||
i++
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
export const sectionize = () =>
|
||||
defineMdastPlugin({
|
||||
name: "sectionize",
|
||||
before(root, ctx) {
|
||||
ctx.setProperty(root, "children", groupIntoSections(root.children))
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineMdastPlugin } from "satteri"
|
||||
|
||||
/**
|
||||
* 把标题层级整体下移 shift 级,只对指定目录下的文章生效。
|
||||
*
|
||||
* 原来是 remark 插件,靠 file.path 判断;这里用 Sätteri 的工厂上下文 fileURL,
|
||||
* 只在匹配的文档上注册插件,其他文档直接跳过(省一次遍历)。
|
||||
*
|
||||
* @param {{ shift?: number, onlyIn?: string[] }} [options]
|
||||
*/
|
||||
export const shiftHeadings =
|
||||
({ shift = 1, onlyIn = ["/src/content/posts/"] } = {}) =>
|
||||
(factoryCtx) => {
|
||||
const path = (factoryCtx.fileURL?.pathname ?? "").replace(/\\/g, "/")
|
||||
|
||||
if (shift <= 0 || !onlyIn.some((segment) => path.includes(segment))) {
|
||||
return null
|
||||
}
|
||||
|
||||
return defineMdastPlugin({
|
||||
name: "shift-headings",
|
||||
heading(node, ctx) {
|
||||
if (typeof node.depth !== "number") return
|
||||
ctx.setProperty(node, "depth", Math.min(node.depth + shift, 6))
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* Obsidian 风格 callout 的样式(DOM 由 satteri-callouts 生成)。
|
||||
*
|
||||
* 刻意**不引入** satteri-callouts 自带的主题 CSS:它那套是 Obsidian 原版配色
|
||||
* (蓝/青/紫高饱和色),而且靠 mix-blend-mode 叠底色 —— 在「暖纸底 + 朱红」这套
|
||||
* 配色上会发灰发脏。这里改为复用主题已有的 --admonitions-color-* token,
|
||||
* 与 .admonition(免责声明块)保持同一种视觉语言:3px 左边框 + 彩色标题、无底色。
|
||||
*
|
||||
* 用法(frontmatter 里写):
|
||||
* > [!tip] 标题
|
||||
* > 内容
|
||||
*
|
||||
* > [!warning]- 默认折叠的
|
||||
* > 展开后才看到
|
||||
*/
|
||||
|
||||
.callout {
|
||||
/* 未指定类型时的兜底色 */
|
||||
--callout-accent: var(--admonitions-color-note);
|
||||
|
||||
border-left: 3px solid var(--callout-accent);
|
||||
background: transparent;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 1.25rem 0;
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
/* ---------- 类型 → 主题配色 ---------- */
|
||||
|
||||
[data-callout="tip"],
|
||||
[data-callout="hint"] {
|
||||
--callout-accent: var(--admonitions-color-tip);
|
||||
}
|
||||
|
||||
[data-callout="note"],
|
||||
[data-callout="info"],
|
||||
[data-callout="todo"],
|
||||
[data-callout="abstract"],
|
||||
[data-callout="summary"],
|
||||
[data-callout="tldr"] {
|
||||
--callout-accent: var(--admonitions-color-note);
|
||||
}
|
||||
|
||||
[data-callout="important"],
|
||||
[data-callout="example"] {
|
||||
--callout-accent: var(--admonitions-color-important);
|
||||
}
|
||||
|
||||
[data-callout="warning"],
|
||||
[data-callout="attention"],
|
||||
[data-callout="question"],
|
||||
[data-callout="help"],
|
||||
[data-callout="faq"] {
|
||||
--callout-accent: var(--admonitions-color-warning);
|
||||
}
|
||||
|
||||
[data-callout="caution"] {
|
||||
--callout-accent: var(--admonitions-color-caution);
|
||||
}
|
||||
|
||||
[data-callout="success"],
|
||||
[data-callout="check"],
|
||||
[data-callout="done"] {
|
||||
--callout-accent: var(--admonitions-color-success);
|
||||
}
|
||||
|
||||
[data-callout="danger"],
|
||||
[data-callout="error"],
|
||||
[data-callout="failure"],
|
||||
[data-callout="fail"],
|
||||
[data-callout="missing"],
|
||||
[data-callout="bug"] {
|
||||
--callout-accent: var(--destructive);
|
||||
}
|
||||
|
||||
[data-callout="quote"],
|
||||
[data-callout="cite"] {
|
||||
--callout-accent: var(--muted-foreground);
|
||||
}
|
||||
|
||||
/* ---------- 标题 ---------- */
|
||||
|
||||
.callout-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
|
||||
color: var(--callout-accent);
|
||||
font-weight: 600;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.callout-title-icon {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.callout-title-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.callout-title-text {
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
/* 折叠版用的是 <summary>,去掉浏览器默认的三角标记 */
|
||||
.callout-title::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.callout-title::marker {
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* ---------- 内容 ---------- */
|
||||
|
||||
/* prose 给段落 1.25rem 的上下边距,在 callout 里太松,收紧一点 */
|
||||
.callout-content p {
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.callout-content > :first-child {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.callout-content > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* ---------- 折叠态 ---------- */
|
||||
|
||||
.callout[data-collapsible="true"] .callout-title {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.callout[data-collapsible="true"] .callout-fold-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-inline-start: auto;
|
||||
}
|
||||
|
||||
.callout-fold-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.callout[data-collapsible="true"] > .callout-title .callout-fold-icon svg {
|
||||
transform: rotate(-90deg);
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
|
||||
.callout[data-collapsible="true"][open] > .callout-title .callout-fold-icon svg {
|
||||
transform: none;
|
||||
}
|
||||
+51
-1
@@ -1,6 +1,7 @@
|
||||
@import "./transition.css";
|
||||
@import "tailwindcss";
|
||||
@import "tailwindcss/index.css";
|
||||
@import "tw-animate-css";
|
||||
@import "./callouts.css";
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@@ -88,6 +89,7 @@
|
||||
--admonitions-color-important: oklch(0.62 0.16 310);
|
||||
--admonitions-color-warning: oklch(0.7 0.18 70);
|
||||
--admonitions-color-caution: oklch(0.62 0.2 var(--primary-hue, 25));
|
||||
--admonitions-color-success: oklch(0.62 0.16 150);
|
||||
/* Code block colors - light mode */
|
||||
/* 暖色阴槽 #f2ece1。原来是 0.97/0.003 的中性灰白,在暖纸底(#f8f3e9)上
|
||||
就是一块冷白补丁,很跳。现在带上同样的暖色相。
|
||||
@@ -149,6 +151,7 @@
|
||||
--admonitions-color-important: oklch(0.7 0.18 310);
|
||||
--admonitions-color-warning: oklch(0.75 0.2 70);
|
||||
--admonitions-color-caution: oklch(0.7 0.22 var(--primary-hue, 25));
|
||||
--admonitions-color-success: oklch(0.7 0.18 150);
|
||||
/* Code block colors - dark mode */
|
||||
--codeblock-bg: oklch(0.20 0.015 250);
|
||||
--codeblock-topbar-bg: oklch(0.24 0.015 250);
|
||||
@@ -802,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);
|
||||
}
|
||||
|
||||
/* ——— 首屏彩蛋:那句话出自王小波《万寿寺》 ———
|
||||
*
|
||||
* 那句话本身不做任何变化(不加下划线、不变色、不可点),保持纯排版。
|
||||
|
||||
Reference in New Issue
Block a user