Add Astro Obsidian notes blog with board layout.

Scaffold a static notes blog with posts/notes collections, wikilinks, vault sync, interactive home gate, and multi-column content-filled layouts across list and article pages.
This commit is contained in:
secret-deus
2026-07-11 23:42:36 +08:00
commit 65636fb8ed
49 changed files with 10470 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# OS / IDE
.DS_Store
.idea/
.vscode/
# local browser / screenshot artifacts
.playwright-mcp/
home-*.png
posts-board.png
+100
View File
@@ -0,0 +1,100 @@
# Notebook —— Obsidian 笔记博客(Astro)
用 **Obsidian** 写作,用 **Astro** 生成静态站点的个人笔记博客。
- 文章(时间线)+ 笔记(常青 / 双链)
- 支持 `[[wikilink]]`、标签、RSS、sitemap
- 一键从 Vault 同步公开笔记
## 快速开始
```bash
npm install
npm run dev
```
浏览器打开终端提示的地址(一般是 `http://localhost:4321`)。
```bash
npm run build # 产出到 dist/
npm run preview # 预览构建结果
```
## 内容放哪
| 路径 | 说明 |
|------|------|
| `src/content/posts/` | 正式文章(需 `title` + `pubDate`) |
| `src/content/notes/` | 笔记(可从 Obsidian 同步) |
| `public/assets/` | 图片等静态资源 |
| `src/consts.ts` | 站名、作者、默认 Vault 路径 |
### 文章示例 frontmatter
```yaml
---
title: 标题
description: 摘要
pubDate: 2026-07-11
tags: [博客]
---
```
### 笔记示例 frontmatter
```yaml
---
title: 标题
tags: [笔记]
publish: true
---
```
`publish: false` 或 `draft: true` 不会上线。
## 从 Obsidian 同步
默认库路径:`C:/Users/admin/Documents/Obsidian Vault`
(可在 `src/consts.ts` 或环境变量 `OBSIDIAN_VAULT` 修改)
```powershell
npm run sync:vault
# 或
$env:OBSIDIAN_VAULT="D:\path\to\vault"
npm run sync:vault
# 只预览不同步
npm run sync:vault -- --dry-run
```
会跳过 `.obsidian`、`private`、`templates` 等目录,以及 `publish: false` 的笔记。
## 双链
笔记正文中的 `[[写作约定]]` 会链到 `/notes/写作约定/`。
文件名与双链目标保持一致即可(中文文件名可用)。
## 自定义
- **站名 / 文案**:`src/consts.ts`
- **配色 / 字体**:`src/styles/global.css`
- **导航**:`src/components/Header.astro`
- **布局**:`src/layouts/`
- **Obsidian 语法插件**:`src/plugins/remark-obsidian.mjs`
Astro 本身可继续加 React/Vue、Tailwind、部署适配器等,自由度很高。
## 部署
构建产物在 `dist/`,可丢到:
- GitHub Pages / Cloudflare Pages / Netlify / 任意静态托管
- 改 `astro.config.mjs` 里的 `site` 为你的域名
## 命令
| 命令 | 作用 |
|------|------|
| `npm run dev` | 本地开发 |
| `npm run build` | 生产构建 |
| `npm run preview` | 预览构建 |
| `npm run sync:vault` | 从 Obsidian 同步笔记 |
+56
View File
@@ -0,0 +1,56 @@
// @ts-check
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
import { unified } from '@astrojs/markdown-remark';
import { defineConfig, fontProviders } from 'astro/config';
import { remarkObsidian } from './src/plugins/remark-obsidian.mjs';
// https://astro.build/config
// Astro 7 默认 Sätteri 不跑 remark 插件;笔记博客需要 [[wikilink]],改用 unified
export default defineConfig({
site: 'https://example.com',
integrations: [mdx(), sitemap()],
markdown: {
processor: unified({
remarkPlugins: [remarkObsidian],
shikiConfig: {
themes: {
light: 'github-light',
dark: 'github-dark',
},
defaultColor: false,
},
}),
},
fonts: [
{
provider: fontProviders.local(),
name: 'Atkinson',
cssVariable: '--font-atkinson',
fallbacks: [
'"Segoe UI"',
'"PingFang SC"',
'"Hiragino Sans GB"',
'"Microsoft YaHei"',
'sans-serif',
],
options: {
variants: [
{
src: ['./src/assets/fonts/atkinson-regular.woff'],
weight: 400,
style: 'normal',
display: 'swap',
},
{
src: ['./src/assets/fonts/atkinson-bold.woff'],
weight: 700,
style: 'normal',
display: 'swap',
},
],
},
},
],
});
+6299
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -0,0 +1,23 @@
{
"name": "notebook",
"type": "module",
"version": "0.0.1",
"engines": {
"node": ">=22.12.0"
},
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"sync:vault": "node scripts/sync-vault.mjs"
},
"dependencies": {
"@astrojs/mdx": "^7.0.2",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.3",
"astro": "^7.0.7",
"sharp": "^0.35.0",
"unist-util-visit": "^5.1.0"
}
}
+1
View File
@@ -0,0 +1 @@
Binary file not shown.

After

Width:  |  Height:  |  Size: 655 B

+9
View File
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128">
<path d="M50.4 78.5a75.1 75.1 0 0 0-28.5 6.9l24.2-65.7c.7-2 1.9-3.2 3.4-3.2h29c1.5 0 2.7 1.2 3.4 3.2l24.2 65.7s-11.6-7-28.5-7L67 45.5c-.4-1.7-1.6-2.8-2.9-2.8-1.3 0-2.5 1.1-2.9 2.7L50.4 78.5Zm-1.1 28.2Zm-4.2-20.2c-2 6.6-.6 15.8 4.2 20.2a17.5 17.5 0 0 1 .2-.7 5.5 5.5 0 0 1 5.7-4.5c2.8.1 4.3 1.5 4.7 4.7.2 1.1.2 2.3.2 3.5v.4c0 2.7.7 5.2 2.2 7.4a13 13 0 0 0 5.7 4.9v-.3l-.2-.3c-1.8-5.6-.5-9.5 4.4-12.8l1.5-1a73 73 0 0 0 3.2-2.2 16 16 0 0 0 6.8-11.4c.3-2 .1-4-.6-6l-.8.6-1.6 1a37 37 0 0 1-22.4 2.7c-5-.7-9.7-2-13.2-6.2Z" />
<style>
path { fill: #000; }
@media (prefers-color-scheme: dark) {
path { fill: #FFF; }
}
</style>
</svg>

After

Width:  |  Height:  |  Size: 749 B

+154
View File
@@ -0,0 +1,154 @@
/**
* 从 Obsidian Vault 同步公开笔记到 src/content/notes
*
* 用法:
* npm run sync:vault
* OBSIDIAN_VAULT="D:/notes" npm run sync:vault
* node scripts/sync-vault.mjs --dry-run
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const dryRun = process.argv.includes('--dry-run');
const DEFAULT_VAULT = 'C:/Users/admin/Documents/Obsidian Vault';
const vault = process.env.OBSIDIAN_VAULT || DEFAULT_VAULT;
const notesOut = path.join(root, 'src/content/notes');
const assetsOut = path.join(root, 'public/assets');
const IGNORE_DIR = new Set([
'.obsidian',
'.git',
'.trash',
'node_modules',
'private',
'templates',
'template',
'attachments-private',
]);
const IMAGE_EXT = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.pdf']);
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
function walk(dir, base = dir, files = []) {
if (!fs.existsSync(dir)) return files;
for (const name of fs.readdirSync(dir)) {
if (name.startsWith('.') && name !== '.') continue;
const full = path.join(dir, name);
const rel = path.relative(base, full);
const stat = fs.statSync(full);
if (stat.isDirectory()) {
if (IGNORE_DIR.has(name.toLowerCase())) continue;
walk(full, base, files);
} else {
files.push({ full, rel });
}
}
return files;
}
function parseFrontmatter(raw) {
if (!raw.startsWith('---')) return { data: {}, body: raw };
const end = raw.indexOf('\n---', 3);
if (end === -1) return { data: {}, body: raw };
const fm = raw.slice(3, end).trim();
const body = raw.slice(end + 4).replace(/^\r?\n/, '');
const data = {};
for (const line of fm.split(/\r?\n/)) {
const m = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!m) continue;
const key = m[1];
let val = m[2].trim();
if (val === 'true') val = true;
else if (val === 'false') val = false;
else if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
data[key] = val;
}
return { data, body, fm };
}
function shouldPublish(data) {
if (data.publish === false) return false;
if (data.draft === true) return false;
return true;
}
function ensureTitle(raw, filename) {
const { data, body, fm } = parseFrontmatter(raw);
if (data.title) return raw;
const title = path.basename(filename, path.extname(filename));
if (raw.startsWith('---') && fm !== undefined) {
return `---\n${fm}\ntitle: ${JSON.stringify(title)}\n---\n${body}`;
}
return `---\ntitle: ${JSON.stringify(title)}\npublish: true\n---\n\n${raw}`;
}
function main() {
console.log(`Vault: ${vault}`);
console.log(`Out: ${notesOut}`);
if (!fs.existsSync(vault)) {
console.error('Vault 路径不存在。请设置 OBSIDIAN_VAULT 或修改 scripts/sync-vault.mjs / src/consts.ts');
process.exit(1);
}
ensureDir(notesOut);
ensureDir(assetsOut);
const files = walk(vault);
let mdCount = 0;
let assetCount = 0;
let skipCount = 0;
for (const { full, rel } of files) {
const ext = path.extname(full).toLowerCase();
const relPosix = rel.split(path.sep).join('/');
if (IMAGE_EXT.has(ext)) {
const dest = path.join(assetsOut, path.basename(full));
if (!dryRun) fs.copyFileSync(full, dest);
assetCount++;
continue;
}
if (ext !== '.md' && ext !== '.mdx') continue;
const raw = fs.readFileSync(full, 'utf8');
const { data } = parseFrontmatter(raw);
if (!shouldPublish(data)) {
skipCount++;
continue;
}
// 输出扁平或保留相对路径:保留相对路径更利于子目录笔记
const destRel = relPosix.replace(/\.mdx?$/i, '') + path.extname(full);
const dest = path.join(notesOut, destRel);
const content = ensureTitle(raw, full);
if (dryRun) {
console.log(` + ${destRel}`);
} else {
ensureDir(path.dirname(dest));
fs.writeFileSync(dest, content, 'utf8');
}
mdCount++;
}
console.log(
dryRun
? `[dry-run] 将同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`
: `完成:同步 ${mdCount} 篇笔记, ${assetCount} 个资源, 跳过 ${skipCount}`,
);
}
main();
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.
Binary file not shown.
+50
View File
@@ -0,0 +1,50 @@
---
import '../styles/global.css';
import '../styles/board.css';
import type { ImageMetadata } from 'astro';
import { Font } from 'astro:assets';
import { SITE_TITLE } from '../consts';
interface Props {
title: string;
description: string;
image?: ImageMetadata;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const { title, description, image } = Astro.props;
const fullTitle =
title === SITE_TITLE || title.endsWith(` · ${SITE_TITLE}`)
? title
: `${title} · ${SITE_TITLE}`;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" href="/favicon.ico" />
<link rel="sitemap" href="/sitemap-index.xml" />
<link
rel="alternate"
type="application/rss+xml"
title={SITE_TITLE}
href={new URL('rss.xml', Astro.site)}
/>
<meta name="generator" content={Astro.generator} />
<Font cssVariable="--font-atkinson" preload />
<link rel="canonical" href={canonicalURL} />
<title>{fullTitle}</title>
<meta name="description" content={description} />
<meta property="og:type" content="website" />
<meta property="og:url" content={Astro.url} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
{image && <meta property="og:image" content={new URL(image.src, Astro.url)} />}
<meta name="twitter:card" content={image ? 'summary_large_image' : 'summary'} />
<meta name="twitter:title" content={fullTitle} />
<meta name="twitter:description" content={description} />
+61
View File
@@ -0,0 +1,61 @@
---
import type { NoteEntry, PostEntry } from '../lib/content';
import { entryHref, entryTitle } from '../lib/content';
import FormattedDate from './FormattedDate.astro';
interface Props {
entries: Array<PostEntry | NoteEntry>;
empty?: string;
showKind?: boolean;
clamp?: number;
}
const {
entries,
empty = '暂无内容',
showKind = false,
clamp,
} = Astro.props;
const list = typeof clamp === 'number' ? entries.slice(0, clamp) : entries;
---
{
list.length === 0 ? (
<p class="empty-hint">{empty}</p>
) : (
<ul class="dense-list">
{list.map((entry) => {
const date =
entry.collection === 'posts'
? entry.data.pubDate
: (entry.data.updatedDate ?? entry.data.pubDate);
const desc = entry.data.description;
const tags = entry.data.tags ?? [];
const kind = entry.collection === 'posts' ? '文章' : '笔记';
return (
<li>
<a class="dense-item" href={entryHref(entry)}>
<div class="dense-main">
<span class="dense-title">
{showKind && (
<span
class:list={['kind-dot', { note: entry.collection === 'notes' }]}
title={kind}
/>
)}
{entryTitle(entry)}
</span>
{desc && <span class="dense-desc">{desc}</span>}
</div>
<div class="dense-meta">
{date && <FormattedDate date={date} />}
{tags[0] && <span class="dense-tag">#{tags[0]}</span>}
</div>
</a>
</li>
);
})}
</ul>
)
}
+50
View File
@@ -0,0 +1,50 @@
---
import type { NoteEntry, PostEntry } from '../lib/content';
import { entryHref, entryTitle } from '../lib/content';
import FormattedDate from './FormattedDate.astro';
interface Props {
entry: PostEntry | NoteEntry;
}
const { entry } = Astro.props;
const title = entryTitle(entry);
const href = entryHref(entry);
const description =
entry.data.description ||
(entry.collection === 'notes' ? '常青笔记' : '');
const date =
entry.collection === 'posts'
? entry.data.pubDate
: (entry.data.updatedDate ?? entry.data.pubDate);
const kind = entry.collection === 'posts' ? '文章' : '笔记';
const tags = entry.data.tags ?? [];
---
<a class="card" href={href}>
<div class="top meta">
<span>{kind}</span>
{date && <FormattedDate date={date} />}
</div>
<h3>{title}</h3>
{description && <p>{description}</p>}
{
tags.length > 0 && (
<div class="tag-row">
{tags.slice(0, 4).map((tag) => (
<span class="tag">#{tag}</span>
))}
</div>
)
}
</a>
<style>
.top {
display: flex;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.45rem;
font-size: 0.85em;
}
</style>
+27
View File
@@ -0,0 +1,27 @@
---
import { SITE_AUTHOR, SITE_TITLE } from '../consts';
const year = new Date().getFullYear();
---
<footer>
<p>
&copy; {year}
{SITE_AUTHOR} · {SITE_TITLE}
</p>
<p class="meta">用 Obsidian 写笔记,用 Astro 建站。</p>
</footer>
<style>
footer {
border-top: 1px solid rgb(var(--line));
padding: 1.75rem 1rem 2.75rem;
text-align: center;
color: rgb(var(--muted));
font-size: 0.85em;
animation: fade-up var(--dur-slow) var(--ease-out) 120ms both;
}
footer p {
margin: 0.2rem 0;
}
</style>
+17
View File
@@ -0,0 +1,17 @@
---
interface Props {
date: Date;
}
const { date } = Astro.props;
---
<time datetime={date.toISOString()}>
{
date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
</time>
+102
View File
@@ -0,0 +1,102 @@
---
import { SITE_TITLE } from '../consts';
import HeaderLink from './HeaderLink.astro';
---
<header>
<nav>
<a class="brand" href="/">{SITE_TITLE}</a>
<div class="links">
<HeaderLink href="/">首页</HeaderLink>
<HeaderLink href="/posts/">文章</HeaderLink>
<HeaderLink href="/notes/">笔记</HeaderLink>
<HeaderLink href="/tags/">标签</HeaderLink>
<HeaderLink href="/about/">关于</HeaderLink>
</div>
</nav>
</header>
<style>
header {
position: sticky;
top: 0;
z-index: 20;
backdrop-filter: blur(10px);
background: rgba(var(--surface), 0.86);
border-bottom: 1px solid rgb(var(--line));
transition: box-shadow var(--dur) var(--ease-out), background var(--dur) var(--ease-out);
}
nav {
width: var(--wide);
max-width: 100%;
margin: 0 auto;
padding: 0 var(--page-pad-x);
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
min-height: 3.1rem;
}
.brand {
font-weight: 700;
font-size: 0.98em;
color: rgb(var(--ink));
text-decoration: none;
letter-spacing: -0.02em;
transition:
color var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
.brand:hover {
color: var(--accent);
transform: translateY(-1px);
}
.links {
display: flex;
align-items: center;
gap: 0.1rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.links :global(a) {
position: relative;
padding: 0.4rem 0.55rem;
color: rgb(var(--muted));
text-decoration: none;
border-radius: 6px;
font-size: 0.9em;
transition:
color var(--dur-fast) var(--ease-out),
background var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out);
}
.links :global(a::after) {
content: '';
position: absolute;
left: 0.55rem;
right: 0.55rem;
bottom: 0.2rem;
height: 1.5px;
border-radius: 1px;
background: var(--accent);
transform: scaleX(0);
transform-origin: left center;
transition: transform var(--dur) var(--ease-out);
opacity: 0.85;
}
.links :global(a:hover) {
color: rgb(var(--ink));
background: rgb(var(--code-bg));
transform: translateY(-1px);
}
.links :global(a:hover::after),
.links :global(a.active::after) {
transform: scaleX(1);
}
.links :global(a.active) {
color: var(--accent-dark);
font-weight: 700;
background: var(--accent-soft);
}
</style>
+17
View File
@@ -0,0 +1,17 @@
---
import type { HTMLAttributes } from 'astro/types';
type Props = HTMLAttributes<'a'>;
const { href, class: className, ...props } = Astro.props;
const path = Astro.url.pathname;
const hrefStr = typeof href === 'string' ? href : '';
const isHome = hrefStr === '/';
const isActive = isHome
? path === '/' || path === ''
: path === hrefStr || path === hrefStr.replace(/\/$/, '') || path.startsWith(hrefStr);
---
<a href={href} class:list={[className, { active: isActive }]} {...props}>
<slot />
</a>
+164
View File
@@ -0,0 +1,164 @@
---
/**
* 全站通用侧栏:快捷入口 + 统计 + 标签 + 另一类内容列表
*/
import type { NoteEntry, PostEntry } from '../lib/content';
import { getPublishedNotes, getPublishedPosts, getTagMap } from '../lib/content';
import DenseEntryList from './DenseEntryList.astro';
interface Props {
/** 当前频道,用于高亮快捷入口 */
active?: 'home' | 'posts' | 'notes' | 'tags' | 'about';
/** 侧栏「相关列表」标题 */
listTitle?: string;
listHref?: string;
listEntries?: Array<PostEntry | NoteEntry>;
listEmpty?: string;
showKind?: boolean;
/** 额外说明文案 */
tips?: string[];
tipsTitle?: string;
}
const {
active,
listTitle = '最近更新',
listHref,
listEntries,
listEmpty = '暂无内容',
showKind = true,
tips,
tipsTitle = '说明',
} = Astro.props;
const [posts, notes, tagMap] = await Promise.all([
getPublishedPosts(),
getPublishedNotes(),
getTagMap(),
]);
const tags = [...tagMap.entries()]
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 10);
const fallbackList =
listEntries ??
(active === 'posts'
? notes.slice(0, 8)
: active === 'notes'
? posts.slice(0, 8)
: [...posts, ...notes]
.sort((a, b) => {
const da =
a.collection === 'posts'
? a.data.pubDate
: (a.data.updatedDate ?? a.data.pubDate ?? new Date(0));
const db =
b.collection === 'posts'
? b.data.pubDate
: (b.data.updatedDate ?? b.data.pubDate ?? new Date(0));
return db.valueOf() - da.valueOf();
})
.slice(0, 8));
const resolvedListTitle =
listTitle ||
(active === 'posts' ? '最近笔记' : active === 'notes' ? '最近文章' : '最近更新');
const resolvedListHref =
listHref || (active === 'posts' ? '/notes/' : active === 'notes' ? '/posts/' : '/');
---
<aside class="board-side">
<section class="panel">
<div class="panel-head">
<h2>快捷入口</h2>
</div>
<nav class="quick-nav" aria-label="站内导航">
<a href="/" class:list={[{ active: active === 'home' }]}>
<span>首页</span><em>⌂</em>
</a>
<a href="/posts/" class:list={[{ active: active === 'posts' }]}>
<span>文章</span><em>{posts.length}</em>
</a>
<a href="/notes/" class:list={[{ active: active === 'notes' }]}>
<span>笔记</span><em>{notes.length}</em>
</a>
<a href="/tags/" class:list={[{ active: active === 'tags' }]}>
<span>标签</span><em>{tagMap.size}</em>
</a>
<a href="/about/" class:list={[{ active: active === 'about' }]}>
<span>关于</span><em>→</em>
</a>
<a href="/rss.xml">
<span>RSS</span><em>xml</em>
</a>
</nav>
</section>
<section class="panel">
<div class="panel-head">
<h2>内容概况</h2>
</div>
<div class="metric-grid">
<div class="metric">
<strong>{posts.length}</strong>
<span>文章</span>
</div>
<div class="metric">
<strong>{notes.length}</strong>
<span>笔记</span>
</div>
<div class="metric">
<strong>{tagMap.size}</strong>
<span>标签</span>
</div>
<div class="metric">
<strong>{posts.length + notes.length}</strong>
<span>合计</span>
</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>{resolvedListTitle}</h2>
{resolvedListHref && <a href={resolvedListHref}>全部 →</a>}
</div>
<DenseEntryList entries={fallbackList} empty={listEmpty} showKind={showKind} />
</section>
{
tags.length > 0 && (
<section class="panel">
<div class="panel-head">
<h2>热门标签</h2>
<a href="/tags/">全部 →</a>
</div>
<div class="tag-cloud">
{tags.map(([tag, items]) => (
<a class="tag" href={`/tags/${encodeURIComponent(tag)}/`}>
#{tag}
<span class="count">{items.length}</span>
</a>
))}
</div>
</section>
)
}
{
tips && tips.length > 0 && (
<section class="panel">
<div class="panel-head">
<h2>{tipsTitle}</h2>
</div>
<ul class="bullet-list">
{tips.map((t) => (
<li set:html={t} />
))}
</ul>
</section>
)
}
</aside>
+13
View File
@@ -0,0 +1,13 @@
/** 站点全局配置 —— 改这里就能换站名、域名、作者信息 */
export const SITE_TITLE = 'Notebook';
export const SITE_DESCRIPTION = '用 Obsidian 写作,用 Astro 发布的个人笔记博客。';
export const SITE_AUTHOR = '你的名字';
/** 部署域名,构建 RSS / sitemap 会用到(不要带 https://) */
export const SITE_URL = 'https://example.com';
/**
* Obsidian 库路径(给 scripts/sync-vault.mjs 用)
* 也可用环境变量 OBSIDIAN_VAULT 覆盖
*/
export const DEFAULT_VAULT_PATH = 'C:/Users/admin/Documents/Obsidian Vault';
+40
View File
@@ -0,0 +1,40 @@
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
const tags = z.array(z.string()).default([]);
/** 正式博文:偏时间线、可带封面 */
const posts = defineCollection({
loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
schema: ({ image }) =>
z.object({
title: z.string(),
description: z.string().default(''),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
heroImage: z.optional(image()),
tags,
draft: z.boolean().default(false),
}),
});
/**
* 常青笔记 / Obsidian 笔记
* frontmatter 尽量宽松,方便直接从 Vault 拷过来
*/
const notes = defineCollection({
loader: glob({ base: './src/content/notes', pattern: '**/*.{md,mdx}' }),
schema: z.object({
title: z.string().optional(),
description: z.string().optional(),
pubDate: z.coerce.date().optional(),
updatedDate: z.coerce.date().optional(),
tags,
/** Obsidian / 同步脚本可用 publish: false 排除私密笔记 */
publish: z.boolean().default(true),
draft: z.boolean().default(false),
}),
});
export const collections = { posts, notes };
+52
View File
@@ -0,0 +1,52 @@
---
title: Obsidian 与本站
description: Vault 如何接到这个 Astro 项目。
tags:
- Obsidian
- 工作流
publish: true
pubDate: 2026-07-11
---
## 默认库路径
见 `src/consts.ts` 里的 `DEFAULT_VAULT_PATH`,当前指向:
`C:/Users/admin/Documents/Obsidian Vault`
也可用环境变量覆盖:
```powershell
$env:OBSIDIAN_VAULT="D:\path\to\vault"
npm run sync:vault
```
## 同步规则(sync-vault)
脚本会:
1. 扫描 Vault 下的 `.md`
2. 跳过 `.obsidian`、以 `.` 开头的目录、`private` / `templates` 等
3. 若 frontmatter 含 `publish: false` 或 `draft: true` 则跳过
4. 拷贝到 `src/content/notes/`,并尽量补全缺失的 `title`
## 图片
把图片放到 Vault 的可同步位置后,同步脚本会把常见图片拷到 `public/assets/`。
正文里可用:
```md
![[photo.png]]
```
或标准 Markdown:
```md
![说明](/assets/photo.png)
```
## 相关
- [[写作约定]]
- [示例文章](/posts/hello-notebook/)
+47
View File
@@ -0,0 +1,47 @@
---
title: 写作约定
description: 本站笔记与文章的约定,方便长期维护。
tags:
- 元笔记
- 开始
publish: true
pubDate: 2026-07-11
---
## Frontmatter
最少只需要:
```yaml
---
title: 标题
publish: true
---
```
常用字段:
- `title`:标题(可省略,默认用文件名)
- `description`:摘要
- `tags`:标签数组
- `pubDate` / `updatedDate`:日期
- `draft: true`:草稿,不构建
- `publish: false`:不同步/不公开
## 双链
Obsidian 风格:
- `[[写作约定]]` → 链到 `/notes/写作约定/`
- `[[写作约定|点这里]]` → 自定义显示文字
指向文章时,建议用标准 Markdown:
`[欢迎文](/posts/hello-notebook/)`
## 私密内容
私密笔记二选一:
1. frontmatter 写 `publish: false`
2. 放在同步脚本忽略的目录(如 `private/`)
@@ -0,0 +1,249 @@
---
title: "在 RTX 5090 32GB 上用 Docker Compose + llama.cpp 运行 MiroThinker-v1.5-30B Q5_K_M GGUF 的部署与调优"
publish: true
---
- 硬件平台:
- 显卡:5090 32GB
- CPU:intel
- 系统:ZimaOS xxx
## 一、前置准备
### 1、基础镜像准备
```Dockerfile
FROM pytorch/pytorch:2.8.0-cuda12.8-cudnn9-devel
RUN apt-get update && apt-get install -y --no-install-recommends \
git cmake build-essential ca-certificates \
libcurl4-openssl-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /opt
# build llama.cpp
RUN git clone --depth=1 https://github.com/ggml-org/llama.cpp.git \
&& cd llama.cpp \
&& cmake -B build -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=120 \
&& cmake --build build --config Release -j \
&& install -m 0755 /opt/llama.cpp/build/bin/llama-server /usr/local/bin/llama-server \
&& install -m 0755 /opt/llama.cpp/build/bin/llama-cli /usr/local/bin/llama-cli
# 约定:API 端口(你要 7070 就保留)
EXPOSE 7070
ENV MODEL_DIR=/models
ENV HOST=0.0.0.0
ENV PORT=7070
ENV CTX=8192
ENV NGL=99
ENV MODEL_FILE="MiroThinker-v1.5-30B.Q4_K_M.gguf"
# 用 CMD 明确执行,避免 ENTRYPOINT 乱拼
CMD ["bash", "-lc", "\
set -euo pipefail; \
echo '[env]'; echo \"MODEL_DIR=${MODEL_DIR}\"; echo \"MODEL_FILE=${MODEL_FILE}\"; echo \"HOST=${HOST}\"; echo \"PORT=${PORT}\"; echo \"CTX=${CTX}\"; echo \"NGL=${NGL}\"; \
echo '[models]'; ls -lah \"${MODEL_DIR}\" || true; \
MODEL_PATH=\"${MODEL_DIR}/${MODEL_FILE}\"; \
if [ -z \"${MODEL_FILE}\" ]; then echo 'ERROR: MODEL_FILE is empty'; exit 2; fi; \
if [ ! -f \"${MODEL_PATH}\" ]; then echo \"ERROR: model not found: ${MODEL_PATH}\"; exit 2; fi; \
echo \"[start] llama-server -m ${MODEL_PATH}\"; \
exec llama-server -m \"${MODEL_PATH}\" --host \"${HOST}\" --port \"${PORT}\" -c \"${CTX}\" -ngl \"${NGL}\" \
"]
```
将上面的文件存储为Dockerfile,放到xxx路径下
然后
```bash
docker build -t llama-cpp:cuda12.8 .
```
### 2、模型下载
依据本例中的硬件平台,选择MiroThinker-v1.5-30B Q5_K_M GGUF作为本次部署的模型,
#### 方法:huggingface-cli(推荐)
##### 1️⃣ 安装工具
```bash
pip install -U huggingface_hub
```
##### 2️⃣ 登录(可选,但建议)
```bash
huggingface-cli login
```
---
##### 3️⃣ 下载模型(示例)
👉 假设模型在类似 repo(示例):
bartowski/MiroThinker-30B-GGUF
执行:
```bash
huggingface-cli download bartowski/MiroThinker-30B-GGUF \
MiroThinker-30B.Q5_K_M.gguf \
--local-dir ./models
```
### 3、docker compose文件准备
```yaml
services:
miro-api:
image: llama-cpp:cuda12.8
container_name: miro-api
restart: unless-stopped
# 端口:llama-server 默认 8080
ports:
- "7070:7070"
# 模型不进镜像:只挂载(只读)
volumes:
- ./models:/models:ro
environment:
# 必填:模型文件名(位于 /models 下)
MODEL_FILE: "MiroThinker-v1.5-30B.Q4_K_M.gguf"
# 可选:server 监听
#HOST: "0.0.0.0"
#PORT: "8080"
# 可选:推理参数(按需改)
#CTX: "8192"
#NGL: "99"
# Docker Compose v2 支持该写法来申请 GPU
# 如果你的环境不支持,请看下面“GPU 兼容写法”
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: ["gpu"]
# (可选)共享内存,避免某些场景下内存不足
shm_size: "8gb"
```
将上面文件写入xxxxxxx路径后
执行
```bash
docker compose up
```
## 4、调用测试
终端运行
```
curl http://ip:7070/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "mirothinker",
"messages": [
{"role": "user", "content": "用简单的话解释量子力学"}
],
"temperature": 0.7
}'
```
看到回复即算调用成功
## 5、使用miroflow
还是以上述平台为例,我们使用mirothinker模型,运行一个简单的miroflow demo
首先
```bash
git clone https://github.com/MiroMindAI/MiroFlow
cd MiroFlow
pip install uv
uv sync
```
### 2、接入本地llama.cpp
修改config
config/agent_quickstart.yaml
```yaml
defaults:
- benchmark: example_dataset
- override hydra/job_logging: none
- _self_
# 避免 benchmark 里 openai_api_key 是 ???(虽然 trace_single_task 一般用不到,但写上更稳)
benchmark:
openai_api_key: "dummy"
main_agent:
prompt_class: MainAgentPromptBoxedAnswer
llm:
provider_class: "GPTOpenAIClient"
model_name: "MiroThinker-v1.5-30B.Q5_K_M.gguf" # 需与 /v1/models 返回的 id 一致
async_client: true
temperature: 0.2
top_p: 0.95
min_p: 0.0
top_k: -1
max_tokens: 512
openai_api_key: "dummy"
openai_base_url: "http://localhost:7070/v1"
keep_tool_result: -1
oai_tool_thinking: false
# 关键:先把工具全部关掉(避免 SERPER_API_KEY / JINA_API_KEY / E2B_API_KEY 等依赖)
tool_config: []
max_turns: 1
max_tool_calls_per_turn: 0
input_process:
hint_generation: false
hint_llm_base_url: "http://localhost:7070/v1"
output_process:
final_answer_extraction: false
final_answer_llm_base_url: "http://localhost:7070/v1"
# 这两个字段你当前版本的 orchestrator 会读,必须保留
openai_api_key: "dummy"
add_message_id: false
keep_tool_result: -1
chinese_context: "false"
sub_agents: null
output_dir: logs/
data_dir: data/
```
```bash
uv run main.py trace \
--config_file_name=agent_quickstart \
--task="请分析以下Python代码并指出bug:
def add(a,b):
return a-b"
```
预期结果如下:
```bash
root@ZimaOS:~/github.com/MiroFlow ➜ # uv run main.py trace --config_file_name=demo --task="分析下面的python代码: def add(a,b): return a-b"
The function subtracts b from a (misnamed as add), boxed_answer = The function subtracts b from a (misnamed as add)
```
+10
View File
@@ -0,0 +1,10 @@
---
title: "欢迎"
publish: true
---
这是你的新*仓库*。
写点笔记,[[创建链接]],或者试一试[导入器](https://help.obsidian.md/Plugins/Importer)插件!
当你准备好了,就将该笔记文件删除,使这个仓库为你所用。
+34
View File
@@ -0,0 +1,34 @@
---
title: 欢迎来到我的笔记博客
description: 用 Astro 搭的 Obsidian 笔记博客骨架,说明内容怎么组织。
pubDate: 2026-07-11
tags:
- 博客
- 开始
---
这是一篇**正式文章**示例。
## 两套内容
| 类型 | 目录 | 适合 |
|------|------|------|
| 文章 | `src/content/posts/` | 有日期的长文、教程、周记 |
| 笔记 | `src/content/notes/` | 常青笔记、卡片、概念页 |
## 写作流程
1. 在 Obsidian 里写
2. 公开笔记加 frontmatter(见下方)
3. `npm run sync:vault` 同步
4. `npm run dev` 预览
```yaml
---
title: 笔记标题
tags: [技术, 笔记]
publish: true
---
```
笔记之间可以用双链互相引用,例如:[[写作约定]]、[[Obsidian 与本站]]。
+37
View File
@@ -0,0 +1,37 @@
---
import BaseHead from '../components/BaseHead.astro';
import Footer from '../components/Footer.astro';
import Header from '../components/Header.astro';
interface Props {
title: string;
description?: string;
/** 宽内容区(列表/看板页) */
wide?: boolean;
}
const { title, description = '', wide = false } = Astro.props;
---
<!doctype html>
<html lang="zh-CN">
<head>
<BaseHead title={title} description={description} />
</head>
<body>
<Header />
<main class:list={[{ wide, boarded: wide }]}>
<slot />
</main>
<Footer />
</body>
</html>
<style>
main.boarded {
width: min(1180px, 100%);
max-width: 100%;
padding-left: var(--page-pad-x);
padding-right: var(--page-pad-x);
}
</style>
+222
View File
@@ -0,0 +1,222 @@
---
import type { ImageMetadata } from 'astro';
import { Image } from 'astro:assets';
import type { NoteEntry, PostEntry } from '../lib/content';
import BaseLayout from './BaseLayout.astro';
import FormattedDate from '../components/FormattedDate.astro';
import DenseEntryList from '../components/DenseEntryList.astro';
import { getPublishedNotes, getPublishedPosts, getTagMap } from '../lib/content';
interface Props {
title: string;
description?: string;
pubDate?: Date;
updatedDate?: Date;
heroImage?: ImageMetadata;
tags?: string[];
kind?: 'post' | 'note';
/** 当前条目 id,用于排除自身 */
currentId?: string;
related?: Array<PostEntry | NoteEntry>;
}
const {
title,
description = '',
pubDate,
updatedDate,
heroImage,
tags = [],
kind = 'post',
currentId,
related,
} = Astro.props;
const [posts, notes, tagMap] = await Promise.all([
getPublishedPosts(),
getPublishedNotes(),
getTagMap(),
]);
const pool = kind === 'post' ? posts : notes;
const others = pool.filter((e) => e.id !== currentId);
const relatedResolved =
related ??
(() => {
if (!tags.length) return others.slice(0, 6);
const scored = others
.map((e) => {
const et = new Set(e.data.tags ?? []);
const score = tags.reduce((n, t) => n + (et.has(t) ? 1 : 0), 0);
return { e, score };
})
.filter((x) => x.score > 0)
.sort((a, b) => b.score - a.score)
.map((x) => x.e);
return (scored.length ? scored : others).slice(0, 6);
})();
const otherKindList = (kind === 'post' ? notes : posts).slice(0, 6);
const topTags = [...tagMap.entries()]
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 8);
---
<BaseLayout title={title} description={description} wide>
<div class="prose-board">
<article class="article">
{
heroImage && (
<div class="hero-image">
<Image width={1020} height={510} src={heroImage} alt="" />
</div>
)
}
<header class="article-head">
<p class="kind meta">{kind === 'post' ? '文章' : '笔记'}</p>
<h1>{title}</h1>
{
(pubDate || updatedDate) && (
<p class="meta">
{pubDate && <FormattedDate date={pubDate} />}
{updatedDate && (
<>
{pubDate ? ' · ' : ''}
更新于 <FormattedDate date={updatedDate} />
</>
)}
</p>
)
}
{
tags.length > 0 && (
<div class="tag-row">
{tags.map((tag) => (
<a class="tag" href={`/tags/${encodeURIComponent(tag)}/`}>
#{tag}
</a>
))}
</div>
)
}
</header>
<div class="prose">
<slot />
</div>
</article>
<aside class="board-side">
<section class="panel">
<div class="panel-head">
<h2>本页</h2>
</div>
<ul class="bullet-list">
<li>类型:{kind === 'post' ? '文章' : '笔记'}</li>
{pubDate && (
<li>
发布:
<FormattedDate date={pubDate} />
</li>
)}
{updatedDate && (
<li>
更新:
<FormattedDate date={updatedDate} />
</li>
)}
<li>
返回
<a href={kind === 'post' ? '/posts/' : '/notes/'}>
{kind === 'post' ? '文章列表' : '笔记列表'}
</a>
</li>
</ul>
</section>
{
tags.length > 0 && (
<section class="panel">
<div class="panel-head">
<h2>标签</h2>
</div>
<div class="tag-cloud">
{tags.map((tag) => (
<a class="tag" href={`/tags/${encodeURIComponent(tag)}/`}>
#{tag}
</a>
))}
</div>
</section>
)
}
<section class="panel">
<div class="panel-head">
<h2>{kind === 'post' ? '相关文章' : '相关笔记'}</h2>
<a href={kind === 'post' ? '/posts/' : '/notes/'}>全部 →</a>
</div>
<DenseEntryList entries={relatedResolved} empty="暂无相关内容" showKind={false} />
</section>
<section class="panel">
<div class="panel-head">
<h2>{kind === 'post' ? '最近笔记' : '最近文章'}</h2>
<a href={kind === 'post' ? '/notes/' : '/posts/'}>全部 →</a>
</div>
<DenseEntryList entries={otherKindList} empty="暂无" showKind={false} />
</section>
{
topTags.length > 0 && (
<section class="panel">
<div class="panel-head">
<h2>热门标签</h2>
<a href="/tags/">全部 →</a>
</div>
<div class="tag-cloud">
{topTags.map(([tag, items]) => (
<a class="tag" href={`/tags/${encodeURIComponent(tag)}/`}>
#{tag}
<span class="count">{items.length}</span>
</a>
))}
</div>
</section>
)
}
</aside>
</div>
</BaseLayout>
<style>
.article-head {
margin-bottom: 1.4rem;
padding-bottom: 0.95rem;
border-bottom: 1px solid rgb(var(--line));
animation: fade-up var(--dur-slow) var(--ease-out) both;
}
.article-head h1 {
margin-bottom: 0.35em;
}
.kind {
margin: 0 0 0.4rem;
letter-spacing: 0.04em;
font-size: 0.8em;
}
.hero-image {
margin: 0 0 1.1rem;
animation: soft-scale var(--dur-slow) var(--ease-out) both;
}
.hero-image :global(img) {
width: 100%;
display: block;
box-shadow: var(--shadow);
}
.prose {
animation: fade-up var(--dur-slow) var(--ease-out) 80ms both;
}
.prose :global(> *:first-child) {
margin-top: 0;
}
</style>
+47
View File
@@ -0,0 +1,47 @@
import { getCollection, type CollectionEntry } from 'astro:content';
export type PostEntry = CollectionEntry<'posts'>;
export type NoteEntry = CollectionEntry<'notes'>;
/** 已发布、非草稿的文章(按日期新→旧) */
export async function getPublishedPosts(): Promise<PostEntry[]> {
const posts = await getCollection('posts', ({ data }) => !data.draft);
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
}
/** 已发布、非草稿的笔记 */
export async function getPublishedNotes(): Promise<NoteEntry[]> {
const notes = await getCollection('notes', ({ data }) => !data.draft && data.publish !== false);
return notes.sort((a, b) => {
const da = a.data.updatedDate ?? a.data.pubDate ?? new Date(0);
const db = b.data.updatedDate ?? b.data.pubDate ?? new Date(0);
return db.valueOf() - da.valueOf();
});
}
/** 汇总标签 → 条目列表 */
export async function getTagMap(): Promise<Map<string, Array<PostEntry | NoteEntry>>> {
const [posts, notes] = await Promise.all([getPublishedPosts(), getPublishedNotes()]);
const map = new Map<string, Array<PostEntry | NoteEntry>>();
for (const entry of [...posts, ...notes]) {
for (const tag of entry.data.tags ?? []) {
const list = map.get(tag) ?? [];
list.push(entry);
map.set(tag, list);
}
}
return map;
}
export function entryHref(entry: PostEntry | NoteEntry): string {
if (entry.collection === 'posts') return `/posts/${entry.id}/`;
return `/notes/${entry.id}/`;
}
export function entryTitle(entry: PostEntry | NoteEntry): string {
if (entry.data.title) return entry.data.title;
const base = entry.id.split('/').pop() ?? entry.id;
return base;
}
+16
View File
@@ -0,0 +1,16 @@
/** 把笔记标题 / 文件名收成 URL 友好的 id(保留中文) */
export function toNoteSlug(input: string): string {
return input
.trim()
.replace(/\\/g, '/')
.replace(/\.mdx?$/i, '')
.replace(/^\/+|\/+$/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
/** 从 content collection id 得到展示标题 */
export function titleFromId(id: string): string {
const base = id.split('/').pop() ?? id;
return base.replace(/-/g, ' ');
}
+86
View File
@@ -0,0 +1,86 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import SiteAside from '../components/SiteAside.astro';
import { SITE_AUTHOR, SITE_DESCRIPTION, SITE_TITLE } from '../consts';
import { getPublishedNotes, getPublishedPosts } from '../lib/content';
const posts = await getPublishedPosts();
const notes = await getPublishedNotes();
---
<BaseLayout title={`关于 · ${SITE_TITLE}`} description={SITE_DESCRIPTION} wide>
<div class="page-board">
<div class="board-main">
<section class="panel page-title-block">
<h1>关于</h1>
<p class="lede">{SITE_DESCRIPTION}</p>
</section>
<section class="panel">
<div class="panel-head">
<h2>这个站</h2>
</div>
<p>
这里是 <strong>{SITE_AUTHOR}</strong> 的笔记博客:在 <strong>Obsidian</strong> 里写,用
<strong>Astro</strong> 静态生成后发布。
</p>
<p class="meta">
当前已发布:文章 {posts.length} · 笔记 {notes.length}
</p>
</section>
<section class="panel">
<div class="panel-head">
<h2>内容结构</h2>
</div>
<ul class="bullet-list">
<li>
<strong>文章</strong>(<code>src/content/posts/</code>):有日期的长文、教程
</li>
<li>
<strong>笔记</strong>(<code>src/content/notes/</code>):常青笔记,支持
<code>[[双链]]</code>
</li>
<li>
<strong>标签</strong>:frontmatter 里的 <code>tags</code> 自动聚合
</li>
</ul>
</section>
<section class="panel">
<div class="panel-head">
<h2>和 Obsidian 怎么配合</h2>
</div>
<ol class="bullet-list numbered">
<li>在库里正常写 Markdown</li>
<li>需要上线的笔记加上 <code>publish: true</code></li>
<li>运行 <code>npm run sync:vault</code> 同步到本站</li>
<li><code>npm run dev</code> 预览,<code>npm run build</code> 构建</li>
</ol>
<p class="meta" style="margin-top: 0.75rem">
站名、作者、库路径在 <code>src/consts.ts</code> 修改。
</p>
</section>
</div>
<SiteAside
active="about"
listTitle="最近内容"
listHref="/"
showKind={true}
tipsTitle="技术栈"
tips={['Astro 静态站点', 'Obsidian wikilink 插件', 'Git 友好 Markdown']}
/>
</div>
</BaseLayout>
<style>
.numbered {
padding-left: 1.25rem;
list-style: decimal;
}
.board-main p {
margin: 0 0 0.75rem;
font-size: 0.95rem;
}
</style>
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
---
import { type CollectionEntry, render } from 'astro:content';
import ProseLayout from '../../layouts/ProseLayout.astro';
import { getPublishedNotes } from '../../lib/content';
import { titleFromId } from '../../lib/slug';
export async function getStaticPaths() {
const notes = await getPublishedNotes();
return notes.map((note) => ({
params: { slug: note.id },
props: note,
}));
}
type Props = CollectionEntry<'notes'>;
const note = Astro.props;
const { Content } = await render(note);
const title = note.data.title || titleFromId(note.id);
---
<ProseLayout
title={title}
description={note.data.description || title}
pubDate={note.data.pubDate}
updatedDate={note.data.updatedDate}
tags={note.data.tags}
kind="note"
currentId={note.id}
>
<Content />
</ProseLayout>
+51
View File
@@ -0,0 +1,51 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DenseEntryList from '../../components/DenseEntryList.astro';
import SiteAside from '../../components/SiteAside.astro';
import { SITE_TITLE } from '../../consts';
import { getPublishedNotes, getPublishedPosts } from '../../lib/content';
const notes = await getPublishedNotes();
const posts = await getPublishedPosts();
---
<BaseLayout title={`笔记 · ${SITE_TITLE}`} description="来自 Obsidian 的常青笔记" wide>
<div class="page-board">
<div class="board-main">
<section class="panel page-title-block">
<h1>笔记</h1>
<p class="lede">
常青笔记,支持 <code>[[双链]]</code>。共 <strong>{notes.length}</strong> 篇。可用
<code>npm run sync:vault</code> 从 Obsidian 同步。
</p>
</section>
<section class="panel">
<div class="panel-head">
<h2>全部笔记</h2>
<span class="meta">{notes.length} 篇</span>
</div>
<DenseEntryList
entries={notes}
empty="还没有笔记。运行 npm run sync:vault 或手动添加 Markdown。"
showKind={false}
/>
</section>
</div>
<SiteAside
active="notes"
listTitle="最近文章"
listHref="/posts/"
listEntries={posts}
listEmpty="暂无文章"
showKind={false}
tipsTitle="同步提示"
tips={[
'公开笔记加 <code>publish: true</code>',
'私密笔记 <code>publish: false</code> 或放 private/',
'文件名尽量与 <code>[[双链]]</code> 目标一致',
]}
/>
</div>
</BaseLayout>
+32
View File
@@ -0,0 +1,32 @@
---
import { type CollectionEntry, render } from 'astro:content';
import ProseLayout from '../../layouts/ProseLayout.astro';
import { getPublishedPosts } from '../../lib/content';
import { titleFromId } from '../../lib/slug';
export async function getStaticPaths() {
const posts = await getPublishedPosts();
return posts.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
type Props = CollectionEntry<'posts'>;
const post = Astro.props;
const { Content } = await render(post);
const title = post.data.title || titleFromId(post.id);
---
<ProseLayout
title={title}
description={post.data.description}
pubDate={post.data.pubDate}
updatedDate={post.data.updatedDate}
heroImage={post.data.heroImage}
tags={post.data.tags}
kind="post"
currentId={post.id}
>
<Content />
</ProseLayout>
+50
View File
@@ -0,0 +1,50 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DenseEntryList from '../../components/DenseEntryList.astro';
import SiteAside from '../../components/SiteAside.astro';
import { SITE_TITLE } from '../../consts';
import { getPublishedNotes, getPublishedPosts } from '../../lib/content';
const posts = await getPublishedPosts();
const notes = await getPublishedNotes();
---
<BaseLayout title={`文章 · ${SITE_TITLE}`} description="按时间排序的博文列表" wide>
<div class="page-board">
<div class="board-main">
<section class="panel page-title-block">
<h1>文章</h1>
<p class="lede">
正式长文与教程,按发布日期排序。共 <strong>{posts.length}</strong> 篇。
</p>
</section>
<section class="panel">
<div class="panel-head">
<h2>全部文章</h2>
<span class="meta">{posts.length} 篇</span>
</div>
<DenseEntryList
entries={posts}
empty="还没有文章。在 src/content/posts/ 新建 Markdown 即可。"
showKind={false}
/>
</section>
</div>
<SiteAside
active="posts"
listTitle="最近笔记"
listHref="/notes/"
listEntries={notes}
listEmpty="暂无笔记"
showKind={false}
tipsTitle="写作提示"
tips={[
'文章适合完整叙述、教程与周记',
'frontmatter 需要 <code>title</code> 与 <code>pubDate</code>',
'草稿用 <code>draft: true</code> 隐藏',
]}
/>
</div>
</BaseLayout>
+21
View File
@@ -0,0 +1,21 @@
import { getCollection } from 'astro:content';
import rss from '@astrojs/rss';
import { SITE_DESCRIPTION, SITE_TITLE } from '../consts';
export async function GET(context) {
const posts = (await getCollection('posts', ({ data }) => !data.draft)).sort(
(a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),
);
return rss({
title: SITE_TITLE,
description: SITE_DESCRIPTION,
site: context.site,
items: posts.map((post) => ({
title: post.data.title,
description: post.data.description,
pubDate: post.data.pubDate,
link: `/posts/${post.id}/`,
})),
});
}
+118
View File
@@ -0,0 +1,118 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DenseEntryList from '../../components/DenseEntryList.astro';
import SiteAside from '../../components/SiteAside.astro';
import { SITE_TITLE } from '../../consts';
import { getPublishedNotes, getPublishedPosts, getTagMap } from '../../lib/content';
export async function getStaticPaths() {
const tagMap = await getTagMap();
return [...tagMap.entries()].map(([tag, entries]) => ({
params: { tag },
props: { tag, entries },
}));
}
const { tag, entries } = Astro.props;
const posts = await getPublishedPosts();
const notes = await getPublishedNotes();
const otherTags = [...(await getTagMap()).entries()]
.filter(([t]) => t !== tag)
.sort((a, b) => b[1].length - a[1].length)
.slice(0, 8);
const postsInTag = entries.filter((e) => e.collection === 'posts');
const notesInTag = entries.filter((e) => e.collection === 'notes');
---
<BaseLayout title={`#${tag} · ${SITE_TITLE}`} description={`标签:${tag}`} wide>
<div class="page-board">
<div class="board-main">
<section class="panel page-title-block">
<p class="meta"><a href="/tags/">← 全部标签</a></p>
<h1>#{tag}</h1>
<p class="lede">
共 <strong>{entries.length}</strong> 篇(文章 {postsInTag.length} · 笔记 {notesInTag.length})
</p>
</section>
{
postsInTag.length > 0 && (
<section class="panel">
<div class="panel-head">
<h2>文章</h2>
<span class="meta">{postsInTag.length}</span>
</div>
<DenseEntryList entries={postsInTag} showKind={false} />
</section>
)
}
{
notesInTag.length > 0 && (
<section class="panel">
<div class="panel-head">
<h2>笔记</h2>
<span class="meta">{notesInTag.length}</span>
</div>
<DenseEntryList entries={notesInTag} showKind={false} />
</section>
)
}
{
entries.length === 0 && (
<section class="panel">
<p class="empty-hint">该标签下暂无内容。</p>
</section>
)
}
</div>
<aside class="board-side">
<section class="panel">
<div class="panel-head">
<h2>其他标签</h2>
<a href="/tags/">全部 →</a>
</div>
{
otherTags.length === 0 ? (
<p class="empty-hint">暂无</p>
) : (
<div class="tag-cloud">
{otherTags.map(([t, items]) => (
<a class="tag" href={`/tags/${encodeURIComponent(t)}/`}>
#{t}
<span class="count">{items.length}</span>
</a>
))}
</div>
)
}
</section>
<section class="panel">
<div class="panel-head">
<h2>快捷入口</h2>
</div>
<nav class="quick-nav">
<a href="/posts/"><span>文章</span><em>{posts.length}</em></a>
<a href="/notes/"><span>笔记</span><em>{notes.length}</em></a>
<a href="/tags/"><span>标签</span><em>→</em></a>
<a href="/"><span>首页</span><em>⌂</em></a>
</nav>
</section>
<section class="panel">
<div class="panel-head">
<h2>站内最近</h2>
</div>
<DenseEntryList
entries={[...posts, ...notes].slice(0, 6)}
showKind={true}
empty="暂无"
/>
</section>
</aside>
</div>
</BaseLayout>
+123
View File
@@ -0,0 +1,123 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import DenseEntryList from '../../components/DenseEntryList.astro';
import SiteAside from '../../components/SiteAside.astro';
import { SITE_TITLE } from '../../consts';
import { getPublishedNotes, getPublishedPosts, getTagMap } from '../../lib/content';
const tagMap = await getTagMap();
const tags = [...tagMap.entries()].sort((a, b) => {
const byCount = b[1].length - a[1].length;
return byCount !== 0 ? byCount : a[0].localeCompare(b[0], 'zh-CN');
});
const posts = await getPublishedPosts();
const notes = await getPublishedNotes();
const recent = [...posts, ...notes].slice(0, 8);
---
<BaseLayout title={`标签 · ${SITE_TITLE}`} description="按标签浏览" wide>
<div class="page-board">
<div class="board-main">
<section class="panel page-title-block">
<h1>标签</h1>
<p class="lede">
从 frontmatter 的 <code>tags</code> 汇总。共 <strong>{tags.length}</strong> 个标签。
</p>
</section>
<section class="panel">
<div class="panel-head">
<h2>全部标签</h2>
<span class="meta">{tags.length} 个</span>
</div>
{
tags.length === 0 ? (
<p class="empty-hint">
还没有标签。在 frontmatter 里写 <code>tags: [笔记]</code> 即可。
</p>
) : (
<ul class="tag-table">
{tags.map(([tag, items]) => (
<li>
<a class="tag-row-item" href={`/tags/${encodeURIComponent(tag)}/`}>
<span class="tag-name">#{tag}</span>
<span class="tag-count">{items.length} 篇</span>
<span class="tag-preview meta">
{items
.slice(0, 3)
.map((e) => e.data.title || e.id)
.join(' · ')}
</span>
</a>
</li>
))}
</ul>
)
}
</section>
</div>
<SiteAside
active="tags"
listTitle="最近内容"
listHref="/"
listEntries={recent}
showKind={true}
tipsTitle="怎么打标签"
tips={[
'YAML:<code>tags: [技术, 笔记]</code>',
'同标签文章与笔记会归在一起',
'标签页可做主题合集入口',
]}
/>
</div>
</BaseLayout>
<style>
.tag-table {
list-style: none;
margin: 0;
padding: 0;
}
.tag-table > li + li {
border-top: 1px solid rgb(var(--line));
}
.tag-row-item {
display: grid;
grid-template-columns: minmax(5rem, 8rem) 3.5rem minmax(0, 1fr);
gap: 0.65rem;
align-items: baseline;
padding: 0.62rem 0.15rem;
text-decoration: none;
color: inherit;
border-radius: 6px;
}
.tag-row-item:hover {
background: var(--accent-soft);
color: inherit;
}
.tag-name {
font-weight: 700;
color: var(--accent-dark);
}
.tag-count {
font-size: 0.8rem;
color: rgb(var(--muted));
font-variant-numeric: tabular-nums;
}
.tag-preview {
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 720px) {
.tag-row-item {
grid-template-columns: 1fr auto;
}
.tag-preview {
grid-column: 1 / -1;
white-space: normal;
}
}
</style>
+89
View File
@@ -0,0 +1,89 @@
/**
* 轻量 Obsidian Markdown 兼容:
* - [[笔记]] / [[笔记|显示名]] → 站内链接
* - ![[image.png]] → 图片
* - #tag(行内,避免标题)可选保留为纯文本(标签页走 frontmatter)
*
* 链接策略:优先当笔记 /notes/{slug}/,也兼容 /posts/{slug}/
* 文件名与 wikilink 目标保持一致即可(中文文件名 OK)。
*/
import { visit } from 'unist-util-visit';
const WIKI_RE = /(!)?\[\[([^\]|#]+)(?:#([^\]|]+))?(?:\|([^\]]+))?\]\]/g;
function toSlug(raw) {
// 与 Astro content id 对齐:空白 → -,ASCII 小写(中文不变)
return String(raw)
.trim()
.replace(/\\/g, '/')
.replace(/\.mdx?$/i, '')
.replace(/^\/+|\/+$/g, '')
.replace(/\s+/g, '-')
.toLowerCase();
}
function guessHref(target) {
const slug = toSlug(target);
// 资源文件走 /assets 或原路径
if (/\.(png|jpe?g|gif|webp|svg|pdf|mp4|mp3)$/i.test(slug)) {
return `/assets/${slug.split('/').pop()}`;
}
// 默认进 notes;posts 同名时请在正文里用标准 Markdown 链到 /posts/...
return `/notes/${encodeURI(slug)}/`;
}
function splitTextNode(value) {
const nodes = [];
let last = 0;
let match;
const re = new RegExp(WIKI_RE.source, 'g');
while ((match = re.exec(value)) !== null) {
const [full, bang, target, heading, alias] = match;
if (match.index > last) {
nodes.push({ type: 'text', value: value.slice(last, match.index) });
}
const display = (alias || target).trim();
const href = guessHref(target) + (heading ? `#${encodeURIComponent(heading)}` : '');
if (bang) {
nodes.push({
type: 'image',
url: href.startsWith('/assets/') ? href : guessHref(target),
alt: display,
title: null,
});
} else {
nodes.push({
type: 'link',
url: href,
title: null,
children: [{ type: 'text', value: display }],
});
}
last = match.index + full.length;
}
if (last < value.length) {
nodes.push({ type: 'text', value: value.slice(last) });
}
return nodes.length ? nodes : [{ type: 'text', value }];
}
export function remarkObsidian() {
return (tree) => {
visit(tree, 'text', (node, index, parent) => {
if (!parent || typeof index !== 'number') return;
if (!node.value || !node.value.includes('[[')) return;
// 跳过代码
if (parent.type === 'code' || parent.type === 'inlineCode') return;
const replacement = splitTextNode(node.value);
parent.children.splice(index, 1, ...replacement);
return index + replacement.length;
});
};
}
+289
View File
@@ -0,0 +1,289 @@
/* 全站信息板:用侧栏模块填内容,不是拉宽留白 */
.page-board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
gap: 0.9rem;
align-items: start;
width: min(1180px, 100%);
margin: 0 auto;
}
.page-board.with-left {
grid-template-columns: minmax(210px, 260px) minmax(0, 1fr) minmax(240px, 300px);
}
.page-board .board-main,
.page-board .board-side,
.page-board .board-left {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.panel {
border: 1px solid rgb(var(--line));
border-radius: var(--radius);
background: rgb(var(--surface-elevated));
box-shadow: var(--shadow);
padding: 0.85rem 0.95rem;
}
.panel-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.6rem;
}
.panel-head h2,
.panel-head h1 {
margin: 0;
font-size: 0.92rem;
letter-spacing: 0.01em;
}
.panel-head a,
.text-link {
font-size: 0.82rem;
text-decoration: none;
color: var(--accent);
font-weight: 600;
}
.panel-head a:hover,
.text-link:hover {
color: var(--accent-dark);
}
.page-title-block h1 {
margin: 0 0 0.35rem;
font-size: clamp(1.35rem, 2.5vw, 1.7rem);
}
.page-title-block .lede {
margin: 0;
color: rgb(var(--muted));
font-size: 0.92rem;
}
.metric-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.4rem;
}
.metric {
padding: 0.5rem 0.55rem;
border-radius: 8px;
background: rgb(var(--code-bg));
}
.metric strong {
display: block;
font-size: 1.15rem;
line-height: 1.1;
color: var(--accent-dark);
font-variant-numeric: tabular-nums;
}
.metric span {
font-size: 0.74rem;
color: rgb(var(--muted));
}
.quick-nav {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.quick-nav a {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.75rem;
padding: 0.45rem 0.5rem;
border-radius: 8px;
text-decoration: none;
color: rgb(var(--ink));
font-size: 0.88rem;
transition: background var(--dur-fast) var(--ease-out);
}
.quick-nav a:hover,
.quick-nav a.active {
background: var(--accent-soft);
color: var(--accent-dark);
}
.quick-nav em {
font-style: normal;
color: rgb(var(--muted));
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
/* 密集行列表 */
.dense-list {
list-style: none;
margin: 0;
padding: 0;
}
.dense-list > li + li {
border-top: 1px solid rgb(var(--line));
}
.dense-item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.5rem 0.75rem;
align-items: start;
padding: 0.58rem 0.1rem;
text-decoration: none;
color: inherit;
border-radius: 6px;
transition: background var(--dur-fast) var(--ease-out);
}
.dense-item:hover {
background: var(--accent-soft);
color: inherit;
}
.dense-main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.dense-title {
font-size: 0.92rem;
font-weight: 600;
line-height: 1.35;
}
.dense-item:hover .dense-title {
color: var(--accent-dark);
}
.dense-desc {
font-size: 0.8rem;
color: rgb(var(--muted));
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dense-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.12rem;
font-size: 0.74rem;
color: rgb(var(--muted));
white-space: nowrap;
padding-top: 0.1rem;
}
.dense-tag {
color: var(--accent-dark);
font-weight: 600;
}
.kind-dot {
display: inline-block;
width: 0.45rem;
height: 0.45rem;
border-radius: 50%;
background: var(--accent);
margin-right: 0.35rem;
vertical-align: middle;
}
.kind-dot.note {
background: color-mix(in srgb, var(--accent) 55%, #888);
}
.tag-cloud {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.tag-cloud .count {
margin-left: 0.25em;
opacity: 0.7;
}
.bullet-list {
margin: 0;
padding-left: 1.1rem;
font-size: 0.86rem;
color: rgb(var(--muted));
}
.bullet-list li {
margin: 0.28rem 0;
}
.empty-hint {
margin: 0.2rem 0;
font-size: 0.88rem;
color: rgb(var(--muted));
}
/* 正文页:主栏阅读 + 右侧信息 */
.prose-board {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(240px, 300px);
gap: 1rem;
align-items: start;
width: min(1120px, 100%);
margin: 0 auto;
}
.prose-board .article {
min-width: 0;
max-width: 46rem;
}
.prose-board .board-side {
position: sticky;
top: 4rem;
}
@media (max-width: 960px) {
.page-board,
.page-board.with-left,
.prose-board {
grid-template-columns: 1fr;
}
.prose-board .board-side {
position: static;
}
.prose-board .article {
max-width: none;
}
}
@media (max-width: 720px) {
.dense-item {
grid-template-columns: 1fr;
gap: 0.2rem;
}
.dense-meta {
flex-direction: row;
align-items: center;
gap: 0.45rem;
}
}
+483
View File
@@ -0,0 +1,483 @@
/*
笔记博客视觉:纸感底、单一强调色、偏阅读密度
*/
:root {
--accent: #1f6b5a;
--accent-soft: rgba(31, 107, 90, 0.12);
--accent-dark: #0f3d34;
--ink: 28, 30, 32;
--muted: 96, 102, 110;
--line: 222, 224, 228;
--surface: 252, 251, 248;
--surface-elevated: 255, 255, 255;
--code-bg: 242, 241, 237;
--shadow: 0 1px 2px rgba(var(--ink), 0.04), 0 8px 24px rgba(var(--ink), 0.05);
--shadow-lift: 0 4px 12px rgba(var(--ink), 0.07), 0 14px 32px rgba(var(--ink), 0.08);
--radius: 10px;
/* 阅读正文略宽一点;列表/首页用更宽壳,减少大屏两侧空旷 */
--content: 46rem;
--wide: min(72rem, calc(100vw - 3rem));
--page-pad-x: clamp(1rem, 3vw, 2rem);
/* 动效节奏:短、稳、不抢戏 */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--dur-fast: 150ms;
--dur: 220ms;
--dur-slow: 420ms;
}
@media (prefers-color-scheme: dark) {
:root {
--accent: #5dbea8;
--accent-soft: rgba(93, 190, 168, 0.14);
--accent-dark: #9ad9c9;
--ink: 236, 236, 232;
--muted: 160, 164, 170;
--line: 52, 56, 62;
--surface: 18, 20, 22;
--surface-elevated: 26, 28, 31;
--code-bg: 34, 37, 42;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.25);
--shadow-lift: 0 6px 18px rgba(0, 0, 0, 0.35), 0 16px 36px rgba(0, 0, 0, 0.28);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
font-family:
var(--font-atkinson),
'Segoe UI',
'PingFang SC',
'Hiragino Sans GB',
'Microsoft YaHei',
sans-serif;
margin: 0;
padding: 0;
background: rgb(var(--surface));
color: rgb(var(--ink));
font-size: 15px;
line-height: 1.7;
word-wrap: break-word;
overflow-wrap: break-word;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
}
main {
width: var(--content);
max-width: 100%;
margin: 0 auto;
padding: 2rem var(--page-pad-x) 3.5rem;
/* 页面进入 */
animation: page-in var(--dur-slow) var(--ease-out) both;
}
main.wide {
width: var(--wide);
max-width: 100%;
}
@keyframes page-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes fade-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes soft-scale {
from {
opacity: 0;
transform: scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin: 0 0 0.55em;
line-height: 1.28;
font-weight: 700;
letter-spacing: -0.02em;
text-wrap: balance;
color: rgb(var(--ink));
}
h1 {
font-size: clamp(1.45rem, 3.2vw, 1.85rem);
}
h2 {
font-size: 1.28rem;
margin-top: 1.75em;
}
h3 {
font-size: 1.08rem;
margin-top: 1.4em;
}
p {
margin: 0 0 0.95em;
}
a {
color: var(--accent);
text-decoration-thickness: 1px;
text-underline-offset: 0.18em;
transition:
color var(--dur-fast) var(--ease-out),
text-underline-offset var(--dur-fast) var(--ease-out),
opacity var(--dur-fast) var(--ease-out);
}
a:hover {
color: var(--accent-dark);
text-underline-offset: 0.28em;
}
strong,
b {
font-weight: 700;
}
ul,
ol {
padding-left: 1.3em;
margin: 0 0 0.95em;
}
li {
margin: 0.2em 0;
}
img {
max-width: 100%;
height: auto;
border-radius: var(--radius);
transition: transform var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out);
}
.prose img:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-lift);
}
hr {
border: none;
border-top: 1px solid rgb(var(--line));
margin: 1.75rem 0;
}
code {
font-family: ui-monospace, 'Cascadia Code', 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.88em;
padding: 0.1em 0.35em;
background: rgb(var(--code-bg));
border-radius: 4px;
}
pre {
padding: 0.95rem 1.05rem;
border-radius: var(--radius);
overflow-x: auto;
background: rgb(var(--code-bg));
border: 1px solid rgb(var(--line));
margin: 0 0 1.2em;
font-size: 0.84em;
line-height: 1.55;
transition: border-color var(--dur) var(--ease-out), box-shadow var(--dur) var(--ease-out);
}
pre:hover {
border-color: color-mix(in srgb, var(--accent) 35%, rgb(var(--line)));
box-shadow: var(--shadow);
}
pre > code {
all: unset;
font-family: inherit;
}
blockquote {
margin: 0 0 1.2em;
padding: 0.15em 0 0.15em 0.9em;
border-left: 3px solid var(--accent);
color: rgb(var(--muted));
transition: background var(--dur) var(--ease-out);
}
table {
width: 100%;
border-collapse: collapse;
margin: 0 0 1.2em;
font-size: 0.92em;
}
th,
td {
border: 1px solid rgb(var(--line));
padding: 0.45em 0.65em;
text-align: left;
}
th {
background: rgb(var(--code-bg));
}
/* Obsidian callout 粗支持:> [!note] */
blockquote:has(> p:first-child) {
background: var(--accent-soft);
border-radius: 0 var(--radius) var(--radius) 0;
padding: 0.75em 0.9em;
}
.prose :where(h2, h3, h4) {
scroll-margin-top: 5rem;
}
/* 正文链接:底线生长感 */
.prose a {
background-image: linear-gradient(currentColor, currentColor);
background-position: 0 100%;
background-repeat: no-repeat;
background-size: 0% 1px;
text-decoration: none;
transition:
background-size var(--dur) var(--ease-out),
color var(--dur-fast) var(--ease-out);
}
.prose a:hover {
background-size: 100% 1px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 0.1em 0.5em;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-dark);
font-size: 0.8em;
text-decoration: none;
line-height: 1.45;
transition:
background var(--dur-fast) var(--ease-out),
transform var(--dur-fast) var(--ease-out),
box-shadow var(--dur-fast) var(--ease-out);
}
.tag:hover {
background: rgba(31, 107, 90, 0.22);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(31, 107, 90, 0.12);
color: var(--accent-dark);
}
.meta {
color: rgb(var(--muted));
font-size: 0.88em;
}
.card-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.75rem;
}
.card-list > li {
animation: fade-up var(--dur-slow) var(--ease-out) both;
}
/* 列表错峰入场,最多 8 项 */
.card-list > li:nth-child(1) {
animation-delay: 40ms;
}
.card-list > li:nth-child(2) {
animation-delay: 80ms;
}
.card-list > li:nth-child(3) {
animation-delay: 120ms;
}
.card-list > li:nth-child(4) {
animation-delay: 160ms;
}
.card-list > li:nth-child(5) {
animation-delay: 200ms;
}
.card-list > li:nth-child(6) {
animation-delay: 240ms;
}
.card-list > li:nth-child(7) {
animation-delay: 280ms;
}
.card-list > li:nth-child(8) {
animation-delay: 320ms;
}
.card-list > li:nth-child(n + 9) {
animation-delay: 360ms;
}
.card {
display: block;
padding: 0.95rem 1.05rem;
border: 1px solid rgb(var(--line));
border-radius: var(--radius);
background: rgb(var(--surface-elevated));
text-decoration: none;
color: inherit;
box-shadow: var(--shadow);
transition:
border-color var(--dur) var(--ease-out),
transform var(--dur) var(--ease-out),
box-shadow var(--dur) var(--ease-out),
background var(--dur) var(--ease-out);
}
.card:hover {
border-color: color-mix(in srgb, var(--accent) 55%, rgb(var(--line)));
transform: translateY(-3px);
box-shadow: var(--shadow-lift);
color: inherit;
background: color-mix(in srgb, rgb(var(--surface-elevated)) 92%, var(--accent-soft));
}
.card:active {
transform: translateY(-1px);
transition-duration: var(--dur-fast);
}
.card h3 {
margin: 0 0 0.3em;
font-size: 1rem;
transition: color var(--dur-fast) var(--ease-out);
}
.card:hover h3 {
color: var(--accent-dark);
}
.card p {
margin: 0;
color: rgb(var(--muted));
font-size: 0.9em;
}
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.6rem;
}
.section-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin: 2.1rem 0 0.85rem;
}
.section-head h2 {
margin: 0;
font-size: 1.1rem;
}
.section-head a {
font-size: 0.88em;
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 0.15em;
transition: transform var(--dur) var(--ease-out), gap var(--dur) var(--ease-out);
}
.section-head a:hover {
transform: translateX(3px);
gap: 0.35em;
}
.hero {
padding: 0.35rem 0 0.15rem;
animation: fade-up var(--dur-slow) var(--ease-out) both;
}
.hero p {
color: rgb(var(--muted));
font-size: 0.98em;
max-width: 34rem;
}
/* 文章页标题区 */
.article {
animation: soft-scale var(--dur-slow) var(--ease-out) both;
}
.sr-only {
border: 0;
padding: 0;
margin: 0;
position: absolute !important;
height: 1px;
width: 1px;
overflow: hidden;
clip: rect(1px, 1px, 1px, 1px);
clip-path: inset(50%);
white-space: nowrap;
}
@media (max-width: 720px) {
body {
font-size: 14.5px;
}
main {
padding: 1.35rem 0 2.75rem;
}
}
/* 减少动效偏好 */
@media (prefers-reduced-motion: reduce) {
html {
scroll-behavior: auto;
}
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "astro/tsconfigs/strict",
"include": [".astro/types.d.ts", "**/*"],
"exclude": ["dist"],
"compilerOptions": {
"strictNullChecks": true
}
}