feat(v1.2.6): 合并审核整改、确认完全原创并完善可选 API 披露

版本升至 1.2.6(1.2.5 审核被拒未发布,整改内容并入本版);README 新增原创声明(4.2.1/4.2.2 不适用);Hero 描述来源默认 none,选一言自动填入官方接口、清空回退本地句子库;文档列出两个 IP 定位候选接口与访客 IP 披露文案;含上一轮 4.4.2 内容渲染安全与 5.2 字体许可证修复。
This commit is contained in:
nxxy335 committed 2026-11-11 16:09:31 +08:00
1 parent dda86294b0
commit c92429d139
18 files changed
+485 -63

No files matched your search

+63 -14
View File
@@ -14,7 +14,7 @@
1. **响应式设计** — 适配桌面端与移动端,支持暗色模式,移动端使用动态视口单位适配地址栏 1. **响应式设计** — 适配桌面端与移动端,支持暗色模式,移动端使用动态视口单位适配地址栏
2. **插件兼容性** — 适配 11 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观 2. **插件兼容性** — 适配 11 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
3. **内置灯箱** — 主题内置 LightGallery 灯箱(GPLv3,随主题自托管打包),支持 EXIF 信息展示;安装 lightgallery.js 插件后自动禁用 3. **内置灯箱** — 主题内置 LightGallery 灯箱(GPLv3,随主题自托管打包),支持 EXIF 信息展示;安装 lightgallery.js 插件后自动禁用
4. **可配置性** — 17 个设置分组、122 个配置项,几乎所有模块可独立开关 4. **可配置性** — 17 个设置分组、126 个配置项,几乎所有模块可独立开关
5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅首屏边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](https://nxxy335.top/WarmIsland/customize/development.html) 5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅首屏边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](https://nxxy335.top/WarmIsland/customize/development.html)
6. **零默认外部请求** — 所有脚本、样式、字体、图标、句子库、主题图标均随主题自托管,默认不向任何第三方发起自动请求;详见[外部资源与隐私](#外部资源与隐私) 6. **零默认外部请求** — 所有脚本、样式、字体、图标、句子库、主题图标均随主题自托管,默认不向任何第三方发起自动请求;详见[外部资源与隐私](#外部资源与隐私)
7. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局 7. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局
@@ -38,14 +38,29 @@
| 服务 / 资源 | 用途 | 涉及的数据类型 | 默认状态 | 关闭 / 替换方法 | | 服务 / 资源 | 用途 | 涉及的数据类型 | 默认状态 | 关闭 / 替换方法 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| 主题自身静态资源 `/themes/warm-island/assets/*` | 样式、脚本、字体、图标、Logo、内置句子库 | 无(同域请求,不含用户数据) | 始终启用,渲染必需 | 不可关闭;均为随包自托管文件 | | 主题自身静态资源 `/themes/warm-island/assets/*` | 样式、脚本、字体、图标、Logo、内置句子库 | 无(同域请求,不含用户数据) | 始终启用,渲染必需 | 不可关闭;均为随包自托管文件 |
| 自定义一言 API `hero_hitokoto_api` | Hero 首屏随机句子 | 无请求体;发起 GET 时第三方可见访客 IP / User-Agent | **关闭**:留空,使用 `assets/data/hitokoto.json` 内置本地句子库 | 主题设置 → Hero 首屏 → 清空「自定义一言 API」 | | 自定义一言 API `hero_hitokoto_api` | Hero 首屏随机句子 | 无请求体;发起 GET 时第三方可见访客 IP / User-Agent | **默认不启用**:「描述文案来源」默认「无」,一言元素不渲染,零请求;选择「一言」时自动填入 `https://v1.hitokoto.cn/` | 主题设置 → Hero 首屏 → 描述来源改为「无」或「自定义」,或清空该字段回退到 `assets/data/hitokoto.json` 内置本地句子库 |
| 自定义 IP 定位主 / 备选 API `welcome_popup_api` / `welcome_popup_fallback_api` | 欢迎弹窗显示「欢迎来自 XX 的朋友」 | **访客 IP** 与第三方返回的粗略地理位置 | **关闭**:两项均留空,弹窗只显示本地欢迎语;且欢迎弹窗开关本身默认关闭 | 主题设置 → 欢迎弹窗 → 关闭开关,或清空两个 API 字段 | | 自定义 IP 定位主 / 备选 API `welcome_popup_api` / `welcome_popup_fallback_api` | 欢迎弹窗显示「欢迎来自 XX 的朋友」 | **访客 IP** 与第三方返回的粗略地理位置 | **关闭**:两项均留空,弹窗只显示本地欢迎语;且欢迎弹窗开关本身默认关闭。可选接口见下方清单 | 主题设置 → 欢迎弹窗 → 关闭开关,或清空两个 API 字段 |
| 分享按钮:QQ / 微博 / X / Facebook / Telegram | 分享文章到对应平台 | 文章 URL 与标题(写在目标站 URL 参数中) | 默认开启 `qq,wechat,weibo`;**仅在访客点击后**跳转,页面加载时不请求 | 主题设置 → 文章 → 关闭「显示分享按钮」,或在「分享渠道」取消勾选 | | 分享按钮:QQ / 微博 / X / Facebook / Telegram | 分享文章到对应平台 | 文章 URL 与标题(写在目标站 URL 参数中) | 默认开启 `qq,wechat,weibo`;**仅在访客点击后**跳转,页面加载时不请求 | 主题设置 → 文章 → 关闭「显示分享按钮」,或在「分享渠道」取消勾选 |
| 微信分享弹层 | 生成并复制文章链接 | 文章 URL(浏览器本地字符串,不发送) | 与分享按钮一致 | 同上;已移除二维码远程库,改为「复制链接」 | | 微信分享弹层 | 生成并复制文章链接 | 文章 URL(浏览器本地字符串,不发送) | 与分享按钮一致 | 同上;已移除二维码远程库,改为「复制链接」 |
| 页脚备案 / Halo / 应用市场外链 | 备案信息、致谢、插件安装提示 | 无(访客主动点击才跳转) | 由站点配置决定,默认仅显示 Halo 致谢 | 主题设置 → 页脚 → 关闭对应显示项 | | 页脚备案 / Halo / 应用市场外链 | 备案信息、致谢、插件安装提示 | 无(访客主动点击才跳转) | 由站点配置决定,默认仅显示 Halo 致谢 | 主题设置 → 页脚 → 关闭对应显示项 |
| 插件前台页面(豆瓣、Steam、朋友圈、微信读书等) | 展示插件提供的数据 | 由对应插件决定,主题不发起请求 | 安装并启用对应插件后才出现 | 卸载 / 停用对应插件;主题自动降级隐藏 | | 插件前台页面(豆瓣、Steam、朋友圈、微信读书等) | 展示插件提供的数据 | 由对应插件决定,主题不发起请求 | 安装并启用对应插件后才出现 | 卸载 / 停用对应插件;主题自动降级隐藏 |
| 内置 LightGallery 灯箱 | 图片放大预览、EXIF 展示 | 无(本地打包,不联网) | 默认启用 | 安装 lightgallery.js 插件后主题自动让位 | | 内置 LightGallery 灯箱 | 图片放大预览、EXIF 展示 | 无(本地打包,不联网) | 默认启用 | 安装 lightgallery.js 插件后主题自动让位 |
#### 可选第三方接口地址(均为候选值,主题不预置请求)
| 能力 | 设置项 | 可选地址 |
| --- | --- | --- |
| Hero 一言 | `hero_hitokoto_api` | `https://v1.hitokoto.cn/`(选择「一言」时自动填入;清空则使用内置本地句子库) |
| IP 定位主接口 | `welcome_popup_api` | `https://myip.ipip.net/json` 或 `https://r.inews.qq.com/api/ip2city?otype=json` |
| IP 定位备选接口 | `welcome_popup_fallback_api` | 同上两个地址任选其一,主接口失败时兜底 |
**描述文案来源默认为「无」**,可选「一言」「自定义」,此时不渲染一言元素、不发起任何请求。选择「一言」后自动填入
`https://v1.hitokoto.cn/`;选择「一言」或「自定义」后若把 API 留空,则使用主题内置的本地句子库(零外部请求)。
**只有填写后才会向该地址发起请求**,需自行在站点隐私说明中披露,详见本章节「逐项说明」与下方「隐私说明(5.1)」。
IP 定位接口同样默认留空;**填写后会把访客 IP 发送给该第三方服务换取地理位置**,
需自行在站点隐私说明中披露,详见本章节「逐项说明」与下方「隐私说明(5.1)」。主题不会代替站点做这项声明。
> 自定义 API 属于**站点管理员主动配置**的可选能力:一旦填写,浏览器会按设置向该第三方发起请求。请自行评估服务商的隐私政策,并在站点隐私说明中补充披露;主题不会代替站点做这项声明。 > 自定义 API 属于**站点管理员主动配置**的可选能力:一旦填写,浏览器会按设置向该第三方发起请求。请自行评估服务商的隐私政策,并在站点隐私说明中补充披露;主题不会代替站点做这项声明。
### 隐私说明(5.1) ### 隐私说明(5.1)
@@ -55,7 +70,7 @@
| 收集的数据类型 | 默认配置下**无**。主题不读取、不上传用户信息、站点内容、访问日志或订单数据 | | 收集的数据类型 | 默认配置下**无**。主题不读取、不上传用户信息、站点内容、访问日志或订单数据 |
| 数据用途 | 仅用于页面渲染;自定义 API 场景下由管理员自行决定 | | 数据用途 | 仅用于页面渲染;自定义 API 场景下由管理员自行决定 |
| 保存位置与期限 | 主题不写入数据库、不上传数据、不生成服务端日志。前端仅在访客本机使用 `sessionStorage`(欢迎弹窗「同会话仅显示一次」)与 `localStorage`(点赞状态),可随时清除浏览器站点数据 | | 保存位置与期限 | 主题不写入数据库、不上传数据、不生成服务端日志。前端仅在访客本机使用 `sessionStorage`(欢迎弹窗「同会话仅显示一次」)与 `localStorage`(点赞状态),可随时清除浏览器站点数据 |
| 是否传输到第三方 | 默认**否**。仅当管理员显式填写「自定义一言 API」或「自定义 IP 定位 API」时才会向该服务发起请求,其中 IP 定位会暴露访客 IP | | 是否传输到第三方 | 默认**否**(描述文案来源为「无」、欢迎弹窗关闭、两个 API 字段留空)。仅当管理员显式填写「自定义一言 API」或「自定义 IP 定位 API」时才会向该服务发起请求,其中 IP 定位会暴露访客 IP |
| 关闭 / 撤销方式 | 见上表;清空相应设置项或关闭对应开关即可立即停止,无需重装主题 | | 关闭 / 撤销方式 | 见上表;清空相应设置项或关闭对应开关即可立即停止,无需重装主题 |
| 遥测 / 统计 / 崩溃上报 / 广告 / AI 分析 / 跨站追踪 | **均不存在**。主题不含任何埋点、指纹或第三方统计脚本 | | 遥测 / 统计 / 崩溃上报 / 广告 / AI 分析 / 跨站追踪 | **均不存在**。主题不含任何埋点、指纹或第三方统计脚本 |
@@ -63,10 +78,29 @@
- **已移除的远程依赖**:`cdn.jsdelivr.net` 预连接与预解析(原出现在全部模板)、PrismJS 1.29.0 核心与 `autoloader`(会按代码语言从 CDN 动态拉取并执行组件)、`qrcode-generator`、默认的一言与 IP 定位接口。 - **已移除的远程依赖**:`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`),由「文章 → 代码高亮主题」切换。 - **代码高亮**:主题不再自带 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` 中,无远程字体请求。 - **字体**:[霞鹜文楷 LXGW WenKai](https://github.com/lxgw/LxgwWenKai) 以 [SIL OFL 1.1](https://openfontlicense.org/) 发布,由 `cn-font-split` 切分为本地 woff2 分片随主题分发;字体来源与版权声明保留在 `public/assets/fonts/LXGWWenKai/result.css`,上游完整的版权声明、保留字体名条款与 **OFL 1.1 许可证全文**随包存放于 `public/assets/fonts/LXGWWenKai/OFL.txt`(构建后为 `templates/assets/fonts/LXGWWenKai/OFL.txt`)。上游的附加许可明确允许把字体切分 / 转换为 WOFF2 仅用于网页字体交付,本主题的使用方式与之相符;无远程字体请求。
- **灯箱**:LightGallery 2.9.0 以 GPLv3 发布,本主题同样采用 GPL-3.0,按开源许可条款使用(非商业授权);已去除占位 `licenseKey`,控制台不再出现 `license key is not valid for production use`。 - **灯箱**:LightGallery 2.9.0 以 GPLv3 发布,本主题同样采用 GPL-3.0,按开源许可条款使用(非商业授权);已去除占位 `licenseKey`,控制台不再出现 `license key is not valid for production use`。
- **图标**:来源与协议见[图标与字体来源](#图标与字体来源)。 - **图标**:来源与协议见[图标与字体来源](#图标与字体来源)。
## 内容渲染安全(4.4.2)
主题对「站点内容 / 用户生成内容」与「管理员显式配置」采取不同的渲染策略,避免把不可信字符串直接拼进可执行上下文:
| 场景 | 处理方式 |
| --- | --- |
| 文章标题、摘要、作者、封面(结构化数据) | 不再用 `th:utext` 把变量拼进 `application/ld+json` 脚本文本。字段先经 Thymeleaf 转义写入 `<meta>`,前端读取后用 `JSON.stringify` + `textContent` 生成 JSON-LD,内容里的尖括号不会被解析为标签 |
| 瞬间正文 | 插件已渲染的 `content.html` 用 `th:utext` 原样呈现;`html` 缺失时的 `content.raw` 兜底改用 `th:text` 转义输出,不再当作 HTML 执行 |
| 文章正文、单页正文、评论 | 使用 Halo 核心 / 评论插件输出的已处理 HTML,主题不二次拼接脚本 |
| 常规模板变量 | 统一用 `th:text` / `th:attr`(转义输出);`th:utext` 仅用于上述受控 HTML 与管理员自定义内容 |
| 链接与图片 | `target="_blank"` 一律带 `rel="noopener noreferrer"`;所有 `<img>` 提供 `alt` / `th:alt` |
以下三类内容由**管理员在后台显式填写**,属于主题的设计能力,写入权限由 Halo 后台角色控制,不属于访客输入:
- `style.accent_color` 与 6 个 Hero 配色项:`settings.yaml` 中声明为 `$formkit: color`,由颜色选择器约束为 `#RRGGBB`,仅用于生成内联 CSS 变量;
- `style.custom_css`(自定义 CSS)、`footer.footer_custom_html`(页脚自定义 HTML,如统计代码):按设计意图原样输出,用途已在设置项说明中标注。若站点允许多名作者或开放后台编辑权限,请自行评估这些字段的信任边界。
字符串截取前先判空(`#strings.substring('', 0, 1)` 会抛越界异常)已在全部 12 处友链 / 留言 / 订阅 / 插件页面兜底,避免空值导致页面 500;控制台无报错与键盘可达性、深浅色对比度一起列入下方人工回归清单。
## 已适配插件 ## 已适配插件
| 插件名称 | 插件标识 | 适配页面路由 | 说明 | | 插件名称 | 插件标识 | 适配页面路由 | 说明 |
@@ -99,7 +133,7 @@
### 首页 ### 首页
- Hero 首屏:明暗双背景图、大标题/副标题独立配色、一言(默认使用内置本地句子库,可选自定义 API 与自动刷新)、下滑箭头平滑滚动 - Hero 首屏:明暗双背景图、大标题/副标题独立配色、描述文案(默认「无」;选「一言」自动填入 `https://v1.hitokoto.cn/`,清空则使用内置本地句子库;可选自定义文案与自动刷新)、下滑箭头平滑滚动
- 欢迎弹窗:默认显示纯本地静态欢迎语,支持 5 种位置、会话去重开关;仅在显式配置自定义 IP 定位 API 时才显示访客所在地(见[外部资源与隐私](#外部资源与隐私)) - 欢迎弹窗:默认显示纯本地静态欢迎语,支持 5 种位置、会话去重开关;仅在显式配置自定义 IP 定位 API 时才显示访客所在地(见[外部资源与隐私](#外部资源与隐私))
- 滚动吸附:首屏与内容区之间滚轮/触摸自动吸附,进入内容区后恢复原生自由滚动 - 滚动吸附:首屏与内容区之间滚轮/触摸自动吸附,进入内容区后恢复原生自由滚动
- 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数 - 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数
@@ -113,7 +147,7 @@
### 全局 ### 全局
- 点击特效:核心价值观 / 爱心 / 烟花 / 粒子等 9 种可选 - 点击特效:核心价值观 / 爱心 / 烟花 / 粒子等 8 种效果可选,也可关闭
- 页脚全站统计:文章、分类、评论、访问、点赞、总字数,支持开关与统计项选择(默认开启) - 页脚全站统计:文章、分类、评论、访问、点赞、总字数,支持开关与统计项选择(默认开启)
- 动效:呼吸动画、滚动渐入,可独立关闭 - 动效:呼吸动画、滚动渐入,可独立关闭
- 备案信息:ICP 与公网安备号,公网安备自动带徽标 - 备案信息:ICP 与公网安备号,公网安备自动带徽标
@@ -187,7 +221,7 @@ pnpm audit:external
# 打包 dist/warm-island-<version>.zip # 打包 dist/warm-island-<version>.zip
pnpm package pnpm package
# 点击特效脚本冒烟测试 # 点击特效脚本 + 内容渲染安全(JSON-LD)冒烟测试
pnpm smoke pnpm smoke
``` ```
@@ -206,11 +240,11 @@ pnpm smoke
│ └── styles/ # SCSS 样式(正文样式在 _content.scss,全局生效) │ └── styles/ # SCSS 样式(正文样式在 _content.scss,全局生效)
├── public/ ├── public/
│ ├── assets/data/ # 内置本地一言句子库(静态 JSON) │ ├── assets/data/ # 内置本地一言句子库(静态 JSON)
│ ├── assets/fonts/ # LXGW WenKai 本地字体分片 │ ├── assets/fonts/ # LXGW WenKai 本地字体分片 + OFL.txt 许可证全文
│ ├── assets/vendor/ # 自托管的代码高亮配色 CSS │ ├── assets/vendor/ # 自托管的代码高亮配色 CSS
│ └── assets/logo.png # 主题图标(唯一一份,256×256 压缩版) │ └── assets/logo.png # 主题图标(唯一一份,256×256 压缩版)
├── compat/assets/ # 历史版本哈希静态资源,构建时合并进 templates/assets ├── compat/assets/ # 历史版本哈希静态资源,构建时合并进 templates/assets
├── scripts/ # 构建后置、资源快照、打包、外部资源审计、冒烟测试 ├── scripts/ # 构建后置、资源快照、打包、外部资源与合规审计、冒烟测试
├── templates/ # 构建产物,Halo 实际加载的模板 ├── templates/ # 构建产物,Halo 实际加载的模板
├── settings.yaml # 后台设置项定义 ├── settings.yaml # 后台设置项定义
└── theme.yaml # 主题清单与自定义页面模板注册 └── theme.yaml # 主题清单与自定义页面模板注册
@@ -221,6 +255,8 @@ pnpm smoke
- **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500, - **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500,
所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`) 所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`)
- **不得引入远程脚本 / 样式 / 字体 / 预连接**:所有第三方资源必须自托管并标注许可,`pnpm package` 会拦截回归 - **不得引入远程脚本 / 样式 / 字体 / 预连接**:所有第三方资源必须自托管并标注许可,`pnpm package` 会拦截回归
- **不得把站点内容用 `th:utext` 拼进 `<script>`**:`<script>` 是原始文本元素,HTML 转义不生效且脚本闭合标签可提前结束元素;动态数据走「转义输出到 `<meta>` → `JSON.stringify` → `textContent`」,或未净化文本一律用 `th:text`
- **第三方字体必须随包携带许可证全文**:修改 / 切分字体时,`public/assets/fonts/LXGWWenKai/OFL.txt` 与 `result.css` 中的版权声明都要保留,`pnpm package` 会校验
- **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号/空格/emoji 会导致页面 500 - **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号/空格/emoji 会导致页面 500
- **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝 - **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝
- **凡是与后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效 - **凡是与后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效
@@ -235,18 +271,20 @@ pnpm smoke
自动化覆盖(每次发版执行): 自动化覆盖(每次发版执行):
- `pnpm build` 生成全部 21 个模板,构建后置脚本校验所有静态资源引用真实存在 - `pnpm build` 生成全部 21 个模板,构建后置脚本校验所有静态资源引用真实存在
- `pnpm audit:external` / `pnpm package` 审计第三方远程加载、预连接、动态加载、README 制品链接、Logo 去重 - `pnpm audit:external` / `pnpm package` 审计第三方远程加载、预连接、动态加载、README 制品链接、Logo 去重,并校验字体许可证全文随包(5.2)、JSON-LD 不拼接站点内容(4.4.2)、README 设置项数字与 `settings.yaml` 一致(2.2)
- `pnpm smoke` 校验点击特效脚本;安装包内容校验模板数量、`theme.yaml` 版本与元数据一致性 - `pnpm smoke` 校验点击特效脚本,并用桩 DOM 验证文章 JSON-LD 合法性与脚本注入防护(4.4.2);安装包内容校验模板数量、`theme.yaml` 版本与元数据一致性
上架前建议在真实 Halo ≥ 2.27 实例上做一次人工回归(主题声明为 AI 辅助开发,此项由站点管理员完成并留存结果): 上架前建议在真实 Halo ≥ 2.27 实例上做一次人工回归(主题声明为 AI 辅助开发,此项由站点管理员完成并留存结果):
| 页面 | 检查要点 | | 页面 | 检查要点 |
| --- | --- | | --- | --- |
| 首页 | Hero、内置一言、滚动吸附、文章卡片、欢迎弹窗(默认本地文案) | | 首页 | Hero(描述文案默认「无」)、切换「一言」后的自动填充与清空回退、滚动吸附、文章卡片、欢迎弹窗(默认本地文案) |
| 文章 | 正文 / 代码块配色、目录、分享按钮与微信复制链接、灯箱、点赞 | | 文章 | 正文 / 代码块配色、目录、分享按钮与微信复制链接、灯箱、点赞 |
| 分类 / 标签 / 归档 | 列表、分页、空状态、暗色模式 | | 分类 / 标签 / 归档 | 列表、分页、空状态、暗色模式 |
| 搜索 / 错误页 | 搜索页与空结果状态、404 / 500 兜底页、暗色模式(4.4.1) |
| 内容与表单 | 标题 / 瞬间正文含尖括号时不执行脚本、JSON-LD 合法、评论与表单校验(4.4.2) |
| 友链 / 留言板 / 订阅 | 空字段兜底(作者为空不报错)、表单与分页 | | 友链 / 留言板 / 订阅 | 空字段兜底(作者为空不报错)、表单与分页 |
| 全局 | 导航栏、页脚、移动端抽屉、深浅色切换、控制台无报错 / 无外部请求 | | 全局 | 导航栏、页脚、移动端抽屉、深浅色切换、键盘可达主要导航与搜索、正文对比度、控制台无报错 / 无外部请求(4.4.3 / 4.4.4) |
## 技术栈 ## 技术栈
@@ -259,6 +297,17 @@ 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)
## 原创声明(审核指南 4.2.1)
WarmIsland 暖屿为**完全原创**主题:视觉设计、页面结构、Astro 构建链路、Thymeleaf 模板与样式代码均由开发者独立编写,
**未基于任何已有 Halo 主题或插件进行 Fork、复制代码或二次开发**,不存在上游应用、上游仓库、派生版本或接续维护关系。
因此审核指南 **4.2.1(Fork 与派生应用)与 4.2.2(停止维护应用的接续维护)不适用于本主题**,无需提供上游贡献记录、上游差异说明或停止维护证明。
主题使用 AI 工具辅助编码与文档编写,全部产出已由开发者完成代码审查、功能验证、安全自查与 Halo 开发生态适配
(见[验证与回归测试](#验证与回归测试))。第三方开源组件(字体、图标、灯箱)均自托管并保留许可证与署名,
来源见[图标与字体来源](#图标与字体来源)与[自托管与许可](#自托管与许可);主题本体以 GPL-3.0 发布,
源码仓库为 https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland 。
## 图标与字体来源 ## 图标与字体来源
本主题使用的图标与字体来自以下开源项目,均随主题自托管分发、不使用远程 CDN: 本主题使用的图标与字体来自以下开源项目,均随主题自托管分发、不使用远程 CDN:
+32 -2
View File
@@ -1,8 +1,25 @@
# 更新日志 # 更新日志
## v1.2.5 ## v1.2.6
### 上架审核整改(v1.2.5 修订) > v1.2.5 为审核被拒的未发布版本,从未对外分发;其全部整改内容纳入 v1.2.6 一并发布。
> 上一个对外发布版本为 v1.1.1(v1.1.2 – v1.2.5 均未发布)。
### 本轮变更
- 🆙 版本号升至 `1.2.6`,与上次被拒的审核快照明确区分
- ✍️ 新增「原创声明(4.2.1)」:本主题完全原创,未基于任何已有主题 / 插件派生,4.2.1 / 4.2.2 不适用
- 🔇 Hero「描述文案来源」默认值由 `hitokoto` 改为 **`none`** —— 默认不渲染一言元素、不显示描述文案,零外部请求
- 🔗 选择「一言」时自动填入官方接口 `https://v1.hitokoto.cn/`;选择「一言」或「自定义」后若 API 留空,
则使用主题内置的本地句子库(零外部请求)。**只有填写后才会向该地址发起请求**,需自行在站点隐私说明中披露,
详见 README「外部资源与隐私」
- 🌐 文档列出两个可选 IP 定位接口:`https://myip.ipip.net/json` 与
`https://r.inews.qq.com/api/ip2city?otype=json`(主 / 备选 API 均可选用)。
**填写后会把访客 IP 发送给该第三方服务换取地理位置**,需自行在站点隐私说明中披露,
详见 README「外部资源与隐私」
- 📄 README「外部资源与隐私」新增可选接口清单与逐项披露;docs 的 Hero 配置、FAQ、快速开始与首页页同步更新
### 上架审核整改(原 v1.2.5 修订,并入 1.2.6)
- 🔒 **默认零第三方自动请求**:移除全部 21 个模板对 `cdn.jsdelivr.net` 的 `preconnect` / `dns-prefetch` - 🔒 **默认零第三方自动请求**:移除全部 21 个模板对 `cdn.jsdelivr.net` 的 `preconnect` / `dns-prefetch`
- 🔒 移除文章页 PrismJS 1.29.0、`autoloader`(会按代码语言从 CDN 动态拉取并执行组件)与 - 🔒 移除文章页 PrismJS 1.29.0、`autoloader`(会按代码语言从 CDN 动态拉取并执行组件)与
@@ -20,6 +37,19 @@
- 🧪 新增 `pnpm audit:external` 与打包前置校验:拦截第三方远程加载、预连接、动态加载、 - 🧪 新增 `pnpm audit:external` 与打包前置校验:拦截第三方远程加载、预连接、动态加载、
README 死链、重复大图;`docs/*.md` 与预览图一并打进安装包 README 死链、重复大图;`docs/*.md` 与预览图一并打进安装包
#### 按 v2026.09.07 审核指南全文补充自查(并入 1.2.6)
- 🔒 **内容渲染安全(4.4.2)**:文章页 JSON-LD 不再用 `th:utext` 把标题 / 摘要 / 作者 / 封面
拼进 `<script>` 原始文本(可被脚本闭合标签截断并注入);改为「Thymeleaf 转义写入 `<meta>`
→ `JSON.stringify` → `textContent`」,并新增 `<meta name="author">`。瞬间正文 `content.raw`
兜底从 `th:utext` 改为 `th:text` 转义输出(首页区块、瞬间页列表 / 瀑布流、详情页共 4 处)
- 📜 **许可证传递(5.2)**:`public/assets/fonts/LXGWWenKai/OFL.txt` 随包携带上游版权声明、
保留字体名附加许可与 SIL OFL 1.1 全文;`pnpm build` / `pnpm package` 均校验其存在与内容
- 🔢 **上架信息校准(2.2 / 4.6)**:README 设置项按 `settings.yaml` 实测改为 17 组 / 126 项、
点击特效改为 8 种效果(可关闭);新增「内容渲染安全」章节与搜索页 / 空结果状态人工回归项
- 🧪 合规审计扩展:拦截任意 `<script>` 上的 `th:utext`、缺失字体许可证、README 数字漂移;
`pnpm smoke` 新增 JSON-LD 合法性与注入防护的桩 DOM 验证
### 兼容性与元数据 ### 兼容性与元数据
- ⬆️ Halo 版本要求提升至 **2.27.0+**(页面布局契约、微信读书页面依赖 2.27 的能力) - ⬆️ Halo 版本要求提升至 **2.27.0+**(页面布局契约、微信读书页面依赖 2.27 的能力)
+16 -13
View File
@@ -9,8 +9,8 @@ Hero 首屏是 WarmIsland 主题的标志性区域,占据首页首屏的全部
| `hero_enabled` | 开关 | `true` | 启用 Hero 模块 | | `hero_enabled` | 开关 | `true` | 启用 Hero 模块 |
| `hero_title` | 文本 | — | 主标题(留空则使用站点标题) | | `hero_title` | 文本 | — | 主标题(留空则使用站点标题) |
| `hero_subtitle` | 文本 | — | 副标题(留空则使用站点副标题) | | `hero_subtitle` | 文本 | — | 副标题(留空则使用站点副标题) |
| `hero_description_mode` | 下拉 | `hitokoto` | 描述文案来源:`none` / `hitokoto` / `custom` | | `hero_description_mode` | 下拉 | `none` | 描述文案来源:`none` / `hitokoto` / `custom`,默认不显示描述文案 |
| `hero_hitokoto_api` | URL | 留空 | 自定义一言 API;留空使用主题内置本地句子库(零外部请求) | | `hero_hitokoto_api` | URL | `https://v1.hitokoto.cn/` | 选「一言」时自动填入官方接口;留空则使用内置本地句子库(零外部请求) |
| `hero_hitokoto_categories` | 多选 | `d, i, k` | 句子类型筛选,仅对自定义 API 生效 | | `hero_hitokoto_categories` | 多选 | `d, i, k` | 句子类型筛选,仅对自定义 API 生效 |
| `hero_custom_description` | 多行文本 | — | 自定义描述文案 | | `hero_custom_description` | 多行文本 | — | 自定义描述文案 |
| `hero_hitokoto_auto_refresh` | 开关 | `false` | 一言自动刷新 | | `hero_hitokoto_auto_refresh` | 开关 | `false` | 一言自动刷新 |
@@ -44,28 +44,31 @@ hero_subtitle: "记录温暖与灵感"
### 描述文案来源(hero_description_mode) ### 描述文案来源(hero_description_mode)
控制 Hero 区域主标题下方显示的描述文案,提供三种模式: 控制 Hero 区域主标题下方显示的描述文案,提供三种模式,**默认为「无」**:
| 值 | 说明 | | 值 | 说明 |
| --- | --- | | --- | --- |
| `none` | 不显示描述文案 | | `none` | **默认**。不显示描述文案,不渲染一言元素,也不发起任何请求 |
| `hitokoto` | 从主题内置本地句子库随机取一条(默认,零外部请求) | | `hitokoto` | 显示一句话文案;自动填入官方接口 `https://v1.hitokoto.cn/`,清空该字段则使用内置本地句子库 |
| `custom` | 显示自定义的描述文案 | | `custom` | 显示自定义的描述文案(静态文本,不发起任何请求) |
### 一言模式(hitokoto) ### 一言模式(hitokoto)
选择 `hitokoto` 模式后,Hero 区域**默认读取主题内置的本地句子库** `assets/data/hitokoto.json` 默认 `none` 时一言元素不渲染。切换到 `hitokoto` 后,设置项「自定义一言 API」自动填入
(随主题自托管的静态 JSON),每次刷新随机展示一条,不依赖任何外部服务,也不会因为第三方接口 `https://v1.hitokoto.cn/`,浏览器会向该地址发起 GET 请求获取句子(第三方可见访客 IP / User-Agent)。
不可用而出现空白。可直接编辑该 JSON 替换成自己喜欢的句子。 若把该字段清空,则回退到主题内置的本地句子库 `assets/data/hitokoto.json`
(随主题自托管的静态 JSON,零外部请求),每次刷新随机展示一条,也不会因为第三方接口不可用而出现空白。
可直接编辑该 JSON 替换成自己喜欢的句子。
#### 自定义一言 API(hero_hitokoto_api) #### 自定义一言 API(hero_hitokoto_api)
默认**留空**(零外部请求)。如果需要使用在线一言或自建实例,可在此填写 API 地址; 选择「一言」或「自定义」后才会显示该设置项。选择「一言」时自动填入 `https://v1.hitokoto.cn/`;
填写后浏览器才会向该地址发起 GET 请求,第三方可见访客 IP / User-Agent。 **选择「一言」或「自定义」后若留空,则使用主题内置的本地句子库(零外部请求)**。
请自行评估服务商隐私政策,并在站点隐私说明中披露,详见 README「外部资源与隐私」。 **只有填写后才会向该地址发起请求**,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」。
也可换成自建实例或其他兼容接口。
::: warning ::: warning
这是站点管理员主动开启的可选能力。留空即可随时关闭,无需重装主题。 这是站点管理员主动开启的可选能力。把描述文案来源改回「无」、或清空该字段即可随时停止外部请求,无需重装主题。
::: :::
#### 一言句子类型(hero_hitokoto_categories) #### 一言句子类型(hero_hitokoto_categories)
+19 -7
View File
@@ -123,21 +123,33 @@ v1.2.1 起安装包会保留各版本的哈希资源(v1.1.7 之后全部覆盖
### 主题会向第三方服务发请求吗? ### 主题会向第三方服务发请求吗?
**默认不会。** 所有脚本、样式、字体、图标、主题 Logo 与一言句子库都随主题自托管, **默认不会。** 所有脚本、样式、字体、图标、主题 Logo 与一言句子库都随主题自托管,
页面不预连接任何 CDN,也不加载远程脚本。只有两类例外: 页面不预连接任何 CDN,也不加载远程脚本;Hero「描述文案来源」默认为「无」,欢迎弹窗默认关闭。
只有两类例外:
1. 访客**主动点击**分享按钮 / 备案链接 / 应用市场链接时才跳转到对应站点; 1. 访客**主动点击**分享按钮 / 备案链接 / 应用市场链接时才跳转到对应站点;
2. 站点管理员在主题设置里**显式填写**了「自定义一言 API」或「自定义 IP 定位 API」—— 2. 站点管理员在主题设置里**显式填写**了「自定义一言 API」或「自定义 IP 定位 API」——
后者会把访客 IP 发送给该服务,请在站点隐私说明中披露。 后者会把访客 IP 发送给该第三方服务换取地理位置,**需自行在站点隐私说明中披露**,
详见 README「外部资源与隐私」。
可选接口地址(仅作候选,主题不会自动请求):
| 能力 | 可选地址 |
| --- | --- |
| 一言 | `https://v1.hitokoto.cn/`(选择「一言」时自动填入) |
| IP 定位主 / 备选 API | `https://myip.ipip.net/json`、`https://r.inews.qq.com/api/ip2city?otype=json` |
完整逐项说明见 README 的「外部资源与隐私」章节。发版前可运行 `pnpm audit:external` 复核。 完整逐项说明见 README 的「外部资源与隐私」章节。发版前可运行 `pnpm audit:external` 复核。
### 一言不显示,或想换成自己的句子? ### 一言不显示,或想换成自己的句子?
1. 主题默认读取内置的 `assets/data/hitokoto.json` 本地句子库,不依赖外部服务; 1. Hero「描述文案来源」**默认为「无」**,此时不显示描述文案、不渲染一言元素,零外部请求;
直接编辑该文件即可替换文案 想显示句子请改为「一言」
2. 想继续使用在线一言,可在主题设置 → Hero 首屏 → 填写「自定义一言 API」; 2. 选择「一言」后会自动填入 `https://v1.hitokoto.cn/`,此时才会产生外部请求;
此时才会产生外部请求,且「句子类型」筛选仅对自定义 API 生效 **把该字段清空即回退到主题内置的 `assets/data/hitokoto.json` 本地句子库(零外部请求)**,
3. 若选择「自定义」模式,请在「自定义描述文案」中填写内容 直接编辑该 JSON 即可替换文案。只有填写后才会向该地址发起请求,需自行在站点隐私说明中披露,
详见 README「外部资源与隐私」
3. 「自定义」模式显示「自定义描述文案」中的静态文本,不发起任何请求;
「句子类型」筛选仅对填写了 API 的一言模式生效
### 文章目录不显示? ### 文章目录不显示?
+1 -1
View File
@@ -64,7 +64,7 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
进入 **Hero 首屏** 设置: 进入 **Hero 首屏** 设置:
- 设置主标题和副标题 - 设置主标题和副标题
- 选择描述文案来源(一言 / 自定义 / 无);「一言」默认读取主题内置的本地句子库,不请求外部服务 - 选择描述文案来源(默认「无」/ 一言 / 自定义);默认不显示描述、不发起请求;选「一言」自动填入 `https://v1.hitokoto.cn/`,清空该字段则使用内置本地句子库(零外部请求)
- 可选上传背景图片 - 可选上传背景图片
### 第五步:页脚信息 ### 第五步:页脚信息
+1 -1
View File
@@ -16,7 +16,7 @@ Hero 区域占据整个视口高度,包含:
- **主标题**:默认使用站点标题,可在主题设置中自定义 - **主标题**:默认使用站点标题,可在主题设置中自定义
- **副标题**:默认使用站点副标题 - **副标题**:默认使用站点副标题
- **描述文案**:支持一言 API、自定义文案或关闭 - **描述文案**:默认「无」(不显示、零请求);可选「一言」(自动填入 `https://v1.hitokoto.cn/`,清空则用内置本地句子库)或「自定义」静态文案
- **视觉效果**:渐变背景 + 呼吸光球 + 滚动模糊 - **视觉效果**:渐变背景 + 呼吸光球 + 滚动模糊
页面滚动时,Hero 区域会产生模糊效果,内容向上淡出,底部有向下箭头提示滚动。 页面滚动时,Hero 区域会产生模糊效果,内容向上淡出,底部有向下箭头提示滚动。
+2 -2
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.2.5", "version": "1.2.6",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@@ -11,7 +11,7 @@
"package": "node scripts/package.mjs", "package": "node scripts/package.mjs",
"audit:external": "node scripts/audit-external.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 && node scripts/smoke-jsonld.mjs",
"astro": "astro", "astro": "astro",
"format": "prettier --write ." "format": "prettier --write ."
}, },
+94
View File
@@ -0,0 +1,94 @@
Copyright 2021-2026 LXGW (https://github.com/lxgw/LxgwWenKai), with Reserved Font Name '霞鹜', '霞鶩', '落霞孤鹜', '落霞孤鶩' and 'LXGW'. [ADDITIONAL PERMISSION] The Reserved Font Names '霞鹜', '霞鶩', '落霞孤鹜', '落霞孤鶩' and 'LXGW' may continue to be used in Modified Versions recompiled from the Original Version, without modifications to the font source code; or in Modified Versions subsetted or converted to other formats (e.g., WOFF/WOFF2) solely for web font delivery, provided such Modified Versions are not made available as installable desktop fonts (e.g., on mainstream platforms like Google Fonts, or third-party non-commercial platforms recognized by the author @lxgw; other web font platforms please contact the author @lxgw for confirmation).
Copyright 2020 The Klee Project Authors (https://github.com/fontworks-fonts/Klee)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+67 -1
View File
@@ -4,6 +4,8 @@
* 对应 Halo 应用市场审核指南: * 对应 Halo 应用市场审核指南:
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件 * - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源 * - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
* - 4.4.2 内容渲染安全:不得把站点内容用 th:utext 拼进 <script> 原始文本
* - 5.2 / 2.2:第三方字体必须随包携带许可证全文;上架说明数字必须与配置定义一致
* - 4.6 / 2.2:不得出现占位内容、无效链接 * - 4.6 / 2.2:不得出现占位内容、无效链接
* *
* 检查项: * 检查项:
@@ -12,6 +14,8 @@
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...) * 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹 * 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
* 5. README 中的相对链接必须在安装包清单中真实存在 * 5. README 中的相对链接必须在安装包清单中真实存在
* 6. 任何 <script> 开标签不得带 th:utext;字体许可证全文必须在制品清单中;
* README 的设置分组 / 配置项数量必须与 settings.yaml 一致
* *
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的, * 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。 * 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
@@ -134,6 +138,63 @@ export function auditReadmeLinks(entryNames, readmePath = path.join(root, "READM
return { problems, checked }; return { problems, checked };
} }
/**
* 合规审计:审核指南 4.4.2(内容渲染安全)、5.2(许可证传递)、2.2(上架信息准确)
*
* @param {Set<string>|null} entryNames 打包清单;传入时额外校验许可证确实进了制品
*/
export function auditCompliance(entryNames = null) {
const problems = [];
const templatesDir = path.join(root, "templates");
const oflRel = "templates/assets/fonts/LXGWWenKai/OFL.txt";
// 1. <script> 是原始文本元素,th:utext 不转义且可被脚本闭合标签截断(4.4.2)
const scriptTagRe = /<script\b[^>]*>/gi;
for (const file of listFiles(templatesDir, [".html"])) {
const rel = path.relative(root, file).replace(/\\/g, "/");
const text = fs.readFileSync(file, "utf8");
scriptTagRe.lastIndex = 0;
let hit;
while ((hit = scriptTagRe.exec(text)) !== null) {
if (/\bth:utext\b/i.test(hit[0])) {
problems.push(`${rel}: <script> 开标签使用了 th:utext(4.4.2 内容注入风险)`);
}
}
}
// 2. SIL OFL 1.1 要求随字体软件附带版权声明与许可证全文(5.2)
const oflPath = path.join(root, oflRel);
if (entryNames && !entryNames.has(oflRel)) {
problems.push(`安装包缺少 ${oflRel}(5.2 字体许可证传递)`);
}
if (!fs.existsSync(oflPath)) {
problems.push(`构建产物缺少 ${oflRel}(5.2 字体许可证传递)`);
} else {
const ofl = fs.readFileSync(oflPath, "utf8");
if (!/SIL OPEN FONT LICENSE Version 1\.1/.test(ofl) || !/Copyright 2021-2026 LXGW/.test(ofl)) {
problems.push(`${oflRel} 内容不完整:需包含上游版权声明与 OFL 1.1 全文`);
}
}
// 3. 上架说明里的配置数量必须与 settings.yaml 一致(2.2 / 4.6)
const settingsPath = path.join(root, "settings.yaml");
const readmePath = path.join(root, "README.md");
let groupCount = 0;
let fieldCount = 0;
if (fs.existsSync(settingsPath) && fs.existsSync(readmePath)) {
const settings = fs.readFileSync(settingsPath, "utf8");
const readme = fs.readFileSync(readmePath, "utf8");
groupCount = (settings.match(/^\s*-\s+group:/gm) || []).length;
fieldCount = (settings.match(/^\s*-\s+\$formkit:/gm) || []).length;
const expected = `${groupCount} 个设置分组、${fieldCount} 个配置项`;
if (!readme.includes(expected)) {
problems.push(`README 配置数量与 settings.yaml 不一致,应为「${expected}」(2.2)`);
}
}
return { problems, groupCount, fieldCount };
}
function report(label, problems) { function report(label, problems) {
if (problems.length === 0) { if (problems.length === 0) {
console.log(`[audit] PASS ${label}`); console.log(`[audit] PASS ${label}`);
@@ -146,11 +207,16 @@ function report(label, problems) {
const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; const isCli = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isCli) { if (isCli) {
const templates = auditTemplates(); const templates = auditTemplates();
const compliance = auditCompliance();
let failures = 0; let failures = 0;
failures += report( failures += report(
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`, `模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
templates.problems templates.problems
); );
failures += report(
`合规审计 4.4.2 / 5.2 / 2.2(${compliance.groupCount} 组 / ${compliance.fieldCount} 配置项)`,
compliance.problems
);
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */ /* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
const distZip = path.join(root, "dist"); const distZip = path.join(root, "dist");
const zip = fs.existsSync(distZip) const zip = fs.existsSync(distZip)
@@ -162,6 +228,6 @@ if (isCli) {
} else { } else {
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)"); console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
} }
console.log(`[audit] 模板审计共 ${failures} 项问题`); console.log(`[audit] 审计共 ${failures} 项问题`);
process.exit(failures ? 1 : 0); process.exit(failures ? 1 : 0);
} }
+13 -2
View File
@@ -10,7 +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"; import { auditTemplates, auditReadmeLinks, auditCompliance } from "./audit-external.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
@@ -235,6 +235,16 @@ if (linkAudit.problems.length) {
fail("README 存在指向制品中不存在文件的相对链接"); fail("README 存在指向制品中不存在文件的相对链接");
} }
/* 4.4.2 / 5.2 / 2.2:内容渲染安全、字体许可证传递、上架说明数字一致性 */
const entryNameSet = new Set(entries.map((entry) => entry.name));
const complianceAudit = auditCompliance(entryNameSet);
for (const problem of complianceAudit.problems) {
console.error(`[package] ${problem}`);
}
if (complianceAudit.problems.length) {
fail("合规审计未通过(4.4.2 内容渲染安全 / 5.2 许可证传递 / 2.2 上架信息)");
}
if (fs.existsSync(path.join(templatesDir, "logo.png"))) { if (fs.existsSync(path.join(templatesDir, "logo.png"))) {
fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png"); fail("templates/logo.png 仍存在:应只保留压缩后的 templates/assets/logo.png");
} }
@@ -243,7 +253,8 @@ if (fs.existsSync(logoFile) && fs.statSync(logoFile).size > 300 * 1024) {
fail("templates/assets/logo.png 超过 300KB,请先压缩再打包"); fail("templates/assets/logo.png 超过 300KB,请先压缩再打包");
} }
console.log( console.log(
`[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、logo 单份压缩)` `[package] 合规审计通过(${templateAudit.htmlCount} 个模板、${linkAudit.checked} 个相对链接、` +
`${complianceAudit.groupCount} 组 / ${complianceAudit.fieldCount} 配置项、logo 单份压缩、OFL 全文随包)`
); );
const outFile = path.join(root, "dist", `warm-island-${version}.zip`); const outFile = path.join(root, "dist", `warm-island-${version}.zip`);
+11
View File
@@ -24,6 +24,17 @@ if (!fs.existsSync(path.join(assetsDir, "logo.png"))) {
} }
console.log("[postbuild] logo.png 已去重,仅保留 templates/assets/logo.png"); console.log("[postbuild] logo.png 已去重,仅保留 templates/assets/logo.png");
// 1b. 第三方字体许可证全文必须随构建产物分发(审核指南 5.2:许可证传递 / 版权声明保留)
const oflFile = path.join(assetsDir, "fonts", "LXGWWenKai", "OFL.txt");
if (!fs.existsSync(oflFile)) {
console.error(
"[postbuild] 未找到 templates/assets/fonts/LXGWWenKai/OFL.txt" +
"(public/assets/fonts/LXGWWenKai/OFL.txt 应随构建复制)"
);
process.exit(1);
}
console.log("[postbuild] LXGW WenKai 的 OFL 1.1 许可证全文已随包");
// 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖) // 2. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
if (fs.existsSync(compatDir)) { if (fs.existsSync(compatDir)) {
let copied = 0; let copied = 0;
+120
View File
@@ -0,0 +1,120 @@
/**
* 结构化数据 / 内容渲染安全冒烟测试(审核指南 4.4.2)
*
* 从构建产物 templates/post.html 提取 JSON-LD 生成脚本,用桩 DOM 执行,验证:
* - 输出的 JSON-LD 合法、字段完整、可选字段缺省时不产生非法 JSON
* - 标题里注入的脚本闭合标签只作为普通字符串保留,不会变成可执行内容
* - 写入方式为 textContent,且模板中的 JSON-LD 标签初始为空
*
* 仅用 Node 内置模块,无浏览器 / 第三方依赖;需先执行 pnpm build。
*/
import fs from "node:fs";
import path from "node:path";
import vm from "node:vm";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const postHtmlPath = path.join(root, "templates", "post.html");
if (!fs.existsSync(postHtmlPath)) {
console.error("[jsonld-smoke] 未找到 templates/post.html,请先执行 pnpm build");
process.exit(1);
}
const html = fs.readFileSync(postHtmlPath, "utf8");
const blocks = [...html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/gi)].map((m) => m[1]);
const code = blocks.find((block) => block.includes("wi-article-jsonld"));
if (!code) {
console.error("[jsonld-smoke] 未找到 JSON-LD 生成脚本");
process.exit(1);
}
let pass = 0;
let fail = 0;
function check(name, fn) {
try {
fn();
console.log(` ✔ ${name}`);
pass++;
} catch (error) {
console.error(` ✘ ${name} -> ${error.message}`);
fail++;
}
}
function render(values) {
const target = { textContent: "" };
const document = {
querySelector: (selector) =>
values[selector] == null ? null : { getAttribute: () => values[selector] },
getElementById: (id) => (id === "wi-article-jsonld" ? target : null),
title: "fallback title",
};
let executed = false;
vm.runInNewContext(
code,
{ document, JSON, console, eval: () => (executed = true) },
{ timeout: 1000 }
);
return { raw: target.textContent, executed };
}
const base = {
'meta[property="og:title"]': "普通标题",
'meta[property="og:url"]': "https://example.com/archives/a",
'meta[property="article:published_time"]': "2026-11-11",
'meta[name="author"]': "暖心向阳335",
'meta[property="og:site_name"]': "WarmIsland",
'meta[property="og:image"]': "https://example.com/cover.png",
'meta[property="og:description"]': '含"引号"与\n换行的摘要',
};
check("正常文章生成合法 JSON-LD", () => {
const data = JSON.parse(render(base).raw);
if (data["@type"] !== "Article" || data.headline !== "普通标题") {
throw new Error("标题 / 类型不符");
}
if (data.author.name !== "暖心向阳335") throw new Error("作者不符");
if (!data.description.includes("\n")) throw new Error("换行丢失");
if (data.image !== base['meta[property="og:image"]']) throw new Error("封面丢失");
});
check("模板里的 JSON-LD 标签初始为空(动态内容不经 HTML 解析)", () => {
const match = html.match(/<script\b[^>]*application\/ld\+json[^>]*>([\s\S]*?)<\/script>/i);
if (!match) throw new Error("未找到 JSON-LD 标签");
if (match[1].trim() !== "") throw new Error("标签内仍有静态 / 拼接内容,可能被解析为 HTML");
});
check("脚本只用 textContent 写入,不含 innerHTML / document.write / eval", () => {
if (!code.includes(".textContent = JSON.stringify")) throw new Error("未使用 textContent 安全赋值");
if (/innerHTML|document\.write|\beval\s*\(/.test(code)) throw new Error("存在不安全写入方式");
});
check("标题含脚本闭合标签时只作为 JSON 字符串保留,不产生动态执行", () => {
const injected = "</" + "script><img src=x onerror=alert(1)>";
const result = render({ ...base, 'meta[property="og:title"]': injected });
const data = JSON.parse(result.raw);
if (data.headline !== injected || typeof data.headline !== "string") {
throw new Error("标题被篡改或不是纯字符串");
}
if (result.executed) throw new Error("发生动态执行");
});
check("缺失可选字段时不产生 undefined / 非法 JSON", () => {
const sparse = { ...base };
delete sparse['meta[property="og:image"]'];
delete sparse['meta[property="og:description"]'];
const data = JSON.parse(render(sparse).raw);
if ("image" in data || "description" in data) throw new Error("空字段未剔除");
});
check("全部 meta 缺失时仍输出合法 JSON", () => {
const data = JSON.parse(render({}).raw);
if (data["@context"] !== "https://schema.org" || data.author.name !== "") {
throw new Error("兜底失败");
}
});
console.log(`[jsonld-smoke] 内容渲染安全 ${pass} 项通过 / ${fail} 项失败`);
process.exit(fail ? 1 : 0);
+6 -5
View File
@@ -69,14 +69,14 @@ spec:
id: welcome_popup_api id: welcome_popup_api
label: 自定义 IP 定位主 API(可选) label: 自定义 IP 定位主 API(可选)
value: "" value: ""
help: 留空则只显示本地欢迎语。填写后会把访客 IP 发送给该第三方服务换取地理位置,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」 help: 留空则只显示本地欢迎语。可选接口 https://myip.ipip.net/json 或 https://r.inews.qq.com/api/ip2city?otype=json。填写后会把访客 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: "" value: ""
help: 留空不使用备选;填写后同样会向第三方发送访客 IP help: 留空不使用备选;可填 https://myip.ipip.net/json 或 https://r.inews.qq.com/api/ip2city?otype=json 作为主接口的备选。填写后同样会把访客 IP 发送给该第三方服务换取地理位置,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」
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
@@ -275,12 +275,13 @@ spec:
value: hitokoto value: hitokoto
- label: 自定义 - label: 自定义
value: custom value: custom
value: hitokoto value: none
- $formkit: url - $formkit: url
name: hero_hitokoto_api name: hero_hitokoto_api
label: 自定义一言 API(可选) label: 自定义一言 API(可选)
value: "" value: "https://v1.hitokoto.cn/"
help: 留空使用主题内置的本地句子库(零外部请求);填写后才会向该地址发起请求,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」 if: "$get(hero_description_mode).value !== 'none'"
help: 选择「一言」时自动填入官方接口 https://v1.hitokoto.cn/;选择「一言」或「自定义」后若留空,则使用主题内置的本地句子库(零外部请求)。只有填写后才会向该地址发起请求,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」
- $formkit: checkbox - $formkit: checkbox
name: hero_hitokoto_categories name: hero_hitokoto_categories
label: 一言句子类型 label: 一言句子类型
+2 -1
View File
@@ -20,7 +20,8 @@ const pageConfig = findPageBySlug("moments");
th:each="moment : ${momentsResult.items}" th:each="moment : ${momentsResult.items}"
> >
<div class="wi-moments-section__dot" th:if="${theme.config?.moments?.moments_style != 'cards'}"></div> <div class="wi-moments-section__dot" th:if="${theme.config?.moments?.moments_style != 'cards'}"></div>
<div class="wi-moments-section__content" th:utext="${moment.spec?.content?.html ?: moment.spec?.content?.raw ?: moment.spec?.content}"></div> <div class="wi-moments-section__content" th:if="${moment.spec?.content?.html}" th:utext="${moment.spec.content.html}"></div>
<div class="wi-moments-section__content" th:unless="${moment.spec?.content?.html}" th:text="${moment.spec?.content?.raw ?: ''}"></div>
<time <time
class="wi-moments-section__date" class="wi-moments-section__date"
th:text="${#dates.format(moment.spec?.releaseTime ?: moment.metadata?.creationTimestamp, 'yyyy-MM-dd HH:mm')}" th:text="${#dates.format(moment.spec?.releaseTime ?: moment.metadata?.creationTimestamp, 'yyyy-MM-dd HH:mm')}"
+2 -1
View File
@@ -59,7 +59,8 @@ import LightGallery from "../components/LightGallery.astro";
></time> ></time>
</div> </div>
<div class="wi-moment-detail__text" th:utext="${detailContent?.html ?: detailContent?.raw ?: ''}"></div> <div class="wi-moment-detail__text" th:if="${detailContent?.html}" th:utext="${detailContent.html}"></div>
<div class="wi-moment-detail__text" th:unless="${detailContent?.html}" th:text="${detailContent?.raw ?: ''}"></div>
<div <div
class="wi-moment-detail__media wi-moments-page__card-media" class="wi-moment-detail__media wi-moments-page__card-media"
+4 -2
View File
@@ -53,7 +53,8 @@ import LightGallery from "../components/LightGallery.astro";
class="wi-moments-page__name" class="wi-moments-page__name"
th:text="${moment.owner != null and !#strings.isEmpty(moment.owner.displayName) ? moment.owner.displayName : site.title}" th:text="${moment.owner != null and !#strings.isEmpty(moment.owner.displayName) ? moment.owner.displayName : site.title}"
></div> ></div>
<div class="wi-moments-page__card-text" th:utext="${moment.spec?.content?.html ?: moment.spec?.content?.raw ?: moment.spec?.content}"></div> <div class="wi-moments-page__card-text" th:if="${moment.spec?.content?.html}" th:utext="${moment.spec.content.html}"></div>
<div class="wi-moments-page__card-text" th:unless="${moment.spec?.content?.html}" th:text="${moment.spec?.content?.raw ?: ''}"></div>
<div class="wi-moments-page__card-media" th:if="${moment.spec.content.medium != null and !#lists.isEmpty(moment.spec.content.medium)}"> <div class="wi-moments-page__card-media" th:if="${moment.spec.content.medium != null and !#lists.isEmpty(moment.spec.content.medium)}">
<th:block th:each="media : ${moment.spec.content.medium}"> <th:block th:each="media : ${moment.spec.content.medium}">
<img <img
@@ -111,7 +112,8 @@ import LightGallery from "../components/LightGallery.astro";
<div class="wi-moments-page__masonry" th:if="${theme.config?.moments?.moments_style == 'masonry'}"> <div class="wi-moments-page__masonry" th:if="${theme.config?.moments?.moments_style == 'masonry'}">
<th:block th:if="${momentsResult != null and not #lists.isEmpty(momentsResult.items)}"> <th:block th:if="${momentsResult != null and not #lists.isEmpty(momentsResult.items)}">
<div class="wi-moments-page__masonry-item" th:each="moment : ${momentsResult.items}"> <div class="wi-moments-page__masonry-item" th:each="moment : ${momentsResult.items}">
<div class="wi-moments-page__masonry-text" th:utext="${moment.spec?.content?.html ?: moment.spec?.content?.raw ?: moment.spec?.content}"></div> <div class="wi-moments-page__masonry-text" th:if="${moment.spec?.content?.html}" th:utext="${moment.spec.content.html}"></div>
<div class="wi-moments-page__masonry-text" th:unless="${moment.spec?.content?.html}" th:text="${moment.spec?.content?.raw ?: ''}"></div>
<div class="wi-moments-page__masonry-media" th:if="${moment.spec.content.medium != null and !#lists.isEmpty(moment.spec.content.medium)}"> <div class="wi-moments-page__masonry-media" th:if="${moment.spec.content.medium != null and !#lists.isEmpty(moment.spec.content.medium)}">
<th:block th:each="media : ${moment.spec.content.medium}"> <th:block th:each="media : ${moment.spec.content.medium}">
<img <img
+31 -10
View File
@@ -18,22 +18,43 @@ import LightGallery from "../components/LightGallery.astro";
<meta th:if="${post.spec.cover}" name="twitter:image" th:content="${post.spec.cover}" /> <meta th:if="${post.spec.cover}" name="twitter:image" th:content="${post.spec.cover}" />
<meta th:if="${post.status.excerpt}" name="twitter:description" th:content="${post.status.excerpt}" /> <meta th:if="${post.status.excerpt}" name="twitter:description" th:content="${post.status.excerpt}" />
<meta property="article:published_time" th:content="${#dates.format(post.spec.publishTime, 'yyyy-MM-dd')}" /> <meta property="article:published_time" th:content="${#dates.format(post.spec.publishTime, 'yyyy-MM-dd')}" />
<meta name="author" th:content="${post.contributors != null and !post.contributors.isEmpty() ? post.contributors[0].displayName : site.title}" />
<th:block th:if="${post.categories != null and !post.categories.isEmpty()}"> <th:block th:if="${post.categories != null and !post.categories.isEmpty()}">
<meta property="article:section" th:content="${post.categories[0].spec.displayName}" /> <meta property="article:section" th:content="${post.categories[0].spec.displayName}" />
</th:block> </th:block>
<th:block th:if="${post.tags != null and !post.tags.isEmpty()}"> <th:block th:if="${post.tags != null and !post.tags.isEmpty()}">
<meta th:each="tag : ${post.tags}" property="article:tag" th:content="${tag.spec.displayName}" /> <meta th:each="tag : ${post.tags}" property="article:tag" th:content="${tag.spec.displayName}" />
</th:block> </th:block>
<script type="application/ld+json" th:utext="${'{ <!-- 4.4.2:结构化数据不再由模板拼接站点内容后以 th:utext 写进脚本文本。
&quot;@context&quot;: &quot;https://schema.org&quot;, 文章标题、摘要、作者、封面等字段先由 Thymeleaf 转义输出到 <meta>,
&quot;@type&quot;: &quot;Article&quot;, 再由 JSON.stringify + textContent 生成合法 JSON-LD;即使内容里含有
&quot;headline&quot;: &quot;' + post.spec.title + '&quot;, 脚本闭合标签,也只会被当作普通文本,不会被浏览器解析执行。 -->
&quot;datePublished&quot;: &quot;' + #dates.format(post.spec.publishTime, &quot;yyyy-MM-dd&quot;) + '&quot;, <script type="application/ld+json" id="wi-article-jsonld"></script>
&quot;author&quot;: { <script is:inline th:inline="none">
&quot;@type&quot;: &quot;Person&quot;, (function () {
&quot;name&quot;: &quot;' + (post.contributors != null and !post.contributors.isEmpty() ? post.contributors[0].displayName : site.title) + '&quot; function meta(selector) {
}' + (!#strings.isEmpty(post.spec.cover) ? ', &quot;image&quot;: &quot;' + post.spec.cover + '&quot;' : '') + (!#strings.isEmpty(post.status.excerpt) ? ', &quot;description&quot;: &quot;' + #strings.replace(post.status.excerpt, '&quot;', '\\&quot;') + '&quot;' : '') + ' var el = document.querySelector(selector);
}'}"></script> return el ? el.getAttribute("content") : null;
}
var data = {
"@context": "https://schema.org",
"@type": "Article",
headline: meta('meta[property="og:title"]') || document.title,
datePublished: meta('meta[property="article:published_time"]'),
author: {
"@type": "Person",
name: meta('meta[name="author"]') || meta('meta[property="og:site_name"]') || ""
},
mainEntityOfPage: meta('meta[property="og:url"]')
};
var image = meta('meta[property="og:image"]');
var description = meta('meta[property="og:description"]');
if (image) data.image = image;
if (description) data.description = description;
var target = document.getElementById("wi-article-jsonld");
if (target) target.textContent = JSON.stringify(data);
})();
</script>
</Fragment> </Fragment>
<div class="wi-reading-progress" id="wi-reading-progress"></div> <div class="wi-reading-progress" id="wi-reading-progress"></div>
+1 -1
View File
@@ -28,7 +28,7 @@ spec:
description: 微信读书书架(依赖微信读书插件) description: 微信读书书架(依赖微信读书插件)
screenshot: screenshot:
file: weread.html file: weread.html
version: "1.2.5" version: "1.2.6"
requires: ">=2.27.0" requires: ">=2.27.0"
license: license:
- name: "GPL-3.0" - name: "GPL-3.0"