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.tsdecennialTitle = "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 文件中的对应字段。
本站十年之约页面见此
友链朋友圈
本站的友链朋友圈实现方案可能涉及部署方式的改变(例如 GitHub Pages -> Vercel),请慎重选择!
先下载 node-cache 包,在项目根目录运行:
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,那么在根目录执行包安装:
pnpm add @astrojs/vercel按照如下方式编辑 astro.config.mjs 文件:
// 开头引入包import vercel from "@astrojs/vercel";
// ……
// https://astro.build/configexport 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";
// 页面开关,关闭时自动返回 404if (!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="苯环"}
卟啉核心: ::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 改动。整体思路变成了「两段式」:
- remark 阶段扫文本节点,把
:mol{...}/::mol{...}替换成槽位元素(span.mol-inline-slot或div.mol-block-slot); - rehype 阶段由
rehypeMol找到槽位,填入构建期生成的 SVG; - 行内版本额外把 SVG 的
viewBox裁剪到实际绘制范围,否则缩到 1.6em 后分子几乎看不见; - 容器写法(
:::mol{} :::)的子节点会保留在图的后面,未闭合也不会吞掉后续正文。
这样做的好处是不再依赖 remark-directive 对文本指令的解析——实测它会把行内指令抛成块级节点,把段落切断。
语法对照
| 写法 | 出现位置 | 渲染结果 |
|---|---|---|
:mol{smiles="c1ccccc1"} | 句子中间 | 行内 SVG,高 1.6em,垂直居中 |
::mol{smiles="c1ccccc1"} | 句子中间 | 同上(双冒号也按行内处理) |
::mol{smiles="c1ccccc1" label="苯环"} | 独占一行 | 块级图 + 图注 |
:::mol{...} … ::: | 独占若干行 | 块级图,子内容保留 |
三个反引号 + mol 的围栏 | 独占若干行 | 块级图(key: value 逐行书写) |
行内示例:苯环 是芳香烃,乙醇 可溶于水。
块级示例:
围栏示例:
新增插件: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 命名),删除该目录即可强制重新渲染。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!


































































































































































