@windcoder/astro-theme-orchid 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +118 -2
  2. package/package.json +70 -5
  3. package/src/components/AlbumCard.astro +73 -0
  4. package/src/components/Analytics.astro +22 -0
  5. package/src/components/BaseLayout.astro +188 -0
  6. package/src/components/Calendar.astro +379 -0
  7. package/src/components/CategoryBar.astro +74 -0
  8. package/src/components/Comment.astro +48 -0
  9. package/src/components/EncryptedContent.astro +42 -0
  10. package/src/components/Footer.astro +31 -0
  11. package/src/components/Header.astro +153 -0
  12. package/src/components/License.astro +29 -0
  13. package/src/components/Live2DWidget.astro +262 -0
  14. package/src/components/MusicPlayer.astro +940 -0
  15. package/src/components/OutdatedCard.astro +53 -0
  16. package/src/components/Pagination.astro +94 -0
  17. package/src/components/PhotoCard.astro +22 -0
  18. package/src/components/PostCard.astro +89 -0
  19. package/src/components/PostNav.astro +40 -0
  20. package/src/components/RecommendedPost.astro +166 -0
  21. package/src/components/SakuraEffect.astro +152 -0
  22. package/src/components/Sidebar.astro +334 -0
  23. package/src/components/Toc.astro +27 -0
  24. package/src/components/analytics/GoogleAnalytics.astro +22 -0
  25. package/src/components/analytics/La51Analytics.astro +52 -0
  26. package/src/components/analytics/MicrosoftClarity.astro +18 -0
  27. package/src/components/analytics/UmamiAnalytics.astro +86 -0
  28. package/src/components/comments/Artalk.astro +51 -0
  29. package/src/components/comments/Disqus.astro +43 -0
  30. package/src/components/comments/Giscus.astro +50 -0
  31. package/src/components/comments/Twikoo.astro +65 -0
  32. package/src/components/comments/Waline.astro +36 -0
  33. package/src/i18n/en.ts +164 -0
  34. package/src/i18n/index.ts +18 -0
  35. package/src/i18n/resolve.ts +49 -0
  36. package/src/i18n/zh-CN.ts +184 -0
  37. package/src/icons.ts +71 -0
  38. package/src/index.ts +411 -0
  39. package/src/plugins/plantuml-encoder.ts +95 -0
  40. package/src/plugins/rehype-diagram.ts +218 -0
  41. package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
  42. package/src/plugins/rehype-email.ts +75 -0
  43. package/src/plugins/rehype-github-card.ts +114 -0
  44. package/src/plugins/rehype.ts +284 -0
  45. package/src/plugins/remark-diagram.ts +78 -0
  46. package/src/plugins/remark-directives.ts +100 -0
  47. package/src/plugins/remark-excerpt.ts +29 -0
  48. package/src/plugins/remark-image-grid.ts +97 -0
  49. package/src/plugins/remark-reading-time.ts +38 -0
  50. package/src/plugins/scripts/diagram-panzoom.js +388 -0
  51. package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
  52. package/src/routes/404.astro +14 -0
  53. package/src/routes/_hydrate.ts +34 -0
  54. package/src/routes/about.astro +25 -0
  55. package/src/routes/api/allPostMeta.json.ts +26 -0
  56. package/src/routes/archive.astro +81 -0
  57. package/src/routes/categories/[category].astro +37 -0
  58. package/src/routes/categories/index.astro +29 -0
  59. package/src/routes/friends.astro +83 -0
  60. package/src/routes/gallery/[album].astro +97 -0
  61. package/src/routes/gallery/index.astro +100 -0
  62. package/src/routes/guestbook.astro +37 -0
  63. package/src/routes/index.astro +47 -0
  64. package/src/routes/og/[...slug].ts +281 -0
  65. package/src/routes/page/[page].astro +53 -0
  66. package/src/routes/posts/[...slug].astro +350 -0
  67. package/src/routes/robots.txt.ts +18 -0
  68. package/src/routes/rss.xml.ts +37 -0
  69. package/src/routes/series/[name].astro +66 -0
  70. package/src/routes/series/index.astro +64 -0
  71. package/src/routes/sponsor.astro +84 -0
  72. package/src/routes/tags/[tag].astro +40 -0
  73. package/src/routes/tags/index.astro +29 -0
  74. package/src/schema.ts +1218 -0
  75. package/src/scripts/app.ts +1041 -0
  76. package/src/scripts/share-poster.ts +468 -0
  77. package/src/styles/global.css +4864 -0
  78. package/src/utils/crypto.ts +46 -0
  79. package/src/utils/gallery.ts +76 -0
  80. package/src/utils/lqip.ts +18 -0
  81. package/src/utils/related.ts +101 -0
  82. package/src/utils.ts +233 -0
  83. package/src/vite.ts +105 -0
package/README.md CHANGED
@@ -1,3 +1,119 @@
1
- # Temporary Holding Version
1
+ # @windcoder/astro-theme-orchid
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ 清新简约的 Astro 博客主题,以 **Astro Integration** 形式提供:站点只做三件事——声明域名、接入主题、传入配置;页面、样式引擎、路由全部由主题注入。支持版本化升级与组件覆盖(`src/overrides/` 同名替换,升级永不覆盖站点定制)。
4
+
5
+ ## 特性一览
6
+
7
+ - **页面**:首页(featured/cover/compact/text 四种卡片混排)、文章页(TOC/相关推荐/分享海报/打赏入口)、归档(按年折叠)、分类、标签、专题、画廊(含加密相册)、友链、留言板、打赏、关于
8
+ - **写作增强**:LaTeX(KaTeX + mhchem)、Mermaid 双主题、PlantUML、Callouts、GitHub 卡片、图语法(figure/九宫格)、Expressive Code 代码块(双主题/行号/折叠/复制/语言徽标)、邮箱防爬
9
+ - **互动系统**:评论五套(Artalk / Twikoo / Waline / Giscus / Disqus)、文章加密、过时提醒、公告、广告位
10
+ - **个性化**:深浅模式、hue 动态调色、樱花飘落、背景壁纸、音乐播放器(跨页不断播)、Live2D 看板娘、i18n(zh-CN / en)
11
+ - **工程化**:配置启动即校验(Zod)、OG 分享图、LQIP 占位、字体子集化、站点统计四件套(GA / Clarity / Umami / 51.la)、RSS / sitemap
12
+
13
+ ## 快速上手
14
+
15
+ ### 1. 安装
16
+
17
+ ```sh
18
+ pnpm add @windcoder/astro-theme-orchid
19
+ # peerDependencies: astro ^7.0.0
20
+ ```
21
+
22
+ ### 2. 接入主题
23
+
24
+ ```ts
25
+ // astro.config.ts
26
+ import orchidTheme from "@windcoder/astro-theme-orchid";
27
+ import { defineConfig } from "astro/config";
28
+
29
+ export default defineConfig({
30
+ site: "https://blog.example.com",
31
+ integrations: [orchidTheme({ title: "我的博客" /* 其余配置见 demo 站指南系列 */ })],
32
+ });
33
+ ```
34
+
35
+ 配置在 dev/build 启动即校验(Zod schema),错误会直接报出来。所有配置项都有默认值,无必填项(`title` 缺省为 `"Orchid Blog"`,正式站点应显式配置)。
36
+
37
+ ### 3. 内容集合
38
+
39
+ Astro 要求 collections 定义在站点项目内,站点通过 `definePostSchema` 在兼容主题字段的基础上扩展自己的 frontmatter:
40
+
41
+ ```ts
42
+ // src/content.config.ts
43
+ import { defineCollection, z } from "astro:content";
44
+ import { definePostSchema } from "@windcoder/astro-theme-orchid/schema";
45
+
46
+ const posts = defineCollection({
47
+ type: "content",
48
+ schema: definePostSchema({ /* 站点自定义字段 */ }),
49
+ });
50
+
51
+ export const collections = { posts };
52
+ ```
53
+
54
+ #### 可选:启用 MDX
55
+
56
+ MDX 是站点能力选择:是否写 `.mdx` 文章由站点决定。主题渲染管线与其天然兼容——`@astrojs/mdx` 默认继承站点 `markdown` 配置,主题注册的自定义 unified processor(KaTeX / Mermaid / PlantUML / directives / Expressive Code)原样作用于 `.mdx`。
57
+
58
+ 启用三步:`pnpm add @astrojs/mdx`、在 `integrations` 数组里注册(与主题顺序无关)、集合 loader 扩为 `"**/*.{md,mdx}"`。组件写作、`.md`/`.mdx` 选型,以及已知的 `style-to-js` 依赖坑(含一行 overrides 根治配方),见 demo 站《MDX 指南》一文。
59
+
60
+ ### 4. 站点结构约定
61
+
62
+ ```text
63
+ site/
64
+ ├── astro.config.ts # 接入主题(见上)
65
+ ├── config/ # 配置按模块拆分(推荐做法,也可直接内联)
66
+ ├── src/content/posts/ # 文章(.md;启用 MDX 后 .mdx 一并收录)
67
+ ├── src/content/spec/ # 独立页内容:about.md / friends.md / guestbook.md
68
+ ├── src/overrides/ # 组件覆盖(路径镜像主题,见「定制与覆盖」)
69
+ ├── scripts/new-post.mjs # 写作辅助:pnpm new-post <标题>[.md|.mdx](demo 站)
70
+ └── public/ # 静态资源(音乐/壁纸/画廊/模型等)
71
+ ```
72
+
73
+ 独立页 `friends.md` / `guestbook.md` 的 frontmatter 只需 `title`;页面数据(友链列表、打赏码等)来自主题配置,见 demo 站《独立页面与数据》一文。
74
+
75
+ ## 配置文档
76
+
77
+ 主题配置项全部有默认值、无必填项,dev/build 启动即经 Zod schema 校验,配错直接报出。字段语义以 [`src/schema.ts`](./src/schema.ts) 注释为准。
78
+
79
+ 完整配置说明与文章 frontmatter 全字段表统一放在 demo 站「主题使用指南」系列文章(源文件在本仓库 `site/src/content/posts/`),按主题分组:
80
+
81
+ - 基础:《站点基础配置》|《导航栏与首页横幅》|《侧边栏与个人资料》|《文章列表与归档》
82
+ - 功能:《文章页功能》|《视觉特效与媒体》(含 fonts 详细配置)|《独立页面与数据》|《评论、公告、统计与广告》
83
+ - 写作:《写作指南》(frontmatter 全字段与增强语法)|《MDX 指南》(MDX 启用与踩坑修复)
84
+
85
+ ## 定制与覆盖
86
+
87
+ - **组件覆盖**:站点 `src/overrides/` 下镜像主题路径放同名文件即可整体替换,例如
88
+ `src/overrides/components/PostCard.astro` 替换文章卡片。主题内部统一经
89
+ `virtual:theme/components/X.astro` 引用组件,解析时优先命中 overrides,找不到回退包内默认实现;
90
+ overrides 内再 import 同名组件时强制解析到包内默认实现(保留包装扩展的能力)。
91
+ - **样式**:主题全局样式经 `@windcoder/astro-theme-orchid/styles/global.css` 注入;强调色派生色全部走 CSS 变量(`--accent-h/s/l`),可通过覆盖变量或 overrides 组件实现深度定制。
92
+ - **页脚注入**:`footer.customHtml` 支持注入备案号、自定义脚本/样式等任意 HTML。
93
+
94
+ ## 文档同步约定
95
+
96
+ - 新增/修改**配置项**:同一次提交内更新 demo 站对应指南文章(`site/src/content/posts/`),并在 `docs/design.md` 记录功能节(§ 编号)。
97
+ - 新增**frontmatter 字段**:同步更新 demo 站《写作指南》字段表。
98
+ - 行为变更不涉及配置面时,至少在 `docs/design.md` 留痕。
99
+
100
+ ## 本仓库开发
101
+
102
+ ```text
103
+ packages/theme 主题包(exports 直指 src,无构建步骤)
104
+ site 中性 demo 站点(config/ 按模块拆分配置,是指南文章与配置示例的载体)
105
+ docs/design.md 设计与实现记录(§ 编号推进,含踩坑与验证口径)
106
+ ```
107
+
108
+ ```sh
109
+ pnpm install
110
+ pnpm --filter site dev # 本地开发
111
+ pnpm --filter site build # 构建
112
+ pnpm --filter site new-post "文章标题" # 生成 frontmatter 骨架到 site/src/content/posts/(.mdx 后缀可显式指定)
113
+ ```
114
+
115
+ > 注意:改动主题渲染管线(rehype/remark 插件)后构建前需清除 content layer 渲染缓存:`rm -rf site/node_modules/.astro`。
116
+
117
+ ## License
118
+
119
+ MIT
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
- "name": "@windcoder/astro-theme-orchid",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@windcoder/astro-theme-orchid",
3
+ "version": "0.2.0",
4
+ "description": "清新简约的 Astro 博客主题,以 Integration 形式提供,支持版本化升级与组件覆盖",
5
+ "type": "module",
6
+ "keywords": [
7
+ "astro",
8
+ "astro-integration",
9
+ "blog",
10
+ "theme",
11
+ "tailwindcss"
12
+ ],
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/iwinder/qy-astro-theme-orchid.git",
17
+ "directory": "packages/theme"
18
+ },
19
+ "homepage": "https://github.com/iwinder/qy-astro-theme-orchid/tree/main/packages/theme#readme",
20
+ "bugs": "https://github.com/iwinder/qy-astro-theme-orchid/issues",
21
+ "publishConfig": {
22
+ "access": "public",
23
+ "registry": "https://registry.npmjs.org/"
24
+ },
25
+ "exports": {
26
+ ".": "./src/index.ts",
27
+ "./schema": "./src/schema.ts",
28
+ "./routes/*": "./src/routes/*",
29
+ "./components/*": "./src/components/*",
30
+ "./plugins/*": "./src/plugins/*",
31
+ "./styles/*": "./src/styles/*"
32
+ },
33
+ "files": [
34
+ "src"
35
+ ],
36
+ "peerDependencies": {
37
+ "@iconify-json/lucide": "^1.2.0",
38
+ "@iconify-json/simple-icons": "^1.2.0",
39
+ "astro": "^7.0.0"
40
+ },
41
+ "dependencies": {
42
+ "@astrojs/markdown-remark": "^7.3.2",
43
+ "@astrojs/rss": "^4.0.19",
44
+ "@astrojs/sitemap": "^3.7.3",
45
+ "@expressive-code/plugin-collapsible-sections": "^0.44.2",
46
+ "@expressive-code/plugin-line-numbers": "^0.44.2",
47
+ "@tailwindcss/vite": "^4.3.2",
48
+ "astro-icon": "^1.2.0",
49
+ "beautiful-mermaid": "^1.1.3",
50
+ "expressive-code-collapsible": "^0.1.0",
51
+ "expressive-code-language-badge": "^1.1.0",
52
+ "hastscript": "^9.0.1",
53
+ "katex": "^0.19.0",
54
+ "l2d-widget": "^0.1.2",
55
+ "pako": "^3.0.2",
56
+ "photoswipe": "^5.4.4",
57
+ "qrcode": "^1.5.4",
58
+ "rehype-expressive-code": "^0.44.2",
59
+ "rehype-katex": "^7.0.1",
60
+ "remark-directive": "^4.0.0",
61
+ "remark-math": "^6.0.0",
62
+ "satori": "^0.47.1",
63
+ "sharp": "^0.35.5",
64
+ "tailwindcss": "^4.3.2"
65
+ },
66
+ "devDependencies": {
67
+ "@types/pako": "^2.0.3",
68
+ "@types/qrcode": "^1.5.6",
69
+ "subset-font": "^2.5.0"
70
+ }
71
+ }
@@ -0,0 +1,73 @@
1
+ ---
2
+ import { t } from "../i18n/index.js";
3
+ /**
4
+ * 相册卡片(M3b-b8):画廊列表页网格单元,样式对齐设计原型 §9。
5
+ * data-tags(标签数组)与 data-search(名称/描述/地点/标签全文小写)供 gallery-filter 筛选。
6
+ */
7
+ import { getLqipStyle } from "../utils/lqip.js";
8
+
9
+ interface Props {
10
+ id: string;
11
+ name: string;
12
+ cover: string;
13
+ description?: string;
14
+ date?: string;
15
+ location?: string;
16
+ tags?: string[];
17
+ photoCount: number;
18
+ encrypted?: boolean;
19
+ }
20
+
21
+ const {
22
+ id,
23
+ name,
24
+ cover,
25
+ description = "",
26
+ date = "",
27
+ location = "",
28
+ tags = [],
29
+ photoCount,
30
+ encrypted = false,
31
+ } = Astro.props;
32
+
33
+ const searchText = [name, description, location, ...tags]
34
+ .join(" ")
35
+ .toLowerCase();
36
+ ---
37
+
38
+ <a class="album-card card" href={`/gallery/${id}/`} data-tags={tags.join(',')} data-search={searchText} aria-label={encrypted ? `${name}(${t('encryptedAlbum')})` : name}>
39
+ <div class="album-cover">
40
+ {cover ? (
41
+ <>
42
+ {/* LQIP 占位渐变(b9.5):外链封面查不到数据时降级纯色 */}
43
+ <div class="qy-lqip" style={getLqipStyle(cover)} aria-hidden="true"></div>
44
+ <img src={cover} alt={name} loading="lazy" decoding="async" data-lqip />
45
+ </>
46
+ ) : (
47
+ <span class="album-cover-fallback">
48
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="5" width="18" height="14" rx="2"/><circle cx="9" cy="10" r="1.6"/><path d="m21 15-4.2-4.2a1.4 1.4 0 0 0-2 0L7 18.6"/></svg>
49
+ </span>
50
+ )}
51
+ {encrypted && (
52
+ <span class="lock-badge">
53
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="18" height="11" x="3" y="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
54
+ {t('encryptedAlbum')}
55
+ </span>
56
+ )}
57
+ </div>
58
+ <div class="album-info">
59
+ <span class="album-name">{name}</span>
60
+ <span class="album-count">{t('galleryPhotos', { n: photoCount })}</span>
61
+ </div>
62
+ {(date || location) && (
63
+ <div class="album-sub">
64
+ {date && <span>{date}</span>}
65
+ {location && <span>{location}</span>}
66
+ </div>
67
+ )}
68
+ {tags.length > 0 && (
69
+ <div class="album-tags">
70
+ {tags.slice(0, 4).map((tag) => <span class="chip">{tag}</span>)}
71
+ </div>
72
+ )}
73
+ </a>
@@ -0,0 +1,22 @@
1
+ ---
2
+ /**
3
+ * 站点统计聚合挂载点(P1-16):BaseLayout body 尾部统一挂载。
4
+ * 各统计 ID 留空即完全不注入对应脚本;全部留空本组件零输出。
5
+ */
6
+ import config from "virtual:theme/config";
7
+ import GoogleAnalytics from "./analytics/GoogleAnalytics.astro";
8
+ import La51Analytics from "./analytics/La51Analytics.astro";
9
+ import MicrosoftClarity from "./analytics/MicrosoftClarity.astro";
10
+ import UmamiAnalytics from "./analytics/UmamiAnalytics.astro";
11
+
12
+ const a = config.analytics;
13
+ const showGA = a.googleAnalyticsId.trim() !== "";
14
+ const showClarity = a.microsoftClarityId.trim() !== "";
15
+ const showUmami = a.umami.websiteId.trim() !== "";
16
+ const show51la = a.la51.id.trim() !== "";
17
+ ---
18
+
19
+ {showGA && <GoogleAnalytics analyticsId={a.googleAnalyticsId} />}
20
+ {showClarity && <MicrosoftClarity clarityId={a.microsoftClarityId} />}
21
+ {showUmami && <UmamiAnalytics {...a.umami} />}
22
+ {show51la && <La51Analytics analyticsId={a.la51.id} sdkUrl={a.la51.sdkUrl} ck={a.la51.ck} autoTrack={a.la51.autoTrack} hashMode={a.la51.hashMode} screenRecord={a.la51.screenRecord} />}
@@ -0,0 +1,188 @@
1
+ ---
2
+ // 字体(§4.66):Astro Fonts API 官方组件,按 cssVariable 渲染 @font-face
3
+ // 与变量定义(preload 开启时附带 <link rel=preload>)
4
+ import { Font } from "astro:assets";
5
+ import { ClientRouter } from "astro:transitions";
6
+ import Analytics from "virtual:theme/components/Analytics.astro";
7
+ import Footer from "virtual:theme/components/Footer.astro";
8
+ import Header from "virtual:theme/components/Header.astro";
9
+ import Live2DWidget from "virtual:theme/components/Live2DWidget.astro";
10
+ import MusicPlayer from "virtual:theme/components/MusicPlayer.astro";
11
+ import SakuraEffect from "virtual:theme/components/SakuraEffect.astro";
12
+ import config from "virtual:theme/config";
13
+ import { t } from "../i18n/index.js";
14
+ import { hexToHsl } from "../utils.js";
15
+ import "@windcoder/astro-theme-orchid/styles/global.css";
16
+ /* 灯箱(§4.69):PhotoSwipe 样式随页面 CSS 一起打包,JS 由 app.ts 惰性动态加载 */
17
+ import "photoswipe/photoswipe.css";
18
+
19
+ interface Props {
20
+ title?: string;
21
+ description?: string;
22
+ /** meta keywords(§4.19):默认站点级配置,文章页传 frontmatter tags 覆盖 */
23
+ keywords?: string[];
24
+ /** body[data-page],驱动导航高亮(home/archive/tags/post...) */
25
+ page?: string;
26
+ /** OG 分享图路径(b9.4,文章页传 /og/<slug>.png);绝对链接依赖站点 site 配置 */
27
+ ogImage?: string;
28
+ }
29
+
30
+ const {
31
+ title,
32
+ description = config.description,
33
+ keywords = config.keywords,
34
+ page,
35
+ ogImage,
36
+ } = Astro.props;
37
+ const pageTitle = title ? `${title} · ${config.title}` : config.title;
38
+ const { h, s, l } = hexToHsl(config.accentColor);
39
+ /* 首页 banner(§4.42,hero+壁纸合并单配置):数组时构建期随机选一张(每页构建各自取,零 JS);
40
+ enabled=false 或 src 未配置不渲染图 */
41
+ const bannerSrc = Array.isArray(config.banner.src)
42
+ ? config.banner.src.length
43
+ ? config.banner.src[Math.floor(Math.random() * config.banner.src.length)]
44
+ : ""
45
+ : config.banner.src || "";
46
+ const showBanner =
47
+ config.banner.enabled &&
48
+ Boolean(bannerSrc) &&
49
+ (!config.banner.homeOnly || page === "home");
50
+ /* hero 文案(§4.42):quote 留空不渲染;有 banner 时叠加其上,否则渲染为列表上方独立区块 */
51
+ const heroEnabled = config.banner.enabled && Boolean(config.banner.quote);
52
+ const heroOnBanner = heroEnabled && showBanner && page === "home";
53
+ /* 字体角色映射(§4.66):配置了 role 的字体生成 html:root 规则,把主题三角色
54
+ 变量重定向为 var(字体变量), 原角色回退栈(取自 global.css :root 默认值,
55
+ 字体未覆盖的字形如中文自然回落)。html:root Specificity (0,1,1) 高于
56
+ :root (0,1,0),稳定覆盖不赌样式顺序;CSS 自定义属性引用是惰性求值,
57
+ 与 <Font> 产出的变量定义先后无关 */
58
+ const fontRoleFallback: Record<string, string> = {
59
+ display: '"Noto Serif SC", "Songti SC", "STSong", "SimSun", serif',
60
+ body: '-apple-system, "PingFang SC", "Segoe UI", "Microsoft YaHei", sans-serif',
61
+ mono: 'ui-monospace, "SF Mono", "JetBrains Mono", "Fira Code", monospace',
62
+ };
63
+ const fontRoleCss = config.fonts
64
+ .filter((f) => f.role)
65
+ .map(
66
+ (f) =>
67
+ `html:root{--font-${f.role}:var(${f.cssVariable}),${fontRoleFallback[f.role]};}`,
68
+ )
69
+ .join("");
70
+ ---
71
+
72
+ <!doctype html>
73
+ <html lang={config.lang} style={`--accent-h:${h};--accent-s:${s}%;--accent-l:${l}%;--page-width:${config.pageWidth}px`} data-accent-h={h} data-color-mode={config.colorMode}>
74
+ <head>
75
+ <meta charset="utf-8" />
76
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
77
+ <meta name="generator" content={Astro.generator} />
78
+ <title>{pageTitle}</title>
79
+ <meta name="description" content={description} />
80
+ {/* keywords(§4.19):非空才输出;文章页以 tags 覆盖站点级配置 */}
81
+ {keywords.length > 0 && <meta name="keywords" content={keywords.join(", ")} />}
82
+ <meta name="author" content={config.author} />
83
+ {/* favicon(§4.30):配置非空时按列表输出,theme 指定 light/dark 经 media 按系统深浅切换 */}
84
+ {config.favicon.length > 0
85
+ ? config.favicon.map((f) => (
86
+ <link
87
+ rel="icon"
88
+ href={f.src}
89
+ type={f.type}
90
+ sizes={f.sizes}
91
+ media={f.theme ? `(prefers-color-scheme: ${f.theme})` : undefined}
92
+ />
93
+ ))
94
+ : <link rel="icon" href="/favicon.svg" type="image/svg+xml" />}
95
+ {/* SEO 头部补全(§4.12):canonical 防重复内容;RSS 供阅读器发现;theme-color 与 global.css :root 同步 */}
96
+ {Astro.site && <link rel="canonical" href={new URL(Astro.url.pathname, Astro.site).href} />}
97
+ <link rel="alternate" type="application/rss+xml" title={config.title} href={Astro.site ? new URL("rss.xml", Astro.site).href : "/rss.xml"} />
98
+ <meta name="theme-color" media="(prefers-color-scheme: light)" content="#f6f4ef" />
99
+ <meta name="theme-color" media="(prefers-color-scheme: dark)" content="#131516" />
100
+ {/* OG/Twitter meta(b9.4):ogImage 仅文章页传入;绝对链接依赖 site 配置 */}
101
+ <meta property="og:type" content={ogImage ? 'article' : 'website'} />
102
+ <meta property="og:title" content={pageTitle} />
103
+ <meta property="og:description" content={description} />
104
+ <meta property="og:site_name" content={config.title} />
105
+ <meta property="og:locale" content={config.lang.replace("-", "_")} />
106
+ {Astro.site && <meta property="og:url" content={new URL(Astro.url.pathname, Astro.site).href} />}
107
+ {ogImage && Astro.site && <meta property="og:image" content={new URL(ogImage, Astro.site).href} />}
108
+ <meta name="twitter:card" content={ogImage ? 'summary_large_image' : 'summary'} />
109
+ <ClientRouter />
110
+ {/* 字体(§4.66):Astro Fonts API 标准方案。每个 <Font> 按注入配置产出
111
+ @font-face + 变量定义(preload 时附带预载链接);角色映射规则随后输出 */}
112
+ {config.fonts.length > 0 && (
113
+ <>
114
+ {config.fonts.map((f) => (
115
+ <Font cssVariable={f.cssVariable} preload={f.preload} />
116
+ ))}
117
+ <style is:global set:html={fontRoleCss} />
118
+ </>
119
+ )}
120
+ <!-- 深浅模式 boot:先于首帧执行防 FOUC;is:inline 在 VT swap 时不重复执行。
121
+ 默认模式读 html[data-color-mode](§4.16 colorMode),用户选择以 localStorage('qy-mode') 优先 -->
122
+ <script is:inline>
123
+ (function(){var m=localStorage.getItem('qy-mode');var d=document.documentElement.dataset.colorMode||'system';if(m==='dark'||(!m&&d==='dark')||(!m&&d==='system'&&matchMedia('(prefers-color-scheme: dark)').matches))document.documentElement.classList.add('dark');var h=localStorage.getItem('qy-hue');if(h)document.documentElement.style.setProperty('--accent-h',h);})();
124
+ </script>
125
+ </head>
126
+ <!-- 客户端动态文案载体(i18n,M3b-b5):app.ts 读取,VT swap 后随新 body 重建 -->
127
+ <script type="application/json" id="qy-ui-labels" set:html={JSON.stringify({ close: t('close'), prevImage: t('prevImage'), nextImage: t('nextImage'), copied: t('copied'), fullscreen: t('fullscreen'), slideshow: t('slideshow'), thumbs: t('thumbs'), sharePoster: t('sharePoster'), generatingPoster: t('generatingPoster'), copyLink: t('copyLink'), savePoster: t('savePoster'), scanToRead: t('scanToRead'), author: t('author'), searchLoading: t('searchLoading'), searchNoResults: t('searchNoResults'), searchUnavailable: t('searchUnavailable'), searchTypeSomething: t('searchTypeSomething') })} />
128
+ <body data-page={page}>
129
+ <a class="skip-link" href="#main">{t('skipToContent')}</a>
130
+ <Header />
131
+ <!-- 首页 banner(§4.42,hero+壁纸合并单配置):Header 之后下沉横幅 + 黑蒙层;enabled=false / src 未配置 / homeOnly 非首页不渲染 -->
132
+ {
133
+ showBanner && (
134
+ <div
135
+ class="wallpaper-banner"
136
+ style={`--banner-h:${config.banner.height}vh;--banner-dim:${config.banner.dimOpacity};--banner-pos:${config.banner.position}`}
137
+ >
138
+ <img src={bannerSrc} alt="" aria-hidden="true" loading="eager" />
139
+ <div class="wallpaper-dim" aria-hidden="true" />
140
+ {/* hero 文案叠加(§4.42):quote 配置时主句/副句居中显示在 banner 上 */}
141
+ {heroOnBanner && (
142
+ <div class="wallpaper-hero reveal">
143
+ <h1 class="hero-quote" set:html={config.banner.quote} />
144
+ {config.banner.sub && <p class="hero-sub">{config.banner.sub}</p>}
145
+ </div>
146
+ )}
147
+ </div>
148
+ )
149
+ }
150
+ <main id="main" class="site-main">
151
+ <div class="shell">
152
+ {/* hero 独立区块形态(§4.42):无 banner 图时的首页 hero 文案,保持原列表上方位置 */}
153
+ {
154
+ heroEnabled && page === "home" && !heroOnBanner && (
155
+ <section class="hero reveal">
156
+ <h1 class="hero-quote" set:html={config.banner.quote} />
157
+ {config.banner.sub && <p class="hero-sub">{config.banner.sub}</p>}
158
+ </section>
159
+ )
160
+ }
161
+ <slot />
162
+ </div>
163
+ </main>
164
+ <!-- 列表形态恢复(§4.34→§4.55):首帧绘制前按 localStorage > defaultLayout 应用防闪烁;
165
+ is:inline 在 VT 换页不重执行,跨页返回由 app.ts initPage 接管 -->
166
+ <script is:inline>
167
+ (function(){try{var l=document.getElementById('postList');var m=localStorage.getItem('qy-postList')||(l&&l.dataset.defaultLayout)||'list';if(m!=='grid'||!l)return;l.classList.add('grid-mode');l.style.setProperty('--col-w',(l.dataset.gridCol||'320')+'px');var b=document.getElementById('layoutSwitch');if(b)b.setAttribute('aria-pressed','true');}catch(e){}})();
168
+ </script>
169
+ <Footer />
170
+ <!-- 站点统计(P1-16):ID 全空零输出,挂 body 尾与 orchid-blog 口径一致 -->
171
+ <Analytics />
172
+ <!-- 樱花飘落特效(§4.30):sakura.enabled 开启才渲染 -->
173
+ {config.sakura.enabled && <SakuraEffect density={config.sakura.density} />}
174
+ <!-- 悬浮音乐播放器(§4.36):music.enabled 且歌单非空才渲染 -->
175
+ {config.music.enabled && config.music.playlist.length > 0 && (
176
+ <MusicPlayer music={config.music} />
177
+ )}
178
+ <!-- Live2D 看板娘(§4.38):live2d.enabled 且配置了 model 才渲染 -->
179
+ {config.live2d.enabled && config.live2d.model && <Live2DWidget live2d={config.live2d} />}
180
+ <button class="back-top" id="backTop" type="button" aria-label={t('backToTop')} hidden>
181
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m18 15-6-6-6 6"/></svg>
182
+ </button>
183
+ <!-- 全局交互(VT 兼容:document 级委托 + astro:page-load 重初始化) -->
184
+ <script>
185
+ import '../scripts/app';
186
+ </script>
187
+ </body>
188
+ </html>