feat: unify notes into posts stream with folder tree and CI
notebook-ci / build-push-deploy (push) Successful in 41s

Merge notes/posts into a single posts collection, redirect /notes to /posts, add folder browsing, and wire Gitea Actions deploy workflow.
This commit is contained in:
secret-deus
2026-07-13 13:40:29 +08:00
parent 6037ff7249
commit 63ae4d64de
174 changed files with 1371 additions and 1677 deletions
+6 -6
View File
@@ -2,9 +2,9 @@
用 **Obsidian** 写作,用 **Astro** 生成静态站点的个人笔记博客。 用 **Obsidian** 写作,用 **Astro** 生成静态站点的个人笔记博客。
- 文章(时间线)+ 笔记(常青 / 双链) - **统一文章流**(坚果云笔记也当文章发布)
- 支持 `[[wikilink]]`、标签、RSS、sitemap - 支持 `[[wikilink]]`、目录树、标签、RSS、sitemap
- 一键从 Vault 同步公开笔记 - 一键从 Vault / 坚果云 WebDAV 同步
## 快速开始 ## 快速开始
@@ -24,10 +24,10 @@ npm run preview # 预览构建结果
| 路径 | 说明 | | 路径 | 说明 |
|------|------| |------|------|
| `src/content/posts/` | 正式文章(需 `title` + `pubDate`) | | `src/content/posts/` | 全部文章(含 KnowledgeBase 同步内容) |
| `src/content/notes/` | 笔记(可从 Obsidian 同步) |
| `public/assets/` | 图片等静态资源 | | `public/assets/` | 图片等静态资源 |
| `src/consts.ts` | 站名、作者、默认 Vault 路径 | | `src/consts.ts` | 站名、作者等 |
| `.env` | 坚果云 WebDAV 凭证(不入库) |
### 文章示例 frontmatter ### 文章示例 frontmatter
+18 -8
View File
@@ -1,5 +1,5 @@
/** /**
* 共享:从本地 Vault 目录同步公开笔记到 src/content/notes * 共享:从本地 Vault 同步到 src/content/posts(统一文章流)
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -84,23 +84,33 @@ export function ensureTitle(raw, filename) {
/** /**
* @param {object} opts * @param {object} opts
* @param {string} opts.vault 源目录 * @param {string} opts.vault 源目录
* @param {string} opts.notesOut 目标 notes 目录 * @param {string} [opts.postsOut] 目标 posts 目录(文章流)
* @param {string} [opts.notesOut] 兼容旧参数,等同 postsOut
* @param {string} opts.assetsOut 目标资源目录 * @param {string} opts.assetsOut 目标资源目录
* @param {boolean} [opts.dryRun] * @param {boolean} [opts.dryRun]
* @param {boolean} [opts.clean] 同步前清空 notesOut 下旧文件(不含子目录 .gitkeep 类) * @param {boolean} [opts.clean] 同步前清空目标目录
*/ */
export function syncLocalVault({ vault, notesOut, assetsOut, dryRun = false, clean = false }) { export function syncLocalVault({
vault,
postsOut,
notesOut,
assetsOut,
dryRun = false,
clean = false,
}) {
const outDir = postsOut || notesOut;
if (!outDir) throw new Error('需要 postsOut(或兼容 notesOut)');
if (!fs.existsSync(vault)) { if (!fs.existsSync(vault)) {
throw new Error(`Vault 路径不存在: ${vault}`); throw new Error(`Vault 路径不存在: ${vault}`);
} }
ensureDir(notesOut); ensureDir(outDir);
ensureDir(assetsOut); ensureDir(assetsOut);
if (clean && !dryRun) { if (clean && !dryRun) {
for (const name of fs.readdirSync(notesOut)) { for (const name of fs.readdirSync(outDir)) {
if (name === '.gitkeep') continue; if (name === '.gitkeep') continue;
fs.rmSync(path.join(notesOut, name), { recursive: true, force: true }); fs.rmSync(path.join(outDir, name), { recursive: true, force: true });
} }
} }
@@ -130,7 +140,7 @@ export function syncLocalVault({ vault, notesOut, assetsOut, dryRun = false, cle
} }
const destRel = relPosix.replace(/\.mdx?$/i, '') + path.extname(full); const destRel = relPosix.replace(/\.mdx?$/i, '') + path.extname(full);
const dest = path.join(notesOut, destRel); const dest = path.join(outDir, destRel);
const content = ensureTitle(raw, full); const content = ensureTitle(raw, full);
if (dryRun) { if (dryRun) {
+6 -6
View File
@@ -14,7 +14,7 @@
* *
* 其它: * 其它:
* --dry-run 只列文件 * --dry-run 只列文件
* --clean 同步前清空 src/content/notes * --clean 同步前清空 src/content/posts
*/ */
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
@@ -37,7 +37,7 @@ const dryRun = process.argv.includes('--dry-run');
const clean = process.argv.includes('--clean'); const clean = process.argv.includes('--clean');
const forceWebdav = process.argv.includes('--webdav'); const forceWebdav = process.argv.includes('--webdav');
const notesOut = path.join(root, 'src/content/notes'); const postsOut = path.join(root, 'src/content/posts');
const assetsOut = path.join(root, 'public/assets'); const assetsOut = path.join(root, 'public/assets');
const cacheDir = path.join(root, '.cache/nutstore-vault'); const cacheDir = path.join(root, '.cache/nutstore-vault');
@@ -212,8 +212,8 @@ async function main() {
if (local && fs.existsSync(local) && !forceWebdav) { if (local && fs.existsSync(local) && !forceWebdav) {
console.log('模式: 坚果云本地同步盘'); console.log('模式: 坚果云本地同步盘');
console.log(`Vault: ${local}`); console.log(`Vault: ${local}`);
console.log(`Out: ${notesOut}`); console.log(`Out: ${postsOut}(统一文章流)`);
const r = syncLocalVault({ vault: local, notesOut, assetsOut, dryRun, clean }); const r = syncLocalVault({ vault: local, postsOut, assetsOut, dryRun, clean });
console.log( console.log(
dryRun dryRun
? `[dry-run] 将同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}` ? `[dry-run] 将同步 ${r.mdCount} 篇笔记, ${r.assetCount} 个资源, 跳过 ${r.skipCount}`
@@ -248,7 +248,7 @@ async function main() {
console.log('模式: 坚果云 WebDAV'); console.log('模式: 坚果云 WebDAV');
console.log(`Remote: ${rootUrl}`); console.log(`Remote: ${rootUrl}`);
console.log(`Cache: ${cacheDir}`); console.log(`Cache: ${cacheDir}`);
console.log(`Out: ${notesOut}`); console.log(`Out: ${postsOut}(统一文章流)`);
console.log('列出远程文件…'); console.log('列出远程文件…');
const files = await listRemoteFiles(rootUrl.endsWith('/') ? rootUrl : rootUrl + '/'); const files = await listRemoteFiles(rootUrl.endsWith('/') ? rootUrl : rootUrl + '/');
@@ -261,7 +261,7 @@ async function main() {
const r = syncLocalVault({ const r = syncLocalVault({
vault: cacheDir, vault: cacheDir,
notesOut, postsOut,
assetsOut, assetsOut,
dryRun: false, dryRun: false,
clean, clean,
+3 -3
View File
@@ -27,15 +27,15 @@ const vault =
process.env.NUTSTORE_LOCAL_PATH || process.env.NUTSTORE_LOCAL_PATH ||
DEFAULT_VAULT; DEFAULT_VAULT;
const notesOut = path.join(root, 'src/content/notes'); const postsOut = path.join(root, 'src/content/posts');
const assetsOut = path.join(root, 'public/assets'); const assetsOut = path.join(root, 'public/assets');
try { try {
console.log(`Vault: ${vault}`); console.log(`Vault: ${vault}`);
console.log(`Out: ${notesOut}`); console.log(`Out: ${postsOut}(统一文章流)`);
const { mdCount, assetCount, skipCount } = syncLocalVault({ const { mdCount, assetCount, skipCount } = syncLocalVault({
vault, vault,
notesOut, postsOut,
assetsOut, assetsOut,
dryRun, dryRun,
clean, clean,
+18 -21
View File
@@ -1,22 +1,16 @@
--- ---
import type { NoteEntry, PostEntry } from '../lib/content'; import type { PostEntry } from '../lib/content';
import { entryHref, entryTitle } from '../lib/content'; import { entryHref, entryTitle } from '../lib/content';
import FormattedDate from './FormattedDate.astro'; import FormattedDate from './FormattedDate.astro';
interface Props { interface Props {
entries: Array<PostEntry | NoteEntry>; entries: PostEntry[];
empty?: string; empty?: string;
showKind?: boolean; showKind?: boolean;
clamp?: number; clamp?: number;
} }
const { const { entries, empty = '暂无内容', showKind = false, clamp } = Astro.props;
entries,
empty = '暂无内容',
showKind = false,
clamp,
} = Astro.props;
const list = typeof clamp === 'number' ? entries.slice(0, clamp) : entries; const list = typeof clamp === 'number' ? entries.slice(0, clamp) : entries;
--- ---
@@ -26,30 +20,23 @@ const list = typeof clamp === 'number' ? entries.slice(0, clamp) : entries;
) : ( ) : (
<ul class="dense-list"> <ul class="dense-list">
{list.map((entry) => { {list.map((entry) => {
const date = const date = entry.data.updatedDate ?? entry.data.pubDate;
entry.collection === 'posts'
? entry.data.pubDate
: (entry.data.updatedDate ?? entry.data.pubDate);
const desc = entry.data.description; const desc = entry.data.description;
const tags = entry.data.tags ?? []; const tags = entry.data.tags ?? [];
const kind = entry.collection === 'posts' ? '文章' : '笔记'; const showDate = date && date.valueOf() > 0;
return ( return (
<li> <li>
<a class="dense-item" href={entryHref(entry)}> <a class="dense-item" href={entryHref(entry)}>
<div class="dense-main"> <div class="dense-main">
<span class="dense-title"> <span class="dense-title">
{showKind && ( {showKind && <span class="kind-dot" title="文章" />}
<span
class:list={['kind-dot', { note: entry.collection === 'notes' }]}
title={kind}
/>
)}
{entryTitle(entry)} {entryTitle(entry)}
</span> </span>
{desc && <span class="dense-desc">{desc}</span>} {desc && <span class="dense-desc">{desc}</span>}
<span class="dense-path meta">{entry.id}</span>
</div> </div>
<div class="dense-meta"> <div class="dense-meta">
{date && <FormattedDate date={date} />} {showDate && <FormattedDate date={date} />}
{tags[0] && <span class="dense-tag">#{tags[0]}</span>} {tags[0] && <span class="dense-tag">#{tags[0]}</span>}
</div> </div>
</a> </a>
@@ -59,3 +46,13 @@ const list = typeof clamp === 'number' ? entries.slice(0, clamp) : entries;
</ul> </ul>
) )
} }
<style>
.dense-path {
font-size: 0.72rem;
opacity: 0.75;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
---
import type { TreeFolder } from '../lib/tree';
import TreeBranch from './TreeBranch.astro';
interface Props {
root: TreeFolder;
activePath?: string;
expandDepth?: number;
compact?: boolean;
}
const { root, activePath = '', expandDepth = 1, compact = false } = Astro.props;
---
<nav class:list={['folder-tree', { compact }]} aria-label="笔记目录">
<TreeBranch nodes={root.children} activePath={activePath} expandDepth={expandDepth} depth={0} />
</nav>
<script>
document.querySelectorAll('[data-tree-toggle]').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const li = (btn as HTMLElement).closest('[data-tree-folder]');
li?.classList.toggle('is-open');
});
});
</script>
<style>
.folder-tree {
max-height: min(70vh, 36rem);
overflow: auto;
padding-right: 0.25rem;
}
.folder-tree.compact {
max-height: min(50vh, 24rem);
}
</style>
-1
View File
@@ -9,7 +9,6 @@ import HeaderLink from './HeaderLink.astro';
<div class="links"> <div class="links">
<HeaderLink href="/">首页</HeaderLink> <HeaderLink href="/">首页</HeaderLink>
<HeaderLink href="/posts/">文章</HeaderLink> <HeaderLink href="/posts/">文章</HeaderLink>
<HeaderLink href="/notes/">笔记</HeaderLink>
<HeaderLink href="/tags/">标签</HeaderLink> <HeaderLink href="/tags/">标签</HeaderLink>
<HeaderLink href="/about/">关于</HeaderLink> <HeaderLink href="/about/">关于</HeaderLink>
</div> </div>
+15 -56
View File
@@ -1,72 +1,42 @@
--- ---
/** /**
* 全站通用侧栏:快捷入口 + 统计 + 标签 + 另一类内容列表 * 全站通用侧栏:快捷入口 + 统计 + 标签 + 最近文章
*/ */
import type { NoteEntry, PostEntry } from '../lib/content'; import type { PostEntry } from '../lib/content';
import { getPublishedNotes, getPublishedPosts, getTagMap } from '../lib/content'; import { getPublishedPosts, getTagMap } from '../lib/content';
import { tagHref } from '../lib/tags';
import DenseEntryList from './DenseEntryList.astro'; import DenseEntryList from './DenseEntryList.astro';
interface Props { interface Props {
/** 当前频道,用于高亮快捷入口 */
active?: 'home' | 'posts' | 'notes' | 'tags' | 'about'; active?: 'home' | 'posts' | 'notes' | 'tags' | 'about';
/** 侧栏「相关列表」标题 */
listTitle?: string; listTitle?: string;
listHref?: string; listHref?: string;
listEntries?: Array<PostEntry | NoteEntry>; listEntries?: PostEntry[];
listEmpty?: string; listEmpty?: string;
showKind?: boolean; showKind?: boolean;
/** 额外说明文案 */
tips?: string[]; tips?: string[];
tipsTitle?: string; tipsTitle?: string;
} }
const { const {
active, active,
listTitle = '最近更新', listTitle = '最近文章',
listHref, listHref = '/posts/',
listEntries, listEntries,
listEmpty = '暂无内容', listEmpty = '暂无内容',
showKind = true, showKind = false,
tips, tips,
tipsTitle = '说明', tipsTitle = '说明',
} = Astro.props; } = Astro.props;
const [posts, notes, tagMap] = await Promise.all([ const posts = await getPublishedPosts();
getPublishedPosts(), const tagMap = await getTagMap();
getPublishedNotes(),
getTagMap(),
]);
const tags = [...tagMap.entries()] const tags = [...tagMap.entries()]
.sort((a, b) => b[1].length - a[1].length) .sort((a, b) => b[1].length - a[1].length)
.slice(0, 10); .slice(0, 10);
const fallbackList = const fallbackList = listEntries ?? posts.slice(0, 8);
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"> <aside class="board-side">
@@ -78,12 +48,9 @@ const resolvedListHref =
<a href="/" class:list={[{ active: active === 'home' }]}> <a href="/" class:list={[{ active: active === 'home' }]}>
<span>首页</span><em>⌂</em> <span>首页</span><em>⌂</em>
</a> </a>
<a href="/posts/" class:list={[{ active: active === 'posts' }]}> <a href="/posts/" class:list={[{ active: active === 'posts' || active === 'notes' }]}>
<span>文章</span><em>{posts.length}</em> <span>文章</span><em>{posts.length}</em>
</a> </a>
<a href="/notes/" class:list={[{ active: active === 'notes' }]}>
<span>笔记</span><em>{notes.length}</em>
</a>
<a href="/tags/" class:list={[{ active: active === 'tags' }]}> <a href="/tags/" class:list={[{ active: active === 'tags' }]}>
<span>标签</span><em>{tagMap.size}</em> <span>标签</span><em>{tagMap.size}</em>
</a> </a>
@@ -105,25 +72,17 @@ const resolvedListHref =
<strong>{posts.length}</strong> <strong>{posts.length}</strong>
<span>文章</span> <span>文章</span>
</div> </div>
<div class="metric">
<strong>{notes.length}</strong>
<span>笔记</span>
</div>
<div class="metric"> <div class="metric">
<strong>{tagMap.size}</strong> <strong>{tagMap.size}</strong>
<span>标签</span> <span>标签</span>
</div> </div>
<div class="metric">
<strong>{posts.length + notes.length}</strong>
<span>合计</span>
</div>
</div> </div>
</section> </section>
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
<h2>{resolvedListTitle}</h2> <h2>{listTitle}</h2>
{resolvedListHref && <a href={resolvedListHref}>全部 →</a>} {listHref && <a href={listHref}>全部 →</a>}
</div> </div>
<DenseEntryList entries={fallbackList} empty={listEmpty} showKind={showKind} /> <DenseEntryList entries={fallbackList} empty={listEmpty} showKind={showKind} />
</section> </section>
@@ -137,7 +96,7 @@ const resolvedListHref =
</div> </div>
<div class="tag-cloud"> <div class="tag-cloud">
{tags.map(([tag, items]) => ( {tags.map(([tag, items]) => (
<a class="tag" href={`/tags/${encodeURIComponent(tag)}/`}> <a class="tag" href={tagHref(tag)}>
#{tag} #{tag}
<span class="count">{items.length}</span> <span class="count">{items.length}</span>
</a> </a>
+152
View File
@@ -0,0 +1,152 @@
---
import type { TreeNode } from '../lib/tree';
import { folderHref } from '../lib/tree';
import TreeBranch from './TreeBranch.astro';
interface Props {
nodes: TreeNode[];
activePath?: string;
expandDepth?: number;
depth?: number;
}
const { nodes, activePath = '', expandDepth = 1, depth = 0 } = Astro.props;
function isOpen(path: string): boolean {
if (depth < expandDepth) return true;
if (!activePath) return false;
return activePath === path || activePath.startsWith(path + '/');
}
---
<ul class:list={[{ 'tree-root': depth === 0 }]}>
{
nodes.map((node) => {
if (node.kind === 'folder') {
const open = isOpen(node.path);
const active = activePath === node.path;
return (
<li class:list={['tree-folder', { 'is-open': open }]} data-tree-folder data-path={node.path}>
<div class:list={['tree-row', { 'is-active': active }]}>
<button type="button" class="tree-toggle" data-tree-toggle aria-label="展开/折叠">
▶
</button>
<a class="tree-link" href={folderHref(node.path)}>
<span class="folder-icon" aria-hidden="true">
📁
</span>
<span class="tree-name">{node.name}</span>
<span class="tree-count">{node.fileCount}</span>
</a>
</div>
{node.children.length > 0 && (
<TreeBranch
nodes={node.children}
activePath={activePath}
expandDepth={expandDepth}
depth={depth + 1}
/>
)}
</li>
);
}
const active = activePath === node.id;
return (
<li class="tree-file">
<a class:list={['tree-row', 'tree-link', { 'is-active': active }]} href={node.href}>
<span class="file-spacer" aria-hidden="true" />
<span class="file-icon" aria-hidden="true">
📄
</span>
<span class="tree-name">{node.title}</span>
</a>
</li>
);
})
}
</ul>
<style>
ul {
list-style: none;
margin: 0;
padding: 0 0 0 0.75rem;
border-left: 1px solid rgb(var(--line));
margin-left: 0.4rem;
}
ul.tree-root {
padding-left: 0;
border-left: 0;
margin-left: 0;
}
li {
margin: 0.04rem 0;
}
.tree-row {
display: flex;
align-items: center;
gap: 0.15rem;
min-width: 0;
}
.tree-link {
display: flex;
align-items: center;
gap: 0.3rem;
flex: 1;
min-width: 0;
padding: 0.28rem 0.35rem;
border-radius: 6px;
text-decoration: none;
color: inherit;
}
.tree-link:hover,
.tree-row.is-active .tree-link,
a.tree-row.is-active {
background: var(--accent-soft);
color: var(--accent-dark);
}
.tree-toggle {
appearance: none;
border: 0;
background: transparent;
cursor: pointer;
width: 1.2rem;
height: 1.2rem;
padding: 0;
color: rgb(var(--muted));
flex-shrink: 0;
font-size: 0.65rem;
line-height: 1;
transition: transform 0.15s ease;
}
.tree-folder.is-open > .tree-row .tree-toggle {
transform: rotate(90deg);
}
.tree-folder:not(.is-open) > ul {
display: none;
}
.tree-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
font-size: 0.88rem;
}
.tree-count {
margin-left: auto;
font-size: 0.72rem;
color: rgb(var(--muted));
flex-shrink: 0;
font-variant-numeric: tabular-nums;
}
.file-spacer {
width: 1.2rem;
flex-shrink: 0;
}
.folder-icon,
.file-icon {
flex-shrink: 0;
font-size: 0.85em;
opacity: 0.85;
}
</style>
+19 -27
View File
@@ -19,51 +19,43 @@ const looseBool = z
}) })
.optional(); .optional();
/** 正式博文:偏时间线、可带封面 */ /**
* 统一文章流:坚果云 / Obsidian 笔记也当文章发
* schema 宽松,避免同步后构建失败
*/
const posts = defineCollection({ const posts = defineCollection({
loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }), loader: glob({ base: './src/content/posts', pattern: '**/*.{md,mdx}' }),
schema: ({ image }) => schema: ({ image }) =>
z.object({ z
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 / 坚果云
* 宽松 schema:允许额外 frontmatter 字段,避免同步后构建失败
*/
const notes = defineCollection({
loader: glob({ base: './src/content/notes', pattern: '**/*.{md,mdx}' }),
schema: z
.object({ .object({
title: z.union([z.string(), z.number()]).transform(String).optional(), title: z.union([z.string(), z.number()]).transform(String).optional(),
description: z.union([z.string(), z.number()]).transform(String).optional(), description: z
.union([z.string(), z.number()])
.transform(String)
.optional(),
pubDate: z.coerce.date().optional(), pubDate: z.coerce.date().optional(),
updatedDate: z.coerce.date().optional(), updatedDate: z.coerce.date().optional(),
// 常见别名
date: z.coerce.date().optional(), date: z.coerce.date().optional(),
created: z.coerce.date().optional(), created: z.coerce.date().optional(),
updated: z.coerce.date().optional(), updated: z.coerce.date().optional(),
heroImage: z.optional(image()),
tags, tags,
/** Obsidian / 同步脚本可用 publish: false 排除私密笔记 */
publish: looseBool, publish: looseBool,
draft: looseBool, draft: looseBool,
}) })
.passthrough() .passthrough()
.transform((data) => ({ .transform((data) => {
const pubDate = data.pubDate ?? data.date ?? data.created ?? new Date(0);
return {
...data, ...data,
title: data.title,
description: data.description ?? '',
publish: data.publish ?? true, publish: data.publish ?? true,
draft: data.draft ?? false, draft: data.draft ?? false,
// 日期别名回填 pubDate,
pubDate: data.pubDate ?? data.date ?? data.created,
updatedDate: data.updatedDate ?? data.updated, updatedDate: data.updatedDate ?? data.updated,
})), };
}),
}); });
export const collections = { posts, notes }; export const collections = { posts };

Some files were not shown because too many files have changed in this diff Show More