4 Commits
Author SHA1 Message Date
nxxy335 6ab408ca4f docs(v1.2.7): 精简 README 自证式内容并重排章节
移除开发者自证、回归检查清单与重复的「不加载远程脚本」类宣言;按安装 → 功能 → 插件 → 隐私 → 开发 → 声明重排章节(331 → 237 行)。5.1 逐项披露、许可表与原创声明完整保留;changelog 同步记录。
2026-11-11 22:47:15 +08:00
nxxy335 5c7c6325b1 feat(v1.2.7): 全面校准文档并精简三个可选 API 配置项文案
- 文档对齐 1.2.7:feed/RSS 聚合改由官方链接管理插件订阅承担、Halo 要求统一 >=2.27.0、补齐设置分组文档、清理与代码不符的描述,并修复正文引用块留白
- 三个可选 API 设置项 help 只保留功能说明,隐私披露统一由 README「外部资源与隐私」与文档站承载;字段恢复按开关条件显示,修正配置块缩进
- audit:external 改为防回潮检查:字段存在、help 不得出现披露文案、README 必须保留隐私披露章节
2026-11-11 22:34:32 +08:00
nxxy335 c92429d139 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 字体许可证修复。
2026-11-11 16:09:31 +08:00
nxxy335 dda86294b0 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 文档与预览图
2026-11-11 14:37:09 +08:00
62 changed files with 1756 additions and 540 deletions

No files matched your search

+172 -133
View File
@@ -2,114 +2,22 @@
暖如灯火,静如留白。暖屿以柔和的色调和舒缓的节奏回归内容本身,为记录与阅读留出一方安静从容的角落。 暖如灯火,静如留白。暖屿以柔和的色调和舒缓的节奏回归内容本身,为记录与阅读留出一方安静从容的角落。
> 本项目由 AI 开发完成,人工仅进行部分功能测试及问题找寻 ![WarmIsland Preview](https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland/raw/branch/main/WarmIsland%20Preview.webp)
> 预览图随安装包分发(包根目录 `WarmIsland Preview.webp`)。
## 环境要求 ## 环境要求
- Halo **2.27.0 及以上**(页面布局契约、微信读书页面均依赖 2.27 的新能力) - Halo **2.27.0 及以上**(页面布局契约与微信读书页面依赖 2.27 的新能力)
- v1.2.0 起内置主题配色系统(纯 CSS 变量实现);更低版本的 Halo 请使用主题 v1.1.x - 更低版本的 Halo 请使用主题 v1.1.x;v1.2.0 起内置纯 CSS 变量实现的主题配色系统
## 特点
1. **响应式设计** — 适配桌面端与移动端,支持暗色模式,移动端使用动态视口单位适配地址栏
2. **插件兼容性** — 适配 11 款常用 Halo 插件,安装即用;提供页面布局契约,插件前台页面自动复用主题外观
3. **内置灯箱** — 主题内置 LightGallery 灯箱,支持 EXIF 信息展示;安装 lightgallery.js 插件后自动禁用
4. **可配置性** — 17 个设置分组、122 个配置项,几乎所有模块可独立开关
5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅首屏边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](docs/customize/development.md)
6. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局
## 预览
![WarmIsland Preview](./WarmIsland%20Preview.webp)
## 已适配插件
| 插件名称 | 插件标识 | 适配页面路由 | 说明 |
|----------|---------|-------------|------|
| [瞬间](https://halo.run/store/apps/app-SnwWD) | `PluginMoments` | `/moments` | 瞬间动态页面,支持点赞、评论、标签筛选、顶部封面 |
| [图库](https://halo.run/store/apps/app-BmQJW) | `PluginPhotos` | `/photos` | 图片展示页面,支持分组、灯箱预览 |
| [友情链接](https://halo.run/store/apps/app-hfbQg) | `PluginLinks` | `/links` | 友链展示页面,支持随机排列、随机传送门、评论区开关 |
| [朋友圈](https://github.com/halo-sigs/plugin-friends) | `plugin-friends` | `/friends` | RSS 订阅朋友圈页面 |
| [装备管理](https://github.com/chengzhongxue/plugin-equipment) | `equipment` | `/equipments` | 装备展示页面,按分组展示,支持封面图 |
| [豆瓣](https://github.com/chengzhongxue/plugin-douban) | `plugin-douban` | `/douban` | 豆瓣记录页面,支持类型/状态/标签筛选,默认音乐;需插件 **v1.2.6+**(SQLite 数据结构) |
| [Steam 信息展示](https://github.com/Tim0x0/halo-plugin-steam) | `PluginSteam` | `/steam` | Steam 游戏库页面,含用户资料、统计数据、最近游玩、游戏库分页 |
| [微信读书](https://github.com/yeora26/halo-weread-plugin) | `halo-weread-plugin` | 自定义页面 | 书架展示页面,见下方「自定义页面模板」 |
| [记得](https://github.com/yyliucha/plugin-important-dates) | `plugin-important-dates` | `/important-dates` | 重要日期 / 牵挂的人 / 爱车双视图页面,见下方「扩展页面」 |
| [文章组件](https://github.com/acanyo/plugin-content-widgets) | `plugin-content-widgets` | 文章内 | 提示框、标签页、时间线等内容组件,主题已映射 `--xhhao-com-*` 变量,自动跟随明暗模式 |
| [API 扩展包](https://github.com/HowieHz/halo-plugin-extra-api) | `extra-api` | 全站 | 服务端字数统计、中英文混排排版、可选服务端代码高亮 |
> **注意**:插件需要单独安装,安装后主题会自动适配对应页面。若未安装某插件,对应页面或功能自动隐藏/降级,不会报错。
## 自定义页面模板
在 Halo 后台「页面 → 新建 → 自定义模板」中选择使用:
| 模板名 | 模板文件 | 说明 |
|--------|---------|------|
| 留言板 | `page_messageboard.html` | 留言墙页面,标题与副标题可配置 |
| 订阅 | `page_subscriptions.html` | 博友文章订阅动态,依赖链接管理插件的 RSS 订阅,支持分页/下滑加载与列表/网格布局 |
| 微信读书 | `weread.html` | 微信读书书架展示,依赖微信读书插件,访客免登录查看 |
## 主要功能
### 首页
- Hero 首屏:明暗双背景图、大标题/副标题独立配色、一言(支持自动刷新与淡入淡出过渡)、下滑箭头平滑滚动
- 欢迎弹窗:基于访客 IP 地理定位的欢迎提示,支持 5 种位置、自定义 API 与会话去重开关
- 滚动吸附:首屏与内容区之间滚轮/触摸自动吸附,进入内容区后恢复原生自由滚动
- 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数
### 文章
- 目录(TOC):桌面端侧栏 + 移动端抽屉,滚动高亮、展开模式可选
- 上下篇导航:悬停反馈与方向箭头动画
- 相关推荐、分享按钮、赞赏、版权声明可独立开关
- 字数与阅读时间:优先使用 API 扩展包的服务端统计,未安装时前端计算兜底
### 全局
- 点击特效:核心价值观 / 爱心 / 烟花 / 粒子等 9 种可选
- 页脚全站统计:文章、分类、评论、访问、点赞、总字数,支持开关与统计项选择(默认开启)
- 动效:呼吸动画、滚动渐入,可独立关闭
- 备案信息:ICP 与公网安备号,公网安备自动带徽标
- 鼠标光感跟随已移除(性能考量)
## 灯箱集成
主题内置 LightGallery 灯箱,为图片提供放大预览功能,并支持 EXIF 信息展示。
### lightgallery.js 插件兼容
如安装 [lightgallery.js](https://www.lightgalleryjs.com/) 灯箱插件,主题内置灯箱将自动禁用,改由插件接管灯箱功能。以下为 lightgallery.js 插件的路径匹配规则和 DOM 节点选择器:
| 页面 | 路径匹配规则 | 匹配区域 DOM 节点 |
|------|-------------|-------------------|
| 文章详情页 | `/archives/*` | `.wi-post__body` |
| 瞬间页 | `/moments` | `.wi-moments-page__content` |
| 图库页 | `/photos` | `.wi-photos-page__grid` |
| 自定义页面 | 用户自定义 | `.wi-page__body` |
> **注意**:自定义页面的路径匹配由用户自行设定,此处仅提供 DOM 节点选择器 `.wi-page__body`。
## 安装 ## 安装
1. 下载主题最新 Release 的 ZIP 包 1. 下载最新 Release 的 ZIP 安装包
2. 进入 Halo 后台 → 主题管理 → 安装主题 → 上传 ZIP 包 2. Halo 后台 → 主题管理 → 安装主题 → 上传 ZIP
3. 安装完成后点击「启用」 3. 点击「启用」
> **升级后首页 404「资源不存在 / Template index was not found」**:说明上传的 ZIP 里没有 `templates/` 从源码构建:
> 模板文件(从源码仓库打包时漏了构建步骤)。请换用完整安装包重新上传,详见 [FAQ 安装相关](docs/faq.md)。
>
> **升级后如果整站没了样式**:Halo 页面缓存或前置反向代理里可能还留着上一版本的 HTML,
> 它引用的 CSS 文件名带内容哈希,升级后已被删除,请求会直接 500。
> 先在 Halo 后台执行一次「清理缓存」(并刷新 CDN / Nginx 缓存)即可。
> 主题从 v1.2.1 起会把历史版本的哈希资源一并发进安装包,旧 HTML 也不会请求失败。
>
> **升级主题或修改配色后,部分页面不生效**:如果安装了「页面静态缓存」插件,
> 它只随文章变更自动刷新,主题层的变更需要手动点一次「刷新页面缓存」,
> 详见 [FAQ:升级主题或修改配色后部分页面不生效](docs/faq.md#升级主题或修改配色后部分页面的新样式配色不生效)。
或从源码构建:
```bash ```bash
git clone https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland.git git clone https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland.git
@@ -118,34 +26,148 @@ 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`;安装包同时含 `docs/*.md` 与预览图,便于离线查阅。
安装包由 `pnpm package` 生成到 `dist/warm-island-<version>.zip`。
> **升级后首页 404「Template index was not found」**:ZIP 中缺少 `templates/`(源码打包时漏了构建步骤),请换用完整安装包重新上传,详见 [FAQ 安装相关](https://nxxy335.top/WarmIsland/faq.html)。
>
> **升级后整站没了样式**:Halo 页面缓存或反向代理仍留着上一版 HTML,其引用的哈希 CSS 已被删除。在后台执行一次「清理缓存」并刷新 CDN / Nginx 缓存即可;主题自 v1.2.1 起会把历史哈希资源一并打进安装包。
>
> **改了主题或配色后部分页面不生效**:若安装了「页面静态缓存」插件,需手动点一次「刷新页面缓存」,详见 [FAQ](https://nxxy335.top/WarmIsland/faq.html)。
## 特点
1. **响应式设计** — 桌面 / 移动端适配、暗色模式,移动端用动态视口单位适配地址栏
2. **插件兼容** — 适配 10 款常用 Halo 插件,提供页面布局契约,插件前台页面自动复用主题外观
3. **内置灯箱** — 随包自托管的 LightGallery(GPLv3),支持 EXIF 展示;安装 lightgallery.js 插件后自动让位
4. **可配置性** — 17 个设置分组、126 个配置项,几乎所有模块可独立开关
5. **性能优化** — 服务端字数统计(API 扩展包)、图片懒加载与响应式缩略图、首屏整屏吸附(仅边界接管,内容区保留原生滚动)、Hero 离屏停绘、CSS/HTML 压缩;详见[开发指南 - 性能与滚动顺滑](https://nxxy335.top/WarmIsland/customize/development.html)
6. **页面布局契约** — 提供 `templates/layout.html`,插件页面不再回退到 Halo 内置布局
## 主要功能
### 首页
- Hero 首屏:明暗双背景图、大标题 / 副标题独立配色、描述文案(默认「无」;API 字段默认预填 `https://v1.hitokoto.cn/`,仅「一言」模式生效,清空则使用内置本地句子库;可选自定义文案与自动刷新)、下滑箭头平滑滚动
- 欢迎弹窗:默认显示纯本地静态欢迎语,支持 5 种位置、会话去重开关;仅在显式配置自定义 IP 定位 API 时才显示访客所在地(见[外部资源与隐私](#外部资源与隐私))
- 滚动吸附:首屏与内容区之间滚轮 / 触摸自动吸附,进入内容区后恢复原生自由滚动
- 文章卡片:可配置显示字数(依赖 API 扩展包)、阅读量、分类、标签、摘要行数
### 文章
- 目录(TOC):桌面端侧栏 + 移动端抽屉,滚动高亮、展开模式可选
- 代码高亮:由代码高亮插件 / API 扩展包输出,主题提供 Warm / Cold 两套本地配色
- 上下篇导航、相关推荐、分享按钮、赞赏、版权声明可独立开关;微信分享改为复制文章链接
- 字数与阅读时间:优先使用 API 扩展包的服务端统计,未安装时前端计算兜底
### 全局
- 点击特效:核心价值观 / 爱心 / 烟花 / 粒子等 8 种效果可选,也可关闭
- 页脚全站统计:文章、分类、评论、访问、点赞、总字数,支持开关与统计项选择(默认开启)
- 动效:呼吸动画、滚动渐入,可独立关闭
- 备案信息:ICP 与公网安备号,公网安备自动带徽标
## 已适配插件
| 插件名称 | 插件标识 | 适配页面路由 | 说明 |
|----------|---------|-------------|------|
| [瞬间](https://halo.run/store/apps/app-SnwWD) | `PluginMoments` | `/moments` | 瞬间动态页面,支持点赞、评论、标签筛选、顶部封面 |
| [图库](https://halo.run/store/apps/app-BmQJW) | `PluginPhotos` | `/photos` | 图片展示页面,支持分组、灯箱预览 |
| [友情链接(链接管理)](https://halo.run/store/apps/app-hfbQg) | `PluginLinks` | `/links` | 友链展示页面,支持随机排列、随机传送门、评论区开关;同一插件的 RSS 订阅驱动「订阅」页面 |
| [装备管理](https://github.com/chengzhongxue/plugin-equipment) | `equipment` | `/equipments` | 装备展示页面,按分组展示,支持封面图 |
| [豆瓣](https://github.com/chengzhongxue/plugin-douban) | `plugin-douban` | `/douban` | 豆瓣记录页面,支持类型 / 状态 / 标签筛选,默认音乐;需插件 **v1.2.6+**(SQLite 数据结构) |
| [Steam 信息展示](https://github.com/Tim0x0/halo-plugin-steam) | `PluginSteam` | `/steam` | Steam 游戏库页面,含用户资料、统计数据、最近游玩、游戏库分页 |
| [微信读书](https://github.com/yeora26/halo-weread-plugin) | `halo-weread-plugin` | 自定义页面 | 书架展示页面,见下方「自定义页面模板」 |
| [记得](https://github.com/yyliucha/plugin-important-dates) | `plugin-important-dates` | `/important-dates` | 重要日期 / 牵挂的人 / 爱车双视图页面,见下方「扩展页面」 |
| [文章组件](https://github.com/acanyo/plugin-content-widgets) | `plugin-content-widgets` | 文章内 | 提示框、标签页、时间线等内容组件,主题已映射 `--xhhao-com-*` 变量,自动跟随明暗模式 |
| [API 扩展包](https://github.com/HowieHz/halo-plugin-extra-api) | `extra-api` | 全站 | 服务端字数统计、中英文混排排版、可选服务端代码高亮 |
> 插件需单独安装;未安装时对应页面或功能自动隐藏 / 降级,不会报错。
>
> **「朋友圈」(`plugin-friends`)插件已不再提供**:原先的 feed / RSS 聚合能力由官方「链接管理」(PluginLinks)插件的订阅取代,对应下方的「订阅」自定义页面模板。主题保留 `friends.html` 与「朋友圈」设置分组仅用于兼容旧站点,新站点请勿再依赖;插件不可用时会显示空状态。
## 自定义页面模板
在 Halo 后台「页面 → 新建 → 自定义模板」中选择使用(页面地址由 slug 决定,无固定路由):
| 模板名 | 模板文件 | 说明 |
|--------|---------|------|
| 留言板 | `page_messageboard.html` | 留言墙页面,标题与副标题可配置 |
| 订阅 | `page_subscriptions.html` | 博友文章订阅动态,由官方链接管理插件的 RSS 订阅提供,支持分页 / 下滑加载与列表 / 网格布局 |
| 微信读书 | `weread.html` | 微信读书书架展示,依赖微信读书插件,访客免登录查看 |
## 灯箱集成
主题内置 LightGallery 2.9.0(GPLv3,与主题的 GPL-3.0 一致),为图片提供放大预览与 EXIF 展示。如安装 [lightgallery.js](https://www.lightgalleryjs.com/) 灯箱插件,内置灯箱自动禁用,改由插件接管。其路径匹配规则与 DOM 节点选择器:
| 页面 | 路径匹配规则 | 匹配区域 DOM 节点 |
|------|-------------|-------------------|
| 文章详情页 | `/archives/*` | `.wi-post__body` |
| 瞬间页 | `/moments` | `.wi-moments-page__content` |
| 图库页 | `/photos` | `.wi-photos-page__grid` |
| 自定义页面 | 用户自定义 | `.wi-page__body` |
> 自定义页面的路径匹配由用户自行设定,此处仅提供 DOM 节点选择器 `.wi-page__body`。
## 外部资源与隐私
默认配置下,主题不会自动向任何第三方服务发起请求: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 | **默认不启用**:「描述文案来源」默认「无」,一言元素不渲染,零请求;该字段默认预填 `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` | 同上两个地址任选其一,主接口失败时兜底 |
**描述文案来源默认为「无」**,此时不渲染一言元素、不发起任何请求。API 字段默认预填 `https://v1.hitokoto.cn/`(仅「一言」模式生效);选择「一言」或「自定义」后若留空,则回退到主题内置的本地句子库(零外部请求)。只有填写后才会向该地址发起请求,第三方可见访客 IP / User-Agent,需自行在站点隐私说明中披露。
两个 IP 定位接口同样默认留空;填写后会把**访客 IP**发送给该第三方服务换取粗略地理位置,需自行在站点隐私说明中披露。
### 隐私说明
| 项目 | 说明 |
| --- | --- |
| 收集的数据类型 | 默认配置下**无**。主题不读取、不上传用户信息、站点内容、访问日志或订单数据 |
| 数据用途 | 仅用于页面渲染;自定义 API 场景下由管理员自行决定 |
| 保存位置与期限 | 主题不写入数据库、不上传数据、不生成服务端日志。前端仅在访客本机使用 `sessionStorage`(欢迎弹窗「同会话仅显示一次」)与 `localStorage`(点赞状态),可随时清除浏览器站点数据 |
| 是否传输到第三方 | 默认**否**(描述文案来源为「无」、欢迎弹窗关闭、两个 API 字段留空)。仅当管理员显式填写「自定义一言 API」或「自定义 IP 定位 API」时才会向该服务发起请求,其中 IP 定位会暴露访客 IP |
| 关闭 / 撤销方式 | 见上表;清空相应设置项或关闭对应开关即可立即停止,无需重装主题 |
| 遥测 / 统计 / 崩溃上报 / 广告 / AI 分析 / 跨站追踪 | **均不存在**。主题不含任何埋点、指纹或第三方统计脚本 |
### 自托管与许可
- **代码高亮**:主题不自带 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`,上游完整的版权声明、保留字体名条款与 **OFL 1.1 许可证全文**随包存放于 `public/assets/fonts/LXGWWenKai/OFL.txt`(构建后为 `templates/assets/fonts/LXGWWenKai/OFL.txt`)。上游附加许可允许把字体切分 / 转换为 WOFF2 用于网页字体交付。
- **灯箱**:LightGallery 2.9.0 以 GPLv3 发布,本主题同样采用 GPL-3.0,按开源许可条款使用(非商业授权)。
- **图标**:来源与协议见[图标与字体来源](#图标与字体来源)。
## 开发 ## 开发
```bash | 命令 | 作用 |
# 安装依赖 | --- | --- |
pnpm install | `pnpm dev` | 监听文件变化并构建到 `templates/` |
| `pnpm build` | 构建模板与静态资源,自动合并历史哈希资源 |
| `pnpm snapshot` | 把本次构建的哈希资源快照进 `compat/assets`(发版前执行) |
| `pnpm audit:external` | 外部资源、隐私与 README 链接审计(打包时自动执行) |
| `pnpm smoke` | 点击特效脚本与 JSON-LD 冒烟测试 |
| `pnpm package` | 打包 `dist/warm-island-<version>.zip` |
| `pnpm release` | 等价于 `build` → `snapshot` → `package` |
# 开发模式(监听文件变化,自动构建到 templates/) 发版执行 `pnpm release`,并把 `compat/assets` 的新增文件一起提交。`pnpm package` 会校验模板、资源目录与版本号一致性并执行外部资源审计,任一不通过即报错。
pnpm dev
# 构建(输出到 templates/,并自动合并历史版本的静态资源)
pnpm build
# 把本次构建的哈希资源快照进 compat/assets(发版前必做)
pnpm snapshot
# 打包 dist/warm-island-<version>.zip
pnpm package
# 点击特效脚本冒烟测试
pnpm smoke
```
发版流程:`pnpm release`(等价于 `pnpm build` → `pnpm snapshot` → `pnpm package`),
并把 `compat/assets` 的新增文件一起提交。`pnpm package` 会校验模板、资源目录与版本号一致性,
产物缺失时直接报错,避免打出不含 `templates/` 的空壳安装包。
### 目录结构 ### 目录结构
@@ -155,9 +177,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 本地字体分片 + OFL.txt 许可证全文
│ ├── 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 # 主题清单与自定义页面模板注册
@@ -165,11 +191,16 @@ pnpm smoke
### 主题开发注意事项 ### 主题开发注意事项
- **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500, - **静态资源文件名带内容哈希**:升级后旧文件被删除,被缓存的 HTML 仍会请求旧文件名并触发 500,所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`)
所以每个版本的哈希文件都要留一份(见 `compat/assets` 与 `pnpm snapshot`) - **第三方资源一律自托管并标注许可**,不得引入远程脚本 / 样式 / 字体 / 预连接,`pnpm package` 会拦截回归
- **Thymeleaf 字符串字面量必须加单引号**:`th:text="'文本'"`,裸中文遇到逗号/空格/emoji 会导致页面 500 - **常规变量统一用 `th:text` / `th:attr` 转义输出**,`th:utext` 仅用于 Halo 已处理的 HTML 与管理员自定义内容;`target="_blank"` 一律带 `rel="noopener noreferrer"`
- **不得把站点内容用 `th:utext` 拼进 `<script>`**:`<script>` 是原始文本元素,HTML 转义不生效且脚本闭合标签可提前结束元素;动态数据走「转义输出到 `<meta>` → `JSON.stringify` → `textContent`」
- **后台自定义字段按设计意图原样输出**:`style.custom_css` 与 `footer.footer_custom_html` 的写入权限由 Halo 后台角色控制,开放多人编辑时请自行评估信任边界
- **第三方字体必须随包携带许可证全文**:修改 / 切分字体时,`public/assets/fonts/LXGWWenKai/OFL.txt` 与 `result.css` 中的版权声明都要保留,`pnpm package` 会校验
- **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 不触发
@@ -182,17 +213,25 @@ pnpm smoke
- [Thymeleaf](https://www.thymeleaf.org) 模板引擎 - [Thymeleaf](https://www.thymeleaf.org) 模板引擎
- [Sass](https://sass-lang.com/) 样式 - [Sass](https://sass-lang.com/) 样式
## 原创声明
WarmIsland 暖屿为**完全原创**主题:视觉设计、页面结构、Astro 构建链路、Thymeleaf 模板与样式代码均由开发者独立编写,**未基于任何已有 Halo 主题或插件进行 Fork、复制代码或二次开发**,不存在上游应用、上游仓库、派生版本或接续维护关系。因此审核指南 **4.2.1(Fork 与派生应用)与 4.2.2(停止维护应用的接续维护)不适用于本主题**,无需提供上游贡献记录、上游差异说明或停止维护证明。
主题使用 AI 工具辅助编码与文档编写。第三方开源组件(字体、图标、灯箱)均自托管并保留许可证与署名,来源见[图标与字体来源](#图标与字体来源)与[自托管与许可](#自托管与许可);主题本体以 GPL-3.0 发布,源码仓库为 https://git.nxxy335.top/nxxy335/halo-theme-WarmIsland 。
## 许可 ## 许可
[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)
## 图标来源 ## 图标与字体来源
本主题使用的图标来自以下开源项目: 本主题使用的图标与字体来自以下开源项目,均随主题自托管分发:
| 来源 | 协议 | 用途 | | 来源 | 协议 | 用途 |
| --- | --- | --- | | --- | --- | --- |
| [霞鹜文楷 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 依赖随构建打包) |
+1
View File
@@ -0,0 +1 @@
@charset "UTF-8";:root{--bg: #faf7f2;--bg-raised: #f3ede5;--bg-overlay: color-mix(in srgb, var(--bg) 85%, transparent);--ink: #2c2420;--ink-2: #7a6e64;--ink-3: #b5a99e;--rule: #e8e0d6;--accent: #d4764e;--accent-hover: #c4613a;--accent-bg: #fdf0e8;--mist-pink: #f0e4de;--sea-salt: #e8e0d6;--caramel: #8b6f5e;--glass-bg: color-mix(in srgb, var(--bg) 72%, transparent);--glass-border: color-mix(in srgb, var(--rule) 50%, transparent);--shadow-sm: 0 1px 3px color-mix(in srgb, var(--ink) 6%, transparent);--shadow-md: 0 4px 16px color-mix(in srgb, var(--ink) 8%, transparent);--shadow-lg: 0 8px 32px color-mix(in srgb, var(--ink) 12%, transparent);--shadow-glow: 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent)}html.dark{--bg: #1e1c1a;--bg-raised: #292623;--bg-overlay: color-mix(in srgb, var(--bg) 85%, transparent);--ink: #e9e4dd;--ink-2: #aaa096;--ink-3: #7c7268;--rule: #3b362f;--accent: #e89a64;--accent-hover: #f2ad79;--accent-bg: #2c231b;--mist-pink: #2e2823;--sea-salt: #302b26;--caramel: #a38b7b;--glass-bg: color-mix(in srgb, var(--bg) 72%, transparent);--glass-border: color-mix(in srgb, var(--rule) 50%, transparent);--shadow-sm: 0 1px 3px rgba(0, 0, 0, .24);--shadow-md: 0 4px 16px rgba(0, 0, 0, .3);--shadow-lg: 0 8px 32px rgba(0, 0, 0, .4);--shadow-glow: 0 0 40px rgba(232, 154, 100, .12)}html[data-palette=matcha]{--bg: #f6f8f1;--bg-raised: #ecf1e3;--ink: #26302a;--ink-2: #6d7a6e;--ink-3: #a8b3a4;--rule: #dde5d4;--accent: #6f9d52;--accent-hover: #5d8943;--accent-bg: #ecf3e2;--mist-pink: #e7eddc;--sea-salt: #dfe8d3;--caramel: #7d8a68}html[data-palette=matcha].dark{--bg: #1b1f1a;--bg-raised: #252b22;--ink: #e6e9e0;--ink-2: #a3ab9c;--ink-3: #767e70;--rule: #363d33;--accent: #9cc47f;--accent-hover: #aed493;--accent-bg: #242c1f;--mist-pink: #262d22;--sea-salt: #282f24;--caramel: #98a387}html[data-palette=indigo]{--bg: #f5f7fa;--bg-raised: #eaeff5;--ink: #232a33;--ink-2: #66707d;--ink-3: #a3adb8;--rule: #dce2ea;--accent: #4a6fa5;--accent-hover: #3d5d8c;--accent-bg: #e9eff8;--mist-pink: #e2e8f0;--sea-salt: #dbe4ee;--caramel: #707c8e}html[data-palette=indigo].dark{--bg: #191d23;--bg-raised: #222831;--ink: #e4e7ec;--ink-2: #9aa4b1;--ink-3: #6d7683;--rule: #323a45;--accent: #7fa3d1;--accent-hover: #93b4dc;--accent-bg: #1f2632;--mist-pink: #232830;--sea-salt: #252b34;--caramel: #8b95a6}html[data-palette=sakura]{--bg: #faf5f6;--bg-raised: #f3eaec;--ink: #322a2c;--ink-2: #7d6e72;--ink-3: #b4a6aa;--rule: #ecdfe2;--accent: #d97387;--accent-hover: #c85d72;--accent-bg: #fbeef1;--mist-pink: #f2e2e6;--sea-salt: #eddfe3;--caramel: #96757e}html[data-palette=sakura].dark{--bg: #201b1c;--bg-raised: #2a2425;--ink: #ece5e6;--ink-2: #b0a4a7;--ink-3: #7f7276;--rule: #3c3436;--accent: #e89aab;--accent-hover: #f0aec0;--accent-bg: #2d2225;--mist-pink: #2f2729;--sea-salt: #31282a;--caramel: #a88d94}html[data-palette=violet]{--bg: #f7f5fa;--bg-raised: #eeeaf5;--ink: #2b2733;--ink-2: #6f6a7c;--ink-3: #aaa5b6;--rule: #e2ddeb;--accent: #8a6fc0;--accent-hover: #7759ae;--accent-bg: #f0eaf9;--mist-pink: #e8e2f0;--sea-salt: #e3dcee;--caramel: #7f7590}html[data-palette=violet].dark{--bg: #1c1a21;--bg-raised: #26222f;--ink: #e8e5ee;--ink-2: #a49eaf;--ink-3: #746e80;--rule: #373241;--accent: #b49ae0;--accent-hover: #c3ace8;--accent-bg: #262033;--mist-pink: #292434;--sea-salt: #2b2536;--caramel: #948caa}html[data-palette=sea]{--bg: #f3f8f7;--bg-raised: #e8f0ee;--ink: #21302d;--ink-2: #647872;--ink-3: #9fb1ac;--rule: #d9e6e3;--accent: #3d9c8f;--accent-hover: #2f8578;--accent-bg: #e5f3f0;--mist-pink: #dee9e6;--sea-salt: #d6e5e1;--caramel: #6c8680}html[data-palette=sea].dark{--bg: #181f1e;--bg-raised: #212a28;--ink: #e3eae8;--ink-2: #9aaba6;--ink-3: #6d7d79;--rule: #2f3b39;--accent: #6cc3b4;--accent-hover: #82d0c2;--accent-bg: #1d2a27;--mist-pink: #222c2a;--sea-salt: #242e2c;--caramel: #869894}html[data-palette=amber]{--bg: #faf7ef;--bg-raised: #f3eddc;--ink: #322b1e;--ink-2: #7d7360;--ink-3: #b5ab94;--rule: #eae1cb;--accent: #b97f1c;--accent-hover: #9f6a0e;--accent-bg: #f9efd8;--mist-pink: #f0e8d2;--sea-salt: #ebe2ca;--caramel: #96835c}html[data-palette=amber].dark{--bg: #201d16;--bg-raised: #2a261d;--ink: #ebe6d9;--ink-2: #b0a893;--ink-3: #7f7866;--rule: #3c3729;--accent: #e0a83e;--accent-hover: #ebba5c;--accent-bg: #2c251a;--mist-pink: #2f2a1e;--sea-salt: #312c20;--caramel: #a89a76}html[data-palette=plum]{--bg: #f9f4f5;--bg-raised: #f1e8ea;--ink: #2f2427;--ink-2: #796a6f;--ink-3: #b1a2a7;--rule: #e9dde0;--accent: #a4506b;--accent-hover: #8d3f59;--accent-bg: #f7e9ee;--mist-pink: #ecdee2;--sea-salt: #e5d8db;--caramel: #8d6f77}html[data-palette=plum].dark{--bg: #1f1a1b;--bg-raised: #292223;--ink: #eae2e4;--ink-2: #ada0a4;--ink-3: #7c6e73;--rule: #3a3133;--accent: #d98aa3;--accent-hover: #e39fb5;--accent-bg: #2b2023;--mist-pink: #2d2527;--sea-salt: #2f2729;--caramel: #a08890}html[data-palette=graphite]{--bg: #f7f7f6;--bg-raised: #eeedeb;--ink: #262626;--ink-2: #6e6e6c;--ink-3: #ababa8;--rule: #e0e0de;--accent: #3f3f3e;--accent-hover: #262626;--accentLine truncated
+1
View File
@@ -0,0 +1 @@
@charset "UTF-8";:root{--bg: #faf7f2;--bg-raised: #f3ede5;--bg-overlay: color-mix(in srgb, var(--bg) 85%, transparent);--ink: #2c2420;--ink-2: #7a6e64;--ink-3: #b5a99e;--rule: #e8e0d6;--accent: #d4764e;--accent-hover: #c4613a;--accent-bg: #fdf0e8;--mist-pink: #f0e4de;--sea-salt: #e8e0d6;--caramel: #8b6f5e;--glass-bg: color-mix(in srgb, var(--bg) 72%, transparent);--glass-border: color-mix(in srgb, var(--rule) 50%, transparent);--shadow-sm: 0 1px 3px color-mix(in srgb, var(--ink) 6%, transparent);--shadow-md: 0 4px 16px color-mix(in srgb, var(--ink) 8%, transparent);--shadow-lg: 0 8px 32px color-mix(in srgb, var(--ink) 12%, transparent);--shadow-glow: 0 0 40px color-mix(in srgb, var(--accent) 15%, transparent)}html.dark{--bg: #1e1c1a;--bg-raised: #292623;--bg-overlay: color-mix(in srgb, var(--bg) 85%, transparent);--ink: #e9e4dd;--ink-2: #aaa096;--ink-3: #7c7268;--rule: #3b362f;--accent: #e89a64;--accent-hover: #f2ad79;--accent-bg: #2c231b;--mist-pink: #2e2823;--sea-salt: #302b26;--caramel: #a38b7b;--glass-bg: color-mix(in srgb, var(--bg) 72%, transparent);--glass-border: color-mix(in srgb, var(--rule) 50%, transparent);--shadow-sm: 0 1px 3px rgba(0, 0, 0, .24);--shadow-md: 0 4px 16px rgba(0, 0, 0, .3);--shadow-lg: 0 8px 32px rgba(0, 0, 0, .4);--shadow-glow: 0 0 40px rgba(232, 154, 100, .12)}html[data-palette=matcha]{--bg: #f6f8f1;--bg-raised: #ecf1e3;--ink: #26302a;--ink-2: #6d7a6e;--ink-3: #a8b3a4;--rule: #dde5d4;--accent: #6f9d52;--accent-hover: #5d8943;--accent-bg: #ecf3e2;--mist-pink: #e7eddc;--sea-salt: #dfe8d3;--caramel: #7d8a68}html[data-palette=matcha].dark{--bg: #1b1f1a;--bg-raised: #252b22;--ink: #e6e9e0;--ink-2: #a3ab9c;--ink-3: #767e70;--rule: #363d33;--accent: #9cc47f;--accent-hover: #aed493;--accent-bg: #242c1f;--mist-pink: #262d22;--sea-salt: #282f24;--caramel: #98a387}html[data-palette=indigo]{--bg: #f5f7fa;--bg-raised: #eaeff5;--ink: #232a33;--ink-2: #66707d;--ink-3: #a3adb8;--rule: #dce2ea;--accent: #4a6fa5;--accent-hover: #3d5d8c;--accent-bg: #e9eff8;--mist-pink: #e2e8f0;--sea-salt: #dbe4ee;--caramel: #707c8e}html[data-palette=indigo].dark{--bg: #191d23;--bg-raised: #222831;--ink: #e4e7ec;--ink-2: #9aa4b1;--ink-3: #6d7683;--rule: #323a45;--accent: #7fa3d1;--accent-hover: #93b4dc;--accent-bg: #1f2632;--mist-pink: #232830;--sea-salt: #252b34;--caramel: #8b95a6}html[data-palette=sakura]{--bg: #faf5f6;--bg-raised: #f3eaec;--ink: #322a2c;--ink-2: #7d6e72;--ink-3: #b4a6aa;--rule: #ecdfe2;--accent: #d97387;--accent-hover: #c85d72;--accent-bg: #fbeef1;--mist-pink: #f2e2e6;--sea-salt: #eddfe3;--caramel: #96757e}html[data-palette=sakura].dark{--bg: #201b1c;--bg-raised: #2a2425;--ink: #ece5e6;--ink-2: #b0a4a7;--ink-3: #7f7276;--rule: #3c3436;--accent: #e89aab;--accent-hover: #f0aec0;--accent-bg: #2d2225;--mist-pink: #2f2729;--sea-salt: #31282a;--caramel: #a88d94}html[data-palette=violet]{--bg: #f7f5fa;--bg-raised: #eeeaf5;--ink: #2b2733;--ink-2: #6f6a7c;--ink-3: #aaa5b6;--rule: #e2ddeb;--accent: #8a6fc0;--accent-hover: #7759ae;--accent-bg: #f0eaf9;--mist-pink: #e8e2f0;--sea-salt: #e3dcee;--caramel: #7f7590}html[data-palette=violet].dark{--bg: #1c1a21;--bg-raised: #26222f;--ink: #e8e5ee;--ink-2: #a49eaf;--ink-3: #746e80;--rule: #373241;--accent: #b49ae0;--accent-hover: #c3ace8;--accent-bg: #262033;--mist-pink: #292434;--sea-salt: #2b2536;--caramel: #948caa}html[data-palette=sea]{--bg: #f3f8f7;--bg-raised: #e8f0ee;--ink: #21302d;--ink-2: #647872;--ink-3: #9fb1ac;--rule: #d9e6e3;--accent: #3d9c8f;--accent-hover: #2f8578;--accent-bg: #e5f3f0;--mist-pink: #dee9e6;--sea-salt: #d6e5e1;--caramel: #6c8680}html[data-palette=sea].dark{--bg: #181f1e;--bg-raised: #212a28;--ink: #e3eae8;--ink-2: #9aaba6;--ink-3: #6d7d79;--rule: #2f3b39;--accent: #6cc3b4;--accent-hover: #82d0c2;--accent-bg: #1d2a27;--mist-pink: #222c2a;--sea-salt: #242e2c;--caramel: #869894}html[data-palette=amber]{--bg: #faf7ef;--bg-raised: #f3eddc;--ink: #322b1e;--ink-2: #7d7360;--ink-3: #b5ab94;--rule: #eae1cb;--accent: #b97f1c;--accent-hover: #9f6a0e;--accent-bg: #f9efd8;--mist-pink: #f0e8d2;--sea-salt: #ebe2ca;--caramel: #96835c}html[data-palette=amber].dark{--bg: #201d16;--bg-raised: #2a261d;--ink: #ebe6d9;--ink-2: #b0a893;--ink-3: #7f7866;--rule: #3c3729;--accent: #e0a83e;--accent-hover: #ebba5c;--accent-bg: #2c251a;--mist-pink: #2f2a1e;--sea-salt: #312c20;--caramel: #a89a76}html[data-palette=plum]{--bg: #f9f4f5;--bg-raised: #f1e8ea;--ink: #2f2427;--ink-2: #796a6f;--ink-3: #b1a2a7;--rule: #e9dde0;--accent: #a4506b;--accent-hover: #8d3f59;--accent-bg: #f7e9ee;--mist-pink: #ecdee2;--sea-salt: #e5d8db;--caramel: #8d6f77}html[data-palette=plum].dark{--bg: #1f1a1b;--bg-raised: #292223;--ink: #eae2e4;--ink-2: #ada0a4;--ink-3: #7c6e73;--rule: #3a3133;--accent: #d98aa3;--accent-hover: #e39fb5;--accent-bg: #2b2023;--mist-pink: #2d2527;--sea-salt: #2f2729;--caramel: #a08890}html[data-palette=graphite]{--bg: #f7f7f6;--bg-raised: #eeedeb;--ink: #262626;--ink-2: #6e6e6c;--ink-3: #ababa8;--rule: #e0e0de;--accent: #3f3f3e;--accent-hover: #262626;--accentLine truncated
@@ -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
+4 -2
View File
@@ -35,7 +35,9 @@ export default defineConfig({
{ text: '瞬间', link: '/config/moments' }, { text: '瞬间', link: '/config/moments' },
{ text: '图库', link: '/config/photos' }, { text: '图库', link: '/config/photos' },
{ text: '友情链接', link: '/config/links' }, { text: '友情链接', link: '/config/links' },
{ text: '朋友圈', link: '/config/friends' }, { text: '订阅', link: '/config/subscriptions' },
{ text: '欢迎弹窗', link: '/config/welcome-popup' },
{ text: '微信读书', link: '/config/weread' },
{ text: '记得', link: '/config/important-dates' }, { text: '记得', link: '/config/important-dates' },
{ text: '留言板', link: '/config/messageboard' }, { text: '留言板', link: '/config/messageboard' },
{ text: '评论', link: '/config/comment' }, { text: '评论', link: '/config/comment' },
@@ -53,7 +55,7 @@ export default defineConfig({
{ text: '瞬间', link: '/pages/moments' }, { text: '瞬间', link: '/pages/moments' },
{ text: '图库', link: '/pages/photos' }, { text: '图库', link: '/pages/photos' },
{ text: '友情链接', link: '/pages/links' }, { text: '友情链接', link: '/pages/links' },
{ text: '朋友圈', link: '/pages/friends' }, { text: '订阅', link: '/pages/subscriptions' },
{ text: '记得', link: '/pages/important-dates' }, { text: '记得', link: '/pages/important-dates' },
{ text: '自定义页面', link: '/pages/custom-page' }, { text: '自定义页面', link: '/pages/custom-page' },
{ text: '扩展页面', link: '/pages/extended' }, { text: '扩展页面', link: '/pages/extended' },
+87 -1
View File
@@ -1,6 +1,92 @@
# 更新日志 # 更新日志
## v1.2.5 ## v1.2.7
### 文档全面校准(与最新版主题对齐)
- 🔌 **移除「朋友圈」插件的全部使用说明**:`plugin-friends` 已不再提供,原先的 feed / RSS 聚合能力
由官方 **「链接管理」(PluginLinks)插件的订阅**承担。主题的对应实现是 `page_subscriptions.html`
自定义模板与 `subscriptions` 设置分组;新增订阅配置 / 订阅页面文档,删除旧的朋友圈配置与页面文档
- 📌 主题保留 `friends.html` 模板与「朋友圈」设置分组**仅用于兼容旧站点**,`pluginFinder` 检测不到
该插件时会显示空状态;新站点请勿再依赖,文档已明确标注
- ⬆️ 全文统一 Halo 版本要求为 **2.27.0+**(`docs/guide/getting-started.md` 原写 `2.24.0`,与
`theme.yaml` 的 `requires: ">=2.27.0"` 不一致)
- 🧹 清理与代码不符的描述:已移除的光感跟随 `animation_cursor_glow`、主题并不自带的
i18n 国际化、没有对应模板的「足迹页面」;`docs/config/style.md` 补上实际存在的
`click_effect_enabled` / `click_effect_type`
- 📚 补齐此前完全没有文档的三个设置分组:**欢迎弹窗、订阅、微信读书**;自定义页面模板清单
同步为 4 个(默认 / 留言板 / 订阅 / 微信读书)
- 🔢 配置数量统一为 **17 组 / 126 项**,插件适配表按实际 **10 款**校准,VitePress 侧边栏同步调整
- 🔗 所有文档内部相对链接复核为 0 处失效
- 🧾 README 精简与重排:移除开发者自证、回归检查清单与重复的「不加载远程脚本」类宣言,
按「安装 → 功能 → 插件 → 隐私 → 开发 → 声明」重排章节(331 行 → 237 行);
5.1 逐项披露、许可表与原创声明完整保留
### 可选 API 设置项文案精简
- ✂️ 主题设置中「自定义 IP 定位主 API」「自定义 IP 定位备选 API」「自定义一言 API」
三个配置项的 `help` 移除三段冗长的隐私披露文案,仅保留功能说明(候选接口地址、留空回退规则)
- 🛡️ 第三方数据流向与隐私披露仍由 README「外部资源与隐私」章节、配置文档与 FAQ 完整承载,
满足 5.1 要求;设置界面文案精简不影响「默认零第三方请求」的结论
- ↩️ 三个字段恢复按功能开关条件显示(「启用欢迎弹窗」开关 / 「描述文案来源」),与 v1.2.6
的设计一致;同时修正三个配置块的缩进,与 `settings.yaml` 其余字段对齐
- 🔒 `pnpm audit:external` 的 5.1 检查改为:三个字段必须存在、`help` 不得回潮出现披露文案、
README 必须保留隐私披露章节
### 正文引用块留白(与上一轮改动合并)
- 🎨 修复文章 / 单页 / 留言板正文引用块文字下方空白过大:块内元素外边距与 `padding` 叠加导致
双层留白,现统一收敛,并折叠编辑器生成的空段落;引用块上下外边距同步收紧
## v1.2.6
> 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 动态拉取并执行组件)与
`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` 与预览图一并打进安装包
#### 按 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 验证
### 兼容性与元数据 ### 兼容性与元数据
+7 -5
View File
@@ -22,6 +22,8 @@
| `article_related_title` | 文本 | `相关推荐` | 相关文章标题 | | `article_related_title` | 文本 | `相关推荐` | 相关文章标题 |
| `article_show_share` | 开关 | `true` | 显示分享按钮 | | `article_show_share` | 开关 | `true` | 显示分享按钮 |
| `article_share_title` | 文本 | `分享` | 分享区域标题 | | `article_share_title` | 文本 | `分享` | 分享区域标题 |
| `article_extra_spacing` | 开关 | `true` | 中英文混排自动加空格;依赖「API 扩展包」,插件未安装时自动跳过 |
| `article_extra_highlight` | 开关 | `false` | 服务端代码高亮;依赖「API 扩展包」全量版(含 JS 运行时),开启后替代主题自带配色,环境不支持请勿开启 |
## 详细说明 ## 详细说明
@@ -84,7 +86,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 +102,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 +138,7 @@ article_related_title: "猜你喜欢"
| 平台 | 说明 | | 平台 | 说明 |
| --- | --- | | --- | --- |
| QQ | 点击后弹出 QQ 分享窗口,自动携带文章标题和链接 | | QQ | 点击后弹出 QQ 分享窗口,自动携带文章标题和链接 |
| 微信 | 扫码分享到微信(生成文章完整 URL 的二维码) | | 微信 | 弹出文章链接并提供「复制链接」按钮,由访客在微信内分享(不加载二维码库) |
| 微博 | 分享到新浪微博 | | 微博 | 分享到新浪微博 |
| X (Twitter) | 分享到 X | | X (Twitter) | 分享到 X |
| Facebook | 分享到 Facebook | | Facebook | 分享到 Facebook |
@@ -157,7 +159,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 +176,7 @@ article_share_channels:
``` ```
::: tip ::: tip
分享功能会自动携带文章标题和完整链接,无需手动配置分享内容。微信分享通过二维码实现,二维码内容为文章的完整 URL(包含域名),用户扫码后可直接打开文章。QQ 分享通过弹窗方式打开 QQ 分享页面。移动端会优先显示适合移动端的分享方式。 分享功能会自动携带文章标题和完整链接,无需手动配置分享内容。微信分享通过「复制链接」实现:弹层显示文章完整 URL(含域名),点击按钮写入剪贴板,全程在浏览器本地完成、不发送外部请求。QQ 分享通过弹窗方式打开 QQ 分享页面。除访客主动点击外,页面加载时不会访问任何第三方平台。
::: :::
## 阅读增强功能 ## 阅读增强功能
+2 -17
View File
@@ -13,7 +13,6 @@
| `label_search` | 文本 | `搜索` | 搜索按钮的标签文案 | | `label_search` | 文本 | `搜索` | 搜索按钮的标签文案 |
| `label_theme_switch` | 文本 | `切换主题` | 主题切换按钮的标签文案 | | `label_theme_switch` | 文本 | `切换主题` | 主题切换按钮的标签文案 |
| `label_archives_title` | 文本 | `归档` | 归档页面的标题文案 | | `label_archives_title` | 文本 | `归档` | 归档页面的标题文案 |
| `language` | 下拉 | `auto` | 主题界面语言 |
## 详细说明 ## 详细说明
@@ -70,22 +69,8 @@ label_archives_title: "Archives"
``` ```
::: tip ::: tip
标签文案的修改会即时生效,无需重启 Halo。如果使用多语言场景,可以配合 Halo 的多语言插件实现更完整的国际化。 标签文案的修改会即时生效,无需重启 Halo。主题**不内置界面语言切换 / 多语言包**,
::: 需要多语言站点时请使用 Halo 或相应插件提供的能力;主题内置的少量界面文案可通过上方 3 个标签项自定义。
### 语言(language)
设置主题界面的显示语言,影响导航栏、文章页、错误页等所有主题内置文案。提供以下选项:
| 值 | 说明 |
| --- | --- |
| `auto` | 跟随浏览器语言自动匹配(默认) |
| `zh_CN` | 简体中文 |
| `zh_TW` | 繁體中文 |
| `en` | English |
::: tip
选择 `auto` 时,主题会根据访客浏览器的语言设置自动匹配最接近的语言。如果浏览器语言不在支持列表中,将回退到简体中文。
::: :::
## 相关页面 ## 相关页面
+6
View File
@@ -12,6 +12,12 @@
| `footer_show_powered` | 开关 | `true` | 显示「Powered by Halo」 | | `footer_show_powered` | 开关 | `true` | 显示「Powered by Halo」 |
| `footer_show_theme` | 开关 | `true` | 显示主题版本 | | `footer_show_theme` | 开关 | `true` | 显示主题版本 |
| `footer_custom_html` | 代码 | — | 自定义 HTML(统计代码等) | | `footer_custom_html` | 代码 | — | 自定义 HTML(统计代码等) |
| `footer_show_stats` | 开关 | `true` | 在页脚显示全站统计 |
| `footer_stats_items` | 多选 | `post,category,comment,visit` | 统计项:`post` 文章 / `category` 分类 / `comment` 评论 / `visit` 访问 / `upvote` 点赞 / `word` 总字数(总字数依赖「API 扩展包」,未安装时自动隐藏) |
| `footer_show_icp` | 开关 | `false` | 显示 ICP 备案号(开启后 `footer_icp` 才会显示) |
| `footer_show_mps` | 开关 | `false` | 显示公网安备号与徽标 |
| `footer_mps` | 文本 | — | 公网安备号(需开启上方开关) |
| `footer_mps_url` | 文本 | — | 公网安备跳转链接,留空默认 `https://beian.mps.gov.cn/` |
## 详细说明 ## 详细说明
-62
View File
@@ -1,62 +0,0 @@
# 朋友圈
朋友圈模块用于聚合展示你订阅的站点最新动态,类似于 RSS 阅读器的社交化呈现。访客可以在你的站点上浏览友站最新文章,发现更多优质内容。
## 配置项一览
| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `friends_page_title` | 文本 | `朋友圈` | 页面标题 |
## 前置依赖
::: danger
朋友圈功能依赖 Halo 的 **plugin-friends** 插件。如果未安装此插件,朋友圈页面将无法正常工作。
安装方式:Halo 后台 → **插件** → **安装** → 搜索「朋友圈」或从 [GitHub Releases](https://www.halo.run/store/apps/app-yISsV) 下载安装。
:::
## 详细说明
### 页面标题(friends_page_title)
朋友圈页面的标题,显示在页面顶部。
```yaml
friends_page_title: "朋友圈" # 默认
friends_page_title: "友圈" # 简称
friends_page_title: "Friends" # 英文站点
```
## RSS 订阅说明
朋友圈的核心机制是通过 RSS 订阅聚合友站内容:
1. 在 Halo 后台的 Friends 插件设置中添加友站的 RSS 订阅地址
2. 插件会定期抓取这些 RSS 源的最新文章
3. 朋友圈页面按时间倒序展示所有订阅源的最新文章
### 添加订阅源
1. 登录 Halo 后台管理控制台
2. 进入 **链接** 管理页面
3. 点击 **新建** 或 **编辑** 填写:元数据 **RSS 地址** 友站的 RSS 订阅地址
4. 保存后,插件会在下次抓取周期自动拉取内容,亦可进入 **朋友圈** 自行同步
::: tip
大多数博客平台都提供 RSS 订阅功能。常见的 RSS 地址格式:
- WordPress:`/feed/`
- Halo:`/rss.xml` 或 `/feed/`
- Hexo:`/atom.xml` 或 `/rss2.xml`
- Typecho:`/feed/`
:::
::: warning
RSS 抓取频率取决于插件配置,通常为每 30 分钟到数小时一次。新添加的订阅源可能需要等待一个抓取周期才能在页面显示内容。
:::
## 相关页面
- [友情链接](/config/links) — 友情链接页面配置
- [页脚](/config/footer) — 页脚社交链接配置
- [朋友圈页面](/pages/friends) — 朋友圈模板与结构说明
+26 -12
View File
@@ -9,9 +9,9 @@ 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 | `https://v1.hitokoto.cn/` | 一言 API 地址 | | `hero_hitokoto_api` | URL | `https://v1.hitokoto.cn/` | 默认预填官方接口(描述来源非「无」时显示,仅「一言」模式生效);清空则使用内置本地句子库(零外部请求) |
| `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` | 一言刷新间隔(秒) |
@@ -44,29 +44,43 @@ hero_subtitle: "记录温暖与灵感"
### 描述文案来源(hero_description_mode) ### 描述文案来源(hero_description_mode)
控制 Hero 区域主标题下方显示的描述文案,提供三种模式: 控制 Hero 区域主标题下方显示的描述文案,提供三种模式,**默认为「无」**:
| 值 | 说明 | | 值 | 说明 |
| --- | --- | | --- | --- |
| `none` | 不显示描述文案 | | `none` | **默认**。不显示描述文案,不渲染一言元素,也不发起任何请求 |
| `hitokoto` | 从一言 API 实时获取随机句子(默认) | | `hitokoto` | 显示一句话文案;API 字段默认预填官方接口 `https://v1.hitokoto.cn/`,清空该字段则使用内置本地句子库 |
| `custom` | 显示自定义的描述文案 | | `custom` | 显示自定义的描述文案(静态文本,不发起任何请求) |
### 一言模式(hitokoto) ### 一言模式(hitokoto)
选择 `hitokoto` 模式后,Hero 区域会从一言 API 获取随机句子,每次刷新页面都会展示不同的内容。 默认 `none` 时一言元素不渲染。「自定义一言 API」设置项在描述来源非「无」时显示,并默认预填
`https://v1.hitokoto.cn/`;切换到 `hitokoto` 后该值生效,浏览器会向该地址发起 GET 请求
获取句子(第三方可见访客 IP / User-Agent)。
若把该字段清空,则回退到主题内置的本地句子库 `assets/data/hitokoto.json`
(随主题自托管的静态 JSON,零外部请求),每次刷新随机展示一条,也不会因为第三方接口不可用而出现空白。
可直接编辑该 JSON 替换成自己喜欢的句子。
#### 一言 API 地址(hero_hitokoto_api) #### 自定义一言 API(hero_hitokoto_api)
默认使用官方 API `https://v1.hitokoto.cn/`。如果你部署了一言的私有实例,可以修改为自建 API 地址。 该设置项在「描述文案来源」非「无」时显示,`help` 只说明候选地址与留空回退规则;
字段默认预填 `https://v1.hitokoto.cn/`,**仅在「一言」模式下生效**。
**选择「一言」或「自定义」后若把该字段留空,则使用主题内置的本地句子库(零外部请求)**。
只有填写后才会向该地址发起请求,第三方可见访客 IP / User-Agent;完整的数据流向与隐私披露
见 README「外部资源与隐私」章节。也可换成自建实例或其他兼容接口。
::: tip ::: tip
自建一言 API 可以提升加载速度并避免第三方服务不稳定的问题。部署方式请参考 [hitokoto 官方文档](https://developer.hitokoto.cn/)。 「描述文案来源」默认「无」时一言元素不渲染,因此即使字段预填了官方地址,
也不会产生任何外部请求。隐私披露统一在 README 与本文档说明,设置界面不重复展示。
:::
::: warning
这是站点管理员主动开启的可选能力。把描述文案来源改回「无」、或清空该字段即可随时停止外部请求,无需重装主题。
::: :::
#### 一言句子类型(hero_hitokoto_categories) #### 一言句子类型(hero_hitokoto_categories)
通过勾选来筛选获取的句子类型,支持多选: 仅在填写了自定义 API 时生效,用于筛选该接口返回的句子类型,支持多选:
| 值 | 类型 | 说明 | | 值 | 类型 | 说明 |
| --- | --- | --- | | --- | --- | --- |
+9 -1
View File
@@ -6,8 +6,12 @@
| 配置项 | 类型 | 默认值 | 说明 | | 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `links_apply_form_enabled` | 开关 | `true` | 页底显示友链申请表单;需插件开启「友链申请」,否则自动隐藏 |
| `links_page_title` | 文本 | `友情链接` | 页面标题 | | `links_page_title` | 文本 | `友情链接` | 页面标题 |
| `links_style` | 下拉 | `cards` | 展示样式:`grid` / `list` / `cards` | | `links_style` | 下拉 | `cards` | 展示样式:`grid` / `list` / `cards` |
| `links_show_comment` | 开关 | `false` | 页面底部显示评论区 |
| `links_random_order` | 开关 | `false` | 友链卡片随机排列 |
| `links_random_portal` | 开关 | `true` | 页面右上角显示「随机传送」按钮 |
## 前置依赖 ## 前置依赖
@@ -15,6 +19,9 @@
友情链接功能依赖 Halo 的 **PluginLinks** 插件。如果未安装此插件,友情链接页面将无法正常工作。 友情链接功能依赖 Halo 的 **PluginLinks** 插件。如果未安装此插件,友情链接页面将无法正常工作。
安装方式:Halo 后台 → **插件** → **安装** → 搜索「链接管理」→ 安装并启用。 安装方式:Halo 后台 → **插件** → **安装** → 搜索「链接管理」→ 安装并启用。
同一个插件还提供 **RSS 订阅能力**(原「朋友圈」插件的 feed 聚合功能已由它取代),
对应主题的[订阅配置](/config/subscriptions)与[订阅页面](/pages/subscriptions)。
::: :::
## 详细说明 ## 详细说明
@@ -65,6 +72,7 @@ PluginLinks 支持链接分组功能。你可以将友链按不同类别分组
## 相关页面 ## 相关页面
- [朋友圈](/config/friends) — 朋友圈页面配置 - [订阅配置](/config/subscriptions) — 同一插件提供的 RSS 订阅动态
- [订阅页面](/pages/subscriptions) — 博友文章动态页面
- [页脚](/config/footer) — 页脚社交链接配置 - [页脚](/config/footer) — 页脚社交链接配置
- [友情链接页面](/pages/links) — 友链模板与结构说明 - [友情链接页面](/pages/links) — 友链模板与结构说明
+1
View File
@@ -10,6 +10,7 @@
| `moments_style` | 下拉 | `cards` | 展示样式:`cards` / `masonry` | | `moments_style` | 下拉 | `cards` | 展示样式:`cards` / `masonry` |
| `moments_loading_mode` | 下拉 | `infinite_scroll` | 加载方式:`infinite_scroll` / `pagination` | | `moments_loading_mode` | 下拉 | `infinite_scroll` | 加载方式:`infinite_scroll` / `pagination` |
| `moments_page_size` | 数字 | `10` | 每页显示数量 | | `moments_page_size` | 数字 | `10` | 每页显示数量 |
| `moments_cover` | 附件 | — | 瞬间页顶部封面图,建议尺寸 1500×500 |
## 前置依赖 ## 前置依赖
+3
View File
@@ -9,6 +9,9 @@
| `navbar_style` | 下拉 | `glass` | 导航栏样式:`glass` / `minimal` / `float` | | `navbar_style` | 下拉 | `glass` | 导航栏样式:`glass` / `minimal` / `float` |
| `navbar_show_search` | 开关 | `true` | 显示搜索按钮 | | `navbar_show_search` | 开关 | `true` | 显示搜索按钮 |
| `navbar_show_theme_switch` | 开关 | `true` | 显示主题切换按钮 | | `navbar_show_theme_switch` | 开关 | `true` | 显示主题切换按钮 |
| `navbar_brand_type` | 下拉 | `title` | 左侧显示:`title` 站点名称 / `logo` Logo |
| `navbar_logo` | 附件 | — | 导航栏 Logo;仅在「Logo」模式下显示,留空回退到基础设置的自定义 Logo |
| `navbar_logo_radius` | 下拉 | `rounded` | Logo 圆角:`square` / `rounded` / `circle`(Logo 模式下生效) |
## 详细说明 ## 详细说明
+6 -7
View File
@@ -14,7 +14,8 @@
| `animation_enabled` | 开关 | `true` | 全局动画总开关 | | `animation_enabled` | 开关 | `true` | 全局动画总开关 |
| `animation_breath` | 开关 | `true` | 呼吸动画效果 | | `animation_breath` | 开关 | `true` | 呼吸动画效果 |
| `animation_scroll_reveal` | 开关 | `true` | 滚动渐入动画 | | `animation_scroll_reveal` | 开关 | `true` | 滚动渐入动画 |
| `animation_cursor_glow` | 开关 | `false` | 光感跟随效果 | | `click_effect_enabled` | 开关 | `false` | 鼠标点击效果总开关 |
| `click_effect_type` | 下拉 | `values` | 点击效果类型(8 种效果 + 关闭,仅在总开关开启时显示) |
| `custom_css` | 代码 | — | 自定义 CSS 代码 | | `custom_css` | 代码 | — | 自定义 CSS 代码 |
## 详细说明 ## 详细说明
@@ -121,13 +122,11 @@ WarmIsland 提供了丰富的动画效果,所有动画都可以独立开关:
滚动渐入动画基于 Intersection Observer API 实现,对性能影响极小。如果页面内容较多,建议保持开启以提升浏览体验。 滚动渐入动画基于 Intersection Observer API 实现,对性能影响极小。如果页面内容较多,建议保持开启以提升浏览体验。
::: :::
#### 光感跟随(animation_cursor_glow) #### 鼠标点击效果(click_effect_enabled / click_effect_type)
启用后,鼠标移动时会在光标附近产生柔和的光晕跟随效果,为页面增添灵动感。 `click_effect_enabled` 默认关闭;开启后 `click_effect_type` 才会显示,可选 8 种效果:
社会主义核心价值观、随机彩色爱心、烟花、烟花波纹、粒子、星星、星星拖尾、粒子拖尾,
::: warning 另可选「关闭」。特效在访客点击时由浏览器本地绘制,不加载远程脚本、不发送任何数据。
光感跟随效果在移动端不会生效(因为移动端没有鼠标光标)。在低端设备上可能会影响渲染性能,如遇卡顿建议关闭。
:::
### 自定义 CSS(custom_css) ### 自定义 CSS(custom_css)
+59
View File
@@ -0,0 +1,59 @@
# 订阅
订阅模块用于展示博友们的最新文章动态(feed / RSS 聚合)。这项能力由 **Halo 官方「链接管理」(PluginLinks)插件的订阅**提供,主题只负责把插件抓取到的订阅动态渲染成页面。
> `plugin-friends`(朋友圈)插件已不再提供,原来的 feed 聚合功能由链接管理插件的订阅取代。
> 主题中的 `friends.html` 模板与「朋友圈」设置分组仅用于兼容旧站点,新站点请使用订阅页面模板,
> 详见[历史兼容说明](#历史兼容说明)。
## 配置项一览
| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `subscriptions_page_title` | 文本 | `订阅` | 页面标题 |
| `subscriptions_subtitle` | 文本 | `博友们的最新文章` | 页面副标题 |
| `subscriptions_page_size` | 数字 | `20` | 每页展示的动态条数,范围 1–100 |
| `subscriptions_loading_mode` | 下拉 | `pagination` | `pagination` 分页 / `infinite_scroll` 下滑加载 |
| `subscriptions_layout` | 下拉 | `list` | `list` 列表卡片 / `grid` 网格卡片 |
## 前置依赖
::: danger
订阅页面依赖 Halo 官方 **「链接管理」(PluginLinks)** 插件的 RSS 订阅能力,
可在[应用市场](https://www.halo.run/store/apps/app-hfbQg)安装。未安装或未启用订阅时,
页面会显示空状态提示,不会报错。
:::
订阅源由链接管理插件维护(为博友链接配置 RSS 订阅地址、按插件自身的周期抓取),
主题通过 Halo 的 `linkFeedFinder` 读取已抓取的动态列表,不自行抓取、不缓存、不向第三方发起请求。
订阅源的添加入口与抓取频率以所安装插件版本的说明为准。
## 详细说明
### 加载方式(subscriptions_loading_mode)
- `pagination`:页面底部显示「较新 / 较旧」翻页链接,按时间游标分页。
- `infinite_scroll`:滚动到列表底部时自动加载下一页,并显示加载状态与「没有更多了」提示。
### 布局样式(subscriptions_layout)
- `list`:单列宽卡片,默认布局,适合长摘要阅读。
- `grid`:一行三列(窄屏自动降为两列 / 一列),卡片内容垂直排列。
两种方式下每条动态都展示作者头像 / 名称、发布日期、文章标题与摘要,标题与作者链接均带
`rel="noopener noreferrer"`;作者名为空时会显示占位字符,不会因字符串截取报错。
## 历史兼容说明
主题仍保留 `templates/friends.html` 与设置中的「朋友圈」分组(`friends_page_title`),
它们只读取旧 `plugin-friends` 插件的数据。该插件已不再提供,因此:
- 新站点**不要**再创建 `/friends` 页面或依赖这个分组;
- 旧站点若已停用该插件,`friends.html` 会显示空状态而不会报错;
- 需要 feed 聚合时,请改用 `page_subscriptions.html` 订阅模板。
## 相关页面
- [订阅页面](/pages/subscriptions) — 页面结构、模板选择与迁移说明
- [友情链接配置](/config/links) — 同一插件的友链展示配置
- [自定义页面](/pages/custom-page) — 全部自定义页面模板
+44
View File
@@ -0,0 +1,44 @@
# 欢迎弹窗
欢迎弹窗在首页以浮层形式向访客打招呼。**默认关闭**,开启后也只显示主题内置的本地静态欢迎语,
不会自动向任何第三方发起请求。
## 配置项一览
| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `welcome_popup_enabled` | 开关 | `false` | 启用欢迎弹窗 |
| `welcome_popup_position` | 下拉 | `top-center` | 弹窗位置,共 5 种 |
| `welcome_popup_duration` | 数字 | `5` | 自动消失时间(秒),范围 3–60 |
| `welcome_popup_show_once` | 开关 | `true` | 同一浏览器会话内仅显示一次 |
| `welcome_popup_api` | URL | 留空 | 自定义 IP 定位主 API(可选) |
| `welcome_popup_fallback_api` | URL | 留空 | 自定义 IP 定位备选 API(可选) |
## 弹窗位置
| 值 | 位置 |
| --- | --- |
| `top-center` | 顶端居中(默认) |
| `top-left` / `top-right` | 左上 / 右上 |
| `bottom-left` / `bottom-right` | 左下 / 右下 |
`welcome_popup_show_once` 使用浏览器 `sessionStorage` 记录同会话已显示状态;
关闭该开关后每次访问首页都会弹出。清除浏览器站点数据即可重置。
## 可选的 IP 定位能力(默认关闭)
两个 API 字段仅在「启用欢迎弹窗」开关打开时显示,`help` 只说明候选地址与留空规则;
第三方数据流向与隐私披露由本文档下方条目及 README「外部资源与隐私」章节承载。
字段**默认留空**,此时弹窗只显示本地欢迎语,不发起任何外部请求。
只有管理员显式填写后,浏览器才会向该地址请求并把**访客 IP**暴露给第三方服务以换取粗略地理位置:
- 候选主 / 备选接口:`https://myip.ipip.net/json`、`https://r.inews.qq.com/api/ip2city?otype=json`
- 数据流向与隐私披露要求见 README「外部资源与隐私」章节
- 清空字段或关闭弹窗即可立即停止,无需重装主题
> 主题不预置、不代理这些接口,也不保存访客位置数据;填写前请自行评估服务商的隐私政策。
## 相关页面
- [基础设置](/config/basic) — 站点名称、Logo 等
- [首页](/pages/home) — 首页模块说明
+30
View File
@@ -0,0 +1,30 @@
# 微信读书
微信读书模块用于展示书架与阅读记录,主题提供 `weread.html` 自定义页面模板,
数据由[微信读书插件](https://github.com/yeora26/halo-weread-plugin)提供,访客无需登录即可查看。
::: info
该页面依赖 Halo **2.27+** 的能力;主题声明的兼容范围为 `>=2.27.0`。
:::
## 配置项一览
| 配置项 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `weread_page_title` | 文本 | — | 页面标题(顶部标题与浏览器标签页标题);插件自带的标题区会自动隐藏以避免重复 |
| `weread_page_subtitle` | 文本 | — | 页面副标题 |
留空时由模板回退到默认文案,不会导致页面报错。
## 使用方式
1. 安装并启用微信读书插件
2. Halo 后台 → **页面** → **新建** → 自定义模板选择「**微信读书**」(`WeRead`)
3. 发布页面并加入导航菜单
页面地址由创建页面时设定的 slug 决定,主题不提供固定路由。
## 相关页面
- [自定义页面](/pages/custom-page) — 全部自定义页面模板
- [扩展页面](/pages/extended) — 插件前台页面一览
+52 -57
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 配置
``` ```
## 技术栈 ## 技术栈
@@ -160,7 +154,7 @@ WarmIsland/
| 技术 | 版本 | 用途 | | 技术 | 版本 | 用途 |
|------|------|------| |------|------|------|
| [Halo](https://www.halo.run/) | 2.27+ | 博客运行时,提供 Finder API 与主题渲染 | | [Halo](https://www.halo.run/) | 2.27.0+ | 博客运行时,提供 Finder API 与主题渲染 |
| [Astro](https://astro.build) | 6.x | 组件开发,构建输出静态 `.html` 模板 | | [Astro](https://astro.build) | 6.x | 组件开发,构建输出静态 `.html` 模板 |
| [Thymeleaf](https://www.thymeleaf.org/) | - | Halo 服务端模板引擎(源码里直接写 `th:*`) | | [Thymeleaf](https://www.thymeleaf.org/) | - | Halo 服务端模板引擎(源码里直接写 `th:*`) |
@@ -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 与可选插件提供 |
### 构建工具 ### 构建工具
+2 -1
View File
@@ -202,6 +202,7 @@ else if (!stored && prefersDark) { isDark = true; }
| `animation_enabled` | 启用动效 | `true` | | `animation_enabled` | 启用动效 | `true` |
| `animation_breath` | 呼吸动画 | `true` | | `animation_breath` | 呼吸动画 | `true` |
| `animation_scroll_reveal` | 滚动渐入 | `true` | | `animation_scroll_reveal` | 滚动渐入 | `true` |
| `animation_cursor_glow` | 光感跟随 | `false` | | `click_effect_enabled` | 鼠标点击效果 | `false` |
| `click_effect_type` | 点击效果类型(8 种 + 关闭) | `values` |
关闭动效时,`html` 元素添加 `wi-no-animations` 类名。 关闭动效时,`html` 元素添加 `wi-no-animations` 类名。
+49 -8
View File
@@ -116,15 +116,54 @@ v1.2.1 起安装包会保留各版本的哈希资源(v1.1.7 之后全部覆盖
| 瞬间 | [PluginMoments](https://www.halo.run/store/apps/app-SnwWD) | | 瞬间 | [PluginMoments](https://www.halo.run/store/apps/app-SnwWD) |
| 图库 | [PluginPhotos](https://www.halo.run/store/apps/app-BmQJW) | | 图库 | [PluginPhotos](https://www.halo.run/store/apps/app-BmQJW) |
| 友链 | [PluginLinks](https://www.halo.run/store/apps/app-hfbQg) | | 友链 | [PluginLinks](https://www.halo.run/store/apps/app-hfbQg) |
| 朋友圈 | [PluginFriends](https://www.halo.run/store/apps/app-yISsV) | | 订阅动态 | 同为 [PluginLinks](https://www.halo.run/store/apps/app-hfbQg) 的 RSS 订阅能力,需选择「订阅」自定义模板 |
安装并启用插件后,页面会自动显示内容。未安装插件时页面会显示友好的提示信息。 安装并启用插件后,页面会自动显示内容。未安装插件时页面会显示友好的提示信息。
### 一言 API 加载失败? **「朋友圈」插件已不再提供**:feed / RSS 聚合已由官方「链接管理」插件的订阅取代,
主题保留 `friends.html` 模板与「朋友圈」设置分组仅用于兼容旧站点。新站点请新建页面并选择
「订阅」自定义模板,详见 README「自定义页面模板」与[订阅页面](https://nxxy335.top/WarmIsland/pages/subscriptions.html)。
1. 检查网络是否能访问 `https://v1.hitokoto.cn/` ### 原来的「朋友圈」页面不能用了,怎么迁移?
2. 如果在国内服务器,可能存在网络延迟
3. 可以在主题设置中更换一言 API 地址为自建镜像 1. 安装 / 启用官方「链接管理」(PluginLinks)插件,把 RSS 订阅源迁到该插件的订阅中
2. Halo 后台 → 页面 → 新建 → 自定义模板选择「订阅」(`page_subscriptions.html`)
3. 在主题设置 → 订阅中调整每页条数、分页 / 下滑加载与列表 / 网格布局
4. 确认动态正常展示后,删除旧的 `/friends` 页面与菜单项
旧的 `friends.html` 检测不到 `plugin-friends` 时只会显示空状态,不会影响站点其他页面。
### 主题会向第三方服务发请求吗?
**默认不会。** 所有脚本、样式、字体、图标、主题 Logo 与一言句子库都随主题自托管,
页面不预连接任何 CDN,也不加载远程脚本;Hero「描述文案来源」默认为「无」,欢迎弹窗默认关闭。
只有两类例外:
1. 访客**主动点击**分享按钮 / 备案链接 / 应用市场链接时才跳转到对应站点;
2. 站点管理员在主题设置里**显式填写**了「自定义一言 API」或「自定义 IP 定位 API」——
后者会把访客 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. Hero「描述文案来源」**默认为「无」**,此时不显示描述文案、不渲染一言元素,零外部请求;
想显示句子请改为「一言」
2. 「自定义一言 API」字段在「描述文案来源」非「无」时显示,且默认预填 `https://v1.hitokoto.cn/`,
选择「一言」后该值才生效并产生外部请求;
**把该字段清空即回退到主题内置的 `assets/data/hitokoto.json` 本地句子库(零外部请求)**,
直接编辑该 JSON 即可替换文案。只有填写后才会向该地址发起请求,数据流向与披露要求见
README「外部资源与隐私」
3. 「自定义」模式显示「自定义描述文案」中的静态文本,不发起任何请求;
「句子类型」筛选仅对填写了 API 的一言模式生效
### 文章目录不显示? ### 文章目录不显示?
@@ -135,9 +174,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),以便插件识别语言
## 样式相关 ## 样式相关
+20 -6
View File
@@ -8,13 +8,15 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
- 🌙 **深色模式** — 跟随系统/浅色/深色三种方案,平滑切换 - 🌙 **深色模式** — 跟随系统/浅色/深色三种方案,平滑切换
- 📱 **响应式布局** — 桌面到手机完美适配 - 📱 **响应式布局** — 桌面到手机完美适配
- 📝 **沉浸阅读** — 侧边目录、进度条、代码高亮、图片灯箱 - 📝 **沉浸阅读** — 侧边目录、进度条、代码高亮、图片灯箱
- 🔌 **插件生态** — 瞬间、图库、友链、朋友圈、豆瓣、装备、Steam - 🔌 **插件生态** — 瞬间、图库、友链、订阅动态、豆瓣、装备、Steam
- ⚙️ **灵活配置** — 全维度自定义面板,无需改代码 - 🔒 **默认零第三方请求** — 静态资源全部自托管,可选 API 默认留空
- ⚙️ **灵活配置** — 17 个分组 / 126 个配置项,无需改代码
## 前置要求 ## 前置要求
- Halo >= 2.24.0 - **Halo >= 2.27.0**(主题 `theme.yaml` 声明 `requires: ">=2.27.0"`;页面布局契约与微信读书页面依赖 2.27 的能力)
- 已安装并运行 Halo 实例 - 已安装并运行 Halo 实例
- 如需插件页面(瞬间、图库、友链、订阅等),另需安装对应插件
## 安装方式 ## 安装方式
@@ -33,6 +35,11 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
4. 上传下载的 `.zip` 文件 4. 上传下载的 `.zip` 文件
5. 点击 **启用** 激活主题 5. 点击 **启用** 激活主题
::: warning
从源码自行打包时,必须先执行 `pnpm release`(build → snapshot → package)。
只上传源码或漏跑构建的 ZIP 会因缺少 `templates/` 导致首页 404,详见 [FAQ](/faq)。
:::
## 初始配置 ## 初始配置
安装并启用主题后,建议按以下顺序进行初始配置: 安装并启用主题后,建议按以下顺序进行初始配置:
@@ -49,8 +56,8 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
进入 **样式设置**: 进入 **样式设置**:
- 选择 **配色方案**(跟随系统/浅色/深色) - 选择 **配色方案**(跟随系统/浅色/深色)与 **主题配色**(11 套预设)
- 设置 **强调色**(默认 `#d4764e` 暖橘色) - 设置 **强调色**(留空则跟随所选配色,暖屿·赤陶为 `#d4764e`)
- 选择 **圆角风格** 和 **容器宽度** - 选择 **圆角风格** 和 **容器宽度**
### 第三步:导航菜单 ### 第三步:导航菜单
@@ -64,7 +71,7 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
进入 **Hero 首屏** 设置: 进入 **Hero 首屏** 设置:
- 设置主标题和副标题 - 设置主标题和副标题
- 选择描述文案来源(一言/自定义/无) - 选择描述文案来源(默认「无」/ 一言 / 自定义);默认不显示描述、不发起请求;「自定义一言 API」字段在来源非「无」时显示并默认预填 `https://v1.hitokoto.cn/`(仅「一言」模式生效),清空该字段则使用内置本地句子库(零外部请求)
- 可选上传背景图片 - 可选上传背景图片
### 第五步:页脚信息 ### 第五步:页脚信息
@@ -75,7 +82,14 @@ WarmIsland(暖屿)是一款为 [Halo](https://halo.run) 打造的博客主
- 添加社交链接(GitHub、微博等) - 添加社交链接(GitHub、微博等)
- 可选添加自定义 HTML(统计代码等) - 可选添加自定义 HTML(统计代码等)
### 可选:订阅动态页面
如需展示博友文章动态,安装官方「链接管理」(PluginLinks)插件并维护 RSS 订阅源,
再新建一个页面选择「订阅」自定义模板即可。原先的「朋友圈」插件已不再提供,
详见[订阅页面](/pages/subscriptions)。
## 下一步 ## 下一步
- [基础设置](/config/basic) — 完整的主题配置说明 - [基础设置](/config/basic) — 完整的主题配置说明
- [样式设置](/config/style) — 调整配色、圆角、动画 - [样式设置](/config/style) — 调整配色、圆角、动画
- [订阅配置](/config/subscriptions) — feed 聚合动态设置
+34 -20
View File
@@ -2,10 +2,15 @@
**WarmIsland(暖屿)** 是一款为 [Halo](https://halo.run) 打造的博客主题。暖如灯火,静如留白——它以柔和的色调和舒缓的节奏回归内容本身,为记录与阅读留出一方安静从容的角落。 **WarmIsland(暖屿)** 是一款为 [Halo](https://halo.run) 打造的博客主题。暖如灯火,静如留白——它以柔和的色调和舒缓的节奏回归内容本身,为记录与阅读留出一方安静从容的角落。
> 本主题由 AI 开发完成。 > 本项目使用 AI 辅助开发,产出已由开发者完成代码审查、构建产物核验与 Halo 开发生态适配。
它的名字来源于一个画面——深夜里温暖的小岛。我们希望每一个来到你站点的访客,都能感受到那份安静而舒适的氛围。 它的名字来源于一个画面——深夜里温暖的小岛。我们希望每一个来到你站点的访客,都能感受到那份安静而舒适的氛围。
## 环境要求
- Halo **2.27.0 及以上**(`theme.yaml` 的 `requires` 为 `>=2.27.0`):页面布局契约、微信读书页面均依赖 2.27 的能力
- v1.2.0 起内置主题配色系统(纯 CSS 变量实现);更低版本的 Halo 请使用主题 v1.1.x
## 设计理念 ## 设计理念
暖屿的设计围绕三个核心词展开:**温暖、沉浸、精致**。 暖屿的设计围绕三个核心词展开:**温暖、沉浸、精致**。
@@ -28,19 +33,20 @@
- **暖色调设计语言** — 橘色强调色 + 呼吸光球 + 渐变背景 - **暖色调设计语言** — 橘色强调色 + 呼吸光球 + 渐变背景
- **三种导航栏样式** — Glass(毛玻璃)/ Minimal(极简)/ Float(浮动胶囊) - **三种导航栏样式** — Glass(毛玻璃)/ Minimal(极简)/ Float(浮动胶囊)
- **内置配色系统** — 11 套预设调色板,每套含浅色 / 深色完整色板
- **深色模式** — 跟随系统、浅色、深色三种方案,一键切换平滑过渡 - **深色模式** — 跟随系统、浅色、深色三种方案,一键切换平滑过渡
- **动效系统** — 滚动渐入、光感跟随、呼吸动画,均可独立开关 - **动效系统** — 滚动渐入、呼吸动画、点击特效,均可独立开关
### 📝 阅读体验 ### 📝 阅读体验
- **文章目录** — 桌面端侧边悬浮 + 移动端底部抽屉,支持展开模式选项 - **文章目录** — 桌面端侧边悬浮 + 移动端底部抽屉,支持展开模式选项
- **阅读进度条** — 实时显示阅读进度 - **阅读进度条** — 实时显示阅读进度
- **代码高亮** — Prism.js 支持,Warm / Cold 双主题 - **代码高亮** — 由代码高亮插件 / API 扩展包输出,主题提供 Warm / Cold 本地配色,不加载远程脚本
- **图片灯箱** — 点击放大,支持左右切换 - **图片灯箱** — 点击放大,支持左右切换与 EXIF 信息
- **图片懒加载** — blur-up 渐显效果,优化加载体验 - **图片懒加载** — blur-up 渐显效果,优化加载体验
- **点赞功能** — 一键点赞,状态持久化 - **点赞功能** — 一键点赞,状态持久化
- **相关文章推荐** — 文章底部推荐相关内容 - **相关文章推荐** — 文章底部推荐相关内容
- **分享功能** — 微博 / X / Facebook / Telegram / 复制链接 - **分享功能** — QQ / 微博 / X / Facebook / Telegram / 复制文章链接
### 📱 响应式 ### 📱 响应式
@@ -49,11 +55,10 @@
- 文章卡片自动切换为上下布局 - 文章卡片自动切换为上下布局
- 目录自动切换为悬浮按钮 + 抽屉 - 目录自动切换为悬浮按钮 + 抽屉
### 🔍 SEO 与国际化 ### 🔍 SEO 与结构化数据
- **Open Graph 元标签** — 优化社交媒体分享预览 - **Open Graph 元标签** — 优化社交媒体分享预览
- **JSON-LD 结构化数据** — 提升搜索引擎理解与展示 - **JSON-LD 结构化数据** — 文章标题 / 作者 / 日期 / 封面,由转义后的 `<meta>` 生成,不拼接不可信字符串
- **i18n 国际化** — 简体中文、繁体中文、英语,支持跟随浏览器自动匹配
### 🔌 插件生态 ### 🔌 插件生态
@@ -64,30 +69,39 @@
| 瞬间 | PluginMoments | Timeline / Cards / Masonry 三种布局 | | 瞬间 | PluginMoments | Timeline / Cards / Masonry 三种布局 |
| 图库 | PluginPhotos | Masonry / Grid / Carousel + 分组浏览 | | 图库 | PluginPhotos | Masonry / Grid / Carousel + 分组浏览 |
| 友情链接 | PluginLinks | Cards / Grid / List 三种布局 | | 友情链接 | PluginLinks | Cards / Grid / List 三种布局 |
| 朋友圈 | plugin-friends | 友站动态时间线 | | 订阅 | PluginLinks(RSS 订阅) | 博友文章动态,分页 / 下滑加载、列表 / 网格布局 |
| 豆瓣 | 豆瓣插件 | 观影/读书/音乐记录 | | 豆瓣 | 豆瓣插件 | 观影/读书/音乐记录 |
| 装备 | 装备插件 | 按分组展示装备清单 | | 装备 | 装备插件 | 按分组展示装备清单 |
| Steam | Steam 插件 | 游戏资料与徽章 | | Steam | Steam 插件 | 游戏资料与徽章 |
| 记得 | plugin-important-dates | 重要日期 / 牵挂的人 / 爱车双视图 |
> 「朋友圈」(`plugin-friends`)插件已不再提供,其 feed 聚合能力由官方「链接管理」插件的订阅取代,
> 详见[订阅页面](/pages/subscriptions)。
### ⚙️ 灵活配置 ### ⚙️ 灵活配置
13 个配置分组,覆盖站点的方方面面: 17 个配置分组、126 个配置项,覆盖站点的方方面面:
- 基础信息、样式、导航栏、Hero 首屏 - 基础信息、样式、导航栏、Hero 首屏、欢迎弹窗
- 首页、文章、瞬间、图库、友情链接 - 首页、文章、瞬间、图库、友情链接、订阅
- 朋友圈、留言板、评论、页脚 - 微信读书、记得、留言板、评论、页脚
所有配置都在 Halo 后台的图形化面板中完成,无需修改代码。 所有配置都在 Halo 后台的图形化面板中完成,无需修改代码。
### 🔒 默认零第三方请求
全部脚本、样式、字体、图标、句子库与主题 Logo 随主题自托管;Hero 描述文案默认「无」、
欢迎弹窗默认关闭、两个可选 API 字段默认留空,页面不会自动向第三方发起请求。
详见 README「外部资源与隐私」。
## 技术栈 ## 技术栈
- **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
## 适用场景 ## 适用场景
@@ -96,7 +110,7 @@ WarmIsland 适合以下类型的站点:
- 📖 **个人博客** — 记录生活、分享思考 - 📖 **个人博客** — 记录生活、分享思考
- 📸 **摄影站** — 图库页面展示作品 - 📸 **摄影站** — 图库页面展示作品
- 🎮 **游戏博客** — Steam 页面展示游戏资料 - 🎮 **游戏博客** — Steam 页面展示游戏资料
- 📚 **读书笔记** — 豆瓣页面展示阅读记录 - 📚 **读书笔记** — 豆瓣 / 微信读书页面展示阅读记录
- 💭 **碎碎念** — 瞬间页面记录日常灵感 - 💭 **碎碎念** — 瞬间页面记录日常灵感
## 下一步 ## 下一步
+1 -1
View File
@@ -26,7 +26,7 @@ features:
- title: 📝 沉浸阅读 - title: 📝 沉浸阅读
details: 文章目录侧边悬浮、阅读进度条、代码高亮、图片灯箱,打造专注的阅读体验 details: 文章目录侧边悬浮、阅读进度条、代码高亮、图片灯箱,打造专注的阅读体验
- title: 🔌 插件生态 - title: 🔌 插件生态
details: 深度集成瞬间、图库、友情链接、朋友圈、豆瓣、装备、Steam 等插件页面 details: 深度集成瞬间、图库、友情链接、订阅动态、豆瓣、装备、Steam 等插件页面
- title: ⚙️ 灵活配置 - title: ⚙️ 灵活配置
details: 丰富的主题设置面板,涵盖样式、导航、首页、文章、评论等全维度自定义 details: 丰富的主题设置面板,涵盖样式、导航、首页、文章、评论等全维度自定义
--- ---
+30 -13
View File
@@ -1,32 +1,49 @@
# 自定义页面 # 自定义页面
自定义页面是 Halo 的独立页面功能,用于创建关于、友链等独立内容页面。 自定义页面是 Halo 的独立页面功能,用于创建关于、留言板、订阅动态、微信读书等独立内容页面。
## 使用方式 ## 使用方式
1. 进入 Halo 后台 → **页面** → **新建** 1. 进入 Halo 后台 → **页面** → **新建**
2. 填写页面标题和内容 2. 填写页面标题和内容
3. 可选择自定义模板 3. 可选:在「自定义模板」中选择主题提供的模板
4. 发布页面 4. 发布页面,并把页面加入导航菜单
页面地址由创建时设定的 slug 决定,主题不为自定义模板提供固定路由。
## 页面模板 ## 页面模板
主题提供以下自定义页面模板: `theme.yaml` 中注册了 3 个自定义模板,加上默认的 `page.html` 共 4 种:
| 模板 | 文件 | 说明 | | 模板 | 模板文件 | 说明 |
|------|------|------| |------|---------|------|
| 默认 | page.html | 标准内容页面 | | 默认 | `page.html` | 标准内容页面 |
| 留言板 | page_messageboard.html | 带评论的留言板页面 | | 留言板(MessageBoard) | `page_messageboard.html` | 带评论的留言墙,标题与副标题可配置 |
| 订阅(Subscriptions) | `page_subscriptions.html` | 博友文章订阅动态,依赖官方「链接管理」插件的 RSS 订阅 |
| 微信读书(WeRead) | `weread.html` | 微信读书书架,依赖微信读书插件,访客免登录查看 |
### 留言板模板 ### 留言板模板
选择留言板模板后,页面会自动集成评论组件,访客可以直接留言。标题和副标题使用主题设置中的配置。 选择留言板模板后,页面会集成评论组件,访客可以直接留言。标题和副标题在
**主题设置 → 留言板** 中配置,详见[留言板配置](/config/messageboard)。
使用步骤: ### 订阅模板
1. 新建页面,选择自定义模板为「留言板」 feed / RSS 聚合不再依赖已停止提供的「朋友圈」插件,而由官方
2. 在主题设置 → 留言板中配置标题和副标题 「链接管理」(PluginLinks)插件的订阅能力提供。使用步骤:
3. 发布页面,添加到导航菜单
1. 安装并启用[链接管理插件](https://www.halo.run/store/apps/app-hfbQg),维护博友链接的 RSS 订阅源
2. 新建页面,自定义模板选择「订阅」
3. 在**主题设置 → 订阅**中调整每页条数、加载方式与布局,详见[订阅配置](/config/subscriptions)
未安装插件或暂无订阅动态时页面会显示友好提示,不会报错。
完整说明见[订阅页面](/pages/subscriptions)。
### 微信读书模板
选择「微信读书」模板后展示书架与阅读记录,标题 / 副标题在
**主题设置 → 微信读书** 中配置,详见[微信读书配置](/config/weread)。
该页面依赖 Halo 2.27+ 的能力。
## 页面功能 ## 页面功能
+25 -20
View File
@@ -6,31 +6,37 @@ WarmIsland 主题深度集成了多款 Halo 插件,提供了丰富的扩展页
| 页面 | 所需插件 | 路由 | | 页面 | 所需插件 | 路由 |
|------|----------|------| |------|----------|------|
| 瞬间 | [PluginMoments](https://www.halo.run/store/apps/app-SnwWD) | /moments | | 瞬间 | [PluginMoments](https://www.halo.run/store/apps/app-SnwWD) | `/moments` |
| 图库 | [PluginPhotos](https://www.halo.run/store/apps/app-BmQJW) | /photos | | 图库 | [PluginPhotos](https://www.halo.run/store/apps/app-BmQJW) | `/photos` |
| 友情链接 | [PluginLinks](https://www.halo.run/store/apps/app-hfbQg) | /links | | 友情链接 | [PluginLinks](https://www.halo.run/store/apps/app-hfbQg) | `/links` |
| 朋友圈 | [plugin-friends](https://www.halo.run/store/apps/app-yISsV) | /friends | | 订阅动态 | [PluginLinks](https://www.halo.run/store/apps/app-hfbQg)(RSS 订阅) | 自定义页面,无固定路由 |
| 豆瓣 | [plugin-douban](https://www.halo.run/store/apps/app-srBOL) | /douban | | 豆瓣 | [plugin-douban](https://www.halo.run/store/apps/app-srBOL) | `/douban` |
| 装备 | [equipment](https://www.halo.run/store/apps/app-ytygyqml) | /equipments | | 装备 | [equipment](https://www.halo.run/store/apps/app-ytygyqml) | `/equipments` |
| 足迹 | [footprint](https://www.halo.run/store/apps/app-XCtAQ) | /footprints | | Steam | [steam](https://www.halo.run/store/apps/app-0ojqyzfh) | `/steam` |
| Steam | [steam](https://www.halo.run/store/apps/app-0ojqyzfh) | /steam | | 记得 | [plugin-important-dates](https://github.com/yyliucha/plugin-important-dates) | `/important-dates` |
| 记得 | [plugin-important-dates](https://github.com/yyliucha/plugin-important-dates) | /important-dates |
> 「朋友圈」(`plugin-friends`)插件已不再提供,其 feed / RSS 聚合能力由官方「链接管理」插件的订阅取代。
> 主题保留的 `friends.html` 仅供旧站点兼容,新站点请使用订阅模板,详见[订阅页面](/pages/subscriptions)。
## 订阅页面
订阅页面依赖官方「链接管理」插件的 RSS 订阅,展示博友们的最新文章,
支持分页 / 下滑加载与列表 / 网格布局。它属于自定义页面模板,地址由你创建页面时的 slug 决定。
使用前请在 Halo 后台 → 页面 → 新建 → 自定义模板中选择「订阅」。
详见[订阅页面](/pages/subscriptions)与[订阅配置](/config/subscriptions)。
## 豆瓣页面 ## 豆瓣页面
[豆瓣插件](https://www.halo.run/store/apps/app-srBOL)
展示豆瓣观影/读书/音乐记录,支持按类型和状态筛选。 [豆瓣插件](https://www.halo.run/store/apps/app-srBOL)展示豆瓣观影/读书/音乐记录,支持按类型和状态筛选。
## 装备页面 ## 装备页面
[装备管理插件](https://www.halo.run/store/apps/app-ytygyqml)
展示个人装备清单,按分组分类展示。
## 足迹页面 [装备管理插件](https://www.halo.run/store/apps/app-ytygyqml)展示个人装备清单,按分组分类展示。
[足迹插件](https://www.halo.run/store/apps/app-XCtAQ)
展示个人足迹地图。
## Steam 页面 ## Steam 页面
[Steam](https://www.halo.run/store/apps/app-0ojqyzfh)
展示 Steam 游戏资料,包括: [Steam](https://www.halo.run/store/apps/app-0ojqyzfh)展示 Steam 游戏资料,包括:
- 玩家头像和昵称 - 玩家头像和昵称
- 游戏统计 - 游戏统计
@@ -39,8 +45,7 @@ WarmIsland 主题深度集成了多款 Halo 插件,提供了丰富的扩展页
## 记得页面 ## 记得页面
[记得插件](https://github.com/yyliucha/plugin-important-dates) [记得插件](https://github.com/yyliucha/plugin-important-dates)记录重要的日子、牵挂的人与座驾,「生活 / 爱车」双视图展示:
记录重要的日子、牵挂的人与座驾,「生活 / 爱车」双视图展示:
- 重要日期(含农历)与倒计时便签 - 重要日期(含农历)与倒计时便签
- 牵挂的人:大头贴、昵称、关系与生日倒计时 - 牵挂的人:大头贴、昵称、关系与生日倒计时
-25
View File
@@ -1,25 +0,0 @@
# 朋友圈
朋友圈页面展示订阅的友站最新文章动态,需要安装 [朋友圈插件](https://www.halo.run/store/apps/app-yISsV)。
## 功能说明
页面以时间线形式展示友站更新,每条动态包含:
- 友站名称和头像
- 文章标题和链接
- 发布时间
## 使用方式
1. 在 Halo 后台安装并启用朋友圈插件
2. 在插件设置中添加友站 RSS 订阅源
3. 主题自动读取并展示友站动态
::: tip
朋友圈插件负责数据抓取,主题负责前端展示。如需调整抓取频率或内容,请在插件设置中操作。
:::
## 相关配置
- [朋友圈配置](/config/friends) — 页面标题
+1 -1
View File
@@ -16,7 +16,7 @@ Hero 区域占据整个视口高度,包含:
- **主标题**:默认使用站点标题,可在主题设置中自定义 - **主标题**:默认使用站点标题,可在主题设置中自定义
- **副标题**:默认使用站点副标题 - **副标题**:默认使用站点副标题
- **描述文案**:支持一言 API、自定义文案或关闭 - **描述文案**:默认「无」(不显示、零请求);可选「一言」(API 字段默认预填 `https://v1.hitokoto.cn/`,清空则用内置本地句子库)或「自定义」静态文案
- **视觉效果**:渐变背景 + 呼吸光球 + 滚动模糊 - **视觉效果**:渐变背景 + 呼吸光球 + 滚动模糊
页面滚动时,Hero 区域会产生模糊效果,内容向上淡出,底部有向下箭头提示滚动。 页面滚动时,Hero 区域会产生模糊效果,内容向上淡出,底部有向下箭头提示滚动。
+2
View File
@@ -29,3 +29,5 @@
## 相关配置 ## 相关配置
- [友情链接配置](/config/links) — 展示样式 - [友情链接配置](/config/links) — 展示样式
- [订阅配置](/config/subscriptions) — 同一插件提供的 RSS 订阅动态
- [订阅页面](/pages/subscriptions) — 博友文章动态页面
+8 -3
View File
@@ -34,10 +34,15 @@
## 代码高亮 ## 代码高亮
使用 Prism.js 实现代码块语法高亮,支持自动语言检测。可在主题设置中选择两种主题: 主题**不再自带 Prism.js 运行时**(含按语言从 CDN 动态加载组件的 autoloader)。语法高亮由
Halo 应用市场的代码高亮插件或「API 扩展包」服务端高亮输出,主题只提供两套自托管的
`.token` 配色,在「主题设置 → 文章 → 代码高亮主题」中切换:
- **Warm**:浅色代码高亮,与暖色调设计一致 - **Warm**:浅色代码高亮,与暖色调设计一致(默认)
- **Cold**:深色代码高亮(Tomorrow Night),适合代码密集型文章 - **Cold**:深色代码高亮(Tomorrow 风格),适合代码密集型文章
未安装高亮插件时,代码块仍以等宽字体正常显示,只是没有语法着色;主题不会因此报错,
也不会发起任何外部请求。
## 图片灯箱 ## 图片灯箱
+53
View File
@@ -0,0 +1,53 @@
# 订阅页面
订阅页面按时间倒序展示博友们的最新文章,即原先「朋友圈 / feed 聚合」所提供的能力。
现在它由 **Halo 官方「链接管理」(PluginLinks)插件的 RSS 订阅**驱动,主题提供
`page_subscriptions.html` 自定义模板负责前端展示。
## 使用方式
1. 安装并启用 [链接管理插件](https://www.halo.run/store/apps/app-hfbQg),在插件中维护博友链接的 RSS 订阅源
2. 进入 Halo 后台 → **页面** → **新建**
3. 选择自定义模板「**订阅**」(`Subscriptions`,对应 `page_subscriptions.html`)
4. 发布页面,并按需加入导航菜单
> 页面地址由你创建页面时设定的 slug 决定,主题**不提供固定的 `/subscriptions` 路由**;
> 只有插件提供的扩展页面(如 `/links`、`/moments`)才是固定路由。
## 页面内容
每条订阅动态是一张卡片,包含:
- 作者头像(缺失时显示首字占位)、作者名称与主页链接
- 发布日期
- 文章标题(点击跳转原文)、摘要
- 空数据时的友好提示,以及未安装链接管理插件时的安装引导
标题与作者链接均使用 `target="_blank"` + `rel="noopener noreferrer"`,
主题本身不会向第三方发起请求。
## 展示与加载
在[订阅配置](/config/subscriptions)中可调:
| 能力 | 可选值 |
| --- | --- |
| 布局 | 单列列表卡片 / 网格卡片(窄屏自动收敛列数) |
| 加载方式 | 底部分页(较新 / 较旧)/ 下滑自动加载 |
| 每页条数 | 1–100,默认 20 |
| 标题与副标题 | 自定义文本 |
## 从「朋友圈」迁移
`plugin-friends`(朋友圈)插件已不再提供,原来的 `/friends` 页面与「朋友圈」设置分组
仅作为旧站点兼容保留,模板检测到插件不可用时会显示空状态。迁移步骤:
1. 安装 / 启用「链接管理」插件,把原本在朋友圈插件里维护的 RSS 订阅源迁到链接管理插件的订阅中
2. 新建一个页面并选择「订阅」自定义模板
3. 确认动态正常展示后,删除旧的 `/friends` 页面与菜单项
## 相关配置
- [订阅配置](/config/subscriptions) — 全部 5 个设置项
- [友情链接页面](/pages/links) — 同一插件的友链展示
- [扩展页面](/pages/extended) — 插件前台页面一览
+3 -2
View File
@@ -1,5 +1,5 @@
{ {
"version": "1.2.5", "version": "1.2.7",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@@ -9,8 +9,9 @@
"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 && node scripts/smoke-jsonld.mjs",
"astro": "astro", "astro": "astro",
"format": "prettier --write ." "format": "prettier --write ."
}, },
+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": "暖屿" }
]
}
+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.
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

+271
View File
@@ -0,0 +1,271 @@
/**
* 外部资源与隐私审计(发版前自动检查,Node 内置模块,无第三方依赖)
*
* 对应 Halo 应用市场审核指南:
* - 1.4 远程执行与动态加载:禁止远程加载核心代码 / 动态拉取并执行组件
* - 4.4.4 性能与外部资源:不得预连接外部 CDN、加载远程脚本样式、打包无关大资源
* - 4.4.2 内容渲染安全:不得把站点内容用 th:utext 拼进 <script> 原始文本
* - 5.1 / 5.2 / 2.2:第三方字体必须随包携带许可证全文;可选 API 的数据流向与隐私披露
* 由 README「外部资源与隐私」及文档站承载;上架说明数字必须与配置定义一致
* - 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 中的相对链接必须在安装包清单中真实存在
* 6. 任何 <script> 开标签不得带 th:utext;字体许可证全文必须在制品清单中;
* README 的设置分组 / 配置项数量必须与 settings.yaml 一致
* 7. 三个可选 API 字段存在且 help 为精简功能说明;隐私披露文案不得回潮到设置界面(5.1)
*
* 说明:<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 };
}
/**
* 合规审计:审核指南 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;
let conciseCount = 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)`);
}
// 4. 5.1 隐私披露:完整的数据流向说明统一由 README「外部资源与隐私」章节及文档站承载,
// 设置界面的三个可选 API 字段只保留精简功能说明,不得回潮出现冗长披露文案。
const apiFields = [
"welcome_popup_api",
"welcome_popup_fallback_api",
"hero_hitokoto_api",
];
const bannedSnippets = [
"需自行在站点隐私说明中披露",
"填写后会把访客 IP 发送给该第三方服务换取地理位置",
"填写后同样会把访客 IP 发送给该第三方服务换取地理位置",
"只有填写后才会向该地址发起请求",
];
const blocks = settings.split(/(?=^[ \t]*-[ \t]+\$formkit:)/m).slice(1);
for (const name of apiFields) {
const block = blocks.find((b) => new RegExp(`^\\s+name:[ \\t]*${name}[ \\t]*$`, "m").test(b));
if (!block) {
problems.push(`settings.yaml 缺少可选 API 配置项 ${name}(5.1 隐私披露)`);
continue;
}
const helpLine = block.split(/\r?\n/).find((line) => /^\s+help:/.test(line)) || "";
if (!helpLine.trim()) {
problems.push(`${name} 缺少 help 功能说明(5.1)`);
continue;
}
if (bannedSnippets.some((snippet) => helpLine.includes(snippet))) {
problems.push(`${name} 的 help 出现了已移除的隐私披露文案(5.1)`);
} else {
conciseCount++;
}
}
if (!readme.includes("外部资源与隐私") || !readme.includes("访客 IP")) {
problems.push("README 缺少可选 API 的隐私披露章节(5.1)");
}
}
return { problems, groupCount, fieldCount, conciseCount };
}
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();
const compliance = auditCompliance();
let failures = 0;
failures += report(
`模板外部资源(${templates.htmlCount} 个 HTML / ${templates.fileCount} 个文件)`,
templates.problems
);
failures += report(
`合规审计 4.4.2 / 5.1 / 5.2 / 2.2(${compliance.groupCount} 组 / ${compliance.fieldCount} 配置项 / ${compliance.conciseCount} 条精简提示)`,
compliance.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);
}
+59 -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, 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)), "..");
@@ -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,49 @@ 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 存在指向制品中不存在文件的相对链接");
}
/* 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");
}
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} 个相对链接、` +
`${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`);
buildZip(entries, outFile); buildZip(entries, outFile);
+18 -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,24 @@ 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");
// 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)) {
+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);
+14 -11
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: 留空则只显示本地欢迎语。可选接口 https://myip.ipip.net/json 或 https://r.inews.qq.com/api/ip2city?otype=json
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: 留空不使用备选;可填 https://myip.ipip.net/json 或 https://r.inews.qq.com/api/ip2city?otype=json 作为主接口的备选
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,11 +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: https://v1.hitokoto.cn/ value: "https://v1.hitokoto.cn/"
if: "$get(hero_description_mode).value !== 'none'"
help: 默认预填官方接口 https://v1.hitokoto.cn/(仅「一言」模式生效);选择「一言」或「自定义」后若留空,则使用主题内置的本地句子库(零外部请求)
- $formkit: checkbox - $formkit: checkbox
name: hero_hitokoto_categories name: hero_hitokoto_categories
label: 一言句子类型 label: 一言句子类型
@@ -501,6 +503,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: 中英文混排自动加空格
@@ -581,7 +584,7 @@ spec:
- $formkit: attachment - $formkit: attachment
name: moments_cover name: moments_cover
label: 顶部封面图 label: 顶部封面图
help: 朋友圈样式顶部的封面图片,建议尺寸 1500×500 help: 瞬间页顶部的封面图片,建议尺寸 1500×500
- $formkit: select - $formkit: select
name: moments_loading_mode name: moments_loading_mode
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>
+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')}"
+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 后台「友链检测」产生):绿点=可访问,红点=异常,未检测不显示 -->
+3 -2
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">
@@ -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"
+5 -3
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">
@@ -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
+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="'较旧'">
较旧 较旧
+98 -81
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>
@@ -194,9 +215,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 +318,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 +679,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 +1248,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 {
+28 -1
View File
@@ -120,8 +120,35 @@
border: 1px solid var(--rule); border: 1px solid var(--rule);
} }
/* ---------- 引用块 ----------
全局 blockquote 的 padding 与正文 p 的 margin-bottom 会在块内叠加,
文字下方因此出现一整条空白。这里把引用块内外间距收敛为单一节奏:
块内子元素不再各自产生外边距,多段之间只保留一个较小的段间距。 */
blockquote { blockquote {
margin-block: 2em; margin-block: 1.25em;
padding-block: 0.7em;
padding-inline: 1.25em;
/* :is() 把特异性提到 0,1,2,确保移动端 .wi-post__body p 规则不会让空白回归 */
> :is(p, ul, ol, pre, figure, table, h1, h2, h3, h4, h5, h6) {
margin-block: 0;
}
/* 与上面的归零规则保持同等特异性,靠顺序生效,避免多段引用挤在一起 */
> :is(p, ul, ol, pre, figure, table, h1, h2, h3, h4, h5, h6) + * {
margin-block-start: 0.55em;
}
/* 编辑器常在引用块末尾留下空段落,避免再撑出一段空白 */
> p:empty {
display: none;
}
/* 仅含一个 <br> 的空段落单独成规则:旧浏览器不支持 :has() 时
只会丢弃这一条,不影响上面 p:empty 的兜底 */
> p:has(> br:only-child) {
display: none;
}
} }
:is(ul, ol) { :is(ul, ol) {
+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.7"
requires: ">=2.27.0" requires: ">=2.27.0"
license: license:
- name: "GPL-3.0" - name: "GPL-3.0"