1350 字
107 分钟
Shirone Markdown 扩展
Shirone 提供了一组主题专属的 Markdown 扩展与自定义语法容器。它们构建在我们原生 unified AST 处理管线之上。
想了解这套管线为什么「零注水」,可以看 Astro 的岛架构说明。
Shirone 提供了一组主题专属的 Markdown 扩展与自定义语法容器。它们构建在我们原生 unified AST 处理管线之上,所有扩展都在站点构建期渲染为无障碍且语义化的 HTML,不产生任何客户端 JavaScript 注水开销,并完全对齐 M3E 设计令牌。
文件树#
文件树把多层级项目结构、源码层级与终端目录输出变成紧凑、可交互的树状视图,支持自动匹配扩展名图标、差异高亮以及可折叠分支。
1. 嵌套列表语法(:::file-tree)#
当你想直接用 Markdown 嵌套列表书写文件层级时,使用 :::file-tree 块级指令。
1:::file-tree{title="Shirone source tree"}2- src3 - components/4 - ++ Navigation.svelte # added component5 - -- Button.astro # removed component6 - content7 - posts/8 - markdown-enhancements.md9 - layouts/10 - PostLayout.astro11 - plugins12 - markdown/13 - rehype-file-tree.mjs14 - styles15 - markdown/16 - trees.css17 - **content.config.ts** # important file18- public/19 - favicon.svg20- package.json21:::Shirone source tree
src
components
- +Navigation.svelteadded component
- -Button.astroremoved component
content
posts
- markdown-enhancements.md
layouts
- PostLayout.astro
plugins
markdown
- rehype-file-tree.mjs
styles
markdown
- trees.css
- content.config.tsimportant file
public
- favicon.svg
- package.json
写作规则与标记#
- 差异状态:给条目前加
++(绿色背景与徽标)或--(红色背景与删除线)即可高亮变更。 - 注释:
#之后的文本会渲染为淡色、右对齐的行内注释。 - 强调:用
**粗体**包裹名称,让关键文件获得更醒目的视觉权重。 - 可折叠目录:由嵌套列表项推断出的目录默认展开。加上结尾斜杠(如
components/)可创建默认折叠的目录,读者点击或用键盘导航即可展开。
2. 终端输出语法(```file-tree)#
当你已经有 tree 这类命令行工具生成的目录树文本时,直接把它粘贴进 file-tree 围栏代码块即可。Unicode 分支字符(├──、└──、│)与 ASCII 分支都会被自动解析。
1```file-tree title="Build output" icon="simple"2dist3├── _astro/4│ ├── index.css5│ └── page.js6└── favicon.ico7```Build output
dist
_astro
- index.css
- page.js
- favicon.ico
配置项#
title="string":为文件树设置自定义头部标题与无障碍标签。icon="colored" | "simple":在多彩扩展名图标(colored,默认)与极简单色图标(simple)之间切换。
代码树#
可交互的代码树把左侧的多层级文件导航面板与右侧即时切换的代码面板配对。它能为多文件示例、模块或整目录走读提供类似 IDE 的阅读体验。
1. 容器语法(:::code-tree)#
在 :::code-tree 块级指令中组合多个围栏代码块。每个代码块通过 title="path/to/file" 指定它的路径。
1:::code-tree{title="Shirone Component Demo" height="380px" entry="src/Button.svelte"}2```svelte title="src/Button.svelte"3<script lang="ts">4 let { label = "Click me" } = $props();5</script>6
7<button class="m3-btn">{label}</button>8```9
10```stylus title="src/styles/button.styl"11.m3-btn12 background: var(--primary)13 color: var(--on-primary)14 border-radius: var(--shape-corner-m)15```16
17```json title="package.json"18{19 "name": "button-demo",20 "version": "1.0.0"21}22```23:::Shirone Component Demo
1<script lang="ts">2 let { label = "Click me" } = $props();3</script>4
5<button class="m3-btn">{label}</button>1.m3-btn2 background: var(--primary)3 color: var(--on-primary)4 border-radius: var(--shape-corner-m)1{2 "name": "button-demo",3 "version": "1.0.0"4}配置与标记#
title="string":为代码树设置头部标题与无障碍标签。height="string":设置桌面视图的高度(默认420px,例如380px、26rem)。entry="filepath":指定首次加载时处于激活状态的文件。icon="colored" | "simple":在彩色与极简单色文件图标之间切换。:active:在任意围栏代码块上放置:active,即可将其指定为默认激活标签页。
2. 本地目录自动导入(@[code-tree])#
直接指向工作区中的任意本地目录路径,即可在构建期自动扫描并生成可交互的代码树,无需手动复制文件内容。
1@[code-tree title="Anime Utilities" entry="status.ts"](/src/utils/anime)Site Configuration
1import type { AboutConfig } from "../types/aboutConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const aboutConfig: AboutConfig = withUserConfig("about", {5 enable: true,6 title: "$t:about",7 description: "$t:about",8});1import type { AlbumsConfig } from "../types/albumsConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const albumsConfig: AlbumsConfig = withUserConfig("albums", {5 enable: true,6 title: "$t:albums",7 description: "$t:albumsBanner",8});1import type {2 AnimeConfig,3 AnimeFallbackKind,4 AnimeProvider,5 AnimeSourceKind,6 ResolvedAnimeOptions,7} from "../types/animeConfig.ts";8import { withUserConfig } from "../utils/config-overlay.ts";9
10/**11 * ─────────────────────────────────────────────────────────────────────────────12 * Shirone 番剧页面与外部数据源配置13 * ─────────────────────────────────────────────────────────────────────────────14 *15 * 遵循「零额外负担」原则与双平面模型(`docs/remote-data-system.md`):16 * - 本地模式 (local):完全离线,直接使用 `src/data/anime.ts`,零网络、零构建脚本负担;17 * - 快照模式 (snapshot):读取构建期抓取清洗后的本地脱敏 JSON 快照(`src/data/anime-snapshots/`);18 * - 外部同步完全发生在显式 `pnpm anime:sync` 阶段,严禁页面运行时或默认构建时直接请求外部 API;19 * - 私密凭据(如 B站 SESSDATA)仅通过环境变量注入同步进程,绝不进入客户端代码与 Git 提交。20 *21 * ─────────────────────────────────────────────────────────────────────────────22 * 【常用配置场景】23 * ─────────────────────────────────────────────────────────────────────────────24 * 场景 A:使用本地手写数据(默认,最稳定安全)25 * ```ts26 * source: { kind: "local" }27 * ```28 *29 * 场景 B:使用 Bangumi 追番快照30 * 1. 填入你的 Bangumi 用户 ID,将 `providers.bangumi.enable` 置为 `true`;31 * 2. 将 `source` 设置为 `{ kind: "snapshot", provider: "bangumi" }`;32 * 3. 终端执行 `pnpm.cmd anime:sync --provider bangumi` 生成快照。33 *34 * 场景 C:使用 Bilibili 追番快照35 * 1. 填入你的 B 站 UID (`vmid`),将 `providers.bilibili.enable` 置为 `true`;36 * 2. 若追番列表设为私密,在 `.env` 中配置 `BILI_SESSDATA="your_sessdata"`;37 * 3. 将 `source` 设置为 `{ kind: "snapshot", provider: "bilibili" }`;38 * 4. 终端执行 `pnpm.cmd anime:sync --provider bilibili` 生成快照。39 * ─────────────────────────────────────────────────────────────────────────────40 */41export const animeConfig: AnimeConfig = withUserConfig("anime", {42 /** 是否启用番剧页;false 时导航入口同步隐藏,访问 /anime/ 跳转 404 */43 enable: true,44 title: "$t:anime",45 description: "$t:animeBanner",46
47/**48 * 主数据源选择。49 *50 * 【2026-10-11 改为 database —— 番剧在线化】站主在后台管理番剧后,51 * 构建期直读 MySQL 的 anime 表(src/utils/anime-data.ts 的 database 分支)。52 * 数据库不可用时自动回退 local(src/data/anime.ts 保留为兜底数据,不删)。53 * 想改回纯静态:把 kind 改回 local 即可。54 */55 source: {56 kind: "database",57 // file: "bangumi.json",58 // fetchOnDev: true,59 },60
61 /** 异常降级策略(快照丢失或解析失败时回退本地数据) */62 fallback: {63 kind: "local",64 },65
66 /** 外部提供方配置 */67 providers: {68 bangumi: {69 enable: false,70 userId: "", // 填入你的 Bangumi 数字 UID 或公开用户名(测试可填 "sai")71 request: {72 pageSize: 50,73 maxItems: 300,74 minDelayMs: 200,75 },76 },77 bilibili: {78 enable: false,79 vmid: "", // 填入你的 B 站公开 UID80 sessdataEnv: "BILI_SESSDATA",81 cover: {82 mode: "local", // "local" 站内下载缓存(推荐)| "remote" 远程链接 | "none"83 useWebp: true,84 },85 request: {86 pageSize: 30,87 maxItems: 300,88 minDelayMs: 300,89 },90 },91 },92
93 /** 快照存储管理 */94 snapshot: {95 directory: "src/data/anime-snapshots",96 staleAfterDays: 30,97 keepLastValid: true,98 },99});100
101const SAFE_FILENAME_PATTERN = /^[a-zA-Z0-9_-]+\.json$/;102
103/**104 * 校验并解析 Anime 配置,返回只读的标准选项105 */106export function resolveAnimeOptions(config: AnimeConfig): ResolvedAnimeOptions {107 const enable = Boolean(config.enable);108 const fallback: AnimeFallbackKind =109 config.fallback?.kind === "empty" ? "empty" : "local";110
111 const directory =112 typeof config.snapshot?.directory === "string" &&113 config.snapshot.directory.trim() &&114 !config.snapshot.directory.includes("..")115 ? config.snapshot.directory.trim().replace(/[\\/]+$/, "")116 : "src/data/anime-snapshots";117
118 const staleAfterDays =119 typeof config.snapshot?.staleAfterDays === "number" &&120 Number.isFinite(config.snapshot.staleAfterDays) &&121 config.snapshot.staleAfterDays > 0122 ? Math.floor(config.snapshot.staleAfterDays)123 : 30;124
125 const keepLastValid = config.snapshot?.keepLastValid ?? true;126
127 const rawKind = config.source?.kind;128 let kind: AnimeSourceKind = "local";129 let provider: AnimeProvider | undefined;130 let file: string | undefined;131
132 /**133 * database 源(2026-10-11 在线化):显式声明 kind: "database" 时启用,134 * 构建期直读 MySQL 的 anime 表(见 utils/anime-data.ts 的 database 分支)。135 * 任何其它配置错误都不影响这里 —— database 不依赖 provider/file。136 */137 if (rawKind === "database") {138 kind = "database";139 }140
141 if (rawKind === "snapshot") {142 const rawProvider = config.source?.provider;143 if (rawProvider === "bangumi" || rawProvider === "bilibili") {144 provider = rawProvider;145 }146
147 const rawFile = config.source?.file?.trim();148 if (149 rawFile &&150 SAFE_FILENAME_PATTERN.test(rawFile) &&151 // 若指定了 provider 但 file 误填了另一 provider 的 json,自动校正为对应 provider 的 json 文件152 !(provider === "bilibili" && rawFile === "bangumi.json") &&153 !(provider === "bangumi" && rawFile === "bilibili.json")154 ) {155 file = rawFile;156 } else if (provider) {157 file = `${provider}.json`;158 }159
160 const fetchOnDev = config.source?.fetchOnDev ?? true;161
162 if (file) {163 kind = "snapshot";164 }165
166 return Object.freeze({167 enable,168 source: Object.freeze({169 kind,170 ...(provider ? { provider } : {}),171 ...(file ? { file } : {}),172 fetchOnDev,173 }),174 fallback,175 snapshot: Object.freeze({176 directory,177 staleAfterDays,178 keepLastValid,179 }),180 });181 }182
183 return Object.freeze({184 enable,185 source: Object.freeze({186 kind,187 ...(provider ? { provider } : {}),188 ...(file ? { file } : {}),189 fetchOnDev: config.source?.fetchOnDev ?? true,190 }),191 fallback,192 snapshot: Object.freeze({193 directory,194 staleAfterDays,195 keepLastValid,196 }),197 });198}199
200export const resolvedAnimeOptions: ResolvedAnimeOptions =201 resolveAnimeOptions(animeConfig);1import type { AnnouncementConfig } from "@/types/announcementConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 公告栏配置6 * 组件显示由 sidebarConfig 统一控制7 */8export const announcementConfig: AnnouncementConfig = withUserConfig(9 "announcement",10 {11 title: "", // 公告标题,填空使用 i18n 字符串 Key.announcement12 content: "收好每一张有趣的图 —— 新投稿持续归档中", // 公告内容13 closable: true, // 允许用户关闭公告14 link: {15 enable: true, // 启用链接16 text: "GitHub", // 链接文本17 url: "https://github.com", // 链接 URL18 external: true, // 外部链接19 },20 },21);1import type { ArticleConfig } from "@/types/articleConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 文章详情页配置。6 */7export const articleConfig: ArticleConfig = withUserConfig("article", {8 lastUpdated: {9 // 关闭后不渲染最后更新提示。10 enable: true,11 // 按 UTC 日历日计算;达到该天数当天开始显示,0 表示立即显示。12 minimumAgeDays: 90,13 },14 discovery: {15 // 总开关关闭后不计算、不渲染文章尾部的延伸阅读区域。16 enable: true,17 related: {18 // 只展示至少共享一个标签或分类的文章。19 enable: true,20 count: 3,21 },22 random: {23 // 按当前文章标识稳定抽样;同一构建中的结果不会随刷新变化。24 enable: true,25 count: 2,26 },27 },28 share: {29 // 关闭后不渲染文章尾部的分享区块,不引入客户端水合。30 enable: true,31 // 生成海报时是否默认包含文章封面(封面不可用时自动降级为无封面排版)。32 includeCover: true,33 },34});35
36const MAX_DISCOVERY_COUNT = 6;37
38export interface ArticleDiscoveryOptions {39 relatedCount: number;40 randomCount: number;41}42
43export interface ArticleShareOptions {44 includeCover: boolean;45}46
47export function normalizeDiscoveryCount(value: number): number {48 return Number.isFinite(value)49 ? Math.min(MAX_DISCOVERY_COUNT, Math.max(0, Math.floor(value)))50 : 0;51}52
53export function resolveArticleDiscoveryOptions(54 config: Pick<ArticleConfig, "discovery">,55): ArticleDiscoveryOptions | null {56 if (!config.discovery.enable) return null;57
58 const relatedCount = config.discovery.related.enable59 ? normalizeDiscoveryCount(config.discovery.related.count)60 : 0;61 const randomCount = config.discovery.random.enable62 ? normalizeDiscoveryCount(config.discovery.random.count)63 : 0;64
65 return relatedCount > 0 || randomCount > 066 ? { relatedCount, randomCount }67 : null;68}69
70export function resolveArticleShareOptions(71 config: Pick<ArticleConfig, "share">,72): ArticleShareOptions | null {73 if (!config.share.enable) return null;74 return { includeCover: config.share.includeCover };75}76
77export function resolveLastUpdatedNoticeOptions(78 config: Pick<ArticleConfig, "lastUpdated">,79): ArticleConfig["lastUpdated"] | null {80 return config.lastUpdated.enable ? config.lastUpdated : null;81}1import { withUserConfig } from "@utils/config-overlay";2
3/**4 * 返回导航配置(阶段 7 收尾,2026-10-10)。5 *6 * 【为什么加它 —— 站主原话】7 * 「我问一下在动态里面,我进入一篇详细动态,我如何返回上一个页面呢,8 * 图一我蓝色框柱的地方缺少返回键,**同理其他地方也是**」9 *10 * 实测场景:从动态页点卡片上的作者名 → 进入关于页 → **没有任何返回入口,卡住了**。11 * 全站审计(11 页)发现 8 个页面零返回:关于页 / 文章详情 / 我的 / 动态 / 归档 /12 * 友链 / 项目 / 时间线。此前只有技能详情、知识详情、相册详情各自零散地放了返回,13 * 样式与位置都不统一。14 *15 * 【行为契约(本组件的核心)】16 * · **从站内点进来**(如动态卡 → 关于页)→ 回到**来处**(动态页)—— 这是站主要的;17 * · **直接输网址 / 从站外进来** → 回落到该页面的**固定上级**(见下),18 * 避免「按了没反应」这种最差体验;19 * · 判断依据是 document.referrer 的**同源**性 —— 跨站 referrer 一律视为「外来」。20 *21 * 【零额外负担】enable: false 时不渲染任何 DOM、不注册任何监听。22 */23export interface BackNavOptions {24 /** 总开关。false = 全站不渲染返回键(零 DOM、零监听) */25 enable: boolean;26 /** 是否在长内容底部再放一个(站主 2026-10-10 确认要) */27 showAtBottom: boolean;28 /**29 * 逐页排除。键为页面的 data-current-page 值(与 sidebarConfig 的 pageKey 同一套口径)。30 * 例如某页你觉得多余,加进来即可 —— 不需要改组件。31 */32 exclude: string[];33}34
35export const backNavConfig: BackNavOptions = withUserConfig("backNav", {36 enable: true,37 showAtBottom: true,38 exclude: [],39});40
41/**42 * 各页面的「固定上级」——仅在**无法判断来处**时使用(直接输网址、站外进入)。43 *44 * 为什么需要它:只做 history.back() 的话,直接访问的页面点了没反应(历史上一条可能是45 * 站外页或空),这是比「没有按钮」更糟的体验。有了回落目标,按钮**永远有去处**。46 *47 * 口径:48 * · 详情类 → 它的列表页(文章→归档、相册详情→相册列表、技能详情→技能列表、知识详情→知识索引)49 * · 独立页 → 首页(关于、我的、友链、项目、时间线等都是「从某处点进来」的独立页)50 */51export const backNavFallback: Record<string, string> = {52 post: "/archive/",53 "album-detail": "/albums/",54 "skill-detail": "/skills/",55 "knowledge-detail": "/knowledge/",56 about: "/",57 me: "/",58 friends: "/",59 projects: "/",60 timeline: "/",61 moments: "/",62 archive: "/",63 albums: "/",64 skills: "/",65 knowledge: "/",66 guestbook: "/",67 graph: "/",68 anime: "/",69 compass: "/",70 devices: "/",71 games: "/",72};1import type {2 CommentConfig,3 GiscusConfig,4 LocalCommentConfig,5 TwikooConfig,6} from "@/types/commentConfig";7import { withUserConfig } from "../utils/config-overlay.ts";8
9/**10 * 评论系统配置单一真源。11 *12 * 遵循「零额外负担」原则:默认全局关闭(enable: false),13 * 在未开启时不产生任何外部网络请求、零额外 DOM 占位与零包体积膨胀。14 *15 * 【开启 Twikoo 评论配置步骤】16 * 1. 部署 Twikoo 服务端并获取环境 ID(腾讯云 CloudBase / Vercel / Railway / 私有部署等);17 * 2. 将 `enable` 置为 `true`,并将 `provider` 设置为 `"twikoo"`;18 * 3. 填入你的 `twikoo.envId`;19 * 4. (可选)自定义 `scriptUrl`(如使用自建 CDN 或官方 unpkg/jsdelivr 源)。20 *21 * 【开启 Giscus 评论配置步骤】22 * 1. 准备一个公开 GitHub 仓库,并在仓库设置中开启 Discussions 功能;23 * 2. 安装 giscus App(https://github.com/apps/giscus)到该仓库;24 * 3. 在 https://giscus.app 按引导选择仓库与 Discussion 分类,25 * 取生成的 `data-repo-id` 与 `data-category-id`;26 * 4. 将 `enable` 置为 `true`,并将 `provider` 设置为 `"giscus"`,27 * 填入 `giscus.repo` / `giscus.repoId` / `giscus.categoryId` 三个必填字段;28 * 5. (可选)调整 `mapping`、`reactionsEnabled`、`inputPosition`、29 * `theme.light` / `theme.dark`(giscus 主题键或自定义主题 CSS URL)、30 * `lang` 与 `scriptUrl`(自托管 giscus 时替换)。31 *32 * 【本站自建评论(local)】33 * 评论落在自己的 MySQL 里,前端直接调 Spring Boot 的 /api/v1 接口,34 * 不加载任何第三方脚本。这是本站当前采用的方案。35 * - `allowGuest`:游客可留言(需图形验证码);**本站已关闭**(2026-10-29 站主36 * 「隐藏掉游客评论的功能,改成需要登录才能评论」),关掉后未登录访客只看到37 * 「登录后即可发表评论」与去登录入口,不加载验证码、不渲染昵称/邮箱输入框;38 * - `needCaptcha`:是否要求验证码(登录用户始终免验证码);39 * - `needAudit`:新留言先待审,后台通过后才在前台显示;40 * - `maxLength`:单条上限,必须与后端校验保持一致。41 */42export const commentConfig: CommentConfig = withUserConfig("comment", {43 /** 全局评论总开关:false 时完全不加载评论脚本与 DOM */44 enable: true,45 /** 评论提供商类型:"none" | "twikoo" | "giscus" | "local" */46 provider: "local",47 /** 是否开启视口懒加载:滚动进入视口才动态加载评论组件(推荐 true) */48 lazy: true,49 /** Twikoo 专有配置 */50 twikoo: {51 /** Twikoo 环境 ID(如 "https://your-twikoo.vercel.app" 或腾讯云环境 ID) */52 envId: "",53 /** Twikoo 前端 JS 脚本 CDN 地址 */54 scriptUrl: "https://cdn.jsdelivr.net/npm/twikoo@1.7.20/dist/twikoo.min.js",55 /** 评论语言:"auto"(跟随站点)| "zh-CN" | "zh-TW" | "en" | "ja" 等 */56 lang: "auto",57 /** 评论输入框占位提示文本 */58 placeholder: "Share your thoughts...",59 },60 /** Giscus 专有配置(基于 GitHub Discussions,评论数据存储在公开仓库中) */61 giscus: {62 /** 公开仓库,格式 "owner/repo"(必填) */63 repo: "",64 /** 仓库 ID,从 giscus.app 配置生成器获取(必填) */65 repoId: "",66 /** Discussion 分类名,如 "Announcements";留空表示不限制分类搜索范围 */67 category: "Announcements",68 /** 分类 ID,从 giscus.app 配置生成器获取(必填) */69 categoryId: "",70 /** 页面 ↔ Discussion 映射:pathname(默认)/ url / title / og:title / specific / number */71 mapping: "pathname",72 /** 严格标题匹配(SHA-1 校验),避免 GitHub 模糊搜索误配相似标题 */73 strict: false,74 /** 是否显示主贴表情反应 */75 reactionsEnabled: true,76 /** 是否向父页面周期性发送 Discussion 元数据(供脚本消费) */77 emitMetadata: false,78 /** 评论输入框位置:bottom(默认,评论框在列表下方)| top(评论框在列表上方) */79 inputPosition: "bottom",80 /** 明暗两套 giscus 主题(giscus 主题键或自定义主题 CSS URL),跟随站点明暗切换 */81 theme: { light: "light", dark: "dark" },82 /** 评论语言:"auto"(跟随站点)| giscus 语言码(如 "zh-CN"、"en") */83 lang: "auto",84 /** giscus client.js 地址;自托管 giscus 时替换为自有地址 */85 scriptUrl: "https://giscus.app/client.js",86 },87 /** 本站自建评论(provider: "local")专属配置 */88 local: {89 /** 接口基址;留空表示沿用 api-client 的 API_BASE */90 apiBase: "",91 /**92 * 允许游客留言(需图形验证码)。93 * 【★ 2026-10-29 站主拍板:关闭游客评论,改为需要登录才能评论】94 * 置 false 后:①不渲染昵称/邮箱与验证码 ②不加载验证码接口95 * ③列表里不显示「回复」按钮 ④只给未登录访客一句「登录后即可发表评论」+ 去登录。96 * 后端必须同步:shirone-backend 的 bluefish.comment.allow-guest=false。97 */98 allowGuest: false,99 /** 要求图形验证码(登录用户始终免验证码) */100 needCaptcha: true,101 /** 新留言先待审,管理员通过后前台可见 */102 needAudit: true,103 /** 单条评论最大字符数(与后端 4003 校验一致) */104 maxLength: 1000,105 },106});107
108export type ResolvedCommentOptions =109 | {110 provider: "twikoo";111 lazy: boolean;112 twikoo: TwikooConfig;113 }114 | {115 provider: "giscus";116 lazy: boolean;117 giscus: GiscusConfig;118 }119 | {120 provider: "local";121 lazy: boolean;122 local: LocalCommentConfig;123 }124 | null;125
126/** 本站自建评论的兜底默认值(配置缺项时归一化用) */127const LOCAL_COMMENT_DEFAULTS: LocalCommentConfig = {128 apiBase: "",129 allowGuest: false,130 needCaptcha: true,131 needAudit: true,132 maxLength: 1000,133};134
135/**136 * 解析并校验评论配置。未启用、提供商为 none 或关键参数缺失时返回 null。137 * 校验收敛在此纯函数中,消费组件只消费解析结果并短路。138 */139export function resolveCommentOptions(140 config: CommentConfig,141): ResolvedCommentOptions {142 if (!config.enable || config.provider === "none") {143 return null;144 }145 if (config.provider === "twikoo") {146 const envId = config.twikoo.envId?.trim();147 const scriptUrl = config.twikoo.scriptUrl?.trim();148 if (!envId || !scriptUrl) {149 return null;150 }151 return {152 provider: "twikoo",153 lazy: config.lazy,154 twikoo: {155 ...config.twikoo,156 envId,157 scriptUrl,158 },159 };160 }161 if (config.provider === "giscus") {162 const repo = config.giscus.repo?.trim();163 const repoId = config.giscus.repoId?.trim();164 const categoryId = config.giscus.categoryId?.trim();165 if (!repo || !repoId || !categoryId) {166 return null;167 }168 return {169 provider: "giscus",170 lazy: config.lazy,171 giscus: {172 ...config.giscus,173 repo,174 repoId,175 categoryId,176 category: config.giscus.category?.trim() ?? "",177 theme: {178 light: config.giscus.theme?.light?.trim() || "light",179 dark: config.giscus.theme?.dark?.trim() || "dark",180 },181 scriptUrl: config.giscus.scriptUrl?.trim(),182 },183 };184 }185 if (config.provider === "local") {186 // 自建评论没有第三方凭据,配置天然可用;这里只做数值归一化,187 // 保证 maxLength 落在合理区间、布尔项缺省时取安全默认。188 const raw = config.local ?? LOCAL_COMMENT_DEFAULTS;189 const maxLength = Number.isFinite(raw.maxLength)190 ? Math.min(Math.max(Math.trunc(raw.maxLength), 1), 10000)191 : LOCAL_COMMENT_DEFAULTS.maxLength;192 return {193 provider: "local",194 lazy: config.lazy,195 local: {196 apiBase: raw.apiBase?.trim() ?? "",197 allowGuest: raw.allowGuest ?? LOCAL_COMMENT_DEFAULTS.allowGuest,198 needCaptcha: raw.needCaptcha ?? LOCAL_COMMENT_DEFAULTS.needCaptcha,199 needAudit: raw.needAudit ?? LOCAL_COMMENT_DEFAULTS.needAudit,200 maxLength,201 },202 };203 }204 return null;205}1import type { CompassConfig } from "../types/compassConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const compassConfig: CompassConfig = withUserConfig("compass", {5 enable: true,6 title: "$t:compass",7 description: "$t:compassBanner",8});1import type { ContextMenuConfig } from "@/types/contextMenuConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/** Optional desktop context-menu enhancement. */5export const contextMenuConfig: ContextMenuConfig = withUserConfig(6 "contextMenu",7 {8 enable: true,9 actions: ["copySelection", "backToTop", "sharePageLink"],10 },11);1import type { DevicesConfig } from "@/types/devicesConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 设备展示页行为与展示配置。6 *7 * 遵循「配置管行为,数据管内容」原则:8 * - enable:页面总开关;false 时导航入口同步隐藏,访问 /devices/ 跳转 404;9 * - categories:场景分类清单(数组顺序即页面顶部 Chips 顺序);10 * - disabledIds:可选被禁用的设备 ID 列表;11 *12 * 注:设备的具体清单数据(设备名、品牌、规格、感受说明、图片等)请在 `src/data/devices.ts` 中维护。13 */14export const devicesConfig: DevicesConfig = withUserConfig("devices", {15 enable: true,16 title: "$t:devices",17 description: "$t:devicesBanner",18 categories: [19 {20 key: "desk",21 label: "Desk Setup",22 icon: "material-symbols:desktop-windows-outline-rounded",23 description: "Workstation & home office hardware",24 },25 {26 key: "mobile",27 label: "Mobile & EDC",28 icon: "material-symbols:phone-iphone",29 description: "Daily portable devices & smart gadgets",30 },31 {32 key: "audio",33 label: "Audio & Visual",34 icon: "material-symbols:headphones-rounded",35 description: "Headphones, speakers & monitoring gears",36 },37 {38 key: "peripheral",39 label: "Peripherals",40 icon: "material-symbols:keyboard-outline-rounded",41 description: "Keyboards, mice & desk accessories",42 },43 ],44 // disabledIds: [],45});1import type { ExpressiveCodeConfig } from "@/types/config";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * Expressive Code 代码块主题(astro.config.mjs 与 setting-utils 消费)。6 * 类型见 src/types/config.ts。7 */8export const expressiveCodeConfig: ExpressiveCodeConfig = withUserConfig(9 "expressiveCode",10 {11 // Note: Some styles (such as background color) are being overridden, see the astro.config.mjs file.12 // 代码块跟随明暗模式切换深浅主题13 theme: "github-dark",14 lightTheme: "github-light",15 darkTheme: "github-dark",16 },17);1import type { FabConfig } from "@/types/fabConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 右下角悬浮控制流(FAB)导航配置。6 *7 * 【核心配置项】8 * - enable:是否开启悬浮操作栏;9 * - align:"start"(靠左)| "end"(靠右,默认);10 * - size:"small" | "regular"(默认)| "large";11 * - offset:右下角边距(支持 CSS 变量或具体像素);12 * - items:操作按钮清单(按数组顺序渲染):13 * - type: "top" —— 平滑返回顶部按钮(滚过横幅后自动浮现);14 * - type: "toc" —— 悬浮文章目录面板(桌面端已有侧栏粘性 TOC,默认仅在 mobile/tablet 显示);15 * - type: "comment" —— 直达评论区按钮(评论系统关闭或文章关闭评论时零 DOM 产物);16 * - type: "home" —— 返回首页按钮(onlySubPages: true 表示仅在非首页展示);17 * - devices:受控设备矩阵("mobile" | "tablet" | "desktop"),省略表示全设备生效;18 * - pages:页面范围过滤(如 ["post"])。19 *20 * 架构规范见 docs/fab-system.md。21 */22export const fabConfig: FabConfig = withUserConfig("fab", {23 enable: true,24 align: "end",25 size: "regular",26 offset: {27 bottom: "var(--m3e-space-8)",28 right: "var(--m3e-space-6)",29 },30 items: [31 {32 type: "top",33 enable: true,34 devices: ["mobile", "tablet", "desktop"],35 },36 {37 type: "toc",38 enable: true,39 devices: ["mobile", "tablet"],40 pages: ["post"],41 depth: 3,42 closeOnSelect: true,43 },44 {45 type: "comment",46 enable: true,47 devices: ["mobile", "tablet"],48 pages: ["post"],49 },50 {51 type: "home",52 enable: true,53 devices: ["mobile", "tablet"],54 onlySubPages: true,55 },56 ],57});1import type { FontConfig, ResolvedFontOptions } from "../types/fontConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3import { resolveFontOptions as resolve } from "../utils/font-options.ts";4
5/**6 * ─────────────────────────────────────────────────────────────────────────────7 * Shirone 全站字体配置指南8 * ─────────────────────────────────────────────────────────────────────────────9 *10 * 博客的字体分为 3 种角色(Role),每个角色各司其职:11 * 1. `body`:西文与默认基础正文字体(英文字母、数字、基础标点)12 * 2. `cjk` :中日韩字体(汉字、日文平假名/片假名、韩文)13 * 3. `mono`:等宽代码字体(文章代码块、行内代码、终端输出)14 *15 * ─────────────────────────────────────────────────────────────────────────────16 * 【常见修改场景】17 * ─────────────────────────────────────────────────────────────────────────────18 * 场景 A:完全使用系统默认字体(零字体打包,极速加载,最省流量)19 * - 将 `mode` 设置为 `"system"`,并将 `fontFamilies` 设为空数组 `[]`。20 *21 * 场景 B:更换本地中文字体或英文字体(.woff2 文件)22 * 1. 准备你的 `.woff2` 字体文件,放入项目 `src/assets/fonts/` 目录下;23 * 2. 找到对应角色的配置(如 `role: "cjk"` 或 `role: "body"`);24 * 3. 设置 `source: "local"`,并在 `file` 中填入你的字体路径(例如 `"src/assets/fonts/MyFont.woff2"`);25 * 4. 将 `family` 设为该字体的真实族名称。26 *27 * 场景 C:使用 npm 的 Fontsource 字体包28 * 1. 安装字体包(如 `pnpm.cmd add @fontsource/inter`);29 * 2. 设置 `source: "fontsource"`,并在 `file` 中填入对应的 CSS 路径(如 `"@fontsource/inter/400.css"`);30 * 3. 将 `family` 设为对应的字体名称(如 `"Inter"`)。31 *32 * ─────────────────────────────────────────────────────────────────────────────33 * 【修改后的验证命令】34 * 在终端依次执行:35 * 1. `npx.cmd astro check` -> 校验配置与页面语法36 * 2. `pnpm.cmd build` -> 执行生产构建与字体打包37 * 3. `pnpm.cmd fonts:check` -> 校验字体格式与体积预算38 * ─────────────────────────────────────────────────────────────────────────────39 */40export const fontConfig: FontConfig = withUserConfig("font", {41 /**42 * 构建模式:43 * - `"custom"`: 启用自定义字体(加载下方 fontFamilies 中配置的字体)44 * - `"system"`: 纯系统字体模式(不打包任何自定义字体文件,完全依赖访客设备)45 */46 mode: "custom",47
48 /**49 * 字体清单列表(按需配置 body、cjk、mono 角色)50 */51 fontFamilies: [52 // ---------------------------------------------------------------------53 // 1. 正文字体(现代几何圆润西文字体 Outfit,与 M3E 大圆角及悠哉圆体绝配)54 // ---------------------------------------------------------------------55 {56 id: "outfit-body",57 family: "Outfit",58 role: "body",59 source: "fontsource",60 variants: [61 {62 file: "@fontsource/outfit/400.css",63 weight: 400,64 style: "normal",65 },66 {67 file: "@fontsource/outfit/500.css",68 weight: 500,69 style: "normal",70 },71 {72 file: "@fontsource/outfit/700.css",73 weight: 700,74 style: "normal",75 },76 ],77 fallback: ["ui-sans-serif", "system-ui", "sans-serif"],78 display: "swap",79 preload: false,80 },81
82 // ---------------------------------------------------------------------83 // 2. 中文 / 日文 CJK 字体(悠哉圆体 Yozai Medium,全量简繁中日韩 100% 覆盖)84 // ---------------------------------------------------------------------85 {86 id: "yozai-cjk",87 family: "Yozai Medium",88 role: "cjk",89 source: "local",90 variants: [91 {92 file: "src/assets/fonts/Yozai-Medium.ttf",93 weight: 500,94 style: "normal",95 },96 ],97 fallback: ["system-ui", "sans-serif"],98 display: "swap",99 preload: false,100 },101
102 // ---------------------------------------------------------------------103 // 3. 代码等宽字体(渲染代码块与终端文本,对应 CSS 变量 --font-mono)104 // ---------------------------------------------------------------------105 {106 id: "jetbrains-mono",107 family: "JetBrains Mono",108 role: "mono",109 source: "fontsource",110 variants: [111 {112 file: "@fontsource-variable/jetbrains-mono/index.css",113 weight: "100 800",114 style: "normal",115 },116 {117 file: "@fontsource-variable/jetbrains-mono/wght-italic.css",118 weight: "100 800",119 style: "italic",120 },121 ],122 fallback: [123 "ui-monospace",124 "SFMono-Regular",125 "Menlo",126 "Monaco",127 "Consolas",128 "monospace",129 ],130 display: "swap",131 preload: false,132 },133 ],134
135 /**136 * 字体子集化配置(生产构建时自动从文章、i18n、配置及 Meting 歌曲中提取字符,生成极速精简版 .woff2)137 * - Dev 开发环境:自动加载完整原字体,任意输入新汉字实时可见,极速 HMR 零等待;138 * - Build 生产构建:自动执行子集裁剪,将几十兆大字体压缩为几百 KB 的专属子集,秒开加载。139 */140 subsetting: {141 enable: true, // 启用自动化子集裁剪142 includeContent: true, // 扫描 src/content/ 下所有文章143 includeI18n: true, // 扫描全部 10 种语言词典144 includeConfig: true, // 扫描站点配置与导航145 includeCommon: true, // 包含常用标点与基础字符146 allowRemoteText: true, // 允许抓取 Meting 云端歌单曲目文本参与字形提取147 },148
149 /**150 * 字体打包体积预算限制(子集化后通常仅 300KB ~ 1MB)151 */152 budget: {153 maxTotalBytes: 6 * 1024 * 1024, // 全站引用自定义字体总大小上限:6MB154 maxFamilyBytes: 4 * 1024 * 1024, // 单个字体族文件大小上限:4MB155 },156});157
158/** 经过校验与标准化处理后的字体配置对象,由 Astro 模板与 CSS 消费 */159export const resolvedFontOptions: ResolvedFontOptions = resolve(fontConfig);160
161/** 字体配置解析与校验函数 */162export const resolveFontOptions: (config: FontConfig) => ResolvedFontOptions =163 resolve;1<!--2 在这里添加自定义的页脚 HTML 内容(例如 ICP 备案号、公安备案图标与链接、自定义声明等)。3 需要在 src/config/footerConfig.ts 中保持 enable: true;4 内容将注入在页脚版权信息上方;若文件留空或仅保留注释,则不会产生任何额外的 DOM 结构。5
6 2026-10-29:挂 ICP 备案号(法定要求,必须链接到工信部备案系统)。7 注意:服务备案号是「主体号 + -1」,不是主体备案号 桂ICP备2026025147号。8 ★ 这里只放裸 <a>:FooterBar 的 __custom 是居中的 flex 列,套 <p> 会引入浏览器默认上下边距,9 与页脚其他行的 0.375rem 间距不一致。链接样式由 .m3-blog-footer__custom a 统一控制。10 公安联网备案通过后,在本块下方再补一行:11 <a href="https://beian.mps.gov.cn/#/query/webSearch?code=XXXXXXXX" target="_blank" rel="noopener noreferrer">桂公网安备XXXXXXXX号</a>12-->13<a href="https://beian.miit.gov.cn/" target="_blank" rel="noopener noreferrer">桂ICP备2026025147号-1</a>1import type { FooterConfig } from "@/types/footerConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 页脚自定义 HTML 注入配置。6 * 开启后将读取 src/config/FooterConfig.html 文件内容并注入到页脚版权信息上方。7 * 关闭时(enable: false)零额外 DOM 占位、零文件读取开销。8 */9export const footerConfig: FooterConfig = withUserConfig("footer", {10 enable: true,11});1import type { FriendsConfig } from "../types/friendsConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const friendsConfig: FriendsConfig = withUserConfig("friends", {5 enable: true,6 title: "$t:friends",7 description: "$t:friendsBanner",8});1import type { GamesConfig } from "@/types/gamesConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 游戏展示页行为与展示配置。6 *7 * 遵循「配置管行为,数据管内容」原则:8 * - enable:页面总开关;false 时导航入口同步隐藏,访问 /games/ 跳转 404;9 * - categories:游戏分类清单(数组顺序即页面顶部 Chips 顺序;10 * 没有任何条目的分类会自动隐藏,因此可放心保留暂时用不到的分类);11 * - disabledIds:可选被禁用的游戏 ID 列表;12 *13 * 注:游戏的具体清单数据(游戏名、开发商、封面、评分、时长、简评等)请在 `src/data/games.ts` 中维护。14 */15export const gamesConfig: GamesConfig = withUserConfig("games", {16 enable: true,17 title: "$t:games",18 description: "$t:gamesBanner",19 categories: [20 {21 key: "open-world",22 label: "Open World",23 icon: "material-symbols:explore-outline-rounded",24 description: "Open-world adventures",25 },26 {27 key: "sandbox",28 label: "Sandbox",29 icon: "material-symbols:widgets-rounded",30 description: "Building, crafting & creative worlds",31 },32 {33 key: "rpg",34 label: "RPG",35 icon: "material-symbols:shield-outline-rounded",36 description: "Role-playing stories & builds",37 },38 {39 key: "action",40 label: "Action",41 icon: "material-symbols:swords-outline-rounded",42 description: "Action, fighting & shooters",43 },44 {45 key: "casual",46 label: "Casual",47 icon: "material-symbols:extension-outline-rounded",48 description: "Cozy, casual & party games",49 },50 ],51 // disabledIds: [],52});1import type { I18nConfig } from "../types/i18nConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const i18nConfig: I18nConfig = withUserConfig("i18n", {});1/**2 * Tonal Bloom(色调辉光占位)默认配置。3 * 与 M3E HCT 色彩系统同源,为全站图片提供防抖动尺寸占位与色彩过渡体验。4 */5import type { ImageBloomConfig } from "@/types/imageBloomConfig";6import { withUserConfig } from "../utils/config-overlay.ts";7
8export const imageBloomConfig: ImageBloomConfig = withUserConfig("imageBloom", {9 enable: true,10 blurRadius: 20,11 opacity: 0.7,12 transitionDuration: 300,13});14
15export function resolveImageBloomOptions(16 config: Partial<ImageBloomConfig> = imageBloomConfig,17): ImageBloomConfig {18 return {19 enable: config.enable ?? true,20 blurRadius: config.blurRadius ?? 20,21 opacity: config.opacity ?? 0.7,22 transitionDuration: config.transitionDuration ?? 300,23 };24}1/**2 * 配置统一出口(barrel):消费方一律 `import { xxx } from "@/config"`。3 *4 * 约定(详见本目录 README.md):5 * - 值放在 `src/config/<domain>Config.ts`,类型放在 `src/types/<domain>Config.ts`;6 * - 存在反向依赖的模块(如 i18n/translation.ts 依赖 siteConfig)只允许从7 * 具体文件导入(`@/config/siteConfig`),禁止走本 barrel,避免循环依赖。8 */9
10export { aboutConfig } from "./aboutConfig";11export { albumsConfig } from "./albumsConfig";12export {13 animeConfig,14 resolveAnimeOptions,15 resolvedAnimeOptions,16} from "./animeConfig";17export { announcementConfig } from "./announcementConfig";18export {19 type ArticleDiscoveryOptions,20 type ArticleShareOptions,21 articleConfig,22 normalizeDiscoveryCount,23 resolveArticleDiscoveryOptions,24 resolveArticleShareOptions,25 resolveLastUpdatedNoticeOptions,26} from "./articleConfig";27export {28 commentConfig,29 type ResolvedCommentOptions,30 resolveCommentOptions,31} from "./commentConfig";32export { compassConfig } from "./compassConfig";33export { contextMenuConfig } from "./contextMenuConfig";34export { devicesConfig } from "./devicesConfig";35export { expressiveCodeConfig } from "./expressiveCodeConfig";36export { fabConfig } from "./fabConfig";37export {38 fontConfig,39 resolvedFontOptions,40 resolveFontOptions,41} from "./fontConfig";42export { footerConfig } from "./footerConfig";43export { friendsConfig } from "./friendsConfig";44export { gamesConfig } from "./gamesConfig";45export { i18nConfig } from "./i18nConfig";46export {47 imageBloomConfig,48 resolveImageBloomOptions,49} from "./imageBloomConfig";50export { insightsConfig } from "./insightsConfig";51export { licenseConfig } from "./licenseConfig";52export { llmsConfig } from "./llmsConfig";53export { momentsConfig } from "./momentsConfig";54export {55 clampMusicVolume,56 musicConfig,57 type ResolvedMusicOptions,58 resolveMusicOptions,59} from "./musicConfig";60export { LinkPresets, navBarConfig } from "./navBarConfig";61export { permalinkConfig } from "./permalinkConfig";62export { POST_CARD_MIN_WIDTH, postListConfig } from "./postListConfig";63export { profileConfig } from "./profileConfig";64export { projectsConfig } from "./projectsConfig";65export { seriesConfig } from "./seriesConfig";66export { sidebarConfig } from "./sidebarConfig";67export {68 getDefaultSpec,69 getDefaultStyle,70 resolveDisplaySettings,71 resolveTextureOptions,72 siteConfig,73} from "./siteConfig";74export { skillsConfig } from "./skillsConfig";75export { timelineConfig } from "./timelineConfig";76export {77 type ResolvedUmamiOptions,78 resolveUmamiOptions,79 umamiConfig,80} from "./umamiConfig";1import type { InsightsConfig } from "@/types/insightsConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 顶栏「站点概览」浮层(统计 + 日历)。6 *7 * 站主原话:「统计和日历也和也拉到图二导航栏附近也是鼠标悬停有hover的作用」。8 *9 * 行为:鼠标移到顶栏的概览入口即展开、移开即收起(触屏改为点击开合),10 * 展开后才必要地渲染交互(日历水合岛走 client:visible,浮层不可见时不耗性能)。11 *12 * 关闭方式(可逆,删代码不可逆):把 enable 置 false,侧栏那份挂件仍可照常开启,13 * 两条路径互不依赖——关闭浮层时顶栏不输出任何 DOM 与脚本。14 */15export const insightsConfig: InsightsConfig = withUserConfig("insights", {16 enable: true,17 stats: true,18 calendar: true,19});1/**2 * 下列 import 全部是 `import type`,在编译期被完全擦除,不产生任何运行时依赖。3 * 用 `Parameters<typeof fn>[0]` 而不是写死类型名,是为了让这里的标注跟着各集成4 * 自己的声明走——它们升级改了选项形状时,这里会跟着报错,而不是悄悄失配。5 */6
7import type mdx from "@astrojs/mdx";8import type swup from "@swup/astro";9import type { AstroUserConfig } from "astro";10import type expressiveCode from "astro-expressive-code";11import type icon from "astro-icon";12
13/**14 * 所有模式共享的集成选项。15 *16 * 主题只有一份 Astro 配置入口:`src/integration/index.ts` 在17 * `astro:config:setup` 阶段的 `updateConfig()`。源码模式(本仓 checkout)与18 * 包模式(npm 安装)都通过 `integrations: [shirones()]` 跑它——本仓的19 * `astro.config.mjs` 只剩这一行接线。源码模式由 in-repo 检测20 * (`paths.isThemeRepo`)把 config/data/content 指回 `src/` 自身,`src/` 下21 * 原生的 pages/组件照常被 Astro 拾取。22 *23 * **只放「选项值」和纯函数,不放接线(wiring)。** 接线集中在24 * `src/integration/index.ts`,按模式分支,不要往这里搬:25 *26 * - `vite.resolve.alias` —— 两侧根目录和依赖树不同;27 * - `svelte().preprocess` —— 由 integration 显式传 `vitePreprocess`(本仓的28 * `svelte.config.js` 只剩编辑器工具用途);29 * - `vite.optimizeDeps.include` —— 要过 `prebundleCandidates()`:Vite 从30 * **项目**根解析这个列表,包模式看不见主题自己的嵌套 node_modules;31 * - `vite.plugins` —— overlay(仅包模式)/ fallback-resolver / SSR shims;32 * - `expressiveCode()` 的 `themes` 和 `plugins` —— 必须走33 * `loadConfigModule()` / `loadPackageModule()`,用户的覆盖才生效;34 * - `fonts`、`markdown.processor`、`site`、`base` —— 由 integration 统一装载。35 *36 * 本文件不 import `../types/`、`../utils/` 等目录:模板拷贝时37 * `CONFIG_REWRITES` 不覆盖这些路径,一旦引用会让 `prepare-templates.mjs` 的38 * `assertNoEscapedImports` 直接失败。同理**不要走 `src/config/index.ts`39 * barrel**——barrel 不会拷进用户项目(`prepare-templates.mjs` 跳过40 * `index.ts`),两侧都必须按文件路径 import 本模块。41 */42
43/**44 * 全站尾斜杠策略。主题的页面和路由 pattern 全程假设 `"always"`,所以不提供45 * 配置项——暴露这个选择等于邀请用户配一个主题并不真正支持的组合。46 */47export const TRAILING_SLASH = "always";48
49/**50 * 图片端点路由。必须带尾斜杠,与 `TRAILING_SLASH` 配对。51 *52 * Astro 的 `image.endpoint.route` 只在 `core/config/schemas/relative.js` 的53 * zod transform 里被规范化一次,而那一步由 `validateConfig` 在 `resolveConfig`54 * 阶段调用。此时 `trailingSlash` 还是默认的 `"ignore"`,于是 route 定格为55 * `/_image`(无尾斜杠)。包模式随后才在 `astro:config:setup` 里把56 * `trailingSlash` 改成 `"always"`,而 `hooks.js` 之后只跑57 * `validateConfigRefined`——它**不含**这个 transform——route 再也不会被规范化。58 * 结果 URL 生成端吐 `/_image?…`,路由 pattern 却要求 `/_image/`,`astro dev`59 * 下每个图片请求都 404(build 和 preview 不受影响)。60 *61 * 这里直接把斜杠补上,等价于 transform 本该产出的结果。integration 的62 * `updateConfig()` 把 `trailingSlash` 与 `image.endpoint.route` 成对设置63 * (见 index.ts 中的注释),**所有模式**——源码模式在内——都走这条路径。64 *65 * ⚠️ 若 `TRAILING_SLASH` 改了,这里要跟着改:66 * `"always"` → `"/_image/"` | `"never"` / `"ignore"` → `"/_image"`67 * 若 Astro 修复了顺序(在 config:setup 之后重跑 relative transform),这一行68 * 会变成冗余但无害,可以删除。跟踪:withastro/astro#11568、#10149。69 */70export const IMAGE_ENDPOINT_ROUTE = "/_image/";71
72/**73 * `@swup/astro` 的选项。74 *75 * `ignore` 用数组形式:`@swup/astro` 1.8.0 的类型声明是76 * `(string | RegExp)[] | ((url, …) => …)`(见其 `dist/index.d.ts`),裸字符串77 * 并不符合——运行时 `script.js` 恰好能处理,但本仓 `astro.config.mjs` 是无类型78 * JS 才没报错。统一成数组后两侧都是合法的。79 *80 * `persistTags` 必须带 `:not([data-swup-optional])`:`data-swup-optional` 是81 * 主题自己的页面发出的(`[...permalink].astro`、`about.astro`、82 * `posts/[...slug].astro`),语义是「这份样式只对当前页有意义」。不带这个83 * 选择器,语法高亮等按页注入的样式会在 Swup 切换后残留。包模式同样注入这些84 * 页面,所以两侧行为必须一致。85 */86export const swupOptions: NonNullable<Parameters<typeof swup>[0]> = {87 theme: false as const,88 ignore: ['a[href="#"]'],89 animationClass: "transition-swup-",90 containers: ["main", "#toc"],91 smoothScrolling: true,92 cache: true,93 preload: true,94 accessibility: true,95 updateHead: {96 /*97 * 【这个开关的作用,以及一次「改对了参数、修错了病」的记录(2026-10-11)】98 *99 * 作用:Swup 切换时是否**等本次 head diff 新增的**资源(link[rel=stylesheet])加载完100 * 再宣告切换完成。true = 等,false = 不等。101 *102 * ⚠️ 【重要更正】它**不是**「归档页排版概率性错乱」的修复。103 * 当时我误判成「CSS 加载竞态」,于是把这里从 false 改成 true 并断言修好了 ——104 * **那个断言是错的**:真实根因是 Layout.astro 的一个双大括号注释把 [object Object]105 * 写进 head、提前终止了 head 解析,导致样式表全落进 body(详见 Layout.astro 的说明)。106 * 既然 link 根本不在 head 里,它就**既不会被 diff、也不会被等待** —— 这个开关无事可做。107 *108 * 保留 true 本身无害(多等一次同源 CSS,通常已预取)。留此注释是为了防止109 * 下一个人读到「awaitAssets 修了排版问题」这种错误的因果叙事。110 *111 * 教训:**「改了一个参数、现象消失了」不等于「找到了根因」** ——112 * 当时的「修好」其实来自其它改动或重跑时机,没有做「改回去看是否复现」的反向验证。113 */114 awaitAssets: true,115 // Keep base styles across Swup visits, but let syntax-scoped styles116 // disappear when the destination page no longer declares them.117 /*118 * 【persistTags 的真实语义(2026-10-11,由独立 QA 定位)】119 *120 * 它是「保留旧标签」的白名单:命中的标签在 Swup 切换时**既不删除也不重新添加**。121 * 原值 "link[rel=stylesheet]:not([data-swup-optional]), style:not(...)" 的本意是122 * 「保留基础样式、丢掉按页注入的语法高亮样式」。123 *124 * ⚠️ 但后来发现:**页面级 CSS 丢失的真因不在这里**,而是 Layout.astro 的一个125 * 双层大括号的块注释(形如 双大括号 + 星号斜杠)被 Astro 当成表达式,126 * 提前终止了 head 解析、把全部 stylesheet link 挤进了 <body>;127 * 而 Swup 的 HeadPlugin 只 diff <head>,因此永远看不到它们。128 * 该注释已修(单层大括号),persistTags 恢复原值即可。129 */130 persistTags:131 "link[rel=stylesheet]:not([data-swup-optional]), style:not([data-swup-optional])",132 },133 updateBodyClass: false,134 globalInstance: true,135};136
137/**138 * `@swup/astro` 1.8.0 的 `Options` 类型不认识、但主题确实要传的选项。该版本在139 * 运行时会静默丢弃它们。140 *141 * 必须和 `swupOptions` 分开:`swupOptions` 标注为 `Partial<Options>`,把这两个142 * 键写进去会直接报 ts(2353)。这个标注本身是有意义的——它是本文件里唯一能让143 * swup 选项拼写错误在编译期暴露出来的东西,不要为了让这里能合并而把它去掉。144 *145 * 等 `@swup/astro` 的类型补上这两个键之后,才可以把它们并回 `swupOptions`。146 */147export const swupForwardOptions = {148 animateHistoryBrowsing: false,149 skipPopStateHandling: (event: { state?: { url?: string } }): boolean =>150 Boolean(event.state?.url?.includes("#")),151};152
153/**154 * `astro-icon` 要打包的图标集。155 *156 * 五个集合一个都不能少:`src/` 里共 340 处图标引用,其中 `material-symbols`157 * 占 308 处、`simple-icons` 占 21 处。源码模式此前漏了这两个,只靠 astro-icon158 * 在扁平 `node_modules` 下的自动发现兜住;包模式在 pnpm 严格布局下没有这个159 * 退路,必须显式声明。160 */161export const iconInclude: NonNullable<162 NonNullable<Parameters<typeof icon>[0]>["include"]163> = {164 "material-symbols": ["*"],165 "simple-icons": ["*"],166 "fa7-brands": ["*"],167 "fa7-solid": ["*"],168};169
170/**171 * `astro-expressive-code` 中与「取值方式」无关的那部分选项。172 *173 * `themes` 不在这里:两侧拿 `expressiveCodeConfig` 的途径不同(源码模式直接174 * import,包模式走 `loadConfigModule()` 让用户的覆盖生效)。`plugins` 也不在:175 * 包模式必须走 `loadPackageModule()`。176 */177export const expressiveCodeShared: Omit<178 NonNullable<Parameters<typeof expressiveCode>[0]>,179 "themes" | "plugins"180> = {181 defaultProps: {182 wrap: true,183 overridesByLang: {184 shellsession: {185 showLineNumbers: false,186 },187 },188 },189 styleOverrides: {190 codeBackground: "var(--codeblock-bg)",191 borderRadius: "0.75rem",192 borderColor: "none",193 codeFontSize: "0.875rem",194 codeFontFamily: "var(--m3e-font-mono-family)",195 codeLineHeight: "1.5rem",196 frames: {197 editorBackground: "var(--codeblock-bg)",198 terminalBackground: "var(--codeblock-bg)",199 terminalTitlebarBackground: "var(--codeblock-topbar-bg)",200 editorTabBarBackground: "var(--codeblock-topbar-bg)",201 editorActiveTabBackground: "none",202 editorActiveTabIndicatorBottomColor: "var(--primary)",203 editorActiveTabIndicatorTopColor: "none",204 editorTabBarBorderBottomColor: "var(--codeblock-topbar-bg)",205 terminalTitlebarBorderBottomColor: "none",206 },207 // Hue values are numbers at runtime — the source config has always208 // passed numbers — but the published types only accept unresolved CSS209 // strings. Cast here rather than at the use sites so both modes stay210 // type-checked everywhere else.211 textMarkers: { delHue: 0, insHue: 180, markHue: 250 } as never,212 },213 frames: {214 showCopyToClipboardButton: false,215 },216};217
218/**219 * `@astrojs/svelte` 的 `compilerOptions`。220 *221 * `preprocess` 不在这里,见文件头对 wiring 的说明。222 *223 * @param isDev 是否 dev 命令。源码模式从 `process.argv` 判断,包模式从224 * `astro:config:setup` 的 `command` 参数判断,两侧取值方式不同,所以由调用方225 * 传进来。226 */227export function svelteCompilerOptions(isDev: boolean) {228 return {229 // CSS-source hashing keeps SSR and client scope hashes stable after moves.230 cssHash: ({ css, hash }: { css: string; hash: (s: string) => string }) =>231 `svelte-${hash(css)}`,232 // Keep repeated Svelte compiler diagnostics out of the dev terminal;233 // check/build still surface the full warning set in CI.234 warningFilter: (): boolean => !isDev,235 };236}237
238/** `@astrojs/mdx` 的选项。 */239export const mdxOptions: NonNullable<Parameters<typeof mdx>[0]> = {240 syntaxHighlight: false as const,241 optimize: true,242};243
244/**245 * 两侧共用的 `vite.build` 选项。246 *247 * 不含 `esbuild`(`drop` / `pure`):那是源码模式独有的构建期优化,会连带剥掉248 * 用户自己代码里的 `console.log`,属于需要单独决策的行为差异,见249 * `shirones` 仓的 `docs/plans/single-source-config.md`。250 */251export const viteBuildShared: NonNullable<252 NonNullable<AstroUserConfig["vite"]>["build"]253> = {254 minify: "esbuild" as const,255 cssCodeSplit: true,256 cssMinify: "esbuild" as const,257 chunkSizeWarningLimit: 1000,258 rollupOptions: {259 // Parameter types are inferred from the build-options type. Hand-writing them260 // narrower than rolldown's OnwarnFunction made the whole object fail to261 // type-check.262 onwarn(warning, defaultHandler) {263 // Astro legitimately mixes static and dynamic imports for islands;264 // silence that specific advisory.265 if (266 warning.message.includes("is dynamically imported by") &&267 warning.message.includes("but also statically imported by")268 ) {269 return;270 }271 // Astro's MDX bridge injects this directive before Rolldown sees the272 // module; the directive is consumed by Astro and is not meaningful in273 // the final browser bundle. Remove this filter once the bridge stops274 // forwarding `astro:head-inject` to the bundler.275 if (276 warning.code === "MODULE_LEVEL_DIRECTIVE" &&277 warning.message.includes('"use astro:head-inject"')278 ) {279 return;280 }281 defaultHandler(warning);282 },283 },284};285
286/**287 * `vite.optimizeDeps.include` 的候选清单。288 *289 * 只是**候选**:包模式必须先过 `prebundleCandidates()` 过滤,因为 Vite 从项目290 * 根解析这些 id,看不见主题自己的嵌套依赖,列一个解析不了的 id 会让每次冷启动291 * 都刷一条警告。292 */293export const prebundleSpecifiers: string[] = [294 "mermaid",295 "@panzoom/panzoom",296 "overlayscrollbars",297 "@fancyapps/ui",298 // share-poster.ts reaches for this one at runtime, on the first share299 // dialog rather than during SSR. Without it here the dev server serves a300 // stale pre-bundle id and the dynamic import rejects, so poster301 // generation fails only in dev.302 "qrcode",303];304
305/**306 * 可选音乐侧栏的虚拟模块 id。307 *308 * 两侧的插件对象本身不共享(解析出来的侧栏路径不同:源码模式指向309 * `./src/components/...`,包模式指向 `paths.packageSrc` 下的同名文件),但虚拟310 * id 和「哪些产物算音乐包」的判定两侧完全一致,而且正是最容易悄悄改歪的地方,311 * 所以收敛到这里。312 */313export const MUSIC_SIDEBAR_VIRTUAL_ID = "virtual:shirone-music-sidebar";314
315/** 关掉音乐组件时,`generateBundle` 阶段要从产物里剔除的文件。 */316export function isMusicBundleFile(fileName: string): boolean {317 return (318 fileName.includes("MusicSidebarClient") ||319 fileName.startsWith("_astro/music.") ||320 fileName.includes("/music.")321 );322}1import type { LicenseConfig } from "@/types/config";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 文章版权声明(文章页 License 区块消费)。类型见 src/types/config.ts。6 */7export const licenseConfig: LicenseConfig = withUserConfig("license", {8 enable: true,9 name: "CC BY-NC-SA 4.0",10 url: "https://creativecommons.org/licenses/by-nc-sa/4.0/",11});1import type { LlmsConfig } from "@/types/llmsConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * ─────────────────────────────────────────────────────────────────────────────6 * Shirone LLMs.txt 与 AI 友好内容系统配置指南7 * ─────────────────────────────────────────────────────────────────────────────8 *9 * 遵循「零额外负担」原则与 https://llmstxt.org/ 官方规范:10 * - 为大语言模型 (ChatGPT, Claude, Perplexity, Cursor 等) 提供结构化 Markdown 索引;11 * - 纯服务端静态生成 `/llms.txt`(精简索引)与 `/llms-full.txt`(全量正文汇编);12 * - 客户端 JS 主包增加 0 KB,前台读者浏览速度 0 影响;13 * - 安全隔离:自动过滤密码保护文章 (encrypted: true) 与草稿 (draft: true),绝不泄漏私密内容。14 *15 * ─────────────────────────────────────────────────────────────────────────────16 * 【自动化机制说明(平时写作无需维护本文件)】17 * ─────────────────────────────────────────────────────────────────────────────18 * 1. 公开博客文章(Articles):19 * - 构建期系统自动调用 `getSortedPosts()` 扫描全站 Markdown 文件;20 * - 自动提取每篇文章的标题、链接、简介与标签,无需手动登记!21 * 2. 站点基本信息:22 * - 站点标题、副标题与简介默认自动继承 `siteConfig` 与 `profileConfig`;23 * 3. 正文脱敏与清洗:24 * - `/llms-full.txt` 自动展开 `<llm-only>` AI 专属提示,自动剔除 `<llm-exclude>` 内容。25 *26 * ─────────────────────────────────────────────────────────────────────────────27 * 【常用配置场景】28 * ─────────────────────────────────────────────────────────────────────────────29 * 场景 A:使用默认配置(开箱即用,最推荐)30 * - 保持下方默认配置即可,全站文章自动收录并生成 `/llms.txt` 与 `/llms-full.txt`。31 *32 * 场景 B:完全关闭 AI 检索端点33 * - 将 `enable` 设置为 `false`(访问对应链接返回 404,不生成任何静态文件)。34 *35 * 场景 C:文章量极大时仅生成精简目录,不生成超长全文 dump36 * - 将 `generateFull` 设置为 `false`(只生成 `/llms.txt`,跳过 `/llms-full.txt`)。37 *38 * 场景 D:防止某些私密标签被大模型检索39 * - 在 `excludeTags` 中追加标签名,例如:`excludeTags: ["secret", "private", "diary"]`。40 *41 * 场景 E:内容仓(external 模式)覆盖42 * - 在内容仓 `config/llms.yaml` 里只写想改的键即可(如 `siteSummary`、`excludeTags`);43 * - 合并规则为「对象递归合并,数组整体替换」,因此改 `corePages` / `customSections`44 * 需要把整个清单写全。契约见 `docs/content-separation/config-overlay.md`。45 * ─────────────────────────────────────────────────────────────────────────────46 */47export const llmsConfig: LlmsConfig = withUserConfig("llms", {48 /**49 * 是否启用 /llms.txt 与 /llms-full.txt 静态端点生成50 * - true (默认): 构建期自动在 dist/ 输出纯文本 Markdown 文件;51 * - false: 彻底禁用此功能,访问返回 404,不产生任何构建文件。52 */53 enable: true,54
55 /**56 * 是否同时生成包含全站公开文章完整正文的 /llms-full.txt 文件57 * - true (默认): 将所有公开非加密文章的正文清洗后合并为一个文件,方便 AI 全量学习与 RAG 导入;58 * - false: 仅生成目录索引 /llms.txt,不生成全量正文。59 */60 generateFull: true,61
62 /**63 * 站点在大模型眼中的自我介绍(可选)64 * - 省略或留空时:自动回退使用 `siteConfig.subtitle` 或 `profileConfig.bio`;65 * - 填写字符串时:优先使用此处的自定义英文/中文介绍覆盖默认值。66 */67 siteSummary: "",68
69 /**70 * 单篇文章在 /llms.txt 目录索引中的摘要截断字数上限(默认 200 字)71 * - 超出长度时会自动在句尾添加省略号 "…";72 * - 不影响 /llms-full.txt 中的完整正文输出。73 */74 descriptionMaxLength: 200,75
76 /**77 * 敏感标签黑名单过滤(可选)78 * - 凡是包含此列表中任意标签的文章,将同时从 /llms.txt 与 /llms-full.txt 中剔除;79 * - 即使文章本身为公开状态(非加密),只要命中黑名单标签也绝不暴露给 AI 模型。80 */81 excludeTags: ["secret", "private"],82
83 /**84 * 敏感分类黑名单过滤(可选)85 * - 凡是属于此分类的文章,将彻底从 LLM 产物中排除。86 */87 excludeCategories: [],88
89 /**90 * 核心引导页面清单(Core Pages)91 * - 向大模型重点介绍站点的核心栏目与功能入口;92 * - 可填写站内相对路径(如 "/about/")或外部完整 URL;93 * - 省略或设为空数组 [] 时,系统会自动使用默认核心页面。94 */95 corePages: [96 {97 title: "Home",98 url: "/",99 description: "Main blog entrance and latest post stream.",100 },101 {102 title: "About",103 url: "/about/",104 description: "Author profile, technical stack, and background.",105 },106 {107 title: "Archive",108 url: "/archive/",109 description: "Chronological index of all published writings.",110 },111 ],112
113 /**114 * 自定义扩展章节(可选)115 * - 用于向 AI Agent 额外推荐外部开源项目、API 文档或衍生资源;116 * - 默认为空数组 [],不输出额外章节。117 *118 * 示例:119 * ```ts120 * customSections: [121 * {122 * title: "Open Source Projects",123 * description: "Featured open source repositories maintained by the author.",124 * items: [125 * { title: "Shirone Theme", url: "https://github.com/LyraVoid/Shirone", description: "M3E blog theme for Astro." },126 * ],127 * },128 * ]129 * ```130 */131 customSections: [],132});1import type { MomentsConfig } from "../types/momentsConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const momentsConfig: MomentsConfig = withUserConfig("moments", {5 enable: true,6 title: "$t:moments",7 description: "$t:momentsBanner",8});1import { musicTracks } from "../data/music.ts";2import type {3 MetingMusicConfig,4 MusicConfig,5 MusicProvider,6 PlaybackMode,7 TrackDescriptor,8} from "../types/musicConfig.ts";9import { withUserConfig } from "../utils/config-overlay.ts";10
11/**12 * 侧栏音乐配置单一真源。13 * 遵循「零额外负担」原则:禁用时不产生任何网络请求与额外 DOM。14 *15 * ─────────────────────────────────────────────────────────────────────────────16 * 【四种工作模式(Provider)使用指南】17 * ─────────────────────────────────────────────────────────────────────────────18 * 1. "local"(本地独立模式,默认):19 * - 数据源:src/data/music.ts20 * - 特点:零外部 API 依赖,首屏毫秒级就绪,静态打包直出,断网也能正常播放。21 * - 示例:22 * provider: "local"23 *24 * 2. "custom"(自定义列表模式):25 * - 数据源:直接在 tracks 字段显式传入曲目数组(支持外链音频与封面)26 * - 特点:灵活自定义,无需修改通用数据文件。27 * - 示例:28 * provider: "custom",29 * tracks: [30 * { id: "song-1", title: "Song", artist: "Artist", source: "https://.../a.mp3", cover: "https://.../c.jpg" }31 * ]32 *33 * 3. "meting"(云端歌单模式):34 * - 数据源:Meting API 远端歌单(网易云 / QQ音乐 / 酷狗等)35 * - 特点:客户端异步按需拉取,海量曲库与封面自动解析。36 * - 可选 `preload: "metadata"`:组件进入视口即预取歌单元数据(不含音频流),37 * 首屏直接显示第一首曲目;默认 "none"(不预取,交互后才请求)。38 * - 示例:39 * provider: "meting",40 * meting: { server: "netease", type: "playlist", id: "14164869977" }41 *42 * 4. "mixed"(混合增强模式,推荐):43 * - 数据源:本地曲目(src/data/music.ts)+ Meting API 远端歌单自动合并44 * - 特点:首屏立即可播本地音乐,后台无感拉取远端歌单并在就绪后无缝扩容;45 * 若遇断网或云端接口故障,自动静默降级为本地曲目播放,绝不报红破版。46 * - 示例:47 * provider: "mixed",48 * meting: { server: "netease", type: "playlist", id: "14164869977" }49 * ─────────────────────────────────────────────────────────────────────────────50 */51export const musicConfig: MusicConfig = withUserConfig("music", {52 enable: true,53 /**54 * 曲目来源改为 **"local"**(2026-10-10,站主反馈后修)。55 *56 * 【原来是什么、为什么错】原来是 `provider: "mixed"` 且写死了一个网易云歌单57 * `meting.id = "14164869977"` —— mixed 的语义是「本地曲目 + 远端歌单自动合并」,58 * 于是前台播放列表 = 后台管理的 5 首 **+ 那个歌单的 12 首**。59 * 站主在后台只看到 5 首,前台却出现 Poppin'Party / 井口裕香 / 小春めう 等歌,60 * 第一反应就是「后台就这几首,怎么前台这么多」—— 这是**数据源与后台不一致**,61 * 而且那些歌根本不在后台可管理范围内(删不掉、也看不到)。62 *63 * 【现在】"local" = 曲目**只**来自后台 `music_track` 表(经64 * `src/utils/music/runtime-source.ts` 拉 `GET /api/v1/music/tracks`)。65 * 构建期 `src/data/music.ts` 只作首屏基线,岛挂载后立刻被 API 数据校正,66 * 所以**后台加/删/改曲目,前台刷新即生效**,两边永远一致。67 *68 * 【想恢复云端歌单怎么办】把 provider 改回 "mixed" 并填 meting.id 即可,69 * 但请注意:那样前台会出现**后台管不到**的曲目(本次站主反馈的正是这个问题)。70 */71 provider: "local",72 defaultVolume: 0.7,73 defaultMode: "sequence",74});75
76export interface ResolvedMusicOptions {77 readonly provider: MusicProvider;78 readonly playlist: readonly TrackDescriptor[];79 readonly meting?: MetingMusicConfig;80 readonly defaultVolume: number;81 readonly defaultMode: PlaybackMode;82}83
84const ABSOLUTE_MEDIA_SOURCE = /^(?:https?:)?\/\//i;85const UNSAFE_SCHEME = /^[a-z][a-z\d+.-]*:/i;86
87function normalizeMediaSource(value: string): string | null {88 const source = value.trim();89 if (!source) return null;90 if (ABSOLUTE_MEDIA_SOURCE.test(source) || source.startsWith("/")) {91 return source;92 }93 if (UNSAFE_SCHEME.test(source)) return null;94 return `/${source.replace(/^\.\//, "")}`;95}96
97function normalizeTrack(98 track: TrackDescriptor,99 usedIds: Set<string>,100): TrackDescriptor | null {101 const id = track.id.trim();102 const title = track.title.trim();103 const source = normalizeMediaSource(track.source);104 if (!id || !title || !source || usedIds.has(id)) return null;105
106 usedIds.add(id);107 const artist = track.artist?.trim() || undefined;108 const cover = track.cover109 ? (normalizeMediaSource(track.cover) ?? undefined)110 : undefined;111 const duration =112 typeof track.duration === "number" &&113 Number.isFinite(track.duration) &&114 track.duration > 0115 ? track.duration116 : undefined;117
118 return Object.freeze({ id, title, source, artist, cover, duration });119}120
121export function clampMusicVolume(value: number, fallback = 0.7): number {122 if (!Number.isFinite(value)) return fallback;123 return Math.min(1, Math.max(0, value));124}125
126/** 补齐 meting 配置的默认值(如 preload 默认 "none"),让 ResolvedMusicOptions 自包含。 */127function resolveMetingConfig(128 meting: MetingMusicConfig | undefined,129): MetingMusicConfig | undefined {130 if (!meting) return meting;131 return Object.freeze({ ...meting, preload: meting.preload ?? "none" });132}133
134export function resolveMusicOptions(135 config: MusicConfig,136): ResolvedMusicOptions | null {137 if (!config.enable) return null;138
139 const provider: MusicProvider = config.provider ?? "local";140
141 if (provider === "meting") {142 const id = config.meting?.id?.trim();143 if (!id) return null;144 return Object.freeze({145 provider: "meting",146 playlist: Object.freeze([]),147 meting: resolveMetingConfig(config.meting),148 defaultVolume: clampMusicVolume(config.defaultVolume),149 defaultMode: config.defaultMode,150 });151 }152
153 let rawTracks: readonly TrackDescriptor[] = [];154 if (provider === "local" || provider === "mixed") {155 rawTracks = config.tracks ?? musicTracks;156 } else if (provider === "custom") {157 rawTracks = config.tracks ?? [];158 }159
160 const usedIds = new Set<string>();161 const playlist = rawTracks162 .map((track) => normalizeTrack(track, usedIds))163 .filter((track): track is TrackDescriptor => track !== null);164
165 if (provider === "mixed") {166 const metingId = config.meting?.id?.trim();167 if (playlist.length === 0 && !metingId) return null;168 return Object.freeze({169 provider: "mixed",170 playlist: Object.freeze(playlist),171 meting: resolveMetingConfig(config.meting),172 defaultVolume: clampMusicVolume(config.defaultVolume),173 defaultMode: config.defaultMode,174 });175 }176
177 if (playlist.length === 0) return null;178
179 return Object.freeze({180 provider,181 playlist: Object.freeze(playlist),182 defaultVolume: clampMusicVolume(config.defaultVolume),183 defaultMode: config.defaultMode,184 });185}1import I18nKey from "@i18n/i18nKey";2import { i18n } from "@i18n/translation";3import { aboutConfig } from "@/config/aboutConfig";4import { albumsConfig } from "@/config/albumsConfig";5import { animeConfig } from "@/config/animeConfig";6import { commentConfig } from "@/config/commentConfig";7import { compassConfig } from "@/config/compassConfig";8import { devicesConfig } from "@/config/devicesConfig";9import { friendsConfig } from "@/config/friendsConfig";10import { gamesConfig } from "@/config/gamesConfig";11import { momentsConfig } from "@/config/momentsConfig";12import { projectsConfig } from "@/config/projectsConfig";13import { seriesConfig } from "@/config/seriesConfig";14import { skillsConfig } from "@/config/skillsConfig";15import { timelineConfig } from "@/config/timelineConfig";16import type {17 NavBarConfig,18 NavBarConfigOverride,19 NavBarLink,20 NavBarLinkOverride,21} from "@/types/navBarConfig";22import { getUserConfig } from "../utils/config-overlay.ts";23import { pruneUnavailableNavLinks } from "../utils/nav-utils.ts";24
25/**26 * 导航栏配置(统一单一来源)。27 * - LinkPresets:命名链接预设表 —— 名称 / 地址 / 图标单点维护,可整体复用;28 * - navBarConfig:导航结构 —— 顺序 + 分组(children 子菜单),29 * 同时驱动顶栏下拉菜单与全端导航抽屉。30 * 新增入口:先在 LinkPresets 登记预设,再在 navBarConfig.links 按序引用。31 *32 * 内容仓可用 `config/nav-bar.yaml` 整体替换 `links`,写法见 `NavBarLinkOverride`。33 * 无论哪种来源,指向已关闭功能页面的入口都会在 `navBarConfig` 处被裁掉。34 */35export const LinkPresets: Record<string, NavBarLink> = {36 Home: {37 name: i18n(I18nKey.home),38 url: "/",39 icon: "material-symbols:home-outline-rounded",40 pageKey: "home",41 },42 Archive: {43 name: i18n(I18nKey.archive),44 url: "/archive/",45 icon: "material-symbols:archive-outline-rounded",46 pageKey: "archive",47 },48 Friends: {49 name: i18n(I18nKey.friends),50 url: "/friends/",51 icon: "material-symbols:handshake-outline-rounded",52 pageKey: "friends",53 },54 Moments: {55 name: i18n(I18nKey.moments),56 url: "/moments/",57 icon: "material-symbols:auto-awesome-outline-rounded",58 pageKey: "moments",59 },60 Anime: {61 name: i18n(I18nKey.anime),62 url: "/anime/",63 icon: "material-symbols:live-tv-outline-rounded",64 pageKey: "anime",65 },66 Compass: {67 name: i18n(I18nKey.compass),68 url: "/compass/",69 icon: "material-symbols:explore-rounded",70 pageKey: "compass",71 },72 Skills: {73 name: i18n(I18nKey.skills),74 url: "/skills/",75 icon: "material-symbols:workspaces-outline-rounded",76 pageKey: "skills",77 },78 Projects: {79 name: i18n(I18nKey.projects),80 url: "/projects/",81 icon: "material-symbols:deployed-code-outline-rounded",82 pageKey: "projects",83 },84 Devices: {85 name: i18n(I18nKey.devices),86 url: "/devices/",87 icon: "material-symbols:devices-rounded",88 pageKey: "devices",89 },90 Games: {91 name: i18n(I18nKey.games),92 url: "/games/",93 icon: "material-symbols:sports-esports-outline-rounded",94 pageKey: "games",95 },96 Timeline: {97 name: i18n(I18nKey.timeline),98 url: "/timeline/",99 icon: "material-symbols:timeline-rounded",100 pageKey: "timeline",101 },102 Albums: {103 name: i18n(I18nKey.albums),104 url: "/albums/",105 icon: "material-symbols:photo-library-outline-rounded",106 pageKey: "albums",107 },108 Categories: {109 name: i18n(I18nKey.categories),110 url: "/categories/",111 icon: "material-symbols:folder-outline-rounded",112 pageKey: "categories",113 },114 Tags: {115 name: i18n(I18nKey.tags),116 url: "/tags/",117 icon: "material-symbols:tag-rounded",118 pageKey: "tags",119 },120 Series: {121 name: i18n(I18nKey.series),122 url: "/series/",123 icon: "material-symbols:auto-stories-outline-rounded",124 pageKey: "series",125 },126 About: {127 name: i18n(I18nKey.about),128 url: "/about/",129 icon: "material-symbols:info-outline-rounded",130 pageKey: "about",131 },132 GitHub: {133 name: "GitHub",134 url: "https://github.com/LyraVoid/Shirone",135 icon: "fa7-brands:github",136 external: true,137 pageKey: "github",138 },139 /** 留言板(阶段 3 新增):本站自建评论系统的公开入口 */140 Guestbook: {141 name: i18n(I18nKey.guestbook),142 url: "/guestbook/",143 icon: "material-symbols:forum-outline-rounded",144 pageKey: "guestbook",145 },146 /**147 * 登录 / 注册(阶段 2 新增)。顶栏右侧也有账号入口,这里供导航抽屉使用。148 *149 * 【★ 2026-10-13 加 visibility: "guest"】站主反馈「登录以后,这里登录注册还存在」。150 * 登录后不该再出现登录入口 —— 由客户端按登录态同步隐藏(见 NavBarLink.visibility 说明)。151 */152 Login: {153 name: "登录 / 注册",154 url: "/login/",155 icon: "material-symbols:login-rounded",156 pageKey: "login",157 visibility: "guest",158 },159 /**160 * 个人中心:需要登录。161 *162 * 【★ 2026-10-13 补 visibility: "auth"】原先注释就写着「需要登录」,但**没有实现** ——163 * 未登录访客的「更多」下拉里也有「个人中心」,点进去只能看到「你还没有登录」。164 * 既然它需要登录,就该**只在登录后显示**(与 Login 的 guest 正好成对)。165 */166 Me: {167 name: "个人中心",168 url: "/me/",169 icon: "material-symbols:account-circle-outline",170 pageKey: "me",171 visibility: "auth",172 },173 /**174 * 知识图谱(阶段 5):节点 = 可见文章 + 知识点词条,边 = 引用关系。175 *176 * 放「更多」下拉而不是一级导航 —— 站主的审美取向是「干净的文章列表」,177 * 一级导航已被主页/归档/友链/动态/番剧/站点罗盘/相册占满,178 * 图谱属于「偶尔想看」的探索型功能,塞进一级会稀释导航的信息密度。179 */180 Graph: {181 name: i18n(I18nKey.graph),182 url: "/graph/",183 icon: "material-symbols:hub-outline",184 pageKey: "graph",185 },186 /**187 * 知识点索引(阶段 5)—— 全部词条的目录。188 *189 * 与图谱的区别:图谱是「关系可视化」,索引是「可检索的目录」。190 * 独立 QA 的 PM 视角指出读者原本**没有任何入口能浏览本站有哪些知识点**191 * (双链要碰巧读到某篇文章才能发现,图谱是 canvas 且不可检索),192 * 所以补上这个目录页;两者都放「更多」,避免稀释一级导航。193 */194 Knowledge: {195 name: i18n(I18nKey.knowledgeIndex),196 url: "/knowledge/",197 icon: "material-symbols:menu-book-outline",198 pageKey: "knowledge",199 },200};201
202const defaultNavBarConfig: NavBarConfig = {203 links: [204 LinkPresets.Home,205 LinkPresets.Archive,206 LinkPresets.Friends,207 LinkPresets.Moments,208 LinkPresets.Anime,209 LinkPresets.Compass,210 LinkPresets.Albums,211 {212 name: i18n(I18nKey.more),213 icon: "material-symbols:apps-rounded",214 children: [215 LinkPresets.Timeline,216 LinkPresets.Projects,217 LinkPresets.Devices,218 LinkPresets.Games,219 LinkPresets.Skills,220 // 分类/标签入口不进导航菜单(避免菜单项过多),预设已登记指向独立页面,221 // 需要时取消注释即可222 // LinkPresets.Categories,223 // LinkPresets.Tags,224 LinkPresets.About,225 LinkPresets.Knowledge,226 LinkPresets.Graph,227 LinkPresets.Guestbook,228 LinkPresets.Me,229 LinkPresets.Login,230 LinkPresets.GitHub,231 ],232 },233 ],234};235
236import { resolveI18nText } from "../utils/i18n-utils.ts";237
238function fail(message: string): never {239 throw new Error(`[config] nav-bar:${message}`);240}241
242function resolveName(name: string): string {243 return resolveI18nText(name);244}245
246/**247 * 已关闭功能对应的站内路由(去尾斜杠),供 `pruneUnavailableNavLinks()` 裁剪导航入口。248 *249 * 功能关闭时对应页面会 `Astro.redirect("/404/")`,因此这些路由不得再出现在导航里。250 * 关闭判定只看配置,与导航结构无关,因此默认结构与内容仓声明式条目共用同一张表。251 */252const unavailableFeatureRoutes: ReadonlySet<string> = new Set([253 ...(friendsConfig.enable ? [] : ["/friends"]),254 ...(momentsConfig.enable ? [] : ["/moments"]),255 ...(animeConfig.enable ? [] : ["/anime"]),256 ...(compassConfig.enable ? [] : ["/compass"]),257 ...(albumsConfig.enable ? [] : ["/albums"]),258 ...(skillsConfig.enable ? [] : ["/skills"]),259 ...(projectsConfig.enable ? [] : ["/projects"]),260 ...(devicesConfig.enable ? [] : ["/devices"]),261 ...(gamesConfig.enable ? [] : ["/games"]),262 ...(timelineConfig.enable ? [] : ["/timeline"]),263 ...(aboutConfig.enable ? [] : ["/about"]),264 ...(seriesConfig.enable ? [] : ["/series"]),265 // 留言板依赖自建评论后端;评论未启用时入口一并裁掉,避免点进去是空页266 ...(commentConfig.enable && commentConfig.provider === "local"267 ? []268 : ["/guestbook"]),269]);270
271/**272 * 把内容仓的声明式导航条目还原成 `NavBarLink`。273 *274 * 预设名与 i18n 词条只有在这里才能校验(`LinkPresets` 与 `I18nKey` 都住在代码仓,275 * 生成期的 Node 脚本受路径别名所限读不到),因此错误在构建加载配置时抛出。276 */277export function resolveNavBarLinks(278 entries: readonly NavBarLinkOverride[],279 presets: Record<string, NavBarLink> = LinkPresets,280): NavBarLink[] {281 return entries.map((entry) => {282 let base: NavBarLink | null = null;283 if (entry.preset !== undefined) {284 base = presets[entry.preset] ?? null;285 if (!base) {286 fail(287 `未知的预设 "${entry.preset}"。可用预设:${Object.keys(presets).join("、")}。`,288 );289 }290 }291
292 const name =293 entry.name !== undefined ? resolveName(entry.name) : base?.name;294 if (name === undefined) {295 fail("每个条目都需要 name,或用 preset 引用一个内置预设。");296 }297
298 // 未声明 children 时沿用预设自带的子菜单(已由 ...base 带入)。299 return {300 ...base,301 name,302 ...(entry.url !== undefined ? { url: entry.url } : {}),303 ...(entry.icon !== undefined ? { icon: entry.icon } : {}),304 ...(entry.pageKey !== undefined ? { pageKey: entry.pageKey } : {}),305 ...(entry.external !== undefined ? { external: entry.external } : {}),306 ...(entry.children307 ? { children: resolveNavBarLinks(entry.children, presets) }308 : {}),309 };310 });311}312
313const userNavBar = getUserConfig("navBar") as NavBarConfigOverride | undefined;314
315/**316 * 导航栏最终结构。317 *318 * 默认结构与内容仓 `config/nav-bar.yaml`(声明式列表,整体替换默认导航,不走任何319 * enable 分支)在这里汇合后统一裁剪:功能关掉时入口一并消失,两种模式下行为一致,320 * 不会留下点进去 404 的死链。321 */322export const navBarConfig: NavBarConfig = {323 links: pruneUnavailableNavLinks(324 resolveNavBarLinks(325 userNavBar ? userNavBar.links : defaultNavBarConfig.links,326 ),327 unavailableFeatureRoutes,328 ),329};1import type { PermalinkConfig } from "../types/permalinkConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * Permalink 固定链接配置6 * 控制文章 URL 路由与生成模板7 */8export const permalinkConfig: PermalinkConfig = withUserConfig("permalink", {9 /** 是否启用全局 permalink 功能,关闭时使用默认的文件名作为链接 (/posts/<slug>/) */10 enable: false,11 /**12 * permalink 格式模板13 * 支持的占位符:14 * - %year% : 4 位年份 (如 2024)15 * - %monthnum% : 2 位月份 (01-12)16 * - %day% : 2 位日期 (01-31)17 * - %hour% : 2 位小时 (00-23)18 * - %minute% : 2 位分钟 (00-59)19 * - %second% : 2 位秒数 (00-59)20 * - %post_id% : 文章序号(按发布时间升序排列,最早的文章为 1)21 * - %postname% : 文章文件名(slug,通常为全小写)22 * - %raw_postname% : 文章原始文件名(保留大小写)23 * - %category% : 分类名(无分类时为 "uncategorized")24 *25 * 示例:26 * - "%year%-%monthnum%-%postname%" => "/2024-12-my-post/"27 * - "%post_id%-%postname%" => "/42-my-post/"28 * - "%category%-%postname%" => "/tech-my-post/"29 * - "%year%/%monthnum%/%day%/%postname%" => "/2024/12/01/my-post/"30 *31 * 注意:支持使用斜杠 "/" 构建嵌套路径。32 */33 format: "%postname%",34});1import type { PostCardWidth, PostListConfig } from "@/types/postListConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 文章列表页配置:分页大小与排版布局。6 *7 * 【核心配置项】8 * - pageSize:每页展示的文章数量(默认 8 篇);9 * - layout:10 * - mode:"list"(经典纵向列表)| "grid"(双列/三列卡片网格);11 * - cover:"left"(封面在左)| "right"(封面在右,默认);12 * - cardWidth(仅在 grid 模式下生效):13 * - "compact":紧凑卡片(最小宽 20rem,适合高密度展示);14 * - "regular":标准卡片(最小宽 24rem,默认推荐);15 * - "relaxed":宽松大卡(最小宽 28rem,突出大图)。16 *17 * 注意:访客可在前端显示设置面板中动态切换 list/grid,此处为站点初始默认值。18 * GridUI 仅在主内容容器至少能容纳两张所选宽度的卡片时生效;侧栏等因素压窄19 * 内容后会暂时回退 ListUI,但保留 grid 偏好,空间恢复后自动切回。20 */21export const postListConfig: PostListConfig = withUserConfig("postList", {22 pageSize: 8,23 layout: {24 mode: "list",25 cover: "right",26 cardWidth: "regular",27 },28});29
30/** grid 档位 → 卡片最小宽度(--post-card-min 预设,与 shape/type 分档哲学同构)。31 页面框架 90rem:regular 24rem 保证宽屏为 2 列大卡(3 列窄卡会让32 日期/分类/字数元信息行换行),compact 才给密排选项。 */33export const POST_CARD_MIN_WIDTH: Record<PostCardWidth, string> = {34 compact: "20rem",35 regular: "24rem",36 relaxed: "28rem",37};1import type { ProfileConfig } from "@/types/config";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 博主资料:头像 / 名称 / 简介 / 社交链接(侧栏 Profile 卡片、页脚、RSS 作者等消费)。6 * 类型见 src/types/config.ts。7 */8export const profileConfig: ProfileConfig = withUserConfig("profile", {9 avatar: "assets/images/demo-avatar.webp", // Relative to the /src directory. Relative to the /public directory if it starts with '/'10 name: "蓝色大肥鱼",11 bio: "DeepSeek鲸鱼娘的个人博客 · 收好每一张有趣的图。",12 links: [13 {14 name: "X",15 icon: "fa7-brands:x-twitter", // Visit https://icones.js.org/ for icon codes16 // You will need to install the corresponding icon set if it's not already included17 // `pnpm add @iconify-json/<icon-set-name>`18 url: "https://x.com",19 },20 {21 name: "Steam",22 icon: "fa7-brands:steam",23 url: "https://store.steampowered.com",24 },25 {26 name: "GitHub",27 icon: "fa7-brands:github",28 url: "https://github.com/LyraVoid/Shirone",29 },30 ],31});1import type { ProjectsConfig } from "@/types/projectsConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 项目页行为与展示配置。6 *7 * 遵循「配置管行为,数据管内容」原则:8 * - enable:页面总开关;false 时导航入口同步隐藏,访问 /projects/ 跳转 404;9 * - categories:筛选分类清单(数组顺序即页面顶部 Chips 顺序);10 * - disabledKeys:可选被禁用的项目 key 列表(例如 ["folkpatch"]);11 *12 * 注:项目的具体内容数据(标题、描述、技术栈、链接、封面等)请在 `src/data/projects.ts` 中维护。13 */14export const projectsConfig: ProjectsConfig = withUserConfig("projects", {15 enable: true,16 title: "$t:projects",17 description: "$t:projectsBanner",18 categories: [19 {20 key: "theme",21 label: "Theme",22 icon: "material-symbols:palette-outline-rounded",23 },24 {25 key: "android",26 label: "Android",27 icon: "material-symbols:android-rounded",28 },29 ],30 // disabledKeys: [],31});1# 配置目录约定2
3本目录是 Shirone 全部用户可选配置的唯一入口。约定如下:4
5## 文件组织6
7| 内容 | 位置 | 示例 |8|---|---|---|9| 配置值(带注释的默认值) | `src/config/<domain>Config.ts` | `siteConfig.ts`、`sidebarConfig.ts`、`fabConfig.ts` |10| 配置类型 | `src/types/<domain>Config.ts` | `types/sidebarConfig.ts`、`types/fabConfig.ts` |11
12通用类型(多领域共享,如 `Favicon`、`LIGHT_DARK_MODE`)放在 `src/types/config.ts`。13
14## 导入规则15
161. **消费方统一从 barrel 导入**:`import { siteConfig, fabConfig } from "@/config"`;17 只需要单一领域时可用具体文件:`import { fabConfig } from "@/config/fabConfig"`。182. **禁止相对路径杂写法**:不允许 `../../config`、`../config`、`src/config` 三种历史写法。193. **循环依赖规避**:`i18n/translation.ts` 依赖 `siteConfig`,而 `navBarConfig` 等又消费20 i18n——该类反向依赖模块只允许从具体文件导入(如 `@/config/siteConfig`),21 **禁止走 barrel**,否则形成 `index → navBar → translation → index` 环。224. `astro.config.mjs` 只做接线(`integrations: [shirones()]`),不直接读任何23 `src/config/` 文件;配置的最终装载入口是 `src/integration/index.ts`。245. **`integrationsConfig.ts` 是上述规则的例外**:它被 `src/integration/index.ts`25 按相对路径导入,且**禁止走 barrel**。原因见该文件头部注释——barrel 不会26 拷进用户项目,而包模式必须在构建期把它静态打进 bundle。它的 `import type`27 全部在编译期擦除,不产生运行时依赖。28
29## 配置(Behavior)与数据(Content)分层原则30
31Shirone 遵循「配置管行为,数据管内容」的清晰分层架构:32
33- **`src/config/*Config.ts`**:控制**展示行为与页面能力**(页面总开关 `enable`、分类显示顺序 `categories`、单项禁用列表 `disabledKeys`、排序方向 `order`、源切换与服务凭据);34- **`src/data/*.ts`**:承载**具体的站点内容实体**(项目条目 `projects.ts`、技能清单 `skills.ts`、时间线节点 `timeline.ts`、设备列表 `devices.ts`、友链 `friends.ts`、罗盘 `compass.ts`、番剧 `anime.ts` 与本地音乐 `music.ts`);35- **`src/utils/feature-data.ts`**:提供构建期纯函数,将 config 的过滤/排序等行为规则应用到对应 data 实体集合上,输出给页面/组件。36
37| 判别问题 | 归属 | 处理方式 |38|---|---|---|39| 它控制「页面是否开启 / 排序 / 凭据」? | **Config** | 写在 `src/config/*Config.ts`(如 `enable: boolean`, `categories`, `order`) |40| 它是「站点要展示的具体条目与说明」? | **Data** | 写在 `src/data/*.ts`(如 `ProjectItem[]`, `SkillItem[]`, `TimelineItem[]`) |41| 单项内容的停用 / 过滤? | **Config** | 在 config 中声明 `disabledKeys`(或对应 ID 列表),data 保持纯净内容 |42
43## 新增一个配置项 / 配置文件44
451. 类型定义加入 `src/types/<domain>Config.ts`(新领域则新建文件,字段带中文注释说明语义与默认值);462. 值加入 `src/config/<domain>Config.ts`,保持注释完整——注释是配置的文档;473. 新文件在 `src/config/index.ts` barrel 注册导出;用 `withUserConfig("<domain>", { ... })`48 包住默认值字面量,并在 `scripts/content/config-domains.mjs` 登记该领域(见下文「用户覆盖层」);494. **安全默认与零额外负担**:可选外部服务/重量级特性默认必须为关闭(如 `enable: false`)。在关闭或未配置时,必须满足「零外部请求、零占位 DOM、零性能损耗、零主包膨胀」的零额外负担要求;50 落地做法与验证方法见 `docs/on-demand-loading.md`;515. UI 文案走 `I18nKey` 枚举 + `i18n()`(如 `navBarConfig` 的用法),**不写死字符串**;52 新增 i18n key 必须同步补全 `src/i18n/languages/` 下全部 10 种语言;536. 跑 `npx.cmd astro check` 确认 0 错误 0 警告。54
55## 用户覆盖层(内容仓 `config/*.yaml`)56
57本目录的每个领域配置都把自己的字面量默认值交给 `withUserConfig()`:58
59```typescript60export const siteConfig: SiteConfig = withUserConfig("site", {61 title: "Shirone",62 // ...默认值连同注释一起留在代码仓63});64```65
66`local` 模式下 `withUserConfig()` 原样返回默认值,零开销。`external` 模式下,67内容仓 `config/site.yaml` 里的键会与默认值**深合并**(对象递归合并、数组整体替换)68后返回,合并源是 `pnpm content:sync` 生成的 `src/user/user-config.ts`。69
70因此本目录的定位没有变:**它是默认值与配置文档的唯一真源**,71注释写得越清楚,内容仓那边越不需要猜。契约与 YAML 写法见72[`docs/content-separation/config-overlay.md`](../../docs/content-separation/config-overlay.md)。73
74新增配置领域时,除了本文下方的清单,还要在75`scripts/content/config-domains.mjs` 补一行登记(领域名、YAML 文件名、类型),76它同时驱动生成、校验与 `content:eject` 的起步文件;`tests/content/content-config.test.mjs`77会检查登记表的文件名唯一且为 kebab-case。78
79`navBarConfig` 是唯一不走 `withUserConfig()` 的领域:导航项要引用 `LinkPresets`80并调用 `i18n()`,深合并只会得到一堆未解析的引用,因此它由 `resolveNavBarLinks()`81把内容仓的声明式条目还原成 `NavBarLink`。82
83`config/nav-bar.yaml` 是**整体替换**,不会跟着各功能的 `enable` 走,因此84`navBarConfig` 在任何来源的条目汇合后都会过一遍 `pruneUnavailableNavLinks()`85(`src/utils/nav-utils.ts`):指向已关闭功能页面的入口被裁掉,空掉的下拉分组一并隐藏。86判定按站内路由(去尾斜杠、忽略查询串与哈希),站外链接与锚点不受影响。87
88### 反向导出覆盖层(`content:export --config`)89
90覆盖层是双向的:`pnpm content:export --config` 会求「当前生效配置」与「主题默认值」的差,91把最小覆盖集写回内容仓的 `config/*.yaml`(保留用户已有的注释与格式,只增改不删键)。92它是 `deepMerge` 的精确逆运算,因此喂回 `content:sync` 之后生效配置逐字段不变。93
94典型用途是救援:`content:clean` 会把 `src/user/user-config.ts` 重置成空覆盖层,95在那份生成物里的改动会因此丢失;导出能先把它固化成 YAML。默认只预演,用法与安全机制见96[`docs/content-separation/cli-workflows.md`](../../docs/content-separation/cli-workflows.md)。97
98两条边界要知道:99
100- **`navBar` 不参与导出**。`resolveNavBarLinks()` 的解析不可逆,`config/nav-bar.yaml` 只能手工维护;101- **不会把本目录的源码改动提升成覆盖**。直接修改 `<domain>Config.ts` 里的默认值字面量时,102 「默认值」与「生效值」同步变化,差分为空,那处改动不会进入导出计划。103 这是有意为之:把当前默认值提升成内容仓的永久覆盖,等于把配置冻结在这一版主题上。104 想固化就在 fork 里提交它,或照常在内容仓写一条对应的 YAML 覆盖。105
106领域键与 YAML 文件名一律 kebab-case 对应驼峰:`llms` ↔ `config/llms.yaml` ↔ `llmsConfig`,107`postList` ↔ `config/post-list.yaml` ↔ `postListConfig`。108
109## 现有配置一览110
111| 文件 | 职责 |112|---|---|113| `footerConfig.ts` | 页脚自定义 HTML 注入开关(控制是否读取并注入 `src/config/FooterConfig.html`,关闭时零开销) |114| `siteConfig.ts` | 站点部署 URL / base 路径 / 标题标识 / 语言 / IANA 时区 / HCT 主题色 / 背景纹理系统 / 显示设置浮层开关 / 横幅 / TOC 深度 / 进度条 / favicon(含 `getDefaultStyle` / `getDefaultSpec` / `resolveDisplaySettings` 回退值) |115| `profileConfig.ts` | 博主资料:头像 / 名称 / 简介 / 社交链接 |116| `licenseConfig.ts` | 文章版权声明 |117| `expressiveCodeConfig.ts` | 代码块明暗主题 |118| `navBarConfig.ts` | 导航栏链接(`LinkPresets` 预设表 + 组装) |119| `sidebarConfig.ts` | 侧栏编排与 widget 清单(`arrangement` 单/双栏、`side` 主栏物理侧、widget `column` 分栏标签;判别联合类型见 `types/sidebarConfig.ts`;编排指导见 `docs/sidebar-system.md`,组件文档见 `docs/sidebar-widgets.md`,新增 widget checklist 见 `docs/common-components.md` §3.1) |120| `fabConfig.ts` | 右下角悬浮控制流(FAB)配置:总开关、各操作项(返回顶部、悬浮目录、直达评论、返回首页、自定义操作)、细粒度设备受控矩阵(`devices?: ("mobile" | "tablet" | "desktop")[]`)、页面范围过滤与图标定制;架构见 `docs/fab-system.md` |121| `announcementConfig.ts` | 公告内容(侧栏 announcement widget 消费,text 为空不渲染) |122| `musicConfig.ts` | 侧栏音乐全局配置:总开关(默认关闭)、`provider` 模式切换接口、`defaultVolume` 初始音量与 `defaultMode` 初始播放模式(本地曲目清单维护在 `src/data/music.ts`);与 `sidebarConfig` 的 music 条目共同控制 `MusicSidebar`,详见下文与 `docs/sidebar-widgets.md` |123| `postListConfig.ts` | 文章列表:分页大小 + 布局(list/grid 模式、封面位置、grid 卡片宽度档位) |124| `articleConfig.ts` | 文章详情:最后更新提示、延伸阅读(相关/随机文章抽样)、以及文章尾部分享区块(总开关、海报生成与封面配置) |125| `commentConfig.ts` | 评论系统:全局开关(默认关闭)、Provider 选择(Twikoo / Giscus)、视口懒加载与服务凭据配置;Giscus 基于 GitHub Discussions(需公开仓库 + 安装 giscus App + 从 giscus.app 取 repoId/categoryId),主题明暗双值跟随站点切换 |126| `contextMenuConfig.ts` | 桌面端右键增强:可选开关(当前默认开启);配置允许页面与操作顺序,关闭时零 DOM、零监听器、零客户端资源 |127| `umamiConfig.ts` | Umami 统计:全局开关(默认关闭)、公开分享统计读取,以及可选的官方访问采集脚本配置;支持内容仓 `config/umami.yaml` 覆盖(领域键 `umami`) |128| `integrationsConfig.ts` | 所有模式(源码仓 + npm 包项目)共用的集成选项,唯一装载入口是 `src/integration/index.ts`:swup / astro-icon / expressive-code / svelte / mdx 的选项、`vite.build` 共用部分、`trailingSlash` 与 `image.endpoint.route` 的配对、音乐侧栏虚拟模块 id。**本目录里唯一的例外**:不走 barrel、不经 `withUserConfig`、也不被 `loadConfigModule` 动态加载(包模式在构建期把它打进 `dist/index.js`),所以它没有用户覆盖层,用户项目里的那份拷贝是死的 |129| `sitemapFilter.ts` | 由 `*Config.enable === false` 推导被关闭的页面清单,供 `sitemap()` 的 `filter` 排除它们。包模式通过 `loadConfigModule` 加载,用户可自行覆盖 |130| `skillsConfig.ts` | 技能页行为控制:页面总开关、分类清单与单项禁用列表(技能内容维护在 `src/data/skills.ts`);关闭页面时导航入口同步隐藏 |131| `projectsConfig.ts` | 项目页行为控制:页面总开关、分类清单与单项禁用列表(项目内容维护在 `src/data/projects.ts`);关闭页面时导航入口同步隐藏 |132| `timelineConfig.ts` | 时间线页行为控制:页面总开关、分类清单、排序方向与单项禁用列表(时间线内容维护在 `src/data/timeline.ts`);关闭页面时导航入口同步隐藏 |133| `devicesConfig.ts` | 设备页行为控制:页面总开关、场景分类清单与单项禁用列表(设备清单维护在 `src/data/devices.ts`);关闭页面时导航入口同步隐藏 |134| `gamesConfig.ts` | 游戏页行为控制:页面总开关、游戏分类清单与单项禁用列表(游戏清单维护在 `src/data/games.ts`,封面支持 src/assets 相对路径、/public 绝对路径与远程 URL);关闭页面时导航入口同步隐藏 |135| `animeConfig.ts` | 番剧页与外部追番数据源:数据源选择(本地 / Bangumi 快照 / Bilibili 快照)、失败降级、提供方凭据环境配置与快照生命周期管理(本地番剧维护在 `src/data/anime.ts`) |136| `insightsConfig.ts` | 顶栏「站点概览」浮层(统计 + 日历):总开关 `enable` 与 `stats`/`calendar` 两个子开关;鼠标悬停入口即展开。关闭时顶栏零 DOM、零取数、零脚本,侧栏同名挂件(sidebarConfig)不受影响 |137| `llmsConfig.ts` | 大语言模型与 AI 友好内容系统:`/llms.txt`(索引)与 `/llms-full.txt`(全量正文汇编)静态端点生成控制、加密文章过滤、排除标签与自定义章节配置;支持内容仓 `config/llms.yaml` 覆盖(领域键 `llms`) |138| `seriesConfig.ts` | 系列连载:`/series/` 索引与 `/series/<slug>/` 详情页总开关(默认开启)、索引页 `title`/`description`(`$t:` i18n 引用或字面量,描述留空用动态汇总)、文章内系列块位置 `cardPosition: "top" \| "bottom"`(top = 元信息区下方、正文之前;bottom = 正文之后、版权/分享/延伸阅读之前);支持内容仓 `config/series.yaml` 覆盖(领域键 `series`)。关闭时系列页 404、导航/侧栏/sitemap 入口裁剪、文章内系列卡消失(系列实体与文章 frontmatter 见 `shirone-writing` 与内容分离文档) |139
140非首页 Banner 的标题、说明和可选日期由各页面通过 `MainGridLayout` 提供,并在 Swup 导航后从被替换的主内容容器同步。该上下文默认显示、不设配置开关;说明为空或与标题相同时自动省略,移动端非首页仍沿用紧凑布局并隐藏 Banner。141
142## 侧栏编排与页框宽度143
144- `arrangement: "single"`(默认)——全部 widget 渲染进唯一侧栏,页框 85rem;145- `arrangement: "dual"`——`column: "secondary"` 的 widget 进入副栏(视口 ≥ 1280px 起三列),146 其余留在主栏;页框自动放宽到 96rem,TOC 悬浮 rail 自动让位(右侧余量被副栏占据)。147 1280px 以下自动退化为单栏(只显主栏),无需配置。148- `side: "left" | "right"` 决定主栏物理侧,dual 线下副栏落在对面。149- 页框宽度用 `resolvePageWidth()`(`src/utils/responsive-utils.ts`)按编排自动解析,150 常量在 `src/constants/constants.ts`(`PAGE_WIDTH` / `PAGE_WIDTH_DUAL`),不提供手动覆盖。151
152## 右下角悬浮控制流(FAB)配置契约153
154`fabConfig.ts` 控制右下角悬浮操作栏的呈现与交互:155
156```typescript157export const fabConfig: FabConfig = {158 enable: true,159 items: [160 {161 type: "top",162 enable: true,163 icon: "material-symbols:keyboard-arrow-up-rounded",164 // 未指定 devices 时全设备生效;滚过横幅高度阈值后平滑浮现165 },166 {167 type: "toc",168 enable: true,169 icon: "material-symbols:format-list-bulleted-rounded",170 pages: ["post"],171 // 桌面端侧栏已有粘性 TOC,故默认仅在移动端与平板端显示悬浮目录172 devices: ["mobile", "tablet"],173 },174 {175 type: "comment",176 enable: true,177 icon: "material-symbols:comment-outline-rounded",178 pages: ["post"],179 devices: ["mobile", "tablet"],180 },181 {182 type: "home",183 enable: false,184 icon: "material-symbols:home-outline-rounded",185 },186 ],187};188```189
190### 核心规则与受控特性191
1921. **细粒度设备受控(`devices`)**:193 - `"mobile"`(< 768px)194 - `"tablet"`(768px ~ 1023px)195 - `"desktop"`(≥ 1024px)196 - 省略时默认所有设备均允许;SSR 阶段直接输出 Tailwind CSS 响应式类(如 `flex lg:hidden`),零首屏闪烁,CLS = 0。1972. **页面范围过滤(`pages`)**:198 - 过滤逻辑与侧栏 `SidebarPage` 统一,通过 `#swup-container` 的 `data-current-page` 在 Swup 站内导航时联动隐藏/显示。1993. **评论按钮零额外负担**:200 - 全局未开启评论系统(`commentConfig.enable: false`)或当前文章关闭评论时,评论 FAB 按钮产物为 0 DOM,零多余外链请求与布局偏移。2014. **无音乐挂件(保持纯粹)**:202 - FAB 控制流不集成音乐播放器,避免与侧边栏 `MusicSidebar` 产生双重状态混乱及包体积膨胀。203
204## 侧栏音乐启用契约与四种模式205
206侧栏音乐是默认关闭的可选能力,必须同时满足以下三项才加载并渲染:207
2081. `musicConfig.enable` 为 `true`;2092. 对应数据源(本地 `src/data/music.ts`、自定义 `tracks` 或 `meting` 远端歌单)至少包含一首有效曲目/合法歌单 ID;2103. `sidebarConfig.components` 中 `type: "music"` 的条目存在且 `enable: true`(默认条目为 `false`)。211
212### 四种数据源工作模式(`provider`)213
214| 模式 | 配置名 | 数据源 | 特点与适用场景 |215|---|---|---|---|216| **本地模式** | `"local"` | `src/data/music.ts` | 默认模式。零外部 API 依赖,构建期静态打包,首屏毫秒级就绪,支持离线/断网播放。 |217| **自定义列表** | `"custom"` | `musicConfig.tracks` | 灵活自定义。直接在配置中传入曲目数组(支持外链音频与封面),无需修改通用数据文件。 |218| **云端歌单** | `"meting"` | `musicConfig.meting` | 接入 Meting API(网易云、QQ 音乐、酷狗等),客户端异步按需拉取,曲目与封面自动清洗加载。 |219| **混合增强模式** | `"mixed"` | 本地数据 + Meting API | **推荐**。首屏立即播放本地音乐,后台无感拉取远端歌单并在就绪后无缝追加合并;若遇断网或云端接口故障,自动静默降级为本地曲目,绝不破版。 |220
221任一条件不满足时,音乐功能不得输出 DOM 或样式,不得请求音频/封面等资源,也不得把播放器代码或依赖带入主 bundle。配置消费者应先完成三项校验,再动态加载 `MusicSidebar`;不能用隐藏空卡片代替短路。222
223`defaultVolume` 与 `defaultMode` 只定义播放器首次初始化的音量和播放模式。播放器挂载后由持久侧栏运行时持有当前曲目、播放位置、音量与模式,Swup 站内导航不应重新读取默认值或重建播放器。1import type { SeriesConfig } from "../types/seriesConfig.ts";2import { withUserConfig } from "../utils/config-overlay.ts";3
4export const seriesConfig: SeriesConfig = withUserConfig("series", {5 enable: true,6 title: "$t:series",7 // 空 = 使用动态汇总(「x 个系列 · y 篇文章」)8 description: "",9 cardPosition: "bottom",10});1/**2 * 侧边栏布局配置(数据驱动编排)。3 *4 * 【核心概念】5 * 1. arrangement(侧栏编排模式):6 * - "single"(单栏,默认):所有 widget 放入主侧栏,适合紧凑布局(页框 85rem);7 * - "dual"(双栏):column: "secondary" 的 widget 放入副侧栏(视口 ≥ 1280px 展开三列,页框 96rem),8 * 在 1024px~1279px 之间会自动优雅退化为单栏,无需手动适配。9 * 2. side(主栏物理位置):10 * - "left":主侧栏在左侧(默认),dual 模式下副栏自动落右侧;11 * - "right":主侧栏在右侧,dual 模式下副栏落左侧。12 * 3. widget 属性:13 * - type:组件类型("profile" | "music" | "announcement" | "categories" | "tags" | "series" | "stats" | "calendar" | "toc");14 * - enable:是否启用该 widget;15 * - slot:"top"(固定在顶部)| "sticky"(页面滚动时吸顶跟随);16 * - column:"primary"(主栏,默认)| "secondary"(副栏,仅在 arrangement: "dual" 时生效);17 * - pages:仅在指定页面展示(如 ["home", "post"],省略时默认全页面展示);18 * - collapseAfter:折叠阈值(适用于 categories/tags/series,超出条数显示展开按钮)。19 *20 * 类型定义见 src/types/sidebarConfig.ts。21 */22import type { SidebarConfig } from "@/types/sidebarConfig";23import { withUserConfig } from "../utils/config-overlay.ts";24
25export const sidebarConfig: SidebarConfig = withUserConfig("sidebar", {26 enable: true,27 arrangement: "single",28 side: "left",29 components: [30 // 资料卡(头像 + 站名 + 简介 + 社交图标):站主决定先不在站点页面显示,31 // 预留给「个人页面」使用。要恢复只需把 enable 改回 true。32 { type: "profile", enable: false, slot: "top" },33 // 站主要求:音乐播放器从侧栏移出,改为「可拖拽悬浮小球」+「顶栏 hover 展开面板」。34 // 侧栏这一项保留但关闭,要回退只需改回 true。35 { type: "music", enable: false, slot: "top" },36 { type: "announcement", enable: true, slot: "top", pages: ["home"] },37 // 站主要求:分类 / 系列 / 标签 在所有站点页面都不显示(个人博客只展示文章列表)。38 // 要恢复只需把各自的 enable 改回 true。39 {40 type: "categories",41 enable: false,42 slot: "sticky",43 collapseAfter: 5,44 pages: [45 "home",46 "archive",47 "friends",48 "moments",49 "anime",50 "compass",51 "skills",52 "projects",53 "devices",54 "games",55 "timeline",56 "albums",57 "about",58 "guestbook",59 "post",60 ],61 },62 {63 type: "series",64 enable: false,65 slot: "sticky",66 collapseAfter: 5,67 pages: [68 "home",69 "archive",70 "friends",71 "moments",72 "anime",73 "compass",74 "skills",75 "projects",76 "devices",77 "games",78 "timeline",79 "albums",80 "about",81 "guestbook",82 "post",83 ],84 },85 {86 type: "tags",87 enable: false,88 slot: "sticky",89 collapseAfter: 6,90 pages: [91 "home",92 "archive",93 "friends",94 "moments",95 "anime",96 "compass",97 "skills",98 "projects",99 "devices",100 "games",101 "timeline",102 "albums",103 "about",104 "guestbook",105 "post",106 ],107 },108 // 站主要求:统计与日历从侧栏移到顶栏「站点概览」浮层(鼠标悬停展开)。109 // 侧栏这两项保留但关闭,要回退只需把 enable 改回 true;110 // 顶栏那份由 src/config/insightsConfig.ts 独立控制,两条路径互不依赖。111 {112 type: "stats",113 enable: false,114 slot: "top",115 column: "secondary",116 pages: ["home", "archive", "categories", "tags"],117 },118 { type: "calendar", enable: false, slot: "top", column: "secondary" },119 // 文章目录。column 是 "primary" 而不是 "secondary":single 编排下副栏整列120 // 不渲染,挂在副栏的 widget 会**连带消失**——切 single 时文章页的目录就121 // 这样丢过一次(实测 #toc 直接不在 DOM 里)。目录是文章页的核心导航,122 // 留在主栏;若将来改回 dual,可自行决定是否挪去副栏。123 {124 type: "toc",125 enable: true,126 slot: "sticky",127 column: "primary",128 /*129 * 【★ 2026-10-13 加 knowledge —— 站主要求】130 *131 * 站主原话:「在我的认知里面知识点不应该是这样子的吗,有目录有内容,还可以切换…132 * 这里的目录我不要求图一这样,但**至少有图二的效果**」。133 *134 * 图二是文章页的目录(带当前节高亮、随滚动更新)—— 而原先这里只有 ["post"],135 * 所以只有文章页有目录、知识点页没有。知识点正文往往有好几个小节,136 * 没有目录就只能一路滚,与站主的预期不符。137 */138 pages: ["post", "knowledge"],139 },140 ],141});1import type { SiteConfig } from "@/types/config";2import type {3 ResolvedTextureOptions,4 TextureConfig,5} from "@/types/textureConfig";6import { withUserConfig } from "../utils/config-overlay.ts";7
8/**9 * 站点核心配置:标题 / 语言 / 主题色(HCT 动态配色)/ 横幅 / 目录 / 进度条 / favicon。10 * 类型见 src/types/config.ts。11 */12export const siteConfig: SiteConfig = withUserConfig("site", {13 site: "https://www.dafeiyu.site/",14 base: "/",15 title: "蓝色大肥鱼",16 subtitle: "DeepSeek鲸鱼娘的个人博客",17 // 电脑端顶栏标题与导航内容区域:"left" 左对齐,"center" 居中。18 topAppBar: {19 contentAlign: "center",20 },21 // 显示设置面板控制:配置各项前端切换项的可见性(默认全部开启)。22 displaySettings: {23 colorStyle: true, // 是否展示配色风格 9 宫格24 colorSpec: true, // 是否展示 Color Spec 调色规范切换25 wallpaperMode: true, // 是否展示页面背景(纯色/横幅)切换26 layoutMode: true, // 是否展示文章列表布局(列表/网格)切换27 reduceMotion: true, // 是否展示减少动效切换28 texture: true, // 是否展示背景纹理选择29 },30 lang: "zh_CN", // Language code, e.g. 'en', 'zh_CN', 'ja', etc.31 // IANA time zone for precise post and moment timestamps. It is independent of lang.32 timeZone: "Asia/Shanghai",33 themeColor: {34 hue: 218, // Default hue 0-360. 蓝白手绘风取 218(蓝);315 粉紫 / 262 紫 也可选35 fixed: false, // Hide the theme color picker for visitors36 // Dynamic Material 3 palette style (TonalSpot/Vibrant/Content/Expressive/Rainbow/FruitSalad/Monochrome/Neutral/Fidelity)37 style: "tonalSpot",38 // Design spec version: "2021" (MD3) or "2025" (M3 Expressive)。角色集一致,39 // 差异仅在调色板派生(库的 colorSpec 静态为 2025 委托)40 spec: "2025",41 },42 // 默认页面背景模式:"banner" 使用壁纸横幅,"none" 使用主题纯色。43 // 访客在“显示设置”中的选择会保存在浏览器中,并覆盖这里的默认值。44 wallpaperMode: {45 defaultMode: "banner",46 },47 // 页面背景纹理系统配置(5 大精美预设 + 零开销 HCT 动态取色)48 texture: {49 enable: true, // 是否启用背景纹理系统50 defaultPreset: "grid-paper", // 默认纹理预设:"none" | "starlight" | "cyber-dots" | "topography" | "geometric" | "sakura" | "grid-paper"51 defaultOpacity: 0.5, // 默认纹理浓度 (0.05 ~ 0.85)52 allowMotion: true, // 是否允许背景微动效(开启 reduced-motion 时自动静止)53 },54 banner: {55 // 推荐将图片放入 src/assets,并填写相对 src 的路径,以启用构建期 AVIF/WebP 响应式优化。56 // 以 "/" 开头的 public 路径与远程 URL 仍可用,但会保留原图、不生成候选。57 // desktop 用于 >= 1024px;mobile 仅用于 < 1024px 的首页,手机非首页不显示壁纸。58 // 数组顺序就是轮播顺序;只需要静态 Banner 时,每组保留一张图片即可。59 src: {60 desktop: ["assets/images/banner/desktop/1.webp"],61 mobile: ["assets/images/banner/mobile/1.webp"],62 },63 // 图片裁切焦点:"top"、"center" 或 "bottom"。64 position: "center",65 dim: {66 // 在图片上覆盖黑色遮罩以提高标题和顶部栏的对比度;opacity 范围为 0-1。67 enable: true,68 opacity: 0.24,69 },70 homeText: {71 // 仅在首页 Banner 中显示,标题与副标题会上下居中排列。72 enable: true,73 title: "蓝色大肥鱼",74 subtitle: [75 "收好每一张有趣的图",76 "猫鲸的日常,小小一只,表情很多",77 "蓝色大肥鱼 · DeepSeek鲸鱼娘的个人博客",78 "表情包、随笔、技术笔记,想到什么写什么",79 "找到今天的心情",80 ],81 typewriter: {82 // 副标题逐字显示;关闭后直接显示完整副标题。83 enable: true,84 // 打字速度(每个字符间隔,毫秒)。85 speed: 100,86 // 回退反向删除速度(每个字符间隔,毫秒)。87 deleteSpeed: 50,88 // 打字完成后停顿时间,单位为毫秒。89 pauseTime: 2000,90 // 完成后是否循环播放;关闭表示只播放一次。91 loop: true,92 },93 },94 carousel: {95 // 是否开启多张图片自动轮播;多张图片时生效,单张图片时自动降级为静态展示。96 enable: true,97 // 轮播切换间隔时间(毫秒),运行时最小值限制为 3000ms。98 interval: 6000,99 // 交叉淡入淡出(Crossfade)过渡时长(毫秒,默认 1200ms)。100 fadeDuration: 1200,101 // 运镜呼吸动画模式:"ken-burns"(默认,循环运镜)| "zoom-in"(推进)| "zoom-out"(拉远)| "pan-left"(左移)| "pan-right"(右移)| "none"(无运镜)。102 animation: "ken-burns",103 },104 waves: {105 // 在 Banner 底部渲染页面背景色水波纹;关闭后不输出波浪 DOM。106 enable: true,107 },108 },109 // Markdown 正文图片处理;仅匹配远程图片,不会产生额外网络请求或客户端代码。110 imageOptimization: {111 // 为需要防盗链兼容的图片 CDN 添加 referrerpolicy="no-referrer",支持通配符。112 noReferrerDomains: ["*.hdslb.com"],113 },114 toc: {115 enable: true, // Display the table of contents on the right side of the post116 depth: 2, // Maximum heading depth to show in the table, from 1 to 3117 },118 progressIndicator: {119 // 进度条预设样式:dual 双向扫描(官方默认双线)/ single 单向扫描(单线)120 style: "dual",121 },122 favicon: [123 // 浏览器标签页图标,路径相对于 public 目录。124 { src: "/logo/icon.webp" },125 ],126});127
128/**129 * 解析并返回背景纹理配置选项(包含关闭短路与 0 开销优化判定)130 */131export function resolveTextureOptions(132 config: boolean | TextureConfig | undefined = siteConfig.texture,133 displaySettingsTexture: boolean = siteConfig.displaySettings?.texture ?? true,134): ResolvedTextureOptions {135 if (config === false || config === undefined) {136 return {137 enable: false,138 defaultPreset: "none",139 defaultOpacity: 0.12,140 allowMotion: false,141 };142 }143
144 if (config === true) {145 return {146 enable: true,147 defaultPreset: "grid-paper",148 defaultOpacity: 0.5,149 allowMotion: true,150 };151 }152
153 const enable = config.enable ?? true;154 const defaultPreset = config.defaultPreset ?? "grid-paper";155 const defaultOpacity = config.defaultOpacity ?? 0.5;156 const allowMotion = config.allowMotion ?? true;157
158 // 性能短路优化:159 // 如果配置 enable: false,或者 defaultPreset: "none" 且显示设置面板未允许切换(访客也无法开启),160 // 则自动视为完全关闭以达成零 DOM、零 CSS、零运行时代价。161 const effectiveEnable =162 enable && (defaultPreset !== "none" || displaySettingsTexture);163
164 return {165 enable: effectiveEnable,166 defaultPreset,167 defaultOpacity,168 allowMotion,169 };170}171
172/** 站点默认配色风格(访客未做选择时的回退值) */173export function getDefaultStyle(): string {174 return siteConfig.themeColor.style;175}176
177/** 站点默认 Color Spec(2021 / 2025) */178export function getDefaultSpec(): string {179 return siteConfig.themeColor.spec;180}181
182/** 解析并返回显示设置面板各项开关(未配置时默认 true) */183export function resolveDisplaySettings(): {184 colorStyle: boolean;185 colorSpec: boolean;186 wallpaperMode: boolean;187 layoutMode: boolean;188 reduceMotion: boolean;189 texture: boolean;190} {191 const cfg = siteConfig.displaySettings;192 const textureOpts = resolveTextureOptions(193 siteConfig.texture,194 cfg?.texture ?? true,195 );196 return {197 colorStyle: cfg?.colorStyle ?? true,198 colorSpec: cfg?.colorSpec ?? true,199 wallpaperMode: cfg?.wallpaperMode ?? true,200 layoutMode: cfg?.layoutMode ?? true,201 reduceMotion: cfg?.reduceMotion ?? true,202 texture: textureOpts.enable && (cfg?.texture ?? true),203 };204}1import { aboutConfig } from "./aboutConfig.ts";2import { albumsConfig } from "./albumsConfig.ts";3import { animeConfig } from "./animeConfig.ts";4import { compassConfig } from "./compassConfig.ts";5import { devicesConfig } from "./devicesConfig.ts";6import { friendsConfig } from "./friendsConfig.ts";7import { gamesConfig } from "./gamesConfig.ts";8import { momentsConfig } from "./momentsConfig.ts";9import { projectsConfig } from "./projectsConfig.ts";10import { seriesConfig } from "./seriesConfig.ts";11import { skillsConfig } from "./skillsConfig.ts";12import { timelineConfig } from "./timelineConfig.ts";13
14/** 获取所有被配置 enable: false 关闭的页面标识清单 */15export function getDisabledPages(): string[] {16 const disabled: string[] = [];17 if (skillsConfig.enable === false) disabled.push("skills");18 if (projectsConfig.enable === false) disabled.push("projects");19 if (timelineConfig.enable === false) disabled.push("timeline");20 if (devicesConfig.enable === false) disabled.push("devices");21 if (gamesConfig.enable === false) disabled.push("games");22 if (animeConfig.enable === false) disabled.push("anime");23 if (aboutConfig.enable === false) disabled.push("about");24 if (friendsConfig.enable === false) disabled.push("friends");25 if (momentsConfig.enable === false) disabled.push("moments");26 if (albumsConfig.enable === false) disabled.push("albums");27 if (compassConfig.enable === false) disabled.push("compass");28 if (seriesConfig.enable === false) disabled.push("series");29 return disabled;30}31
32/**33 * 校验页面路径是否应当被收录进 sitemap。34 * 排除被关闭页面(例如 /skills/, /skills/index.html 等)以及 404 跳转存根。35 */36export function isSitemapPageAllowed(pageUrl: string): boolean {37 const disabled = getDisabledPages();38 for (const p of disabled) {39 if (40 pageUrl.endsWith(`/${p}/`) ||41 pageUrl.endsWith(`/${p}`) ||42 pageUrl.includes(`/${p}/`)43 ) {44 return false;45 }46 }47 return true;48}1import type { SkillsConfig } from "@/types/skillsConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 技能页行为与展示配置。6 *7 * 遵循「配置管行为,数据管内容」原则:8 * - enable:页面总开关;false 时导航入口同步隐藏,访问 /skills/ 跳转 404;9 * - categories:筛选分类清单(数组顺序即页面顶部 Chips 顺序);10 * - disabledNames:可选被禁用的技能名称列表(例如 ["PHP"]);11 *12 * 注:技能的具体内容数据(技能名称、熟练度等级、图标、描述等)请在 `src/data/skills.ts` 中维护。13 */14export const skillsConfig: SkillsConfig = withUserConfig("skills", {15 enable: true,16 title: "$t:skills",17 description: "$t:skillsBanner",18 categories: [19 {20 key: "frontend",21 label: "Frontend",22 icon: "material-symbols:web-rounded",23 },24 {25 key: "backend",26 label: "Backend",27 icon: "material-symbols:dns-rounded",28 },29 {30 key: "tooling",31 label: "Tooling",32 icon: "material-symbols:construction-rounded",33 },34 ],35 // disabledNames: [],36});1import type { TimelineConfig } from "@/types/timelineConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * 时间线页行为与展示配置。6 *7 * 遵循「配置管行为,数据管内容」原则:8 * - enable:页面总开关;false 时导航入口同步隐藏,访问 /timeline/ 跳转 404;9 * - categories:筛选分类清单(数组顺序即页面顶部 Chips 顺序);10 * - order:排序方向,默认为 "desc"(时间倒序,最新在前);可选 "asc"(正序);11 * - disabledTitles:可选被禁用的事件标题列表;12 *13 * 注:时间线的具体节点数据(标题、日期、经历描述、要点列表、关联链接等)请在 `src/data/timeline.ts` 中维护。14 */15export const timelineConfig: TimelineConfig = withUserConfig("timeline", {16 enable: true,17 title: "$t:timeline",18 description: "$t:timelineBanner",19 categories: [20 {21 key: "milestone",22 label: "Milestones",23 icon: "material-symbols:flag-rounded",24 },25 {26 key: "project",27 label: "Projects",28 icon: "material-symbols:code-rounded",29 },30 {31 key: "career",32 label: "Career",33 icon: "material-symbols:work-rounded",34 },35 {36 key: "education",37 label: "Education",38 icon: "material-symbols:school-rounded",39 },40 {41 key: "life",42 label: "Life",43 icon: "material-symbols:favorite-rounded",44 },45 ],46 order: "desc",47 // disabledTitles: [],48});1import type { ResolvedUmamiOptions, UmamiConfig } from "@/types/umamiConfig";2import { withUserConfig } from "../utils/config-overlay.ts";3
4/**5 * Umami 统计配置单一真源(由 oddmisc 提供)。6 *7 * 遵循「零额外负担」原则:默认全局关闭(enable: false),8 * 在未开启时不产生任何外部网络请求、零额外 DOM 占位与零包体积膨胀。9 *10 * 详细用法见:`docs/umami-guide.md`11 */12export const umamiConfig: UmamiConfig = withUserConfig("umami", {13 /** 全局 Umami 统计总开关:false 时完全不加载 oddmisc 运行时脚本与 DOM */14 enable: false,15 /** Umami 分享链接(必填) */16 shareUrl: "",17 /** Umami Website ID;与 scriptUrl 同时填写时启用访问采集 */18 websiteId: "",19 /** Umami 采集脚本 URL;与 websiteId 同时填写时启用访问采集 */20 scriptUrl: "",21});22
23/**24 * 解析并校验 Umami 配置。未启用或关键参数缺失时返回 null。25 */26export function resolveUmamiOptions(config: UmamiConfig): ResolvedUmamiOptions {27 if (!config.enable) {28 return null;29 }30 const shareUrl = config.shareUrl?.trim();31 if (!shareUrl) {32 return null;33 }34 return {35 shareUrl,36 websiteId: config.websiteId?.trim() || undefined,37 scriptUrl: config.scriptUrl?.trim() || undefined,38 };39}40
41export type { ResolvedUmamiOptions };第 3 篇,共 3 篇·本文属于系列
Shirone Markdown 扩展
https://www.dafeiyu.site/posts/markdown-enhancements/分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
沿着主题读
基于共同的标签与分类
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 71 天
部分内容可能已过时

评论
0 条评论