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 { pluginCollapsibleSections } from "@expressive-code/plugin-collapsible-sections"
|
||||||
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers"
|
import { pluginLineNumbers } from "@expressive-code/plugin-line-numbers"
|
||||||
import expressiveCode from "astro-expressive-code"
|
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 { 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 { 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"
|
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({
|
export default defineConfig({
|
||||||
srcDir: "./src",
|
srcDir: "./src",
|
||||||
output: "static",
|
output: "static",
|
||||||
@@ -85,57 +75,44 @@ export default defineConfig({
|
|||||||
sitemap(),
|
sitemap(),
|
||||||
mdx(),
|
mdx(),
|
||||||
],
|
],
|
||||||
|
compressHTML: true,
|
||||||
markdown: {
|
markdown: {
|
||||||
remarkPlugins: [
|
// Astro 7 的 Sätteri 管线。以下插件是 unified 时期 remark/rehype 插件的对应移植,
|
||||||
remarkMath,
|
// 见 src/plugins/satteri/。
|
||||||
remarkReadingTime,
|
//
|
||||||
remarkExcerpt,
|
// 注意:math 只负责「解析」,渲染成 KaTeX 需要额外的 HAST 插件 —— 尚未迁移。
|
||||||
remarkShiftHeadings,
|
processor: satteri({
|
||||||
remarkGithubAdmonitionsToDirectives,
|
// dashes: false 是为了和迁移前的输出逐字一致:Astro 的 unified 管线
|
||||||
remarkDirective,
|
// 会把直引号排成弯引号,但不转换 --- (Sätteri 默认会转成破折号)。
|
||||||
remarkSectionize,
|
features: {
|
||||||
parseDirectiveNode,
|
math: true,
|
||||||
],
|
smartPunctuation: { dashes: false },
|
||||||
rehypePlugins: [
|
// 脚注文案中文化(默认是 "Footnotes" / "Back to reference 1")
|
||||||
rehypeKatex,
|
gfm: {
|
||||||
rehypeSlug,
|
footnotes: {
|
||||||
[
|
label: "脚注",
|
||||||
rehypeComponents,
|
backLabel: "返回第 {reference} 条",
|
||||||
{
|
backContent: "↑",
|
||||||
components: {
|
|
||||||
github: GithubCardComponent,
|
|
||||||
note: createAdmonitionComponent("note"),
|
|
||||||
tip: createAdmonitionComponent("tip"),
|
|
||||||
important: createAdmonitionComponent("important"),
|
|
||||||
caution: createAdmonitionComponent("caution"),
|
|
||||||
warning: createAdmonitionComponent("warning"),
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
mdastPlugins: [
|
||||||
|
readingTime,
|
||||||
|
excerpt,
|
||||||
|
shiftHeadings(),
|
||||||
|
sectionize(),
|
||||||
|
// KaTeX 必须排在 reading-time / excerpt 之后:它把 math 节点换成 KaTeX 的
|
||||||
|
// HTML,先换的话 ctx.textContent() 会把 KaTeX 标记也算进字数。
|
||||||
|
satteriKatex(),
|
||||||
],
|
],
|
||||||
[
|
hastPlugins: [
|
||||||
rehypeAutolinkHeadings,
|
satteriHeadingIdsPlugin(),
|
||||||
{
|
autolinkHeadings,
|
||||||
behavior: "append",
|
imageLightbox,
|
||||||
properties: {
|
// Obsidian 语法的 callout:> [!tip] 标题
|
||||||
className: ["anchor"],
|
satteriCallouts(),
|
||||||
},
|
|
||||||
content: {
|
|
||||||
type: "element",
|
|
||||||
tagName: "span",
|
|
||||||
properties: {
|
|
||||||
className: ["anchor-icon"],
|
|
||||||
"data-pagefind-ignore": true,
|
|
||||||
},
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
value: "#",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
],
|
}),
|
||||||
},
|
},
|
||||||
vite: {
|
vite: {
|
||||||
server: {
|
server: {
|
||||||
|
|||||||
@@ -299,6 +299,19 @@ sudo sysctl fs.inotify.max_user_watches=524288
|
|||||||
|
|
||||||
如果连空态提示都看不到,检查 `src/data/reading.json` 是否存在(这个文件要提交进 git)。
|
如果连空态提示都看不到,检查 `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` 启动时满屏刷红**
|
**`pnpm dev` 启动时满屏刷红**
|
||||||
|
|
||||||
历史上踩过两个独立的原因,都已修好,这里记下来免得再犯:
|
历史上踩过两个独立的原因,都已修好,这里记下来免得再犯:
|
||||||
|
|||||||
+14
-20
@@ -14,14 +14,15 @@
|
|||||||
"reading": "node scripts/fetch-reading.mjs"
|
"reading": "node scripts/fetch-reading.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/mdx": "^5.0.6",
|
"@astrojs/markdown-satteri": "^0.4.1",
|
||||||
"@astrojs/react": "^4.4.2",
|
"@astrojs/mdx": "^8.0.1",
|
||||||
"@astrojs/rss": "^4.0.12",
|
"@astrojs/react": "^6.0.6",
|
||||||
"@astrojs/sitemap": "^3.5.1",
|
"@astrojs/rss": "^4.0.19",
|
||||||
|
"@astrojs/sitemap": "^3.7.4",
|
||||||
"@aws-sdk/client-s3": "^3.1037.0",
|
"@aws-sdk/client-s3": "^3.1037.0",
|
||||||
"@expressive-code/core": "^0.41.5",
|
"@expressive-code/core": "^0.44.2",
|
||||||
"@expressive-code/plugin-collapsible-sections": "^0.41.5",
|
"@expressive-code/plugin-collapsible-sections": "^0.44.2",
|
||||||
"@expressive-code/plugin-line-numbers": "^0.41.5",
|
"@expressive-code/plugin-line-numbers": "^0.44.2",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@radix-ui/react-accordion": "1.2.2",
|
"@radix-ui/react-accordion": "1.2.2",
|
||||||
"@radix-ui/react-alert-dialog": "1.1.4",
|
"@radix-ui/react-alert-dialog": "1.1.4",
|
||||||
@@ -50,9 +51,9 @@
|
|||||||
"@radix-ui/react-toggle": "1.1.1",
|
"@radix-ui/react-toggle": "1.1.1",
|
||||||
"@radix-ui/react-toggle-group": "1.1.1",
|
"@radix-ui/react-toggle-group": "1.1.1",
|
||||||
"@radix-ui/react-tooltip": "1.1.6",
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
"@swup/astro": "^1.7.0",
|
"@swup/astro": "^1.8.0",
|
||||||
"@tabler/icons-react": "^3.40.0",
|
"@tabler/icons-react": "^3.40.0",
|
||||||
"astro-expressive-code": "^0.41.5",
|
"astro-expressive-code": "^0.44.2",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
@@ -62,7 +63,6 @@
|
|||||||
"date-fns": "4.1.0",
|
"date-fns": "4.1.0",
|
||||||
"embla-carousel-react": "8.5.1",
|
"embla-carousel-react": "8.5.1",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"hastscript": "^9.0.1",
|
|
||||||
"input-otp": "1.4.1",
|
"input-otp": "1.4.1",
|
||||||
"katex": "^0.16.22",
|
"katex": "^0.16.22",
|
||||||
"lucide-react": "^0.454.0",
|
"lucide-react": "^0.454.0",
|
||||||
@@ -76,19 +76,13 @@
|
|||||||
"react-resizable-panels": "^2.1.7",
|
"react-resizable-panels": "^2.1.7",
|
||||||
"reading-time": "^1.5.0",
|
"reading-time": "^1.5.0",
|
||||||
"recharts": "2.15.4",
|
"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",
|
"sanitize-html": "^2.17.0",
|
||||||
|
"satteri": "^0.10.5",
|
||||||
|
"satteri-callouts": "^1.0.1",
|
||||||
|
"satteri-katex": "^0.1.1",
|
||||||
"sonner": "^1.7.4",
|
"sonner": "^1.7.4",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"unist-util-visit": "^5.0.0",
|
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"zod": "3.25.76"
|
"zod": "3.25.76"
|
||||||
},
|
},
|
||||||
@@ -102,7 +96,7 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@types/sanitize-html": "^2.16.0",
|
"@types/sanitize-html": "^2.16.0",
|
||||||
"astro": "^5.16.5",
|
"astro": "^7.3.3",
|
||||||
"postcss": "^8.5",
|
"postcss": "^8.5",
|
||||||
"tailwindcss": "^4.1.9",
|
"tailwindcss": "^4.1.9",
|
||||||
"tw-animate-css": "1.3.3",
|
"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
|
esbuild: true
|
||||||
sharp: true
|
sharp: true
|
||||||
swup: 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 ========================= */}
|
{/* ========================= Main ========================= */}
|
||||||
<section class="flex-1 min-w-0" data-bookshelf-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 && (
|
{isReadingView && (
|
||||||
<div id="reading" class="mb-16 scroll-mt-24">
|
<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">
|
<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 class="text-sm italic font-serif text-muted-foreground">博学而笃志,切问而近思</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ---------- Stats + Search + filters ----------
|
{/* ---------- Stats + Search + filters ---------- 只有存在手工书单时才显示。books.json 为空时这三行都是「0 本已读 / 0 本推荐 / 0 个领域」加一排没东西可筛的按钮,纯占地方。 */}
|
||||||
只有存在手工书单时才显示。books.json 为空时这三行都是「0 本已读 /
|
|
||||||
0 本推荐 / 0 个领域」加一排没东西可筛的按钮,纯占地方。 */}
|
|
||||||
{hasManualBooks && (
|
{hasManualBooks && (
|
||||||
<>
|
<>
|
||||||
<div class="flex flex-wrap gap-x-7 gap-y-2 text-sm text-muted-foreground mb-7">
|
<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>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* 微信读书里已读完的书,并入「书籍」区。
|
{/* 微信读书里已读完的书,并入「书籍」区。 卡片用和「最近在读」同一个 ReadingCard 组件,只是底部换成「开始/读完」两行、没有进度条。 data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
|
||||||
卡片用和「最近在读」同一个 ReadingCard 组件,只是底部换成「开始/读完」两行、没有进度条。
|
|
||||||
data-weread-card 让搜索框和领域/评价筛选同样作用于这一组。 */}
|
|
||||||
{finishedReading.length > 0 && (
|
{finishedReading.length > 0 && (
|
||||||
<section data-books-section={WEREAD_CATEGORY}>
|
<section data-books-section={WEREAD_CATEGORY}>
|
||||||
<div class="flex items-center gap-3 mb-5">
|
<div class="flex items-center gap-3 mb-5">
|
||||||
@@ -363,7 +357,6 @@ const recommendBadge = (status?: string) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -285,10 +285,20 @@ export function TableOfContents({ showHeader = true }: TableOfContentsProps) {
|
|||||||
}, 2500)
|
}, 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 (
|
return (
|
||||||
<nav ref={navRef} aria-label="目录" className="toc-nav">
|
<nav
|
||||||
|
ref={navRef}
|
||||||
|
aria-label="目录"
|
||||||
|
className={cn("toc-nav", headings.length === 0 && "hidden")}
|
||||||
|
>
|
||||||
{showHeader ? (
|
{showHeader ? (
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<div className="w-1 h-4 bg-primary rounded-full" />
|
<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({
|
const posts = defineCollection({
|
||||||
type: "content",
|
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/posts" }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
published: z.date(),
|
published: z.date(),
|
||||||
@@ -26,7 +28,7 @@ const posts = defineCollection({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const thoughts = defineCollection({
|
const thoughts = defineCollection({
|
||||||
type: "content",
|
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/thoughts" }),
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
published: z.date(),
|
published: z.date(),
|
||||||
@@ -43,9 +45,9 @@ const thoughts = defineCollection({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const studies = defineCollection({
|
const studies = defineCollection({
|
||||||
type: "content",
|
loader: glob({ pattern: "**/*.{md,mdx}", base: "./src/content/studies" }),
|
||||||
// 注意:frontmatter 里的 `slug` 字段被 Astro 当 reserved key 处理(用作 URL slug),
|
// 注意:`id` 由 glob loader 从文件路径生成(Content Layer API),
|
||||||
// 不会出现在 schema 校验的 data 里。所以这里不声明 slug,直接读 entry.slug。
|
// 所以这里不声明 slug,直接读 entry.id。
|
||||||
schema: z.object({
|
schema: z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
status: z.enum(["在读", "沉淀中", "暂搁", "已结"]),
|
status: z.enum(["在读", "沉淀中", "暂搁", "已结"]),
|
||||||
@@ -17,13 +17,10 @@ draft: false
|
|||||||
其实现在看来,一个有趣的网站固然重要,但是毕竟是一个内容输出的平台,没有足够多的原创内容支持,也只能是一个测试站。
|
其实现在看来,一个有趣的网站固然重要,但是毕竟是一个内容输出的平台,没有足够多的原创内容支持,也只能是一个测试站。
|
||||||
另外要想清楚一点,持续的创作和更新其实才是最难的工作。
|
另外要想清楚一点,持续的创作和更新其实才是最难的工作。
|
||||||
|
|
||||||
::: tip 下周计划
|
> [!tip] 下周计划
|
||||||
|
> - [ ] 阅读累计4小时
|
||||||
- [ ] 阅读累计4小时
|
> - [ ] 汇总整理近期拍摄的照片
|
||||||
- [ ] 汇总整理近期拍摄的照片
|
> - [ ] 写一篇原创文章
|
||||||
- [ ] 写一篇原创文章
|
|
||||||
|
|
||||||
:::
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"updatedAt": "2026-09-17T08:50:20.126Z",
|
"updatedAt": "2026-09-17T10:36:53.871Z",
|
||||||
"source": "weread",
|
"source": "weread",
|
||||||
"books": [
|
"books": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+3
-3
@@ -41,11 +41,11 @@ export async function getSortedPosts(ignorePinned = false): Promise<PostEntry[]>
|
|||||||
export async function getPostsWithNav(): Promise<PostEntry[]> {
|
export async function getPostsWithNav(): Promise<PostEntry[]> {
|
||||||
const sorted = await getSortedPosts()
|
const sorted = await getSortedPosts()
|
||||||
for (let i = 1; i < sorted.length; i += 1) {
|
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
|
sorted[i].data.nextTitle = sorted[i - 1].data.title
|
||||||
}
|
}
|
||||||
for (let i = 0; i < sorted.length - 1; i += 1) {
|
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
|
sorted[i].data.prevTitle = sorted[i + 1].data.title
|
||||||
}
|
}
|
||||||
return sorted
|
return sorted
|
||||||
@@ -63,7 +63,7 @@ export const toPostMeta = (post: PostEntry) => {
|
|||||||
const category = (post.data.category ?? "").toString().trim() || "未分类"
|
const category = (post.data.category ?? "").toString().trim() || "未分类"
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slug: post.slug,
|
slug: post.id,
|
||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
excerpt,
|
excerpt,
|
||||||
category,
|
category,
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export async function getSiteStats(latestCount = 4): Promise<SiteStats> {
|
|||||||
.slice(0, latestCount)
|
.slice(0, latestCount)
|
||||||
.map((post) => ({
|
.map((post) => ({
|
||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
href: `/posts/${post.slug}/`,
|
href: `/posts/${post.id}/`,
|
||||||
date: new Date(post.data.published).toISOString().slice(0, 10),
|
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")
|
const no = String(idx + 1).padStart(2, "0")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: entry.slug,
|
id: entry.id,
|
||||||
slug: entry.slug,
|
slug: entry.id,
|
||||||
no,
|
no,
|
||||||
title: entry.data.title,
|
title: entry.data.title,
|
||||||
subtitle: entry.data.subtitle,
|
subtitle: entry.data.subtitle,
|
||||||
|
|||||||
+2
-2
@@ -57,7 +57,7 @@ export const toThoughtMeta = (thought: ThoughtEntry) => {
|
|||||||
const content = md.render(body)
|
const content = md.render(body)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slug: thought.slug,
|
slug: thought.id,
|
||||||
title: thought.data.title,
|
title: thought.data.title,
|
||||||
content,
|
content,
|
||||||
date: formatDate(thought.data.published),
|
date: formatDate(thought.data.published),
|
||||||
@@ -75,7 +75,7 @@ export const toThoughtPreview = (thought: ThoughtEntry, maxLength = 96) => {
|
|||||||
const text = stripHtml(content)
|
const text = stripHtml(content)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slug: thought.slug,
|
slug: thought.id,
|
||||||
title: thought.data.title,
|
title: thought.data.title,
|
||||||
date: formatDate(thought.data.published),
|
date: formatDate(thought.data.published),
|
||||||
excerpt: truncateText(text, maxLength),
|
excerpt: truncateText(text, maxLength),
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getSortedPosts } from "@/lib/posts"
|
|||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getSortedPosts(true)
|
const posts = await getSortedPosts(true)
|
||||||
return posts.map((post) => ({
|
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 SiteHeader from "@/components/site-header.astro"
|
||||||
import Footer from "@/components/footer.astro"
|
import Footer from "@/components/footer.astro"
|
||||||
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
|
import { getPostsWithNav, type PostEntry } from "@/lib/posts"
|
||||||
|
import { render } from "astro:content"
|
||||||
import Disclaimer from "@/components/Disclaimer.astro"
|
import Disclaimer from "@/components/Disclaimer.astro"
|
||||||
import PasswordProtection from "@/components/PasswordProtection.astro"
|
import PasswordProtection from "@/components/PasswordProtection.astro"
|
||||||
import Comments from "@/components/Comments.astro"
|
import Comments from "@/components/Comments.astro"
|
||||||
@@ -15,7 +16,7 @@ import { site, siteTitleSuffix } from "@/data/site"
|
|||||||
export async function getStaticPaths() {
|
export async function getStaticPaths() {
|
||||||
const posts = await getPostsWithNav()
|
const posts = await getPostsWithNav()
|
||||||
return posts.map((post) => ({
|
return posts.map((post) => ({
|
||||||
params: { slug: post.slug },
|
params: { slug: post.id },
|
||||||
props: { post },
|
props: { post },
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -26,7 +27,7 @@ if (!post) {
|
|||||||
return Astro.redirect("/")
|
return Astro.redirect("/")
|
||||||
}
|
}
|
||||||
|
|
||||||
const { Content, remarkPluginFrontmatter } = await post.render()
|
const { Content, remarkPluginFrontmatter } = await render(post)
|
||||||
const words = remarkPluginFrontmatter?.words
|
const words = remarkPluginFrontmatter?.words
|
||||||
const minutes = remarkPluginFrontmatter?.minutes
|
const minutes = remarkPluginFrontmatter?.minutes
|
||||||
|
|
||||||
@@ -113,8 +114,8 @@ if (post.data.encrypted && post.data.password) {
|
|||||||
</div>
|
</div>
|
||||||
{encryptedData ? (
|
{encryptedData ? (
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<PasswordProtection encryptedData={encryptedData} slug={post.slug} class="mb-6 rounded-xl" />
|
<PasswordProtection encryptedData={encryptedData} slug={post.id} class="mb-6 rounded-xl" />
|
||||||
<div id={`article-content-${post.slug}`} class="mb-6 hidden encrypted-content">
|
<div id={`article-content-${post.id}`} class="mb-6 hidden encrypted-content">
|
||||||
<!-- 内容将在验证密码后动态插入 -->
|
<!-- 内容将在验证密码后动态插入 -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ export async function GET(context: APIContext) {
|
|||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
pubDate: post.data.published,
|
pubDate: post.data.published,
|
||||||
description: post.data.description || "",
|
description: post.data.description || "",
|
||||||
link: `/posts/${post.slug}/`,
|
link: `/posts/${post.id}/`,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (index >= FULL_CONTENT_LIMIT) return item
|
if (index >= FULL_CONTENT_LIMIT) return item
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const posts = await getSortedPosts(true)
|
|||||||
|
|
||||||
// 转换为时间线所需的简化数据
|
// 转换为时间线所需的简化数据
|
||||||
const timelineItems = posts.map((post) => ({
|
const timelineItems = posts.map((post) => ({
|
||||||
slug: post.slug,
|
slug: post.id,
|
||||||
title: post.data.title,
|
title: post.data.title,
|
||||||
date: post.data.published.toISOString().slice(0, 10),
|
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 "./transition.css";
|
||||||
@import "tailwindcss";
|
@import "tailwindcss/index.css";
|
||||||
@import "tw-animate-css";
|
@import "tw-animate-css";
|
||||||
|
@import "./callouts.css";
|
||||||
@plugin "@tailwindcss/typography";
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
@@ -88,6 +89,7 @@
|
|||||||
--admonitions-color-important: oklch(0.62 0.16 310);
|
--admonitions-color-important: oklch(0.62 0.16 310);
|
||||||
--admonitions-color-warning: oklch(0.7 0.18 70);
|
--admonitions-color-warning: oklch(0.7 0.18 70);
|
||||||
--admonitions-color-caution: oklch(0.62 0.2 var(--primary-hue, 25));
|
--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 */
|
/* Code block colors - light mode */
|
||||||
/* 暖色阴槽 #f2ece1。原来是 0.97/0.003 的中性灰白,在暖纸底(#f8f3e9)上
|
/* 暖色阴槽 #f2ece1。原来是 0.97/0.003 的中性灰白,在暖纸底(#f8f3e9)上
|
||||||
就是一块冷白补丁,很跳。现在带上同样的暖色相。
|
就是一块冷白补丁,很跳。现在带上同样的暖色相。
|
||||||
@@ -149,6 +151,7 @@
|
|||||||
--admonitions-color-important: oklch(0.7 0.18 310);
|
--admonitions-color-important: oklch(0.7 0.18 310);
|
||||||
--admonitions-color-warning: oklch(0.75 0.2 70);
|
--admonitions-color-warning: oklch(0.75 0.2 70);
|
||||||
--admonitions-color-caution: oklch(0.7 0.22 var(--primary-hue, 25));
|
--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 */
|
/* Code block colors - dark mode */
|
||||||
--codeblock-bg: oklch(0.20 0.015 250);
|
--codeblock-bg: oklch(0.20 0.015 250);
|
||||||
--codeblock-topbar-bg: oklch(0.24 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