@windcoder/astro-theme-orchid 0.1.0 → 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.
- package/README.md +14 -207
- package/package.json +70 -70
- package/src/index.ts +5 -0
- package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
- package/src/routes/posts/[...slug].astro +48 -35
- package/src/routes/series/[name].astro +23 -20
- package/src/schema.ts +6 -0
- package/src/styles/global.css +34 -0
- package/src/utils.ts +52 -0
package/README.md
CHANGED
|
@@ -28,11 +28,11 @@ import { defineConfig } from "astro/config";
|
|
|
28
28
|
|
|
29
29
|
export default defineConfig({
|
|
30
30
|
site: "https://blog.example.com",
|
|
31
|
-
integrations: [orchidTheme({ title: "我的博客" /*
|
|
31
|
+
integrations: [orchidTheme({ title: "我的博客" /* 其余配置见 demo 站指南系列 */ })],
|
|
32
32
|
});
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
配置在 dev/build 启动即校验(Zod schema
|
|
35
|
+
配置在 dev/build 启动即校验(Zod schema),错误会直接报出来。所有配置项都有默认值,无必填项(`title` 缺省为 `"Orchid Blog"`,正式站点应显式配置)。
|
|
36
36
|
|
|
37
37
|
### 3. 内容集合
|
|
38
38
|
|
|
@@ -53,31 +53,9 @@ export const collections = { posts };
|
|
|
53
53
|
|
|
54
54
|
#### 可选:启用 MDX
|
|
55
55
|
|
|
56
|
-
MDX 是站点能力选择:是否写 `.mdx`
|
|
57
|
-
`@astrojs/mdx` 默认继承站点 `markdown` 配置,主题注册的自定义 unified processor
|
|
58
|
-
(KaTeX / Mermaid / PlantUML / directives / Expressive Code)原样作用于 `.mdx`,无需任何额外接线。
|
|
56
|
+
MDX 是站点能力选择:是否写 `.mdx` 文章由站点决定。主题渲染管线与其天然兼容——`@astrojs/mdx` 默认继承站点 `markdown` 配置,主题注册的自定义 unified processor(KaTeX / Mermaid / PlantUML / directives / Expressive Code)原样作用于 `.mdx`。
|
|
59
57
|
|
|
60
|
-
|
|
61
|
-
pnpm add @astrojs/mdx
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
// astro.config.ts
|
|
66
|
-
import mdx from "@astrojs/mdx";
|
|
67
|
-
|
|
68
|
-
export default defineConfig({
|
|
69
|
-
integrations: [mdx(), orchidTheme(themeConfig)],
|
|
70
|
-
});
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
集合 loader 同步扩展为 `"**/*.{md,mdx}"`。正文可直接 `import` 并渲染 Astro 组件
|
|
74
|
-
(组件是站点直接依赖,如 `astro-icon` 需在站点 `package.json` 声明)。
|
|
75
|
-
|
|
76
|
-
> 已知上游坑(§4.67):`hast-util-to-estree@3.1.3` 依赖的 `style-to-js@1.0.0`
|
|
77
|
-
> 纯 CJS(`exports.default` 形态),ESM 默认导入得到对象而非函数,MDX 编译带
|
|
78
|
-
> style 属性的节点(EC/katex 输出)报 `styleToJs is not a function`。在
|
|
79
|
-
> `pnpm-workspace.yaml` 的 `overrides` 加 `style-to-js: ^1.1.16` 即可
|
|
80
|
-
> (1.1.x 起修正 `module.exports` 互操作)。
|
|
58
|
+
启用三步:`pnpm add @astrojs/mdx`、在 `integrations` 数组里注册(与主题顺序无关)、集合 loader 扩为 `"**/*.{md,mdx}"`。组件写作、`.md`/`.mdx` 选型,以及已知的 `style-to-js` 依赖坑(含一行 overrides 根治配方),见 demo 站《MDX 指南》一文。
|
|
81
59
|
|
|
82
60
|
### 4. 站点结构约定
|
|
83
61
|
|
|
@@ -92,188 +70,17 @@ site/
|
|
|
92
70
|
└── public/ # 静态资源(音乐/壁纸/画廊/模型等)
|
|
93
71
|
```
|
|
94
72
|
|
|
95
|
-
独立页 `friends.md` / `guestbook.md` 的 frontmatter 只需 `title
|
|
96
|
-
|
|
97
|
-
## 配置参考
|
|
98
|
-
|
|
99
|
-
> **同步约定**:主题新增或修改配置项时,本表同步更新(同一次提交内)。字段语义以 [`src/schema.ts`](./src/schema.ts) 注释为准。
|
|
100
|
-
|
|
101
|
-
### 基础信息
|
|
102
|
-
|
|
103
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
104
|
-
|---|---|---|---|
|
|
105
|
-
| `title` | `string` | (必填) | 站点名 |
|
|
106
|
-
| `description` | `string` | `""` | 站点描述(首页与 meta description 默认值) |
|
|
107
|
-
| `keywords` | `string[]` | `[]` | 站点级 SEO keywords;文章页由 frontmatter `tags` 覆盖,留空不输出 meta |
|
|
108
|
-
| `logo` | `string` | `""` | 导航栏 Logo 图片 URL(public 路径或外链),留空只渲染文字标题 |
|
|
109
|
-
| `author` | `string` | `""` | 作者名 |
|
|
110
|
-
| `accentColor` | `string` | `"#4a9d8f"` | 强调色(6 位 hex)。构建期转 HSL,全部派生色由公式计算 |
|
|
111
|
-
| `lang` | `string` | `"zh-CN"` | 站点语言(`<html lang>`),同时驱动 UI 文案(支持 `zh-CN` / `en`) |
|
|
112
|
-
| `pageWidth` | `number` | `1120` | 主容器页宽(800-1920 px),驱动主壳与文章页双列布局 |
|
|
113
|
-
| `timezone` | `string` | — | 构建期日期时区(IANA 名,如 `"Asia/Shanghai"`),留空跟随构建环境 |
|
|
114
|
-
| `since` | `string` | — | 建站日期 `YYYY-MM-DD`(站点信息卡「已运行」),不配置不显示 |
|
|
115
|
-
| `colorMode` | `"light" \| "dark" \| "system"` | `"system"` | 默认深浅模式(仅初值,用户切换后以 localStorage 为准) |
|
|
116
|
-
| `favicon` | `{src, type?, sizes?, theme?}[]` | `[]` | 数组非空时替换默认 favicon;`theme: "light"/"dark"` 按系统深浅切换 |
|
|
117
|
-
| `footer.customHtml` | `string` | `""` | 页脚自定义 HTML(备案号等),渲染在页脚最底部 |
|
|
118
|
-
|
|
119
|
-
### 导航与背景
|
|
120
|
-
|
|
121
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
122
|
-
|---|---|---|---|
|
|
123
|
-
| `nav` | `{label, href?, external?, children?}[]` | `[]` | 顶部导航。`children` 非空时父项为下拉入口;`external: true` 新窗口打开 |
|
|
124
|
-
| `banner.enabled` | `boolean` | `true` | 首页 banner 总开关(hero 文案 + 背景图合并单配置,§4.42);`false` 时两者都不渲染 |
|
|
125
|
-
| `banner.src` | `string \| string[]` | — | banner 图源。数组时构建期随机选一张(零 JS);不配置则无图(quote 有值时 hero 文案渲染为列表上方独立区块) |
|
|
126
|
-
| `banner.height` | `number` | `32` | banner 高度(10-90 vh) |
|
|
127
|
-
| `banner.dimOpacity` | `number` | `0.2` | 黑蒙层不透明度(0-1) |
|
|
128
|
-
| `banner.position` | `string` | `"center"` | 图片 object-position |
|
|
129
|
-
| `banner.homeOnly` | `boolean` | `true` | `true` 仅首页显示 |
|
|
130
|
-
| `banner.quote` / `banner.sub` | `string` | `""` | hero 主句/副句;quote 支持 `<em>` 强调色,留空不渲染。有 banner 图时叠加显示其上,否则为列表上方独立区块 |
|
|
131
|
-
| `huePanel.enabled` | `boolean` | `true` | 导航栏 hue 调色面板(预设色板 + 滑杆,运行时覆写 `--accent-h`,持久化 localStorage) |
|
|
132
|
-
|
|
133
|
-
### 文章列表与侧栏
|
|
134
|
-
|
|
135
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
136
|
-
|---|---|---|---|
|
|
137
|
-
| `postList.style` | `"cover" \| "compact"` | `"cover"` | 默认卡片形态;frontmatter `cardStyle` 可按篇覆盖、可混排 |
|
|
138
|
-
| `postList.showCover` | `boolean` | `true` | 列表封面开关(§4.55);`false` 时列表卡不渲染封面(整体降级纯文字形态) |
|
|
139
|
-
| `postList.pageSize` | `number` | `10` | 每页文章数 |
|
|
140
|
-
| `postList.fallbackCover` | `string` | — | 无图文章兜底封面;不配置则降级纯文字卡 |
|
|
141
|
-
| `postList.randomCover.apis` | `string[]` | `[]` | 随机封面图源(URL 支持 `{slug}` 占位符);frontmatter `image: "api"` 的文章从此取图,客户端失败自动重试降级 |
|
|
142
|
-
| `postList.defaultLayout` | `"list" \| "grid"` | `"list"` | 默认列表形态(§4.55):访客未手动切换过时的初始形态;移动端小屏不生效(恒为单列上下布局) |
|
|
143
|
-
| `postList.allowSwitch` | `boolean` | `false` | 导航栏右上的列表/网格切换按钮(§4.55 移至 Header,仅首页/列表页显示;访客选择持久化 localStorage) |
|
|
144
|
-
| `postList.gridColumnWidth` | `number` | `320` | 网格模式最小列宽(220-600 px) |
|
|
145
|
-
| `categoryBar.enabled` | `boolean` | `false` | 分类导航栏(首页列表与文章页正文顶部) |
|
|
146
|
-
| `toc.enabled` | `boolean` | `true` | 文章页目录 |
|
|
147
|
-
| `archive.fold` | `boolean` | `false` | 归档页按年折叠(最新一年默认展开) |
|
|
148
|
-
| `outdated.enabled` / `outdated.threshold` | `boolean` / `number` | `true` / `30` | 文章过时提醒与阈值(天,按最后更新时间计算) |
|
|
149
|
-
| `sidebar.onPost` | `boolean` | `false` | 文章详情页是否启用侧栏 |
|
|
150
|
-
| `sidebar.widgets` | `{type, enabled?, showOnPost?, postOnly?, hideOnMobile?, position?, specificConfig?}[]` | 公告/分类/标签/友链/日历/TOC/站点信息/广告 | widget 顺序即渲染顺序,公共配置(开关/显隐/位置)配在数组项上,专属参数统一收进 `specificConfig` 入口(§4.52)。类型:`announcement` `categories` `tags` `friends` `calendar` `toc` `siteInfo` `advertisement` `profile`;`showOnPost: false` 仅列表页显示,`postOnly: true` 仅文章页显示,`hideOnMobile: true` 移动端隐藏,`position: "sticky"` ≥768px 跟随滚动 |
|
|
151
|
-
| `sidebar.widgets` tags 项 `specificConfig.count` | `number` | `12` | 展示的热门标签数量(0 = 全部展示),超出收进「…更多」入口 |
|
|
152
|
-
| `sidebar.widgets` friends 项 `specificConfig.count` / `specificConfig.random` | `number` / `boolean` | `3` / `false` | 友链展示数量;`random: true` 构建期随机取样 |
|
|
153
|
-
| `sidebar.widgets` calendar 项 `specificConfig.postTooltip` | `boolean` | `false` | `true` 时日历下方文章列表条目悬停显示全文 title |
|
|
154
|
-
| `announcement` | `{title, content, link{text,url,external}, closable}` | content 空 | 侧栏公告卡;`content` 留空整卡不渲染 |
|
|
155
|
-
| `advertisement` | `{enabled, title, image, imageLink, imageAlt, content, link{text,url,external}, closable, displayCount, expireDate?}` | `enabled: false` | 侧栏广告位;`displayCount` 按 localStorage 计数限次(-1 不限),`expireDate` 过期不渲染 |
|
|
156
|
-
| `profile` | `{avatar, name, bio, url, links[{name,url,icon,showName}]}` | 全空 | 侧栏个人资料卡(`sidebar.widgets` 含 `profile` 时渲染);`icon` 填内置图标名或内联 SVG,见「社交图标」 |
|
|
157
|
-
|
|
158
|
-
#### 社交图标
|
|
159
|
-
|
|
160
|
-
`profile.links[].icon` 三种写法:**内置简名**(推荐,构建期映射到 Iconify 标准集合内联渲染,无运行时请求与 JS 开销)、**Iconify 全名**(`"集:名"`,如 `"simple-icons:bilibili"`;站点补装任意 `@iconify-json/*` 集合包即可使用,无需改主题)或**内联 SVG 字符串**(以 `<` 开头,向后兼容)。名字 trim + 转小写后解析,未命中跳过图标只渲染文字,不报错。品牌图标 `fill: currentColor`、线性图标 `stroke: currentColor`,颜色随文字继承。
|
|
161
|
-
|
|
162
|
-
基于 [astro-icon](https://docs.astro.build/en/guides/integrations-guide/icon/) + Iconify 生态(§4.65 标准化方案),内置简名 20 枚:
|
|
163
|
-
|
|
164
|
-
- 线性描边(lucide,ISC):`github` `email` `rss`
|
|
165
|
-
- 品牌填充(simple-icons,CC0):`qq` `wechat`(别名 `weixin`) `weibo` `bilibili` `xiaohongshu` `x`(别名 `twitter`) `telegram` `youtube` `instagram` `linkedin` `discord` `steam` `zhihu` `gitee` `juejin` `csdn` `douyin`(simple-icons 未收录抖音官方形,暂以内联 SVG 兜底)
|
|
166
|
-
|
|
167
|
-
图文示例见 demo 站「主题使用指南」专题的《社交图标配置》一文。
|
|
168
|
-
|
|
169
|
-
### 写作增强
|
|
170
|
-
|
|
171
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
172
|
-
|---|---|---|---|
|
|
173
|
-
| `codeTheme.light` / `codeTheme.dark` | `string` | `"github-light"` / `"github-dark"` | Expressive Code 双主题(Shiki 主题名),按站点深浅模式切换 |
|
|
174
|
-
| `codeTheme.fold` | `{enabled, lineThreshold, previewLines, defaultCollapsed}` | `false` / `15` / `8` / `true` | 长代码自动折叠(pluginCollapsible):行数超过 `lineThreshold` 折叠为 `previewLines` 行预览;meta 标 `collapse` 的块恒可手动折叠;math/mermaid/plantuml 块不参与 |
|
|
175
|
-
| `codeTheme.lineNumbers` | `boolean` | `true` | 代码块显示行号(EC pluginLineNumbers,shell 会话块自动关闭) |
|
|
176
|
-
| `codeTheme.languageBadge` | `boolean` | `false` | 代码块右上角显示语言徽标(EC pluginLanguageBadge) |
|
|
177
|
-
| `codeTheme.wrap` | `boolean` | `false` | 长行自动换行(false 时横向滚动) |
|
|
178
|
-
| `latex.enabled` | `boolean` | `false` | `$...$` / `$$...$$` 数学公式(KaTeX,含 mhchem 化学宏) |
|
|
179
|
-
| `diagram.mermaid` | `{enabled, lightTheme, darkTheme}` | `false` / `"github-light"` / `"github-dark"` | Mermaid 构建期双主题静态渲染(` ```mermaid ` 代码块) |
|
|
180
|
-
| `diagram.plantuml` | `{enabled, server, lightTheme, darkTheme}` | `false` / 官方服务 / `""` / `""` | PlantUML(` ```plantuml ` 代码块),图片由浏览器访问渲染服务加载 |
|
|
181
|
-
| `emailProtection` | `{enabled, method}` | `false` / `"base64"` | mailto 链接构建期编码防爬(`base64` / `rot13`),点击时还原 |
|
|
182
|
-
| `og.enabled` | `boolean` | `false` | 构建期生成每篇文章 OG 分享图(1200x630);Google Fonts 拉取失败降级占位图,不中断构建 |
|
|
183
|
-
| `sharePoster.enabled` | `boolean` | `true` | 文章页「生成分享海报」按钮(纯浏览器 canvas,按需加载) |
|
|
184
|
-
| `relatedPosts` | `{enabled, count, randomCount}` | `true` / `5` / `5` | 文章页相关/随机推荐(相关栏构建期评分;加密文章不进两个池) |
|
|
185
|
-
| `license` | `{enabled, name, url, prefix, suffix}` | `true` / `"CC BY-NC-SA 4.0"` / CC 链接 / `""` / `""` | 版权声明;frontmatter `licenseName/licenseUrl/license` 可按篇覆盖 |
|
|
186
|
-
|
|
187
|
-
### 独立页与数据
|
|
188
|
-
|
|
189
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
190
|
-
|---|---|---|---|
|
|
191
|
-
| `pages` | `{about, friends, guestbook, sponsor, gallery, series}` | 全 `true` | 独立页路由开关,`false` 则路由完全不生成 |
|
|
192
|
-
| `gallery` | `{title, description, albums[], columnWidth}` | 「画廊」/ `[]` / `240` | 相册数据纯配置驱动。`albums[]`: `{id, name, description?, date?, location?, tags?, cover?, password?, passwordHint?}`;照片来源 `public/gallery/<id>/` 目录扫描 + 同目录 `urls.txt` 远程列表;`password` 非空即加密相册(构建期加密) |
|
|
193
|
-
| `friends` | `{title, description, sort, apply, list[]}` | 「友链」/ weight 排序 | `list[]`: `{title, site, image?, desc?, tags?, weight?, enabled?, featured?}`;同时驱动友链页与侧栏 widget;头像留空渲染字头像 |
|
|
194
|
-
| `friends.apply` | `{title, description, link, label}` | title 空 | 申请友链卡片,title 留空整卡不渲染 |
|
|
195
|
-
| `sponsor` | `{title, description, usage, showSponsorsList, showButtonInPost, methods[], sponsors[]}` | 「打赏」 | `methods[]`: `{name, icon?, image?, link?, description?, enabled?}`(收款码与跳转链接至少配一个);`sponsors[]`: `{name, avatar?, amount?, date?}` 打赏者名单 |
|
|
196
|
-
| `comment` | `{enabled, provider, title, artalk, twikoo, waline, giscus, disqus}` | `false` / `"none"` / 「评论」 | 五套评论按 `provider` 分发;`enabled: false` 或 `provider: "none"` 页面不渲染评论区。各套子配置:`artalk{server, locale, visitorCount}`、`twikoo{envId, jsUrl, cssUrl}`、`waline{serverURL, lang, visitorCount}`、`giscus{repo, repoId, category, categoryId, mapping, strict, reactionsEnabled, emitMetadata, inputPosition, lang, loading}`、`disqus{shortname}`;对应字段留空即不启用该套 |
|
|
197
|
-
| `analytics` | `{googleAnalyticsId, microsoftClarityId, umami, la51}` | 全空 | 四选多,ID 留空即不注入。`umami{websiteId, scriptUrl, replaysScriptUrl, trackOutboundLinks, collectWebVitals, replays{...}}`;`la51{id, sdkUrl, ck, autoTrack, hashMode, screenRecord}` |
|
|
198
|
-
|
|
199
|
-
### 悬浮组件与特效
|
|
200
|
-
|
|
201
|
-
| 配置项 | 类型 | 默认值 | 说明 |
|
|
202
|
-
|---|---|---|---|
|
|
203
|
-
| `sakura` | `{enabled, density}` | `false` / `24` | 樱花飘落(全站 canvas 粒子,零贴图);`prefers-reduced-motion` 自动停用 |
|
|
204
|
-
| `music` | `{enabled, position, volume, playMode, showLyrics, playlist[]}` | `false` / `"left"` / `0.7` / `"list"` / `true` / `[]` | 音乐播放器(local 模式,`transition:persist` 跨页不断播)。`playlist[]`: `{name, artist?, url, cover?, lrc?}`;`lrc` 支持 `.lrc` 文件路径或内联歌词文本;`playMode`: `list`/`one`/`random`;音量访客调整后记忆。`enabled` 且歌单非空才渲染 |
|
|
205
|
-
| `fonts` | `{name, cssVariable, provider, weights?, styles?, subsets?, fallbacks?, display?, preload?, role?, options?}[]` | `[]` | 字体(Astro Fonts API 标准方案,§4.66):声明式接入,构建期产出 @font-face 与变量,`<head>` 渲染 `<Font>` 组件;`role` 映射主题三角色变量。详见下方「fonts 详细配置」 |
|
|
206
|
-
| `live2d` | 见下 | `enabled: false` | Live2D 看板娘(基于 [l2d-widget](https://l2d-widget.hacxy.cn)),`transition:persist` 跨页常驻 |
|
|
207
|
-
|
|
208
|
-
#### live2d 详细配置
|
|
209
|
-
|
|
210
|
-
| 子项 | 类型 | 默认值 | 说明 |
|
|
211
|
-
|---|---|---|---|
|
|
212
|
-
| `enabled` | `boolean` | `false` | 总开关 |
|
|
213
|
-
| `model` | `{path, volume?, scale?, x?, y?}` 或其数组 | — | 模型配置,数组可多模型切换(默认菜单自动出现「切换模型」)。`path` 为模型 json 的 public 路径或外链;`volume` 动作音量 0-1(默认 0 静音);`scale` 缩放;`x`/`y` 偏移 -2~2 |
|
|
214
|
-
| `position` | `"bottom-left" \| "bottom-right"` | `"bottom-left"` | 显示位置。与音乐播放器(左下)/ 返回顶部(右下)同角时会重叠,注意 `zIndex` 层级取舍 |
|
|
215
|
-
| `size` | `number` 或 `{width, height}` | `280` | 画布尺寸(px),数字为正方形边长 |
|
|
216
|
-
| `primaryColor` | `string` | `""` | 菜单/气泡等 UI 主题色,留空用库默认 |
|
|
217
|
-
| `transitionDuration` / `transitionType` | `number` / `"slide" \| "fade"` | — | 入场/退场动画时长(ms)与类型 |
|
|
218
|
-
| `menus.items` | `{icon?, label, action, url?}[]` | 主页/回到顶部/休眠(+切换模型) | 自定义菜单(覆盖默认)。`action` 内置动作:`home` `scrollToTop` `sleep` `switchModel`,或 `"link"` + `url` 跳转外链 |
|
|
219
|
-
| `menus.align` | `"left" \| "right"` | `"right"` | 菜单对齐 |
|
|
220
|
-
| `tips` | `{enable, welcomeMessage[], messages[], duration, interval, offset{x,y}, typing?}` | — | 提示气泡:欢迎语与循环提示文案(站点个性化内容,自行配置);`duration` 显示时长 / `interval` 轮换间隔(ms);不配置用库默认,`enable: false` 关闭 |
|
|
221
|
-
| `responsive` | `{hideOnMobile, mobileBreakpoint}` | `true` / `768` | 移动端按断点隐藏 |
|
|
222
|
-
| `zIndex` | `number` | `240` | 容器层级(返回顶部 250 / 音乐播放器 255,默认放其下避免按钮被挡) |
|
|
223
|
-
|
|
224
|
-
#### fonts 详细配置
|
|
225
|
-
|
|
226
|
-
| 子项 | 类型 | 默认值 | 说明 |
|
|
227
|
-
|---|---|---|---|
|
|
228
|
-
| `name` | `string` | (必填) | 字体族名(provider 侧标识,如 `"Inter"`;`local` 为自定义名) |
|
|
229
|
-
| `cssVariable` | `string` | (必填) | CSS 变量名(`--` 开头,如 `"--font-inter"`),Font 组件据此产出变量 |
|
|
230
|
-
| `provider` | `"google" \| "fontsource" \| "local" \| "bunny" \| "fontshare" \| "npm"` | (必填) | 字体来源。`google`/`fontsource`/`bunny`/`fontshare` 构建期联网拉取;`local` 配合 `options.variants` 声明本地文件;`npm` 从已装字体包读取 |
|
|
231
|
-
| `weights` | `number[]` | `[400]` | 字重档位(Astro 只下载列出的字重) |
|
|
232
|
-
| `styles` | `("normal" \| "italic" \| "oblique")[]` | `["normal", "italic"]` | 字形 |
|
|
233
|
-
| `subsets` | `string[]` | `["latin"]` | 语言子集 |
|
|
234
|
-
| `fallbacks` | `string[]` | `["sans-serif"]` | 回退字体族(Astro 自动产出 size-adjust 优化 fallback @font-face,减少布局抖动) |
|
|
235
|
-
| `display` | `"auto" \| "swap" \| "block" \| "metadata" \| "fallback"` | Astro 默认 | font-display 行为 |
|
|
236
|
-
| `preload` | `boolean` | `false` | `<link rel=preload>` 首屏预载字体文件(正文字体建议开启) |
|
|
237
|
-
| `role` | `"body" \| "display" \| "mono"` | — | 角色映射(主题层字段):`<head>` 产出 `html:root` 高优先级规则,把 `--font-body` / `--font-display` / `--font-mono` 重定向为 `var(<cssVariable>), <原角色回退栈>`——字体未覆盖的字形(如 Inter 只有 latin,中文)自然回落原栈 |
|
|
238
|
-
| `options.variants` | `{src, weight?, style?}[]` | — | `provider: "local"` 专属:本地字体文件声明(相对站点项目根) |
|
|
239
|
-
|
|
240
|
-
示例(demo 站即此配置):
|
|
241
|
-
|
|
242
|
-
```ts
|
|
243
|
-
// site/config/appearanceConfig.ts
|
|
244
|
-
export const fonts: ThemeConfig["fonts"] = [
|
|
245
|
-
{
|
|
246
|
-
name: "Inter",
|
|
247
|
-
cssVariable: "--font-inter",
|
|
248
|
-
provider: "fontsource",
|
|
249
|
-
weights: [400, 700],
|
|
250
|
-
preload: true,
|
|
251
|
-
role: "body",
|
|
252
|
-
},
|
|
253
|
-
];
|
|
254
|
-
```
|
|
73
|
+
独立页 `friends.md` / `guestbook.md` 的 frontmatter 只需 `title`;页面数据(友链列表、打赏码等)来自主题配置,见 demo 站《独立页面与数据》一文。
|
|
255
74
|
|
|
256
|
-
|
|
75
|
+
## 配置文档
|
|
257
76
|
|
|
258
|
-
|
|
77
|
+
主题配置项全部有默认值、无必填项,dev/build 启动即经 Zod schema 校验,配错直接报出。字段语义以 [`src/schema.ts`](./src/schema.ts) 注释为准。
|
|
259
78
|
|
|
260
|
-
`
|
|
79
|
+
完整配置说明与文章 frontmatter 全字段表统一放在 demo 站「主题使用指南」系列文章(源文件在本仓库 `site/src/content/posts/`),按主题分组:
|
|
261
80
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
| `description` | `string` | `""` | 摘要描述(构建期另有自动摘要插件兜底) |
|
|
266
|
-
| `published` / `updated` | `date` | 必填 / — | 发布/更新时间 |
|
|
267
|
-
| `draft` / `pinned` | `boolean` | `false` | 草稿 / 置顶 |
|
|
268
|
-
| `image` | `string` | — | 封面图;`"api"` 走 `postList.randomCover.apis` 随机图 |
|
|
269
|
-
| `tags` / `category` | `string[]` / `string` | `[]` / — | 标签 / 分类 |
|
|
270
|
-
| `password` / `passwordHint` | `string` | — | 非空即加密文章(构建期加密,密码校验在客户端) |
|
|
271
|
-
| `cardStyle` | `"cover" \| "compact" \| "text"` | — | 列表卡片形态按篇覆盖 |
|
|
272
|
-
| `series` / `seriesOrder` | `string` / `number` | — | 专题名与专题内顺序(聚合到 `/series/<name>/`) |
|
|
273
|
-
| `lang` / `author` | `string` | — | 语言 / 作者(空跟随站点) |
|
|
274
|
-
| `sourceLink` | `string` | — | 原文链接(转载) |
|
|
275
|
-
| `licenseName` / `licenseUrl` / `license` | `string` / `string` / `boolean` | — / — / `true` | 文章级版权覆盖,`license: false` 按篇关闭声明 |
|
|
276
|
-
| `comment` | `boolean` | `true` | 本篇是否展示评论 |
|
|
81
|
+
- 基础:《站点基础配置》|《导航栏与首页横幅》|《侧边栏与个人资料》|《文章列表与归档》
|
|
82
|
+
- 功能:《文章页功能》|《视觉特效与媒体》(含 fonts 详细配置)|《独立页面与数据》|《评论、公告、统计与广告》
|
|
83
|
+
- 写作:《写作指南》(frontmatter 全字段与增强语法)|《MDX 指南》(MDX 启用与踩坑修复)
|
|
277
84
|
|
|
278
85
|
## 定制与覆盖
|
|
279
86
|
|
|
@@ -286,15 +93,15 @@ export const fonts: ThemeConfig["fonts"] = [
|
|
|
286
93
|
|
|
287
94
|
## 文档同步约定
|
|
288
95
|
|
|
289
|
-
-
|
|
290
|
-
- 新增**frontmatter
|
|
96
|
+
- 新增/修改**配置项**:同一次提交内更新 demo 站对应指南文章(`site/src/content/posts/`),并在 `docs/design.md` 记录功能节(§ 编号)。
|
|
97
|
+
- 新增**frontmatter 字段**:同步更新 demo 站《写作指南》字段表。
|
|
291
98
|
- 行为变更不涉及配置面时,至少在 `docs/design.md` 留痕。
|
|
292
99
|
|
|
293
100
|
## 本仓库开发
|
|
294
101
|
|
|
295
102
|
```text
|
|
296
103
|
packages/theme 主题包(exports 直指 src,无构建步骤)
|
|
297
|
-
site 中性 demo 站点(config/
|
|
104
|
+
site 中性 demo 站点(config/ 按模块拆分配置,是指南文章与配置示例的载体)
|
|
298
105
|
docs/design.md 设计与实现记录(§ 编号推进,含踩坑与验证口径)
|
|
299
106
|
```
|
|
300
107
|
|
package/package.json
CHANGED
|
@@ -1,71 +1,71 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
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
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
rehypeMermaid,
|
|
48
48
|
rehypePlantuml,
|
|
49
49
|
} from "./plugins/rehype-diagram.js";
|
|
50
|
+
import { rehypeEcVtRerun } from "./plugins/rehype-ec-vt-rerun.js";
|
|
50
51
|
import { rehypeEmailProtection } from "./plugins/rehype-email.js";
|
|
51
52
|
import { rehypeGithubCard } from "./plugins/rehype-github-card.js";
|
|
52
53
|
import { remarkMermaid, remarkPlantuml } from "./plugins/remark-diagram.js";
|
|
@@ -242,6 +243,10 @@ export function defineTheme(userConfig: ThemeConfig): AstroIntegration[] {
|
|
|
242
243
|
frames: { showCopyToClipboardButton: true },
|
|
243
244
|
},
|
|
244
245
|
]);
|
|
246
|
+
// §4.74:必须在 EC 之后追加——社区折叠插件(expressive-code-collapsible)
|
|
247
|
+
// 注入的内联脚本无 View Transitions 适配,ClientRouter 换页后按钮绑定
|
|
248
|
+
// 失效;此插件给该脚本打 data-astro-rerun 标记,swap 时由 Astro 重执行
|
|
249
|
+
rehypePlugins.push(rehypeEcVtRerun);
|
|
245
250
|
updateConfig({
|
|
246
251
|
// 主题路由全部按尾斜杠组织(如 /posts/xxx/),统一强制,dev 与 prod 行为一致
|
|
247
252
|
trailingSlash: "always",
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §4.74:Astro ClientRouter(View Transitions)下 EC 社区折叠插件的换页失效修复。
|
|
3
|
+
* §4.76:二次根因修正——data-astro-rerun 确实会重执行脚本,但被脚本自身的
|
|
4
|
+
* window 级幂等守卫拦截(早退),新页按钮依然无监听;首版修复曾试图把守卫
|
|
5
|
+
* 标志位改名规避,实测仍有同会话跨页残留(写作指南页无折叠块但注入脚本并
|
|
6
|
+
* 置标志,再 VT 进快速开始时读标志早退),最终改为整段删除守卫与赋值。
|
|
7
|
+
*
|
|
8
|
+
* 现象:经系列导航等 VT 换页进入新文章后,折叠代码块的展开按钮点击无效。
|
|
9
|
+
*
|
|
10
|
+
* 根因(两层):
|
|
11
|
+
* 1. expressive-code-collapsible@0.1.0 的交互脚本只在 DOMContentLoaded
|
|
12
|
+
* 绑定一次,且 MutationObserver 闭包引用换页前的旧 body——ClientRouter
|
|
13
|
+
* swap 整体替换 body 后:内联脚本不重新执行、observer 观察的旧 body 已
|
|
14
|
+
* 脱离文档,新页面的 .ec-collapse__toggle 无 click 监听。
|
|
15
|
+
* (EC core 官方脚本已用 astro:page-load 自适配 VT,copy 按钮等不受影响。)
|
|
16
|
+
* → 修复:标记 data-astro-rerun 让 ClientRouter swap 时重执行该脚本。
|
|
17
|
+
* 2. §4.76 两轮实测发现重执行仍无效:脚本首两行是 window 级早退守卫
|
|
18
|
+
* (if (window.ecCollapsibleInit) return; window.ecCollapsibleInit = true;),
|
|
19
|
+
* ClientRouter 换页只换 body 不换 window——标志位在同一浏览器会话内跨页
|
|
20
|
+
* 存活:任何注入过该脚本的页面(含无折叠代码块的文章,脚本照注入置位)
|
|
21
|
+
* 之后 VT 进入有折叠块的文章时,重执行被早退拦截,绑定整体短路。
|
|
22
|
+
* → 修复:脚本文本中整段删除该守卫与标志赋值,每次执行都完整初始化。
|
|
23
|
+
* 安全性:按钮级 dataset.init 守卫防重复绑定;MutationObserver 每次重
|
|
24
|
+
* 建并观察当前 body,旧 observer 附着已脱离文档的旧 body,随其 GC;
|
|
25
|
+
* data-astro-rerun 每次 swap 恰执行一次、full load 一次,无同 DOM 重入;
|
|
26
|
+
* 离开含折叠块的页面后脚本不存在、不执行,无额外开销。
|
|
27
|
+
*
|
|
28
|
+
* 仅含折叠代码块的页面才有该脚本;若上游插件改守卫名或改结构,删除正则
|
|
29
|
+
* 不命中即退化为 §4.74 行为(仅重执行,不删守卫),不会更糟。
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** 内联脚本特征:社区折叠插件按钮选择器,官方 EC 脚本不含此串 */
|
|
33
|
+
const COLLAPSIBLE_SCRIPT_MARKER = "ec-collapse__toggle";
|
|
34
|
+
|
|
35
|
+
/** window 级早退守卫:if (window.ecCollapsibleInit) return; (含前导空白) */
|
|
36
|
+
const GUARD_RE = /\s*if\s*\(\s*window\.ecCollapsibleInit\s*\)\s*return\s*;?/;
|
|
37
|
+
/** 守卫标志赋值:window.ecCollapsibleInit = true; (含前导空白) */
|
|
38
|
+
const FLAG_SET_RE = /\s*window\.ecCollapsibleInit\s*=\s*true\s*;?/;
|
|
39
|
+
|
|
40
|
+
interface HastNode {
|
|
41
|
+
type: string;
|
|
42
|
+
tagName?: string;
|
|
43
|
+
properties?: Record<string, unknown>;
|
|
44
|
+
children?: HastNode[];
|
|
45
|
+
value?: unknown;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function scriptText(node: HastNode): string {
|
|
49
|
+
return (node.children ?? [])
|
|
50
|
+
.map((child) => (typeof child.value === "string" ? child.value : ""))
|
|
51
|
+
.join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function walk(node: HastNode): void {
|
|
55
|
+
for (const child of node.children ?? []) {
|
|
56
|
+
if (child.type !== "element") continue;
|
|
57
|
+
if (
|
|
58
|
+
child.tagName === "script" &&
|
|
59
|
+
!child.properties?.src &&
|
|
60
|
+
scriptText(child).includes(COLLAPSIBLE_SCRIPT_MARKER)
|
|
61
|
+
) {
|
|
62
|
+
child.properties["data-astro-rerun"] = true;
|
|
63
|
+
// 删除 window 级早退守卫(§4.76 根因 2):守卫标志在 ClientRouter
|
|
64
|
+
// 换页间随 window 存活,任何先访问过含该脚本页面的会话都会让后续
|
|
65
|
+
// 重执行早退短路;删除后每次执行都完整绑定新页按钮
|
|
66
|
+
for (const text of child.children ?? []) {
|
|
67
|
+
if (typeof text.value === "string") {
|
|
68
|
+
text.value = text.value
|
|
69
|
+
.replace(GUARD_RE, "")
|
|
70
|
+
.replace(FLAG_SET_RE, "");
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
walk(child);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function rehypeEcVtRerun() {
|
|
79
|
+
return (tree: HastNode) => walk(tree);
|
|
80
|
+
}
|
|
@@ -17,7 +17,13 @@ import config from "virtual:theme/config";
|
|
|
17
17
|
import { t } from "../../i18n/index.js";
|
|
18
18
|
import { getLqipStyle } from "../../utils/lqip.js";
|
|
19
19
|
import { getRelatedPosts } from "../../utils/related.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
formatDate,
|
|
22
|
+
getSortedPosts,
|
|
23
|
+
resolveCover,
|
|
24
|
+
type SeriesEntry,
|
|
25
|
+
seriesEntries,
|
|
26
|
+
} from "../../utils.js";
|
|
21
27
|
|
|
22
28
|
export async function getStaticPaths() {
|
|
23
29
|
const all = await getCollection("posts", ({ data }) =>
|
|
@@ -25,7 +31,8 @@ export async function getStaticPaths() {
|
|
|
25
31
|
);
|
|
26
32
|
const sorted = getSortedPosts(all);
|
|
27
33
|
|
|
28
|
-
// 专题索引(§4.31):
|
|
34
|
+
// 专题索引(§4.31→§4.75):seriesEntries 分块规范化(未分组块最前、
|
|
35
|
+
// 各分组按组内首篇顺序排列、全局序号连续),与专题详情页共用同一规则
|
|
29
36
|
const seriesGroups = new Map<string, typeof sorted>();
|
|
30
37
|
for (const post of sorted) {
|
|
31
38
|
const name = post.data.series;
|
|
@@ -34,40 +41,43 @@ export async function getStaticPaths() {
|
|
|
34
41
|
if (list) list.push(post);
|
|
35
42
|
else seriesGroups.set(name, [post]);
|
|
36
43
|
}
|
|
37
|
-
const seriesSorted = new Map<
|
|
44
|
+
const seriesSorted = new Map<
|
|
45
|
+
string,
|
|
46
|
+
SeriesEntry<(typeof sorted)[number]>[]
|
|
47
|
+
>();
|
|
38
48
|
for (const [name, list] of seriesGroups) {
|
|
39
|
-
seriesSorted.set(
|
|
40
|
-
name,
|
|
41
|
-
[...list].sort((a, b) => {
|
|
42
|
-
const oa = a.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
|
|
43
|
-
const ob = b.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
|
|
44
|
-
if (oa !== ob) return oa - ob;
|
|
45
|
-
return a.data.published.getTime() - b.data.published.getTime();
|
|
46
|
-
}),
|
|
47
|
-
);
|
|
49
|
+
seriesSorted.set(name, seriesEntries(list));
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
// sorted 内 idx+1 为更早一篇(prev),idx-1 为更新一篇(next)
|
|
51
53
|
return sorted.map((post, idx) => {
|
|
52
|
-
// 专题导航(§4.31):当前篇在专题内的位置 +
|
|
54
|
+
// 专题导航(§4.31):当前篇在专题内的位置 + 完整目录(含分块组头)
|
|
53
55
|
let seriesInfo: {
|
|
54
56
|
name: string;
|
|
55
57
|
total: number;
|
|
56
58
|
cur: number;
|
|
57
|
-
items: {
|
|
59
|
+
items: {
|
|
60
|
+
title: string;
|
|
61
|
+
id: string;
|
|
62
|
+
cur: boolean;
|
|
63
|
+
idx: number;
|
|
64
|
+
groupHead: string | null;
|
|
65
|
+
}[];
|
|
58
66
|
} | null = null;
|
|
59
67
|
const sName = post.data.series;
|
|
60
68
|
if (sName) {
|
|
61
|
-
const
|
|
62
|
-
const sIdx =
|
|
69
|
+
const entries = seriesSorted.get(sName)!;
|
|
70
|
+
const sIdx = entries.findIndex((e) => e.post.id === post.id);
|
|
63
71
|
seriesInfo = {
|
|
64
72
|
name: sName,
|
|
65
|
-
total:
|
|
73
|
+
total: entries.length,
|
|
66
74
|
cur: sIdx + 1,
|
|
67
|
-
items:
|
|
68
|
-
title:
|
|
69
|
-
id:
|
|
70
|
-
cur:
|
|
75
|
+
items: entries.map((e) => ({
|
|
76
|
+
title: e.post.data.title,
|
|
77
|
+
id: e.post.id,
|
|
78
|
+
cur: e.index === sIdx,
|
|
79
|
+
idx: e.index + 1,
|
|
80
|
+
groupHead: e.groupHead,
|
|
71
81
|
})),
|
|
72
82
|
};
|
|
73
83
|
}
|
|
@@ -244,20 +254,23 @@ const jsonLd = {
|
|
|
244
254
|
</span>
|
|
245
255
|
</div>
|
|
246
256
|
<ol class="series-nav-list">
|
|
247
|
-
{seriesInfo.items.map((item
|
|
248
|
-
|
|
249
|
-
{item.
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
<span class="series-nav-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
<
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
257
|
+
{seriesInfo.items.map((item) => (
|
|
258
|
+
<>
|
|
259
|
+
{item.groupHead && <li class="series-nav-group" aria-hidden="true">{item.groupHead}</li>}
|
|
260
|
+
<li class:list={["series-nav-item", { cur: item.cur }]}>
|
|
261
|
+
{item.cur ? (
|
|
262
|
+
<span class="series-nav-row">
|
|
263
|
+
<span class="series-nav-idx">{item.idx}</span>
|
|
264
|
+
<span class="series-nav-label">{item.title}</span>
|
|
265
|
+
</span>
|
|
266
|
+
) : (
|
|
267
|
+
<a class="series-nav-row" href={`/posts/${item.id}/`}>
|
|
268
|
+
<span class="series-nav-idx">{item.idx}</span>
|
|
269
|
+
<span class="series-nav-label">{item.title}</span>
|
|
270
|
+
</a>
|
|
271
|
+
)}
|
|
272
|
+
</li>
|
|
273
|
+
</>
|
|
261
274
|
))}
|
|
262
275
|
</ol>
|
|
263
276
|
</nav>
|
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
import { getCollection } from "astro:content";
|
|
3
3
|
import BaseLayout from "virtual:theme/components/BaseLayout.astro";
|
|
4
4
|
import { t } from "../../i18n/index.js";
|
|
5
|
-
import { formatDate, type PostLike } from "../../utils.js";
|
|
5
|
+
import { formatDate, type PostLike, seriesEntries } from "../../utils.js";
|
|
6
6
|
import { hydratePosts } from "../_hydrate.js";
|
|
7
7
|
|
|
8
|
-
// 专题详情页(§4.31
|
|
8
|
+
// 专题详情页(§4.31→§4.75):seriesEntries 分块规范化——
|
|
9
|
+
// 未分组文章块最前,各分组按组内首篇顺序排列,全局序号连续
|
|
9
10
|
export async function getStaticPaths() {
|
|
10
11
|
const all = await getCollection("posts", ({ data }) =>
|
|
11
12
|
import.meta.env.PROD ? !data.draft : true,
|
|
@@ -21,18 +22,17 @@ export async function getStaticPaths() {
|
|
|
21
22
|
else groups.set(name, [post]);
|
|
22
23
|
}
|
|
23
24
|
|
|
24
|
-
return [...groups.entries()].map(([name, list]) => {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
if (oa !== ob) return oa - ob;
|
|
29
|
-
return a.data.published.getTime() - b.data.published.getTime();
|
|
30
|
-
});
|
|
31
|
-
return { params: { name }, props: { name, posts: sorted } };
|
|
32
|
-
});
|
|
25
|
+
return [...groups.entries()].map(([name, list]) => ({
|
|
26
|
+
params: { name },
|
|
27
|
+
props: { name, entries: seriesEntries(list) },
|
|
28
|
+
}));
|
|
33
29
|
}
|
|
34
30
|
|
|
35
|
-
const { name,
|
|
31
|
+
const { name, entries } = Astro.props as {
|
|
32
|
+
name: string;
|
|
33
|
+
entries: ReturnType<typeof seriesEntries>;
|
|
34
|
+
};
|
|
35
|
+
const posts = entries.map((e) => e.post);
|
|
36
36
|
const latest = posts.reduce(
|
|
37
37
|
(max, p) => (p.data.published > max ? p.data.published : max),
|
|
38
38
|
posts[0]!.data.published,
|
|
@@ -49,14 +49,17 @@ const latest = posts.reduce(
|
|
|
49
49
|
</header>
|
|
50
50
|
<ol class="series-list">
|
|
51
51
|
{
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
52
|
+
entries.map((entry) => (
|
|
53
|
+
<>
|
|
54
|
+
{entry.groupHead && <li class="series-group-title" aria-hidden="true">{entry.groupHead}</li>}
|
|
55
|
+
<li class="series-item reveal">
|
|
56
|
+
<a class="series-link card" href={`/posts/${entry.post.id}/`}>
|
|
57
|
+
<span class="series-idx">{entry.index + 1}</span>
|
|
58
|
+
<span class="series-title">{entry.post.data.title}</span>
|
|
59
|
+
<span class="series-date">{formatDate(entry.post.data.published)}</span>
|
|
60
|
+
</a>
|
|
61
|
+
</li>
|
|
62
|
+
</>
|
|
60
63
|
))
|
|
61
64
|
}
|
|
62
65
|
</ol>
|
package/src/schema.ts
CHANGED
|
@@ -1207,6 +1207,12 @@ export function definePostSchema(extensions: z.ZodRawShape = {}) {
|
|
|
1207
1207
|
series: z.string().optional(),
|
|
1208
1208
|
/** 专题内顺序(小在前);未配置则按发布时间升序 */
|
|
1209
1209
|
seriesOrder: z.number().optional(),
|
|
1210
|
+
/**
|
|
1211
|
+
* 专题内分组(§4.75):专题页与文章页专题目录按组分块展示
|
|
1212
|
+
* (未分组文章块在最前,各分组按组内首篇顺序依次排列,全局序号连续)。
|
|
1213
|
+
* 组名直接来自本字段文本,无 i18n 参与。
|
|
1214
|
+
*/
|
|
1215
|
+
seriesGroup: z.string().optional(),
|
|
1210
1216
|
})
|
|
1211
1217
|
.extend(extensions);
|
|
1212
1218
|
}
|
package/src/styles/global.css
CHANGED
|
@@ -2705,6 +2705,28 @@ button.archive-year .year-caret {
|
|
|
2705
2705
|
display: grid;
|
|
2706
2706
|
gap: 10px;
|
|
2707
2707
|
}
|
|
2708
|
+
/* §4.75 专题分组:详情页组头(组内首条之前的小标题) */
|
|
2709
|
+
.series-group-title {
|
|
2710
|
+
margin-top: 16px;
|
|
2711
|
+
padding-left: 2px;
|
|
2712
|
+
font-size: 13px;
|
|
2713
|
+
font-weight: 700;
|
|
2714
|
+
letter-spacing: 0.04em;
|
|
2715
|
+
color: var(--muted);
|
|
2716
|
+
display: flex;
|
|
2717
|
+
align-items: center;
|
|
2718
|
+
gap: 8px;
|
|
2719
|
+
}
|
|
2720
|
+
.series-group-title::before {
|
|
2721
|
+
content: "";
|
|
2722
|
+
width: 14px;
|
|
2723
|
+
height: 3px;
|
|
2724
|
+
border-radius: 2px;
|
|
2725
|
+
background: var(--accent);
|
|
2726
|
+
}
|
|
2727
|
+
.series-group-title:first-child {
|
|
2728
|
+
margin-top: 0;
|
|
2729
|
+
}
|
|
2708
2730
|
.series-link {
|
|
2709
2731
|
display: flex;
|
|
2710
2732
|
align-items: center;
|
|
@@ -2794,6 +2816,18 @@ button.archive-year .year-caret {
|
|
|
2794
2816
|
display: grid;
|
|
2795
2817
|
gap: 2px;
|
|
2796
2818
|
}
|
|
2819
|
+
/* §4.75 专题分组:文章页专题目录组头 */
|
|
2820
|
+
.series-nav-group {
|
|
2821
|
+
margin-top: 8px;
|
|
2822
|
+
padding-left: 2px;
|
|
2823
|
+
font-size: 12px;
|
|
2824
|
+
font-weight: 700;
|
|
2825
|
+
letter-spacing: 0.04em;
|
|
2826
|
+
color: var(--muted);
|
|
2827
|
+
}
|
|
2828
|
+
.series-nav-group:first-child {
|
|
2829
|
+
margin-top: 0;
|
|
2830
|
+
}
|
|
2797
2831
|
.series-nav-item {
|
|
2798
2832
|
position: relative;
|
|
2799
2833
|
padding-left: 14px;
|
package/src/utils.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface PostLike {
|
|
|
41
41
|
series?: string;
|
|
42
42
|
/** 专题内顺序(小在前);未配置则按发布时间升序 */
|
|
43
43
|
seriesOrder?: number;
|
|
44
|
+
/** 专题内分组(§4.75):专题页/专题目录按组分块展示 */
|
|
45
|
+
seriesGroup?: string;
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -89,6 +91,56 @@ export function getSortedPosts<T extends PostLike>(posts: T[]): T[] {
|
|
|
89
91
|
});
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
/** 专题内排序比较器:seriesOrder 升序(未配置排最后),同序按发布时间升序 */
|
|
95
|
+
function compareSeriesOrder<T extends PostLike>(a: T, b: T): number {
|
|
96
|
+
const oa = a.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
|
|
97
|
+
const ob = b.data.seriesOrder ?? Number.MAX_SAFE_INTEGER;
|
|
98
|
+
if (oa !== ob) return oa - ob;
|
|
99
|
+
return a.data.published.getTime() - b.data.published.getTime();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 专题渲染序列条目:groupHead 非空 = 该条目是新分块首条,值为组名(未分组块无头) */
|
|
103
|
+
export interface SeriesEntry<T> {
|
|
104
|
+
post: T;
|
|
105
|
+
/** 全局序号(0 起,贯穿分块连续编号) */
|
|
106
|
+
index: number;
|
|
107
|
+
groupHead: string | null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 专题分块序列(§4.75):按 seriesOrder 排序后分块规范化——
|
|
112
|
+
* 未分组文章为最前块,各分组(seriesGroup 非空)按组内首篇顺序依次排列。
|
|
113
|
+
* 同组文章 seriesOrder 不连续时自动归拢,不依赖作者保证组内连续。
|
|
114
|
+
* 序号贯穿所有分块连续递增,供专题页与文章页专题目录共用。
|
|
115
|
+
*/
|
|
116
|
+
export function seriesEntries<T extends PostLike>(
|
|
117
|
+
posts: T[],
|
|
118
|
+
): SeriesEntry<T>[] {
|
|
119
|
+
const sorted = [...posts].sort(compareSeriesOrder);
|
|
120
|
+
const entries: SeriesEntry<T>[] = [];
|
|
121
|
+
const groups = new Map<string, T[]>();
|
|
122
|
+
for (const post of sorted) {
|
|
123
|
+
const group = post.data.seriesGroup;
|
|
124
|
+
if (!group) {
|
|
125
|
+
entries.push({ post, index: entries.length, groupHead: null });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const list = groups.get(group);
|
|
129
|
+
if (list) list.push(post);
|
|
130
|
+
else groups.set(group, [post]);
|
|
131
|
+
}
|
|
132
|
+
for (const [group, list] of groups) {
|
|
133
|
+
for (const post of list) {
|
|
134
|
+
entries.push({
|
|
135
|
+
post,
|
|
136
|
+
index: entries.length,
|
|
137
|
+
groupHead: post === list[0] ? group : null,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return entries;
|
|
142
|
+
}
|
|
143
|
+
|
|
92
144
|
/** 按标签聚合:Map<标签名, 文章[]>,标签按文章数降序 */
|
|
93
145
|
export function collectTags<T extends PostLike>(posts: T[]): Map<string, T[]> {
|
|
94
146
|
const map = new Map<string, T[]>();
|