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:
1 parent
dda86294b0
commit
c92429d139
18 files changed
+485
-63
No files matched your search
@@ -14,7 +14,7 @@
|
||||
1. **响应式设计** — 适配桌面端与移动端,支持暗色模式,移动端使用动态视口单位适配地址栏
|
||||
2. **插件兼容性** — 适配 11 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
|
||||
3. **内置灯箱** — 主题内置 LightGallery 灯箱(GPLv3,随主题自托管打包),支持 EXIF 信息展示;安装 lightgallery.js 插件后自动禁用
|
||||
4. **可配置性** — 17 个设置分组、122 个配置项,几乎所有模块可独立开关
|
||||
4. **可配置性** — 17 个设置分组、126 个配置项,几乎所有模块可独立开关
|
||||
5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅首屏边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](https://nxxy335.top/WarmIsland/customize/development.html)
|
||||
6. **零默认外部请求** — 所有脚本、样式、字体、图标、句子库、主题图标均随主题自托管,默认不向任何第三方发起自动请求;详见[外部资源与隐私](#外部资源与隐私)
|
||||
7. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局
|
||||
@@ -38,14 +38,29 @@
|
||||
| 服务 / 资源 | 用途 | 涉及的数据类型 | 默认状态 | 关闭 / 替换方法 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 主题自身静态资源 `/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 字段 |
|
||||
| 自定义一言 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 字段 |
|
||||
| 分享按钮:QQ / 微博 / X / Facebook / Telegram | 分享文章到对应平台 | 文章 URL 与标题(写在目标站 URL 参数中) | 默认开启 `qq,wechat,weibo`;**仅在访客点击后**跳转,页面加载时不请求 | 主题设置 → 文章 → 关闭「显示分享按钮」,或在「分享渠道」取消勾选 |
|
||||
| 微信分享弹层 | 生成并复制文章链接 | 文章 URL(浏览器本地字符串,不发送) | 与分享按钮一致 | 同上;已移除二维码远程库,改为「复制链接」 |
|
||||
| 页脚备案 / Halo / 应用市场外链 | 备案信息、致谢、插件安装提示 | 无(访客主动点击才跳转) | 由站点配置决定,默认仅显示 Halo 致谢 | 主题设置 → 页脚 → 关闭对应显示项 |
|
||||
| 插件前台页面(豆瓣、Steam、朋友圈、微信读书等) | 展示插件提供的数据 | 由对应插件决定,主题不发起请求 | 安装并启用对应插件后才出现 | 卸载 / 停用对应插件;主题自动降级隐藏 |
|
||||
| 内置 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 属于**站点管理员主动配置**的可选能力:一旦填写,浏览器会按设置向该第三方发起请求。请自行评估服务商的隐私政策,并在站点隐私说明中补充披露;主题不会代替站点做这项声明。
|
||||
|
||||
### 隐私说明(5.1)
|
||||
@@ -55,7 +70,7 @@
|
||||
| 收集的数据类型 | 默认配置下**无**。主题不读取、不上传用户信息、站点内容、访问日志或订单数据 |
|
||||
| 数据用途 | 仅用于页面渲染;自定义 API 场景下由管理员自行决定 |
|
||||
| 保存位置与期限 | 主题不写入数据库、不上传数据、不生成服务端日志。前端仅在访客本机使用 `sessionStorage`(欢迎弹窗「同会话仅显示一次」)与 `localStorage`(点赞状态),可随时清除浏览器站点数据 |
|
||||
| 是否传输到第三方 | 默认**否**。仅当管理员显式填写「自定义一言 API」或「自定义 IP 定位 API」时才会向该服务发起请求,其中 IP 定位会暴露访客 IP |
|
||||
| 是否传输到第三方 | 默认**否**(描述文案来源为「无」、欢迎弹窗关闭、两个 API 字段留空)。仅当管理员显式填写「自定义一言 API」或「自定义 IP 定位 API」时才会向该服务发起请求,其中 IP 定位会暴露访客 IP |
|
||||
| 关闭 / 撤销方式 | 见上表;清空相应设置项或关闭对应开关即可立即停止,无需重装主题 |
|
||||
| 遥测 / 统计 / 崩溃上报 / 广告 / AI 分析 / 跨站追踪 | **均不存在**。主题不含任何埋点、指纹或第三方统计脚本 |
|
||||
|
||||
@@ -63,10 +78,29 @@
|
||||
|
||||
- **已移除的远程依赖**:`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` 中,无远程字体请求。
|
||||
- **字体**:[霞鹜文楷 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`。
|
||||
- **图标**:来源与协议见[图标与字体来源](#图标与字体来源)。
|
||||
|
||||
## 内容渲染安全(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 时才显示访客所在地(见[外部资源与隐私](#外部资源与隐私))
|
||||
- 滚动吸附:首屏与内容区之间滚轮/触摸自动吸附,进入内容区后恢复原生自由滚动
|
||||
- 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数
|
||||
@@ -113,7 +147,7 @@
|
||||
|
||||
### 全局
|
||||
|
||||
- 点击特效:核心价值观 / 爱心 / 烟花 / 粒子等 9 种可选
|
||||
- 点击特效:核心价值观 / 爱心 / 烟花 / 粒子等 8 种效果可选,也可关闭
|
||||
- 页脚全站统计:文章、分类、评论、访问、点赞、总字数,支持开关与统计项选择(默认开启)
|
||||
- 动效:呼吸动画、滚动渐入,可独立关闭
|
||||
- 备案信息:ICP 与公网安备号,公网安备自动带徽标
|
||||
@@ -187,7 +221,7 @@ pnpm audit:external
|
||||
# 打包 dist/warm-island-<version>.zip
|
||||
pnpm package
|
||||
|
||||
# 点击特效脚本冒烟测试
|
||||
# 点击特效脚本 + 内容渲染安全(JSON-LD)冒烟测试
|
||||
pnpm smoke
|
||||
```
|
||||
|
||||
@@ -206,11 +240,11 @@ pnpm smoke
|
||||
│ └── styles/ # SCSS 样式(正文样式在 _content.scss,全局生效)
|
||||
├── public/
|
||||
│ ├── assets/data/ # 内置本地一言句子库(静态 JSON)
|
||||
│ ├── assets/fonts/ # LXGW WenKai 本地字体分片
|
||||
│ ├── assets/fonts/ # LXGW WenKai 本地字体分片 + OFL.txt 许可证全文
|
||||
│ ├── assets/vendor/ # 自托管的代码高亮配色 CSS
|
||||
│ └── assets/logo.png # 主题图标(唯一一份,256×256 压缩版)
|
||||
├── compat/assets/ # 历史版本哈希静态资源,构建时合并进 templates/assets
|
||||
├── scripts/ # 构建后置、资源快照、打包、外部资源审计、冒烟测试
|
||||
├── scripts/ # 构建后置、资源快照、打包、外部资源与合规审计、冒烟测试
|
||||
├── templates/ # 构建产物,Halo 实际加载的模板
|
||||
├── settings.yaml # 后台设置项定义
|
||||
└── theme.yaml # 主题清单与自定义页面模板注册
|
||||
@@ -221,6 +255,8 @@ pnpm smoke
|
||||
- **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500,
|
||||
所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`)
|
||||
- **不得引入远程脚本 / 样式 / 字体 / 预连接**:所有第三方资源必须自托管并标注许可,`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
|
||||
- **`th:attr` 只用简单表达式**(取属性、Elvis 默认值、三元、拼接、等值比较),表达式对象方法调用会被受限求值器拒绝
|
||||
- **凡是与后台开关绑定的模板表达式都要给默认值**:`?: '默认值'`,配置未落库时属性会被整体移除,功能会静默失效
|
||||
@@ -235,18 +271,20 @@ pnpm smoke
|
||||
自动化覆盖(每次发版执行):
|
||||
|
||||
- `pnpm build` 生成全部 21 个模板,构建后置脚本校验所有静态资源引用真实存在
|
||||
- `pnpm audit:external` / `pnpm package` 审计第三方远程加载、预连接、动态加载、README 制品链接、Logo 去重
|
||||
- `pnpm smoke` 校验点击特效脚本;安装包内容校验模板数量、`theme.yaml` 版本与元数据一致性
|
||||
- `pnpm audit:external` / `pnpm package` 审计第三方远程加载、预连接、动态加载、README 制品链接、Logo 去重,并校验字体许可证全文随包(5.2)、JSON-LD 不拼接站点内容(4.4.2)、README 设置项数字与 `settings.yaml` 一致(2.2)
|
||||
- `pnpm smoke` 校验点击特效脚本,并用桩 DOM 验证文章 JSON-LD 合法性与脚本注入防护(4.4.2);安装包内容校验模板数量、`theme.yaml` 版本与元数据一致性
|
||||
|
||||
上架前建议在真实 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)
|
||||
|
||||
## 原创声明(审核指南 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:
|
||||
|
||||
+32
-2
@@ -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`
|
||||
- 🔒 移除文章页 PrismJS 1.29.0、`autoloader`(会按代码语言从 CDN 动态拉取并执行组件)与
|
||||
@@ -20,6 +37,19 @@
|
||||
- 🧪 新增 `pnpm audit:external` 与打包前置校验:拦截第三方远程加载、预连接、动态加载、
|
||||
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 的能力)
|
||||
|
||||
+16
-13
@@ -9,8 +9,8 @@ Hero 首屏是 WarmIsland 主题的标志性区域,占据首页首屏的全部
|
||||
| `hero_enabled` | 开关 | `true` | 启用 Hero 模块 |
|
||||
| `hero_title` | 文本 | — | 主标题(留空则使用站点标题) |
|
||||
| `hero_subtitle` | 文本 | — | 副标题(留空则使用站点副标题) |
|
||||
| `hero_description_mode` | 下拉 | `hitokoto` | 描述文案来源:`none` / `hitokoto` / `custom` |
|
||||
| `hero_hitokoto_api` | URL | 留空 | 自定义一言 API;留空使用主题内置本地句子库(零外部请求) |
|
||||
| `hero_description_mode` | 下拉 | `none` | 描述文案来源:`none` / `hitokoto` / `custom`,默认不显示描述文案 |
|
||||
| `hero_hitokoto_api` | URL | `https://v1.hitokoto.cn/` | 选「一言」时自动填入官方接口;留空则使用内置本地句子库(零外部请求) |
|
||||
| `hero_hitokoto_categories` | 多选 | `d, i, k` | 句子类型筛选,仅对自定义 API 生效 |
|
||||
| `hero_custom_description` | 多行文本 | — | 自定义描述文案 |
|
||||
| `hero_hitokoto_auto_refresh` | 开关 | `false` | 一言自动刷新 |
|
||||
@@ -44,28 +44,31 @@ hero_subtitle: "记录温暖与灵感"
|
||||
|
||||
### 描述文案来源(hero_description_mode)
|
||||
|
||||
控制 Hero 区域主标题下方显示的描述文案,提供三种模式:
|
||||
控制 Hero 区域主标题下方显示的描述文案,提供三种模式,**默认为「无」**:
|
||||
|
||||
| 值 | 说明 |
|
||||
| --- | --- |
|
||||
| `none` | 不显示描述文案 |
|
||||
| `hitokoto` | 从主题内置本地句子库随机取一条(默认,零外部请求) |
|
||||
| `custom` | 显示自定义的描述文案 |
|
||||
| `none` | **默认**。不显示描述文案,不渲染一言元素,也不发起任何请求 |
|
||||
| `hitokoto` | 显示一句话文案;自动填入官方接口 `https://v1.hitokoto.cn/`,清空该字段则使用内置本地句子库 |
|
||||
| `custom` | 显示自定义的描述文案(静态文本,不发起任何请求) |
|
||||
|
||||
### 一言模式(hitokoto)
|
||||
|
||||
选择 `hitokoto` 模式后,Hero 区域**默认读取主题内置的本地句子库** `assets/data/hitokoto.json`
|
||||
(随主题自托管的静态 JSON),每次刷新随机展示一条,不依赖任何外部服务,也不会因为第三方接口
|
||||
不可用而出现空白。可直接编辑该 JSON 替换成自己喜欢的句子。
|
||||
默认 `none` 时一言元素不渲染。切换到 `hitokoto` 后,设置项「自定义一言 API」自动填入
|
||||
`https://v1.hitokoto.cn/`,浏览器会向该地址发起 GET 请求获取句子(第三方可见访客 IP / User-Agent)。
|
||||
若把该字段清空,则回退到主题内置的本地句子库 `assets/data/hitokoto.json`
|
||||
(随主题自托管的静态 JSON,零外部请求),每次刷新随机展示一条,也不会因为第三方接口不可用而出现空白。
|
||||
可直接编辑该 JSON 替换成自己喜欢的句子。
|
||||
|
||||
#### 自定义一言 API(hero_hitokoto_api)
|
||||
|
||||
默认**留空**(零外部请求)。如果需要使用在线一言或自建实例,可在此填写 API 地址;
|
||||
填写后浏览器才会向该地址发起 GET 请求,第三方可见访客 IP / User-Agent。
|
||||
请自行评估服务商隐私政策,并在站点隐私说明中披露,详见 README「外部资源与隐私」。
|
||||
选择「一言」或「自定义」后才会显示该设置项。选择「一言」时自动填入 `https://v1.hitokoto.cn/`;
|
||||
**选择「一言」或「自定义」后若留空,则使用主题内置的本地句子库(零外部请求)**。
|
||||
**只有填写后才会向该地址发起请求**,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」。
|
||||
也可换成自建实例或其他兼容接口。
|
||||
|
||||
::: warning
|
||||
这是站点管理员主动开启的可选能力。留空即可随时关闭,无需重装主题。
|
||||
这是站点管理员主动开启的可选能力。把描述文案来源改回「无」、或清空该字段即可随时停止外部请求,无需重装主题。
|
||||
:::
|
||||
|
||||
#### 一言句子类型(hero_hitokoto_categories)
|
||||
|
||||
+19
-7
@@ -123,21 +123,33 @@ v1.2.1 起安装包会保留各版本的哈希资源(v1.1.7 之后全部覆盖
|
||||
### 主题会向第三方服务发请求吗?
|
||||
|
||||
**默认不会。** 所有脚本、样式、字体、图标、主题 Logo 与一言句子库都随主题自托管,
|
||||
页面不预连接任何 CDN,也不加载远程脚本。只有两类例外:
|
||||
页面不预连接任何 CDN,也不加载远程脚本;Hero「描述文案来源」默认为「无」,欢迎弹窗默认关闭。
|
||||
只有两类例外:
|
||||
|
||||
1. 访客**主动点击**分享按钮 / 备案链接 / 应用市场链接时才跳转到对应站点;
|
||||
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` 复核。
|
||||
|
||||
### 一言不显示,或想换成自己的句子?
|
||||
|
||||
1. 主题默认读取内置的 `assets/data/hitokoto.json` 本地句子库,不依赖外部服务;
|
||||
直接编辑该文件即可替换文案
|
||||
2. 想继续使用在线一言,可在主题设置 → Hero 首屏 → 填写「自定义一言 API」;
|
||||
此时才会产生外部请求,且「句子类型」筛选仅对自定义 API 生效
|
||||
3. 若选择「自定义」模式,请在「自定义描述文案」中填写内容
|
||||
1. Hero「描述文案来源」**默认为「无」**,此时不显示描述文案、不渲染一言元素,零外部请求;
|
||||
想显示句子请改为「一言」
|
||||
2. 选择「一言」后会自动填入 `https://v1.hitokoto.cn/`,此时才会产生外部请求;
|
||||
**把该字段清空即回退到主题内置的 `assets/data/hitokoto.json` 本地句子库(零外部请求)**,
|
||||
直接编辑该 JSON 即可替换文案。只有填写后才会向该地址发起请求,需自行在站点隐私说明中披露,
|
||||
详见 README「外部资源与隐私」
|
||||
3. 「自定义」模式显示「自定义描述文案」中的静态文本,不发起任何请求;
|
||||
「句子类型」筛选仅对填写了 API 的一言模式生效
|
||||
|
||||
### 文章目录不显示?
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
|
||||
进入 **Hero 首屏** 设置:
|
||||
|
||||
- 设置主标题和副标题
|
||||
- 选择描述文案来源(一言 / 自定义 / 无);「一言」默认读取主题内置的本地句子库,不请求外部服务
|
||||
- 选择描述文案来源(默认「无」/ 一言 / 自定义);默认不显示描述、不发起请求;选「一言」自动填入 `https://v1.hitokoto.cn/`,清空该字段则使用内置本地句子库(零外部请求)
|
||||
- 可选上传背景图片
|
||||
|
||||
### 第五步:页脚信息
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ Hero 区域占据整个视口高度,包含:
|
||||
|
||||
- **主标题**:默认使用站点标题,可在主题设置中自定义
|
||||
- **副标题**:默认使用站点副标题
|
||||
- **描述文案**:支持一言 API、自定义文案或关闭
|
||||
- **描述文案**:默认「无」(不显示、零请求);可选「一言」(自动填入 `https://v1.hitokoto.cn/`,清空则用内置本地句子库)或「自定义」静态文案
|
||||
- **视觉效果**:渐变背景 + 呼吸光球 + 滚动模糊
|
||||
|
||||
页面滚动时,Hero 区域会产生模糊效果,内容向上淡出,底部有向下箭头提示滚动。
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
@@ -11,7 +11,7 @@
|
||||
"package": "node scripts/package.mjs",
|
||||
"audit:external": "node scripts/audit-external.mjs",
|
||||
"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",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
@@ -4,6 +4,8 @@
|
||||
* 对应 Halo 应用市场审核指南:
|
||||
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
|
||||
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
|
||||
* - 4.4.2 内容渲染安全:不得把站点内容用 th:utext 拼进 <script> 原始文本
|
||||
* - 5.2 / 2.2:第三方字体必须随包携带许可证全文;上架说明数字必须与配置定义一致
|
||||
* - 4.6 / 2.2:不得出现占位内容、无效链接
|
||||
*
|
||||
* 检查项:
|
||||
@@ -12,6 +14,8 @@
|
||||
* 3. 模板与静态资源中不得硬编码 fetch("https://...") / import("https://...") / url(https://...)
|
||||
* 4. 不得出现历史上被审核点名的第三方服务域名,或 autoloader 动态加载痕迹
|
||||
* 5. README 中的相对链接必须在安装包清单中真实存在
|
||||
* 6. 任何 <script> 开标签不得带 th:utext;字体许可证全文必须在制品清单中;
|
||||
* README 的设置分组 / 配置项数量必须与 settings.yaml 一致
|
||||
*
|
||||
* 说明:<a href="https://..."> 这类需要访客主动点击的分享 / 备案 / 应用市场外链是允许的,
|
||||
* 不属于「自动外部请求」,但已在 README「外部资源与隐私」章节逐项披露。
|
||||
@@ -134,6 +138,63 @@ export function auditReadmeLinks(entryNames, readmePath = path.join(root, "READM
|
||||
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) {
|
||||
if (problems.length === 0) {
|
||||
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;
|
||||
if (isCli) {
|
||||
const templates = auditTemplates();
|
||||
const compliance = auditCompliance();
|
||||
let failures = 0;
|
||||
failures += report(
|
||||
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
|
||||
templates.problems
|
||||
);
|
||||
failures += report(
|
||||
`合规审计 4.4.2 / 5.2 / 2.2(${compliance.groupCount} 组 / ${compliance.fieldCount} 配置项)`,
|
||||
compliance.problems
|
||||
);
|
||||
/* 独立运行时按当前磁盘上的打包清单口径检查 README 链接 */
|
||||
const distZip = path.join(root, "dist");
|
||||
const zip = fs.existsSync(distZip)
|
||||
@@ -162,6 +228,6 @@ if (isCli) {
|
||||
} else {
|
||||
console.log("[audit] 提示:README 链接校验在 pnpm package 内执行(当前无安装包)");
|
||||
}
|
||||
console.log(`[audit] 模板审计共 ${failures} 项问题`);
|
||||
console.log(`[audit] 审计共 ${failures} 项问题`);
|
||||
process.exit(failures ? 1 : 0);
|
||||
}
|
||||
+13
-2
@@ -10,7 +10,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import zlib from "node:zlib";
|
||||
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)), "..");
|
||||
|
||||
@@ -235,6 +235,16 @@ if (linkAudit.problems.length) {
|
||||
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"))) {
|
||||
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,请先压缩再打包");
|
||||
}
|
||||
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`);
|
||||
|
||||
@@ -24,6 +24,17 @@ if (!fs.existsSync(path.join(assetsDir, "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. 历史资源合并(已存在的同名文件以当前构建为准,不覆盖)
|
||||
if (fs.existsSync(compatDir)) {
|
||||
let copied = 0;
|
||||
|
||||
@@ -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
@@ -69,14 +69,14 @@ spec:
|
||||
id: welcome_popup_api
|
||||
label: 自定义 IP 定位主 API(可选)
|
||||
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"
|
||||
- $formkit: url
|
||||
name: welcome_popup_fallback_api
|
||||
id: welcome_popup_fallback_api
|
||||
label: 自定义 IP 定位备选 API(可选)
|
||||
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"
|
||||
- $formkit: switch
|
||||
name: welcome_popup_show_once
|
||||
@@ -275,12 +275,13 @@ spec:
|
||||
value: hitokoto
|
||||
- label: 自定义
|
||||
value: custom
|
||||
value: hitokoto
|
||||
value: none
|
||||
- $formkit: url
|
||||
name: hero_hitokoto_api
|
||||
label: 自定义一言 API(可选)
|
||||
value: ""
|
||||
help: 留空使用主题内置的本地句子库(零外部请求);填写后才会向该地址发起请求,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」
|
||||
value: "https://v1.hitokoto.cn/"
|
||||
if: "$get(hero_description_mode).value !== 'none'"
|
||||
help: 选择「一言」时自动填入官方接口 https://v1.hitokoto.cn/;选择「一言」或「自定义」后若留空,则使用主题内置的本地句子库(零外部请求)。只有填写后才会向该地址发起请求,需自行在站点隐私说明中披露,详见 README「外部资源与隐私」
|
||||
- $formkit: checkbox
|
||||
name: hero_hitokoto_categories
|
||||
label: 一言句子类型
|
||||
|
||||
@@ -20,7 +20,8 @@ const pageConfig = findPageBySlug("moments");
|
||||
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__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
|
||||
class="wi-moments-section__date"
|
||||
th:text="${#dates.format(moment.spec?.releaseTime ?: moment.metadata?.creationTimestamp, 'yyyy-MM-dd HH:mm')}"
|
||||
|
||||
@@ -59,7 +59,8 @@ import LightGallery from "../components/LightGallery.astro";
|
||||
></time>
|
||||
</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
|
||||
class="wi-moment-detail__media wi-moments-page__card-media"
|
||||
|
||||
@@ -53,7 +53,8 @@ import LightGallery from "../components/LightGallery.astro";
|
||||
class="wi-moments-page__name"
|
||||
th:text="${moment.owner != null and !#strings.isEmpty(moment.owner.displayName) ? moment.owner.displayName : site.title}"
|
||||
></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)}">
|
||||
<th:block th:each="media : ${moment.spec.content.medium}">
|
||||
<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'}">
|
||||
<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-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)}">
|
||||
<th:block th:each="media : ${moment.spec.content.medium}">
|
||||
<img
|
||||
|
||||
+31
-10
@@ -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.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 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()}">
|
||||
<meta property="article:section" th:content="${post.categories[0].spec.displayName}" />
|
||||
</th:block>
|
||||
<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}" />
|
||||
</th:block>
|
||||
<script type="application/ld+json" th:utext="${'{
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
"headline": "' + post.spec.title + '",
|
||||
"datePublished": "' + #dates.format(post.spec.publishTime, "yyyy-MM-dd") + '",
|
||||
"author": {
|
||||
"@type": "Person",
|
||||
"name": "' + (post.contributors != null and !post.contributors.isEmpty() ? post.contributors[0].displayName : site.title) + '"
|
||||
}' + (!#strings.isEmpty(post.spec.cover) ? ', "image": "' + post.spec.cover + '"' : '') + (!#strings.isEmpty(post.status.excerpt) ? ', "description": "' + #strings.replace(post.status.excerpt, '"', '\\"') + '"' : '') + '
|
||||
}'}"></script>
|
||||
<!-- 4.4.2:结构化数据不再由模板拼接站点内容后以 th:utext 写进脚本文本。
|
||||
文章标题、摘要、作者、封面等字段先由 Thymeleaf 转义输出到 <meta>,
|
||||
再由 JSON.stringify + textContent 生成合法 JSON-LD;即使内容里含有
|
||||
脚本闭合标签,也只会被当作普通文本,不会被浏览器解析执行。 -->
|
||||
<script type="application/ld+json" id="wi-article-jsonld"></script>
|
||||
<script is:inline th:inline="none">
|
||||
(function () {
|
||||
function meta(selector) {
|
||||
var el = document.querySelector(selector);
|
||||
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>
|
||||
|
||||
<div class="wi-reading-progress" id="wi-reading-progress"></div>
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ spec:
|
||||
description: 微信读书书架(依赖微信读书插件)
|
||||
screenshot:
|
||||
file: weread.html
|
||||
version: "1.2.5"
|
||||
version: "1.2.6"
|
||||
requires: ">=2.27.0"
|
||||
license:
|
||||
- name: "GPL-3.0"
|
||||
|
||||
Reference in new issue
Block a user