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
|
||||||
|
}
|
||||||
|
}
|
||||||