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.
@@ -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
|
||||
|
||||
@@ -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 同步笔记 |
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
|
After Width: | Height: | Size: 655 B |
@@ -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 |
@@ -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();
|
||||
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -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} />
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
import { SITE_AUTHOR, SITE_TITLE } from '../consts';
|
||||
|
||||
const year = new Date().getFullYear();
|
||||
---
|
||||
|
||||
<footer>
|
||||
<p>
|
||||
© {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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
@@ -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
|
||||

|
||||
```
|
||||
|
||||
## 相关
|
||||
|
||||
- [[写作约定]]
|
||||
- [示例文章](/posts/hello-notebook/)
|
||||
@@ -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)
|
||||
```
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
title: "欢迎"
|
||||
publish: true
|
||||
---
|
||||
|
||||
这是你的新*仓库*。
|
||||
|
||||
写点笔记,[[创建链接]],或者试一试[导入器](https://help.obsidian.md/Plugins/Importer)插件!
|
||||
|
||||
当你准备好了,就将该笔记文件删除,使这个仓库为你所用。
|
||||
@@ -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 与本站]]。
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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, ' ');
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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}/`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"include": [".astro/types.d.ts", "**/*"],
|
||||
"exclude": ["dist"],
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||