feat: unify notes into posts stream with folder tree and CI
notebook-ci / build-push-deploy (push) Successful in 41s
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
+35
-43
@@ -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(),
|
.object({
|
||||||
description: z.string().default(''),
|
title: z.union([z.string(), z.number()]).transform(String).optional(),
|
||||||
pubDate: z.coerce.date(),
|
description: z
|
||||||
updatedDate: z.coerce.date().optional(),
|
.union([z.string(), z.number()])
|
||||||
heroImage: z.optional(image()),
|
.transform(String)
|
||||||
tags,
|
.optional(),
|
||||||
draft: z.boolean().default(false),
|
pubDate: z.coerce.date().optional(),
|
||||||
}),
|
updatedDate: z.coerce.date().optional(),
|
||||||
|
date: z.coerce.date().optional(),
|
||||||
|
created: z.coerce.date().optional(),
|
||||||
|
updated: z.coerce.date().optional(),
|
||||||
|
heroImage: z.optional(image()),
|
||||||
|
tags,
|
||||||
|
publish: looseBool,
|
||||||
|
draft: looseBool,
|
||||||
|
})
|
||||||
|
.passthrough()
|
||||||
|
.transform((data) => {
|
||||||
|
const pubDate = data.pubDate ?? data.date ?? data.created ?? new Date(0);
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
title: data.title,
|
||||||
|
description: data.description ?? '',
|
||||||
|
publish: data.publish ?? true,
|
||||||
|
draft: data.draft ?? false,
|
||||||
|
pubDate,
|
||||||
|
updatedDate: data.updatedDate ?? data.updated,
|
||||||
|
};
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
export const collections = { posts };
|
||||||
* 常青笔记 / Obsidian / 坚果云
|
|
||||||
* 宽松 schema:允许额外 frontmatter 字段,避免同步后构建失败
|
|
||||||
*/
|
|
||||||
const notes = defineCollection({
|
|
||||||
loader: glob({ base: './src/content/notes', pattern: '**/*.{md,mdx}' }),
|
|
||||||
schema: z
|
|
||||||
.object({
|
|
||||||
title: z.union([z.string(), z.number()]).transform(String).optional(),
|
|
||||||
description: z.union([z.string(), z.number()]).transform(String).optional(),
|
|
||||||
pubDate: z.coerce.date().optional(),
|
|
||||||
updatedDate: z.coerce.date().optional(),
|
|
||||||
// 常见别名
|
|
||||||
date: z.coerce.date().optional(),
|
|
||||||
created: z.coerce.date().optional(),
|
|
||||||
updated: z.coerce.date().optional(),
|
|
||||||
tags,
|
|
||||||
/** Obsidian / 同步脚本可用 publish: false 排除私密笔记 */
|
|
||||||
publish: looseBool,
|
|
||||||
draft: looseBool,
|
|
||||||
})
|
|
||||||
.passthrough()
|
|
||||||
.transform((data) => ({
|
|
||||||
...data,
|
|
||||||
publish: data.publish ?? true,
|
|
||||||
draft: data.draft ?? false,
|
|
||||||
// 日期别名回填
|
|
||||||
pubDate: data.pubDate ?? data.date ?? data.created,
|
|
||||||
updatedDate: data.updatedDate ?? data.updated,
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const collections = { posts, notes };
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user