fix: 按 Halo 应用市场审核意见整改外部资源与隐私(v1.2.5 修订)

- 默认零第三方自动请求:移除 21 个模板的 preconnect/dns-prefetch、PrismJS+autoloader、qrcode-generator 及内置一言/IP 定位 API
- 一言与代码高亮配色改为自托管;欢迎弹窗自定义 IP 接口改为 opt-in;微信分享改为复制链接;LightGallery 按 GPLv3 使用
- README 新增「外部资源与隐私」「验证与回归测试」章节,修正无效链接并补充字体 OFL 来源;同步 docs 与修正虚构的目录树
- friends 等 12 处 #strings.substring 加空值保护;logo 由两份 1.5MB 压缩去重为单份 146KB
- 新增 pnpm audit:external 与打包前置合规审计;制品补齐 34 个 docs 文档与预览图
This commit is contained in:
nxxy335 committed 2026-11-11 14:37:09 +08:00
1 parent c0ad7331d4
commit dda86294b0
36 files changed
+829 -235

No files matched your search

+96 -21
View File
@@ -2,7 +2,7 @@
暖如灯火,静如留白。暖屿以柔和的色调和舒缓的节奏回归内容本身,为记录与阅读留出一方安静从容的角落。 暖如灯火,静如留白。暖屿以柔和的色调和舒缓的节奏回归内容本身,为记录与阅读留出一方安静从容的角落。
> 本项目由 AI 开发完成,人工仅进行部分功能测试及问题找寻 > 本项目使用 AI 辅助开发。开发者已按 Halo 主题规范完成代码审查、构建产物核验、外部资源审计与主要模板的回归检查清单(见[验证与回归测试](#验证与回归测试));主题不加载远程脚本、不动态下载代码、不进行未披露的数据收集。
## 环境要求 ## 环境要求
@@ -13,14 +13,59 @@
1. **响应式设计** — 适配桌面端与移动端,支持暗色模式,移动端使用动态视口单位适配地址栏 1. **响应式设计** — 适配桌面端与移动端,支持暗色模式,移动端使用动态视口单位适配地址栏
2. **插件兼容性** — 适配 11 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观 2. **插件兼容性** — 适配 11 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
3. **内置灯箱** — 主题内置 LightGallery 灯箱,支持 EXIF 信息展示;安装 lightgallery.js 插件后自动禁用 3. **内置灯箱** — 主题内置 LightGallery 灯箱(GPLv3,随主题自托管打包),支持 EXIF 信息展示;安装 lightgallery.js 插件后自动禁用
4. **可配置性** — 17 个设置分组、122 个配置项,几乎所有模块可独立开关 4. **可配置性** — 17 个设置分组、122 个配置项,几乎所有模块可独立开关
5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅首屏边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](docs/customize/development.md) 5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅首屏边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](https://nxxy335.top/WarmIsland/customize/development.html)
6. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局 6. **零默认外部请求** — 所有脚本、样式、字体、图标、句子库、主题图标均随主题自托管,默认不向任何第三方发起自动请求;详见[外部资源与隐私](#外部资源与隐私)
7. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局
## 预览 ## 预览
![WarmIsland Preview](./WarmIsland%20Preview.webp) ![WarmIsland Preview](https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland/raw/branch/main/WarmIsland%20Preview.webp)
> 预览图同时随安装包分发(包根目录 `WarmIsland Preview.webp`),离线查看制品时也可打开。
## 外部资源与隐私
本章节对应 [Halo 应用市场审核指南](https://docs.halo.run/developer-guide/app-store/app-review-guidelines) 1.4 / 4.4.4 / 5.1,逐项说明服务名称、用途、数据类型、默认状态与关闭方法。
### 默认行为
**默认配置下,主题不会自动向任何第三方服务发起请求。** 页面加载的 CSS、JS、字体(LXGW WenKai 本地分片)、图标、主题 Logo、一言句子库全部来自站点自身的 `/themes/warm-island/assets/`,不经过 CDN,也不使用 `preconnect` / `dns-prefetch`。已移除的远程依赖见下方[自托管与许可](#自托管与许可)。
### 逐项说明
| 服务 / 资源 | 用途 | 涉及的数据类型 | 默认状态 | 关闭 / 替换方法 |
| --- | --- | --- | --- | --- |
| 主题自身静态资源 `/themes/warm-island/assets/*` | 样式、脚本、字体、图标、Logo、内置句子库 | 无(同域请求,不含用户数据) | 始终启用,渲染必需 | 不可关闭;均为随包自托管文件 |
| 自定义一言 API `hero_hitokoto_api` | Hero 首屏随机句子 | 无请求体;发起 GET 时第三方可见访客 IP / User-Agent | **关闭**:留空,使用 `assets/data/hitokoto.json` 内置本地句子库 | 主题设置 → Hero 首屏 → 清空「自定义一言 API」 |
| 自定义 IP 定位主 / 备选 API `welcome_popup_api` / `welcome_popup_fallback_api` | 欢迎弹窗显示「欢迎来自 XX 的朋友」 | **访客 IP** 与第三方返回的粗略地理位置 | **关闭**:两项均留空,弹窗只显示本地欢迎语;且欢迎弹窗开关本身默认关闭 | 主题设置 → 欢迎弹窗 → 关闭开关,或清空两个 API 字段 |
| 分享按钮:QQ / 微博 / X / Facebook / Telegram | 分享文章到对应平台 | 文章 URL 与标题(写在目标站 URL 参数中) | 默认开启 `qq,wechat,weibo`;**仅在访客点击后**跳转,页面加载时不请求 | 主题设置 → 文章 → 关闭「显示分享按钮」,或在「分享渠道」取消勾选 |
| 微信分享弹层 | 生成并复制文章链接 | 文章 URL(浏览器本地字符串,不发送) | 与分享按钮一致 | 同上;已移除二维码远程库,改为「复制链接」 |
| 页脚备案 / Halo / 应用市场外链 | 备案信息、致谢、插件安装提示 | 无(访客主动点击才跳转) | 由站点配置决定,默认仅显示 Halo 致谢 | 主题设置 → 页脚 → 关闭对应显示项 |
| 插件前台页面(豆瓣、Steam、朋友圈、微信读书等) | 展示插件提供的数据 | 由对应插件决定,主题不发起请求 | 安装并启用对应插件后才出现 | 卸载 / 停用对应插件;主题自动降级隐藏 |
| 内置 LightGallery 灯箱 | 图片放大预览、EXIF 展示 | 无(本地打包,不联网) | 默认启用 | 安装 lightgallery.js 插件后主题自动让位 |
> 自定义 API 属于**站点管理员主动配置**的可选能力:一旦填写,浏览器会按设置向该第三方发起请求。请自行评估服务商的隐私政策,并在站点隐私说明中补充披露;主题不会代替站点做这项声明。
### 隐私说明(5.1)
| 项目 | 说明 |
| --- | --- |
| 收集的数据类型 | 默认配置下**无**。主题不读取、不上传用户信息、站点内容、访问日志或订单数据 |
| 数据用途 | 仅用于页面渲染;自定义 API 场景下由管理员自行决定 |
| 保存位置与期限 | 主题不写入数据库、不上传数据、不生成服务端日志。前端仅在访客本机使用 `sessionStorage`(欢迎弹窗「同会话仅显示一次」)与 `localStorage`(点赞状态),可随时清除浏览器站点数据 |
| 是否传输到第三方 | 默认**否**。仅当管理员显式填写「自定义一言 API」或「自定义 IP 定位 API」时才会向该服务发起请求,其中 IP 定位会暴露访客 IP |
| 关闭 / 撤销方式 | 见上表;清空相应设置项或关闭对应开关即可立即停止,无需重装主题 |
| 遥测 / 统计 / 崩溃上报 / 广告 / AI 分析 / 跨站追踪 | **均不存在**。主题不含任何埋点、指纹或第三方统计脚本 |
### 自托管与许可
- **已移除的远程依赖**:`cdn.jsdelivr.net` 预连接与预解析(原出现在全部模板)、PrismJS 1.29.0 核心与 `autoloader`(会按代码语言从 CDN 动态拉取并执行组件)、`qrcode-generator`、默认的一言与 IP 定位接口。
- **代码高亮**:主题不再自带 Prism.js 运行时。语法高亮请安装 Halo 应用市场的代码高亮插件,或启用「API 扩展包」的服务端代码高亮;主题只提供两套自托管的 `.token` 配色(`assets/vendor/prism-warm.css` / `prism-cold.css`),由「文章 → 代码高亮主题」切换。
- **字体**:[霞鹜文楷 LXGW WenKai](https://github.com/lxgw/LxgwWenKai) 以 [SIL OFL 1.1](https://openfontlicense.org/) 发布,由 `cn-font-split` 切分为本地 woff2 分片随主题分发;字体来源与版权声明保留在 `public/assets/fonts/LXGWWenKai/result.css` 中,无远程字体请求。
- **灯箱**:LightGallery 2.9.0 以 GPLv3 发布,本主题同样采用 GPL-3.0,按开源许可条款使用(非商业授权);已去除占位 `licenseKey`,控制台不再出现 `license key is not valid for production use`。
- **图标**:来源与协议见[图标与字体来源](#图标与字体来源)。
## 已适配插件 ## 已适配插件
@@ -29,7 +74,7 @@
| [瞬间](https://halo.run/store/apps/app-SnwWD) | `PluginMoments` | `/moments` | 瞬间动态页面,支持点赞、评论、标签筛选、顶部封面 | | [瞬间](https://halo.run/store/apps/app-SnwWD) | `PluginMoments` | `/moments` | 瞬间动态页面,支持点赞、评论、标签筛选、顶部封面 |
| [图库](https://halo.run/store/apps/app-BmQJW) | `PluginPhotos` | `/photos` | 图片展示页面,支持分组、灯箱预览 | | [图库](https://halo.run/store/apps/app-BmQJW) | `PluginPhotos` | `/photos` | 图片展示页面,支持分组、灯箱预览 |
| [友情链接](https://halo.run/store/apps/app-hfbQg) | `PluginLinks` | `/links` | 友链展示页面,支持随机排列、随机传送门、评论区开关 | | [友情链接](https://halo.run/store/apps/app-hfbQg) | `PluginLinks` | `/links` | 友链展示页面,支持随机排列、随机传送门、评论区开关 |
| [朋友圈](https://github.com/halo-sigs/plugin-friends) | `plugin-friends` | `/friends` | RSS 订阅朋友圈页面 | | [朋友圈](https://www.halo.run/store/apps/app-yISsV) | `plugin-friends` | `/friends` | RSS 订阅朋友圈页面 |
| [装备管理](https://github.com/chengzhongxue/plugin-equipment) | `equipment` | `/equipments` | 装备展示页面,按分组展示,支持封面图 | | [装备管理](https://github.com/chengzhongxue/plugin-equipment) | `equipment` | `/equipments` | 装备展示页面,按分组展示,支持封面图 |
| [豆瓣](https://github.com/chengzhongxue/plugin-douban) | `plugin-douban` | `/douban` | 豆瓣记录页面,支持类型/状态/标签筛选,默认音乐;需插件 **v1.2.6+**(SQLite 数据结构) | | [豆瓣](https://github.com/chengzhongxue/plugin-douban) | `plugin-douban` | `/douban` | 豆瓣记录页面,支持类型/状态/标签筛选,默认音乐;需插件 **v1.2.6+**(SQLite 数据结构) |
| [Steam 信息展示](https://github.com/Tim0x0/halo-plugin-steam) | `PluginSteam` | `/steam` | Steam 游戏库页面,含用户资料、统计数据、最近游玩、游戏库分页 | | [Steam 信息展示](https://github.com/Tim0x0/halo-plugin-steam) | `PluginSteam` | `/steam` | Steam 游戏库页面,含用户资料、统计数据、最近游玩、游戏库分页 |
@@ -54,16 +99,16 @@
### 首页 ### 首页
- Hero 首屏:明暗双背景图、大标题/副标题独立配色、一言(支持自动刷新与淡入淡出过渡)、下滑箭头平滑滚动 - Hero 首屏:明暗双背景图、大标题/副标题独立配色、一言(默认使用内置本地句子库,可选自定义 API 与自动刷新)、下滑箭头平滑滚动
- 欢迎弹窗:基于访客 IP 地理定位的欢迎提示,支持 5 种位置、自定义 API 与会话去重开关 - 欢迎弹窗:默认显示纯本地静态欢迎语,支持 5 种位置、会话去重开关;仅在显式配置自定义 IP 定位 API 时才显示访客所在地(见[外部资源与隐私](#外部资源与隐私))
- 滚动吸附:首屏与内容区之间滚轮/触摸自动吸附,进入内容区后恢复原生自由滚动 - 滚动吸附:首屏与内容区之间滚轮/触摸自动吸附,进入内容区后恢复原生自由滚动
- 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数 - 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数
### 文章 ### 文章
- 目录(TOC):桌面端侧栏 + 移动端抽屉,滚动高亮、展开模式可选 - 目录(TOC):桌面端侧栏 + 移动端抽屉,滚动高亮、展开模式可选
- 上下篇导航:悬停反馈与方向箭头动画 - 代码高亮:由代码高亮插件 / API 扩展包输出,主题提供 Warm / Cold 两套本地配色;主题不加载任何远程高亮脚本
- 相关推荐、分享按钮、赞赏、版权声明可独立开关 - 上下篇导航、相关推荐、分享按钮、赞赏、版权声明可独立开关;微信分享改为复制文章链接,无需二维码库
- 字数与阅读时间:优先使用 API 扩展包的服务端统计,未安装时前端计算兜底 - 字数与阅读时间:优先使用 API 扩展包的服务端统计,未安装时前端计算兜底
### 全局 ### 全局
@@ -76,7 +121,7 @@
## 灯箱集成 ## 灯箱集成
主题内置 LightGallery 灯箱,为图片提供放大预览功能,并支持 EXIF 信息展示。 主题内置 LightGallery 灯箱,为图片提供放大预览功能,并支持 EXIF 信息展示。该库以 GPLv3 发布,本主题以 GPL-3.0 开源,按开源许可条款自托管使用;控制台不会出现商业授权提示。
### lightgallery.js 插件兼容 ### lightgallery.js 插件兼容
@@ -98,7 +143,7 @@
3. 安装完成后点击「启用」 3. 安装完成后点击「启用」
> **升级后首页 404「资源不存在 / Template index was not found」**:说明上传的 ZIP 里没有 `templates/` > **升级后首页 404「资源不存在 / Template index was not found」**:说明上传的 ZIP 里没有 `templates/`
> 模板文件(从源码仓库打包时漏了构建步骤)。请换用完整安装包重新上传,详见 [FAQ 安装相关](docs/faq.md)。 > 模板文件(从源码仓库打包时漏了构建步骤)。请换用完整安装包重新上传,详见 [FAQ 安装相关](https://nxxy335.top/WarmIsland/faq.html)。
> >
> **升级后如果整站没了样式**:Halo 页面缓存或前置反向代理里可能还留着上一版本的 HTML, > **升级后如果整站没了样式**:Halo 页面缓存或前置反向代理里可能还留着上一版本的 HTML,
> 它引用的 CSS 文件名带内容哈希,升级后已被删除,请求会直接 500。 > 它引用的 CSS 文件名带内容哈希,升级后已被删除,请求会直接 500。
@@ -107,7 +152,7 @@
> >
> **升级主题或修改配色后,部分页面不生效**:如果安装了「页面静态缓存」插件, > **升级主题或修改配色后,部分页面不生效**:如果安装了「页面静态缓存」插件,
> 它只随文章变更自动刷新,主题层的变更需要手动点一次「刷新页面缓存」, > 它只随文章变更自动刷新,主题层的变更需要手动点一次「刷新页面缓存」,
> 详见 [FAQ:升级主题或修改配色后部分页面不生效](docs/faq.md#升级主题或修改配色后部分页面的新样式配色不生效)。 > 详见 [FAQ:升级主题或修改配色后部分页面不生效](https://nxxy335.top/WarmIsland/faq.html)。
或从源码构建: 或从源码构建:
@@ -115,11 +160,11 @@
git clone https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland.git git clone https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland.git
cd halo-theme-WarmIsland cd halo-theme-WarmIsland
pnpm install pnpm install
pnpm release # build → snapshot → package pnpm release # build → snapshot → package(含外部资源审计)
``` ```
`pnpm build` 只生成 `templates/`(模板 + 静态资源),不会产出 ZIP; `pnpm build` 只生成 `templates/`(模板 + 静态资源),不会产出 ZIP;
安装包由 `pnpm package` 生成到 `dist/warm-island-<version>.zip`。 安装包由 `pnpm package` 生成到 `dist/warm-island-<version>.zip`。安装包同时包含 `docs/*.md` 与预览图,便于离线查阅上架说明中引用的文档。
## 开发 ## 开发
@@ -136,6 +181,9 @@ pnpm build
# 把本次构建的哈希资源快照进 compat/assets(发版前必做) # 把本次构建的哈希资源快照进 compat/assets(发版前必做)
pnpm snapshot pnpm snapshot
# 外部资源 / 隐私 / README 链接审计(打包时也会自动执行)
pnpm audit:external
# 打包 dist/warm-island-<version>.zip # 打包 dist/warm-island-<version>.zip
pnpm package pnpm package
@@ -145,7 +193,8 @@ pnpm smoke
发版流程:`pnpm release`(等价于 `pnpm build` → `pnpm snapshot` → `pnpm package`), 发版流程:`pnpm release`(等价于 `pnpm build` → `pnpm snapshot` → `pnpm package`),
并把 `compat/assets` 的新增文件一起提交。`pnpm package` 会校验模板、资源目录与版本号一致性, 并把 `compat/assets` 的新增文件一起提交。`pnpm package` 会校验模板、资源目录与版本号一致性,
产物缺失时直接报错,避免打出不含 `templates/` 的空壳安装包。 并执行外部资源审计(禁止第三方预连接、远程脚本、动态加载、无效 README 链接、重复大图),
任一不通过都会直接报错,避免打出不合规或不含 `templates/` 的空壳安装包。
### 目录结构 ### 目录结构
@@ -155,9 +204,13 @@ pnpm smoke
│ ├── layouts/ # Layout.astro(含页面布局契约渲染模式) │ ├── layouts/ # Layout.astro(含页面布局契约渲染模式)
│ ├── pages/ # 页面模板(构建后输出到 templates/) │ ├── pages/ # 页面模板(构建后输出到 templates/)
│ └── styles/ # SCSS 样式(正文样式在 _content.scss,全局生效) │ └── styles/ # SCSS 样式(正文样式在 _content.scss,全局生效)
├── compat/ ├── public/
│ └── assets/ # 历史版本哈希静态资源,构建时合并进 templates/assets │ ├── assets/data/ # 内置本地一言句子库(静态 JSON)
├── scripts/ # 构建后置、资源快照、打包、冒烟测试 │ ├── assets/fonts/ # LXGW WenKai 本地字体分片
│ ├── assets/vendor/ # 自托管的代码高亮配色 CSS
│ └── assets/logo.png # 主题图标(唯一一份,256×256 压缩版)
├── compat/assets/ # 历史版本哈希静态资源,构建时合并进 templates/assets
├── scripts/ # 构建后置、资源快照、打包、外部资源审计、冒烟测试
├── templates/ # 构建产物,Halo 实际加载的模板 ├── templates/ # 构建产物,Halo 实际加载的模板
├── settings.yaml # 后台设置项定义 ├── settings.yaml # 后台设置项定义
└── theme.yaml # 主题清单与自定义页面模板注册 └── theme.yaml # 主题清单与自定义页面模板注册
@@ -167,14 +220,34 @@ pnpm smoke
- **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500, - **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500,
所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`) 所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`)
- **不得引入远程脚本 / 样式 / 字体 / 预连接**:所有第三方资源必须自托管并标注许可,`pnpm package` 会拦截回归
- **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号/空格/emoji 会导致页面 500 - **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号/空格/emoji 会导致页面 500
- **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝 - **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝
- **凡是与后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效 - **凡是与后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效
- **截取首字符前先判空**:`#strings.substring('', 0, 1)` 会抛字符串越界异常,统一写成 `!#strings.isEmpty(x) ? #strings.substring(x, 0, 1) : '·'`
- **正文样式必须写在全局样式表**(`src/styles/_content.scss`):Astro scoped 样式对 Thymeleaf 运行时注入的内容无效 - **正文样式必须写在全局样式表**(`src/styles/_content.scss`):Astro scoped 样式对 Thymeleaf 运行时注入的内容无效
- **内联脚本统一加 `th:inline="none"`**:避免 Thymeleaf 解析脚本中的 `[[` / `[(` / `${}` - **内联脚本统一加 `th:inline="none"`**:避免 Thymeleaf 解析脚本中的 `[[` / `[(` / `${}`
- **新插入的动画元素要强制回流**(读一次 `offsetWidth`):否则初态与终态在同一帧被合并,transition 不触发 - **新插入的动画元素要强制回流**(读一次 `offsetWidth`):否则初态与终态在同一帧被合并,transition 不触发
- 修改主题外观无需单独维护 `layout.astro`:页面布局契约复用 `Layout.astro`,构建时自动生成 - 修改主题外观无需单独维护 `layout.astro`:页面布局契约复用 `Layout.astro`,构建时自动生成
## 验证与回归测试
自动化覆盖(每次发版执行):
- `pnpm build` 生成全部 21 个模板,构建后置脚本校验所有静态资源引用真实存在
- `pnpm audit:external` / `pnpm package` 审计第三方远程加载、预连接、动态加载、README 制品链接、Logo 去重
- `pnpm smoke` 校验点击特效脚本;安装包内容校验模板数量、`theme.yaml` 版本与元数据一致性
上架前建议在真实 Halo ≥ 2.27 实例上做一次人工回归(主题声明为 AI 辅助开发,此项由站点管理员完成并留存结果):
| 页面 | 检查要点 |
| --- | --- |
| 首页 | Hero、内置一言、滚动吸附、文章卡片、欢迎弹窗(默认本地文案) |
| 文章 | 正文 / 代码块配色、目录、分享按钮与微信复制链接、灯箱、点赞 |
| 分类 / 标签 / 归档 | 列表、分页、空状态、暗色模式 |
| 友链 / 留言板 / 订阅 | 空字段兜底(作者为空不报错)、表单与分页 |
| 全局 | 导航栏、页脚、移动端抽屉、深浅色切换、控制台无报错 / 无外部请求 |
## 技术栈 ## 技术栈
- [Halo](https://halo.run) 2.27+ - [Halo](https://halo.run) 2.27+
@@ -186,13 +259,15 @@ pnpm smoke
[GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html) [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html)
## 图标来源 ## 图标与字体来源
本主题使用的图标来自以下开源项目: 本主题使用的图标与字体来自以下开源项目,均随主题自托管分发、不使用远程 CDN:
| 来源 | 协议 | 用途 | | 来源 | 协议 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| [霞鹜文楷 LXGW WenKai](https://github.com/lxgw/LxgwWenKai) | [SIL OFL 1.1](https://openfontlicense.org/) | 全站中文字体(本地 woff2 分片,含保留字体名与版权声明) |
| [Font Awesome](https://fontawesome.com/) Free v7.2.0 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) / [SIL OFL 1.1](https://scripts.sil.org/OFL) | QQ 分享图标 | | [Font Awesome](https://fontawesome.com/) Free v7.2.0 | [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) / [SIL OFL 1.1](https://scripts.sil.org/OFL) | QQ 分享图标 |
| [Simple Icons](https://simpleicons.org/) | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | 微博、X、Facebook、Telegram 分享图标 | | [Simple Icons](https://simpleicons.org/) | [CC0 1.0](https://creativecommons.org/publicdomain/zero/1.0/) | 微博、X、Facebook、Telegram 分享图标 |
| [Feather Icons](https://feathericons.com/) | [MIT](https://opensource.org/licenses/MIT) | 导航、界面交互图标 | | [Feather Icons](https://feathericons.com/) | [MIT](https://opensource.org/licenses/MIT) | 导航、界面交互图标 |
| [Halo](https://halo.run/) | [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html) | 主题内置图标 | | [Halo](https://halo.run/) | [GPL-3.0](https://www.gnu.org/licenses/gpl-3.0.en.html) | 主题内置图标 |
| [LightGallery](https://www.lightgalleryjs.com/) 2.9.0 | GPLv3 | 图片灯箱(npm 依赖随构建打包,非远程加载) |
@@ -0,0 +1,62 @@
var I=function(){return I=Object.assign||function(t){for(var e,i=1,s=arguments.length;i<s;i++){e=arguments[i];for(var r in e)Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r])}return t},I.apply(this,arguments)};function N(){for(var o=0,t=0,e=arguments.length;t<e;t++)o+=arguments[t].length;for(var i=Array(o),s=0,t=0;t<e;t++)for(var r=arguments[t],n=0,l=r.length;n<l;n++,s++)i[s]=r[n];return i}var p={afterAppendSlide:"lgAfterAppendSlide",init:"lgInit",hasVideo:"lgHasVideo",containerResize:"lgContainerResize",updateSlides:"lgUpdateSlides",afterAppendSubHtml:"lgAfterAppendSubHtml",beforeOpen:"lgBeforeOpen",afterOpen:"lgAfterOpen",slideItemLoad:"lgSlideItemLoad",beforeSlide:"lgBeforeSlide",afterSlide:"lgAfterSlide",posterClick:"lgPosterClick",dragStart:"lgDragStart",dragMove:"lgDragMove",dragEnd:"lgDragEnd",beforeNextSlide:"lgBeforeNextSlide",beforePrevSlide:"lgBeforePrevSlide",beforeClose:"lgBeforeClose",afterClose:"lgAfterClose"},q={mode:"lg-slide",easing:"ease",speed:400,licenseKey:"0000-0000-000-0000",height:"100%",width:"100%",addClass:"",startClass:"lg-start-zoom",backdropDuration:300,container:"",startAnimationDuration:400,zoomFromOrigin:!0,hideBarsDelay:0,showBarsAfter:1e4,slideDelay:0,supportLegacyBrowser:!0,allowMediaOverlap:!1,videoMaxSize:"1280-720",loadYouTubePoster:!0,defaultCaptionHeight:0,ariaLabelledby:"",ariaDescribedby:"",resetScrollPosition:!0,hideScrollbar:!1,closable:!0,swipeToClose:!0,closeOnTap:!0,showCloseIcon:!0,showMaximizeIcon:!1,loop:!0,escKey:!0,keyPress:!0,trapFocus:!0,controls:!0,slideEndAnimation:!0,hideControlOnEnd:!1,mousewheel:!1,getCaptionFromTitleOrAlt:!0,appendSubHtmlTo:".lg-sub-html",subHtmlSelectorRelative:!1,preload:2,numberOfSlideItemsInDom:10,selector:"",selectWithin:"",nextHtml:"",prevHtml:"",index:0,iframeWidth:"100%",iframeHeight:"100%",iframeMaxWidth:"100%",iframeMaxHeight:"100%",download:!0,counter:!0,appendCounterTo:".lg-toolbar",swipeThreshold:50,enableSwipe:!0,enableDrag:!0,dynamic:!1,dynamicEl:[],extraProps:[],exThumbImage:"",isMobile:void 0,mobileSettings:{controls:!1,showCloseIcon:!1,download:!1},plugins:[],strings:{closeGallery:"Close gallery",toggleMaximize:"Toggle maximize",previousSlide:"Previous slide",nextSlide:"Next slide",download:"Download",playVideo:"Play video",mediaLoadingFailed:"Oops... Failed to load content..."}};function V(){(function(){if(typeof window.CustomEvent=="function")return!1;function o(t,e){e=e||{bubbles:!1,cancelable:!1,detail:null};var i=document.createEvent("CustomEvent");return i.initCustomEvent(t,e.bubbles,e.cancelable,e.detail),i}window.CustomEvent=o})(),(function(){Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector)})()}var D=(function(){function o(t){return this.cssVenderPrefixes=["TransitionDuration","TransitionTimingFunction","Transform","Transition"],this.selector=this._getSelector(t),this.firstElement=this._getFirstEl(),this}return o.generateUUID=function(){return"xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g,function(t){var e=Math.random()*16|0,i=t=="x"?e:e&3|8;return i.toString(16)})},o.prototype._getSelector=function(t,e){if(e===void 0&&(e=document),typeof t!="string")return t;e=e||document;var i=t.substring(0,1);return i==="#"?e.querySelector(t):e.querySelectorAll(t)},o.prototype._each=function(t){return this.selector?(this.selector.length!==void 0?[].forEach.call(this.selector,t):t(this.selector,0),this):this},o.prototype._setCssVendorPrefix=function(t,e,i){var s=e.replace(/-([a-z])/gi,function(r,n){return n.toUpperCase()});this.cssVenderPrefixes.indexOf(s)!==-1?(t.style[s.charAt(0).toLowerCase()+s.slice(1)]=i,t.style["webkit"+s]=i,t.style["moz"+s]=i,t.style["ms"+s]=i,t.style["o"+s]=i):t.style[s]=i},o.prototype._getFirstEl=function(){return this.selector&&this.selector.length!==void 0?this.selector[0]:this.selector},o.prototype.isEventMatched=function(t,e){var i=e.split(".");return t.split(".").filter(function(s){return s}).every(function(s){return i.indexOf(s)!==-1})},o.prototype.attr=function(t,e){return e===void 0?this.firstElement?this.firstElement.getAttribute(t):"":(this._each(function(i){i.setAttribute(t,e)}),this)},o.prototype.find=function(t){return c(this._getSelector(t,this.selector))},o.prototype.first=function(){return this.selector&&this.selector.length!==void 0?c(this.selector[0]):c(this.selector)},o.prototype.eq=function(t){return c(this.selector[t])},o.prototype.parent=function(){return c(this.selector.parentElement)},o.prototype.get=function(){return this._getFirstEl()},o.prototype.removeAttr=function(t){var e=t.split(" ");return this._each(function(i){e.forEach(function(s){return i.removeAttribute(s)})}),this},o.prototype.wrap=function(t){if(!this.firstElement)return this;var e=document.createElement("div");return e.className=t,this.firstElement.parentNode.insertBefore(e,this.firstElement),this.firstElement.parentNode.removeChild(this.firstElement),e.appendChild(this.firstElement),this},o.prototype.addClasLine truncated
<iframe class="lg-object" frameborder="0" `+n+' src="'+s+`" allowfullscreen="true"></iframe>
</div>`},getImgMarkup:function(o,t,e,i,s,r){var n=i?'srcset="'+i+'"':"",l=s?'sizes="'+s+'"':"",g="<img "+e+" "+n+" "+l+' class="lg-object lg-image" data-index="'+o+'" src="'+t+'" />',h="";if(r){var d=typeof r=="string"?JSON.parse(r):r;h=d.map(function(u){var a="";return Object.keys(u).forEach(function(m){a+=" "+m+'="'+u[m]+'"'}),"<source "+a+"></source>"})}return""+h+g},getResponsiveSrc:function(o){for(var t=[],e=[],i="",s=0;s<o.length;s++){var r=o[s].split(" ");r[0]===""&&r.splice(0,1),e.push(r[0]),t.push(r[1])}for(var n=window.innerWidth,l=0;l<t.length;l++)if(parseInt(t[l],10)>n){i=e[l];break}return i},isImageLoaded:function(o){return!(!o||!o.complete||o.naturalWidth===0)},getVideoPosterMarkup:function(o,t,e,i,s){var r="";s&&s.youtube?r="lg-has-youtube":s&&s.vimeo?r="lg-has-vimeo":r="lg-has-html5";var n=t;return typeof t!="string"&&(n=t.outerHTML),'<div class="lg-video-cont '+r+'" style="'+e+`">
<div class="lg-video-play-button">
<svg
viewBox="0 0 20 20"
preserveAspectRatio="xMidYMid"
focusable="false"
aria-labelledby="`+i+`"
role="img"
class="lg-video-play-icon"
>
<title>`+i+`</title>
<polygon class="lg-video-play-icon-inner" points="1,0 20,10 1,20"></polygon>
</svg>
<svg class="lg-video-play-icon-bg" viewBox="0 0 50 50" focusable="false">
<circle cx="50%" cy="50%" r="20"></circle></svg>
<svg class="lg-video-play-icon-circle" viewBox="0 0 50 50" focusable="false">
<circle cx="50%" cy="50%" r="20"></circle>
</svg>
</div>
`+n+`
<img class="lg-object lg-video-poster" src="`+o+`" />
</div>`},getFocusableElements:function(o){var t=o.querySelectorAll('a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])'),e=[].filter.call(t,function(i){var s=window.getComputedStyle(i);return s.display!=="none"&&s.visibility!=="hidden"});return e},getDynamicOptions:function(o,t,e,i){var s=[],r=N(j,t);return[].forEach.call(o,function(n){for(var l={},g=0;g<n.attributes.length;g++){var h=n.attributes[g];if(h.specified){var d=K(h.name),u="";r.indexOf(d)>-1&&(u=d),u&&(l[u]=h.value)}}var a=c(n),m=a.find("img").first().attr("alt"),f=a.attr("title"),y=i?a.attr(i):a.find("img").first().attr("src");l.thumb=y,e&&!l.subHtml&&(l.subHtml=f||m||""),l.alt=m||f||"",s.push(l)}),s},isMobile:function(){return/iPhone|iPad|iPod|Android/i.test(navigator.userAgent)},isVideo:function(o,t,e){if(!o){if(t)return{html5:!0};console.error("lightGallery :- data-src is not provided on slide item "+(e+1)+". Please make sure the selector property is properly configured. More info - https://www.lightgalleryjs.com/demos/html-markup/");return}var i=o.match(/\/\/(?:www\.)?youtu(?:\.be|be\.com|be-nocookie\.com)\/(?:watch\?v=|embed\/)?([a-z0-9\-\_\%]+)([\&|?][\S]*)*/i),s=o.match(/\/\/(?:www\.)?(?:player\.)?vimeo.com\/(?:video\/)?([0-9a-z\-_]+)(.*)?/i),r=o.match(/https?:\/\/(.+)?(wistia\.com|wi\.st)\/(medias|embed)\/([0-9a-z\-_]+)(.*)/);if(i)return{youtube:i};if(s)return{vimeo:s};if(r)return{wistia:r}}},$=0,U=(function(){function o(t,e){if(this.lgOpened=!1,this.index=0,this.plugins=[],this.lGalleryOn=!1,this.lgBusy=!1,this.currentItemsInDom=[],this.prevScrollTop=0,this.bodyPaddingRight=0,this.isDummyImageRemoved=!1,this.dragOrSwipeEnabled=!1,this.mediaContainerPosition={top:0,bottom:0},!t)return this;if($++,this.lgId=$,this.el=t,this.LGel=c(t),this.generateSettings(e),this.buildModules(),this.settings.dynamic&&this.settings.dynamicEl!==void 0&&!Array.isArray(this.settings.dynamicEl))throw"When using dynamic mode, you must also define dynamicEl as an Array.";return this.galleryItems=this.getItems(),this.normalizeSettings(),this.init(),this.validateLicense(),this}return o.prototype.generateSettings=function(t){if(this.settings=I(I({},q),t),this.settings.isMobile&&typeof this.settings.isMobile=="function"?this.settings.isMobile():b.isMobile()){var e=I(I({},this.settings.mobileSettings),this.settings.mobileSettings);this.settings=I(I({},this.settings),e)}},o.prototype.normalizeSettings=function(){if(this.settings.slideEndAnimation&&(this.settings.hideControlOnEnd=!1),this.settings.closable||(this.settings.swipeToClose=!1),this.zoomFromOrigin=this.settings.zoomFromOrigin,this.settings.dynamic&&(this.zoomFromOrigin=!1),this.settings.container){var t=this.settings.container;if(typeof t=="function")this.settings.container=t();else if(typeof t=="string"){var e=document.querySelector(t);this.settings.container=e??document.body}}else this.settings.container=document.body;this.settings.preload=Math.min(this.settings.preload,this.galleryItems.length)},o.prototype.init=function(){var t=this;this.addSlideVideoInfo(this.galleryItems),this.buildStructure(),this.LGel.trigger(p.init,{instance:this}),this.settings.keyPress&&this.keyPress(),setTimeout(function(){t.enableDrag(),t.enableSwipe(),t.triggerPosterClick()},50),this.arrow(),this.settings.mousewheel&&this.mousewheel(),this.settings.dynamic||this.openGalleryOnItemClick()},o.prototype.openGalleryOnItemClick=function(){for(var t=this,e=function(r){var n=i.items[r],l=c(n),g=D.generateUUID();l.attr("data-lg-id",g).on("click.lgcustom-item-"+g,function(h){h.preventDefault();var d=t.settings.index||r;t.openGallery(d,n)})},i=this,s=0;s<this.items.length;s++)e(s)},o.prototype.buildModules=function(){var t=this;this.settings.plugins.forEach(function(e){t.plugins.push(new e(t,c))})},o.prototype.validateLicense=function(){this.settings.licenseKey?this.settings.licenseKey==="0000-0000-000-0000"&&console.warn("lightGallery: "+this.settings.licenseKey+" license key is not valid for production use"):console.error("Please provide a valid license key")},o.prototype.getSlideItem=function(t){return c(this.getSlideItemId(t))},o.prototype.getSlideItemId=function(t){return"#lg-item-"+this.lgId+"-"+t},o.prototype.getIdName=function(t){return t+"-"+this.lgId},o.prototype.getElementById=function(t){return c("#"+this.getIdName(t))},o.prototype.manageSingleSlideClassName=function(){this.galleryItems.length<2?this.outer.addClass("lg-single-item"):this.outer.removeClass("lg-single-item")},o.prototype.buildStructure=function(){var t=this,e=this.$container&&this.$container.get();if(!e){var i="",s="";this.settings.controls&&(i='<button type="button" id="'+this.getIdName("lg-prev")+'" aria-label="'+this.settings.strings.previousSlide+'" class="lg-prev lg-icon"> '+this.settings.prevHtml+` </button>
<button type="button" id="`+this.getIdName("lg-next")+'" aria-label="'+this.settings.strings.nextSlide+'" class="lg-next lg-icon"> '+this.settings.nextHtml+" </button>"),this.settings.appendSubHtmlTo!==".lg-item"&&(s='<div class="lg-sub-html" role="status" aria-live="polite"></div>');var r="";this.settings.allowMediaOverlap&&(r+="lg-media-overlap ");var n=this.settings.ariaLabelledby?'aria-labelledby="'+this.settings.ariaLabelledby+'"':"",l=this.settings.ariaDescribedby?'aria-describedby="'+this.settings.ariaDescribedby+'"':"",g="lg-container "+this.settings.addClass+" "+(document.body!==this.settings.container?"lg-inline":""),h=this.settings.closable&&this.settings.showCloseIcon?'<button type="button" aria-label="'+this.settings.strings.closeGallery+'" id="'+this.getIdName("lg-close")+'" class="lg-close lg-icon"></button>':"",d=this.settings.showMaximizeIcon?'<button type="button" aria-label="'+this.settings.strings.toggleMaximize+'" id="'+this.getIdName("lg-maximize")+'" class="lg-maximize lg-icon"></button>':"",u=`
<div class="`+g+'" id="'+this.getIdName("lg-container")+'" tabindex="-1" aria-modal="true" '+n+" "+l+` role="dialog"
>
<div id="`+this.getIdName("lg-backdrop")+`" class="lg-backdrop"></div>
<div id="`+this.getIdName("lg-outer")+'" class="lg-outer lg-use-css3 lg-css3 lg-hide-items '+r+` ">
<div id="`+this.getIdName("lg-content")+`" class="lg-content">
<div id="`+this.getIdName("lg-inner")+`" class="lg-inner">
</div>
`+i+`
</div>
<div id="`+this.getIdName("lg-toolbar")+`" class="lg-toolbar lg-group">
`+d+`
`+h+`
</div>
`+(this.settings.appendSubHtmlTo===".lg-outer"?s:"")+`
<div id="`+this.getIdName("lg-components")+`" class="lg-components">
`+(this.settings.appendSubHtmlTo===".lg-sub-html"?s:"")+`
</div>
</div>
</div>
`;c(this.settings.container).append(u),document.body!==this.settings.container&&c(this.settings.container).css("position","relative"),this.outer=this.getElementById("lg-outer"),this.$lgComponents=this.getElementById("lg-components"),this.$backdrop=this.getElementById("lg-backdrop"),this.$container=this.getElementById("lg-container"),this.$inner=this.getElementById("lg-inner"),this.$content=this.getElementById("lg-content"),this.$toolbar=this.getElementById("lg-toolbar"),this.$backdrop.css("transition-duration",this.settings.backdropDuration+"ms");var a=this.settings.mode+" ";this.manageSingleSlideClassName(),this.settings.enableDrag&&(a+="lg-grab "),this.outer.addClass(a),this.$inner.css("transition-timing-function",this.settings.easing),this.$inner.css("transition-duration",this.settings.speed+"ms"),this.settings.download&&this.$toolbar.append('<a id="'+this.getIdName("lg-download")+'" target="_blank" rel="noopener" aria-label="'+this.settings.strings.download+'" download class="lg-download lg-icon"></a>'),this.counter(),c(window).on("resize.lg.global"+this.lgId+" orientationchange.lg.global"+this.lgId,function(){t.refreshOnResize()}),this.hideBars(),this.manageCloseGallery(),this.toggleMaximize(),this.initModules()}},o.prototype.refreshOnResize=function(){if(this.lgOpened){var t=this.galleryItems[this.index],e=t.__slideVideoInfo;this.mediaContainerPosition=this.getMediaContainerPosition();var i=this.mediaContainerPosition,s=i.top,r=i.bottom;if(this.currentImageSize=b.getSize(this.items[this.index],this.outer,s+r,e&&this.settings.videoMaxSize),e&&this.resizeVideoSlide(this.index,this.currentImageSize),this.zoomFromOrigin&&!this.isDummyImageRemoved){var n=this.getDummyImgStyles(this.currentImageSize);this.outer.find(".lg-current .lg-dummy-img").first().attr("style",n)}this.LGel.trigger(p.containerResize)}},o.prototype.resizeVideoSlide=function(t,e){var i=this.getVideoContStyle(e),s=this.getSlideItem(t);s.find(".lg-video-cont").attr("style",i)},o.prototype.updateSlides=function(t,e){if(this.index>t.length-1&&(this.index=t.length-1),t.length===1&&(this.index=0),!t.length){this.closeGallery();return}var i=this.galleryItems[e].src;this.galleryItems=t,this.updateControls(),this.$inner.empty(),this.currentItemsInDom=[];var s=0;this.galleryItems.some(function(r,n){return r.src===i?(s=n,!0):!1}),this.currentItemsInDom=this.organizeSlideItems(s,-1),this.loadContent(s,!0),this.getSlideItem(s).addClass("lg-current"),this.index=s,this.updateCurrentCounter(s),this.LGel.trigger(p.updateSlides)},o.prototype.getItems=function(){if(this.items=[],this.settings.dynamic)return this.settings.dynamicEl||[];if(this.settings.selector==="this")this.items.push(this.el);else if(this.settings.selector)if(typeof this.settings.selector=="string")if(this.settings.selectWithin){var t=c(this.settings.selectWithin);this.items=t.find(this.settings.selector).get()}else this.items=this.el.querySelectorAll(this.settings.selector);else this.items=this.settings.selector;else this.items=this.el.children;return b.getDynamicOptions(this.items,this.settings.extraProps,this.settings.getCaptionFromTitleOrAlt,this.settings.exThumbImage)},o.prototype.shouldHideScrollbar=function(){return this.settings.hideScrollbar&&document.body===this.settings.container},o.prototype.hideScrollbar=function(){if(this.shouldHideScrollbar()){this.bodyPaddingRight=parseFloat(c("body").style().paddingRight);var t=document.documentElement.getBoundingClientRect(),e=window.innerWidth-t.width;c(document.body).css("padding-right",e+this.bodyPaddingRight+"px"),c(document.body).addClass("lg-overlay-open")}},o.prototype.resetScrollBar=function(){this.shouldHideScrollbar()&&(c(document.body).css("padding-right",this.bodyPaddingRight+"px"),c(document.body).removeClass("lg-overlay-open"))},o.prototype.openGallery=function(t,e){var i=this;if(t===void 0&&(t=this.settings.index),!this.lgOpened){this.lgOpened=!0,this.outer.removeClass("lg-hide-items"),this.hideScrollbar(),this.$container.addClass("lg-show");var s=this.getItemsToBeInsertedToDom(t,t);this.currentItemsInDom=s;var r="";s.forEach(function(a){r=r+('<div id="'+a+'" class="lg-item"></div>')}),this.$inner.append(r),this.addHtml(t);var n="";this.mediaContainerPosition=this.getMediaContainerPosition();var l=this.mediaContainerPosition,g=l.top,h=l.bottom;this.settings.allowMediaOverlap||this.setMediaContainerPosition(g,h);var d=this.galleryItems[t].__slideVideoInfo;this.zoomFromOrigin&&e&&(this.currentImageSize=b.getSize(e,this.outer,g+h,d&&this.settings.videoMaxSize),n=b.getTransform(e,this.outer,g,h,this.currentImageSize)),(!this.zoomFromOrigin||!n)&&(this.outer.addClass(this.settings.startClass),this.getSlideItem(t).removeClass("lg-complete"));var u=this.settings.zoomFromOrigin?100:this.settings.backdropDuration;setTimeout(function(){i.outer.addClass("lg-components-open")},u),this.index=t,this.LGel.trigger(p.beforeOpen),this.getSlideItem(t).addClass("lg-current"),this.lGalleryOn=!1,this.prevScrollTop=c(window).scrollTop(), Line truncated
<span id="`+this.getIdName("lg-counter-current")+'" class="lg-counter-current">'+(this.index+1)+` </span> /
<span id="`+this.getIdName("lg-counter-all")+'" class="lg-counter-all">'+this.galleryItems.length+" </span></div>";this.outer.find(this.settings.appendCounterTo).append(t)}},o.prototype.addHtml=function(t){var e,i;if(this.galleryItems[t].subHtmlUrl?i=this.galleryItems[t].subHtmlUrl:e=this.galleryItems[t].subHtml,!i)if(e){var s=e.substring(0,1);if(s==="."||s==="#")try{this.settings.subHtmlSelectorRelative&&!this.settings.dynamic?e=c(this.items).eq(t).find(e).first().html():e=c(e).first().html()}catch{console.warn('Error processing subHtml selector "'+e+'"'),e=""}}else e="";if(this.settings.appendSubHtmlTo!==".lg-item")i?b.fetchCaptionFromUrl(i,this.outer.find(".lg-sub-html"),"replace"):this.outer.find(".lg-sub-html").html(e);else{var r=c(this.getSlideItemId(t));i?b.fetchCaptionFromUrl(i,r,"append"):r.append('<div class="lg-sub-html">'+e+"</div>")}typeof e<"u"&&e!==null&&(e===""?this.outer.find(this.settings.appendSubHtmlTo).addClass("lg-empty-html"):this.outer.find(this.settings.appendSubHtmlTo).removeClass("lg-empty-html")),this.LGel.trigger(p.afterAppendSubHtml,{index:t})},o.prototype.preload=function(t){for(var e=1;e<=this.settings.preload&&!(e>=this.galleryItems.length-t);e++)this.loadContent(t+e,!1);for(var i=1;i<=this.settings.preload&&!(t-i<0);i++)this.loadContent(t-i,!1)},o.prototype.getDummyImgStyles=function(t){return t?"width:"+t.width+`px;
margin-left: -`+t.width/2+`px;
margin-top: -`+t.height/2+`px;
height:`+t.height+"px":""},o.prototype.getVideoContStyle=function(t){return t?"width:"+t.width+`px;
height:`+t.height+"px":""},o.prototype.getDummyImageContent=function(t,e,i){var s;if(this.settings.dynamic||(s=c(this.items).eq(e)),s){var r=void 0;if(this.settings.exThumbImage?r=s.attr(this.settings.exThumbImage):r=s.find("img").first().attr("src"),!r)return"";var n=this.getDummyImgStyles(this.currentImageSize),l=document.createElement("img");return l.alt=i||"",l.src=r,l.className="lg-dummy-img",l.style.cssText=n,t.addClass("lg-first-slide"),this.outer.addClass("lg-first-slide-loading"),l}return""},o.prototype.setImgMarkup=function(t,e,i){var s=this.galleryItems[i],r=s.alt,n=s.srcset,l=s.sizes,g=s.sources,h="",d=r?'alt="'+r+'"':"";this.isFirstSlideWithZoomAnimation()?h=this.getDummyImageContent(e,i,d):h=b.getImgMarkup(i,t,d,n,l,g);var u=document.createElement("picture");u.className="lg-img-wrap",c(u).append(h),e.prepend(u)},o.prototype.onSlideObjectLoad=function(t,e,i,s){var r=t.find(".lg-object").first();b.isImageLoaded(r.get())||e?i():(r.on("load.lg error.lg",function(){i&&i()}),r.on("error.lg",function(){s&&s()}))},o.prototype.onLgObjectLoad=function(t,e,i,s,r,n){var l=this;this.onSlideObjectLoad(t,n,function(){l.triggerSlideItemLoad(t,e,i,s,r)},function(){t.addClass("lg-complete lg-complete_"),t.html('<span class="lg-error-msg">'+l.settings.strings.mediaLoadingFailed+"</span>")})},o.prototype.triggerSlideItemLoad=function(t,e,i,s,r){var n=this,l=this.galleryItems[e],g=r&&this.getSlideType(l)==="video"&&!l.poster?s:0;setTimeout(function(){t.addClass("lg-complete lg-complete_"),n.LGel.trigger(p.slideItemLoad,{index:e,delay:i||0,isFirstSlide:r})},g)},o.prototype.isFirstSlideWithZoomAnimation=function(){return!!(!this.lGalleryOn&&this.zoomFromOrigin&&this.currentImageSize)},o.prototype.addSlideVideoInfo=function(t){var e=this;t.forEach(function(i,s){i.__slideVideoInfo=b.isVideo(i.src,!!i.video,s),i.__slideVideoInfo&&e.settings.loadYouTubePoster&&!i.poster&&i.__slideVideoInfo.youtube&&(i.poster="//img.youtube.com/vi/"+i.__slideVideoInfo.youtube[1]+"/maxresdefault.jpg")})},o.prototype.loadContent=function(t,e){var i=this,s=this.galleryItems[t],r=c(this.getSlideItemId(t)),n=s.poster,l=s.srcset,g=s.sizes,h=s.sources,d=s.src,u=s.video,a=u&&typeof u=="string"?JSON.parse(u):u;if(s.responsive){var m=s.responsive.split(",");d=b.getResponsiveSrc(m)||d}var f=s.__slideVideoInfo,y="",w=!!s.iframe,v=!this.lGalleryOn,C=0;if(v&&(this.zoomFromOrigin&&this.currentImageSize?C=this.settings.startAnimationDuration+10:C=this.settings.backdropDuration+10),!r.hasClass("lg-loaded")){if(f){var P=this.mediaContainerPosition,F=P.top,B=P.bottom,H=b.getSize(this.items[t],this.outer,F+B,f&&this.settings.videoMaxSize);y=this.getVideoContStyle(H)}if(w){var T=b.getIframeMarkup(this.settings.iframeWidth,this.settings.iframeHeight,this.settings.iframeMaxWidth,this.settings.iframeMaxHeight,d,s.iframeTitle);r.prepend(T)}else if(n){var G="",W=v&&this.zoomFromOrigin&&this.currentImageSize;W&&(G=this.getDummyImageContent(r,t,""));var T=b.getVideoPosterMarkup(n,G||"",y,this.settings.strings.playVideo,f);r.prepend(T)}else if(f){var T='<div class="lg-video-cont " style="'+y+'"></div>';r.prepend(T)}else if(this.setImgMarkup(d,r,t),l||h){var Y=r.find(".lg-object");this.initPictureFill(Y)}(n||f)&&this.LGel.trigger(p.hasVideo,{index:t,src:d,html5Video:a,hasPoster:!!n}),this.LGel.trigger(p.afterAppendSlide,{index:t}),this.lGalleryOn&&this.settings.appendSubHtmlTo===".lg-item"&&this.addHtml(t)}var x=0;C&&!c(document.body).hasClass("lg-from-hash")&&(x=C),this.isFirstSlideWithZoomAnimation()&&(setTimeout(function(){r.removeClass("lg-start-end-progress lg-start-progress").removeAttr("style")},this.settings.startAnimationDuration+100),r.hasClass("lg-loaded")||setTimeout(function(){if(i.getSlideType(s)==="image"){var k=s.alt,R=k?'alt="'+k+'"':"";if(r.find(".lg-img-wrap").append(b.getImgMarkup(t,d,R,l,g,s.sources)),l||h){var Z=r.find(".lg-object");i.initPictureFill(Z)}}(i.getSlideType(s)==="image"||i.getSlideType(s)==="video"&&n)&&(i.onLgObjectLoad(r,t,C,x,!0,!1),i.onSlideObjectLoad(r,!!(f&&f.html5&&!n),function(){i.loadContentOnFirstSlideLoad(t,r,x)},function(){i.loadContentOnFirstSlideLoad(t,r,x)}))},this.settings.startAnimationDuration+100)),r.addClass("lg-loaded"),(!this.isFirstSlideWithZoomAnimation()||this.getSlideType(s)==="video"&&!n)&&this.onLgObjectLoad(r,t,C,x,v,!!(f&&f.html5&&!n)),(!this.zoomFromOrigin||!this.currentImageSize)&&r.hasClass("lg-complete_")&&!this.lGalleryOn&&setTimeout(function(){r.addClass("lg-complete")},this.settings.backdropDuration),this.lGalleryOn=!0,e===!0&&(r.hasClass("lg-complete_")?this.preload(t):r.find(".lg-object").first().on("load.lg error.lg",function(){i.preload(t)}))},o.prototype.loadContentOnFirstSlideLoad=function(t,e,i){var s=this;setTimeout(function(){e.find(".lg-dummy-img").remove(),e.removeClass("lg-first-slide"),s.outer.removeClass("lg-first-slide-loading"),s.isDummyImageRemoved=!0,s.preload(t)},i+300)},o.prototype.getItemsToBeInsertedToDom=function(t,e,i){var s=this;i===void 0&&(i=0);var r=[] Line truncated
<div class="lg-thumb lg-group">
</div>
</div>`;this.core.outer.addClass("lg-has-thumb"),this.settings.appendThumbnailsTo===".lg-components"?this.core.$lgComponents.append(e):this.core.outer.append(e),this.$thumbOuter=this.core.outer.find(".lg-thumb-outer").first(),this.$lgThumb=this.core.outer.find(".lg-thumb").first(),this.settings.animateThumb&&this.core.outer.find(".lg-thumb").css("transition-duration",this.core.settings.speed+"ms").css("width",this.thumbTotalWidth+"px").css("position","relative"),this.setThumbItemHtml(this.core.galleryItems)},o.prototype.enableThumbDrag=function(){var t=this,e={cords:{startX:0,endX:0},isMoved:!1,newTranslateX:0,startTime:new Date,endTime:new Date,touchMoveTime:0},i=!1;this.$thumbOuter.addClass("lg-grab"),this.core.outer.find(".lg-thumb").first().on("mousedown.lg.thumb",function(s){t.thumbTotalWidth>t.thumbOuterWidth&&(s.preventDefault(),e.cords.startX=s.pageX,e.startTime=new Date,t.thumbClickable=!1,i=!0,t.core.outer.get().scrollLeft+=1,t.core.outer.get().scrollLeft-=1,t.$thumbOuter.removeClass("lg-grab").addClass("lg-grabbing"))}),this.$LG(window).on("mousemove.lg.thumb.global"+this.core.lgId,function(s){t.core.lgOpened&&i&&(e.cords.endX=s.pageX,e=t.onThumbTouchMove(e))}),this.$LG(window).on("mouseup.lg.thumb.global"+this.core.lgId,function(){t.core.lgOpened&&(e.isMoved?e=t.onThumbTouchEnd(e):t.thumbClickable=!0,i&&(i=!1,t.$thumbOuter.removeClass("lg-grabbing").addClass("lg-grab")))})},o.prototype.enableThumbSwipe=function(){var t=this,e={cords:{startX:0,endX:0},isMoved:!1,newTranslateX:0,startTime:new Date,endTime:new Date,touchMoveTime:0};this.$lgThumb.on("touchstart.lg",function(i){t.thumbTotalWidth>t.thumbOuterWidth&&(i.preventDefault(),e.cords.startX=i.targetTouches[0].pageX,t.thumbClickable=!1,e.startTime=new Date)}),this.$lgThumb.on("touchmove.lg",function(i){t.thumbTotalWidth>t.thumbOuterWidth&&(i.preventDefault(),e.cords.endX=i.targetTouches[0].pageX,e=t.onThumbTouchMove(e))}),this.$lgThumb.on("touchend.lg",function(){e.isMoved?e=t.onThumbTouchEnd(e):t.thumbClickable=!0})},o.prototype.rebuildThumbnails=function(){var t=this;this.$thumbOuter.addClass("lg-rebuilding-thumbnails"),setTimeout(function(){t.thumbTotalWidth=t.core.galleryItems.length*(t.settings.thumbWidth+t.settings.thumbMargin),t.$lgThumb.css("width",t.thumbTotalWidth+"px"),t.$lgThumb.empty(),t.setThumbItemHtml(t.core.galleryItems),t.animateThumb(t.core.index)},50),setTimeout(function(){t.$thumbOuter.removeClass("lg-rebuilding-thumbnails")},200)},o.prototype.setTranslate=function(t){this.$lgThumb.css("transform","translate3d(-"+t+"px, 0px, 0px)")},o.prototype.getPossibleTransformX=function(t){return t>this.thumbTotalWidth-this.thumbOuterWidth&&(t=this.thumbTotalWidth-this.thumbOuterWidth),t<0&&(t=0),t},o.prototype.animateThumb=function(t){if(this.$lgThumb.css("transition-duration",this.core.settings.speed+"ms"),this.settings.animateThumb){var e=0;switch(this.settings.currentPagerPosition){case"left":e=0;break;case"middle":e=this.thumbOuterWidth/2-this.settings.thumbWidth/2;break;case"right":e=this.thumbOuterWidth-this.settings.thumbWidth}this.translateX=(this.settings.thumbWidth+this.settings.thumbMargin)*t-1-e,this.translateX>this.thumbTotalWidth-this.thumbOuterWidth&&(this.translateX=this.thumbTotalWidth-this.thumbOuterWidth),this.translateX<0&&(this.translateX=0),this.setTranslate(this.translateX)}},o.prototype.onThumbTouchMove=function(t){return t.newTranslateX=this.translateX,t.isMoved=!0,t.touchMoveTime=new Date().valueOf(),t.newTranslateX-=t.cords.endX-t.cords.startX,t.newTranslateX=this.getPossibleTransformX(t.newTranslateX),this.setTranslate(t.newTranslateX),this.$thumbOuter.addClass("lg-dragging"),t},o.prototype.onThumbTouchEnd=function(t){t.isMoved=!1,t.endTime=new Date,this.$thumbOuter.removeClass("lg-dragging");var e=t.endTime.valueOf()-t.startTime.valueOf(),i=t.cords.endX-t.cords.startX,s=Math.abs(i)/e;return s>.15&&t.endTime.valueOf()-t.touchMoveTime<30?(s+=1,s>2&&(s+=1),s=s+s*(Math.abs(i)/this.thumbOuterWidth),this.$lgThumb.css("transition-duration",Math.min(s-1,2)+"settings"),i=i*s,this.translateX=this.getPossibleTransformX(this.translateX-i),this.setTranslate(this.translateX)):this.translateX=t.newTranslateX,Math.abs(t.cords.endX-t.cords.startX)<this.settings.thumbnailSwipeThreshold&&(this.thumbClickable=!0),t},o.prototype.getThumbHtml=function(t,e,i){var s=this.core.galleryItems[e].__slideVideoInfo||{},r;s.youtube&&this.settings.loadYouTubeThumbnail?r="//img.youtube.com/vi/"+s.youtube[1]+"/"+this.settings.youTubeThumbSize+".jpg":r=t;var n=document.createElement("div");n.setAttribute("data-lg-item-id",e+""),n.className="lg-thumb-item "+(e===this.core.index?"active":""),n.style.cssText="width: "+this.settings.thumbWidth+"px; height: "+this.settings.thumbHeight+"; margin-right: "+this.settings.thumbMargin+"px;";var l=document.createElement("img");return l.alt=i||"",l.setAttribute("data-lg-item-id",e+""),l.src=r,n.appendChild(l),n},o.prototype.setThumbItemHtml=function(t){for(var e=0;e<t.length;e++){var i=this.getLine truncated
webkitfullscreenchange.lg.global`+this.core.lgId+`
mozfullscreenchange.lg.global`+this.core.lgId+`
MSFullscreenChange.lg.global`+this.core.lgId,function(){t.core.lgOpened&&t.core.outer.toggleClass("lg-fullscreen-on")}),this.core.outer.find(".lg-fullscreen").first().on("click.lg",function(){t.isFullScreen()?t.exitFullscreen():t.requestFullscreen()})},o.prototype.closeGallery=function(){this.isFullScreen()&&this.exitFullscreen()},o.prototype.destroy=function(){this.$LG(document).off("fullscreenchange.lg.global"+this.core.lgId+`
webkitfullscreenchange.lg.global`+this.core.lgId+`
mozfullscreenchange.lg.global`+this.core.lgId+`
MSFullscreenChange.lg.global`+this.core.lgId)},o})(),X=new WeakSet;function rt(o){return!!X.has(o)}function nt(o){X.add(o)}function lt(o,t){if(!o)return t||"";var e=o.split("|");if(e.length<8)return t||"";var i=e[0],s=e[1],r=e[2],n=e[3],l=e[4],g=e[5],h=e[6],d=e[7],u=i||s||r||n||l||g||h;if(!u)return t||"";var a='<div class="wi-lg-caption">'+(t||"")+"</div>";if(a+='<div class="wi-lg-exif">',(i||s)&&(a+='<span class="wi-lg-exif__item wi-lg-exif__camera">'+(i?i+" ":"")+s+"</span>"),r&&(a+='<span class="wi-lg-exif__item wi-lg-exif__lens">'+r+"</span>"),a+='<div class="wi-lg-exif__params">',n&&(a+='<span class="wi-lg-exif__item wi-lg-exif__aperture">f/'+n+"</span>"),l&&(a+='<span class="wi-lg-exif__item wi-lg-exif__shutter">'+l+"s</span>"),g&&(a+='<span class="wi-lg-exif__item wi-lg-exif__iso">ISO '+g+"</span>"),h){var m=h+"mm";d&&d!==h&&(m+=" (eq. "+d+"mm)"),a+='<span class="wi-lg-exif__item wi-lg-exif__focal">'+m+"</span>"}return a+="</div></div>",a}function at(o){var t=o.querySelectorAll(".wi-lightgallery-item[data-exif]");t.forEach(function(e){var i=e.getAttribute("data-exif");if(i){var s=e.getAttribute("data-sub-html")||"",r=lt(i,s);r&&r!==s&&e.setAttribute("data-sub-html",r)}})}function gt(){return{selector:".wi-lightgallery-item",plugins:[tt,it,ot],speed:280,mode:"lg-fade",licenseKey:"GPLv3",download:!1,counter:!0,preload:2,thumbWidth:80,thumbHeight:"60px",thumbMargin:4,zoomFromOrigin:!0,actualSize:!1,showZoomInOutIcons:!0,toggleThumb:!0,allowMediaOverlap:!0,mobileSettings:{controls:!0,showCloseIcon:!0,download:!1}}}function O(o){if(!rt(o)){var t=o.querySelectorAll(".wi-lightgallery-item");t.length!==0&&(at(o),J(o,gt()),nt(o))}}function ht(){return window.innerWidth<768}function E(){ut(),dt(),ct(),document.addEventListener("click",function(o){var t=o.target.closest(".lg-toggle-thumb");if(t){o.stopPropagation();var e=t.closest(".lg-outer");e&&e.classList.toggle("wi-thumb-shown")}},!0),document.addEventListener("click",function(o){var t=o.target.closest(".wi-lightgallery-item");t&&setTimeout(function(){var e=document.querySelector(".lg-outer");e&&!e.classList.contains("wi-thumb-shown")&&!ht()&&e.classList.add("wi-thumb-shown")},350)})}function ut(){var o=[".wi-post__body",".wi-page__body"];o.forEach(function(t){var e=document.querySelectorAll(t);e.forEach(function(i){mt(i),O(i)})})}function dt(){var o=document.querySelector(".wi-photos-page__all-groups");if(o){var t=o.querySelectorAll(".wi-photos-page__link");t.forEach(function(r){r.classList.add("wi-lightgallery-item")}),O(o);return}var e=document.querySelector(".wi-photos-page__grid:not(.wi-photos-page__all-groups .wi-photos-page__grid)");if(e){var i=e.querySelectorAll(".wi-photos-page__link");i.forEach(function(r){r.classList.add("wi-lightgallery-item")}),O(e);return}var s=document.querySelectorAll(".wi-photos-page__grid");s.forEach(function(r){ft(r),O(r)})}function ct(){var o=[".wi-moments-page__media",".wi-moments-page__card-media",".wi-moments-page__masonry-media"];o.forEach(function(t){var e=document.querySelectorAll(t);e.forEach(function(i){pt(i),O(i)})})}function mt(o){var t=o.querySelectorAll("img");t.forEach(function(e){var i=e.getAttribute("src");if(i){var s=e.getAttribute("width"),r=e.getAttribute("height");if(!(s&&parseInt(s)<50||r&&parseInt(r)<50)){var n=(e.getAttribute("class")||"").toLowerCase();if(!/emoji|icon|avatar|logo|badge|smiley/.test(n)&&e.getAttribute("role")!=="presentation"&&e.getAttribute("aria-hidden")!=="true"){var l=e.closest("a");if(l){if(l.getAttribute("target")==="_blank")return;if(l.classList.add("wi-lightgallery-item"),!l.getAttribute("data-src")){var g=l.getAttribute("href")||i;l.setAttribute("data-src",g)}var h=e.getAttribute("alt")||"";h&&!l.getAttribute("data-sub-html")&&l.setAttribute("data-sub-html",h)}else{var d=document.createElement("a");d.setAttribute("href",i),d.setAttribute("data-src",i),d.classList.add("wi-lightgallery-item");var u=e.getAttribute("alt")||"";u&&d.setAttribute("data-sub-html",u),e.parentNode.insertBefore(d,e),d.appendChild(e)}}}}})}function ft(o){var t=o.querySelectorAll(".wi-photos-page__link");t.forEach(function(e){e.classList.add("wi-lightgallery-item")})}function pt(o){var t=o.querySelectorAll("img");t.forEach(function(e){var i=e.getAttribute("src");i&&(e.classList.add("wi-lightgallery-item"),e.getAttribute("data-src")||e.setAttribute("data-src",i))})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",function(){"requestIdleCallback"in window?requestIdleCallback(E,{timeout:2e3}):setTimeout(E,200)}):"requestIdleCallback"in window?requestIdleCallback(E,{timeout:2e3}):setTimeout(E,200);
+1
View File
@@ -0,0 +1 @@
.wi-reading-progress[data-astro-cid-ftxg5n3y]{position:fixed;top:0;left:0;width:0;height:3px;background:var(--accent);z-index:101;transition:width .1s linear}.wi-post[data-astro-cid-ftxg5n3y]{display:grid;gap:1rem;padding-block:1rem;overflow-x:hidden}.wi-post__header[data-astro-cid-ftxg5n3y]{display:grid;gap:var(--space-lg);padding:var(--space-lg) 0 var(--space-md);border-bottom:1px solid var(--rule);text-align:center}.wi-post__cover-wrap[data-astro-cid-ftxg5n3y]{border-radius:12px;overflow:hidden}.wi-post__cover[data-astro-cid-ftxg5n3y]{width:100%;max-height:480px;object-fit:cover;border-radius:12px}.wi-post__title[data-astro-cid-ftxg5n3y]{font-family:var(--font-sans);font-size:clamp(1.8rem,4vw,2.8rem);font-weight:700;line-height:var(--leading-tight);letter-spacing:-.02em;color:var(--ink);margin:0}.wi-page-accent[data-astro-cid-ftxg5n3y]{display:block;width:48px;height:3px;margin:var(--space-sm) auto 0;border-radius:9999px;background:var(--accent)}.wi-post__meta[data-astro-cid-ftxg5n3y]{display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:.4rem;color:var(--ink-3);font-size:var(--text-sm);letter-spacing:var(--tracking-wide)}.wi-post__meta[data-astro-cid-ftxg5n3y] a[data-astro-cid-ftxg5n3y]{color:var(--ink-2);text-decoration:none;transition:color var(--duration-fast) var(--ease-out-quart)}.wi-post__meta[data-astro-cid-ftxg5n3y] a[data-astro-cid-ftxg5n3y]:hover{color:var(--accent)}.wi-post__meta-sep[data-astro-cid-ftxg5n3y]{color:var(--ink-3)}.wi-post__author[data-astro-cid-ftxg5n3y]{display:inline-flex;align-items:center;gap:.35rem}.wi-post__author-avatar[data-astro-cid-ftxg5n3y]{width:20px;height:20px;border-radius:50%;object-fit:cover}.wi-post__meta-categories[data-astro-cid-ftxg5n3y] a[data-astro-cid-ftxg5n3y]+a[data-astro-cid-ftxg5n3y]:before{content:", "}.wi-post__meta-tags[data-astro-cid-ftxg5n3y] a[data-astro-cid-ftxg5n3y]+a[data-astro-cid-ftxg5n3y]:before{content:", "}.wi-post__stats[data-astro-cid-ftxg5n3y]{display:inline-flex;align-items:center;gap:.4rem}.wi-post__stat-item[data-astro-cid-ftxg5n3y]+.wi-post__stat-item[data-astro-cid-ftxg5n3y]:before{content:"·";margin-right:.4rem;color:var(--ink-3)}.wi-post__body[data-astro-cid-ftxg5n3y]{width:100%;font-size:clamp(1.05rem,1rem + .25vw,1.15rem);line-height:1.85;color:#3d3530;overflow-wrap:break-word;word-wrap:break-word}html.dark .wi-post__body[data-astro-cid-ftxg5n3y]{color:#d9d2c9}.wi-toc[data-astro-cid-ftxg5n3y]{display:none}@media(min-width:1280px){.wi-toc[data-astro-cid-ftxg5n3y]{display:block;position:fixed;top:80px;width:220px;max-height:calc(100vh - 100px);overflow-y:auto;z-index:10}}.wi-toc__header[data-astro-cid-ftxg5n3y]{display:flex;align-items:center;padding-bottom:var(--space-sm);margin-bottom:var(--space-sm);border-bottom:1px solid var(--rule)}.wi-toc__title[data-astro-cid-ftxg5n3y]{font-family:var(--font-sans);font-size:var(--text-sm);font-weight:600;color:var(--ink);letter-spacing:var(--tracking-wide)}.wi-toc__nav[data-astro-cid-ftxg5n3y]{display:flex;flex-direction:column;gap:0;overflow:hidden}.wi-toc-fab[data-astro-cid-ftxg5n3y]{display:none;position:fixed;bottom:32px;right:24px;width:44px;height:44px;border-radius:50%;border:1px solid var(--rule);background:var(--bg-raised);color:var(--ink-2);cursor:pointer;z-index:20;align-items:center;justify-content:center;box-shadow:var(--shadow-md);transition:background .2s ease,color .2s ease,border-color .2s ease,opacity .3s ease}.wi-toc-fab[data-astro-cid-ftxg5n3y]:hover{color:var(--accent);border-color:var(--accent)}@media(max-width:1279px){.wi-toc-fab[data-astro-cid-ftxg5n3y]{display:inline-flex;bottom:24px}}@media(max-width:640px){.wi-toc-fab[data-astro-cid-ftxg5n3y]{right:16px;bottom:24px;width:40px;height:40px}.wi-post__share-btn[data-astro-cid-ftxg5n3y]{width:44px;height:44px}.wi-like-btn[data-astro-cid-ftxg5n3y]{padding:.75rem 1.75rem}}.wi-toc-drawer[data-astro-cid-ftxg5n3y]{display:none}.wi-toc-drawer--open[data-astro-cid-ftxg5n3y]{display:block}.wi-toc-drawer__overlay[data-astro-cid-ftxg5n3y]{position:fixed;inset:0;background:#0006;z-index:50}.wi-toc-drawer__panel[data-astro-cid-ftxg5n3y]{position:fixed;bottom:0;left:0;right:0;max-height:60vh;background:var(--bg);border-top:1px solid var(--rule);border-radius:16px 16px 0 0;padding:1.25rem;z-index:51;overflow-y:auto;transform:translateY(0);transition:transform .3s cubic-bezier(.16,1,.3,1)}.wi-toc-drawer__header[data-astro-cid-ftxg5n3y]{display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem;padding-bottom:.75rem;border-bottom:1px solid var(--rule)}.wi-toc-drawer__title[data-astro-cid-ftxg5n3y]{font-family:var(--font-sans);font-size:var(--text-sm);font-weight:600;color:var(--ink)}.wi-toc-drawer__close[data-astro-cid-ftxg5n3y]{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border:none;background:none;color:var(--ink-3);cursor:pointer}.wi-toc-drawer__close[data-astro-cid-ftxg5n3y]:hover{color:var(--accent)}.wi-toc-drawer__nav[data-astro-cid-ftxg5n3y]{disLine truncated
+18
View File
@@ -2,6 +2,24 @@
## v1.2.5 ## v1.2.5
### 上架审核整改(v1.2.5 修订)
- 🔒 **默认零第三方自动请求**:移除全部 21 个模板对 `cdn.jsdelivr.net` 的 `preconnect` / `dns-prefetch`
- 🔒 移除文章页 PrismJS 1.29.0、`autoloader`(会按代码语言从 CDN 动态拉取并执行组件)与
`qrcode-generator`;代码高亮改由应用市场代码高亮插件 / API 扩展包提供,主题只保留自托管的
Warm / Cold token 配色(微信分享改为「复制链接」)
- 🔒 一言默认改用内置本地句子库 `assets/data/hitokoto.json`,不再默认请求 `v1.hitokoto.cn`
- 🔒 欢迎弹窗默认显示本地静态欢迎语,移除内置的 IP 定位主 / 备选第三方接口;IP 定位改为
**管理员显式配置**的可选能力(开启时会向该服务发送访客 IP,需自行披露)
- 📄 README 新增「外部资源与隐私」章节(服务名称 / 用途 / 数据类型 / 默认状态 / 关闭方法 /
遥测声明)与「验证与回归测试」清单;补充 LXGW WenKai 的 OFL 来源与 LightGallery GPLv3 说明
- 🔗 修正上架说明中的无效链接:文档与预览图改为有效绝对地址并随包分发;朋友圈插件链接改为应用市场地址
- 🐛 全部 `#strings.substring(..., 0, 1)` 增加空值保护,修复 `friends.html` 作者为空时的字符串越界
- 🖼️ `logo.png` 由两份 1024×1024(各约 1.5MB)压缩去重为单份 256×256(约 146KB)
- ✅ LightGallery 去掉占位 `licenseKey`,按 GPLv3 开源许可使用,控制台不再出现授权提示
- 🧪 新增 `pnpm audit:external` 与打包前置校验:拦截第三方远程加载、预连接、动态加载、
README 死链、重复大图;`docs/*.md` 与预览图一并打进安装包
### 兼容性与元数据 ### 兼容性与元数据
- ⬆️ Halo 版本要求提升至 **2.27.0+**(页面布局契约、微信读书页面依赖 2.27 的能力) - ⬆️ Halo 版本要求提升至 **2.27.0+**(页面布局契约、微信读书页面依赖 2.27 的能力)
+5 -5
View File
@@ -84,7 +84,7 @@
### 代码高亮主题(article_code_theme) ### 代码高亮主题(article_code_theme)
WarmIsland 使用 Prism.js 实现代码语法高亮,提供两种主题风格: 主题不再加载 Prism.js 运行时与 autoloader,**语法高亮由 Halo 应用市场的代码高亮插件或「API 扩展包」服务端高亮输出**。本项只切换主题自托管的两套 `.token` 配色(`assets/vendor/prism-warm.css` / `prism-cold.css`),不产生任何远程请求:
| 值 | 说明 | | 值 | 说明 |
| --- | --- | | --- | --- |
@@ -100,7 +100,7 @@ article_code_theme: warm
``` ```
::: tip ::: tip
代码高亮支持多种编程语言,包括 JavaScript、Python、Java、Go、Rust、YAML、JSON、Bash 等。语言检测基于代码块的语言标记自动完成。 支持的语言范围取决于所安装的高亮插件(常见的 JavaScript、Python、Java、Go、Rust、YAML、JSON、Bash 等均可覆盖)。语言识别基于代码块的语言标记,建议始终显式标注。
::: :::
### 目录展开模式(article_toc_expand) ### 目录展开模式(article_toc_expand)
@@ -136,7 +136,7 @@ article_related_title: "猜你喜欢"
| 平台 | 说明 | | 平台 | 说明 |
| --- | --- | | --- | --- |
| QQ | 点击后弹出 QQ 分享窗口,自动携带文章标题和链接 | | QQ | 点击后弹出 QQ 分享窗口,自动携带文章标题和链接 |
| 微信 | 扫码分享到微信(生成文章完整 URL 的二维码) | | 微信 | 弹出文章链接并提供「复制链接」按钮,由访客在微信内分享(不加载二维码库) |
| 微博 | 分享到新浪微博 | | 微博 | 分享到新浪微博 |
| X (Twitter) | 分享到 X | | X (Twitter) | 分享到 X |
| Facebook | 分享到 Facebook | | Facebook | 分享到 Facebook |
@@ -157,7 +157,7 @@ article_related_title: "猜你喜欢"
| Telegram | ❌ | | Telegram | ❌ |
::: info 图标来源 ::: info 图标来源
分享按钮图标来自 [Font Awesome](https://fontawesome.com/)(Free v7.2.0,CC BY 4.0 / SIL OFL 1.1 协议)和 [Simple Icons](https://simpleicons.org/)(CC0 协议)。微信图标点击后会弹出二维码弹窗,用户可使用微信扫码分享。 分享按钮图标来自 [Font Awesome](https://fontawesome.com/)(Free v7.2.0,CC BY 4.0 / SIL OFL 1.1 协议)和 [Simple Icons](https://simpleicons.org/)(CC0 协议)。微信图标点击后会弹出「复制链接」弹层,用户复制后可在微信内分享;二维码由远程库生成的旧实现已移除,主题不再加载任何第三方脚本。
::: :::
#### 分享区域标题(article_share_title) #### 分享区域标题(article_share_title)
@@ -174,7 +174,7 @@ article_share_channels:
``` ```
::: tip ::: tip
分享功能会自动携带文章标题和完整链接,无需手动配置分享内容。微信分享通过二维码实现,二维码内容为文章的完整 URL(包含域名),用户扫码后可直接打开文章。QQ 分享通过弹窗方式打开 QQ 分享页面。移动端会优先显示适合移动端的分享方式。 分享功能会自动携带文章标题和完整链接,无需手动配置分享内容。微信分享通过「复制链接」实现:弹层显示文章完整 URL(含域名),点击按钮写入剪贴板,全程在浏览器本地完成、不发送外部请求。QQ 分享通过弹窗方式打开 QQ 分享页面。除访客主动点击外,页面加载时不会访问任何第三方平台。
::: :::
## 阅读增强功能 ## 阅读增强功能
+13 -9
View File
@@ -10,8 +10,8 @@ Hero 首屏是 WarmIsland 主题的标志性区域,占据首页首屏的全部
| `hero_title` | 文本 | — | 主标题(留空则使用站点标题) | | `hero_title` | 文本 | — | 主标题(留空则使用站点标题) |
| `hero_subtitle` | 文本 | — | 副标题(留空则使用站点副标题) | | `hero_subtitle` | 文本 | — | 副标题(留空则使用站点副标题) |
| `hero_description_mode` | 下拉 | `hitokoto` | 描述文案来源:`none` / `hitokoto` / `custom` | | `hero_description_mode` | 下拉 | `hitokoto` | 描述文案来源:`none` / `hitokoto` / `custom` |
| `hero_hitokoto_api` | URL | `https://v1.hitokoto.cn/` | 一言 API 地址 | | `hero_hitokoto_api` | URL | 留空 | 自定义一言 API;留空使用主题内置本地句子库(零外部请求) |
| `hero_hitokoto_categories` | 多选 | `d, i, k` | 一言句子类型 | | `hero_hitokoto_categories` | 多选 | `d, i, k` | 句子类型筛选,仅对自定义 API 生效 |
| `hero_custom_description` | 多行文本 | — | 自定义描述文案 | | `hero_custom_description` | 多行文本 | — | 自定义描述文案 |
| `hero_hitokoto_auto_refresh` | 开关 | `false` | 一言自动刷新 | | `hero_hitokoto_auto_refresh` | 开关 | `false` | 一言自动刷新 |
| `hero_hitokoto_refresh_interval` | 下拉 | `10000` | 一言刷新间隔(秒) | | `hero_hitokoto_refresh_interval` | 下拉 | `10000` | 一言刷新间隔(秒) |
@@ -49,24 +49,28 @@ hero_subtitle: "记录温暖与灵感"
| 值 | 说明 | | 值 | 说明 |
| --- | --- | | --- | --- |
| `none` | 不显示描述文案 | | `none` | 不显示描述文案 |
| `hitokoto` | 从一言 API 实时获取随机句子(默认) | | `hitokoto` | 从主题内置本地句子库随机取一条(默认,零外部请求) |
| `custom` | 显示自定义的描述文案 | | `custom` | 显示自定义的描述文案 |
### 一言模式(hitokoto) ### 一言模式(hitokoto)
选择 `hitokoto` 模式后,Hero 区域会从一言 API 获取随机句子,每次刷新页面都会展示不同的内容。 选择 `hitokoto` 模式后,Hero 区域**默认读取主题内置的本地句子库** `assets/data/hitokoto.json`
(随主题自托管的静态 JSON),每次刷新随机展示一条,不依赖任何外部服务,也不会因为第三方接口
不可用而出现空白。可直接编辑该 JSON 替换成自己喜欢的句子。
#### 一言 API 地址(hero_hitokoto_api) #### 自定义一言 API(hero_hitokoto_api)
默认使用官方 API `https://v1.hitokoto.cn/`。如果你部署了一言的私有实例,可以修改为自建 API 地址。 默认**留空**(零外部请求)。如果需要使用在线一言或自建实例,可在此填写 API 地址;
填写后浏览器才会向该地址发起 GET 请求,第三方可见访客 IP / User-Agent。
请自行评估服务商隐私政策,并在站点隐私说明中披露,详见 README「外部资源与隐私」。
::: tip ::: warning
自建一言 API 可以提升加载速度并避免第三方服务不稳定的问题。部署方式请参考 [hitokoto 官方文档](https://developer.hitokoto.cn/)。 这是站点管理员主动开启的可选能力。留空即可随时关闭,无需重装主题。
::: :::
#### 一言句子类型(hero_hitokoto_categories) #### 一言句子类型(hero_hitokoto_categories)
通过勾选来筛选获取的句子类型,支持多选: 仅在填写了自定义 API 时生效,用于筛选该接口返回的句子类型,支持多选:
| 值 | 类型 | 说明 | | 值 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
+51 -56
View File
@@ -54,18 +54,36 @@ pnpm build
### 打包主题 ### 打包主题
```bash ```bash
pnpm release # 推荐:build → snapshot → package pnpm release # 推荐:build → snapshot → package(含合规审计)
# 或分步:pnpm build && pnpm snapshot && pnpm package # 或分步:pnpm build && pnpm snapshot && pnpm audit:external && pnpm package
``` ```
生成 `dist/warm-island-<version>.zip`,可直接在 Halo 后台上传安装。 生成 `dist/warm-island-<version>.zip`,可直接在 Halo 后台上传安装。
ZIP 根目录直接放 `theme.yaml` / `settings.yaml` / `templates/`,不要额外套一层目录。 ZIP 根目录直接放 `theme.yaml` / `settings.yaml` / `templates/`,不要额外套一层目录;
`docs/*.md` 与预览图也会一并打包,保证上架说明里的引用在制品中真实存在。
> **`templates/` 被 `.gitignore` 忽略**,克隆仓库后默认不存在。若跳过 `pnpm build` > **`templates/` 被 `.gitignore` 忽略**,克隆仓库后默认不存在。若跳过 `pnpm build`
> 直接 `pnpm package`,过去会产出不含任何模板的空壳包,上传后 Halo 报 > 直接 `pnpm package`,过去会产出不含任何模板的空壳包,上传后 Halo 报
> `Template index was not found.` 并让首页 404。现在打包脚本会先校验 > `Template index was not found.` 并让首页 404。现在打包脚本会先校验
> `index.html` 等模板、资源目录与版本号,不满足即报错退出。 > `index.html` 等模板、资源目录与版本号,不满足即报错退出。
### 上架合规审计
`scripts/audit-external.mjs`(`pnpm audit:external`)在打包前自动执行,对应应用市场审核指南
1.4 / 4.4.4 / 4.6 / 5.1,检查:
- 模板中不含第三方 `preconnect` / `dns-prefetch` / `preload`
- 不含 `src` / `href` 指向 http(s) 的远程脚本、样式、图片、媒体
- 不含硬编码的外部 `fetch()` / 动态 `import()` / CSS `url(https://...)`,
也不含 Prism autoloader 之类的动态加载痕迹与被移除的服务域名
- README 中的相对链接都能在安装包清单里找到对应文件
- `templates/logo.png` 不存在(只允许一份压缩后的 `templates/assets/logo.png`)
任一不通过,`pnpm package` 直接 `exit 1`,避免打出不合规制品。
`<a href="https://...">` 这类需要访客主动点击的外链是允许的,但必须在 README
「外部资源与隐私」章节逐项披露。新增第三方资源时,请先自托管到 `public/assets/`
并在 README 的许可表中登记来源与协议。
## 性能与滚动顺滑 ## 性能与滚动顺滑
首页滚动曾经卡顿,根因不是 DOM 数量,而是「每帧高成本绘制 + 劫持滚动输入」: 首页滚动曾经卡顿,根因不是 DOM 数量,而是「每帧高成本绘制 + 劫持滚动输入」:
@@ -98,60 +116,36 @@ ZIP 根目录直接放 `theme.yaml` / `settings.yaml` / `templates/`,不要额
## 项目结构 ## 项目结构
以下按磁盘实际文件列出(`src/pages/` 下每个 `.astro` 对应一个输出模板,共 21 个):
``` ```
WarmIsland/ WarmIsland/
├── src/ # 源代码 ├── src/
│ ├── components/ # Astro 组件 │ ├── components/ # 18 个组件:Navbar / Footer / HeroSection / WelcomePopup /
│ │ ├── HeroSection.astro # Hero 首屏 │ │ # LightGallery / ClickEffect / ScrollReveal / ThemeSwitcher 等
│ │ ├── PostCard.astro # 文章卡片 │ ├── layouts/ # Layout.astro(含页面布局契约渲染模式)
│ │ ├── Pagination.astro # 分页组件 │ ├── pages/ # index / post / page / archives / categories / category /
│ │ ├── TableOfContents.astro # 目录组件 │ │ # tags / tag / links / friends / moments / moment / photos /
│ │ ├── ReadingProgress.astro # 阅读进度条 │ │ # douban / equipments / important-dates / steam / weread /
│ │ ├── LikeButton.astro # 点赞按钮 │ │ # page_messageboard / page_subscriptions / layout
│ │ ├── CommentSection.astro # 评论区 │ └── styles/ # main.scss + 12 个 _*.scss 片段
│ │ ├── Footer.astro # 页脚 ├── public/
│ │ └── ... │ ├── assets/
│ ├── layouts/ # 布局组件 │ │ ├── data/ # hitokoto.json 内置本地句子库
│ │ ├── BaseLayout.astro # 基础布局 │ │ ├── fonts/ # LXGW WenKai 本地 woff2 分片与 result.css
│ │ └── PostLayout.astro # 文章布局 │ │ ├── vendor/ # 自托管 prism-warm.css / prism-cold.css
│ ├── lib/ # 工具库 │ │ └── logo.png # 主题图标(唯一一份,256×256 压缩版)
│ │ ├── pages.ts # 页面路由定义 │ ├── error/ # 错误页静态资源
│ │ └── utils.ts # 工具函数 │ └── favicon.ico
│ ├── pages/ # 页面入口 ├── compat/assets/ # 历史版本哈希静态资源,构建时合并进 templates/assets
│ │ ├── index.astro # 首页 ├── scripts/ # postbuild / snapshot-assets / package / audit-external / smoke-clickfx
│ │ ├── post.astro # 文章详情 ├── docs/ # VitePress 文档站(*.md 随安装包分发)
│ │ ├── archives.astro # 归档 ├── templates/ # 构建产物(被 .gitignore 忽略)
│ │ ├── categories.astro # 分类 ├── dist/ # ZIP 安装包(被 .gitignore 忽略)
│ │ ├── tags.astro # 标签 ├── theme.yaml # 主题清单与自定义页面模板注册
│ │ ├── moments.astro # 瞬间 ├── settings.yaml # 后台设置项定义
│ │ ├── photos.astro # 图库 ├── astro.config.mjs / package.json / pnpm-lock.yaml / tsconfig.json
│ │ ├── links.astro # 友情链接 └── WarmIsland Preview.webp
│ │ ├── friends.astro # 朋友圈
│ │ ├── page.astro # 自定义页面
│ │ ├── page_messageboard.astro # 留言板
│ │ ├── douban.astro # 豆瓣
│ │ ├── equipments.astro # 装备
│ │ ├── footprints.astro # 足迹
│ │ └── steam.astro # Steam
│ └── styles/ # 样式文件
│ ├── main.scss # 主样式入口
│ ├── _variables.scss # SCSS 变量
│ ├── _colors.scss # 颜色定义
│ ├── _typography.scss # 排版
│ ├── _spacing.scss # 间距
│ ├── _animations.scss # 动画
│ ├── _mixins.scss # 混入
│ └── components/ # 组件样式
├── templates/ # Thymeleaf 模板(构建输出)
├── public/ # 静态资源
│ └── assets/ # 编译后的 CSS/JS
├── docs/ # 文档
├── theme.yaml # 主题元信息
├── settings.yaml # 主题设置定义
├── astro.config.mjs # Astro 配置
├── package.json # 项目依赖
├── tsconfig.json # TypeScript 配置
└── vite.config.ts # Vite 配置
``` ```
## 技术栈 ## 技术栈
@@ -170,7 +164,8 @@ WarmIsland/
|------|------| |------|------|
| [SCSS](https://sass-lang.com/) | 样式预处理,CSS 变量驱动的配色系统 | | [SCSS](https://sass-lang.com/) | 样式预处理,CSS 变量驱动的配色系统 |
| 原生 JavaScript | 滚动、菜单、主题切换、无限滚动等交互,无前端框架运行时 | | 原生 JavaScript | 滚动、菜单、主题切换、无限滚动等交互,无前端框架运行时 |
| [LightGallery](https://www.lightgalleryjs.com/) | 图片灯箱(可选) | | [LightGallery](https://www.lightgalleryjs.com/) 2.9.0(GPLv3) | 图片灯箱,默认内置;安装 lightgallery.js 插件后自动让位 |
| [LXGW WenKai](https://github.com/lxgw/LxgwWenKai)(OFL 1.1) | 本地字体分片,随主题自托管,无远程字体请求 |
| Halo 插件生态 | 评论、代码高亮、服务端字数统计等由 Halo 与可选插件提供 | | Halo 插件生态 | 评论、代码高亮、服务端字数统计等由 Halo 与可选插件提供 |
### 构建工具 ### 构建工具
+22 -7
View File
@@ -120,11 +120,24 @@ v1.2.1 起安装包会保留各版本的哈希资源(v1.1.7 之后全部覆盖
安装并启用插件后,页面会自动显示内容。未安装插件时页面会显示友好的提示信息。 安装并启用插件后,页面会自动显示内容。未安装插件时页面会显示友好的提示信息。
### 一言 API 加载失败? ### 主题会向第三方服务发请求吗?
1. 检查网络是否能访问 `https://v1.hitokoto.cn/` **默认不会。** 所有脚本、样式、字体、图标、主题 Logo 与一言句子库都随主题自托管,
2. 如果在国内服务器,可能存在网络延迟 页面不预连接任何 CDN,也不加载远程脚本。只有两类例外:
3. 可以在主题设置中更换一言 API 地址为自建镜像
1. 访客**主动点击**分享按钮 / 备案链接 / 应用市场链接时才跳转到对应站点;
2. 站点管理员在主题设置里**显式填写**了「自定义一言 API」或「自定义 IP 定位 API」——
后者会把访客 IP 发送给该服务,请在站点隐私说明中披露。
完整逐项说明见 README 的「外部资源与隐私」章节。发版前可运行 `pnpm audit:external` 复核。
### 一言不显示,或想换成自己的句子?
1. 主题默认读取内置的 `assets/data/hitokoto.json` 本地句子库,不依赖外部服务;
直接编辑该文件即可替换文案
2. 想继续使用在线一言,可在主题设置 → Hero 首屏 → 填写「自定义一言 API」;
此时才会产生外部请求,且「句子类型」筛选仅对自定义 API 生效
3. 若选择「自定义」模式,请在「自定义描述文案」中填写内容
### 文章目录不显示? ### 文章目录不显示?
@@ -135,9 +148,11 @@ v1.2.1 起安装包会保留各版本的哈希资源(v1.1.7 之后全部覆盖
### 代码高亮不生效? ### 代码高亮不生效?
1. 确认代码块使用了正确的语言标记(如 \`\`\`python) 主题**不再自带 Prism.js 运行时**(含 autoloader),因此不会自动给代码块加语法着色:
2. Prism.js 支持自动语言检测,但部分语言需要手动标记
3. 检查浏览器控制台是否有 Prism.js 加载错误 1. 安装 Halo 应用市场的代码高亮插件,或启用「API 扩展包」的服务端代码高亮
2. 主题设置 → 文章 → 「代码高亮主题」仅切换本地自托管的 Warm / Cold `.token` 配色
3. 确认代码块使用了正确的语言标记(如 \`\`\`python),以便插件识别语言
## 样式相关 ## 样式相关
+1 -1
View File
@@ -64,7 +64,7 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
进入 **Hero 首屏** 设置: 进入 **Hero 首屏** 设置:
- 设置主标题和副标题 - 设置主标题和副标题
- 选择描述文案来源(一言/自定义/无) - 选择描述文案来源(一言 / 自定义 / 无);「一言」默认读取主题内置的本地句子库,不请求外部服务
- 可选上传背景图片 - 可选上传背景图片
### 第五步:页脚信息 ### 第五步:页脚信息
+7 -8
View File
@@ -35,7 +35,7 @@
- **文章目录** — 桌面端侧边悬浮 + 移动端底部抽屉,支持展开模式选项 - **文章目录** — 桌面端侧边悬浮 + 移动端底部抽屉,支持展开模式选项
- **阅读进度条** — 实时显示阅读进度 - **阅读进度条** — 实时显示阅读进度
- **代码高亮** — Prism.js 支持,Warm / Cold 双主题 - **代码高亮** — 由代码高亮插件 / API 扩展包输出,主题提供 Warm / Cold 本地配色,不加载远程脚本
- **图片灯箱** — 点击放大,支持左右切换 - **图片灯箱** — 点击放大,支持左右切换
- **图片懒加载** — blur-up 渐显效果,优化加载体验 - **图片懒加载** — blur-up 渐显效果,优化加载体验
- **点赞功能** — 一键点赞,状态持久化 - **点赞功能** — 一键点赞,状态持久化
@@ -71,7 +71,7 @@
### ⚙️ 灵活配置 ### ⚙️ 灵活配置
13 个配置分组,覆盖站点的方方面面: 17 个配置分组、120 余个配置项,覆盖站点的方方面面:
- 基础信息、样式、导航栏、Hero 首屏 - 基础信息、样式、导航栏、Hero 首屏
- 首页、文章、瞬间、图库、友情链接 - 首页、文章、瞬间、图库、友情链接
@@ -81,13 +81,12 @@
## 技术栈 ## 技术栈
- **Astro** — 静态站点生成器,编译模板 - **Astro 6** — 构建输出 Thymeleaf 模板
- **vite-plugin-halo-theme** — Halo 主题 Vite 插件
- **Thymeleaf** — Halo 使用的服务端模板引擎 - **Thymeleaf** — Halo 使用的服务端模板引擎
- **Vue 3** — 部分交互组件(搜索、主题切换、光感跟随) - **原生 JavaScript + SCSS** — 交互与样式(无 Vue / 无远程运行时)
- **SCSS** — CSS 预处理器 - **LightGallery 2.9.0(GPLv3)** — 图片灯箱,随构建自托管打包
- **Prism.js** — 代码语法高亮 - **LXGW WenKai(OFL 1.1)** — 本地字体分片,无远程字体请求
- **LightGallery** — 图片灯箱 - **代码高亮** — 交由 Halo 应用市场插件提供,主题只自托管配色 CSS
## 适用场景 ## 适用场景
+8 -3
View File
@@ -34,10 +34,15 @@
## 代码高亮 ## 代码高亮
使用 Prism.js 实现代码块语法高亮,支持自动语言检测。可在主题设置中选择两种主题: 主题**不再自带 Prism.js 运行时**(含按语言从 CDN 动态加载组件的 autoloader)。语法高亮由
Halo 应用市场的代码高亮插件或「API 扩展包」服务端高亮输出,主题只提供两套自托管的
`.token` 配色,在「主题设置 → 文章 → 代码高亮主题」中切换:
- **Warm**:浅色代码高亮,与暖色调设计一致 - **Warm**:浅色代码高亮,与暖色调设计一致(默认)
- **Cold**:深色代码高亮(Tomorrow Night),适合代码密集型文章 - **Cold**:深色代码高亮(Tomorrow 风格),适合代码密集型文章
未安装高亮插件时,代码块仍以等宽字体正常显示,只是没有语法着色;主题不会因此报错,
也不会发起任何外部请求。
## 图片灯箱 ## 图片灯箱
+1
View File
@@ -9,6 +9,7 @@
"build": "astro build && node scripts/postbuild.mjs", "build": "astro build && node scripts/postbuild.mjs",
"snapshot": "node scripts/snapshot-assets.mjs", "snapshot": "node scripts/snapshot-assets.mjs",
"package": "node scripts/package.mjs", "package": "node scripts/package.mjs",
"audit:external": "node scripts/audit-external.mjs",
"release": "pnpm run build && pnpm run snapshot && pnpm run package", "release": "pnpm run build && pnpm run snapshot && pnpm run package",
"smoke": "node scripts/smoke-clickfx.mjs", "smoke": "node scripts/smoke-clickfx.mjs",
"astro": "astro", "astro": "astro",
+27
View File
@@ -0,0 +1,27 @@
{
"note": "WarmIsland 内置本地一言句子库,纯静态数据,零外部请求。站点可自行编辑本文件替换文案。",
"sentences": [
{ "text": "暖如灯火,静如留白。", "from": "暖屿" },
{ "text": "慢慢来,比较快。", "from": "暖屿" },
{ "text": "把日子过成自己喜欢的样子。", "from": "暖屿" },
{ "text": "风会记得每一朵花的香。", "from": "暖屿" },
{ "text": "愿你在忙碌里,仍留一扇看云的窗。", "from": "暖屿" },
{ "text": "写下此刻,便是对时间最轻柔的挽留。", "from": "暖屿" },
{ "text": "山不争高,水不争先。", "from": "暖屿" },
{ "text": "有些答案,要走很远才遇见。", "from": "暖屿" },
{ "text": "夜色温柔,适合读一封长信。", "from": "暖屿" },
{ "text": "把灯留着,等一个晚归的人。", "from": "暖屿" },
{ "text": "心有所栖,便是归处。", "from": "暖屿" },
{ "text": "日常里藏着最不动声色的欢喜。", "from": "暖屿" },
{ "text": "不必催促春天,花自会开。", "from": "暖屿" },
{ "text": "走过的路,都会变成身上的光。", "from": "暖屿" },
{ "text": "一杯热茶,足以安顿一个傍晚。", "from": "暖屿" },
{ "text": "愿你所念之人,也在念着你。", "from": "暖屿" },
{ "text": "安静不是空白,是留给自己呼吸。", "from": "暖屿" },
{ "text": "把复杂留给世界,把简单留给生活。", "from": "暖屿" },
{ "text": "长路漫漫,慢慢走也算抵达。", "from": "暖屿" },
{ "text": "今天也值得被认真记下。", "from": "暖屿" },
{ "text": "微光虽小,足以照亮一隅。", "from": "暖屿" },
{ "text": "在喧嚣之外,给自己留一座小岛。", "from": "暖屿" }
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

+77
View File
@@ -0,0 +1,77 @@
/* WarmIsland 自托管代码配色(Cold / 深色,参考 Prism Tomorrow 配色)
- 仅定义 .token.* 语法着色,供 Halo 代码高亮插件或「API 扩展包」服务端高亮输出使用。
- 主题本身不再加载 Prism.js / autoloader / 任何远程脚本。
- 本文件随主题分发,不含 @import、url()、远程字体或外部请求。
*/
pre[class*="language-"],
code[class*="language-"] {
color: #cccccc;
background: none;
text-shadow: none;
}
pre[class*="language-"] {
background: #2d2d2d;
border: 1px solid rgba(255, 255, 255, 0.08);
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #999999;
}
.token.punctuation {
color: #cccccc;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #f2777a;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #99cc99;
}
.token.operator,
.token.entity,
.token.url,
.token.variable {
color: #f99157;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #cc99cc;
}
.token.function,
.token.class-name {
color: #6699cc;
}
.token.regex,
.token.important {
color: #ffcc66;
}
.token.bold {
font-weight: 600;
}
.token.italic {
font-style: italic;
}
+77
View File
@@ -0,0 +1,77 @@
/* WarmIsland 自托管代码配色(Warm / 浅色)
- 仅定义 .token.* 语法着色,供 Halo 代码高亮插件或「API 扩展包」服务端高亮输出使用。
- 主题本身不再加载 Prism.js / autoloader / 任何远程脚本。
- 本文件随主题分发,不含 @import、url()、远程字体或外部请求。
*/
pre[class*="language-"],
code[class*="language-"] {
color: #4b4038;
background: none;
text-shadow: none;
}
pre[class*="language-"] {
background: #f7f2ec;
border: 1px solid rgba(90, 70, 55, 0.1);
}
.token.comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: #8c8279;
}
.token.punctuation {
color: #7a6f66;
}
.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.deleted {
color: #b04a3c;
}
.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.inserted {
color: #567c4e;
}
.token.operator,
.token.entity,
.token.url,
.token.variable {
color: #8a6b3f;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #a0527f;
}
.token.function,
.token.class-name {
color: #3f6e9e;
}
.token.regex,
.token.important {
color: #bd7423;
}
.token.bold {
font-weight: 600;
}
.token.italic {
font-style: italic;
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

+167
View File
@@ -0,0 +1,167 @@
/**
* 外部资源与隐私审计(发版前自动检查,Node 内置模块,无第三方依赖)
*
* 对应 Halo 应用市场审核指南:
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
* - 4.6 / 2.2:不得出现占位内容、无效链接
*
* 检查项:
* 1. 模板中不得有指向第三方的 <link rel=preconnect|dns-prefetch|preload>
* 2. 模板中不得有 src/href 直接指向 http(s) 的 script/link/img/iframe/source/video/audio
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
* 5. README 中的相对链接必须在安装包清单中真实存在
*
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
/** 曾经造成未披露外部请求的服务域名,出现即视为回归 */
const BLOCKED_HOSTS = [
"cdn.jsdelivr.net",
"unpkg.com",
"cdnjs.cloudflare.com",
"fonts.googleapis.com",
"fonts.gstatic.com",
"v1.hitokoto.cn",
"myip.ipip.net",
"r.inews.qq.com",
];
const REMOTE_LOAD_RE =
/<(?:script|link|img|iframe|source|video|audio)\b[^>]*\b(?:src|href)\s*=\s*["']https?:\/\/[^"']+["']/gi;
const PRECONNECT_RE =
/<link\b[^>]*\brel\s*=\s*["'](?:preconnect|dns-prefetch|preload)["'][^>]*\bhref\s*=\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_FETCH_RE = /\b(?:fetch|importScripts)\s*\(\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_IMPORT_RE = /\bimport\s*\(\s*["']https?:\/\/[^"']+["']/gi;
const REMOTE_CSS_URL_RE = /url\(\s*["']?https?:\/\/[^)"']+/gi;
const AUTOLOADER_RE = /\b(?:prism-autoloader|languages_path|Prism\.plugins\.autoloader)\b/gi;
function listFiles(dir, exts, out = []) {
if (!fs.existsSync(dir)) return out;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) {
/* 字体目录只有本地 woff2 与版权注释,不参与审计 */
if (entry.name === "fonts") continue;
listFiles(abs, exts, out);
} else if (exts.some((ext) => entry.name.endsWith(ext))) {
out.push(abs);
}
}
return out;
}
export function auditTemplates(templatesDir = path.join(root, "templates")) {
const problems = [];
const files = listFiles(templatesDir, [".html", ".js", ".css"]);
const htmlFiles = files.filter((f) => f.endsWith(".html"));
/* 对外报告的模板数只算 templates/ 根目录下的 Halo 页面模板;
templates/error/ 下的错误页 HTML 同样参与审计,但不计入模板数。 */
const htmlCount = htmlFiles.filter((f) => path.dirname(f) === templatesDir).length;
if (htmlFiles.length === 0) {
problems.push("未找到任何 templates/*.html,构建产物不完整");
return { fileCount: 0, htmlCount: 0, problems };
}
for (const file of files) {
const rel = path.relative(root, file).replace(/\\/g, "/");
const text = fs.readFileSync(file, "utf8");
const isHtml = file.endsWith(".html");
const rules = isHtml
? [
["远程预连接 / 预加载", PRECONNECT_RE],
["远程脚本 / 样式 / 媒体", REMOTE_LOAD_RE],
["硬编码外部 fetch", REMOTE_FETCH_RE],
["远程动态 import", REMOTE_IMPORT_RE],
["autoloader 动态加载", AUTOLOADER_RE],
]
: [
["硬编码外部 fetch", REMOTE_FETCH_RE],
["远程动态 import", REMOTE_IMPORT_RE],
["CSS 远程 url()", REMOTE_CSS_URL_RE],
];
for (const [label, re] of rules) {
re.lastIndex = 0;
const hit = re.exec(text);
if (hit) {
problems.push(`${rel}: ${label} → ${hit[0].slice(0, 110)}`);
}
}
for (const host of BLOCKED_HOSTS) {
if (isHtml && text.includes(host)) {
problems.push(`${rel}: 出现已移除的第三方服务域名 ${host}`);
}
}
}
return { fileCount: files.length, htmlCount, problems };
}
export function auditReadmeLinks(entryNames, readmePath = path.join(root, "README.md")) {
const problems = [];
if (!fs.existsSync(readmePath)) return ["README.md 不存在"];
const readme = fs.readFileSync(readmePath, "utf8");
const linkRe = /!?\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
let match;
let checked = 0;
while ((match = linkRe.exec(readme)) !== null) {
const href = match[1];
if (/^(?:https?:|mailto:|#)/.test(href)) continue;
let target = href.split("#")[0];
if (!target) continue;
try {
target = decodeURIComponent(target);
} catch (e) {
/* 保留原值 */
}
target = target.replace(/^\.\//, "");
checked++;
if (!entryNames.has(target)) {
problems.push(`README.md 相对链接在安装包中不存在:${href}(期望 ${target})`);
}
}
return { problems, checked };
}
function report(label, problems) {
if (problems.length === 0) {
console.log(`[audit] PASS ${label}`);
return 0;
}
for (const p of problems) console.error(`[audit] FAIL ${label}: ${p}`);
return problems.length;
}
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) {
const templates = auditTemplates();
let failures = 0;
failures += report(
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
templates.problems
);
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
const distZip = path.join(root, "dist");
const zip = fs.existsSync(distZip)
? fs.readdirSync(distZip).filter((f) => f.endsWith(".zip") && f.includes("warm-island"))
: [];
if (zip.length) {
/* 简单口径:zip 存在时仅提示需通过 pnpm package 做完整链接校验 */
console.log(`[audit] 提示:README 链接校验在 pnpm package 内执行(发现 ${zip.length} 个安装包)`);
} else {
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
}
console.log(`[audit] 模板审计共 ${failures} 项问题`);
process.exit(failures ? 1 : 0);
}
+48 -3
View File
@@ -10,6 +10,7 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import zlib from "node:zlib"; import zlib from "node:zlib";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { auditTemplates, auditReadmeLinks } from "./audit-external.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -131,11 +132,17 @@ const INCLUDE_ROOT = [
"settings.yaml", "settings.yaml",
"LICENSE", "LICENSE",
"README.md", "README.md",
/* 上架说明引用的预览图必须真实存在于制品中(审核指南 4.6 / 2.2) */
"WarmIsland Preview.webp",
"pnpm-lock.yaml", "pnpm-lock.yaml",
"pnpm-workspace.yaml", "pnpm-workspace.yaml",
]; ];
const INCLUDE_DIRS = ["templates"]; const INCLUDE_DIRS = ["templates"];
const EXCLUDE_DIRS = new Set([".git", "node_modules", "src", "dist", "compat", ".astro"]); /* README 引用的 docs/*.md 也要随包分发,避免制品内出现死链 */
const INCLUDE_DOCS = ["docs"];
const EXCLUDE_DIRS = new Set([
".git", "node_modules", "src", "dist", "compat", ".astro", ".vitepress",
]);
/* ---------------- 构建产物前置校验 ---------------- /* ---------------- 构建产物前置校验 ----------------
* templates/ 被 .gitignore 忽略,克隆仓库或清理工作区后默认不存在。 * templates/ 被 .gitignore 忽略,克隆仓库或清理工作区后默认不存在。
@@ -173,14 +180,16 @@ for (const name of INCLUDE_ROOT) {
} }
} }
function walk(dir, prefix) { function walk(dir, prefix, opts = {}) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name); const abs = path.join(dir, entry.name);
const rel = prefix ? `${prefix}/${entry.name}` : entry.name; const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (EXCLUDE_DIRS.has(entry.name)) continue; if (EXCLUDE_DIRS.has(entry.name)) continue;
walk(abs, rel); walk(abs, rel, opts);
} else { } else {
/* docs 只收 Markdown 源文件,不收 VitePress 构建产物 / 依赖 README */
if (opts.mdOnly && !entry.name.endsWith(".md")) continue;
entries.push({ name: rel, data: fs.readFileSync(abs), date: fs.statSync(abs).mtime }); entries.push({ name: rel, data: fs.readFileSync(abs), date: fs.statSync(abs).mtime });
} }
} }
@@ -190,6 +199,10 @@ for (const dir of INCLUDE_DIRS) {
const full = path.join(root, dir); const full = path.join(root, dir);
if (fs.existsSync(full)) walk(full, dir); if (fs.existsSync(full)) walk(full, dir);
} }
for (const dir of INCLUDE_DOCS) {
const full = path.join(root, dir);
if (fs.existsSync(full)) walk(full, dir, { mdOnly: true });
}
if (!entries.some((entry) => entry.name === "templates/index.html")) { if (!entries.some((entry) => entry.name === "templates/index.html")) {
fail("打包清单中缺少 templates/index.html"); fail("打包清单中缺少 templates/index.html");
@@ -201,6 +214,38 @@ if (pkgVersion !== version) {
fail(`版本号不一致:package.json=${pkgVersion},theme.yaml=${version}`); fail(`版本号不一致:package.json=${pkgVersion},theme.yaml=${version}`);
} }
/* ---------------- 上架合规前置校验 ----------------
* 1. 主题不得出现未披露的第三方自动请求 / 远程动态加载(审核指南 1.4 / 4.4.4)
* 2. README 的相对链接必须在制品内真实存在(审核指南 4.6 / 2.2)
* 3. logo 只保留一份压缩版,避免打包两份 1.5MB 大图
*/
const templateAudit = auditTemplates(templatesDir);
for (const problem of templateAudit.problems) {
console.error(`[package] ${problem}`);
}
if (templateAudit.problems.length) {
fail("外部资源审计未通过:模板仍存在第三方远程加载 / 预连接");
}
const linkAudit = auditReadmeLinks(new Set(entries.map((entry) => entry.name)));
for (const problem of linkAudit.problems) {
console.error(`[package] ${problem}`);
}
if (linkAudit.problems.length) {
fail("README 存在指向制品中不存在文件的相对链接");
}
if (fs.existsSync(path.join(templatesDir, "logo.png"))) {
fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png");
}
const logoFile = path.join(templatesDir, "assets", "logo.png");
if (fs.existsSync(logoFile) && fs.statSync(logoFile).size > 300 * 1024) {
fail("templates/assets/logo.png 超过 300KB,请先压缩再打包");
}
console.log(
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、logo 单份压缩)`
);
const outFile = path.join(root, "dist", `warm-island-${version}.zip`); const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
buildZip(entries, outFile); buildZip(entries, outFile);
+7 -5
View File
@@ -1,6 +1,6 @@
/** /**
* 构建后置处理: * 构建后置处理:
* 1. 复制 logo.png 到 assets(保留历史行为) * 1. 校验主题 logo 存在且只保留一份(public/assets/logo.png → templates/assets/logo.png)
* 2. 合并 compat/assets 下的历史版本静态资源,避免站点/代理缓存中的旧 HTML * 2. 合并 compat/assets 下的历史版本静态资源,避免站点/代理缓存中的旧 HTML
* 仍然引用上一版本的哈希文件名时出现 500(FileNotFoundException) * 仍然引用上一版本的哈希文件名时出现 500(FileNotFoundException)
* 3. 校验所有 templates/*.html 引用的静态资源真实存在,缺失即构建失败 * 3. 校验所有 templates/*.html 引用的静态资源真实存在,缺失即构建失败
@@ -16,11 +16,13 @@ const templatesDir = path.join(root, "templates");
fs.mkdirSync(assetsDir, { recursive: true }); fs.mkdirSync(assetsDir, { recursive: true });
// 1. logo // 1. logo:旧实现把 templates/logo.png 再复制一份到 assets,安装包里会有两份 1.5MB 大图。
const logoSrc = path.join(templatesDir, "logo.png"); // 现在源文件统一放在 public/assets/logo.png(256x256 压缩版),这里只做存在性校验。
if (fs.existsSync(logoSrc)) { if (!fs.existsSync(path.join(assetsDir, "logo.png"))) {
fs.copyFileSync(logoSrc, path.join(assetsDir, "logo.png")); console.error("[postbuild] 未找到 templates/assets/logo.png(public/assets/logo.png 应随构建复制)");
process.exit(1);
} }
console.log("[postbuild] logo.png 已去重,仅保留 templates/assets/logo.png");
// 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖) // 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
if (fs.existsSync(compatDir)) { if (fs.existsSync(compatDir)) {
+11 -9
View File
@@ -36,7 +36,7 @@ spec:
id: welcome_popup_enabled id: welcome_popup_enabled
label: 启用欢迎弹窗 label: 启用欢迎弹窗
value: false value: false
help: 首页显示"欢迎来自 XX·XX·XX 的朋友"的浮动提示弹窗,基于访客 IP 地理定位 help: 首页显示浮动欢迎提示;默认只显示本地静态欢迎语,不发起任何外部请求
- $formkit: select - $formkit: select
name: welcome_popup_position name: welcome_popup_position
id: welcome_popup_position id: welcome_popup_position
@@ -67,16 +67,16 @@ spec:
- $formkit: url - $formkit: url
name: welcome_popup_api name: welcome_popup_api
id: welcome_popup_api id: welcome_popup_api
label: IP 定位主 API label: 自定义 IP 定位主 API(可选)
value: "https://myip.ipip.net/json" value: ""
help: 返回 JSON 的 IP 定位主接口,默认 IPIP;留空使用默认值。支持 IPIP/腾讯/ipapi.co 等常见格式 help: 留空则只显示本地欢迎语。填写后会把访客 IP 发送给该第三方服务换取地理位置,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」
if: "$get(welcome_popup_enabled).value === true" if: "$get(welcome_popup_enabled).value === true"
- $formkit: url - $formkit: url
name: welcome_popup_fallback_api name: welcome_popup_fallback_api
id: welcome_popup_fallback_api id: welcome_popup_fallback_api
label: IP 定位备选 API label: 自定义 IP 定位备选 API(可选)
value: "https://r.inews.qq.com/api/ip2city?otype=json" value: ""
help: 主接口失败时尝试的备选接口,默认腾讯 ip2city;留空则不使用备选 help: 留空不使用备选;填写后同样会向第三方发送访客 IP
if: "$get(welcome_popup_enabled).value === true" if: "$get(welcome_popup_enabled).value === true"
- $formkit: switch - $formkit: switch
name: welcome_popup_show_once name: welcome_popup_show_once
@@ -278,8 +278,9 @@ spec:
value: hitokoto value: hitokoto
- $formkit: url - $formkit: url
name: hero_hitokoto_api name: hero_hitokoto_api
label: 一言 API 地址 label: 自定义一言 API(可选)
value: https://v1.hitokoto.cn/ value: ""
help: 留空使用主题内置的本地句子库(零外部请求);填写后才会向该地址发起请求,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」
- $formkit: checkbox - $formkit: checkbox
name: hero_hitokoto_categories name: hero_hitokoto_categories
label: 一言句子类型 label: 一言句子类型
@@ -501,6 +502,7 @@ spec:
- label: Cold - label: Cold
value: cold value: cold
value: warm value: warm
help: 仅切换主题自托管的本地代码配色样式;语法高亮本身由 Halo 应用市场的代码高亮插件或「API 扩展包」服务端高亮提供,主题不再加载任何远程高亮脚本
- $formkit: switch - $formkit: switch
name: article_extra_spacing name: article_extra_spacing
label: 中英文混排自动加空格 label: 中英文混排自动加空格
+1 -1
View File
@@ -29,7 +29,7 @@
th:unless="${link.spec.logo}" th:unless="${link.spec.logo}"
class="wi-friends__avatar wi-friends__avatar--placeholder" class="wi-friends__avatar wi-friends__avatar--placeholder"
> >
<span th:text="${#strings.substring(link.spec.displayName, 0, 1)}"></span> <span th:text="${!#strings.isEmpty(link.spec.displayName) ? #strings.substring(link.spec.displayName, 0, 1) : '·'}"></span>
</div> </div>
<div class="wi-friends__info"> <div class="wi-friends__info">
<span class="wi-friends__name" th:text="${link.spec.displayName}"></span> <span class="wi-friends__name" th:text="${link.spec.displayName}"></span>
+18 -6
View File
@@ -59,7 +59,7 @@
class="hero__description" class="hero__description"
th:if="${theme.config?.hero?.hero_description_mode == 'hitokoto'}" th:if="${theme.config?.hero?.hero_description_mode == 'hitokoto'}"
id="wi-hitokoto" id="wi-hitokoto"
th:attr="data-api=${theme.config?.hero?.hero_hitokoto_api ?: 'https://v1.hitokoto.cn/'},data-categories=${theme.config?.hero?.hero_hitokoto_categories},data-auto-refresh=${theme.config?.hero?.hero_hitokoto_auto_refresh == true ? 'true' : 'false'},data-refresh-interval=${theme.config?.hero?.hero_hitokoto_refresh_interval ?: 10000}" th:attr="data-api=${theme.config?.hero?.hero_hitokoto_api ?: ''},data-categories=${theme.config?.hero?.hero_hitokoto_categories},data-auto-refresh=${theme.config?.hero?.hero_hitokoto_auto_refresh == true ? 'true' : 'false'},data-refresh-interval=${theme.config?.hero?.hero_hitokoto_refresh_interval ?: 10000}"
></p> ></p>
<p <p
class="hero__description" class="hero__description"
@@ -125,7 +125,10 @@
var el = document.getElementById("wi-hitokoto"); var el = document.getElementById("wi-hitokoto");
if (!el) return; if (!el) return;
var api = el.getAttribute("data-api") || "https://v1.hitokoto.cn/"; /* 默认读取主题内置的本地句子库(同域静态 JSON,零第三方请求)。
仅当站点管理员在主题设置里显式填写自定义 API 时,才会产生外部请求。 */
var api = el.getAttribute("data-api");
var localUrl = "/themes/warm-island/assets/data/hitokoto.json";
var categories = el.getAttribute("data-categories"); var categories = el.getAttribute("data-categories");
var autoRefresh = el.getAttribute("data-auto-refresh") === "true"; var autoRefresh = el.getAttribute("data-auto-refresh") === "true";
var refreshInterval = parseInt(el.getAttribute("data-refresh-interval"), 10) || 10000; var refreshInterval = parseInt(el.getAttribute("data-refresh-interval"), 10) || 10000;
@@ -151,15 +154,24 @@
el.textContent = "\u2026"; el.textContent = "\u2026";
} }
return fetch(buildUrl()) return fetch(api ? buildUrl() : localUrl)
.then(function (res) { .then(function (res) {
if (!res.ok) throw new Error("HTTP " + res.status); if (!res.ok) throw new Error("HTTP " + res.status);
return res.json(); return res.json();
}) })
.then(function (data) { .then(function (data) {
var text = data.hitokoto || ""; var text, from;
var from = data.from || data.from_who || ""; if (!api && data && Array.isArray(data.sentences) && data.sentences.length) {
if (from) { /* 内置词库:随机取一条,避免每次刷新都是同一句 */
var item = data.sentences[Math.floor(Math.random() * data.sentences.length)];
text = item.text || "";
from = item.from || "";
} else {
/* 站点显式配置的自定义一言 API */
text = data.hitokoto || "";
from = data.from || data.from_who || "";
}
if (from && text) {
text += " \u2014\u2014 " + from; text += " \u2014\u2014 " + from;
} }
+6 -1
View File
@@ -73,7 +73,12 @@
plugins: [lgZoom, lgThumbnail, lgFullscreen], plugins: [lgZoom, lgThumbnail, lgFullscreen],
speed: 280, speed: 280,
mode: 'lg-fade', mode: 'lg-fade',
licenseKey: '0000-0000-0000-0000', /* lightGallery 2.9.0 以 GPLv3 发布,本主题同样采用 GPL-3.0,
按其开源许可条款使用(不申请商业授权)。
官方示例里的 '0000-0000-000-0000' 会触发
"license key is not valid for production use" 警告,
这里去掉占位字符串、显式声明许可依据,控制台不再出现该提示。 */
licenseKey: 'GPLv3',
download: false, download: false,
counter: true, counter: true,
preload: 2, preload: 2,
+1 -1
View File
@@ -26,7 +26,7 @@
th:unless="${comment.spec.owner.avatar}" th:unless="${comment.spec.owner.avatar}"
class="wi-message-wall__avatar wi-message-wall__avatar--placeholder" class="wi-message-wall__avatar wi-message-wall__avatar--placeholder"
> >
<span th:text="${#strings.substring(comment.spec.owner.displayName, 0, 1)}"></span> <span th:text="${!#strings.isEmpty(comment.spec.owner?.displayName) ? #strings.substring(comment.spec.owner?.displayName, 0, 1) : '·'}"></span>
</div> </div>
<div class="wi-message-wall__meta"> <div class="wi-message-wall__meta">
<span class="wi-message-wall__name" th:text="${comment.spec.owner.displayName}"></span> <span class="wi-message-wall__name" th:text="${comment.spec.owner.displayName}"></span>
+25 -16
View File
@@ -5,7 +5,7 @@
class="wi-welcome-popup" class="wi-welcome-popup"
id="wi-welcome-popup" id="wi-welcome-popup"
th:if="${theme.config?.welcome_popup?.welcome_popup_enabled == true}" th:if="${theme.config?.welcome_popup?.welcome_popup_enabled == true}"
th:attr="data-position=${theme.config?.welcome_popup?.welcome_popup_position ?: 'top-center'},data-duration=${theme.config?.welcome_popup?.welcome_popup_duration ?: 5},data-api=${theme.config?.welcome_popup?.welcome_popup_api},data-fallback-api=${theme.config?.welcome_popup?.welcome_popup_fallback_api},data-show-once=${theme.config?.welcome_popup?.welcome_popup_show_once != false ? 'true' : 'false'}" th:attr="data-position=${theme.config?.welcome_popup?.welcome_popup_position ?: 'top-center'},data-duration=${theme.config?.welcome_popup?.welcome_popup_duration ?: 5},data-api=${theme.config?.welcome_popup?.welcome_popup_api ?: ''},data-fallback-api=${theme.config?.welcome_popup?.welcome_popup_fallback_api ?: ''},data-show-once=${theme.config?.welcome_popup?.welcome_popup_show_once != false ? 'true' : 'false'}"
aria-live="polite" aria-live="polite"
aria-atomic="true" aria-atomic="true"
> >
@@ -16,7 +16,7 @@
<circle cx="12" cy="10" r="3"></circle> <circle cx="12" cy="10" r="3"></circle>
</svg> </svg>
</div> </div>
<div class="wi-welcome-popup__content" id="wi-welcome-popup-text">加载中…</div> <div class="wi-welcome-popup__content" id="wi-welcome-popup-text">欢迎来访,愿你在这里度过一段安静的时光</div>
<button class="wi-welcome-popup__close" type="button" aria-label="关闭"> <button class="wi-welcome-popup__close" type="button" aria-label="关闭">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"></path> <path d="M18 6 6 18"></path>
@@ -46,8 +46,12 @@
var closeBtn = popup.querySelector(".wi-welcome-popup__close"); var closeBtn = popup.querySelector(".wi-welcome-popup__close");
var progress = document.getElementById("wi-welcome-popup-progress"); var progress = document.getElementById("wi-welcome-popup-progress");
var duration = parseInt(popup.getAttribute("data-duration"), 10) || 5; var duration = parseInt(popup.getAttribute("data-duration"), 10) || 5;
var api = popup.getAttribute("data-api") || "https://myip.ipip.net/json"; /* 默认零外部请求:主题内置静态欢迎语,不向任何第三方发送访客 IP。
var fallbackApi = popup.getAttribute("data-fallback-api") || "https://r.inews.qq.com/api/ip2city?otype=json"; 仅当站点管理员在主题设置里显式填写自定义 IP 定位 API 时才会 fetch;
一旦填写,浏览器会向该服务发送访客 IP,需自行在站点隐私说明中披露
(详见 README「外部资源与隐私」)。 */
var api = popup.getAttribute("data-api") || "";
var fallbackApi = popup.getAttribute("data-fallback-api") || "";
var hideTimer = null; var hideTimer = null;
var progressRaf = null; var progressRaf = null;
@@ -91,6 +95,14 @@
hideTimer = setTimeout(hidePopup, duration * 1000); hideTimer = setTimeout(hidePopup, duration * 1000);
} }
function renderWelcome() {
if (textEl) {
textEl.textContent = "欢迎来访,愿你在这里度过一段安静的时光";
}
popup.classList.add("wi-welcome-popup--visible");
startAutoHide();
}
function renderMessage(location) { function renderMessage(location) {
var loc = (location || "").trim(); var loc = (location || "").trim();
if (!loc) loc = "未知星球"; if (!loc) loc = "未知星球";
@@ -102,26 +114,24 @@
} }
function fetchLocation() { function fetchLocation() {
if (textEl) {
textEl.textContent = "加载中…";
}
popup.classList.add("wi-welcome-popup--visible"); popup.classList.add("wi-welcome-popup--visible");
/* 按顺序尝试主 API -> 备选 API */ /* 按顺序尝试主 API -> 备选 API;两者都为空则完全不发外部请求 */
var endpoints = []; var endpoints = [];
if (api) endpoints.push(api); if (api) endpoints.push(api);
if (fallbackApi && fallbackApi !== api) endpoints.push(fallbackApi); if (fallbackApi && fallbackApi !== api) endpoints.push(fallbackApi);
if (endpoints.length === 0) { if (endpoints.length === 0) {
renderMessage(""); renderWelcome();
return; return;
} }
if (textEl) textEl.textContent = "正在获取位置…";
var idx = 0; var idx = 0;
function tryNext() { function tryNext() {
if (idx >= endpoints.length) { if (idx >= endpoints.length) {
renderMessage(""); renderWelcome();
return; return;
} }
var url = endpoints[idx++]; var url = endpoints[idx++];
@@ -167,15 +177,14 @@
}); });
} }
/* 解析多种 IP API 的返回格式: /* 解析常见 IP 定位接口的返回格式(仅在管理员显式配置自定义 API 时才会用到):
- IPIP: {"ret":"ok","data":{"location":["中国","河北","张家口","","联通"]}} - { data: { location: [...] } }
- 腾讯 ip2city: {"ret":0,"province":"河北","city":"张家口",...} - { province, city, ... } / { regionName, city, ... }
- ipapi.co / ip-api.com: {"country":"China","regionName":"Hebei","city":"Zhangjiakou"} - { pro, city, ... } */
- 太平洋: {"pro":"广东省","city":"广州市",...} */
function parseLocation(data) { function parseLocation(data) {
if (!data) return ""; if (!data) return "";
/* IPIP: data.data.location 数组 */ /* data.location 数组格式 */
if (data.data && Array.isArray(data.data.location)) { if (data.data && Array.isArray(data.data.location)) {
var loc = data.data.location var loc = data.data.location
.filter(function (s) { .filter(function (s) {
+2 -2
View File
@@ -86,8 +86,8 @@ const fragmentAttr = layoutFragment ? "html (head, content)" : undefined;
th:href="${theme.config?.basic?.favicon ?: '/favicon.ico'}" th:href="${theme.config?.basic?.favicon ?: '/favicon.ico'}"
/> />
<link rel="alternate" type="application/rss+xml" th:title="${site.title}" th:href="@{/feed.xml}" /> <link rel="alternate" type="application/rss+xml" th:title="${site.title}" th:href="@{/feed.xml}" />
<link rel="preconnect" href="https://cdn.jsdelivr.net" /> <!-- 主题不预连接任何第三方 CDN:所有脚本、样式、字体均随主题自托管。
<link rel="dns-prefetch" href="https://cdn.jsdelivr.net" /> 分享链接等外链只在访客主动点击时打开,不产生自动外部请求。 -->
<link rel="stylesheet" th:href="@{/assets/fonts/LXGWWenKai/result.css}" media="print" onload="this.media='all'" /> <link rel="stylesheet" th:href="@{/assets/fonts/LXGWWenKai/result.css}" media="print" onload="this.media='all'" />
<noscript><link rel="stylesheet" th:href="@{/assets/fonts/LXGWWenKai/result.css}" /></noscript> <noscript><link rel="stylesheet" th:href="@{/assets/fonts/LXGWWenKai/result.css}" /></noscript>
<style is:inline th:if="${theme.config?.style?.accent_color}" th:utext="${':root { --accent: ' + theme.config?.style?.accent_color + '; --accent-hover: ' + theme.config?.style?.accent_color + '; }'}">/*accent*/</style> <style is:inline th:if="${theme.config?.style?.accent_color}" th:utext="${':root { --accent: ' + theme.config?.style?.accent_color + '; --accent-hover: ' + theme.config?.style?.accent_color + '; }'}">/*accent*/</style>
+1 -1
View File
@@ -141,7 +141,7 @@ import Layout from "../layouts/Layout.astro";
/> />
</div> </div>
<div class="wi-douban__poster wi-douban__poster--placeholder" th:if="${#strings.isEmpty(item.poster)}"> <div class="wi-douban__poster wi-douban__poster--placeholder" th:if="${#strings.isEmpty(item.poster)}">
<span th:text="${#strings.substring(item.name ?: '', 0, 1)}"></span> <span th:text="${!#strings.isEmpty(item.name) ? #strings.substring(item.name, 0, 1) : '·'}"></span>
</div> </div>
<div class="wi-douban__body"> <div class="wi-douban__body">
<h3 class="wi-douban__name" th:text="${item.name}"></h3> <h3 class="wi-douban__name" th:text="${item.name}"></h3>
+1 -1
View File
@@ -45,7 +45,7 @@ import Layout from "../layouts/Layout.astro";
class="wi-equipments__cover wi-equipments__cover--placeholder" class="wi-equipments__cover wi-equipments__cover--placeholder"
th:if="${#strings.isEmpty(equip.spec?.cover)}" th:if="${#strings.isEmpty(equip.spec?.cover)}"
> >
<span th:text="${#strings.substring(equip.spec?.displayName ?: '', 0, 1)}"></span> <span th:text="${!#strings.isEmpty(equip.spec?.displayName) ? #strings.substring(equip.spec?.displayName, 0, 1) : '·'}"></span>
</div> </div>
<div class="wi-equipments__body"> <div class="wi-equipments__body">
<h3 <h3
+1 -1
View File
@@ -41,7 +41,7 @@ import Layout from "../layouts/Layout.astro";
class="wi-friends-page__avatar wi-friends-page__avatar--placeholder" class="wi-friends-page__avatar wi-friends-page__avatar--placeholder"
> >
<span <span
th:text="${#strings.substring(spec.author ?: '', 0, 1)}" th:text="${!#strings.isEmpty(spec.author) ? #strings.substring(spec.author, 0, 1) : '·'}"
></span> ></span>
</div> </div>
<div class="wi-friends-page__author-info"> <div class="wi-friends-page__author-info">
+3 -3
View File
@@ -341,7 +341,7 @@ import Layout from "../layouts/Layout.astro";
<span class="wi-id__avatar"> <span class="wi-id__avatar">
<span <span
class="wi-id__avatar-fallback" class="wi-id__avatar-fallback"
th:text="${#strings.substring(p.displayName, 0, 1)}" th:text="${!#strings.isEmpty(p.displayName) ? #strings.substring(p.displayName, 0, 1) : '·'}"
>老</span >老</span
> >
<img <img
@@ -407,7 +407,7 @@ import Layout from "../layouts/Layout.astro";
<span class="wi-id__avatar"> <span class="wi-id__avatar">
<span <span
class="wi-id__avatar-fallback" class="wi-id__avatar-fallback"
th:text="${#strings.substring(p.displayName, 0, 1)}" th:text="${!#strings.isEmpty(p.displayName) ? #strings.substring(p.displayName, 0, 1) : '·'}"
>老</span >老</span
> >
<img <img
@@ -466,7 +466,7 @@ import Layout from "../layouts/Layout.astro";
<span class="wi-id__avatar"> <span class="wi-id__avatar">
<span <span
class="wi-id__avatar-fallback" class="wi-id__avatar-fallback"
th:text="${#strings.substring(p.displayName, 0, 1)}" th:text="${!#strings.isEmpty(p.displayName) ? #strings.substring(p.displayName, 0, 1) : '·'}"
>张</span >张</span
> >
<img <img
+1 -1
View File
@@ -70,7 +70,7 @@ import Layout from "../layouts/Layout.astro";
class="wi-links__avatar wi-links__avatar--placeholder" class="wi-links__avatar wi-links__avatar--placeholder"
> >
<span <span
th:text="${#strings.substring(link.spec.displayName, 0, 1)}" th:text="${!#strings.isEmpty(link.spec.displayName) ? #strings.substring(link.spec.displayName, 0, 1) : '·'}"
></span> ></span>
</div> </div>
<!-- 可达性检测徽章(plugin-links 后台「友链检测」产生):绿点=可访问,红点=异常,未检测不显示 --> <!-- 可达性检测徽章(plugin-links 后台「友链检测」产生):绿点=可访问,红点=异常,未检测不显示 -->
+1 -1
View File
@@ -43,7 +43,7 @@ import LightGallery from "../components/LightGallery.astro";
class="wi-moment-detail__avatar wi-moment-detail__avatar--fallback" class="wi-moment-detail__avatar wi-moment-detail__avatar--fallback"
aria-hidden="true" aria-hidden="true"
> >
<span th:text="${#strings.substring(site.title, 0, 1)}"></span> <span th:text="${!#strings.isEmpty(site.title) ? #strings.substring(site.title, 0, 1) : '·'}"></span>
</div> </div>
<div class="wi-moment-detail__main"> <div class="wi-moment-detail__main">
+1 -1
View File
@@ -45,7 +45,7 @@ import LightGallery from "../components/LightGallery.astro";
class="wi-moments-page__avatar wi-moments-page__avatar--fallback" class="wi-moments-page__avatar wi-moments-page__avatar--fallback"
aria-hidden="true" aria-hidden="true"
> >
<span th:text="${#strings.substring(site.title, 0, 1)}"></span> <span th:text="${!#strings.isEmpty(site.title) ? #strings.substring(site.title, 0, 1) : '·'}"></span>
</div> </div>
<div class="wi-moments-page__main"> <div class="wi-moments-page__main">
+1 -1
View File
@@ -18,7 +18,7 @@ import Layout from "../layouts/Layout.astro";
订阅 订阅
</h1> <p class="wi-subs__subtitle" th:text="${theme.config?.subscriptions?.subscriptions_subtitle ?: '博友们的最新文章'}"> </h1> <p class="wi-subs__subtitle" th:text="${theme.config?.subscriptions?.subscriptions_subtitle ?: '博友们的最新文章'}">
博友们的最新文章 博友们的最新文章
</p> <span class="wi-page-accent"></span> </div> <th:block th:if="${feedPage != null and !#lists.isEmpty(feedPage.items)}"> <div class="wi-subs__flow"> <article class="wi-subs__card" th:each="item : ${feedPage.items}" th:with="authorName=${item.author ?: '匿名'}"> <div class="wi-subs__author"> <img th:if="${item.authorLogo}" th:src="${item.authorLogo}" th:alt="${authorName}" class="wi-subs__avatar" loading="lazy" decoding="async"> <div th:unless="${item.authorLogo}" class="wi-subs__avatar wi-subs__avatar--placeholder"> <span th:text="${#strings.substring(authorName, 0, 1)}"></span> </div> <div class="wi-subs__author-info"> <a th:if="${item.authorUrl}" th:href="${item.authorUrl}" target="_blank" rel="noopener noreferrer" class="wi-subs__author-name" th:text="${authorName}"></a> <span th:unless="${item.authorUrl}" class="wi-subs__author-name" th:text="${authorName}"></span> <time class="wi-subs__date" th:if="${item.publishedAt}" th:text="${#temporals.format(item.publishedAt, 'yyyy-MM-dd')}"></time> </div> </div> <div class="wi-subs__body"> <a th:href="${item.url}" target="_blank" rel="noopener noreferrer" class="wi-subs__title-link"> <h2 class="wi-subs__post-title" th:text="${item.title}"></h2> </a> <p class="wi-subs__summary" th:if="${item.summary}" th:text="${item.summary}"></p> </div> </article> </div> <nav class="wi-subs__pagination" th:if="${loadingMode != 'infinite_scroll' and (hasCursor or feedPage.hasNext)}" th:with="permalink=${singlePage.status.permalink}"> <a th:if="${hasCursor}" th:href="@{${permalink}}" class="wi-subs__page-link" th:text="'较新'"> </p> <span class="wi-page-accent"></span> </div> <th:block th:if="${feedPage != null and !#lists.isEmpty(feedPage.items)}"> <div class="wi-subs__flow"> <article class="wi-subs__card" th:each="item : ${feedPage.items}" th:with="authorName=${item.author ?: '匿名'}"> <div class="wi-subs__author"> <img th:if="${item.authorLogo}" th:src="${item.authorLogo}" th:alt="${authorName}" class="wi-subs__avatar" loading="lazy" decoding="async"> <div th:unless="${item.authorLogo}" class="wi-subs__avatar wi-subs__avatar--placeholder"> <span th:text="${!#strings.isEmpty(authorName) ? #strings.substring(authorName, 0, 1) : '·'}"></span> </div> <div class="wi-subs__author-info"> <a th:if="${item.authorUrl}" th:href="${item.authorUrl}" target="_blank" rel="noopener noreferrer" class="wi-subs__author-name" th:text="${authorName}"></a> <span th:unless="${item.authorUrl}" class="wi-subs__author-name" th:text="${authorName}"></span> <time class="wi-subs__date" th:if="${item.publishedAt}" th:text="${#temporals.format(item.publishedAt, 'yyyy-MM-dd')}"></time> </div> </div> <div class="wi-subs__body"> <a th:href="${item.url}" target="_blank" rel="noopener noreferrer" class="wi-subs__title-link"> <h2 class="wi-subs__post-title" th:text="${item.title}"></h2> </a> <p class="wi-subs__summary" th:if="${item.summary}" th:text="${item.summary}"></p> </div> </article> </div> <nav class="wi-subs__pagination" th:if="${loadingMode != 'infinite_scroll' and (hasCursor or feedPage.hasNext)}" th:with="permalink=${singlePage.status.permalink}"> <a th:if="${hasCursor}" th:href="@{${permalink}}" class="wi-subs__page-link" th:text="'较新'">
较新 较新
</a> <span th:unless="${hasCursor}" class="wi-subs__page-link wi-subs__page-link--placeholder" aria-hidden="true"></span> <span class="wi-subs__page-info"></span> <a th:if="${feedPage.hasNext}" th:href="@{|${permalink}?beforePublishedAt=${feedPage.nextBeforePublishedAt}&beforeId=${feedPage.nextBeforeId}|}" class="wi-subs__page-link" th:text="'较旧'"> </a> <span th:unless="${hasCursor}" class="wi-subs__page-link wi-subs__page-link--placeholder" aria-hidden="true"></span> <span class="wi-subs__page-info"></span> <a th:if="${feedPage.hasNext}" th:href="@{|${permalink}?beforePublishedAt=${feedPage.nextBeforePublishedAt}&beforeId=${feedPage.nextBeforeId}|}" class="wi-subs__page-link" th:text="'较旧'">
较旧 较旧
+67 -71
View File
@@ -194,9 +194,11 @@ import LightGallery from "../components/LightGallery.astro";
<button class="wi-wechat-qr__close" type="button" aria-label="Close"> <button class="wi-wechat-qr__close" type="button" aria-label="Close">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg> <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
</button> </button>
<div class="wi-wechat-qr__title" th:text="'微信扫码分享'">微信扫码分享</div> <div class="wi-wechat-qr__title" th:text="'微信分享'">微信分享</div>
<canvas class="wi-wechat-qr__canvas" id="wi-wechat-qr-canvas"></canvas> <!-- 微信分享不加载任何远程二维码库:改为复制文章链接,由访客在微信内分享 -->
<div class="wi-wechat-qr__hint" th:text="'打开微信,扫描二维码即可分享'">打开微信,扫描二维码即可分享</div> <input class="wi-wechat-qr__url" id="wi-wechat-qr-url" type="text" readonly="readonly" aria-label="文章链接" />
<button class="wi-wechat-qr__copy" id="wi-wechat-qr-copy" type="button" th:text="'复制链接'">复制链接</button>
<div class="wi-wechat-qr__hint" th:text="'复制链接后,在微信中发送给好友'">复制链接后,在微信中发送给好友</div>
</div> </div>
</div> </div>
@@ -295,37 +297,20 @@ import LightGallery from "../components/LightGallery.astro";
<LightGallery /> <LightGallery />
<!-- 代码高亮:主题不再加载远程语法高亮运行时与自动加载插件(后者会按代码语言
从 CDN 动态拉取并执行组件,属于远程动态加载)。语法高亮改由应用市场的
代码高亮插件或「API 扩展包」服务端高亮输出,主题只自托管两套 token 配色。 -->
<link <link
th:if="${theme.config?.article?.article_code_theme == 'cold'}" th:if="${theme.config?.article?.article_code_theme == 'cold'}"
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism-tomorrow.min.css" href="/themes/warm-island/assets/vendor/prism-cold.css"
/> />
<link <link
th:if="${theme.config?.article?.article_code_theme != 'cold'}" th:if="${theme.config?.article?.article_code_theme != 'cold'}"
rel="stylesheet" rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/themes/prism.min.css" href="/themes/warm-island/assets/vendor/prism-warm.css"
/> />
<script th:if="${theme.config?.article?.article_show_share != false and #strings.contains(theme.config?.article?.article_share_channels ?: 'qq,wechat,weibo', 'wechat')}" is:inline defer src="https://cdn.jsdelivr.net/npm/qrcode-generator@1.4.4/qrcode.min.js"></script>
<script is:inline defer src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/prism.min.js"></script>
<script is:inline defer src="https://cdn.jsdelivr.net/npm/prismjs@1.29.0/plugins/autoloader/prism-autoloader.min.js"></script>
<script is:inline th:inline="none">
(function () {
if (typeof Prism !== "undefined") {
Prism.plugins.autoloader.languages_path = "https://cdn.jsdelivr.net/npm/prismjs@1.29.0/components/";
var body = document.getElementById("wi-post-body");
if (body) {
body.querySelectorAll("pre code").forEach(function (block) {
if (!block.classList.contains("language-")) {
Prism.highlightElement(block);
}
});
}
}
})();
</script>
<script is:inline th:inline="none"> <script is:inline th:inline="none">
(function () { (function () {
var progressBar = document.getElementById("wi-reading-progress"); var progressBar = document.getElementById("wi-reading-progress");
@@ -673,58 +658,45 @@ import LightGallery from "../components/LightGallery.astro";
var wechatBtn = document.querySelector(".wi-post__share-btn--wechat"); var wechatBtn = document.querySelector(".wi-post__share-btn--wechat");
var wechatQr = document.getElementById("wi-wechat-qr"); var wechatQr = document.getElementById("wi-wechat-qr");
var wechatQrCanvas = document.getElementById("wi-wechat-qr-canvas"); var wechatUrlInput = document.getElementById("wi-wechat-qr-url");
var wechatCopyBtn = document.getElementById("wi-wechat-qr-copy");
var wechatQrClose = wechatQr ? wechatQr.querySelector(".wi-wechat-qr__close") : null; var wechatQrClose = wechatQr ? wechatQr.querySelector(".wi-wechat-qr__close") : null;
var wechatQrGenerated = false;
function generateQr(text, canvas) { function resolveShareUrl() {
var size = 200; var rawUrl = (wechatBtn && wechatBtn.getAttribute("data-url")) || window.location.pathname;
var cellSize = 5; return rawUrl.startsWith("http") ? rawUrl : window.location.origin + rawUrl;
var qr = null; }
/* 复制链接后由访客在微信内分享,零远程脚本 */
function copyShareUrl() {
var url = resolveShareUrl();
if (wechatUrlInput) wechatUrlInput.value = url;
var done = function () {
if (!wechatCopyBtn) return;
wechatCopyBtn.textContent = "已复制";
setTimeout(function () {
wechatCopyBtn.textContent = "复制链接";
}, 1800);
};
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(url).then(done, function () {
if (wechatUrlInput) wechatUrlInput.select();
});
} else if (wechatUrlInput) {
wechatUrlInput.select();
try { try {
var typeNumber = 0; document.execCommand("copy");
var errorCorrectionLevel = "M"; done();
var qrData = []; } catch (e) {}
for (var i = 0; i < text.length; i++) {
qrData.push(text.charCodeAt(i));
}
qr = qrcode(typeNumber, errorCorrectionLevel);
qr.addData(text);
qr.make();
} catch (e) {
return;
}
var moduleCount = qr.getModuleCount();
var margin = 4;
var totalCells = moduleCount + margin * 2;
var canvasSize = totalCells * cellSize;
canvas.width = canvasSize;
canvas.height = canvasSize;
var ctx = canvas.getContext("2d");
ctx.fillStyle = "#ffffff";
ctx.fillRect(0, 0, canvasSize, canvasSize);
ctx.fillStyle = "#000000";
for (var row = 0; row < moduleCount; row++) {
for (var col = 0; col < moduleCount; col++) {
if (qr.isDark(row, col)) {
ctx.fillRect((col + margin) * cellSize, (row + margin) * cellSize, cellSize, cellSize);
}
}
} }
} }
if (wechatBtn && wechatQr) { if (wechatBtn && wechatQr) {
wechatBtn.addEventListener("click", function () { wechatBtn.addEventListener("click", function () {
wechatQr.classList.add("wi-wechat-qr--visible"); wechatQr.classList.add("wi-wechat-qr--visible");
if (!wechatQrGenerated && wechatQrCanvas) { if (wechatUrlInput) wechatUrlInput.value = resolveShareUrl();
var rawUrl = wechatBtn.getAttribute("data-url") || window.location.pathname;
var url = rawUrl.startsWith("http") ? rawUrl : window.location.origin + rawUrl;
if (typeof qrcode !== "undefined") {
generateQr(url, wechatQrCanvas);
wechatQrGenerated = true;
}
}
}); });
if (wechatCopyBtn) wechatCopyBtn.addEventListener("click", copyShareUrl);
if (wechatQrClose) { if (wechatQrClose) {
wechatQrClose.addEventListener("click", function () { wechatQrClose.addEventListener("click", function () {
wechatQr.classList.remove("wi-wechat-qr--visible"); wechatQr.classList.remove("wi-wechat-qr--visible");
@@ -1255,12 +1227,36 @@ import LightGallery from "../components/LightGallery.astro";
margin-bottom: var(--space-md); margin-bottom: var(--space-md);
} }
.wi-wechat-qr__canvas { .wi-wechat-qr__url {
display: block; display: block;
margin: 0 auto var(--space-md); width: 100%;
box-sizing: border-box;
margin: 0 0 var(--space-sm, 0.5rem);
padding: 10px 12px;
border: 1px solid var(--rule);
border-radius: var(--radius-sm, 8px); border-radius: var(--radius-sm, 8px);
background: #fff; background: var(--bg-raised);
padding: 8px; color: var(--ink);
font-size: var(--text-sm);
text-overflow: ellipsis;
}
.wi-wechat-qr__copy {
display: block;
width: 100%;
margin: 0 0 var(--space-md);
padding: 10px 12px;
border: none;
border-radius: var(--radius-sm, 8px);
background: var(--accent);
color: #fff;
font-size: var(--text-sm);
cursor: pointer;
transition: opacity 0.15s ease;
}
.wi-wechat-qr__copy:hover {
opacity: 0.88;
} }
.wi-wechat-qr__hint { .wi-wechat-qr__hint {