初始提交:时之刃的小站
基于 VermilionVoid(朱墨留白)主题的个人博客。 - 站点配置集中在 src/data/site.ts 与 src/data/profile.ts - 30 篇文章、8 条偶得、相册 - 评论使用自托管 twikoo - 部署走 deploy.sh(rsync 到树莓派 + 云服务器)
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export const PAGE_SIZE = 10
|
||||
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs"
|
||||
import path from "node:path"
|
||||
import { galleryConfig, type GalleryAlbum } from "@/data/gallery"
|
||||
|
||||
const IMAGE_RE = /\.(jpe?g|png|webp|avif|gif)$/i
|
||||
|
||||
/**
|
||||
* 扫描 public/gallery/<albumId>/ 下的所有图片。
|
||||
* 返回可直接用作 src 的 URL,文件名做了 URL 编码(相册里常有中文和空格)。
|
||||
*/
|
||||
export function scanAlbumPhotos(albumId: string): string[] {
|
||||
const dir = path.join(process.cwd(), "public", "gallery", albumId)
|
||||
if (!fs.existsSync(dir)) return []
|
||||
|
||||
const files = fs
|
||||
.readdirSync(dir)
|
||||
.filter((f) => IMAGE_RE.test(f))
|
||||
.sort()
|
||||
|
||||
// cover.* 提到第一位,作为默认封面
|
||||
const coverIdx = files.findIndex((f) => /^cover\./i.test(f))
|
||||
if (coverIdx > 0) {
|
||||
const [coverFile] = files.splice(coverIdx, 1)
|
||||
files.unshift(coverFile)
|
||||
}
|
||||
|
||||
return files.map((f) => `/gallery/${albumId}/${encodeURIComponent(f)}`)
|
||||
}
|
||||
|
||||
/** 封面优先级:手动指定 > cover.* 文件 > 第一张图 */
|
||||
export function getAlbumCover(album: GalleryAlbum, photos: string[]): string {
|
||||
if (album.cover) return album.cover
|
||||
const coverFile = photos.find((p) => /\/cover\./i.test(p))
|
||||
return coverFile || photos[0] || ""
|
||||
}
|
||||
|
||||
export type AlbumWithPhotos = GalleryAlbum & {
|
||||
photos: string[]
|
||||
cover: string
|
||||
photoCount: number
|
||||
}
|
||||
|
||||
/** 构建全部相册数据(扫描照片、解析封面) */
|
||||
export function getAlbums(): AlbumWithPhotos[] {
|
||||
return galleryConfig.albums.map((album) => {
|
||||
const photos = scanAlbumPhotos(album.id)
|
||||
return {
|
||||
...album,
|
||||
photos,
|
||||
cover: getAlbumCover(album, photos),
|
||||
photoCount: photos.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** 收集所有相册标签,用于索引页筛选 */
|
||||
export function getAllAlbumTags(albums: AlbumWithPhotos[]): string[] {
|
||||
return Array.from(new Set(albums.flatMap((a) => a.tags ?? []))).sort()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { getCollection, type CollectionEntry } from "astro:content"
|
||||
import readingTime from "reading-time"
|
||||
|
||||
export type PostEntry = CollectionEntry<"posts">
|
||||
export type SidebarCategory = { id: string; name: string; count: number }
|
||||
|
||||
const stripMarkdown = (input: string) => {
|
||||
return input
|
||||
.replace(/```[\s\S]*?```/g, " ")
|
||||
.replace(/`[^`]*`/g, " ")
|
||||
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
|
||||
.replace(/\[[^\]]*\]\([^)]+\)/g, "$1")
|
||||
.replace(/[#>*_~]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
}
|
||||
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return ""
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export async function getSortedPosts(ignorePinned = false): Promise<PostEntry[]> {
|
||||
const allPosts = await getCollection("posts", (post: PostEntry) => {
|
||||
return import.meta.env.PROD ? post.data.draft !== true : true
|
||||
})
|
||||
|
||||
const sorted = allPosts.sort((a: PostEntry, b: PostEntry) => {
|
||||
if (!ignorePinned) {
|
||||
if (a.data.pinned && !b.data.pinned) return -1
|
||||
if (!a.data.pinned && b.data.pinned) return 1
|
||||
}
|
||||
const dateA = new Date(a.data.published)
|
||||
const dateB = new Date(b.data.published)
|
||||
return dateA > dateB ? -1 : 1
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
export async function getPostsWithNav(): Promise<PostEntry[]> {
|
||||
const sorted = await getSortedPosts()
|
||||
for (let i = 1; i < sorted.length; i += 1) {
|
||||
sorted[i].data.nextSlug = sorted[i - 1].slug
|
||||
sorted[i].data.nextTitle = sorted[i - 1].data.title
|
||||
}
|
||||
for (let i = 0; i < sorted.length - 1; i += 1) {
|
||||
sorted[i].data.prevSlug = sorted[i + 1].slug
|
||||
sorted[i].data.prevTitle = sorted[i + 1].data.title
|
||||
}
|
||||
return sorted
|
||||
}
|
||||
|
||||
export const toPostMeta = (post: PostEntry) => {
|
||||
const body = typeof post.body === "string" ? post.body : String(post.body || "")
|
||||
const stats = readingTime(body)
|
||||
const words = stats.words
|
||||
const minutes = Math.max(1, Math.round(stats.minutes))
|
||||
const excerptSource = post.data.description?.trim() || ""
|
||||
const fallbackExcerpt = stripMarkdown(body).slice(0, 140)
|
||||
const excerpt = excerptSource.length > 0 ? excerptSource : fallbackExcerpt
|
||||
const tags = post.data.tags?.length ? post.data.tags : []
|
||||
const category = (post.data.category ?? "").toString().trim() || "未分类"
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
title: post.data.title,
|
||||
excerpt,
|
||||
category,
|
||||
categoryLabel: category,
|
||||
tags,
|
||||
date: formatDate(post.data.published),
|
||||
wordCount: words,
|
||||
readTime: `${minutes} 分钟`,
|
||||
image: post.data.image || undefined,
|
||||
pinned: post.data.pinned ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export const buildSidebarMeta = (articles: { category: string; categoryLabel: string; tags: string[] }[]) => {
|
||||
const counts = new Map<string, SidebarCategory>()
|
||||
articles.forEach((article) => {
|
||||
if (!counts.has(article.category)) {
|
||||
counts.set(article.category, { id: article.category, name: article.categoryLabel, count: 0 })
|
||||
}
|
||||
counts.get(article.category)!.count += 1
|
||||
})
|
||||
|
||||
const categories = [
|
||||
{ id: "all", name: "全部", count: articles.length },
|
||||
...Array.from(counts.values()).sort((a, b) => a.name.localeCompare(b.name)),
|
||||
]
|
||||
|
||||
const tags = Array.from(new Set(articles.flatMap((article) => article.tags || []))).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)
|
||||
|
||||
return { categories, tags }
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// 专题数据访问层
|
||||
// 从 Astro content collection 读 markdown,解析 body 里的资源/笔记行,
|
||||
// 输出供前端组件消费的结构化数据。
|
||||
//
|
||||
// body 语法(详见 ~/Documents/Obsidian/YOLO/skills/专题-keeper.md):
|
||||
//
|
||||
// - 论文:Constitutional AI by Bai et al. (2022) 2026-04-25 【已读】
|
||||
// - 书:正义论 by 约翰·罗尔斯 (1971) 2026-04-22 【在读·第4章】
|
||||
//
|
||||
// > 2026-04-25: 重新读第 3 节。这个隐喻把价值表达为一组明文条款……
|
||||
//
|
||||
// - 资源行:可选 `- ` 列表前缀 + <类型>:<title> + 日期 + 可选 【status】
|
||||
// 类型用自然词,不预设枚举。Obsidian 渲染成 bullet 列表
|
||||
// - 笔记行:可选 `> ` 引用前缀 + <YYYY-MM-DD>:<body>
|
||||
// 多行 body:blank line 之前都算同一条笔记。Obsidian 渲染成 blockquote
|
||||
// - 不带前缀(裸 `论文:...` / 裸 `2026-04-25: ...`)也兼容;前缀只为 Obsidian 视觉区分
|
||||
// - 渲染顺序 = 写入顺序(Trail 视图就是"按你记录的顺序")
|
||||
|
||||
import { getCollection, type CollectionEntry } from "astro:content"
|
||||
|
||||
export type StudyEntry = CollectionEntry<"studies">
|
||||
|
||||
export type StudyStatus = "在读" | "沉淀中" | "暂搁" | "已结"
|
||||
|
||||
export type ParsedResource = {
|
||||
id: string
|
||||
type: string
|
||||
typeFull?: string // 中文全名 eyebrow("论文" / "书籍" / "影片"),由 type 映射得出;与 type 重复时为 undefined
|
||||
title: string // 完整标题(保持原样,向后兼容)
|
||||
titleMain: string // 标题主体(去掉 by xxx (年份) 部分)
|
||||
byline?: string // 作者("by xxx" 抽出来的部分)
|
||||
year?: string // 年份(标题尾部 (YYYY) 抽出来)
|
||||
date: string // ISO "YYYY-MM-DD"
|
||||
status?: string
|
||||
description?: string // 资源下方的内嵌读后感(多行 Markdown 列表续行)
|
||||
}
|
||||
|
||||
export type ParsedNote = {
|
||||
id: string
|
||||
date: string // ISO "YYYY-MM-DD"
|
||||
body: string
|
||||
}
|
||||
|
||||
export type ParsedTrailItem =
|
||||
| ({ kind: "resource" } & ParsedResource)
|
||||
| ({ kind: "note" } & ParsedNote)
|
||||
|
||||
export type StudyData = {
|
||||
id: string // = slug
|
||||
slug: string
|
||||
no: string // 自动编号 "01" "02" ...
|
||||
title: string
|
||||
subtitle?: string
|
||||
epigraph?: string
|
||||
field?: string
|
||||
started: string // "YYYY · MM" 已格式化
|
||||
startedRaw: string // 原始 "YYYY-MM" 用于排序
|
||||
updated?: string // 自动派生 "YYYY · MM · DD"
|
||||
status: StudyStatus
|
||||
resources: ParsedResource[]
|
||||
notes: ParsedNote[]
|
||||
trail: ParsedTrailItem[] // 资源 + 笔记按 body 顺序混排
|
||||
counts: Record<string, number> // 按 type 分组计数
|
||||
}
|
||||
|
||||
// ────────────────────────── type label mapping ──────────────────────────
|
||||
// 类型短形 → 中文全名(用作 eyebrow 副标签)。
|
||||
// 用户在 markdown 里写短形("文" / "影"),渲染时 stamp 仍是短形,eyebrow 显示全名。
|
||||
// 用户若直接写了全名("论文" / "播客"),stamp 显示全名、eyebrow 跳过避免重复。
|
||||
// 想新增类型,按表补一条即可。
|
||||
const TYPE_FULL: Record<string, string> = {
|
||||
书: "书籍",
|
||||
册: "书籍",
|
||||
文: "论文",
|
||||
影: "影片",
|
||||
课: "课程",
|
||||
谈: "访谈",
|
||||
播: "播客",
|
||||
视: "视频",
|
||||
网: "文章",
|
||||
漫: "漫画",
|
||||
展: "展览",
|
||||
讲: "讲座",
|
||||
}
|
||||
|
||||
// 标题里抠出 "by 作者 (年份)" 形式的元数据
|
||||
// 例:
|
||||
// "Constitutional AI by Bai et al. (2022)" → main: "Constitutional AI", byline: "Bai et al.", year: "2022"
|
||||
// "正义论 by 约翰·罗尔斯 (1971)" → main: "正义论", byline: "约翰·罗尔斯", year: "1971"
|
||||
// "AlphaGo by Greg Kohs (2017)" → main: "AlphaGo", byline: "Greg Kohs", year: "2017"
|
||||
// "正义论" → main: "正义论"
|
||||
const TITLE_RE = /^(.+?)\s+by\s+(.+?)(?:\s+\((\d{4})\))?$/i
|
||||
const TITLE_YEAR_ONLY_RE = /^(.+?)\s+\((\d{4})\)$/
|
||||
|
||||
function splitTitle(raw: string): { main: string; byline?: string; year?: string } {
|
||||
const m = raw.match(TITLE_RE)
|
||||
if (m) {
|
||||
return { main: m[1].trim(), byline: m[2].trim(), year: m[3] }
|
||||
}
|
||||
// 没有 "by" 但有年份:例 "正义论 (1971)"
|
||||
const m2 = raw.match(TITLE_YEAR_ONLY_RE)
|
||||
if (m2) {
|
||||
return { main: m2[1].trim(), year: m2[2] }
|
||||
}
|
||||
return { main: raw }
|
||||
}
|
||||
|
||||
// ────────────────────────── parser ──────────────────────────
|
||||
|
||||
const RESOURCE_RE =
|
||||
/^([^\s::]+)\s*[::]\s*(.+?)\s+(\d{4}-?\d{2}-?\d{2})(?:\s*【([^】]+)】)?\s*$/
|
||||
const NOTE_RE = /^(\d{4}-?\d{2}-?\d{2})\s*[::]\s*([\s\S]+)$/
|
||||
// Obsidian callout:`> [!note] 2026-04-05`(标题行只有日期,body 在后续 `> ` 行里)
|
||||
const CALLOUT_NOTE_RE = /^>\s*\[!note\]\s*(\d{4}-?\d{2}-?\d{2})\s*$/i
|
||||
|
||||
// 剥掉 markdown bold/italic 包裹(**xxx**, __xxx__, *xxx*, _xxx_, `xxx`)
|
||||
// 用于资源标题:用户在 Obsidian 里写 `**title**` 增强可读性,前端不渲染样式
|
||||
function stripInlineEmphasis(s: string): string {
|
||||
return s.replace(/\*\*([^*]+)\*\*/g, "$1")
|
||||
.replace(/__([^_]+)__/g, "$1")
|
||||
.replace(/(?<!\*)\*([^*\s][^*]*?)\*(?!\*)/g, "$1")
|
||||
.replace(/(?<!_)_([^_\s][^_]*?)_(?!_)/g, "$1")
|
||||
.replace(/`([^`]+)`/g, "$1")
|
||||
}
|
||||
|
||||
function normalizeDate(s: string): string {
|
||||
if (s.includes("-")) return s
|
||||
return `${s.slice(0, 4)}-${s.slice(4, 6)}-${s.slice(6, 8)}`
|
||||
}
|
||||
|
||||
// 剥掉 Markdown 列表/引用前缀,便于做模式匹配
|
||||
const LIST_PREFIX_RE = /^[-*•]\s+/
|
||||
const QUOTE_PREFIX_RE = /^>\s*/
|
||||
|
||||
function stripMarkdownPrefix(line: string): string {
|
||||
return line.replace(LIST_PREFIX_RE, "").replace(QUOTE_PREFIX_RE, "")
|
||||
}
|
||||
|
||||
function isEntryStart(line: string): boolean {
|
||||
const s = stripMarkdownPrefix(line.trim())
|
||||
return RESOURCE_RE.test(s) || NOTE_RE.test(s)
|
||||
}
|
||||
|
||||
export function parseStudyBody(body: string): ParsedTrailItem[] {
|
||||
const lines = body.split(/\n/)
|
||||
const out: ParsedTrailItem[] = []
|
||||
let rIdx = 0
|
||||
let nIdx = 0
|
||||
|
||||
let i = 0
|
||||
while (i < lines.length) {
|
||||
const raw = lines[i]
|
||||
const trimmed = raw.trim()
|
||||
if (!trimmed) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Obsidian callout:`> [!note] 2026-04-05` + 后续 `> body` 续行
|
||||
const calloutMatch = trimmed.match(CALLOUT_NOTE_RE)
|
||||
if (calloutMatch) {
|
||||
const bodyLines: string[] = []
|
||||
let j = i + 1
|
||||
while (j < lines.length) {
|
||||
const nextRaw = lines[j]
|
||||
const nextTrim = nextRaw.trim()
|
||||
if (!nextTrim) break
|
||||
// callout body 必须以 `>` 开头,否则视为 callout 结束
|
||||
if (!nextTrim.startsWith(">")) break
|
||||
bodyLines.push(nextTrim.replace(/^>\s?/, ""))
|
||||
j++
|
||||
}
|
||||
out.push({
|
||||
kind: "note",
|
||||
id: `n-${nIdx++}`,
|
||||
date: normalizeDate(calloutMatch[1]),
|
||||
body: bodyLines.join("\n").trim(),
|
||||
})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
const stripped = stripMarkdownPrefix(trimmed)
|
||||
|
||||
// resource:bullet 行 + 可选的缩进续行(Markdown 列表续行 = 资源描述)
|
||||
const resMatch = stripped.match(RESOURCE_RE)
|
||||
if (resMatch) {
|
||||
// 收集紧随其后的"描述"续行:直到空行 / 下一条 entry
|
||||
const descLines: string[] = []
|
||||
let j = i + 1
|
||||
while (j < lines.length) {
|
||||
const next = lines[j]
|
||||
if (!next.trim()) break // 空行结束描述
|
||||
if (isEntryStart(next.trim())) break // 下一条 entry
|
||||
descLines.push(next.trim().replace(/^>\s*/, ""))
|
||||
j++
|
||||
}
|
||||
const rawType = resMatch[1]
|
||||
const rawTitle = stripInlineEmphasis(resMatch[2].trim())
|
||||
const titleParts = splitTitle(rawTitle)
|
||||
const typeFull = TYPE_FULL[rawType]
|
||||
out.push({
|
||||
kind: "resource",
|
||||
id: `r-${rIdx++}`,
|
||||
type: rawType,
|
||||
typeFull: typeFull && typeFull !== rawType ? typeFull : undefined,
|
||||
title: rawTitle,
|
||||
titleMain: titleParts.main,
|
||||
byline: titleParts.byline,
|
||||
year: titleParts.year,
|
||||
date: normalizeDate(resMatch[3]),
|
||||
status: resMatch[4],
|
||||
description: descLines.length ? descLines.join(" ").trim() : undefined,
|
||||
})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// note:可跨多行,blank line 或下一条 entry 开始时终止
|
||||
const noteMatch = stripped.match(NOTE_RE)
|
||||
if (noteMatch) {
|
||||
const bodyLines: string[] = [noteMatch[2]]
|
||||
let j = i + 1
|
||||
while (j < lines.length) {
|
||||
const nextRaw = lines[j]
|
||||
const nextTrim = nextRaw.trim()
|
||||
if (!nextTrim) break // 空行结束
|
||||
if (isEntryStart(nextTrim)) break // 下一条 entry 起头
|
||||
bodyLines.push(stripMarkdownPrefix(nextTrim))
|
||||
j++
|
||||
}
|
||||
out.push({
|
||||
kind: "note",
|
||||
id: `n-${nIdx++}`,
|
||||
date: normalizeDate(noteMatch[1]),
|
||||
body: bodyLines.join("\n").trim(),
|
||||
})
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
// 不识别的行静默跳过(用户可在 body 里写自由 markdown,目前不渲染)
|
||||
i++
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ────────────────────────── date helpers ──────────────────────────
|
||||
|
||||
function parseISO(s: string): { y: number; m: number; d: number } | null {
|
||||
// 支持 "YYYY-MM" / "YYYY-MM-DD"
|
||||
const m = s.match(/^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/)
|
||||
if (!m) return null
|
||||
return { y: +m[1], m: m[2] ? +m[2] : 0, d: m[3] ? +m[3] : 0 }
|
||||
}
|
||||
|
||||
function formatYM(s: string): string {
|
||||
const p = parseISO(s)
|
||||
if (!p) return s
|
||||
return p.m ? `${p.y} · ${String(p.m).padStart(2, "0")}` : String(p.y)
|
||||
}
|
||||
|
||||
function formatYMD(s: string): string {
|
||||
const p = parseISO(s)
|
||||
if (!p) return s
|
||||
const segs: string[] = [String(p.y)]
|
||||
if (p.m) segs.push(String(p.m).padStart(2, "0"))
|
||||
if (p.d) segs.push(String(p.d).padStart(2, "0"))
|
||||
return segs.join(" · ")
|
||||
}
|
||||
|
||||
function maxDate(items: { date: string }[]): string | undefined {
|
||||
let best: { y: number; m: number; d: number; raw: string } | null = null
|
||||
for (const it of items) {
|
||||
const p = parseISO(it.date)
|
||||
if (!p) continue
|
||||
if (
|
||||
!best ||
|
||||
p.y > best.y ||
|
||||
(p.y === best.y && (p.m > best.m || (p.m === best.m && p.d > best.d)))
|
||||
) {
|
||||
best = { ...p, raw: it.date }
|
||||
}
|
||||
}
|
||||
return best?.raw
|
||||
}
|
||||
|
||||
// ────────────────────────── shaping ──────────────────────────
|
||||
|
||||
function shape(entry: StudyEntry, idx: number): StudyData {
|
||||
const body = typeof entry.body === "string" ? entry.body : ""
|
||||
const trail = parseStudyBody(body)
|
||||
const resources = trail.filter(
|
||||
(t): t is { kind: "resource" } & ParsedResource => t.kind === "resource",
|
||||
)
|
||||
const notes = trail.filter(
|
||||
(t): t is { kind: "note" } & ParsedNote => t.kind === "note",
|
||||
)
|
||||
|
||||
const counts: Record<string, number> = {}
|
||||
for (const r of resources) counts[r.type] = (counts[r.type] ?? 0) + 1
|
||||
|
||||
const latestRaw = maxDate(trail)
|
||||
|
||||
// 编号:按 started 升序排列后的位置(最早 = 01)
|
||||
// shape() 在已排序的 entries 上调用,idx 直接用
|
||||
const no = String(idx + 1).padStart(2, "0")
|
||||
|
||||
return {
|
||||
id: entry.slug,
|
||||
slug: entry.slug,
|
||||
no,
|
||||
title: entry.data.title,
|
||||
subtitle: entry.data.subtitle,
|
||||
epigraph: entry.data.epigraph,
|
||||
field: entry.data.field,
|
||||
started: formatYM(entry.data.started),
|
||||
startedRaw: entry.data.started,
|
||||
updated: latestRaw ? formatYMD(latestRaw) : undefined,
|
||||
status: entry.data.status,
|
||||
resources,
|
||||
notes,
|
||||
trail,
|
||||
counts,
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────── public API ──────────────────────────
|
||||
|
||||
export async function getAllStudies(): Promise<StudyData[]> {
|
||||
const all = (await getCollection("studies")) as StudyEntry[]
|
||||
// 按 started 升序(最早的开题排前 = No.01)
|
||||
const sorted = all.slice().sort((a: StudyEntry, b: StudyEntry) => {
|
||||
const pa = parseISO(a.data.started)
|
||||
const pb = parseISO(b.data.started)
|
||||
if (!pa || !pb) return 0
|
||||
if (pa.y !== pb.y) return pa.y - pb.y
|
||||
if (pa.m !== pb.m) return pa.m - pb.m
|
||||
if (pa.d !== pb.d) return pa.d - pb.d
|
||||
return a.slug.localeCompare(b.slug)
|
||||
})
|
||||
return sorted.map((entry: StudyEntry, idx: number) => shape(entry, idx))
|
||||
}
|
||||
|
||||
export async function getStudyBySlug(
|
||||
slug: string,
|
||||
): Promise<StudyData | null> {
|
||||
const all = await getAllStudies()
|
||||
return all.find((s) => s.slug === slug) ?? null
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { getCollection, type CollectionEntry } from "astro:content"
|
||||
import MarkdownIt from "markdown-it"
|
||||
import { site } from "@/data/site"
|
||||
|
||||
export type ThoughtEntry = CollectionEntry<"thoughts">
|
||||
|
||||
const md = new MarkdownIt()
|
||||
|
||||
/**
|
||||
* 按站点时区格式化成 YYYY-MM-DD。
|
||||
* 偶得条目带具体时间(如 2022-10-29T20:58Z),用 UTC 会比 Asia/Shanghai 早一天。
|
||||
*/
|
||||
const formatDate = (date?: Date) => {
|
||||
if (!date) return ""
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: site.timezone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date)
|
||||
const pick = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
|
||||
return `${pick("year")}-${pick("month")}-${pick("day")}`
|
||||
}
|
||||
|
||||
const stripHtml = (html: string) => {
|
||||
const withBreaks = html
|
||||
.replace(/<\s*br\s*\/?>/gi, "\n")
|
||||
.replace(/<\/p>/gi, "\n")
|
||||
.replace(/<\/li>/gi, "\n")
|
||||
const text = withBreaks.replace(/<[^>]*>/g, "")
|
||||
return text.replace(/\n\s*\n+/g, "\n").trim()
|
||||
}
|
||||
|
||||
const truncateText = (text: string, maxLength: number) => {
|
||||
if (text.length <= maxLength) return text
|
||||
const slice = text.slice(0, Math.max(0, maxLength)).replace(/\s+$/g, "")
|
||||
return `${slice}...`
|
||||
}
|
||||
|
||||
export async function getSortedThoughts(): Promise<ThoughtEntry[]> {
|
||||
const allThoughts = await getCollection("thoughts")
|
||||
|
||||
const sorted = allThoughts.sort((a: ThoughtEntry, b: ThoughtEntry) => {
|
||||
if (a.data.pinned && !b.data.pinned) return -1
|
||||
if (!a.data.pinned && b.data.pinned) return 1
|
||||
const dateA = new Date(a.data.published)
|
||||
const dateB = new Date(b.data.published)
|
||||
return dateA > dateB ? -1 : 1
|
||||
})
|
||||
|
||||
return sorted
|
||||
}
|
||||
|
||||
export const toThoughtMeta = (thought: ThoughtEntry) => {
|
||||
const tags = thought.data.tags?.length ? thought.data.tags : []
|
||||
const body = typeof thought.body === "string" ? thought.body : ""
|
||||
const content = md.render(body)
|
||||
|
||||
return {
|
||||
slug: thought.slug,
|
||||
title: thought.data.title,
|
||||
content,
|
||||
date: formatDate(thought.data.published),
|
||||
tags,
|
||||
location: thought.data.location ?? "",
|
||||
weather: thought.data.weather ?? "",
|
||||
images: thought.data.images ?? [],
|
||||
pinned: thought.data.pinned ?? false,
|
||||
}
|
||||
}
|
||||
|
||||
export const toThoughtPreview = (thought: ThoughtEntry, maxLength = 96) => {
|
||||
const body = typeof thought.body === "string" ? thought.body : ""
|
||||
const content = md.render(body)
|
||||
const text = stripHtml(content)
|
||||
|
||||
return {
|
||||
slug: thought.slug,
|
||||
title: thought.data.title,
|
||||
date: formatDate(thought.data.published),
|
||||
excerpt: truncateText(text, maxLength),
|
||||
location: thought.data.location ?? "",
|
||||
}
|
||||
}
|
||||
|
||||
export const buildThoughtsTags = (thoughts: { tags: string[] }[]) => {
|
||||
return Array.from(new Set(thoughts.flatMap((t) => t.tags || []))).sort((a, b) =>
|
||||
a.localeCompare(b),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user