视频加载失败

Firefly 魔改指南

9487 字
47 分钟
Firefly 魔改指南

“十年之约”页面#

在 src/pages/ 下新建文件 decennial.astro 并写入:

---
import MainGridLayout from "@/layouts/MainGridLayout.astro";
import I18nKey from "@/i18n/i18nKey";
import { i18n } from "@/i18n/translation";
import { Icon } from "astro-icon";
import { getCollection } from "astro:content";
import { siteConfig } from "@/config";
const allBlogPosts = await getCollection("posts", ({ data }) => {
return import.meta.env.PROD ? data.draft !== true : true;
});
let words = 0;
for (let post of allBlogPosts) {
if (post.body) {
let text = post.body
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]*`/g, "")
.replace(/\s+/g, " ")
.trim();
const chineseChars = text.match(/[\u4e00-\u9fa5]/g) || [];
const englishChars = text.match(/[a-zA-Z]/g) || [];
words += chineseChars.length + englishChars.length;
}
}
const sorted = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.data.published);
const dateB = new Date(b.data.published);
return dateA > dateB ? -1 : 1;
});
const title = sorted[0].data.title;
const startDate = siteConfig.siteStartDate || '2025-01-01';
const begin = new Date(startDate);
let end = new Date(startDate);
end.setDate(begin.getDate() + 3650);
const latestDate = new Date(sorted[0].data.published);
const total = Math.ceil((end - begin) / (1000 * 60 * 60 * 24));
const dayU = i18n(I18nKey.decennialDayUnit);
const dayAgoU = i18n(I18nKey.decennialDaysAgo);
const daysTogoU = i18n(I18nKey.decennialDaysToGo);
---
<style>
.container {
width: 100%;
background: var(--card-bg);
border-radius: 20px;
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.1);
padding: 40px;
text-align: center;
position: relative;
overflow: hidden;
}
.container::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 8px;
background: linear-gradient(90deg, #ff7e5f, #feb47b);
}
h1 {
font-size: 2.8rem;
margin-bottom: 30px;
letter-spacing: 2px;
text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.1);
}
.avatar-container {
margin: 20px auto 30px;
width: 150px;
height: 150px;
border-radius: 50%;
overflow: hidden;
border: 5px solid #f8f9fa;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
position: relative;
user-select: none;
}
.avatar {
width: 100%;
height: 100%;
background: linear-gradient(45deg, #3498db, #9b59b6);
display: flex;
justify-content: center;
align-items: center;
font-size: 3rem;
}
.decennial-title {
margin: 0 0 30px 0;
}
#daysCounter {
margin: 20px 0;
}
#days-label {
font-size: 1.2rem;
margin-bottom: 30px;
}
.date-info {
display: flex;
justify-content: space-between;
margin: 30px 0;
flex-wrap: wrap;
}
.date-box {
flex: 1;
min-width: 200px;
padding: 20px;
margin: 10px;
background: var(--page-bg);
border-radius: 12px;
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
transition: transform 0.3s ease;
}
.date-box:hover {
transform: translateY(-5px);
}
.date-title {
margin-bottom: 10px;
}
.label-container {
display: flex;
flex-direction: column;
text-align: left;
line-height: 2.5;
}
.horizontal-items {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
.progress-container {
margin: 40px 0 20px;
text-align: left;
}
.progress-label {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
font-size: 1.1rem;
}
.progress-bar {
height: 20px;
background: var(--page-bg);
border-radius: 10px;
overflow: hidden;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.1);
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #3498db, #2ecc71);
border-radius: 10px;
width: 0;
transition: width 1.5s ease-in-out;
position: relative;
overflow: hidden;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
background-image: linear-gradient(
-45deg,
rgba(255, 255, 255, 0.2) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.2) 50%,
rgba(255, 255, 255, 0.2) 75%,
transparent 75%,
transparent
);
background-size: 20px 20px;
animation: move 1s linear infinite;
}
@keyframes move {
0% {
background-position: 0 0;
}
100% {
background-position: 20px 0;
}
}
.quote {
margin-top: 30px;
font-style: italic;
color: #7f8c8d;
font-size: 1.1rem;
line-height: 1.6;
border-left: 4px solid #3498db;
padding-left: 15px;
text-align: left;
}
img {
max-width: 100%;
}
@media (max-width: 600px) {
.container {
padding: 20px;
}
h1 {
font-size: 2rem;
}
.days-counter {
font-size: 3rem;
}
.date-info {
flex-direction: column;
}
.date-box {
min-width: 100%;
}
}
</style>
<MainGridLayout title={i18n(I18nKey.decennialTitle)} description={i18n(I18nKey.decennialDesc)}>
<div class="container">
<div class="decennial-title text-4xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors truncate pr-1">{i18n(I18nKey.decennialSubtitle)}</div>
<div class="avatar-container">
<div class="avatar">
<img src="https://justpureh2o.cn/avatar.jpg">
</div>
</div>
<div class="text-7xl font-bold text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors truncate pr-1" id="daysCounter"></div>
<div class="days-label font-bold text-base text-neutral-900 dark:text-neutral-100 group-hover:text-(--primary) transition-colors truncate pr-4">{i18n(I18nKey.decennialDaysCounter)}</div>
<div class="date-info">
<div class="date-box">
<div class="date-title text-base text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialCountFrom)}</div>
<div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="startDate">2025-01-01</div>
</div>
<div class="date-box">
<div class="date-title text-base text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialCountTo)}</div>
<div class="text-2xl font-bold text-neutral-900 dark:text-neutral-100" id="endDate">2034-12-29</div>
</div>
</div>
<div class="label-container">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">📄 {i18n(I18nKey.decennialPromiseTitle)}</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry1Title">{i18n(I18nKey.decennialPromiseEntry1)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry1Text"></div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${sorted.length >= 10 ? "oklch(0.6 0.2 170)" : "oklch(0.7 0.2 25)"}`>{i18n(I18nKey.decennialPromiseEntry2)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{sorted.length} {i18n(I18nKey.decennialPostUnit)}</div>
</div>
</div>
<div class="label-container">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">🎉 {i18n(I18nKey.decennialAchievementTitle)}</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${sorted.length >= 100 ? "oklch(0.6 0.2 170)" : ""}`>{i18n(I18nKey.decennialAchievementEntry1)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{sorted.length} {i18n(I18nKey.decennialPostUnit)}</div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${words >= 100000 ? "oklch(0.6 0.2 170)" : ""}`>{i18n(I18nKey.decennialAchievementEntry2)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{words} {i18n(I18nKey.decennialCharacterUnit)}</div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" style=`color:${words >= 1000000 ? "oklch(0.6 0.2 170)" : ""}`>{i18n(I18nKey.decennialAchievementEntry3)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100">{words} {i18n(I18nKey.decennialCharacterUnit)}</div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry4Title">{i18n(I18nKey.decennialAchievementEntry4)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry4Text"></div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry5Title">{i18n(I18nKey.decennialAchievementEntry5)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry5Text"></div>
</div>
<div class="horizontal-items">
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="entry6Title">{i18n(I18nKey.decennialAchievementEntry6)}</div>
<div class="text-base text-neutral-900 dark:text-neutral-100" id="entry6Text"></div>
</div>
</div>
<div class="progress-container">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialAchievementsTitle)}</div>
<div class="progress-label">
<div class="text-xlarge font-bold text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialProgressTitle)}</div>
<div class="text-base font-bold text-neutral-900 dark:text-neutral-100" id="progressPercent"></div>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
</div>
<div class="quote text-base font-bold text-neutral-900 dark:text-neutral-100">{i18n(I18nKey.decennialQuote)}</div>
</div>
</MainGridLayout>
<script is:inline define:vars={{ startDate, latestDate, title, dayU, dayAgoU, daysTogoU }}>
const today = new Date();
const begin = new Date(startDate);
const end = new Date(startDate);
const latest = new Date(latestDate);
end.setDate(end.getDate() + 3650);
const elapsed = Math.ceil((today - latest) / (1000 * 60 * 60 * 24));
const counter = Math.ceil((today - begin) / (1000 * 60 * 60 * 24));
const daysTogo = Math.max(Math.ceil((end - today) / (1000 * 60 * 60 * 24)), 0);
// 计算进度百分比
const progress = (counter / 3650 * 100).toFixed(3);
// 格式化日期为 YYYY-MM-DD
function formatDate(date) {
date = new Date(date);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 更新页面内容
document.getElementById('startDate').textContent = formatDate(begin);
document.getElementById('endDate').textContent = formatDate(end);
document.getElementById('progressPercent').textContent = `${progress}%`;
document.getElementById('entry1Title').style.color = elapsed <= 180 ? "oklch(0.6 0.2 170)" : "oklch(0.7 0.2 25)";
document.getElementById('entry1Text').textContent = `${elapsed} ${dayAgoU}: ${title}`;
document.getElementById('entry4Title').style.color = counter >= 100 ? "oklch(0.6 0.2 170)" : "";
document.getElementById('entry4Text').textContent = `${counter} ${dayU}`;
document.getElementById('entry5Title').style.color = counter >= 1000 ? "oklch(0.6 0.2 170)" : "";
document.getElementById('entry5Text').textContent = `${counter} ${dayU}`;
document.getElementById('entry6Title').style.color = daysTogo === 0 ? "oklch(0.6 0.2 170)" : "";
document.getElementById('entry6Text').textContent = `${daysTogo} ${daysTogoU}`;
// 动画显示进度条
setTimeout(() => {
document.getElementById('progressFill').style.width = `${progress}%`;
}, 500);
// 更新运行天数(动态效果)
let currentDays = 0;
const daysCounter = document.getElementById('daysCounter');
const increment = Math.ceil(counter / 100);
const counterInterval = setInterval(() => {
currentDays += increment;
if (currentDays >= counter) {
currentDays = counter;
clearInterval(counterInterval);
}
daysCounter.textContent = currentDays;
}, 20);
</script>

请在 siteConfig 中写入正确的博客建站日期。若对应字段为空,则默认设置为 2025 年 1 月 1 日建站。

同时,分别在 src/i18n/i18nKey.ts、src/i18n/languages/zh_CN.ts 和 src/i18n/languages/en.ts 三个文件中新增:

// 十年之约 i18nKey.ts
decennialTitle = "decennialTitle",
decennialDesc = "decennialDesc",
decennialSubtitle = "decennialSubtitle",
decennialDaysCounter = "decennialDaysCounter",
decennialCountFrom = "decennialCountFrom",
decennialCountTo = "decennialCountTo",
decennialPromiseTitle = "decennialPromiseTitle",
decennialAchievementTitle = "decennialAchievementTitle",
decennialProgressTitle = "decennialProgressTitle",
decennialQuote = "decennialQuote",
decennialDaysAgo = "decennialDaysAgo",
decennialDaysToGo = "decennialDaysToGo",
decennialCharacterUnit = "decennialCharacterUnit",
decennialDayUnit = "decennialDayUnit",
decennialPostUnit = "decennialPostUnit",
decennialAchievementEntry1 = "decennialAchievementEntry1",
decennialAchievementEntry2 = "decennialAchievementEntry2",
decennialAchievementEntry3 = "decennialAchievementEntry3",
decennialAchievementEntry4 = "decennialAchievementEntry4",
decennialAchievementEntry5 = "decennialAchievementEntry5",
decennialAchievementEntry6 = "decennialAchievementEntry6",
decennialPromiseEntry1 = "decennialPromiseEntry1",
decennialPromiseEntry2 = "decennialPromiseEntry2",
// 十年之约 zh_CN.ts
[Key.decennialTitle]: "十年之约",
[Key.decennialDesc]: "",
[Key.decennialSubtitle]: "我的博客 · 十年之约",
[Key.decennialDaysCounter]: "已运行天数",
[Key.decennialCountFrom]: "建站日期",
[Key.decennialCountTo]: "十年之约到期日",
[Key.decennialPromiseTitle]: "履约事项:",
[Key.decennialAchievementTitle]: "履约成就:",
[Key.decennialProgressTitle]: "履约进度:",
[Key.decennialQuote]: "十年之约,是坚持,是承诺,是对未来的期许。无论风雨,无论晴好,我会在这里记录、分享、成长。",
[Key.decennialDaysAgo]: "天前发布",
[Key.decennialDaysToGo]: "天后",
[Key.decennialCharacterUnit]: "字",
[Key.decennialDayUnit]: "天",
[Key.decennialPostUnit]: "篇",
[Key.decennialAchievementEntry1]: "发布100篇原创文章",
[Key.decennialAchievementEntry2]: "总字数达10万字",
[Key.decennialAchievementEntry3]: "总字数达100万字",
[Key.decennialAchievementEntry4]: "正常运行100天",
[Key.decennialAchievementEntry5]: "正常运行1000天",
[Key.decennialAchievementEntry6]: "完成十年之约",
[Key.decennialPromiseEntry1]: "六个月内有新文章发布",
[Key.decennialPromiseEntry2]: "至少有十篇原创文章",
// Decennial Countdown en.ts
[Key.decennialTitle]: "Decennial Countdown",
[Key.decennialDesc]: "",
[Key.decennialSubtitle]: "My Blog - Decennial Countdown",
[Key.decennialDaysCounter]: "Days Since Online",
[Key.decennialCountFrom]: "Starts on",
[Key.decennialCountTo]: "Ends on",
[Key.decennialPromiseTitle]: "Promise TODOs",
[Key.decennialAchievementTitle]: "Achievements",
[Key.decennialProgressTitle]: "Countdown progress",
[Key.decennialQuote]: "A decennial miracle will soon bloom on this site.",
[Key.decennialDaysAgo]: "days after post",
[Key.decennialDaysToGo]: "days to go",
[Key.decennialCharacterUnit]: "characters",
[Key.decennialDayUnit]: "days",
[Key.decennialPostUnit]: "posts",
[Key.decennialAchievementEntry1]: "Post 100 original articles",
[Key.decennialAchievementEntry2]: "Write 100k words",
[Key.decennialAchievementEntry3]: "Write 1m words",
[Key.decennialAchievementEntry4]: "Run normally for 100 days",
[Key.decennialAchievementEntry5]: "Run normally for 1000 days",
[Key.decennialAchievementEntry6]: "Accomplish the Decennial Countdown",
[Key.decennialPromiseEntry1]: "Post at least one article in six months",
[Key.decennialPromiseEntry2]: "Post at least ten original posts",

请根据博客的自身情况,定义 zh_CN.ts 和 en.ts 文件中的对应字段。

本站十年之约页面见此

友链朋友圈#

Warning

本站的友链朋友圈实现方案可能涉及部署方式的改变(例如 GitHub Pages -> Vercel),请慎重选择!

先下载 node-cache 包,在项目根目录运行:

Terminal window
pnpm add node-cache

在 src/pages/api/ 下新建文件 feed.json.ts 并写入:

import type { APIRoute } from 'astro';
import { analyzeFeed } from '@/utils/feed-utils';
import NodeCache from 'node-cache';
export const prerender = false;
const cache = new NodeCache({ stdTTL: 1800, checkperiod: 120 });
export const POST: APIRoute = async ({ request }) => {
try {
const { friends } = await request.json();
const cacheKey = JSON.stringify(
friends.map(f => ({ baseid: f.baseid, feed: f.feed, feedtype: f.feedtype }))
);
let cachedLinks = cache.get(cacheKey);
if (cachedLinks) {
return new Response(JSON.stringify(cachedLinks), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
const promises = friends.map(async (friend: any) => {
const type = friend.feedtype || 'rss';
const feed = await analyzeFeed(type, friend.feed, 3);
return { baseid: friend.baseid, feed };
});
const links = await Promise.all(promises);
cache.set(cacheKey, links);
return new Response(JSON.stringify(links), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
} catch (error) {
return new Response(JSON.stringify({ error: 'Failed to fetch feeds' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

src/pages/ 下新建文件 circle.astro 并写入:

---
import { Icon } from "astro-icon/components";
import {
friendsPageConfig,
getEnabledFriends,
siteConfig,
} from "@/config";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
if (!siteConfig.pages.circle) {
return Astro.redirect("/404/");
}
const title = "友链朋友圈";
const description = "友链里发生的那些新鲜事";
let friends = [];
getEnabledFriends()
.filter(friend => friend.feed?.length > 0)
.map(friend => {
friends.push({
...friend,
baseid: Buffer.from(friend.siteurl, 'utf-8').toString('base64'),
});
});
---
<MainGridLayout title={title} description={description}>
<div
class="flex w-full rounded-(--radius-large) overflow-hidden relative min-h-32"
>
<div class="card-base z-10 px-9 py-6 relative w-full">
<!-- 页面标题和描述 -->
<div class="mb-4">
<div class="flex items-center gap-3 mb-3">
<div
class="h-8 w-8 rounded-lg bg-(--primary) flex items-center justify-center text-white dark:text-black/70"
>
<Icon is:inline name="material-symbols:rss-feed-rounded" class="text-[1.5rem]" />
</div>
<div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{title}
</div>
</div>
{
description && (
<p class="text-base text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{description}
</p>
)
}
</div>
<div class="mb-4">
{
friends.map((item) => (
<div class="mb-2">
<div
data-tags={item.tags?.join(",")}
class="friend-card group flex items-center gap-3 p-2.5 rounded-xl border border-(--line-divider) transition-all duration-300 hover:shadow-lg relative overflow-hidden"
>
<!-- 头像 -->
<div class="relative w-16 h-16 shrink-0 rounded-xl overflow-hidden bg-zinc-100 dark:bg-zinc-800 border border-black/5 dark:border-white/5 transition-transform duration-300">
<img
src={item.imgurl}
alt={item.title}
class="w-full h-full object-cover"
onerror="this.onerror=null;this.src='/defavatar.svg';"
/>
</div>
<!-- 内容 -->
<div class="grow min-h-0 flex flex-col gap-1">
<div class="flex items-center justify-between">
<div class="font-bold text-base text-neutral-900 dark:text-neutral-100 transition-colors truncate pr-4">
{item.title}
</div>
</div>
<div
class="text-sm text-neutral-500 dark:text-neutral-400 line-clamp-1"
title={item.desc}
>
{item.desc}
</div>
</div>
<div class="flex flex-col gap-0.5" id={item.baseid}>
<div class="feed-loading text-sm text-neutral-400 dark:text-neutral-500 animate-pulse">
加载 RSS/Atom 订阅中...
</div>
</div>
</div>
</div>
))
}
</div>
</div>
</div>
</MainGridLayout>
<script define:vars={{ friends }}>
const friendParams = friends.map(f => ({
baseid: f.baseid,
feedtype: f.feedtype,
feed: f.feed,
}));
async function loadFeeds() {
try {
const response = await fetch('/api/feed.json', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ friends: friendParams }),
});
if (!response.ok) throw new Error('Network response was not ok');
const links = await response.json();
links.forEach((item) => {
const container = document.getElementById(item.baseid);
if (!container) return;
container.replaceChildren();
item.feed.entries.forEach((entry) => {
container.insertAdjacentHTML("beforeend", `
<a
href="${entry.url}"
target="_blank"
rel="noopener noreferrer"
data-tags="${item.tags?.join(",") || ''}"
class="friend-card group flex items-center gap-3 p-2.5 rounded-xl border border-(--line-divider) hover:border-(--primary) hover:bg-(--card-bg) transition-all duration-300 hover:shadow-lg relative overflow-hidden w-100"
>
<div class="flex flex-col">
<div class="font-bold text-base text-neutral-900 dark:text-neutral-100 transition-colors pr-4 line-clamp-1 w-100 truncate">
${entry.title}
</div>
<div class="text-sm text-neutral-500 dark:text-neutral-400 line-clamp-1 indent-1">
${new Date(entry.date).toLocaleDateString()}
</div>
</div>
</a>
`);
});
});
} catch (error) {
console.error('Failed to load feeds:', error);
}
}
loadFeeds();
</script>

在 siteConfig 中,在如下位置添加字段并设置为 true 来启用友链朋友圈页面:

// 页面开关配置 - 控制特定页面的访问权限,设为false会返回404并自动隐藏对应的导航栏菜单项
pages: {
// 友链页面开关
friends: true,
// 友链朋友圈页面开关
circle: true,
// 其他字段……
},

src/utils/ 下新建文件 feed-utils.ts 并写入:

class SimpleXMLParser {
constructor(xmlString) {
this.xml = xmlString;
this.root = null;
this.parse();
}
parse() {
const stack = [];
let currentParent = null;
let i = 0;
const len = this.xml.length;
while (i < len) {
// 跳过空白字符(仅对标签外有效,但为了简化,我们统一跳过)
if (this.xml[i] === ' ' || this.xml[i] === '\n' || this.xml[i] === '\r' || this.xml[i] === '\t') {
i++;
continue;
}
if (this.xml.startsWith('<?', i)) {
const end = this.xml.indexOf('?>', i);
if (end === -1) throw new Error('处理指令未闭合');
i = end + 2;
continue;
}
// 跳过注释(如 <!-- comment -->)
if (this.xml.startsWith('<!--', i)) {
const end = this.xml.indexOf('-->', i);
if (end === -1) throw new Error('注释未闭合');
i = end + 3;
continue;
}
// 处理标签或 CDATA
if (this.xml[i] === '<') {
// --- 检测 CDATA 开始 ---
if (this.xml.startsWith('<![CDATA[', i)) {
const cdataStart = i + 9; // '<![CDATA[' 的长度
const cdataEnd = this.xml.indexOf(']]>', cdataStart);
if (cdataEnd === -1) {
throw new Error('CDATA 未闭合');
}
const cdataContent = this.xml.substring(cdataStart, cdataEnd);
// 创建 CDATA 节点(作为文本节点的一种)
const cdataNode = {
nodeType: 'cdata',
nodeValue: cdataContent,
parent: currentParent
};
if (currentParent) {
currentParent.childNodes.push(cdataNode);
} else {
// CDATA 不能作为根节点,但为了容错,仍添加
// 实际上根元素应该是标签,此处防止错误使用
throw new Error('CDATA 必须在元素内部');
}
i = cdataEnd + 3; // 跳过 ']]>'
continue;
}
// --- 结束标签 ---
if (this.xml[i + 1] === '/') {
const endTagEnd = this.xml.indexOf('>', i);
if (endTagEnd === -1) throw new Error('标签未闭合');
const tagName = this.xml.substring(i + 2, endTagEnd).trim();
if (stack.length === 0 || stack[stack.length - 1].tagName !== tagName) {
throw new Error(`标签不匹配: 期望 ${stack[stack.length - 1]?.tagName}, 得到 ${tagName}`);
}
stack.pop();
currentParent = stack.length > 0 ? stack[stack.length - 1] : null;
i = endTagEnd + 1;
continue;
}
// --- 自闭合标签(如 <book />)---
const selfClose = this.xml.indexOf('/>', i);
const closeBracket = this.xml.indexOf('>', i);
if (selfClose !== -1 && selfClose < closeBracket) {
const tagContent = this.xml.substring(i + 1, selfClose).trim();
const tagName = tagContent.split(/\s/)[0];
const newNode = {
tagName: tagName,
attributes: {},
childNodes: [],
parent: currentParent
};
if (currentParent) {
currentParent.childNodes.push(newNode);
} else {
if (this.root) throw new Error('文档只能有一个根元素');
this.root = newNode;
}
i = selfClose + 2;
continue;
}
// --- 普通开始标签 ---
const tagEnd = this.xml.indexOf('>', i);
if (tagEnd === -1) throw new Error('标签未闭合');
const tagContent = this.xml.substring(i + 1, tagEnd).trim();
const tagName = tagContent.split(/\s/)[0];
const newNode = {
tagName: tagName,
attributes: {},
childNodes: [],
parent: currentParent
};
if (currentParent) {
currentParent.childNodes.push(newNode);
} else {
if (this.root) throw new Error('文档只能有一个根元素');
this.root = newNode;
}
stack.push(newNode);
currentParent = newNode;
i = tagEnd + 1;
continue;
}
// --- 普通文本内容(非 CDATA) ---
const nextLt = this.xml.indexOf('<', i);
let text = '';
if (nextLt === -1) {
text = this.xml.substring(i).trim();
i = len;
} else {
text = this.xml.substring(i, nextLt).trim();
i = nextLt;
}
if (text && currentParent) {
currentParent.childNodes.push({
nodeType: 'text',
nodeValue: text,
parent: currentParent
});
}
}
if (stack.length > 0) {
throw new Error(`标签未闭合: ${stack[stack.length - 1].tagName}`);
}
}
getElementsByTagName(tagName) {
const result = [];
const traverse = (node) => {
if (node.tagName === tagName) {
result.push(node);
}
if (node.childNodes) {
for (const child of node.childNodes) {
traverse(child);
}
}
};
if (this.root) traverse(this.root);
return result;
}
hasChild() {
return this.childNodes?.length > 0;
}
getRoot() {
return this.root;
}
}
export async function analyzeFeed(type: string, url: string, count: int = -1) : Object {
let ret = {url: url, entries: []};
if (type === 'atom') {
ret.entries = await readAtom(url, count);
} else if (type === 'rss') {
ret.entries = await readRss(url, count);
}
return ret;
}
async function readAtom(url: string, count: int = -1) : Object[] {
let atoms = [];
await fetch(url).then(res => res.text()).then(data => {
const parser = new SimpleXMLParser(data);
const entries = parser.getElementsByTagName('entry').sort((a, b) => {
const at = new Date(b.childNodes.find(n => n.tagName === 'published').childNodes[0].nodeValue);
const bt = new Date(a.childNodes.find(n => n.tagName === 'published').childNodes[0].nodeValue);
return at.getTime() - bt.getTime();
});
if (count < 0) count = entries.length;
for (let i = 0; i < count; i++) {
if (i >= entries.length) break;
const entry = entries[i];
const title = entry.childNodes.find(n => n.tagName === 'title').childNodes[0].nodeValue;
const date = entry.childNodes.find(n => n.tagName === 'published').childNodes[0].nodeValue;
const url = entry.childNodes.find(n => n.tagName === 'id').childNodes[0].nodeValue;
atoms.push({
title: title,
date: date,
url: url,
});
}
}).catch(e => {
console.log(e);
atoms.push({title: null, date: null, url: null})
});
return atoms;
}
async function readRss(url: string, count: int = -1) : Object[] {
let rss = [];
await fetch(url).then(res => res.text()).then(data => {
const parser = new SimpleXMLParser(data);
const items = parser.getElementsByTagName('item').sort((a, b) => {
const at = new Date(b.childNodes.find(n => n.tagName === 'pubDate').childNodes[0].nodeValue);
const bt = new Date(a.childNodes.find(n => n.tagName === 'pubDate').childNodes[0].nodeValue)
return at.getTime() - bt.getTime()
});
if (count < 0) count = items.length;
for (let i = 0; i < count; i++) {
if (i >= items.length) break;
const item = items[i];
const title = item.childNodes.find(n => n.tagName === 'title').childNodes[0].nodeValue;
const date = item.childNodes.find(n => n.tagName === 'pubDate').childNodes[0].nodeValue;
const url = item.childNodes.find(n => n.tagName === 'link').childNodes[0].nodeValue;
rss.push({
title: title,
date: date,
url: url,
});
}
}).catch(e => {
console.log(e);
rss.push({title: null, date: null, url: null})
});
return rss;
}

此时便可以通过编辑友链配置 friendsConfig 来抓取对应博客的订阅文章。本站示例如下:

{
title: "JustPureH2O",
imgurl: "https://justpureh2o.cn/avatar.jpg",
desc: "穷方圆平直之情,尽规矩准绳之用",
siteurl: "https://justpureh2o.cn/",
tags: [],
weight: 10000,
enabled: true,
feed: 'https://justpureh2o.cn/rss.xml', // RSS/Atom 文件地址
feedtype: 'rss' // 支持的值:rss(默认)、atom,用来指定订阅格式,暂不支持其他格式
},

如果需要部署到网页,需要下载对应平台的 Adapter 用来处理我们新建的服务端 API(即 feed.json.ts)。本站选用 Vercel,那么在根目录执行包安装:

Terminal window
pnpm add @astrojs/vercel

按照如下方式编辑 astro.config.mjs 文件:

// 开头引入包
import vercel from "@astrojs/vercel";
// ……
// https://astro.build/config
export default defineConfig({
// ……
adapter: vercel(),
// ……
});

在 src/content/spec/ 下新建空文件 circle.md/circle.mdx。将博客源码上传至你的仓库。

此时在 Vercel Dashboard 新建项目,从存储本站源码的仓库里导入,默认设置不要动(会自动检测为 Astro 框架),等待初次构建完毕后,绑定域名。

本站友链朋友圈页面见此

“轻小说推荐墙”页面#

在 src/pages 下新建文件 novel.astro,并写入:

---
import { Icon } from "astro-icon/components";
import { novelsConfig, novelsPageConfig, siteConfig } from "@/config";
import MainGridLayout from "@/layouts/MainGridLayout.astro";
import type { NovelStatus } from "@/types/novelsConfig";
import { formatDateI18nWithTime } from "@/utils/date-utils";
// 页面开关,关闭时自动返回 404
if (!siteConfig.pages.novels) {
return Astro.redirect("/404/");
}
// ============ 页面文案 ============
const title = novelsPageConfig.title ?? "小说推荐";
const subtitle =
novelsPageConfig.subtitle ?? "那些读过的、正在读的,以及想推荐给你的故事";
const description = novelsPageConfig.description ?? subtitle;
const defaultSort = novelsPageConfig.defaultSort ?? "score";
const showStats = novelsPageConfig.showStats !== false;
const showSearch = novelsPageConfig.showSearch !== false;
const showFilter = novelsPageConfig.showFilter !== false;
// ============ 常量与工具 ============
// 未填写 rankFullPoint 时的默认满分
const DEFAULT_FULL_POINT = 10;
// 阅读状态的展示文案与配色
const STATUS_META: Record<NovelStatus, { label: string; className: string; idx: number }> = {
read: {
label: "已读",
className: "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400",
idx: 2,
},
reading: {
label: "在读",
className: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
idx: 1,
},
plan: {
label: "想读",
className: "bg-amber-500/10 text-amber-600 dark:text-amber-400",
idx: 0,
},
};
/** 评分格式化:整数原样展示,小数保留一位 */
function formatScore(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(1);
}
// ============ 数据处理 ============
// 把配置文件里的原始数据补齐为页面可用的结构
const novels = novelsConfig.map((novel, index) => {
const full =
novel.rankFullPoint && novel.rankFullPoint > 0
? novel.rankFullPoint
: DEFAULT_FULL_POINT;
const score = typeof novel.ranking === "number" ? novel.ranking : null;
// 评分占满分的百分比,用于卡片上的进度条与前端排序
const percent =
score === null
? null
: Math.min(100, Math.max(0, (score / full) * 100));
const status = novel.status ? STATUS_META[novel.status] : null;
// 没有封面时用书名首字做占位封面
const initial = Array.from(novel.title.trim())[0] ?? "书";
const searchText = [
novel.title,
novel.alias,
novel.author,
novel.desc,
novel.tags,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
const publish = novel.publish;
const issuing = novel.issuing;
return {
...novel,
index,
score,
full,
percent,
status,
initial,
searchText,
publish,
issuing,
// 按序号错开占位封面的色相,让书架看起来更活泼
hue: (index * 47 + 205) % 360,
};
});
// 按归一化后的评分为全部小说排名
const novelsSortedByScore = [...novels].sort(
(a, b) => {
if (a.status?.idx || 0 < (b.status?.idx || 0) && b.status?.idx === 0) return 1;
if (a.status?.idx || 0 > (b.status?.idx || 0) && a.status?.idx === 0) return -1;
return (b.score ?? -1) / b.full - (a.score ?? -1) / a.full;
});
// 首屏顺序跟随 defaultSort 配置
const initialList =
defaultSort === "title"
? [...novels].sort((a, b) => {
if (a.status?.idx || 0 < (b.status?.idx || 0) && b.status?.idx === 0) return 1;
if (a.status?.idx || 0 > (b.status?.idx || 0) && a.status?.idx === 0) return -1;
return a.title.localeCompare(b.title, "zh-Hans-CN");
})
: defaultSort === "custom"
? [...novels]
: novelsSortedByScore;
// ============ 统计信息 ============
const totalCount = novels.length;
const ratedNovels = novels.filter((novel) => novel.score !== null);
const averageScore = ratedNovels.length
? (ratedNovels.reduce(
(sum, novel) => sum + (novel.score ?? 0) / novel.full,
0,
) / ratedNovels.length) * 10
: null;
const topNovel = ratedNovels.length
? [...ratedNovels].sort(
(a, b) => (b.score ?? 0) / b.full - (a.score ?? 0) / a.full,
)[0]
: null;
const tags = [
...new Set(
novels
.flatMap((novel) => novel.tags)
.filter((tags): tags is string => Boolean(tags)),
),
];
const hasContent = totalCount > 0;
const buildTime = formatDateI18nWithTime(new Date());
---
<MainGridLayout title={title} description={description}>
<div class="relative flex min-h-32 w-full overflow-hidden rounded-(--radius-large)">
<div class="card-base relative z-10 w-full px-4 py-4 sm:px-6 sm:py-6 md:px-9">
{/* 页面标题 */}
<div class="mb-6">
<div class="mb-3 flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-lg bg-(--primary) text-white dark:text-black/70">
<Icon is:inline name="material-symbols:auto-stories" class="text-[1.5rem]" />
</div>
<div class="text-3xl font-bold text-neutral-900 dark:text-neutral-100">
{title}
</div>
</div>
<p class="text-base leading-relaxed text-neutral-600 dark:text-neutral-400">
{subtitle}
</p>
<p class="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-neutral-500 dark:text-neutral-500">
<span class="inline-flex items-center gap-1">
<Icon is:inline name="material-symbols:menu-book" class="text-sm" />
共 {totalCount} 本
</span>
<span class="text-neutral-300 dark:text-neutral-600">·</span>
<span class="inline-flex items-center gap-1">
<Icon is:inline name="material-symbols:schedule" class="text-sm" />
更新于 {buildTime}
</span>
</p>
</div>
{/* 统计卡片 */}
{showStats && hasContent && (
<div class="mb-6 grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
<div class="rounded-xl border border-(--line-divider) bg-(--card-bg) p-3 sm:p-4">
<div class="flex items-center gap-2 sm:gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-(--primary)/10 sm:h-10 sm:w-10">
<Icon is:inline name="material-symbols:menu-book" class="text-[1rem] text-(--primary) sm:text-[1.25rem]" />
</div>
<div class="min-w-0">
<div class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">收录小说</div>
<div class="text-lg font-bold text-neutral-900 sm:text-2xl dark:text-neutral-100">
{totalCount}
<span class="ml-0.5 text-xs font-normal text-neutral-400">本</span>
</div>
</div>
</div>
</div>
<div class="rounded-xl border border-(--line-divider) bg-(--card-bg) p-3 sm:p-4">
<div class="flex items-center gap-2 sm:gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-amber-500/10 sm:h-10 sm:w-10">
<Icon is:inline name="material-symbols:star" class="text-[1rem] text-amber-500 sm:text-[1.25rem]" />
</div>
<div class="min-w-0">
<div class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">平均评分</div>
<div class="text-lg font-bold text-neutral-900 sm:text-2xl dark:text-neutral-100">
{averageScore === null ? "—" : formatScore(averageScore)}
</div>
</div>
</div>
</div>
<div class="rounded-xl border border-(--line-divider) bg-(--card-bg) p-3 sm:p-4">
<div class="flex items-center gap-2 sm:gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-emerald-500/10 sm:h-10 sm:w-10">
<Icon is:inline name="material-symbols:layers" class="text-[1rem] text-emerald-500 sm:text-[1.25rem]" />
</div>
<div class="min-w-0">
<div class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">来源分类</div>
<div class="text-lg font-bold text-neutral-900 sm:text-2xl dark:text-neutral-100">
{tags.length}
<span class="ml-0.5 text-xs font-normal text-neutral-400">类</span>
</div>
</div>
</div>
</div>
<div class="rounded-xl border border-(--line-divider) bg-(--card-bg) p-3 sm:p-4">
<div class="flex items-center gap-2 sm:gap-3">
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-pink-500/10 sm:h-10 sm:w-10">
<Icon is:inline name="material-symbols:trophy" class="text-[1rem] text-pink-500 sm:text-[1.25rem]" />
</div>
<div class="min-w-0">
<div class="text-[10px] text-neutral-500 sm:text-xs dark:text-neutral-400">最高评分</div>
<div class="truncate text-lg font-bold text-neutral-900 sm:text-2xl dark:text-neutral-100" title={topNovel?.title}>
{topNovel?.score != null ? formatScore(topNovel.score) : "—"}
</div>
</div>
</div>
</div>
</div>
)}
{hasContent ? (
<novels-shelf class="block">
{/* 搜索与排序 */}
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{showSearch ? (
<label class="relative block w-full max-w-md">
<Icon is:inline
name="material-symbols:search"
class="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-lg text-neutral-400 dark:text-neutral-500"
/>
<input
data-novel-search
type="search"
placeholder="搜索书名、作者或简介…"
aria-label="搜索小说"
class="w-full rounded-xl border border-(--line-divider) bg-transparent py-2.5 pl-10 pr-4 text-sm text-neutral-900 outline-none transition-all duration-200 placeholder-neutral-400 focus:border-(--primary) focus:ring-1 focus:ring-(--primary) dark:text-neutral-100 dark:placeholder-neutral-500"
/>
</label>
) : (
<div class="hidden md:block" />
)}
<div class="flex items-center gap-2 self-end md:self-auto">
<Icon is:inline name="material-symbols:sort" class="text-lg text-neutral-400 dark:text-neutral-500" />
<div class="relative">
<select
data-novel-sort
aria-label="排序方式"
class="cursor-pointer appearance-none rounded-xl border border-(--line-divider) bg-transparent py-2.5 pl-3 pr-9 text-sm text-neutral-700 outline-none transition-colors focus:border-(--primary) dark:text-neutral-200"
>
<option value="score" selected={defaultSort === "score"}>评分从高到低</option>
<option value="title" selected={defaultSort === "title"}>按书名排序</option>
<option value="custom" selected={defaultSort === "custom"}>自定义顺序</option>
</select>
<Icon is:inline
name="material-symbols:expand-more"
class="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-lg text-neutral-400 dark:text-neutral-500"
/>
</div>
</div>
</div>
{/* 来源筛选 */}
{showFilter && tags.length > 1 && (
<div class="mb-3 flex flex-wrap items-center gap-2">
<button
type="button"
data-novel-filter="all"
class="novel-chip rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors duration-200 border-(--primary) bg-(--primary) text-white"
>
全部
</button>
{tags.map((tag) => (
<button
type="button"
data-novel-filter={tag}
class="novel-chip rounded-lg border px-3 py-1.5 text-sm font-medium transition-colors duration-200 border-(--line-divider) bg-(--btn-regular-bg) text-(--btn-content) hover:bg-(--btn-regular-bg-hover)"
>
{tag}
</button>
))}
</div>
)}
{/* 数量提示 */}
<div class="mb-3 flex items-center gap-1.5 text-xs text-neutral-500 dark:text-neutral-400" aria-live="polite">
<Icon is:inline name="material-symbols:bookmarks" class="text-base" />
<span data-novel-count>共 {totalCount} 本</span>
</div>
{/* 小说列表 */}
<div class="novels-grid">
{initialList.map((novel) => {
return (
<article
class="novel-card group relative flex flex-col overflow-hidden rounded-xl border border-(--line-divider) bg-(--card-bg) transition-all duration-300 hover:-translate-y-0.5 hover:border-(--primary)/50 hover:shadow-lg"
data-search={novel.searchText}
data-tags={novel.tags ?? ""}
data-status={novel.status?.idx}
data-title={novel.title}
data-percent={novel.percent ?? -1}
data-index={novel.index}
style={`--novel-index: ${novel.index};`}
>
<div class="flex grow gap-3 p-3">
{/* 封面 */}
<div class="relative min-h-24 w-20 shrink-0 self-stretch overflow-hidden rounded-lg bg-zinc-100 ring-1 ring-black/5 sm:w-24 dark:bg-zinc-800 dark:ring-white/5">
<div class="novel-cover absolute inset-0" style={`--novel-hue: ${novel.hue}; --novel-hue-2: ${(novel.hue + 45) % 360};`}>
<span class="novel-cover__initial">{novel.initial}</span>
</div>
{novel.cover && (
<img
src=`../${novel.cover}`
alt={novel.title}
loading="lazy"
class="absolute inset-0 h-full w-full object-cover"
onerror="this.remove()"
/>
)}
<div class="novel-spine pointer-events-none absolute inset-y-0 left-0 z-10 w-1.5" />
</div>
{/* 文字信息 */}
<div class="flex min-w-0 grow flex-col">
<div class="flex items-start justify-between gap-2">
<h3
class="truncate text-base font-bold text-neutral-900 transition-colors group-hover:text-(--primary) dark:text-neutral-100"
title={novel.title}
>
{novel.title}
</h3>
<div class="flex flex-row">
{novel.link && (
<a
href={novel.link}
target="_blank"
rel="noopener noreferrer"
aria-label={`查看《${novel.title}》详情`}
class="shrink-0 rounded-md p-1 text-neutral-400 transition-colors hover:bg-(--btn-regular-bg) hover:text-(--primary)"
>
<Icon is:inline name="material-symbols:arrow-outward-rounded" class="text-base" />
</a>
)}
</div>
</div>
{(novel.author && novel.alias) && (
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[0.7rem] text-neutral-500 dark:text-neutral-400">
{novel.alias && <span class="truncate">别名:{novel.alias}</span>}
</div>
)}
{(novel.author) && (
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[0.7rem] text-neutral-500 dark:text-neutral-400">
{novel.author && <span class="truncate">{novel.author}</span>}
{novel.author && novel.source && (
<span class="text-neutral-300 dark:text-neutral-600">·</span>
)}
{novel.source && <span class="truncate">{novel.source}</span>}
{(novel.publish || novel.issuing !== undefined) && (
<span class="text-neutral-300 dark:text-neutral-600">·</span>
)}
{novel.publish && <span class="truncate">发行于 {novel.publish}</span>}
{novel.publish && novel.issuing !== undefined && (
<span class="text-neutral-300 dark:text-neutral-600">·</span>
)}
{novel.issuing !== undefined &&
<span class="truncate">{novel.issuing ? "连载中" : "已完结"}</span>
}
</div>
)}
{(novel.award) && (
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[0.7rem] text-neutral-500 dark:text-neutral-400">
{novel.award && <span class="truncate">{novel.award}</span>}
</div>
)}
{novel.desc && (
<div class="mt-1.5">
<p class="novel-desc line-clamp-2 text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
{novel.desc}
</p>
{novel.desc.length > 30 && (
<button
type="button"
data-desc-toggle
aria-expanded="false"
class="mt-1 text-[0.7rem] font-medium text-(--primary) hover:underline"
>
展开简介
</button>
)}
</div>
)}
{(novel.status) && (
<div class="mt-auto pt-2.5" style=`visibility: ${novel.status?.idx === 0 || !novel.ranking ? 'hidden' : 'visible'}`>
<div class="mb-1 flex items-baseline justify-between">
<span class="text-[0.7rem] text-neutral-500 dark:text-neutral-400">我的评分</span>
{novel.score !== null ? (
<span class="text-[0.7rem] text-neutral-400 dark:text-neutral-500">
<span class="text-sm font-bold text-(--primary)">{formatScore(novel.score)}</span>
<span class="ml-0.5">/ {formatScore(novel.full)}</span>
</span>
) : (
<span class="text-[0.7rem] text-neutral-400 dark:text-neutral-500">暂无评分</span>
)}
</div>
<div class="h-1.5 w-full overflow-hidden rounded-full bg-(--btn-regular-bg)">
<div
class="h-full rounded-full bg-(--primary) transition-[width] duration-500"
style={`width: ${novel.percent ?? 0}%`}
/>
</div>
</div>
)}
{/* 来源与状态 */}
{(novel.tags || novel.status) && (
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{novel.tags && novel.tags.map((tag) => (
<span class="rounded bg-(--btn-regular-bg) px-1.5 py-0.5 text-[0.65rem] text-(--btn-content)" data-novel-filter={tag}>
{tag}
</span>
)
)}
{novel.status && (
<span class:list={["rounded px-1.5 py-0.5 text-[0.65rem]", novel.status.className]}>
{novel.status.label}
</span>
)}
</div>
)}
</div>
</div>
</article>
);
})}
</div>
{/* 筛选无结果 */}
<div
data-novel-empty
class="hidden flex-col items-center justify-center py-12 text-neutral-400 dark:text-neutral-500"
>
<Icon is:inline name="material-symbols:search-off-rounded" class="mb-3 text-4xl" />
<div class="text-sm">没有找到匹配的小说,换个关键词试试?</div>
</div>
</novels-shelf>
) : (
/* 配置为空时的提示 */
<div class="flex flex-col items-center justify-center py-16 text-neutral-400 dark:text-neutral-500">
<div class="mb-6 inline-flex h-16 w-16 items-center justify-center rounded-full border border-(--line-divider) bg-(--btn-regular-bg)">
<Icon is:inline name="material-symbols:menu-book" class="text-[2rem] text-(--btn-content)" />
</div>
<h2 class="mb-2 text-xl font-semibold text-black/80 dark:text-white/80">
书架还是空的
</h2>
<p class="max-w-md text-center text-sm">
{novelsPageConfig.emptyText ?? "去添加喜欢的小说吧~"}
</p>
</div>
)}
</div>
</div>
<script slot="head" is:inline>
// 搜索 / 筛选 / 排序全部在前端完成,改了配置重新构建即可
if (!customElements.get("novels-shelf")) {
class NovelsShelf extends HTMLElement {
connectedCallback() {
if (this._bound) return;
this._bound = true;
this.activeSource = "all";
// 事件委托:监听挂在自定义元素本身。
// 内联脚本在 <head> 中执行,元素可能在子节点解析完成前就被升级,
// 委托可以保证输入、筛选、排序在任何时机都生效。
this.addEventListener("input", (event) => {
const target = event.target;
if (target instanceof Element && target.closest("[data-novel-search]")) {
this.apply();
}
});
this.addEventListener("change", (event) => {
const target = event.target;
if (target instanceof Element && target.closest("[data-novel-sort]")) {
this.apply();
}
});
this.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Element)) return;
// 展开 / 收起完整书评
const toggle = target.closest("[data-desc-toggle]");
if (toggle) {
const desc = toggle.parentElement.querySelector(".novel-desc");
if (!desc) return;
const collapsed = desc.classList.toggle("line-clamp-2");
toggle.textContent = collapsed ? "展开书评" : "收起";
toggle.setAttribute("aria-expanded", String(!collapsed));
return;
}
const chip = target.closest("[data-novel-filter]");
if (!chip || !this.contains(chip)) return;
this.activeSource = chip.getAttribute("data-novel-filter") || "all";
this.updateChips();
this.apply();
});
this.apply();
// 元素刚被升级时可能还读不到卡片,下一帧再算一次
requestAnimationFrame(() => this.apply());
}
updateChips() {
this.querySelectorAll("[data-novel-filter]").forEach((chip) => {
const active =
(chip.getAttribute("data-novel-filter") || "") === this.activeSource;
chip.classList.toggle("border-(--primary)", active);
chip.classList.toggle("bg-(--primary)", active);
chip.classList.toggle("text-white", active);
chip.classList.toggle("border-(--line-divider)", !active);
chip.classList.toggle("bg-(--btn-regular-bg)", !active);
chip.classList.toggle("text-(--btn-content)", !active);
});
}
apply() {
const input = this.querySelector("[data-novel-search]");
const sortSelect = this.querySelector("[data-novel-sort]");
const grid = this.querySelector(".novels-grid");
const emptyState = this.querySelector("[data-novel-empty]");
const countEl = this.querySelector("[data-novel-count]");
const cards = Array.from(this.querySelectorAll(".novel-card"));
// 子节点还没解析完时先不处理,等下一次交互或下一帧
if (cards.length === 0) return;
const query = input ? input.value.trim().toLowerCase() : "";
const sort = sortSelect ? sortSelect.value : "score";
let visible = 0;
cards.forEach((card) => {
const matchesQuery = !query || (card.dataset.search || "").includes(query) || (card.dataset.tags || "").includes(query);
const matchesSource =
this.activeSource === "all" ||
card.dataset.tags.includes(this.activeSource);
const shown = matchesQuery && matchesSource;
card.classList.toggle("hidden", !shown);
if (shown) visible += 1;
});
const sorted = [...cards].sort((a, b) => {
if (sort === "custom") return Number(a.dataset.index) - Number(b.dataset.index);
if (sort === "title") {
return (a.dataset.title || "").localeCompare(
b.dataset.title || "",
"zh-Hans-CN",
);
}
if (Number(a.dataset.status) < Number(b.dataset.status) && Number(b.dataset.status) === 0) return 1;
if (Number(a.dataset.status) > Number(b.dataset.status) && Number(a.dataset.status) === 0) return -1;
return Number(b.dataset.percent || -1) - Number(a.dataset.percent || -1);
});
if (grid) {
sorted.forEach((card) => grid.appendChild(card));
// scoped 样式优先级高于 .hidden,这里直接控制 display
grid.style.display = visible === 0 ? "none" : "";
}
if (countEl) {
countEl.textContent =
visible === cards.length
? "共 " + visible + " 本"
: "筛选出 " + visible + " 本 / 共 " + cards.length + " 本";
}
if (emptyState) {
emptyState.classList.toggle("hidden", visible > 0);
emptyState.classList.toggle("flex", visible === 0);
}
}
}
customElements.define("novels-shelf", NovelsShelf);
}
</script>
<style>
:root {
--novel-hue: 0;
--novel-hue-2: 0;
--novel-index: 0;
}
/* 小说陈列网格:根据可用宽度自动排成 1~2 列 */
.novels-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 20rem), 1fr));
gap: 0.75rem;
}
/* 没有封面时的渐变占位封面 */
.novel-cover {
background-image: linear-gradient(
155deg,
oklch(0.78 0.11 var(--novel-hue, 250)),
oklch(0.52 0.15 var(--novel-hue-2, 295))
);
}
/* 占位封面上的斜纹装饰 */
.novel-cover::after {
content: "";
position: absolute;
inset: 0;
background-image: repeating-linear-gradient(
115deg,
rgb(255 255 255 / 0.18) 0 1px,
transparent 1px 10px
);
opacity: 0.55;
}
.novel-cover__initial {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: rgb(255 255 255 / 0.92);
font-size: 1.75rem;
font-weight: 700;
line-height: 1;
text-shadow: 0 1px 8px rgb(0 0 0 / 0.25);
}
/* 书脊阴影 */
.novel-spine {
background-image: linear-gradient(90deg, rgb(0 0 0 / 0.22), transparent);
}
/* 卡片入场动画,按配置顺序错开 */
.novel-card {
animation: novel-card-in 360ms cubic-bezier(0.22, 1, 0.36, 1) backwards;
animation-delay: calc(var(--novel-index, 0) * 45ms);
}
@keyframes novel-card-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.novel-card {
animation: none;
}
}
</style>
</MainGridLayout>

同时在配置目录 src/config 下新建 novelsConfig.ts,写入:

import type { NovelsConfig, NovelsPageConfig } from "@/types/novelsConfig";
// 小说推荐页面配置
export const novelsPageConfig: NovelsPageConfig = {
// 页面标题
title: "轻小说推荐墙",
// 页面副标题
subtitle: "轻小说赛高!!!",
// 页面描述(SEO / 分享卡片),留空时使用副标题
description: "我读过的那些轻小说",
// 是否显示顶部统计卡片
showStats: true,
// 是否显示搜索框
showSearch: true,
// 是否显示来源筛选
showFilter: true,
// 默认排序:score=评分从高到低 | title=书名 | custom=下面的书写顺序
defaultSort: "score",
// 没有任何小说时的提示
emptyText: "还没有收录任何小说",
};
// 小说列表
// ranking 为评分,rankFullPoint 为满分(默认 10 分)
// cover 可填 public 目录下的路径(如 /images/novels/xxx.jpg)或完整外链,留空则自动生成占位封面
// link 为详情页外链(豆瓣、Bangumi、维基等),填了之后卡片右上角会出现跳转箭头
// source 既是来源也是筛选标签,同一个 source 会自动归为一类
export const novelsConfig: NovelsConfig[] = [
// 示例小说,可自行更改
{
title: "这里是,终末停滞委员会",
alias: "这里是,终局停滞委员会",
author: "逢缘奇演",
ranking: 9,
rankFullPoint: 10,
desc: "终局并不能停止,只能使其停滞。即使如此,他们仍旧拿起枪械。要问为什么?因为对他们来说这就是家常便饭,每天都像节庆,青春正该如此!原本替黑帮打杂而向往「普通」的我,在这里不就可以过起开心的学园生活吗?",
source: "电击文库",
tags: ["轻小说", "电击文库"],
status: "read",
cover: "assets/images/novels/3947s.jpg",
award: "这本轻小说真厉害 2026 第八名",
link: "https://www.wenku8.net/book/3947.htm",
issuing: true,
publish: 2024,
},
];

相关 types 的书写不再赘述。

本站对应页面:轻小说推荐墙

化学结构式支持#

搭配 openchenmlib 实现 SMILES 语法转 SVG 结构式。下面是几个简单的示例:

乙炔: ::mol{smiles="C#C", label="乙炔"}

异丁烷: ::mol{smiles="CC(C)C", label="异丁烷"}

苯环: ::mol{smiles="c1ccccc1", label="苯环"}

卟啉核心: C H C H C H C H N H N H N H N H ::mol{smiles="c1cc2cc3ccc(cc4ccc(cc5ccc(cc1[nH]2)[nH]5)[nH]4)[nH]3" label="卟啉核心" width=300}

行内写法::mol{},行间写法:::mol{},容器写法::::mol{}:::(注意三冒号要闭合)


首先自定义插件 rehype-mol,放入 src/plugins/ 中(目录可任选,最后需在 astro.config.mjs 中按你的存放路径注册插件)。

插件中写入:

/**
* rehype-mol.mjs
*
* 把 `:::mol{smiles="..." label="..." width=...}` 指令在构建期渲染为结构式 SVG。
*
* - 语法:`:::mol` 指令(remark-directive 解析,parseDirectiveNode 转成 <mol …> 元素)
* - 渲染:openchemlib 在 Node 侧把 SMILES 转成 SVG(纯 JS,无 LaTeX、无浏览器)
* - 主题:把 SVG 里的 rgb(0,0,0) 换成 currentColor,跟随明暗主题的正文颜色
* - 缓存:按 SMILES+尺寸做内存缓存,并可选写 node_modules/.cache 以加速重复构建
*
* 属性:
* smiles 必填,SMILES 字符串
* label 可选,图注(figcaption)
* width 可选,渲染宽度(px,默认 260)
* height 可选,渲染高度(px,默认 180)
* inline 可选,true 时输出行内版本(不加 figure 外边距)
*/
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fromHtml } from "hast-util-from-html";
import { h } from "hastscript";
import { Molecule } from "openchemlib";
const DEFAULTS = { width: 260, height: 180 };
const memoryCache = new Map();
const diskCacheDir = path.join(process.cwd(), "node_modules", ".cache", "firefly-mol");
function diskPath(key) {
try {
fs.mkdirSync(diskCacheDir, { recursive: true });
return path.join(diskCacheDir, `${key}.svg`);
} catch {
return null;
}
}
/** 生成结构式 SVG(已把颜色换成 currentColor,并用占位 id 便于去重) */
function renderMolecule(smiles, width, height) {
const key = createHash("sha1")
.update(`${smiles}|${width}x${height}`)
.digest("hex")
.slice(0, 16);
if (memoryCache.has(key)) return memoryCache.get(key);
const file = diskPath(key);
if (file && fs.existsSync(file)) {
const cached = fs.readFileSync(file, "utf8");
memoryCache.set(key, cached);
return cached;
}
const molecule = Molecule.fromSmiles(smiles);
let svg = molecule.toSVG(width, height, "__MOL_ID__");
// 颜色跟随主题;同时去掉硬编码宽高,交给外层 CSS/属性控制
svg = svg
.replace(/rgb\(\s*0\s*,\s*0\s*,\s*0\s*\)/g, "currentColor")
.replace(/\s(width|height)="[^"]*"/g, "");
if (file) {
try {
fs.writeFileSync(file, svg, "utf8");
} catch {
/* 缓存写失败不影响构建 */
}
}
memoryCache.set(key, svg);
return svg;
}
function toNumber(value, fallback) {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
function buildFigure(smiles, label, width, height, inline, index) {
const svgRaw = renderMolecule(smiles, width, height).replaceAll(
"__MOL_ID__",
`mol-${index}`,
);
// 用 SVG 字符串解析出 hast,再补上展示属性
const fragment = fromHtml(svgRaw, { fragment: true });
const svgNode = fragment.children.find(
(node) => node.type === "element" && node.tagName === "svg",
);
if (!svgNode) {
return h(
"span",
{ className: ["mol-error"] },
`mol: 无法渲染 ${smiles}`,
);
}
svgNode.properties = {
...svgNode.properties,
width: String(width),
height: String(height),
role: "img",
"aria-label": label || `化学结构式 ${smiles}`,
style: "max-width:100%;height:auto",
};
const children = [svgNode];
if (label) {
children.push(h("figcaption", { className: ["mol-caption"] }, label));
}
return h(
"figure",
{
className: ["mol-figure", inline ? "mol-inline" : "mol-block"],
"data-mol-smiles": smiles,
},
children,
);
}
export function rehypeMol() {
return (tree) => {
let counter = 0;
const walk = (node) => {
if (!node || typeof node !== "object") return;
if (Array.isArray(node.children)) {
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (
child &&
child.type === "element" &&
(child.tagName === "mol" || child.tagName === "molecule")
) {
const props = child.properties || {};
const smiles = String(props.smiles ?? props.sm ?? "").trim();
if (!smiles) {
node.children[i] = h(
"span",
{ className: ["mol-error"] },
"mol: 缺少 smiles 属性",
);
continue;
}
const label = props.label ? String(props.label) : "";
const width = toNumber(props.width, DEFAULTS.width);
const height = toNumber(props.height, Math.round((width * DEFAULTS.height) / DEFAULTS.width));
const inline = String(props.inline ?? "") === "true";
try {
node.children[i] = buildFigure(
smiles,
label,
width,
height,
inline,
counter++,
);
} catch (error) {
node.children[i] = h(
"span",
{ className: ["mol-error"] },
`mol: ${smiles} 渲染失败(${error.message})`,
);
}
continue;
}
walk(child);
}
}
};
walk(tree);
};
}
export default rehypeMol;

在 astro.config.mjs 的如下位置注册 rehype-mol 插件:

// 开头引入插件
import { rehypeMol } from "./src/plugins/rehype-mol.mjs"; /* 注意文件路径 */
// ...
rehypePlugins: [
// 其他rehype插件
// `:::mol` / `::mol` 指令 -> 构建期结构式 SVG(必须在 rehypeComponents 之前)
rehypeMol,
[
rehypeComponents,
{
components: {
github: GithubCardComponent,
},
},
],
// 其他rehype插件...
]

化学结构式:行内写法与渲染细节#

上一节的插件把结构式当成块级元素,只能单独占一行。后来想在正文句子里直接写 :mol{},于是补了一个 remark 插件和三处 rehype-mol 改动。整体思路变成了「两段式」:

  1. remark 阶段扫文本节点,把 :mol{...} / ::mol{...} 替换成槽位元素(span.mol-inline-slot 或 div.mol-block-slot);
  2. rehype 阶段由 rehypeMol 找到槽位,填入构建期生成的 SVG;
  3. 行内版本额外把 SVG 的 viewBox 裁剪到实际绘制范围,否则缩到 1.6em 后分子几乎看不见;
  4. 容器写法(:::mol{} :::)的子节点会保留在图的后面,未闭合也不会吞掉后续正文。

这样做的好处是不再依赖 remark-directive 对文本指令的解析——实测它会把行内指令抛成块级节点,把段落切断。

语法对照#

写法出现位置渲染结果
:mol{smiles="c1ccccc1"}句子中间行内 SVG,高 1.6em,垂直居中
::mol{smiles="c1ccccc1"}句子中间同上(双冒号也按行内处理)
::mol{smiles="c1ccccc1" label="苯环"}独占一行块级图 + 图注
:::mol{...} … :::独占若干行块级图,子内容保留
三个反引号 + mol 的围栏独占若干行块级图(key: value 逐行书写)

行内示例:苯环 是芳香烃,乙醇 O H 可溶于水。

块级示例:

苯环(块级)

围栏示例:

异丁烷

新增插件:src/plugins/remark-mol-inline.js#

/**
* remark-mol-inline.js
*
* 把正文里的 `:mol{smiles="..." label="..."}`(单冒号、行内)和
* `::mol{smiles="..."}`(双冒号,写在段落里时同样按行内处理)
* 转成行内 HTML 槽位,交给 rehype-mol 在构建期填入结构式 SVG。
*
* 这样不必另起一行,指令可以写在句子中间:
* 苯环 :mol{smiles="c1ccccc1"} 是最简单的芳香烃。
*
* 代码块 / 行内代码 / 数学公式里的内容不会被处理。
*/
// 只匹配 `:mol{...}` / `::mol{...}`,不碰 `:::mol{...}`(容器指令交给 remark-directive)
const DIRECTIVE = /(?<!:):{1,2}mol\{([^{}]*)\}/g;
/** 解析 `smiles="x" label="y" width=200` 这类属性串 */
function parseAttrs(raw) {
const out = {};
const re = /([a-zA-Z-]+)\s*=\s*("([^"]*)"|'([^']*)'|([^\s,]+))/g;
let m;
for (;;) {
m = re.exec(raw);
if (!m) break;
out[m[1].toLowerCase()] = m[3] ?? m[4] ?? m[5] ?? "";
}
return out;
}
export function remarkMolInline() {
return (tree) => {
const walk = (node, parent) => {
if (!node || typeof node !== "object") return;
if (!Array.isArray(node.children)) return;
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
if (
child.type === "text" &&
typeof child.value === "string" &&
child.value.includes("mol{")
) {
const parts = [];
let last = 0;
DIRECTIVE.lastIndex = 0;
let m = DIRECTIVE.exec(child.value);
while (m) {
if (m.index > last) {
parts.push({
type: "text",
value: child.value.slice(last, m.index),
});
}
const attrs = parseAttrs(m[1]);
if (attrs.smiles) {
// 整行独占 -> 块级 figure;句中 -> 行内 span
const lineStart =
child.value.lastIndexOf("\n", m.index) + 1;
const lineEndIndex = child.value.indexOf("\n", m.index);
const lineEnd =
lineEndIndex === -1 ? child.value.length : lineEndIndex;
const before = child.value.slice(lineStart, m.index).trim();
const after = child.value
.slice(m.index + m[0].length, lineEnd)
.trim();
const isBlock = before === "" && after === "";
const properties = {
className: [isBlock ? "mol-block-slot" : "mol-inline-slot"],
"data-mol-smiles": attrs.smiles,
};
if (attrs.label) properties["data-mol-label"] = attrs.label;
if (attrs.width) properties["data-mol-width"] = attrs.width;
if (attrs.height) properties["data-mol-height"] = attrs.height;
if (attrs.inline) properties["data-mol-force-inline"] = attrs.inline;
parts.push(
isBlock
? {
// 块级:用 paragraph + hName=div,保证是块级容器
type: "paragraph",
children: [],
data: { hName: "div", hProperties: properties },
}
: {
type: "emphasis",
children: [],
data: { hName: "span", hProperties: properties },
},
);
} else {
parts.push({ type: "text", value: m[0] });
}
last = m.index + m[0].length;
m = DIRECTIVE.exec(child.value);
}
if (parts.length === 0) continue;
if (last < child.value.length) {
parts.push({ type: "text", value: child.value.slice(last) });
}
node.children.splice(i, 1, ...parts);
i += parts.length - 1;
continue;
}
walk(child, node);
}
};
walk(tree, null);
};
}
export default remarkMolInline;

在 astro.config.mjs 里注册(放在 remarkDirective 之前,先把文本吃掉,避免被当成块级指令):

import { remarkMolInline } from "./src/plugins/remark-mol-inline.js";
remarkPlugins: [
remarkMolInline,
// ...
],

rehype-mol.mjs 的三处改动#

一、行内 SVG 用 em 给尺寸,并按裁剪后的 viewBox 定宽高比

if (inline) {
// 行内:用 em 直接给尺寸(跟随行高),避免 flex/属性互相覆盖导致塌陷。
// 宽高比取裁剪后的 viewBox,保证不变形且填满行高。
const em = 1.6;
const vb = (svgNode.properties?.viewBox || "").split(/\s+/).map(Number);
const ratio =
vb.length === 4 && vb[2] > 0 && vb[3] > 0
? vb[2] / vb[3]
: width / Math.max(1, height);
svgNode.properties = {
...base,
className: ["mol-svg-inline"],
style: `height:${em}em;width:${(em * ratio).toFixed(2)}em;vertical-align:middle`,
};
return h(
"span",
{
className: ["mol-inline"],
"data-mol-smiles": smiles,
title: label || smiles,
},
svgNode,
);
}

二、tightenViewBox():把 openchemlib 的大片留白裁掉

/**
* 把 SVG 的 viewBox 收紧到实际绘制范围。
* openchemlib 会在画布四周留大量空白,行内缩放到 1.6em 时分子会变得极小。
*/
function tightenViewBox(svgString, pad = 4) {
let minX = Number.POSITIVE_INFINITY;
let minY = Number.POSITIVE_INFINITY;
let maxX = Number.NEGATIVE_INFINITY;
let maxY = Number.NEGATIVE_INFINITY;
const add = (x, y) => {
if (!Number.isFinite(x) || !Number.isFinite(y)) return;
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
};
// line / rect / circle / ellipse
for (const m of svgString.matchAll(/<(?:line|rect|circle|ellipse)\b([^>]*)>/g)) {
const attrs = m[1];
const num = (name) => {
const mm = attrs.match(new RegExp(`${name}="(-?[\\d.]+)"`));
return mm ? Number(mm[1]) : Number.NaN;
};
if (/^<line/.test(m[0])) {
add(num("x1"), num("y1"));
add(num("x2"), num("y2"));
} else if (/^<rect/.test(m[0])) {
const x = num("x");
const y = num("y");
add(x, y);
add(x + num("width"), y + num("height"));
} else {
const cx = num("cx");
const cy = num("cy");
const r = num("r");
add(cx - r, cy - r);
add(cx + r, cy + r);
}
}
// polygon / polyline points
for (const m of svgString.matchAll(/<(?:polygon|polyline)\b[^>]*points="([^"]+)"/g)) {
for (const pair of m[1].trim().split(/\s+/)) {
const [x, y] = pair.split(",").map(Number);
add(x, y);
}
}
// text 元素(原子标签)
for (const m of svgString.matchAll(/<text\b([^>]*)>/g)) {
const x = m[1].match(/x="(-?[\d.]+)"/);
const y = m[1].match(/y="(-?[\d.]+)"/);
if (x && y) add(Number(x[1]), Number(y[1]));
}
if (!Number.isFinite(minX) || !Number.isFinite(minY)) return svgString;
// text 有字高,纵向多留一点
const padY = pad + 6;
const x = minX - pad;
const y = minY - padY;
const w = Math.max(1, maxX - minX + pad * 2);
const h = Math.max(1, maxY - minY + padY * 2);
return svgString.replace(
/viewBox="[^"]*"/,
`viewBox="${x.toFixed(2)} ${y.toFixed(2)} ${w.toFixed(2)} ${h.toFixed(2)}"`,
);
}

调用位置在 buildFigure() 开头,仅对行内版本生效:

function buildFigure(smiles, label, width, height, inline, index) {
let svgRaw = renderMolecule(smiles, width, height).replaceAll(
"__MOL_ID__",
`mol-${index}`,
);
// 行内版本裁剪空白,否则缩到 1.6em 后分子几乎看不见
if (inline) svgRaw = tightenViewBox(svgRaw);
// ...

三、识别槽位 + 保留容器子节点

const classList = Array.isArray(child.properties?.className)
? child.properties.className
: [];
const isInlineSlot = classList.includes("mol-inline-slot");
const isBlockSlot = classList.includes("mol-block-slot");
if (
child.tagName === "mol" ||
child.tagName === "molecule" ||
isInlineSlot ||
isBlockSlot
) {
const props = child.properties || {};
const smiles = String(
props.smiles ?? props.sm ?? props["data-mol-smiles"] ?? "",
).trim();
// ...
// 显式 inline 属性优先;否则行内槽位必然行内,段落/标题里也自动行内
const forcedRaw = props.inline ?? props["data-mol-force-inline"];
const forced =
forcedRaw === undefined
? undefined
: String(forcedRaw).toLowerCase() === "true";
const autoInline =
(isInlineSlot && !isBlockSlot) ||
INLINE_PARENTS.has(parentTag || "");

其中 INLINE_PARENTS 是 p / span / a / em / strong / b / i / code / sup / sub / td / th / h1~h6,这些父容器里放不下块级 <figure>,所以自动降级为行内。

容器写法不再吞内容:

const replaceWith = (parent, index, figure, child) => {
const kept = Array.isArray(child.children) && child.children.length > 0
? child.children
: [];
if (kept.length > 0) {
parent.children.splice(index, 1, figure, ...kept);
} else {
parent.children[index] = figure;
}
};

样式:src/styles/molecule.css#

/* 行内写法 :mol{smiles="..."}:尺寸由插件以内联 em 值给出,这里只负责对齐 */
.mol-inline {
display: inline-block;
vertical-align: middle;
margin: 0 0.1em;
color: var(--molecule-color, currentColor);
}
.mol-inline > svg.mol-svg-inline {
color: var(--molecule-color, currentColor);
}

块级部分(.mol-figure / .mol-caption / .mol-error)与上一节一致,不重复贴。

几个踩过的坑#

  • 插件注册别加括号。rehypePlugins: [rehypeMol]、remarkPlugins: [remarkMolInline] 都要传工厂函数本身。写成 rehypeMol() 时 unified 会把返回值当 attacher,收到的是 processor 而不是 tree,插件静默失效(当时表现为页面里留着未处理的 <mol> 标签)。
  • 行内不能产出 <figure>。<p> 里塞块级元素会被序列化器拆开,段落被切断。所以行内必须是 span,块级才用 figure(或 paragraph + hName: "div")。
  • openchemlib 的 viewBox 留白很大。260×180 的画布里分子实际只占中间一小块,直接缩到 1.6em 就只有几个像素,必须裁剪。
  • 异步不适用于 rehype/remark 插件。unified 的 transformer 必须同步返回,所以 openchemlib 要在插件模块顶部同步 import,不能 await import()。
  • 改了插件要重启 dev。src/plugins/* 被 astro.config.mjs 直接 import,改动会让 dev 服务器重启;重启后浏览器报 transport was disconnected 时刷新一次即可。
  • chemfig 不能自动转 SMILES。两者语法没有对应关系,需要手工(或借助工具)把目标结构写成 SMILES;反应箭头、条件排版这类需求继续用 \ce{}(mhchem)。
  • 非法 SMILES 不会中断构建,会在原位渲染一个红色 span.mol-error,构建日志里可以看到具体解析错误。

渲染缓存落在 node_modules/.cache/firefly-mol/(按 SMILES+尺寸 的 SHA1 命名),删除该目录即可强制重新渲染。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Firefly 魔改指南
https://justpureh2o.cn/articles/20018/
作者
JustPureH2O
发布于
2026-07-07
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
JustPureH2O
穷方圆平直之情,尽规矩准绳之用
公告
分类
标签
站点统计
文章
108
分类
18
标签
64
总字数
420,129
运行时长
0 天
最后活动
0 天前
站点信息
构建平台
Vercel
博客版本
Firefly vunknown
文章许可
CC BY-NC-SA 4.0
文章目录