19816 字
99 分钟
页面浏览--·访问次数--
代码树
代码树是一个将「左侧文件树导航」与「右侧代码高亮展示」无缝结合的高级复合组件,非常适合展示多文件工程示例与组件封装结构。
容器语法#
在 code-tree 容器内包裹多个带有 title 属性的代码块:
1::: code-tree{title="按钮组件封装" entry="Button.svelte" height="380px"}2
3```svelte title="Button.svelte"4<script lang="ts">5 export let variant: 'primary' | 'secondary' = 'primary';6</script>7
8<button class="btn btn-{variant}">9 <slot />10</button>11```12
13```ts title="types.ts"14export type ButtonVariant = 'primary' | 'secondary';15```16
17:::渲染效果:
::: code-tree{title=“按钮组件封装” entry=“Button.svelte” height=“380px”}
1<script lang="ts">2 export let variant: 'primary' | 'secondary' = 'primary';3</script>4
5<button class="btn btn-{variant}">6 <slot />7</button>1export type ButtonVariant = 'primary' | 'secondary';:::
每个代码块的 title 就是它在左侧树中的文件名,entry 指定首次加载时默认激活的文件,height 控制整体高度。
用嵌套路径表达多层目录#
代码块的 title 支持用 / 书写层级,左侧会自动生成对应的目录树:
1::: code-tree{title="配置叠加示例" entry="user-config.ts" height="420px"}2
3```ts title="src/user/user-config.ts"4import site from '../../config/site.yaml';5export default { site };6```7
8```yaml title="config/site.yaml"9site: https://example.com/10title: 我的博客11```12
13```ts title="src/config/siteConfig.ts"14export const siteConfig = {15 base: '/',16 trailingSlash: 'always',17};18```19
20:::渲染效果:
::: code-tree{title=“配置叠加示例” entry=“src/user/user-config.ts” height=“420px”}
1import site from '../../config/site.yaml';2export default { site };1site: https://example.com/2title: 我的博客1export const siteConfig = {2 base: '/',3 trailingSlash: 'always',4};:::
本地目录直接导入语法#
Shirone 支持直接通过文件路径导入本地工程目录生成代码树:
1@[code-tree title="站点基础配置" entry="siteConfig.ts"](/src/config)渲染效果:
站点基础配置
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 source: {49 kind: "local",50 // provider: "bangumi",51 // file: "bangumi.json",52 // fetchOnDev: true,53 },54
55 /** 异常降级策略(快照丢失或解析失败时回退本地数据) */56 fallback: {57 kind: "local",58 },59
60 /** 外部提供方配置 */61 providers: {62 bangumi: {63 enable: false,64 userId: "", // 填入你的 Bangumi 数字 UID 或公开用户名(测试可填 "sai")65 request: {66 pageSize: 50,67 maxItems: 300,68 minDelayMs: 200,69 },70 },71 bilibili: {72 enable: false,73 vmid: "", // 填入你的 B 站公开 UID74 sessdataEnv: "BILI_SESSDATA",75 cover: {76 mode: "local", // "local" 站内下载缓存(推荐)| "remote" 远程链接 | "none"77 useWebp: true,78 },79 request: {80 pageSize: 30,81 maxItems: 300,82 minDelayMs: 300,83 },84 },85 },86
87 /** 快照存储管理 */88 snapshot: {89 directory: "src/data/anime-snapshots",90 staleAfterDays: 30,91 keepLastValid: true,92 },93});94
95const SAFE_FILENAME_PATTERN = /^[a-zA-Z0-9_-]+\.json$/;96
97/**98 * 校验并解析 Anime 配置,返回只读的标准选项99 */100export function resolveAnimeOptions(config: AnimeConfig): ResolvedAnimeOptions {101 const enable = Boolean(config.enable);102 const fallback: AnimeFallbackKind =103 config.fallback?.kind === "empty" ? "empty" : "local";104
105 const directory =106 typeof config.snapshot?.directory === "string" &&107 config.snapshot.directory.trim() &&108 !config.snapshot.directory.includes("..")109 ? config.snapshot.directory.trim().replace(/[\\/]+$/, "")110 : "src/data/anime-snapshots";111
112 const staleAfterDays =113 typeof config.snapshot?.staleAfterDays === "number" &&114 Number.isFinite(config.snapshot.staleAfterDays) &&115 config.snapshot.staleAfterDays > 0116 ? Math.floor(config.snapshot.staleAfterDays)117 : 30;118
119 const keepLastValid = config.snapshot?.keepLastValid ?? true;120
121 const rawKind = config.source?.kind;122 let kind: AnimeSourceKind = "local";123 let provider: AnimeProvider | undefined;124 let file: string | undefined;125
126 if (rawKind === "snapshot") {127 const rawProvider = config.source?.provider;128 if (rawProvider === "bangumi" || rawProvider === "bilibili") {129 provider = rawProvider;130 }131
132 const rawFile = config.source?.file?.trim();133 if (134 rawFile &&135 SAFE_FILENAME_PATTERN.test(rawFile) &&136 // 若指定了 provider 但 file 误填了另一 provider 的 json,自动校正为对应 provider 的 json 文件137 !(provider === "bilibili" && rawFile === "bangumi.json") &&138 !(provider === "bangumi" && rawFile === "bilibili.json")139 ) {140 file = rawFile;141 } else if (provider) {142 file = `${provider}.json`;143 }144
145 const fetchOnDev = config.source?.fetchOnDev ?? true;146
147 if (file) {148 kind = "snapshot";149 }150
151 return Object.freeze({152 enable,153 source: Object.freeze({154 kind,155 ...(provider ? { provider } : {}),156 ...(file ? { file } : {}),157 fetchOnDev,158 }),159 fallback,160 snapshot: Object.freeze({161 directory,162 staleAfterDays,163 keepLastValid,164 }),165 });166 }167
168 return Object.freeze({169 enable,170 source: Object.freeze({171 kind,172 ...(provider ? { provider } : {}),173 ...(file ? { file } : {}),174 fetchOnDev: config.source?.fetchOnDev ?? true,175 }),176 fallback,177 snapshot: Object.freeze({178 directory,179 staleAfterDays,180 keepLastValid,181 }),182 });183}184
185export const resolvedAnimeOptions: ResolvedAnimeOptions =186 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: "The only way to do great work is to love what you do", // 公告内容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 type {2 CommentConfig,3 GiscusConfig,4 TwikooConfig,5} from "@/types/commentConfig";6import { withUserConfig } from "../utils/config-overlay.ts";7
8/**9 * 评论系统配置单一真源。10 *11 * 遵循「零额外负担」原则:默认全局关闭(enable: false),12 * 在未开启时不产生任何外部网络请求、零额外 DOM 占位与零包体积膨胀。13 *14 * 【开启 Twikoo 评论配置步骤】15 * 1. 部署 Twikoo 服务端并获取环境 ID(腾讯云 CloudBase / Vercel / Railway / 私有部署等);16 * 2. 将 `enable` 置为 `true`,并将 `provider` 设置为 `"twikoo"`;17 * 3. 填入你的 `twikoo.envId`;18 * 4. (可选)自定义 `scriptUrl`(如使用自建 CDN 或官方 unpkg/jsdelivr 源)。19 *20 * 【开启 Giscus 评论配置步骤】21 * 1. 准备一个公开 GitHub 仓库,并在仓库设置中开启 Discussions 功能;22 * 2. 安装 giscus App(https://github.com/apps/giscus)到该仓库;23 * 3. 在 https://giscus.app 按引导选择仓库与 Discussion 分类,24 * 取生成的 `data-repo-id` 与 `data-category-id`;25 * 4. 将 `enable` 置为 `true`,并将 `provider` 设置为 `"giscus"`,26 * 填入 `giscus.repo` / `giscus.repoId` / `giscus.categoryId` 三个必填字段;27 * 5. (可选)调整 `mapping`、`reactionsEnabled`、`inputPosition`、28 * `theme.light` / `theme.dark`(giscus 主题键或自定义主题 CSS URL)、29 * `lang` 与 `scriptUrl`(自托管 giscus 时替换)。30 */31export const commentConfig: CommentConfig = withUserConfig("comment", {32 /** 全局评论总开关:false 时完全不加载评论脚本与 DOM */33 enable: false,34 /** 评论提供商类型:"none" | "twikoo" | "giscus" */35 provider: "none",36 /** 是否开启视口懒加载:滚动进入视口才动态加载评论组件(推荐 true) */37 lazy: true,38 /** Twikoo 专有配置 */39 twikoo: {40 /** Twikoo 环境 ID(如 "https://your-twikoo.vercel.app" 或腾讯云环境 ID) */41 envId: "",42 /** Twikoo 前端 JS 脚本 CDN 地址 */43 scriptUrl: "https://cdn.jsdelivr.net/npm/twikoo@1.7.20/dist/twikoo.min.js",44 /** 评论语言:"auto"(跟随站点)| "zh-CN" | "zh-TW" | "en" | "ja" 等 */45 lang: "auto",46 /** 评论输入框占位提示文本 */47 placeholder: "Share your thoughts...",48 },49 /** Giscus 专有配置(基于 GitHub Discussions,评论数据存储在公开仓库中) */50 giscus: {51 /** 公开仓库,格式 "owner/repo"(必填) */52 repo: "",53 /** 仓库 ID,从 giscus.app 配置生成器获取(必填) */54 repoId: "",55 /** Discussion 分类名,如 "Announcements";留空表示不限制分类搜索范围 */56 category: "Announcements",57 /** 分类 ID,从 giscus.app 配置生成器获取(必填) */58 categoryId: "",59 /** 页面 ↔ Discussion 映射:pathname(默认)/ url / title / og:title / specific / number */60 mapping: "pathname",61 /** 严格标题匹配(SHA-1 校验),避免 GitHub 模糊搜索误配相似标题 */62 strict: false,63 /** 是否显示主贴表情反应 */64 reactionsEnabled: true,65 /** 是否向父页面周期性发送 Discussion 元数据(供脚本消费) */66 emitMetadata: false,67 /** 评论输入框位置:bottom(默认,评论框在列表下方)| top(评论框在列表上方) */68 inputPosition: "bottom",69 /** 明暗两套 giscus 主题(giscus 主题键或自定义主题 CSS URL),跟随站点明暗切换 */70 theme: { light: "light", dark: "dark" },71 /** 评论语言:"auto"(跟随站点)| giscus 语言码(如 "zh-CN"、"en") */72 lang: "auto",73 /** giscus client.js 地址;自托管 giscus 时替换为自有地址 */74 scriptUrl: "https://giscus.app/client.js",75 },76});77
78export type ResolvedCommentOptions =79 | {80 provider: "twikoo";81 lazy: boolean;82 twikoo: TwikooConfig;83 }84 | {85 provider: "giscus";86 lazy: boolean;87 giscus: GiscusConfig;88 }89 | null;90
91/**92 * 解析并校验评论配置。未启用、提供商为 none 或关键参数缺失时返回 null。93 * 校验收敛在此纯函数中,消费组件只消费解析结果并短路。94 */95export function resolveCommentOptions(96 config: CommentConfig,97): ResolvedCommentOptions {98 if (!config.enable || config.provider === "none") {99 return null;100 }101 if (config.provider === "twikoo") {102 const envId = config.twikoo.envId?.trim();103 const scriptUrl = config.twikoo.scriptUrl?.trim();104 if (!envId || !scriptUrl) {105 return null;106 }107 return {108 provider: "twikoo",109 lazy: config.lazy,110 twikoo: {111 ...config.twikoo,112 envId,113 scriptUrl,114 },115 };116 }117 if (config.provider === "giscus") {118 const repo = config.giscus.repo?.trim();119 const repoId = config.giscus.repoId?.trim();120 const categoryId = config.giscus.categoryId?.trim();121 if (!repo || !repoId || !categoryId) {122 return null;123 }124 return {125 provider: "giscus",126 lazy: config.lazy,127 giscus: {128 ...config.giscus,129 repo,130 repoId,131 categoryId,132 category: config.giscus.category?.trim() ?? "",133 theme: {134 light: config.giscus.theme?.light?.trim() || "light",135 dark: config.giscus.theme?.dark?.trim() || "dark",136 },137 scriptUrl: config.giscus.scriptUrl?.trim(),138 },139 };140 }141 return null;142}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});1/**2 * 内容 i18n 配置:内容语言 ↔ URL 路由段 ↔ 展示标签。**唯一真源。**3 *4 * 从 `integrationsConfig.ts` 抽成独立文件是有意为之:语言表是用户可编辑的5 * 站点选择(决定站上展示哪些内容语言),必须走常规配置文件的覆盖通道——6 * 源码模式直接改本文件,包模式在 `shirones/config/contentI18nConfig.ts`7 * 放同名文件(`loadConfigModule()` 与 Vite overlay 都按「用户优先」探测,8 * 见 `docs/packaging-contract.md` 的 config 模块一节)。`integrationsConfig.ts`9 * 在包模式被静态打进 `dist/index.js`、没有覆盖层,语言改动落不进去。10 *11 * **不走 `withUserConfig()`**:那是内容仓 `config/*.yaml` 的深合并通道,12 * 而语言表是路由与集合 id 的结构性输入,`as const` 字面量类型(`ContentLocale`13 * 联合的来源)必须钉在代码里;文件级覆盖已经满足「改配置文件选语言」。14 *15 * URL 形态遵循 Astro 国际化指南的「非默认语言加前缀」策略16 * (`routing.prefixDefaultLocale: false`):17 *18 * | 内容语言 | 路由段 | 展示标签 | 文章 URL |19 * |---|---|---|---|20 * | `zh-CN`(默认) | `zh` | 简体中文 | `/posts/<slug>/` |21 * | `en` | `en` | English | `/en/<slug>/` |22 *23 * 三套命名刻意分开:24 *25 * - **路由段**(`zh` / `en`):URL 首段、`i18n.locales` 条目,也是26 * `src/pages/[locale]/` 动态路由里 `locale` 参数的取值;27 * - **内容语言**(`zh-CN` / `en`):内容文件后缀(`index.en.md`)、28 * frontmatter `lang`、`hreflang` 与 `Content-Language` 用的 BCP 47 标签;29 * - **展示标签**(`简体中文` / `English`):语言自称(endonym),顶栏语言菜单、30 * 「本文还提供」互链、语言徽标与桥接页提示直接显示它——语言名描述的是31 * 「那个语言本身」,不随站点 UI 语言翻译,所以不占 `I18nKey` 词典,32 * 十个 locale 模块也不必登记同值副本。33 *34 * 互译只发生在 `src/utils/i18n-routing.ts`——`src/content.config.ts` 的35 * `generateId`、`src/utils/middleware.ts` 与 `url-utils` 都从那里派生。36 * URL 前缀不写在这里:它由路由段推导(默认语言为空串,其余为 `/<route>`),37 * 避免同一张表出现两处可漂移的值。38 *39 * ## 增删语言40 *41 * 只改本表(数组顺序即语言菜单与互链的顺序),其余全部自动跟随:42 * `astroI18n` 派生注入、`i18n-routing` 的全部映射、`[locale]` 路由按表产出43 * 页面与桥接页、列表/互链/徽标按表渲染。路由段冲突、标签缺失等写错形态由44 * `src/utils/i18n-routing.ts` 的 `validateContentI18n()` 在内容同步与构建45 * 入口 fail-fast 报错。46 */47export const contentI18n = {48 defaultLocale: "zh-CN",49 locales: [50 { locale: "zh-CN", route: "zh", label: "简体中文" },51 { locale: "en", route: "en", label: "English" },52 ],53} as const;54
55/**56 * 注入 Astro 的 `i18n` 配置(由 {@link contentI18n} 派生,同文件保证57 * 包模式下两者看到同一份语言表)。58 *59 * `locales` 只能是**路由段**:Astro 要求 `i18n.locales` 与 URL 首段逐字60 * 匹配,而前缀由 `src/pages/[locale]/` 动态路由按段产出,是 `zh` 不是61 * `zh-CN`;用 `{ path, codes }` 自定义前缀又要求 `output: "server"`,与62 * 本主题的静态输出冲突。`defaultLocale` 同理——它是内容语言标签63 * (`zh-CN`),注入前必须查表换成路由段(`zh`),否则 Astro 的64 * `updateConfig({ i18n })` 校验直接失败(`defaultLocale` 必须在65 * `locales` 内)。66 * `prefixDefaultLocale: false` 让默认语言保持无前缀(`/posts/<slug>/`),67 * 非默认语言走 `/<route>/<slug>/`。68 *69 * 显式形状注解有两个作用:让 `--isolatedDeclarations`(`pnpm type-check`)70 * 能从声明表面导出类型(`.map()` 表达式本身推不出),并把三个 routing71 * 字段钉成字面量必填,供 `updateConfig({ i18n })` 的参数类型直接消费。72 * 包模式由 `src/integration/index.ts` 经 `loadConfigModule()` 动态装载本73 * 文件后取用——静态 import 看不到用户覆盖,会让注入的 `i18n.locales`74 * 与按同一张表生成的路由漂移。75 */76export const astroI18n: {77 locales: string[];78 defaultLocale: string;79 routing: {80 prefixDefaultLocale: false;81 redirectToDefaultLocale: false;82 fallbackType: "redirect";83 };84} = {85 locales: contentI18n.locales.map((entry) => entry.route),86 // Astro 的 `i18n.defaultLocale` 必须是路由段(`locales` 列表成员),87 // 而 `contentI18n.defaultLocale` 是内容语言标签(`zh-CN`);查表换成88 // 对应路由段(查不到时保留原值,由 `validateContentI18n()` 的89 // defaultLocale ∈ locales 校验兜底报错)。90 defaultLocale:91 contentI18n.locales.find(92 (entry) => entry.locale === contentI18n.defaultLocale,93 )?.route ?? contentI18n.defaultLocale,94 // Astro 7 的 routing 类型要求三个字段一起给出;运行时校验则要求95 // `prefixDefaultLocale: false` 时 `redirectToDefaultLocale` 必须为96 // `false`(true 只允许配 prefix=true,否则可能无限重定向)。该选项97 // 仅在 prefix=true 时参与,`fallbackType` 仅在配置 fallback 映射时98 // 参与——两值都与本配置的实际行为一致,不改变运行时语义。99 routing: {100 prefixDefaultLocale: false as const,101 redirectToDefaultLocale: false as const,102 fallbackType: "redirect" as const,103 },104};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<a href="https://beian.miit.gov.cn" target="_Blank">闽ICP备2021002988号-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: false,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 { astroI18n, contentI18n } from "./contentI18nConfig";34export { contextMenuConfig } from "./contextMenuConfig";35export { devicesConfig } from "./devicesConfig";36export { expressiveCodeConfig } from "./expressiveCodeConfig";37export { fabConfig } from "./fabConfig";38export {39 fontConfig,40 resolvedFontOptions,41 resolveFontOptions,42} from "./fontConfig";43export { footerConfig } from "./footerConfig";44export { friendsConfig } from "./friendsConfig";45export { gamesConfig } from "./gamesConfig";46export { i18nConfig } from "./i18nConfig";47export {48 imageBloomConfig,49 resolveImageBloomOptions,50} from "./imageBloomConfig";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";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// 内容 i18n 语言表(`contentI18n`)与它派生的 `astroI18n` 已迁至50// `src/config/contentI18nConfig.ts`:它们是用户可编辑的站点选择,必须落在51// 有覆盖层的常规配置文件里,而不是本文件(包模式静态打进 dist/index.js,52// 没有覆盖通道)。此处不再转出,避免出现两个真源。53
54/**55 * 图片端点路由。必须带尾斜杠,与 `TRAILING_SLASH` 配对。56 *57 * Astro 的 `image.endpoint.route` 只在 `core/config/schemas/relative.js` 的58 * zod transform 里被规范化一次,而那一步由 `validateConfig` 在 `resolveConfig`59 * 阶段调用。此时 `trailingSlash` 还是默认的 `"ignore"`,于是 route 定格为60 * `/_image`(无尾斜杠)。包模式随后才在 `astro:config:setup` 里把61 * `trailingSlash` 改成 `"always"`,而 `hooks.js` 之后只跑62 * `validateConfigRefined`——它**不含**这个 transform——route 再也不会被规范化。63 * 结果 URL 生成端吐 `/_image?…`,路由 pattern 却要求 `/_image/`,`astro dev`64 * 下每个图片请求都 404(build 和 preview 不受影响)。65 *66 * 这里直接把斜杠补上,等价于 transform 本该产出的结果。integration 的67 * `updateConfig()` 把 `trailingSlash` 与 `image.endpoint.route` 成对设置68 * (见 index.ts 中的注释),**所有模式**——源码模式在内——都走这条路径。69 *70 * ⚠️ 若 `TRAILING_SLASH` 改了,这里要跟着改:71 * `"always"` → `"/_image/"` | `"never"` / `"ignore"` → `"/_image"`72 * 若 Astro 修复了顺序(在 config:setup 之后重跑 relative transform),这一行73 * 会变成冗余但无害,可以删除。跟踪:withastro/astro#11568、#10149。74 */75export const IMAGE_ENDPOINT_ROUTE = "/_image/";76
77/**78 * `@swup/astro` 的选项。79 *80 * `ignore` 用数组形式:`@swup/astro` 1.8.0 的类型声明是81 * `(string | RegExp)[] | ((url, …) => …)`(见其 `dist/index.d.ts`),裸字符串82 * 并不符合——运行时 `script.js` 恰好能处理,但本仓 `astro.config.mjs` 是无类型83 * JS 才没报错。统一成数组后两侧都是合法的。84 *85 * `persistTags` 必须带 `:not([data-swup-optional])`:`data-swup-optional` 是86 * 主题自己的页面发出的(`[...permalink].astro`、`about.astro`、87 * `posts/[...slug].astro`),语义是「这份样式只对当前页有意义」。不带这个88 * 选择器,语法高亮等按页注入的样式会在 Swup 切换后残留。包模式同样注入这些89 * 页面,所以两侧行为必须一致。90 */91export const swupOptions: NonNullable<Parameters<typeof swup>[0]> = {92 theme: false as const,93 ignore: ['a[href="#"]'],94 animationClass: "transition-swup-",95 containers: ["main", "#toc"],96 smoothScrolling: true,97 cache: true,98 preload: true,99 accessibility: true,100 updateHead: {101 awaitAssets: false,102 // Keep base styles across Swup visits, but let syntax-scoped styles103 // disappear when the destination page no longer declares them.104 persistTags:105 "link[rel=stylesheet]:not([data-swup-optional]), style:not([data-swup-optional])",106 },107 updateBodyClass: false,108 globalInstance: true,109};110
111/**112 * `@swup/astro` 1.8.0 的 `Options` 类型不认识、但主题确实要传的选项。该版本在113 * 运行时会静默丢弃它们。114 *115 * 必须和 `swupOptions` 分开:`swupOptions` 标注为 `Partial<Options>`,把这两个116 * 键写进去会直接报 ts(2353)。这个标注本身是有意义的——它是本文件里唯一能让117 * swup 选项拼写错误在编译期暴露出来的东西,不要为了让这里能合并而把它去掉。118 *119 * 等 `@swup/astro` 的类型补上这两个键之后,才可以把它们并回 `swupOptions`。120 */121export const swupForwardOptions = {122 animateHistoryBrowsing: false,123 skipPopStateHandling: (event: { state?: { url?: string } }): boolean =>124 Boolean(event.state?.url?.includes("#")),125};126
127/**128 * `astro-icon` 要打包的图标集。129 *130 * 五个集合一个都不能少:`src/` 里共 340 处图标引用,其中 `material-symbols`131 * 占 308 处、`simple-icons` 占 21 处。源码模式此前漏了这两个,只靠 astro-icon132 * 在扁平 `node_modules` 下的自动发现兜住;包模式在 pnpm 严格布局下没有这个133 * 退路,必须显式声明。134 */135export const iconInclude: NonNullable<136 NonNullable<Parameters<typeof icon>[0]>["include"]137> = {138 "material-symbols": ["*"],139 "simple-icons": ["*"],140 "fa7-brands": ["*"],141 "fa7-solid": ["*"],142};143
144/**145 * `astro-expressive-code` 中与「取值方式」无关的那部分选项。146 *147 * `themes` 不在这里:两侧拿 `expressiveCodeConfig` 的途径不同(源码模式直接148 * import,包模式走 `loadConfigModule()` 让用户的覆盖生效)。`plugins` 也不在:149 * 包模式必须走 `loadPackageModule()`。150 */151export const expressiveCodeShared: Omit<152 NonNullable<Parameters<typeof expressiveCode>[0]>,153 "themes" | "plugins"154> = {155 defaultProps: {156 wrap: true,157 overridesByLang: {158 shellsession: {159 showLineNumbers: false,160 },161 },162 },163 styleOverrides: {164 codeBackground: "var(--codeblock-bg)",165 borderRadius: "0.75rem",166 borderColor: "none",167 codeFontSize: "0.875rem",168 codeFontFamily: "var(--m3e-font-mono-family)",169 codeLineHeight: "1.5rem",170 frames: {171 editorBackground: "var(--codeblock-bg)",172 terminalBackground: "var(--codeblock-bg)",173 terminalTitlebarBackground: "var(--codeblock-topbar-bg)",174 editorTabBarBackground: "var(--codeblock-topbar-bg)",175 editorActiveTabBackground: "none",176 editorActiveTabIndicatorBottomColor: "var(--primary)",177 editorActiveTabIndicatorTopColor: "none",178 editorTabBarBorderBottomColor: "var(--codeblock-topbar-bg)",179 terminalTitlebarBorderBottomColor: "none",180 },181 // Hue values are numbers at runtime — the source config has always182 // passed numbers — but the published types only accept unresolved CSS183 // strings. Cast here rather than at the use sites so both modes stay184 // type-checked everywhere else.185 textMarkers: { delHue: 0, insHue: 180, markHue: 250 } as never,186 },187 frames: {188 showCopyToClipboardButton: false,189 },190};191
192/**193 * `@astrojs/svelte` 的 `compilerOptions`。194 *195 * `preprocess` 不在这里,见文件头对 wiring 的说明。196 *197 * @param isDev 是否 dev 命令。源码模式从 `process.argv` 判断,包模式从198 * `astro:config:setup` 的 `command` 参数判断,两侧取值方式不同,所以由调用方199 * 传进来。200 */201export function svelteCompilerOptions(isDev: boolean) {202 return {203 // CSS-source hashing keeps SSR and client scope hashes stable after moves.204 cssHash: ({ css, hash }: { css: string; hash: (s: string) => string }) =>205 `svelte-${hash(css)}`,206 // Keep repeated Svelte compiler diagnostics out of the dev terminal;207 // check/build still surface the full warning set in CI.208 warningFilter: (): boolean => !isDev,209 };210}211
212/** `@astrojs/mdx` 的选项。 */213export const mdxOptions: NonNullable<Parameters<typeof mdx>[0]> = {214 syntaxHighlight: false as const,215 optimize: true,216};217
218/**219 * 两侧共用的 `vite.build` 选项。220 *221 * 不含 `esbuild`(`drop` / `pure`):那是源码模式独有的构建期优化,会连带剥掉222 * 用户自己代码里的 `console.log`,属于需要单独决策的行为差异,见223 * `shirones` 仓的 `docs/plans/single-source-config.md`。224 */225export const viteBuildShared: NonNullable<226 NonNullable<AstroUserConfig["vite"]>["build"]227> = {228 minify: "esbuild" as const,229 cssCodeSplit: true,230 cssMinify: "esbuild" as const,231 chunkSizeWarningLimit: 1000,232 rollupOptions: {233 // Parameter types are inferred from the build-options type. Hand-writing them234 // narrower than rolldown's OnwarnFunction made the whole object fail to235 // type-check.236 onwarn(warning, defaultHandler) {237 // Astro legitimately mixes static and dynamic imports for islands;238 // silence that specific advisory.239 if (240 warning.message.includes("is dynamically imported by") &&241 warning.message.includes("but also statically imported by")242 ) {243 return;244 }245 // Astro's MDX bridge injects this directive before Rolldown sees the246 // module; the directive is consumed by Astro and is not meaningful in247 // the final browser bundle. Remove this filter once the bridge stops248 // forwarding `astro:head-inject` to the bundler.249 if (250 warning.code === "MODULE_LEVEL_DIRECTIVE" &&251 warning.message.includes('"use astro:head-inject"')252 ) {253 return;254 }255 defaultHandler(warning);256 },257 },258};259
260/**261 * `vite.optimizeDeps.include` 的候选清单。262 *263 * 只是**候选**:包模式必须先过 `prebundleCandidates()` 过滤,因为 Vite 从项目264 * 根解析这些 id,看不见主题自己的嵌套依赖,列一个解析不了的 id 会让每次冷启动265 * 都刷一条警告。266 */267export const prebundleSpecifiers: string[] = [268 "mermaid",269 "@panzoom/panzoom",270 "overlayscrollbars",271 "@fancyapps/ui",272 // share-poster.ts reaches for this one at runtime, on the first share273 // dialog rather than during SSR. Without it here the dev server serves a274 // stale pre-bundle id and the dynamic import rejects, so poster275 // generation fails only in dev.276 "qrcode",277];278
279/**280 * 可选音乐侧栏的虚拟模块 id。281 *282 * 两侧的插件对象本身不共享(解析出来的侧栏路径不同:源码模式指向283 * `./src/components/...`,包模式指向 `paths.packageSrc` 下的同名文件),但虚拟284 * id 和「哪些产物算音乐包」的判定两侧完全一致,而且正是最容易悄悄改歪的地方,285 * 所以收敛到这里。286 */287export const MUSIC_SIDEBAR_VIRTUAL_ID = "virtual:shirone-music-sidebar";288
289/** 关掉音乐组件时,`generateBundle` 阶段要从产物里剔除的文件。 */290export function isMusicBundleFile(fileName: string): boolean {291 return (292 fileName.includes("MusicSidebarClient") ||293 fileName.startsWith("_astro/music.") ||294 fileName.includes("/music.")295 );296}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 provider: "mixed",54 // tracks: [55 // {56 // id: "custom-1",57 // title: "示例曲目",58 // artist: "艺术家",59 // cover: "/assets/music/cover/example.webp",60 // source: "/assets/music/url/example.mp3",61 // duration: 240,62 // },63 // ],64 meting: {65 server: "netease",66 type: "playlist",67 id: "14164869977",68 // 进入视口时预取歌单元数据(仅元信息,不预取音频流):69 // "metadata"(取)| "none"(默认,不取;交互后才请求,卡片显示「尚未请求」占位)70 preload: "none",71 },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 { aboutConfig } from "@/config/aboutConfig";3import { albumsConfig } from "@/config/albumsConfig";4import { animeConfig } from "@/config/animeConfig";5import { compassConfig } from "@/config/compassConfig";6import { devicesConfig } from "@/config/devicesConfig";7import { friendsConfig } from "@/config/friendsConfig";8import { gamesConfig } from "@/config/gamesConfig";9import { momentsConfig } from "@/config/momentsConfig";10import { projectsConfig } from "@/config/projectsConfig";11import { seriesConfig } from "@/config/seriesConfig";12import { skillsConfig } from "@/config/skillsConfig";13import { timelineConfig } from "@/config/timelineConfig";14import type {15 NavBarConfig,16 NavBarConfigOverride,17 NavBarLink,18 NavBarLinkOverride,19} from "@/types/navBarConfig";20import { getUserConfig } from "../utils/config-overlay.ts";21import { pruneUnavailableNavLinks } from "../utils/nav-utils.ts";22
23/**24 * 导航栏配置(统一单一来源)。25 * - LinkPresets:命名链接预设表 —— 名称 / 地址 / 图标单点维护,可整体复用;26 * - navBarConfig:导航结构 —— 顺序 + 分组(children 子菜单),27 * 同时驱动顶栏下拉菜单与全端导航抽屉。28 * 新增入口:先在 LinkPresets 登记预设,再在 navBarConfig.links 按序引用。29 *30 * `name` 统一存 `"$t:<i18nKey>"` 引用(GitHub 这类字面量除外),**不在此31 * 求值**:本模块在服务器启动/构建加载时执行一次,立即 `i18n()` 会把文案32 * 冻在当时的语言上。词条由渲染方(TopAppBar / SiteNavigationDrawer)在33 * 请求上下文内调 `resolveI18nText` 解析,导航文案因此跟随页面语言。34 *35 * 内容仓可用 `config/nav-bar.yaml` 整体替换 `links`,写法见 `NavBarLinkOverride`。36 * 无论哪种来源,指向已关闭功能页面的入口都会在 `navBarConfig` 处被裁掉。37 */38export const LinkPresets: Record<string, NavBarLink> = {39 Home: {40 name: "$t:home",41 url: "/",42 icon: "material-symbols:home-outline-rounded",43 pageKey: "home",44 },45 Archive: {46 name: "$t:archive",47 url: "/archive/",48 icon: "material-symbols:archive-outline-rounded",49 pageKey: "archive",50 },51 Friends: {52 name: "$t:friends",53 url: "/friends/",54 icon: "material-symbols:handshake-outline-rounded",55 pageKey: "friends",56 },57 Moments: {58 name: "$t:moments",59 url: "/moments/",60 icon: "material-symbols:auto-awesome-outline-rounded",61 pageKey: "moments",62 },63 Anime: {64 name: "$t:anime",65 url: "/anime/",66 icon: "material-symbols:live-tv-outline-rounded",67 pageKey: "anime",68 },69 Compass: {70 name: "$t:compass",71 url: "/compass/",72 icon: "material-symbols:explore-rounded",73 pageKey: "compass",74 },75 Skills: {76 name: "$t:skills",77 url: "/skills/",78 icon: "material-symbols:workspaces-outline-rounded",79 pageKey: "skills",80 },81 Projects: {82 name: "$t:projects",83 url: "/projects/",84 icon: "material-symbols:deployed-code-outline-rounded",85 pageKey: "projects",86 },87 Devices: {88 name: "$t:devices",89 url: "/devices/",90 icon: "material-symbols:devices-rounded",91 pageKey: "devices",92 },93 Games: {94 name: "$t:games",95 url: "/games/",96 icon: "material-symbols:sports-esports-outline-rounded",97 pageKey: "games",98 },99 Timeline: {100 name: "$t:timeline",101 url: "/timeline/",102 icon: "material-symbols:timeline-rounded",103 pageKey: "timeline",104 },105 Albums: {106 name: "$t:albums",107 url: "/albums/",108 icon: "material-symbols:photo-library-outline-rounded",109 pageKey: "albums",110 },111 Categories: {112 name: "$t:categories",113 url: "/categories/",114 icon: "material-symbols:folder-outline-rounded",115 pageKey: "categories",116 },117 Tags: {118 name: "$t:tags",119 url: "/tags/",120 icon: "material-symbols:tag-rounded",121 pageKey: "tags",122 },123 Series: {124 name: "$t:series",125 url: "/series/",126 icon: "material-symbols:auto-stories-outline-rounded",127 pageKey: "series",128 },129 About: {130 name: "$t:about",131 url: "/about/",132 icon: "material-symbols:info-outline-rounded",133 pageKey: "about",134 },135 GitHub: {136 name: "GitHub",137 url: "https://github.com/LyraVoid/Shirone",138 icon: "fa7-brands:github",139 external: true,140 pageKey: "github",141 },142};143
144const defaultNavBarConfig: NavBarConfig = {145 links: [146 LinkPresets.Home,147 LinkPresets.Archive,148 LinkPresets.Friends,149 LinkPresets.Moments,150 LinkPresets.Anime,151 LinkPresets.Compass,152 LinkPresets.Albums,153 {154 name: "$t:more",155 icon: "material-symbols:apps-rounded",156 children: [157 LinkPresets.Timeline,158 LinkPresets.Projects,159 LinkPresets.Devices,160 LinkPresets.Games,161 LinkPresets.Skills,162 // 分类/标签入口不进导航菜单(避免菜单项过多),预设已登记指向独立页面,163 // 需要时取消注释即可164 // LinkPresets.Categories,165 // LinkPresets.Tags,166 LinkPresets.About,167 LinkPresets.GitHub,168 ],169 },170 ],171};172
173import { I18N_REFERENCE_PREFIX } from "../utils/i18n-utils.ts";174
175function fail(message: string): never {176 throw new Error(`[config] nav-bar:${message}`);177}178
179/**180 * 条目名只做 `$t:` 引用格式校验,**不求值**。181 *182 * 词条解析推迟到渲染期(`TopAppBar` / `SiteNavigationDrawer` 的183 * `resolveI18nText`):本模块在服务器启动/构建加载时求值一次,立即184 * `i18n()` 会把文案冻在当时的语言上,`/zh/*` 页面的导航就再也切不过185 * 来。校验保留下来——内容仓写错词条必须在配置加载时立刻抛出。186 */187function resolveName(name: string): string {188 if (name.startsWith(I18N_REFERENCE_PREFIX)) {189 const key = name.slice(I18N_REFERENCE_PREFIX.length);190 if (!Object.hasOwn(I18nKey, key)) {191 fail(192 `未知的 i18n 词条 "${key}"。可用词条见 src/i18n/i18nKey.ts;` +193 " 若本意是普通文本,去掉开头的 $t: 即可。",194 );195 }196 }197 return name;198}199
200/**201 * 已关闭功能对应的站内路由(去尾斜杠),供 `pruneUnavailableNavLinks()` 裁剪导航入口。202 *203 * 功能关闭时对应页面会 `Astro.redirect("/404/")`,因此这些路由不得再出现在导航里。204 * 关闭判定只看配置,与导航结构无关,因此默认结构与内容仓声明式条目共用同一张表。205 */206const unavailableFeatureRoutes: ReadonlySet<string> = new Set([207 ...(friendsConfig.enable ? [] : ["/friends"]),208 ...(momentsConfig.enable ? [] : ["/moments"]),209 ...(animeConfig.enable ? [] : ["/anime"]),210 ...(compassConfig.enable ? [] : ["/compass"]),211 ...(albumsConfig.enable ? [] : ["/albums"]),212 ...(skillsConfig.enable ? [] : ["/skills"]),213 ...(projectsConfig.enable ? [] : ["/projects"]),214 ...(devicesConfig.enable ? [] : ["/devices"]),215 ...(gamesConfig.enable ? [] : ["/games"]),216 ...(timelineConfig.enable ? [] : ["/timeline"]),217 ...(aboutConfig.enable ? [] : ["/about"]),218 ...(seriesConfig.enable ? [] : ["/series"]),219]);220
221/**222 * 把内容仓的声明式导航条目还原成 `NavBarLink`。223 *224 * 预设名与 i18n 词条只有在这里才能校验(`LinkPresets` 与 `I18nKey` 都住在代码仓,225 * 生成期的 Node 脚本受路径别名所限读不到),因此错误在构建加载配置时抛出。226 */227export function resolveNavBarLinks(228 entries: readonly NavBarLinkOverride[],229 presets: Record<string, NavBarLink> = LinkPresets,230): NavBarLink[] {231 return entries.map((entry) => {232 let base: NavBarLink | null = null;233 if (entry.preset !== undefined) {234 base = presets[entry.preset] ?? null;235 if (!base) {236 fail(237 `未知的预设 "${entry.preset}"。可用预设:${Object.keys(presets).join("、")}。`,238 );239 }240 }241
242 const name =243 entry.name !== undefined ? resolveName(entry.name) : base?.name;244 if (name === undefined) {245 fail("每个条目都需要 name,或用 preset 引用一个内置预设。");246 }247
248 // 未声明 children 时沿用预设自带的子菜单(已由 ...base 带入)。249 return {250 ...base,251 name,252 ...(entry.url !== undefined ? { url: entry.url } : {}),253 ...(entry.icon !== undefined ? { icon: entry.icon } : {}),254 ...(entry.pageKey !== undefined ? { pageKey: entry.pageKey } : {}),255 ...(entry.external !== undefined ? { external: entry.external } : {}),256 ...(entry.children257 ? { children: resolveNavBarLinks(entry.children, presets) }258 : {}),259 };260 });261}262
263const userNavBar = getUserConfig("navBar") as NavBarConfigOverride | undefined;264
265/**266 * 导航栏最终结构。267 *268 * 默认结构与内容仓 `config/nav-bar.yaml`(声明式列表,整体替换默认导航,不走任何269 * enable 分支)在这里汇合后统一裁剪:功能关掉时入口一并消失,两种模式下行为一致,270 * 不会留下点进去 404 的死链。271 */272export const navBarConfig: NavBarConfig = {273 links: pruneUnavailableNavLinks(274 resolveNavBarLinks(275 userNavBar ? userNavBar.links : defaultNavBarConfig.links,276 ),277 unavailableFeatureRoutes,278 ),279};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 * - languageScope:首页列表的多语言范围(类型注释见 types/postListConfig.ts):10 * - "language"(默认):只渲染当前页面语言的文章;11 * - "all":渲染全部文章组——卡片链接一律指向页面语言前缀下的路径,12 * 无当前语言版本时由语言回退桥接页承接(提示 + 语言互链)。仅影响13 * 首页,归档/分类/标签/feed 的按语言过滤口径不变;14 * - languageFallbackBadge:混合列表中缺当前语言版本的卡片是否渲染语言15 * 徽标(默认 true;false = 完全不渲染);16 * - layout:17 * - mode:"list"(经典纵向列表)| "grid"(双列/三列卡片网格);18 * - cover:"left"(封面在左)| "right"(封面在右,默认);19 * - cardWidth(仅在 grid 模式下生效):20 * - "compact":紧凑卡片(最小宽 20rem,适合高密度展示);21 * - "regular":标准卡片(最小宽 24rem,默认推荐);22 * - "relaxed":宽松大卡(最小宽 28rem,突出大图)。23 *24 * 注意:访客可在前端显示设置面板中动态切换 list/grid,此处为站点初始默认值。25 * GridUI 仅在主内容容器至少能容纳两张所选宽度的卡片时生效;侧栏等因素压窄26 * 内容后会暂时回退 ListUI,但保留 grid 偏好,空间恢复后自动切回。27 */28export const postListConfig: PostListConfig = withUserConfig("postList", {29 pageSize: 8,30 languageScope: "all",31 languageFallbackBadge: true,32 layout: {33 mode: "list",34 cover: "right",35 cardWidth: "regular",36 },37});38
39/** grid 档位 → 卡片最小宽度(--post-card-min 预设,与 shape/type 分档哲学同构)。40 页面框架 90rem:regular 24rem 保证宽屏为 2 列大卡(3 列窄卡会让41 日期/分类/字数元信息行换行),compact 才给密排选项。 */42export const POST_CARD_MIN_WIDTH: Record<PostCardWidth, string> = {43 compact: "20rem",44 regular: "24rem",45 relaxed: "28rem",46};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: "Shirone",11 bio: "The rain remembers what the sky forgot to say.",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 改的站点选择(如内容语言表 `contentI18nConfig.ts`)必须留在常规可覆盖29 模块里——曾与集成选项同放 `integrationsConfig.ts` 的语言表因此迁出,30 否则包模式用户永远改不到它(split-brain)。`src/integration/index.ts`31 对它的消费也改走 `loadConfigModule()`(用户文件优先)。32
33## 配置(Behavior)与数据(Content)分层原则34
35Shirone 遵循「配置管行为,数据管内容」的清晰分层架构:36
37- **`src/config/*Config.ts`**:控制**展示行为与页面能力**(页面总开关 `enable`、分类显示顺序 `categories`、单项禁用列表 `disabledKeys`、排序方向 `order`、源切换与服务凭据);38- **`src/data/*.ts`**:承载**具体的站点内容实体**(项目条目 `projects.ts`、技能清单 `skills.ts`、时间线节点 `timeline.ts`、设备列表 `devices.ts`、友链 `friends.ts`、罗盘 `compass.ts`、番剧 `anime.ts` 与本地音乐 `music.ts`);39- **`src/utils/feature-data.ts`**:提供构建期纯函数,将 config 的过滤/排序等行为规则应用到对应 data 实体集合上,输出给页面/组件。40
41| 判别问题 | 归属 | 处理方式 |42|---|---|---|43| 它控制「页面是否开启 / 排序 / 凭据」? | **Config** | 写在 `src/config/*Config.ts`(如 `enable: boolean`, `categories`, `order`) |44| 它是「站点要展示的具体条目与说明」? | **Data** | 写在 `src/data/*.ts`(如 `ProjectItem[]`, `SkillItem[]`, `TimelineItem[]`) |45| 单项内容的停用 / 过滤? | **Config** | 在 config 中声明 `disabledKeys`(或对应 ID 列表),data 保持纯净内容 |46
47## 新增一个配置项 / 配置文件48
491. 类型定义加入 `src/types/<domain>Config.ts`(新领域则新建文件,字段带中文注释说明语义与默认值);502. 值加入 `src/config/<domain>Config.ts`,保持注释完整——注释是配置的文档;513. 新文件在 `src/config/index.ts` barrel 注册导出;用 `withUserConfig("<domain>", { ... })`52 包住默认值字面量,并在 `scripts/content/config-domains.mjs` 登记该领域(见下文「用户覆盖层」);534. **安全默认与零额外负担**:可选外部服务/重量级特性默认必须为关闭(如 `enable: false`)。在关闭或未配置时,必须满足「零外部请求、零占位 DOM、零性能损耗、零主包膨胀」的零额外负担要求;54 落地做法与验证方法见 `docs/on-demand-loading.md`;555. UI 文案走 `I18nKey` 枚举 + `i18n()`,**不写死字符串**;`i18n()` 语言跟随56 请求上下文(解析链见 `src/i18n/AGENTS.md`),模块顶层(配置求值期)57 **不要**调用它——那会把文案冻在当时的语言上,需要延迟的场景存58 `"$t:<key>"` 引用、渲染期用 `resolveI18nText()` 解析(`navBarConfig`59 即此模式);新增 i18n key 必须同步补全 `src/i18n/languages/` 下全部 10 种语言;606. 跑 `npx.cmd astro check` 确认 0 错误 0 警告。61
62## 用户覆盖层(内容仓 `config/*.yaml`)63
64本目录的每个领域配置都把自己的字面量默认值交给 `withUserConfig()`:65
66```typescript67export const siteConfig: SiteConfig = withUserConfig("site", {68 title: "Shirone",69 // ...默认值连同注释一起留在代码仓70});71```72
73`local` 模式下 `withUserConfig()` 原样返回默认值,零开销。`external` 模式下,74内容仓 `config/site.yaml` 里的键会与默认值**深合并**(对象递归合并、数组整体替换)75后返回,合并源是 `pnpm content:sync` 生成的 `src/user/user-config.ts`。76
77因此本目录的定位没有变:**它是默认值与配置文档的唯一真源**,78注释写得越清楚,内容仓那边越不需要猜。契约与 YAML 写法见79[`docs/content-separation/config-overlay.md`](../../docs/content-separation/config-overlay.md)。80
81新增配置领域时,除了本文下方的清单,还要在82`scripts/content/config-domains.mjs` 补一行登记(领域名、YAML 文件名、类型),83它同时驱动生成、校验与 `content:eject` 的起步文件;`tests/content/content-config.test.mjs`84会检查登记表的文件名唯一且为 kebab-case。85
86`navBarConfig` 是唯一不走 `withUserConfig()` 的领域:导航项要引用 `LinkPresets`87且 `name` 存 `"$t:<key>"` 惰性引用(模块顶层立即调 `i18n()` 会把文案冻在88加载期语言上,词条由 TopAppBar / SiteNavigationDrawer 在渲染期89`resolveI18nText()` 解析,跟随页面语言),深合并只会得到一堆未解析的引用,90因此它由 `resolveNavBarLinks()` 把内容仓的声明式条目还原成 `NavBarLink`91(只做 `$t:` 词条校验,不求值)。92
93`config/nav-bar.yaml` 是**整体替换**,不会跟着各功能的 `enable` 走,因此94`navBarConfig` 在任何来源的条目汇合后都会过一遍 `pruneUnavailableNavLinks()`95(`src/utils/nav-utils.ts`):指向已关闭功能页面的入口被裁掉,空掉的下拉分组一并隐藏。96判定按站内路由(去尾斜杠、忽略查询串与哈希),站外链接与锚点不受影响。97
98### 反向导出覆盖层(`content:export --config`)99
100覆盖层是双向的:`pnpm content:export --config` 会求「当前生效配置」与「主题默认值」的差,101把最小覆盖集写回内容仓的 `config/*.yaml`(保留用户已有的注释与格式,只增改不删键)。102它是 `deepMerge` 的精确逆运算,因此喂回 `content:sync` 之后生效配置逐字段不变。103
104典型用途是救援:`content:clean` 会把 `src/user/user-config.ts` 重置成空覆盖层,105在那份生成物里的改动会因此丢失;导出能先把它固化成 YAML。默认只预演,用法与安全机制见106[`docs/content-separation/cli-workflows.md`](../../docs/content-separation/cli-workflows.md)。107
108两条边界要知道:109
110- **`navBar` 不参与导出**。`resolveNavBarLinks()` 的解析不可逆,`config/nav-bar.yaml` 只能手工维护;111- **不会把本目录的源码改动提升成覆盖**。直接修改 `<domain>Config.ts` 里的默认值字面量时,112 「默认值」与「生效值」同步变化,差分为空,那处改动不会进入导出计划。113 这是有意为之:把当前默认值提升成内容仓的永久覆盖,等于把配置冻结在这一版主题上。114 想固化就在 fork 里提交它,或照常在内容仓写一条对应的 YAML 覆盖。115
116领域键与 YAML 文件名一律 kebab-case 对应驼峰:`llms` ↔ `config/llms.yaml` ↔ `llmsConfig`,117`postList` ↔ `config/post-list.yaml` ↔ `postListConfig`。118
119## 现有配置一览120
121| 文件 | 职责 |122|---|---|123| `footerConfig.ts` | 页脚自定义 HTML 注入开关(控制是否读取并注入 `src/config/FooterConfig.html`,关闭时零开销) |124| `siteConfig.ts` | 站点部署 URL / base 路径 / 标题标识 / 语言 / IANA 时区 / HCT 主题色 / 背景纹理系统 / 显示设置浮层开关 / 横幅 / TOC 深度 / 进度条 / favicon(含 `getDefaultStyle` / `getDefaultSpec` / `resolveDisplaySettings` 回退值) |125| `profileConfig.ts` | 博主资料:头像 / 名称 / 简介 / 社交链接 |126| `licenseConfig.ts` | 文章版权声明 |127| `expressiveCodeConfig.ts` | 代码块明暗主题 |128| `navBarConfig.ts` | 导航栏链接(`LinkPresets` 预设表 + 组装) |129| `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) |130| `fabConfig.ts` | 右下角悬浮控制流(FAB)配置:总开关、各操作项(返回顶部、悬浮目录、直达评论、返回首页、自定义操作)、细粒度设备受控矩阵(`devices?: ("mobile" | "tablet" | "desktop")[]`)、页面范围过滤与图标定制;架构见 `docs/fab-system.md` |131| `announcementConfig.ts` | 公告内容(侧栏 announcement widget 消费,text 为空不渲染) |132| `musicConfig.ts` | 侧栏音乐全局配置:总开关(默认关闭)、`provider` 模式切换接口、`defaultVolume` 初始音量与 `defaultMode` 初始播放模式(本地曲目清单维护在 `src/data/music.ts`);与 `sidebarConfig` 的 music 条目共同控制 `MusicSidebar`,详见下文与 `docs/sidebar-widgets.md` |133| `postListConfig.ts` | 文章列表:分页大小、布局(list/grid 模式、封面位置、grid 卡片宽度档位)、首页多语言范围 `languageScope`(`"language"` 默认只列当前语言文章;`"all"` 列出全部文章组——无当前语言版本时以默认语言版本显示,卡片链接始终为页面语言前缀(跨语言代表链到桥接页),仅首页生效,归档/分类/标签/feed 的按语言过滤口径不变;架构与验证见 `docs/content-i18n.md` §6/§6a)与跨语言卡片语言徽标 `languageFallbackBadge`(默认开启,关闭后跨语言卡片仍链桥接页、只是不渲染徽标) |134| `articleConfig.ts` | 文章详情:最后更新提示、延伸阅读(相关/随机文章抽样)、以及文章尾部分享区块(总开关、海报生成与封面配置) |135| `commentConfig.ts` | 评论系统:全局开关(默认关闭)、Provider 选择(Twikoo / Giscus)、视口懒加载与服务凭据配置;Giscus 基于 GitHub Discussions(需公开仓库 + 安装 giscus App + 从 giscus.app 取 repoId/categoryId),主题明暗双值跟随站点切换 |136| `contextMenuConfig.ts` | 桌面端右键增强:可选开关(当前默认开启);配置允许页面与操作顺序,关闭时零 DOM、零监听器、零客户端资源 |137| `umamiConfig.ts` | Umami 统计:全局开关(默认关闭)、公开分享统计读取,以及可选的官方访问采集脚本配置;支持内容仓 `config/umami.yaml` 覆盖(领域键 `umami`) |138| `contentI18nConfig.ts` | 内容多语言语言表(`contentI18n`:内容语言 ↔ URL 路由段 ↔ endonym 展示标签,含派生的 `astroI18n`);数组顺序即语言菜单顺序,增删语言只改此表,写错形态由 `validateContentI18n()` fail-fast;架构见 `docs/content-i18n.md`。**不走 `withUserConfig()`**:语言表是路由与集合 id 的结构性输入,需保留 `as const` 字面量类型,走常规文件级覆盖(包模式 `shirones/config/contentI18nConfig.ts`) |139| `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`),所以它没有用户覆盖层,用户项目里的那份拷贝是死的 |140| `sitemapFilter.ts` | 由 `*Config.enable === false` 推导被关闭的页面清单,供 `sitemap()` 的 `filter` 排除它们。包模式通过 `loadConfigModule` 加载,用户可自行覆盖 |141| `skillsConfig.ts` | 技能页行为控制:页面总开关、分类清单与单项禁用列表(技能内容维护在 `src/data/skills.ts`);关闭页面时导航入口同步隐藏 |142| `projectsConfig.ts` | 项目页行为控制:页面总开关、分类清单与单项禁用列表(项目内容维护在 `src/data/projects.ts`);关闭页面时导航入口同步隐藏 |143| `timelineConfig.ts` | 时间线页行为控制:页面总开关、分类清单、排序方向与单项禁用列表(时间线内容维护在 `src/data/timeline.ts`);关闭页面时导航入口同步隐藏 |144| `devicesConfig.ts` | 设备页行为控制:页面总开关、场景分类清单与单项禁用列表(设备清单维护在 `src/data/devices.ts`);关闭页面时导航入口同步隐藏 |145| `gamesConfig.ts` | 游戏页行为控制:页面总开关、游戏分类清单与单项禁用列表(游戏清单维护在 `src/data/games.ts`,封面支持 src/assets 相对路径、/public 绝对路径与远程 URL);关闭页面时导航入口同步隐藏 |146| `animeConfig.ts` | 番剧页与外部追番数据源:数据源选择(本地 / Bangumi 快照 / Bilibili 快照)、失败降级、提供方凭据环境配置与快照生命周期管理(本地番剧维护在 `src/data/anime.ts`) |147| `llmsConfig.ts` | 大语言模型与 AI 友好内容系统:`/llms.txt`(索引)与 `/llms-full.txt`(全量正文汇编)静态端点生成控制、加密文章过滤、排除标签与自定义章节配置;支持内容仓 `config/llms.yaml` 覆盖(领域键 `llms`) |148| `seriesConfig.ts` | 系列连载:`/series/` 索引与 `/series/<slug>/` 详情页总开关(默认开启)、索引页 `title`/`description`(`$t:` i18n 引用或字面量,描述留空用动态汇总)、文章内系列块位置 `cardPosition: "top" \| "bottom"`(top = 元信息区下方、正文之前;bottom = 正文之后、版权/分享/延伸阅读之前);支持内容仓 `config/series.yaml` 覆盖(领域键 `series`)。关闭时系列页 404、导航/侧栏/sitemap 入口裁剪、文章内系列卡消失(系列实体与文章 frontmatter 见 `shirone-writing` 与内容分离文档) |149
150非首页 Banner 的标题、说明和可选日期由各页面通过 `MainGridLayout` 提供,并在 Swup 导航后从被替换的主内容容器同步。该上下文默认显示、不设配置开关;说明为空或与标题相同时自动省略,移动端非首页仍沿用紧凑布局并隐藏 Banner。151
152## 侧栏编排与页框宽度153
154- `arrangement: "single"`(默认)——全部 widget 渲染进唯一侧栏,页框 85rem;155- `arrangement: "dual"`——`column: "secondary"` 的 widget 进入副栏(视口 ≥ 1280px 起三列),156 其余留在主栏;页框自动放宽到 96rem,TOC 悬浮 rail 自动让位(右侧余量被副栏占据)。157 1280px 以下自动退化为单栏(只显主栏),无需配置。158- `side: "left" | "right"` 决定主栏物理侧,dual 线下副栏落在对面。159- 页框宽度用 `resolvePageWidth()`(`src/utils/responsive-utils.ts`)按编排自动解析,160 常量在 `src/constants/constants.ts`(`PAGE_WIDTH` / `PAGE_WIDTH_DUAL`),不提供手动覆盖。161
162## 右下角悬浮控制流(FAB)配置契约163
164`fabConfig.ts` 控制右下角悬浮操作栏的呈现与交互:165
166```typescript167export const fabConfig: FabConfig = {168 enable: true,169 items: [170 {171 type: "top",172 enable: true,173 icon: "material-symbols:keyboard-arrow-up-rounded",174 // 未指定 devices 时全设备生效;滚过横幅高度阈值后平滑浮现175 },176 {177 type: "toc",178 enable: true,179 icon: "material-symbols:format-list-bulleted-rounded",180 pages: ["post"],181 // 桌面端侧栏已有粘性 TOC,故默认仅在移动端与平板端显示悬浮目录182 devices: ["mobile", "tablet"],183 },184 {185 type: "comment",186 enable: true,187 icon: "material-symbols:comment-outline-rounded",188 pages: ["post"],189 devices: ["mobile", "tablet"],190 },191 {192 type: "home",193 enable: false,194 icon: "material-symbols:home-outline-rounded",195 },196 ],197};198```199
200### 核心规则与受控特性201
2021. **细粒度设备受控(`devices`)**:203 - `"mobile"`(< 768px)204 - `"tablet"`(768px ~ 1023px)205 - `"desktop"`(≥ 1024px)206 - 省略时默认所有设备均允许;SSR 阶段直接输出 Tailwind CSS 响应式类(如 `flex lg:hidden`),零首屏闪烁,CLS = 0。2072. **页面范围过滤(`pages`)**:208 - 过滤逻辑与侧栏 `SidebarPage` 统一,通过 `#swup-container` 的 `data-current-page` 在 Swup 站内导航时联动隐藏/显示。2093. **评论按钮零额外负担**:210 - 全局未开启评论系统(`commentConfig.enable: false`)或当前文章关闭评论时,评论 FAB 按钮产物为 0 DOM,零多余外链请求与布局偏移。2114. **无音乐挂件(保持纯粹)**:212 - FAB 控制流不集成音乐播放器,避免与侧边栏 `MusicSidebar` 产生双重状态混乱及包体积膨胀。213
214## 侧栏音乐启用契约与四种模式215
216侧栏音乐是默认关闭的可选能力,必须同时满足以下三项才加载并渲染:217
2181. `musicConfig.enable` 为 `true`;2192. 对应数据源(本地 `src/data/music.ts`、自定义 `tracks` 或 `meting` 远端歌单)至少包含一首有效曲目/合法歌单 ID;2203. `sidebarConfig.components` 中 `type: "music"` 的条目存在且 `enable: true`(默认条目为 `false`)。221
222### 四种数据源工作模式(`provider`)223
224| 模式 | 配置名 | 数据源 | 特点与适用场景 |225|---|---|---|---|226| **本地模式** | `"local"` | `src/data/music.ts` | 默认模式。零外部 API 依赖,构建期静态打包,首屏毫秒级就绪,支持离线/断网播放。 |227| **自定义列表** | `"custom"` | `musicConfig.tracks` | 灵活自定义。直接在配置中传入曲目数组(支持外链音频与封面),无需修改通用数据文件。 |228| **云端歌单** | `"meting"` | `musicConfig.meting` | 接入 Meting API(网易云、QQ 音乐、酷狗等),客户端异步按需拉取,曲目与封面自动清洗加载。 |229| **混合增强模式** | `"mixed"` | 本地数据 + Meting API | **推荐**。首屏立即播放本地音乐,后台无感拉取远端歌单并在就绪后无缝追加合并;若遇断网或云端接口故障,自动静默降级为本地曲目,绝不破版。 |230
231任一条件不满足时,音乐功能不得输出 DOM 或样式,不得请求音频/封面等资源,也不得把播放器代码或依赖带入主 bundle。配置消费者应先完成三项校验,再动态加载 `MusicSidebar`;不能用隐藏空卡片代替短路。232
233`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: "dual",28 side: "left",29 components: [30 { type: "profile", enable: true, slot: "top" },31 { type: "music", enable: true, slot: "top" },32 { type: "announcement", enable: true, slot: "top", pages: ["home"] },33 {34 type: "categories",35 enable: true,36 slot: "sticky",37 collapseAfter: 5,38 pages: [39 "home",40 "archive",41 "friends",42 "moments",43 "anime",44 "compass",45 "skills",46 "projects",47 "devices",48 "games",49 "timeline",50 "albums",51 "about",52 "post",53 ],54 },55 {56 type: "series",57 enable: true,58 slot: "sticky",59 collapseAfter: 5,60 pages: [61 "home",62 "archive",63 "friends",64 "moments",65 "anime",66 "compass",67 "skills",68 "projects",69 "devices",70 "games",71 "timeline",72 "albums",73 "about",74 "post",75 ],76 },77 {78 type: "tags",79 enable: true,80 slot: "sticky",81 collapseAfter: 6,82 pages: [83 "home",84 "archive",85 "friends",86 "moments",87 "anime",88 "compass",89 "skills",90 "projects",91 "devices",92 "games",93 "timeline",94 "albums",95 "about",96 "post",97 ],98 },99 {100 type: "stats",101 enable: true,102 slot: "top",103 column: "secondary",104 pages: ["home", "archive", "categories", "tags"],105 },106 { type: "calendar", enable: true, slot: "top", column: "secondary" },107 {108 type: "toc",109 enable: true,110 slot: "sticky",111 column: "secondary",112 pages: ["post"],113 },114 ],115});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://shirone.mysqil.com/",14 base: "/",15 title: "Shirone",16 subtitle: "A Material 3 anime blog",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. 站点 UI 语言(i18n() 构建期兜底、feed `<language>`),与内容默认语言(`contentI18nConfig.ts`)各自独立。31 // IANA time zone for precise post and moment timestamps. It is independent of lang.32 timeZone: "Asia/Shanghai",33 themeColor: {34 hue: 315, // Default hue 0-360. 站点设计默认粉紫(偏二次元);262 紫 / 345 粉 也可选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: "starlight", // 默认纹理预设:"none" | "starlight" | "cyber-dots" | "topography" | "geometric" | "sakura"51 defaultOpacity: 0.12, // 默认纹理浓度 (0.05 ~ 0.25)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: "Shirone",74 subtitle: [75 "特別なことはないけど、君がいると十分です",76 "今でもあなたは私の光",77 "君ってさ、知らないうちに私の毎日になってたよ",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: "starlight",148 defaultOpacity: 0.12,149 allowMotion: true,150 };151 }152
153 const enable = config.enable ?? true;154 const defaultPreset = config.defaultPreset ?? "starlight";155 const defaultOpacity = config.defaultOpacity ?? 0.12;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 };参数一览#
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
title | string | 可选 | 代码树顶栏标题 |
entry | string | 可选 | 默认激活的文件名(或相对路径) |
height | string | 可选 | 容器高度,如 380px、60vh |
交互特性#
- 双栏联动:点击左侧树状结构中的任意文件名,右侧代码区即时切换至对应的文件内容与语法高亮。
- 默认入口:通过
entry="filename"指定页面首次加载时默认激活展示的文件。 - 自适应滚动:高度与滚动条经过无障碍与移动端优化,保持阅读舒适。
Markdown 语法
已完结第 16 篇,共 30 篇·本文属于系列
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
沿着主题读
基于共同的标签与分类
换条路线
从其他文章中稳定抽取
最后更新于 ,距今已过 14 天
部分内容可能已过时
评论