Update: add Prolog translation, 4A system design, Mermaid, Hextra partials & blog articles
This commit is contained in:
@@ -0,0 +1,388 @@
|
|||||||
|
# AI全流程兜底闭环
|
||||||
|
|
||||||
|
## 1. 核心理念
|
||||||
|
|
||||||
|
两个月自研一套对标泰岳的4A系统,**不可能靠纯人工完成**。AI不是锦上添花,而是项目成功的前提条件。
|
||||||
|
|
||||||
|
核心策略:**AI最大化替代重复性工作,人工聚焦架构设计、安全审查和关键决策。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. AI在各个阶段的介入点
|
||||||
|
|
||||||
|
### 2.1 开发阶段
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 开发阶段 AI 介入 │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ AI代码生成 │ │ AI代码审查 │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ • API层CRUD代码 │ │ • 安全漏洞扫描 │ │
|
||||||
|
│ │ • 数据模型→SQL │ │ (SQL注入/XSS/越权) │ │
|
||||||
|
│ │ • Service层业务逻辑 │ │ • 接口规范一致性检查 │ │
|
||||||
|
│ │ • 前端列表/表单页面 │ │ • 日志埋点完整性检查 │ │
|
||||||
|
│ │ • 单元测试代码 │ │ • 异常处理覆盖率检查 │ │
|
||||||
|
│ │ • 接口文档生成 │ │ • 代码风格统一 │ │
|
||||||
|
│ └────────────────────┘ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ AI数据模型辅助 │ │ AI对接适配器生成 │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ • 从泰岳界面反推 │ │ • 金科人脸API适配器 │ │
|
||||||
|
│ │ 数据模型 │ │ • 消息中心API适配器 │ │
|
||||||
|
│ │ • 字段映射脚本生成 │ │ • 现网账号同步适配器 │ │
|
||||||
|
│ │ • 数据迁移/同步 │ │ • 亚信接口规范适配器 │ │
|
||||||
|
│ │ 代码生成 │ │ • 泰岳接口模拟器 │ │
|
||||||
|
│ └────────────────────┘ └────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**AI代码生成策略**:
|
||||||
|
|
||||||
|
1. **模型选型**:使用 DeepSeek/Claude 等具有编程能力的模型
|
||||||
|
2. **Prompt模板化**:为每种代码类型(API CRUD、数据模型、单元测试、接口适配层)建立标准 Prompt
|
||||||
|
3. **微调/知识注入**:将本项目的表结构定义、接口规范作为 System Prompt 上下文
|
||||||
|
4. **人机协作**:AI生成初稿 → 人工审查 → AI根据Review意见修正 → 循环至满意
|
||||||
|
5. **生成量目标**:争取 60-70% 的业务代码由AI完成初稿
|
||||||
|
|
||||||
|
### 2.2 测试阶段
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 测试阶段 AI 介入 │
|
||||||
|
│ │
|
||||||
|
│ 1. 测试用例自动生成 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 输入: API接口定义/数据模型/业务规则描述 → AI生成: │ │
|
||||||
|
│ │ - 正常流程测试用例集 │ │
|
||||||
|
│ │ - 异常路径测试用例集(参数错误/越权/并发/超时) │ │
|
||||||
|
│ │ - 边界条件测试用例集(空值/超长/特殊字符/数据溢出) │ │
|
||||||
|
│ │ - 安全性测试用例(SQL注入/XSS/CSRF/越权枚举) │ │
|
||||||
|
│ │ - 压力测试基准脚本 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 2. 与泰岳版行为比对测试 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI Agent 框架: │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ① AI录制泰岳版操作 → 生成操作序列 │ │
|
||||||
|
│ │ ② AI将操作序列转为自动化测试脚本 │ │
|
||||||
|
│ │ ③ AI同时在泰岳版和自研版执行测试脚本 │ │
|
||||||
|
│ │ ④ AI比对两系统的: │ │
|
||||||
|
│ │ - 接口请求/响应差异 │ │
|
||||||
|
│ │ - 界面元素/流程差异 │ │
|
||||||
|
│ │ - 数据持久化结果差异 │ │
|
||||||
|
│ │ ⑤ AI输出差异报告,标注关键程度 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 3. 回归测试自愈 │
|
||||||
|
│ - 当代码变更导致测试失败时,AI自动分析失败原因 │
|
||||||
|
│ - 测试预期结果过时?AI自动更新测试用例 │
|
||||||
|
│ - 产品逻辑改变?AI识别并标记为"预期变化" │
|
||||||
|
│ - 测试环境问题?AI标记为"环境异常"而非"功能失败" │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 部署阶段
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 部署阶段 AI 介入 │
|
||||||
|
│ │
|
||||||
|
│ 1. 基础设施编排 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI生成 Ansible Playbook / Terraform 配置: │ │
|
||||||
|
│ │ - 服务器初始化(OS调优、安全基线配置) │ │
|
||||||
|
│ │ - 中间件部署(Nginx/Redis/MySQL/ES/MinIO) │ │
|
||||||
|
│ │ - 应用部署(认证中心/资源中心/审计中心各模块) │ │
|
||||||
|
│ │ - 网络策略配置(防火墙规则、反向代理、TLS证书) │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 2. 配置差异自适应 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 当部署失败时,AI分析错误日志: │ │
|
||||||
|
│ │ - 配置参数与环境不匹配?AI推荐正确值 │ │
|
||||||
|
│ │ - 依赖组件版本冲突?AI推荐兼容版本组合 │ │
|
||||||
|
│ │ - 权限问题?AI生成正确的权限设置命令 │ │
|
||||||
|
│ │ - 网络不可达?AI诊断网络路径 + 推荐修复方案 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 3. CI/CD Pipeline │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 流水线步骤(AI辅助维护): │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ [代码提交] → [AI代码审查] → [AI生成测试] → [自动编译] │ │
|
||||||
|
│ │ → [单元测试] → [集成测试] → [AI比对测试] → [安全扫描] │ │
|
||||||
|
│ │ → [构建镜像] → [部署到测试环境] → [冒烟测试] → [通知] │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 运维阶段
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 运维阶段 AI 介入 │
|
||||||
|
│ │
|
||||||
|
│ 1. 智能告警与根因分析 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ 场景1:用户反馈"登录不上了" │ │
|
||||||
|
│ AI排查链路:API超时?→ Redis连接失败?→ Redis进程挂了? │ │
|
||||||
|
│ → OOM被Killed?→ 内存泄漏? │ │
|
||||||
|
│ 根因定位后AI自动在5分钟内输出根因报告 + 修复方案 │ │
|
||||||
|
│ │
|
||||||
|
│ 场景2:SSO跳转后白屏 │ │
|
||||||
|
│ AI排查链路:前端报错→ API返回500 → 后端日志ClassNotFound │ │
|
||||||
|
│ → 发版时删了jar包 → 回滚建议 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 2. 数据一致性巡检 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ AI Agent定时任务(每天凌晨执行): │ │
|
||||||
|
│ ✓ 自研系统账号数与现网账号平台对比 → 偏差 > 0.1% 告警 │ │
|
||||||
|
│ ✓ 昨日新增的日志量与API调用量匹配度 → 缺漏事件告警 │ │
|
||||||
|
│ ✓ 授权数据完整性和无循环引用 → 数据损坏告警 │ │
|
||||||
|
│ ✓ 密钥轮换成功率检测 → 失败操作通知管理员 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 3. 性能基线追踪 │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────┐ │
|
||||||
|
│ 持续采集: │ │
|
||||||
|
│ - API响应时间 P50/P95/P99(按接口维度) │ │
|
||||||
|
│ - 认证成功率/耗时趋势 │ │
|
||||||
|
│ - ES查询响应时间趋势 │ │
|
||||||
|
│ - 堡垒机并发数/响应延迟趋势 │ │
|
||||||
|
│ │
|
||||||
|
│ 当性能偏离基线 > 20%时,AI自动生成性能分析报告 │ │
|
||||||
|
│ 与上一发版/配置变更/流量突增做关联分析 │ │
|
||||||
|
│ └───────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 与泰岳版的比对验证方案
|
||||||
|
|
||||||
|
### 3.1 验证框架
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 比对验证框架 │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ 功能覆盖度验证 │ │ 行为一致性验证 │ │
|
||||||
|
│ ├────────────────────┤ ├────────────────────┤ │
|
||||||
|
│ │ 工具: AI扫描 + │ │ 工具: AI Agent │ │
|
||||||
|
│ │ 功能矩阵表 │ │ 录制→回放→比对 │ │
|
||||||
|
│ │ 方法: │ │ 方法: │ │
|
||||||
|
│ │ ① 列出泰岳版所有 │ │ ① 录制泰岳版操作 │ │
|
||||||
|
│ │ 功能点 │ │ ② 生成测试脚本 │ │
|
||||||
|
│ │ ② 逐项标记自研版 │ │ ③ 在两系统执行 │ │
|
||||||
|
│ │ 覆盖状态 │ │ ④ AI比对行为差异 │ │
|
||||||
|
│ │ ③ AI辅助识别遗漏 │ │ ⑤ 输出差异报告 │ │
|
||||||
|
│ └────────────────────┘ └────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────┐ ┌────────────────────┐ │
|
||||||
|
│ │ 性能基准验证 │ │ 安全基准验证 │ │
|
||||||
|
│ ├────────────────────┤ ├────────────────────┤ │
|
||||||
|
│ │ 工具: JMeter/k6 │ │ 工具: 安全扫描器+AI │ │
|
||||||
|
│ │ 方法: │ │ 方法: │ │
|
||||||
|
│ │ ① 相同硬件上运行 │ │ ① OWASP Top10扫描 │ │
|
||||||
|
│ │ ② 定义核心场景 │ │ ② 认证机制审查 │ │
|
||||||
|
│ │ (登录/SSO/授权/ │ │ ③ 敏感数据加密审计 │ │
|
||||||
|
│ │ 日志查询) │ │ ④ 越权测试 │ │
|
||||||
|
│ │ ③ 逐步增加并发 │ │ ⑤ AI对比差异 + │ │
|
||||||
|
│ │ ④ AI输出对比曲线 │ │ 给出安全建议 │ │
|
||||||
|
│ └────────────────────┘ └────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 功能覆盖度矩阵(示例片段)
|
||||||
|
|
||||||
|
| 功能模块 | 功能点 | 泰岳版 | 自研版 | 完成度 | 备注 |
|
||||||
|
|---------|--------|--------|--------|--------|------|
|
||||||
|
| 认证 | 密码认证 | ✓ | ✓ | 100% | |
|
||||||
|
| 认证 | 短信验证码 | ✓ | ✓ | 100% | |
|
||||||
|
| 认证 | 人脸识别认证 | ✓ | ✓ | 100% | 金科接口 |
|
||||||
|
| 认证 | SIM卡无感认证 | — | ✓ | 100% | 自研特有 |
|
||||||
|
| 认证 | LDAP集成 | ✓ | ☐ | 0% | 二期 |
|
||||||
|
| 认证 | MFA策略配置 | ✓ | ✓ | 100% | |
|
||||||
|
| 账号 | 账号新建 | ✓ | ✓ | 100% | |
|
||||||
|
| 账号 | 账号批量导入 | ✓ | ✓ | 100% | |
|
||||||
|
| 账号 | 账号同步(多源) | ✓ | ✓ | 100% | |
|
||||||
|
| 账号 | 密码策略配置 | ✓ | ✓ | 100% | |
|
||||||
|
| 应用SSO | OIDC协议 | ✓ | ✓ | 100% | |
|
||||||
|
| 应用SSO | SAML协议 | ✓ | ✓ | 80% | 部分场景验证中 |
|
||||||
|
| 应用权限 | RBAC模型 | ✓ | ✓ | 100% | |
|
||||||
|
| 应用权限 | ABAC策略 | — | ✓ | 100% | 自研特有 |
|
||||||
|
| 应用权限 | 临时授权 | ✓ | ✓ | 100% | |
|
||||||
|
| 系统资源 | 资产纳管 | ✓ | ✓ | 100% | |
|
||||||
|
| 系统资源 | 批量导入资产 | ✓ | ✓ | 100% | |
|
||||||
|
| 系统资源 | 命令白名单 | ✓ | ✓ | 100% | |
|
||||||
|
| 系统资源 | 命令拦截 | ✓ | ✓ | 100% | |
|
||||||
|
| 系统资源 | 密码轮换 | ✓ | ✓ | 50% | 实现中 |
|
||||||
|
| 堡垒机 | SSH单点登录 | ✓ | ✓ | 100% | |
|
||||||
|
| 堡垒机 | RDP单点登录 | ✓ | ☐ | 0% | 二期 |
|
||||||
|
| 堡垒机 | 会话录制 | ✓ | ✓ | 100% | |
|
||||||
|
| 堡垒机 | 会话回放 | ✓ | ✓ | 80% | 播放器优化中 |
|
||||||
|
| 审计 | 操作日志 | ✓ | ✓ | 100% | |
|
||||||
|
| 审计 | 合规规则引擎 | — | ✓ | 100% | 自研特有 |
|
||||||
|
| 审计 | 定时报表 | ✓ | ✓ | 100% | |
|
||||||
|
| 审计 | 自定义报表 | ✓ | ✓ | 80% | 查询条件还在完善 |
|
||||||
|
|
||||||
|
### 3.3 比对验证AI Agent(核心设计)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
验证Agent设计:
|
||||||
|
|
||||||
|
输入:
|
||||||
|
- 泰岳版API/界面操作录制
|
||||||
|
- 自研版API/界面操作
|
||||||
|
- 期望行为描述
|
||||||
|
|
||||||
|
处理流程:
|
||||||
|
1. 理解场景: 从录制内容中提取操作步骤和预期结果
|
||||||
|
2. 脚本生成: 将操作步骤转换为可执行的测试脚本
|
||||||
|
3. 双系统执行: 并行在泰岳版和自研版中执行
|
||||||
|
4. 结果采集: 捕获接口请求/响应、页面状态、数据变更
|
||||||
|
5. 差异分析:
|
||||||
|
- 返回JSON字段差异
|
||||||
|
- 流程状态机步骤差异
|
||||||
|
- 数据持久化差异
|
||||||
|
- 错误处理差异
|
||||||
|
6. 差异分级:
|
||||||
|
Critical: 功能缺失/数据丢失
|
||||||
|
Major: 行为不符合预期
|
||||||
|
Minor: 界面/错误提示文本差异
|
||||||
|
Info: 实现方式不同但结果一致
|
||||||
|
7. 报告生成: 输出结构化差异报告
|
||||||
|
|
||||||
|
输出:
|
||||||
|
- 功能覆盖度百分比
|
||||||
|
- 行为一致度评分
|
||||||
|
- 差异清单(含分级和截图/调用链)
|
||||||
|
- 建议排期(哪些差异需要立即修复)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 用户发现问题 → 闭环解决的AI全链路支持
|
||||||
|
|
||||||
|
### 4.1 问题闭环流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 用户反馈 → AI初步诊断 → 自动修复/路由 → 人工确认 → 知识沉淀 │
|
||||||
|
│ │
|
||||||
|
│ 阶段1: 用户反馈 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 用户: "我通过4A跳转到报销系统,显示'无权限',但我昨天还能用" │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 阶段2: AI初步诊断 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI Agent 自动排查: │ │
|
||||||
|
│ │ ① 查询该用户的权限分配记录(最近变更) │ │
|
||||||
|
│ │ ② 查询报销系统的应用注册配置是否变更 │ │
|
||||||
|
│ │ ③ 查询用户所属组织的角色继承链路 │ │
|
||||||
|
│ │ ④ 检查授权有效期是否已过期 │ │
|
||||||
|
│ │ ⑤ 模拟该用户的权限验证请求 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 初步结论:用户角色有效期已过期(昨日到期) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 阶段3: 自动修复 / 路由 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 自动处理: │ │
|
||||||
|
│ │ ① 生成角色续期申请单(自动填充用户信息+原有角色) │ │
|
||||||
|
│ │ ② 推送给用户的直接上级审批 │ │
|
||||||
|
│ │ ③ 回复用户:"经排查,您对报销系统的访问权限已于昨日到期。 │ │
|
||||||
|
│ │ 已为您生成续期申请,请等待审批人确认,预计5分钟内恢复。" │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 阶段4: 人工确认 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 审批人确认 → 权限生效 │ │
|
||||||
|
│ │ AI自动通知用户:"权限已恢复,请重新登录报销系统" │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ▼ │
|
||||||
|
│ 阶段5: 知识沉淀 │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI将本次问题 + 排查链路 + 修复方案 存入"问题知识库" │ │
|
||||||
|
│ │ 同类问题下次出现时,AI可更快定位 + 自动修复 │ │
|
||||||
|
│ │ 定期输出"常见问题处理SOP" 给运维团队 │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 问题知识库设计
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE T_ISSUE_KNOWLEDGE (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
issue_type VARCHAR(64) NOT NULL COMMENT '问题类型',
|
||||||
|
symptom TEXT NOT NULL COMMENT '用户描述的症状',
|
||||||
|
diagnosis_steps JSON NOT NULL COMMENT '排查步骤(结构化)',
|
||||||
|
root_cause TEXT COMMENT '根因分析',
|
||||||
|
solution TEXT COMMENT '解决方案',
|
||||||
|
auto_fixable TINYINT NOT NULL DEFAULT 0 COMMENT '是否可自动修复',
|
||||||
|
auto_fix_script TEXT COMMENT '自动修复脚本/指令',
|
||||||
|
severity VARCHAR(16) COMMENT 'high/medium/low',
|
||||||
|
occurrence_count INT NOT NULL DEFAULT 1,
|
||||||
|
last_occurrence DATETIME,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
INDEX idx_issue_type (issue_type),
|
||||||
|
INDEX idx_symptom (symptom(100))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='问题知识库';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 AI闭环成熟度模型
|
||||||
|
|
||||||
|
| 阶段 | 能力 | 目标月份 |
|
||||||
|
|------|------|---------|
|
||||||
|
| L1: 人工全流程 | 用户反馈 → 人工排查 → 人工修复 | 第1个月 |
|
||||||
|
| L2: AI辅助诊断 | AI根据知识库推荐排查路径 + 建议修复方案 | 第1-2个月 |
|
||||||
|
| L3: AI半自动修复 | AI可处理 >50% 的常见问题(权限过期、配置错误等) | 第2个月+ |
|
||||||
|
| L4: AI全自动闭环 | AI自动发现、自动诊断、自动修复、自动通知 | 后续迭代 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 技术实现要点
|
||||||
|
|
||||||
|
### 5.1 AI工具链选型
|
||||||
|
|
||||||
|
| 用途 | 工具/平台 | 备注 |
|
||||||
|
|------|----------|------|
|
||||||
|
| 代码生成 | Claude / DeepSeek Coder | 用于核心逻辑编写 |
|
||||||
|
| 代码审查 | DeepSeek + 自定义规则 | 自动化Review |
|
||||||
|
| 测试生成 | AI Agent(自定义实现) | 基于接口定义生成 |
|
||||||
|
| 比对验证 | AI Agent | 录制→回放→比对 |
|
||||||
|
| 部署编排 | Ansible + AI生成Playbook | 配置即代码 |
|
||||||
|
| 运维Agent | 自建Agent框架 | 使用LLM API |
|
||||||
|
| 知识库 | 向量数据库(Milvus/Chroma) | 问题案例语义搜索 |
|
||||||
|
|
||||||
|
### 5.2 Prompt策略
|
||||||
|
|
||||||
|
| 场景 | Prompt策略 |
|
||||||
|
|------|-----------|
|
||||||
|
| 代码生成 | 给出完整的表结构、接口规范、业务规则描述,要求严格遵循 |
|
||||||
|
| 测试生成 | 给出API定义,要求覆盖正常/异常/边界/安全场景 |
|
||||||
|
| 问题诊断 | 给出系统日志、用户描述、相关配置,要求结构化输出排查链路 |
|
||||||
|
| 比对验证 | 给出双系统的操作结果,要求逐字段比对并分级差异 |
|
||||||
|
|
||||||
|
### 5.3 重要提醒
|
||||||
|
|
||||||
|
> **AI不是银弹**。关键约束:
|
||||||
|
> 1. AI生成的代码必须经过人工安全审查——尤其涉及认证和授权的逻辑
|
||||||
|
> 2. AI不擅长系统架构决策——架构师责任不可外包
|
||||||
|
> 3. AI生成的测试用例依赖人类确认预期结果是否正确
|
||||||
|
> 4. 数据一致性场景AI不能100%信任——关键路径需人工复核
|
||||||
|
> 5. 泰岳版接口反推中,AI只能辅助分析,最终结论需人工验证
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
# 审计中心设计
|
||||||
|
|
||||||
|
## 1. 总体定位
|
||||||
|
|
||||||
|
审计中心是4A系统的**可观测性和合规性基座**,负责收集、存储、分析全平台的操作行为数据。核心职责:
|
||||||
|
|
||||||
|
- 从认证身份中心、应用资源管理中心、系统资源管理中心采集全量操作事件
|
||||||
|
- 基于现有审计报表逆推审计数据模型
|
||||||
|
- 提供合规规则引擎,实时/准实时检测异常行为
|
||||||
|
- 生成分级审计报表,支撑部门级和公司级审计需求
|
||||||
|
- 利用招标空窗期(几个月)充分梳理数据模型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 设计原则
|
||||||
|
|
||||||
|
### 2.1 从报表反推模型——逆推方法论
|
||||||
|
|
||||||
|
泰岳版现网已有成熟审计报表。我们的策略:**不猜测泰岳的存储结构,而是从"报表要什么"反推"数据该存什么"**。
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────┐
|
||||||
|
│ 逆推路径:报表需求 → 数据模型 │
|
||||||
|
│ │
|
||||||
|
│ Step 1: 收集所有现网审计报表 │
|
||||||
|
│ - 固定报表(日/周/月报) │
|
||||||
|
│ - 自定义报表(管理员手动查询) │
|
||||||
|
│ - 触发式报表(异常告警详情) │
|
||||||
|
│ │
|
||||||
|
│ Step 2: 提取报表中的字段 │
|
||||||
|
│ - 每个报表的列、筛选条件、汇总维度 │
|
||||||
|
│ - 示例:登录报表需要{用户、时间、IP、结果、 │
|
||||||
|
│ 认证方式、地理位置、设备指纹} │
|
||||||
|
│ │
|
||||||
|
│ Step 3: 构建事件模型 │
|
||||||
|
│ - 将字段归类到事件模型中 │
|
||||||
|
│ - 识别共性字段(audit_id, timestamp, user, │
|
||||||
|
│ action, resource, result, client_info) │
|
||||||
|
│ │
|
||||||
|
│ Step 4: 设计存储结构 │
|
||||||
|
│ - 根据查询模式选择存储引擎 │
|
||||||
|
│ - 高频查询字段建索引 │
|
||||||
|
│ - 根据报表筛选条件确定分区策略 │
|
||||||
|
└──────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 逆推示例
|
||||||
|
|
||||||
|
**现网报表-A:账号登录统计日报**
|
||||||
|
```
|
||||||
|
字段: 日期 | 登录名 | 姓名 | 部门 | 登录次数 | 成功次数 | 失败次数 |
|
||||||
|
最后登录时间 | 最后登录IP | 最后认证方式
|
||||||
|
```
|
||||||
|
|
||||||
|
**逆推结论 → 登录事件字段集**:
|
||||||
|
- 基础:`event_id, event_type, timestamp`
|
||||||
|
- 主体:`account_id, login_name, display_name, org_path`
|
||||||
|
- 行为:`action=login, auth_method, result, fail_reason`
|
||||||
|
- 环境:`source_ip, user_agent, device_id, geo_location`
|
||||||
|
- 关联:`session_id, mfa_level`
|
||||||
|
|
||||||
|
**现网报表-B:权限变更审计报表**
|
||||||
|
```
|
||||||
|
字段: 变更时间 | 操作人 | 被授权人 | 应用/资产 |
|
||||||
|
授权角色 | 操作类型(授予/回收) | 审批人 | 生效时间
|
||||||
|
```
|
||||||
|
|
||||||
|
**逆推结论 → 授权事件字段集**:
|
||||||
|
- `event_id, event_type=auth_change, timestamp`
|
||||||
|
- `operator_id, target_user_id`
|
||||||
|
- `resource_type(app/asset), resource_id`
|
||||||
|
- `old_role, new_role, change_type(grant/revoke/expire)`
|
||||||
|
- `approver_id, approval_comment`
|
||||||
|
- `effective_period`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 三中心数据来源定义
|
||||||
|
|
||||||
|
### 3.1 事件分类与来源
|
||||||
|
|
||||||
|
| 事件类型 | 来源中心 | 采集方式 | 实时性 |
|
||||||
|
|---------|---------|---------|--------|
|
||||||
|
| 登录事件 | 认证身份中心 | API回调 / MQ | 实时 |
|
||||||
|
| 登出事件 | 认证身份中心 | API回调 | 实时 |
|
||||||
|
| 令牌刷新 | 认证身份中心 | 中间件Hook | 实时 |
|
||||||
|
| 认证失败 | 认证身份中心 | 业务日志 | 实时 |
|
||||||
|
| 密码修改 | 认证身份中心 | 业务日志 | 近实时 |
|
||||||
|
| 账号创建/修改/删除 | 认证身份中心 | CQRS事件 | 近实时 |
|
||||||
|
| SSO跳转 | 应用资源管理中心 | 网关日志 | 实时 |
|
||||||
|
| 权限分配/回收 | 应用资源管理中心 | 业务日志 | 实时 |
|
||||||
|
| 权限校验结果 | 应用资源管理中心 | 授权引擎日志 | 准实时 |
|
||||||
|
| 应用注册/配置变动 | 应用资源管理中心 | 业务日志 | 近实时 |
|
||||||
|
| 资产访问(SSH/RDP) | 系统资源管理中心 | 堡垒节点代理 | 实时 |
|
||||||
|
| 命令执行记录 | 系统资源管理中心 | 堡垒节点代理 | 实时 |
|
||||||
|
| 文件传输记录 | 系统资源管理中心 | 堡垒节点代理 | 实时 |
|
||||||
|
| 密码轮换操作 | 系统资源管理中心 | 业务日志 | 近实时 |
|
||||||
|
| 资产授权变动 | 系统资源管理中心 | 业务日志 | 近实时 |
|
||||||
|
|
||||||
|
### 3.2 数据采集架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||||
|
│ 认证身份中心 │ │ 应用资源中心 │ │ 系统资源中心 │
|
||||||
|
│ (业务事件) │ │ (业务事件) │ │ (业务事件) │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
│ 异步MQ │ 异步MQ │ 异步MQ
|
||||||
|
│ (RocketMQ) │ (RocketMQ) │ (RocketMQ)
|
||||||
|
├────────────────┼────────────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌────────────────────────────────────────────────┐
|
||||||
|
│ 审计事件消费处理器 │
|
||||||
|
│ │
|
||||||
|
│ 1. 事件解析器(JSON → 统一事件格式) │
|
||||||
|
│ 2. 事件增强器(补充IP地理信息、资产元数据等) │
|
||||||
|
│ 3. 事件路由器(分类型写入不同索引) │
|
||||||
|
│ 4. 合规规则引擎(实时检测异常规则) │
|
||||||
|
└──────┬─────────────────────────────────┬────────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┐ ┌──────────────────┐
|
||||||
|
│ Elasticsearch │ │ 告警通道 │
|
||||||
|
│ (事件存储) │ │ (企业微信/短信等) │
|
||||||
|
│ 按时间分索引 │ │ │
|
||||||
|
│ 按月建索引别名 │ │ 合规告警 → 通知 │
|
||||||
|
└──────────────┘ └──────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ 报表引擎 │
|
||||||
|
│ (定时+按需) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 统一审计事件模型
|
||||||
|
|
||||||
|
### 4.1 事件基础结构
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audit_id": "aud-20260517-abc123def456",
|
||||||
|
"event_type": "login", // 事件类型
|
||||||
|
"event_version": "1.0", // 事件格式版本
|
||||||
|
"timestamp": 1700000000000, // 事件发生时间(毫秒级)
|
||||||
|
"received_at": 1700000001000, // 审计中心接收时间
|
||||||
|
|
||||||
|
"actor": { // 行为主体
|
||||||
|
"uid": "uid_00123",
|
||||||
|
"login_name": "zhangsan",
|
||||||
|
"display_name": "张三",
|
||||||
|
"org_code": "BJ_SALES",
|
||||||
|
"org_path": "省公司/北京分公司/销售部",
|
||||||
|
"identity_type": "internal"
|
||||||
|
},
|
||||||
|
|
||||||
|
"action": { // 行为描述
|
||||||
|
"category": "auth", // 大类: auth/access/perm/admin/system
|
||||||
|
"operation": "login", // 具体操作
|
||||||
|
"detail": "密码认证登录", // 人工可读描述
|
||||||
|
"result": "success", // success/failure/blocked
|
||||||
|
"reason": "" // 失败/阻断原因
|
||||||
|
},
|
||||||
|
|
||||||
|
"resource": { // 操作对象
|
||||||
|
"type": "system", // system/app/asset/data
|
||||||
|
"id": "auth-service", // 资源标识
|
||||||
|
"name": "认证服务" // 资源名称
|
||||||
|
},
|
||||||
|
|
||||||
|
"context": { // 操作上下文
|
||||||
|
"source_ip": "10.0.1.100",
|
||||||
|
"source_ip_country": "中国",
|
||||||
|
"source_ip_city": "北京",
|
||||||
|
"user_agent": "Mozilla/5.0 ...",
|
||||||
|
"device_id": "dev_fingerprint_xxx",
|
||||||
|
"session_id": "sess_xyz789",
|
||||||
|
"mfa_level": 2,
|
||||||
|
"auth_methods": ["password", "sms"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"metadata": { // 元数据
|
||||||
|
"source_center": "auth-identity",
|
||||||
|
"source_service": "auth-api",
|
||||||
|
"trace_id": "trace-uuid-xxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 ES索引模型
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 索引模板:audit-events-{year}-{month}
|
||||||
|
{
|
||||||
|
"mappings": {
|
||||||
|
"dynamic": "strict",
|
||||||
|
"properties": {
|
||||||
|
"audit_id": { "type": "keyword" },
|
||||||
|
"event_type": { "type": "keyword" },
|
||||||
|
"event_version":{ "type": "keyword", "index": false },
|
||||||
|
"timestamp": { "type": "date", "format": "epoch_millis" },
|
||||||
|
"received_at": { "type": "date", "format": "epoch_millis" },
|
||||||
|
// actor字段
|
||||||
|
"actor.uid": { "type": "keyword" },
|
||||||
|
"actor.login_name": { "type": "keyword" },
|
||||||
|
"actor.display_name": { "type": "text", "fields": { "keyword": { "type": "keyword" } } },
|
||||||
|
"actor.org_code": { "type": "keyword" },
|
||||||
|
"actor.org_path": { "type": "keyword" },
|
||||||
|
"actor.identity_type": { "type": "keyword" },
|
||||||
|
// action字段
|
||||||
|
"action.category": { "type": "keyword" },
|
||||||
|
"action.operation": { "type": "keyword" },
|
||||||
|
"action.detail": { "type": "text" },
|
||||||
|
"action.result": { "type": "keyword" },
|
||||||
|
"action.reason": { "type": "text" },
|
||||||
|
// resource字段
|
||||||
|
"resource.type": { "type": "keyword" },
|
||||||
|
"resource.id": { "type": "keyword" },
|
||||||
|
"resource.name": { "type": "text" },
|
||||||
|
// context字段
|
||||||
|
"context.source_ip": { "type": "ip" },
|
||||||
|
"context.source_ip_country": { "type": "keyword" },
|
||||||
|
"context.source_ip_city": { "type": "keyword" },
|
||||||
|
"context.user_agent": { "type": "text", "index": false },
|
||||||
|
"context.device_id": { "type": "keyword" },
|
||||||
|
"context.session_id": { "type": "keyword" },
|
||||||
|
"context.mfa_level": { "type": "integer" },
|
||||||
|
"context.auth_methods": { "type": "keyword" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"number_of_shards": 3,
|
||||||
|
"number_of_replicas": 1,
|
||||||
|
"refresh_interval": "5s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 索引与生命周期管理
|
||||||
|
|
||||||
|
```
|
||||||
|
索引命名: audit-events-2026-05 (按月)
|
||||||
|
别名策略:
|
||||||
|
write: audit-events-active (当前写入索引)
|
||||||
|
read: audit-events-all (查询所有)
|
||||||
|
|
||||||
|
生命周期(ILM):
|
||||||
|
热阶段(7天): SSD存储, 副本数2, 刷新间隔1s
|
||||||
|
温阶段(30天): HDD存储, 副本数1, 刷新间隔30s
|
||||||
|
冷阶段(180天): HDD存储, 副本数1, forcemerge 1段
|
||||||
|
删除: 180天后删除索引
|
||||||
|
|
||||||
|
容量估算:
|
||||||
|
假设日均50万事件 × 1KB/事件 ≈ 500MB/天
|
||||||
|
30天 ≈ 15GB, 180天 ≈ 90GB (含副本)
|
||||||
|
上硬件资源绰绰有余
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 合规规则引擎
|
||||||
|
|
||||||
|
### 5.1 规则示例
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rules:
|
||||||
|
- id: "RULE-001"
|
||||||
|
name: "非工作时间登录告警"
|
||||||
|
severity: "medium"
|
||||||
|
condition:
|
||||||
|
event_type: "login"
|
||||||
|
time_range:
|
||||||
|
- "22:00-08:00" # 非工作时间
|
||||||
|
- "周六,周日"
|
||||||
|
action: "notify_manager"
|
||||||
|
|
||||||
|
- id: "RULE-002"
|
||||||
|
name: "连续登陆失败锁定"
|
||||||
|
severity: "high"
|
||||||
|
condition:
|
||||||
|
event_type: "login"
|
||||||
|
action.result: "failure"
|
||||||
|
count: 5
|
||||||
|
window: "5m" # 5分钟内
|
||||||
|
per: "actor.uid"
|
||||||
|
action: "lock_account"
|
||||||
|
|
||||||
|
- id: "RULE-003"
|
||||||
|
name: "异地登录检测"
|
||||||
|
severity: "high"
|
||||||
|
condition:
|
||||||
|
event_type: "login"
|
||||||
|
action.result: "success"
|
||||||
|
geo_change: true # 与前一次登录IP不同城市
|
||||||
|
window: "1h" # 1小时内跨越城市
|
||||||
|
action: "notify_user_sms"
|
||||||
|
|
||||||
|
- id: "RULE-004"
|
||||||
|
name: "高危命令执行"
|
||||||
|
severity: "critical"
|
||||||
|
condition:
|
||||||
|
event_type: "command"
|
||||||
|
command_match: "(rm -rf|shutdown|reboot|mkfs|dd if=)"
|
||||||
|
action: "block_and_notify"
|
||||||
|
|
||||||
|
- id: "RULE-005"
|
||||||
|
name: "权限批量回收异常"
|
||||||
|
severity: "medium"
|
||||||
|
condition:
|
||||||
|
event_type: "perm_change"
|
||||||
|
change_type: "revoke"
|
||||||
|
count: 10
|
||||||
|
window: "10m"
|
||||||
|
per: "actor.uid"
|
||||||
|
action: "notify_security_admin"
|
||||||
|
|
||||||
|
- id: "RULE-006"
|
||||||
|
name: "非授权资产访问尝试"
|
||||||
|
severity: "high"
|
||||||
|
condition:
|
||||||
|
event_type: "access"
|
||||||
|
action.result: "denied"
|
||||||
|
count: 3
|
||||||
|
window: "5m"
|
||||||
|
action: "notify_security_admin"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 规则执行架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
│ 审计事件流 │
|
||||||
|
│ (MQ消费) │
|
||||||
|
└──────┬──────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 规则引擎核心 │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ 规则编译模块 │ │
|
||||||
|
│ │ (YAML → 执行计划) │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ 规则执行器 │ │
|
||||||
|
│ │ (有状态: 使用 │ │
|
||||||
|
│ │ Redis计数窗口) │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
│ ┌──────────────────┐ │
|
||||||
|
│ │ 告警聚合器 │ │
|
||||||
|
│ │ (去重+降噪) │ │
|
||||||
|
│ └──────────────────┘ │
|
||||||
|
└──────┬──────┬───────┘
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
┌──────────┐ ┌──────────┐
|
||||||
|
│ 告警存储 │ │ 通知通道 │
|
||||||
|
│ (ES告警索引)│ │ 企业微信 │
|
||||||
|
│ │ │ 短信/电话 │
|
||||||
|
│ │ │ 4A管理台 │
|
||||||
|
└──────────┘ └──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 规则治理
|
||||||
|
|
||||||
|
- **规则热加载**:规则从DB/配置中心动态读取,变更无需重启
|
||||||
|
- **规则测试沙箱**:支持在沙箱中测试规则效果再发布
|
||||||
|
- **规则级联**:支持规则之间的依赖和触发关系
|
||||||
|
- **误报反馈**:告警可标注"误报",自动调整规则阈值
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 审计报表输出模型
|
||||||
|
|
||||||
|
### 6.1 报表分类
|
||||||
|
|
||||||
|
| 报表类型 | 周期 | 受众 | 内容 |
|
||||||
|
|---------|------|------|------|
|
||||||
|
| **部门操作日报** | 日 | 部门安全员 | 本部门人员登录、权限变更、资产访问汇总 |
|
||||||
|
| **公司安全周报** | 周 | 安全管理员 | 全公司异常行为统计、高危操作汇总 |
|
||||||
|
| **合规月报** | 月 | 合规部门/领导 | 合规指标完成情况、不合规事件明细 |
|
||||||
|
| **账号健康检查报告** | 月 | 系统管理员 | 僵尸账号、过期权限、密码过期情况 |
|
||||||
|
| **资产访问趋势分析** | 月 | 运维负责人 | 各资产访问频次、高峰时段、异常访问 |
|
||||||
|
| **权限审计专报** | 季 | 审计部门 | 权限合规性专项检查、最小权限原则遵从度 |
|
||||||
|
| **自定义报表** | 按需 | 指定人员 | 按事件类型/时间/用户/资产组合查询 |
|
||||||
|
|
||||||
|
### 6.2 报表模板示例
|
||||||
|
|
||||||
|
**报表:部门操作日报**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 4A操作日报 - 日期:2026-05-17 │
|
||||||
|
│ 部门:北京分公司/销售部 │
|
||||||
|
├────────────────────────────────────────────────────────────────┤
|
||||||
|
│ 一、登录统计 │
|
||||||
|
│ ┌─────────────────────┬────────┬────────┬────────┐ │
|
||||||
|
│ │ 项目 │ 总量 │ 成功 │ 失败 │ │
|
||||||
|
│ ├─────────────────────┼────────┼────────┼────────┤ │
|
||||||
|
│ │ 密码登录 │ 156 │ 148 │ 8 │ │
|
||||||
|
│ │ 人脸登录 │ 23 │ 22 │ 1 │ │
|
||||||
|
│ │ SIM卡登录 │ 12 │ 12 │ 0 │ │
|
||||||
|
│ │ 短信验证码登录 │ 45 │ 44 │ 1 │ │
|
||||||
|
│ └─────────────────────┴────────┴────────┴────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 二、权限变更 │
|
||||||
|
│ ┌─────────────────────┬────────┬────────┬────────┐ │
|
||||||
|
│ │ 操作类型 │ 次数 │ 涉及用户 │ 涉及应用 │ │
|
||||||
|
│ ├─────────────────────┼────────┼────────┼────────┤ │
|
||||||
|
│ │ 角色授予 │ 3 │ 3 │ 2 │ │
|
||||||
|
│ │ 角色回收 │ 1 │ 1 │ 1 │ │
|
||||||
|
│ │ 临时授权 │ 2 │ 2 │ 1 │ │
|
||||||
|
│ └─────────────────────┴────────┴────────┴────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 三、资产访问 │
|
||||||
|
│ ┌─────────────────────┬────────┬────────┐ │
|
||||||
|
│ │ 资产 │ 访问次数 │ 操作人员数 │ │
|
||||||
|
│ ├─────────────────────┼────────┼────────┤ │
|
||||||
|
│ │ 10.0.1.10 (DB-MAST)│ 12 │ 3 │ │
|
||||||
|
│ │ 10.0.2.20 (APP-01) │ 8 │ 2 │ │
|
||||||
|
│ └─────────────────────┴────────┴────────┘ │
|
||||||
|
│ │
|
||||||
|
│ 四、异常告警 │
|
||||||
|
│ ┌────────────────────────────┬────────┬──────────┐ │
|
||||||
|
│ │ 告警内容 │ 等级 │ 处理状态 │ │
|
||||||
|
│ ├────────────────────────────┼────────┼──────────┤ │
|
||||||
|
│ │ 用户张三 22:35 登录(异地) │ 中 │ 待处理 │ │
|
||||||
|
│ └────────────────────────────┴────────┴──────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 报表引擎设计
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
报表引擎:
|
||||||
|
调度器:
|
||||||
|
- 定时任务 (cron表达式)
|
||||||
|
- 按需触发 (API)
|
||||||
|
- 事件触发 (达到某告警阈值时自动生成详细报告)
|
||||||
|
|
||||||
|
数据源:
|
||||||
|
ES查询: 按事件类型/时间范围/用户/资产等组合查询
|
||||||
|
聚合: 使用ES的聚合API (terms/date_histogram/filters/cardinality)
|
||||||
|
补充: 需要时从MySQL读取用户/资产元数据
|
||||||
|
|
||||||
|
输出格式:
|
||||||
|
- HTML (内联样式, 可邮件发送)
|
||||||
|
- PDF (wkhtmltopdf / puppeteer)
|
||||||
|
- Excel (报表导出)
|
||||||
|
|
||||||
|
分发通道:
|
||||||
|
- 企业微信/钉钉机器人
|
||||||
|
- 邮件 (IMAP-SMTP技能)
|
||||||
|
- 4A管理台消息中心
|
||||||
|
- 短信通知(仅紧急告警)
|
||||||
|
|
||||||
|
报表存储:
|
||||||
|
- 生成的报表文件 → MinIO对象存储
|
||||||
|
- 元数据 → MySQL
|
||||||
|
- 保留周期: 日报30天, 周报6个月, 月报2年
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 与泰岳版比对关注点
|
||||||
|
|
||||||
|
| 项 | 自研方案 | 泰岳方案(推测) | 验证方法 |
|
||||||
|
|----|---------|-----------------|---------|
|
||||||
|
| 事件模型 | 统一JSON事件模型,ES存储 | 可能关系型数据库分表存储 | 事件查询效率对比(涉及跨类型查询场景) |
|
||||||
|
| 合规规则 | 可编程规则引擎,热加载 | 可能硬编码或有限规则 | 新增规则的上线周期对比 |
|
||||||
|
| 审计报表 | 模板引擎+ES聚合 | 可能定时SQL查询生成 | 复杂报表生成时间对比 |
|
||||||
|
| 存储方案 | ES按时间分区+ILM | 可能MySQL分表+归档 | 180天数据查询性能对比 |
|
||||||
|
| 实时告警 | 流式规则引擎 | 可能定时任务扫描 | 告警延迟对比 |
|
||||||
|
| 会话回放 | asciinema播放器 | 自有格式播放器 | 回放流畅度+存储效率对比 |
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
# 认证身份中心设计
|
||||||
|
|
||||||
|
## 1. 总体定位
|
||||||
|
|
||||||
|
认证身份中心是4A系统的**统一认证入口和账号数据基座**,合并了传统4A中"认证"和"身份"两个模块。核心职责:
|
||||||
|
|
||||||
|
- 维护全平台统一的账号数据模型
|
||||||
|
- 提供多因素认证能力(密码 + 人脸 + SIM卡识别 + 短信验证码)
|
||||||
|
- 管理用户会话和令牌(JWT签发/验证/刷新)
|
||||||
|
- 与现网账号平台保持账号数据实时/准实时一致
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 数据模型
|
||||||
|
|
||||||
|
### 2.1 核心账号表(T_ACCOUNT)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 账号主表
|
||||||
|
CREATE TABLE T_ACCOUNT (
|
||||||
|
account_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
-- 统一账号标识(与现网账号平台对齐)
|
||||||
|
uid VARCHAR(64) NOT NULL UNIQUE COMMENT '统一用户ID,与现网平台一致',
|
||||||
|
login_name VARCHAR(128) NOT NULL UNIQUE COMMENT '登录名',
|
||||||
|
display_name VARCHAR(256) COMMENT '显示姓名',
|
||||||
|
-- 身份信息
|
||||||
|
id_type TINYINT NOT NULL DEFAULT 1 COMMENT '身份类型:1-内部员工 2-外包 3-第三方 4-临时',
|
||||||
|
employee_id VARCHAR(64) COMMENT '工号',
|
||||||
|
org_code VARCHAR(64) COMMENT '所属组织编码',
|
||||||
|
org_path VARCHAR(512) COMMENT '组织全路径, 如:省公司/市公司/部门',
|
||||||
|
-- 联系方式
|
||||||
|
mobile VARCHAR(32) COMMENT '手机号',
|
||||||
|
email VARCHAR(256) COMMENT '邮箱',
|
||||||
|
-- 认证凭据
|
||||||
|
password_hash VARCHAR(256) COMMENT '密码哈希(BCrypt)',
|
||||||
|
password_salt VARCHAR(64) COMMENT '密码盐值',
|
||||||
|
password_version INT NOT NULL DEFAULT 1 COMMENT '密码策略版本号',
|
||||||
|
need_reset_password TINYINT NOT NULL DEFAULT 0 COMMENT '需要重置密码:0-否 1-是',
|
||||||
|
-- 人脸信息
|
||||||
|
face_template_id VARCHAR(128) COMMENT '人脸模板ID(金科侧)',
|
||||||
|
face_registered TINYINT NOT NULL DEFAULT 0 COMMENT '是否已注册人脸',
|
||||||
|
-- SIM卡信息
|
||||||
|
sim_imsi VARCHAR(64) COMMENT 'SIM卡IMSI(互联网SIM卡识别用)',
|
||||||
|
sim_phone VARCHAR(32) COMMENT 'SIM卡关联手机号',
|
||||||
|
sim_verified TINYINT NOT NULL DEFAULT 0 COMMENT 'SIM卡是否已验证',
|
||||||
|
-- 状态管理
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-禁用 1-正常 2-锁定 3-过期',
|
||||||
|
lock_reason VARCHAR(256) COMMENT '锁定原因',
|
||||||
|
last_login_ip VARCHAR(64) COMMENT '最后登录IP',
|
||||||
|
last_login_time DATETIME COMMENT '最后登录时间',
|
||||||
|
login_fail_count INT NOT NULL DEFAULT 0 COMMENT '连续登录失败次数',
|
||||||
|
unlock_time DATETIME COMMENT '自动解锁时间',
|
||||||
|
-- 有效期
|
||||||
|
effective_date DATE COMMENT '生效日期',
|
||||||
|
expire_date DATE COMMENT '失效日期',
|
||||||
|
-- 审计字段
|
||||||
|
created_by VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_by VARCHAR(64) NOT NULL,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0 COMMENT '逻辑删除:0-未删除 1-已删除',
|
||||||
|
|
||||||
|
INDEX idx_uid (uid),
|
||||||
|
INDEX idx_login_name (login_name),
|
||||||
|
INDEX idx_org_code (org_code),
|
||||||
|
INDEX idx_mobile (mobile),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号主表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 账号扩展属性表(T_ACCOUNT_ATTR)
|
||||||
|
|
||||||
|
支持与现网平台扩展属性的灵活映射,避免频繁改表。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE T_ACCOUNT_ATTR (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
account_id BIGINT NOT NULL COMMENT '关联账号ID',
|
||||||
|
attr_key VARCHAR(128) NOT NULL COMMENT '属性键',
|
||||||
|
attr_value VARCHAR(1024) COMMENT '属性值',
|
||||||
|
attr_type VARCHAR(32) COMMENT '值类型:string/number/date/json',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
UNIQUE KEY uk_account_attr (account_id, attr_key),
|
||||||
|
INDEX idx_attr_key (attr_key, attr_value(128))
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账号扩展属性表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 与现网账号平台拉齐方案
|
||||||
|
|
||||||
|
现网账号平台通常是多系统异构的(HR系统、OA、运维平台等各有账号体系)。拉齐策略:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 现网账号平台(多源) │
|
||||||
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │
|
||||||
|
│ │ HR系统 │ │ OA系统 │ │ 运维平台 │ │ 第三方接入 │ │
|
||||||
|
│ │ (工号为主)│ │(邮箱为主) │ │ (SSH用户) │ │ (API对接) │ │
|
||||||
|
│ └─────┬────┘ └────┬─────┘ └─────┬────┘ └──────┬───────┘ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ └──────┬─────┴──────┬──────┴──────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌──────▼────────────▼──────┐ │
|
||||||
|
│ │ 账号同步适配器层 │ │
|
||||||
|
│ │ (定时拉取 + 变更推送) │ │
|
||||||
|
│ │ 策略:现网→自研单向同步 │ │
|
||||||
|
│ │ 冲突:以现网最新时间为准 │ │
|
||||||
|
│ └───────────┬──────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────▼──────────────┐ │
|
||||||
|
│ │ 自研T_ACCOUNT主表 │ │
|
||||||
|
│ │ (以uid为统一标识) │ │
|
||||||
|
│ │ 额外字段:sync_source │ │
|
||||||
|
│ │ sync_version │ │
|
||||||
|
│ └──────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**同步策略关键点**:
|
||||||
|
|
||||||
|
1. **单向同步**:现网平台 → 自研系统(防止自研的修改反向污染现网)
|
||||||
|
2. **标识统一**:`uid` 字段作为跨系统唯一标识,映射现网各系统 ID
|
||||||
|
3. **定时+事件驱动**:
|
||||||
|
- 定时全量同步(每天凌晨)
|
||||||
|
- 变更事件订阅(MQ消息,实时增量同步)
|
||||||
|
- 手动触发全量同步(管理员操作)
|
||||||
|
4. **冲突处理**:
|
||||||
|
- 自研系统只读现网字段,不做覆盖
|
||||||
|
- 现网字段为主版本,自研扩展字段(如人脸模板ID)为从版本
|
||||||
|
- 时间戳精度到毫秒级,解决并发冲突
|
||||||
|
5. **挂载表记录同步日志**:T_ACCOUNT_SYNC_LOG 记录每次同步的变更明细
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 认证能力接入
|
||||||
|
|
||||||
|
### 3.1 认证流程总图
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ 统一认证入口 │
|
||||||
|
│ POST /api/v1/auth │
|
||||||
|
└──────────┬───────────┘
|
||||||
|
│
|
||||||
|
┌─────────────────┼─────────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌─────▼─────┐ ┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ 密码认证 │ │ 人脸认证 │ │ SIM认证 │
|
||||||
|
│ provider │ │ provider │ │ provider │
|
||||||
|
└─────┬─────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
└────────┬────────┴────────┬────────┘
|
||||||
|
│ │
|
||||||
|
┌──────▼──────┐ ┌──────▼──────┐
|
||||||
|
│ 认证成功 │ │ 认证失败 │
|
||||||
|
│ → 生成令牌 │ │ → 记录失败 │
|
||||||
|
│ → 返回JWT │ │ → 触发告警 │
|
||||||
|
└─────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 密码认证
|
||||||
|
|
||||||
|
- **技术选型**:BCrypt(自适应哈希,cost factor=10-12)
|
||||||
|
- **策略**:
|
||||||
|
- 支持密码复杂度策略(长度/大小写/特殊字符组合)
|
||||||
|
- 支持密码历史管理(禁止使用最近N次密码)
|
||||||
|
- 连续失败N次后锁定账号 T 分钟
|
||||||
|
- 支持首次登录强制改密
|
||||||
|
- **接口**:标准 OAuth2 Password Grant + 自定义扩展
|
||||||
|
|
||||||
|
### 3.3 金科人脸识别接入
|
||||||
|
|
||||||
|
**集成方案**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 金科人脸识别对接配置
|
||||||
|
face_recognition:
|
||||||
|
provider: "jinke"
|
||||||
|
api_base: "https://face-api.chinamobile.com/v2"
|
||||||
|
auth_mode: "aksk" # API Key + Secret Key 认证
|
||||||
|
timeout_ms: 5000
|
||||||
|
retry:
|
||||||
|
max_attempts: 2
|
||||||
|
backoff_ms: 200
|
||||||
|
endpoints:
|
||||||
|
verify: "/face/verify" # 1:1 人脸验证
|
||||||
|
detect: "/face/detect" # 人脸检测
|
||||||
|
register: "/face/register" # 人脸模板注册
|
||||||
|
delete: "/face/template/delete" # 删除人脸模板
|
||||||
|
liveness:
|
||||||
|
enabled: true # 活体检测开关
|
||||||
|
mode: "action" # action: 动作指令式, flash: 闪光
|
||||||
|
```
|
||||||
|
|
||||||
|
**认证流程**:
|
||||||
|
|
||||||
|
1. 客户端采集人脸图片 → 上传至自研后台
|
||||||
|
2. 自研后台调用金科 `/face/detect` 检测有效性(活体检测)
|
||||||
|
3. 通过后调用 `/face/verify` 传入用户 `uid` + 人脸图片
|
||||||
|
4. 金科返回置信度和匹配结果(阈值 >= 0.85 视为通过)
|
||||||
|
5. 自研后台记录认证日志,签发JWT
|
||||||
|
|
||||||
|
### 3.4 互联网SIM卡识别
|
||||||
|
|
||||||
|
**能力说明**:
|
||||||
|
- 利用运营商网关能力,识别用户手机号与SIM卡IMSI的绑定关系
|
||||||
|
- 用户通过蜂窝网络访问时,网关可携带SIM卡信息
|
||||||
|
- 实现"无感认证"——用户只需打开页面即可自动识别身份
|
||||||
|
|
||||||
|
**接入方案**:
|
||||||
|
|
||||||
|
```
|
||||||
|
用户手机(蜂窝网络) ──→ 运营商网关 ──→ 自研4A网关
|
||||||
|
↑ ↑
|
||||||
|
HTTP Header携带 解析Header提取
|
||||||
|
IMSI/MSISDN SIM信息,与账号
|
||||||
|
T_ACCOUNT.sim_imsi
|
||||||
|
做匹配验证
|
||||||
|
```
|
||||||
|
|
||||||
|
**技术要点**:
|
||||||
|
|
||||||
|
1. 依赖运营商网关配置(需与网络部协调开通)
|
||||||
|
2. 作为辅助认证因子,不可单独作为强认证手段
|
||||||
|
3. 需配合设备指纹、IP地理位置等信息做风险判断
|
||||||
|
4. 当SIM信息与预注册信息不一致时,触发二次认证(短信验证码)
|
||||||
|
|
||||||
|
### 3.5 消息中心短信接入
|
||||||
|
|
||||||
|
**集成方案**:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
sms:
|
||||||
|
provider: "message_center" # 中国移动消息中心
|
||||||
|
api_base: "https://msg-center.chinamobile.com/api"
|
||||||
|
app_id: "${SMS_APP_ID}"
|
||||||
|
app_secret: "${SMS_APP_SECRET}"
|
||||||
|
endpoints:
|
||||||
|
send: "/sms/send"
|
||||||
|
batch_send: "/sms/batch_send"
|
||||||
|
query_status: "/sms/status/{msg_id}"
|
||||||
|
features:
|
||||||
|
template: true # 使用短信模板(防篡改)
|
||||||
|
signature: "【中国移动】"
|
||||||
|
expiry_minutes: 5 # 验证码有效期5分钟
|
||||||
|
```
|
||||||
|
|
||||||
|
**流程**:
|
||||||
|
|
||||||
|
1. 发送验证码:生成6位数字验证码 → 写入 Redis(key=sms:code:{mobile}, TTL=300s)→ 调用消息中心API下发
|
||||||
|
2. 验证:用户提交验证码 → 查 Redis 比对 → 正确则完成认证
|
||||||
|
3. 限制策略:60秒内不可重复发送,每日上限10次
|
||||||
|
|
||||||
|
### 3.6 多因素认证组合策略
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ MFA策略配置(可动态调整) │
|
||||||
|
│ │
|
||||||
|
│ 场景类型 │ 认证因子组合 │
|
||||||
|
│ ──────────────────┼─────────────────────────────────────────── │
|
||||||
|
│ 内部网络登录 │ 密码 (单因素) │
|
||||||
|
│ 远程VPN登录 │ 密码 + 短信验证码 (双因素) │
|
||||||
|
│ 堡垒机登录 │ 密码 + 人脸/SIM (双因素) │
|
||||||
|
│ 敏感操作确认 │ 已登录令牌 + 短信验证码 (双因素) │
|
||||||
|
│ 管理员操作 │ 密码 + 人脸 + 短信验证码 (三因素) │
|
||||||
|
│ SIM卡网关识别 │ SIM识别 (零操作) │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 令牌与会话管理
|
||||||
|
|
||||||
|
### 4.1 JWT令牌设计
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": {
|
||||||
|
"header": {"alg": "RS256", "typ": "JWT", "kid": "2026-01"},
|
||||||
|
"payload": {
|
||||||
|
"sub": "uid_00123",
|
||||||
|
"iss": "4a-self",
|
||||||
|
"aud": "4a-resource-centers",
|
||||||
|
"exp": 3600,
|
||||||
|
"iat": 1700000000,
|
||||||
|
"jti": "unique-token-id-abc123",
|
||||||
|
"auth_context": {
|
||||||
|
"mfa_level": 2,
|
||||||
|
"auth_methods": ["password", "sms"],
|
||||||
|
"session_id": "sess_xyz789"
|
||||||
|
},
|
||||||
|
"roles": ["operator", "auditor"]
|
||||||
|
},
|
||||||
|
"sign": "RS256_signature..."
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"expiry": 86400 * 7,
|
||||||
|
"rotation": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **签名算法**:RS256(非对称),access_token 和 refresh_token 使用不同密钥对
|
||||||
|
- **有效期**:access_token 1小时,refresh_token 7天(支持滑动刷新)
|
||||||
|
- **吊销机制**:Redis 维护黑名单(`jti:revoked`),注销/改密后立即使所有token失效
|
||||||
|
|
||||||
|
### 4.2 会话状态存储
|
||||||
|
|
||||||
|
```
|
||||||
|
Redis Key 设计:
|
||||||
|
4a:session:{session_id} → Session对象(用户信息、MFA等级、设备指纹)
|
||||||
|
4a:token:jti:{jti} → 令牌元数据(issuer_ip、issued_at、status)
|
||||||
|
4a:token:user:{uid} → 用户当前有效令牌列表
|
||||||
|
4a:token:blacklist:{jti} → 吊销令牌集合(TTL = token有效期)
|
||||||
|
4a:sms:code:{mobile} → 短信验证码(TTL = 300s)
|
||||||
|
4a:login:fail:{uid} → 登录失败计数(TTL = 锁定周期)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 接口规范
|
||||||
|
|
||||||
|
### 5.1 认证接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/v1/auth/login` | POST | 统一登录入口,请求体指定认证方式 |
|
||||||
|
| `/api/v1/auth/refresh` | POST | 刷新access_token |
|
||||||
|
| `/api/v1/auth/logout` | POST | 登出,吊销令牌 |
|
||||||
|
| `/api/v1/auth/check` | GET | 校验令牌有效性 |
|
||||||
|
| `/api/v1/auth/sms/send` | POST | 发送短信验证码 |
|
||||||
|
| `/api/v1/auth/face/register` | POST | 注册人脸模板 |
|
||||||
|
| `/api/v1/auth/sim/verify` | POST | SIM卡验证 |
|
||||||
|
|
||||||
|
### 5.2 账号管理接口
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/api/v1/accounts` | GET | 分页查询账号列表 |
|
||||||
|
| `/api/v1/accounts/{uid}` | GET | 查询单个账号详情 |
|
||||||
|
| `/api/v1/accounts` | POST | 创建账号(同步到现网需审批) |
|
||||||
|
| `/api/v1/accounts/{uid}` | PUT | 更新账号信息 |
|
||||||
|
| `/api/v1/accounts/{uid}` | DELETE | 禁用/删除账号 |
|
||||||
|
| `/api/v1/accounts/{uid}/password` | PUT | 修改密码 |
|
||||||
|
| `/api/v1/accounts/{uid}/status` | PATCH | 修改账号状态 |
|
||||||
|
| `/api/v1/accounts/sync/trigger` | POST | 手动触发全量同步 |
|
||||||
|
| `/api/v1/accounts/sync/log` | GET | 查询同步日志 |
|
||||||
|
|
||||||
|
### 5.3 响应格式
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 成功响应
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {},
|
||||||
|
"request_id": "req-uuid-xxx"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 错误响应
|
||||||
|
{
|
||||||
|
"code": 40101,
|
||||||
|
"message": "invalid_credentials",
|
||||||
|
"detail": "密码错误,还剩3次尝试机会",
|
||||||
|
"request_id": "req-uuid-xxx"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 与泰岳版比对关注点
|
||||||
|
|
||||||
|
| 项 | 自研方案 | 泰岳方案(推测) | 验证方法 |
|
||||||
|
|----|---------|-----------------|---------|
|
||||||
|
| 密码存储 | BCrypt + 多代盐值 | 厂商自研加密模块 | 黑盒:记录认证耗时 |
|
||||||
|
| 会话管理 | Redis + JWT | 可能为DB + Session | 比较并发下Session创建吞吐 |
|
||||||
|
| 认证流程扩展 | Provider模式,可插拔 | 模块化程度未知 | 接入新因子时的改动量对比 |
|
||||||
|
| 多因素组合 | 策略引擎(DSL配置) | 可能硬编码 | 配置灵活度对比 |
|
||||||
|
| 账号同步 | 适配器模式对接多源 | 可能单源集中 | 同步延迟和完整性对比 |
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
# 自研4A对标系统 - 总体架构说明
|
||||||
|
|
||||||
|
## 1. 系统定位
|
||||||
|
|
||||||
|
### 1.1 为什么做
|
||||||
|
|
||||||
|
现网4A系统为神州泰岳采购版,存在以下核心痛点:
|
||||||
|
- **黑盒依赖**:核心数据模型、流程引擎、认证逻辑不可控,定制需求依赖厂商排期(通常3-6个月)
|
||||||
|
- **报价不透明**:二期扩容、功能增补的报价缺乏比价基准
|
||||||
|
- **技术演进受限**:无法灵活接入新认证方式(人脸、SIM卡识别等),扩展成本高
|
||||||
|
- **运维锁死**:故障排查依赖厂商支撑,MTTR难以压缩
|
||||||
|
|
||||||
|
本项目利用**1级4A 8期工程已申请的硬件资源**,划拨部分服务器,自研一套与泰岳版功能对标的4A系统,周期**两个月**。
|
||||||
|
|
||||||
|
### 1.2 核心目标
|
||||||
|
|
||||||
|
| 维度 | 目标 |
|
||||||
|
|------|------|
|
||||||
|
| **功能对标** | 覆盖泰岳版90%以上的核心功能(SSO、账号管理、权限管理、审计) |
|
||||||
|
| **流程对标** | 实现对标的24个账号管理流程、资源接入流程 |
|
||||||
|
| **架构验证** | 验证自研方案在性能、稳定性、安全性上是否可替代采购版 |
|
||||||
|
| **成本基线** | 建立"自研 vs 采购"的成本-能力对比基线,支撑后续采购决策 |
|
||||||
|
| **技术自主** | 形成完全可控的4A技术栈,后续功能迭代不受厂商约束 |
|
||||||
|
|
||||||
|
### 1.3 约束条件
|
||||||
|
|
||||||
|
- **周期**:2个月(包含开发、测试、部署、基线对比)
|
||||||
|
- **硬件**:8期工程剩余资源(具体规格视项目申请而定,按通用X86服务器 + KVM虚拟化部署)
|
||||||
|
- **团队**:非专职团队,需兼顾现网运维,强调AI辅助开发提效
|
||||||
|
- **范围**:不做全量替代,做"可验证对标"版本,功能深度聚焦核心链路
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 四中心架构总览
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 自研4A对标系统 │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────┐ │
|
||||||
|
│ │ 认证身份中心 │ │ 应用资源管理中心 │ │ 系统资源管理中心 │ │
|
||||||
|
│ │ │ │ │ │ (含堡垒集群) │ │
|
||||||
|
│ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │ ┌───────────────┐ │ │
|
||||||
|
│ │ │ 账号数据模型 │ │ │ │ SSO网关 │ │ │ │ 系统资源 │ │ │
|
||||||
|
│ │ │ (LDAP/DB) │ │ │ │ (OIDC/SAML) │ │ │ │ 授权模型 │ │ │
|
||||||
|
│ │ ├───────────────┤ │ │ ├───────────────┤ │ │ ├───────────────┤ │ │
|
||||||
|
│ │ │ 多因素认证 │ │ │ │ 应用注册 │ │ │ │ 堡垒机集群 │ │ │
|
||||||
|
│ │ │ 人脸/SIM/密码 │ │ │ │ +元数据管理 │ │ │ │ SSO/录屏 │ │ │
|
||||||
|
│ │ ├───────────────┤ │ │ ├───────────────┤ │ │ ├───────────────┤ │ │
|
||||||
|
│ │ │ 令牌/会话管理 │ │ │ │ 权限配置 │ │ │ │ 权限 │ │ │
|
||||||
|
│ │ │ (JWT/Redis) │ │ │ │ +授权规则引擎 │ │ │ │ 回收/审批流 │ │ │
|
||||||
|
│ │ └───────────────┘ │ │ └───────────────┘ │ │ └───────────────┘ │ │
|
||||||
|
│ └─────────┬───────────┘ └─────────┬───────────┘ └─────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └──────────┬─────────────┴─────────────┬───────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ ┌────────▼──────────────────────────▼────────┐ │
|
||||||
|
│ │ 审计中心 │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ ┌──────────────┐ ┌───────────────────┐ │ │
|
||||||
|
│ │ │ 审计数据湖 │ │ 报表引擎 │ │ │
|
||||||
|
│ │ │ (ES引擎存储) │ │ (定时/按需生成) │ │ │
|
||||||
|
│ │ ├──────────────┤ ├───────────────────┤ │ │
|
||||||
|
│ │ │ 合规规则引擎 │ │ 分级下发通道 │ │ │
|
||||||
|
│ │ │ (策略驱动) │ │ (部门级/公司级) │ │ │
|
||||||
|
│ │ └──────────────┘ └───────────────────┘ │ │
|
||||||
|
│ └────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ AI全流程兜底闭环层 │ │
|
||||||
|
│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │
|
||||||
|
│ │ │代码生成 │ │自动测试 │ │部署编排 │ │智能运维 │ │数据一致性│ │ │
|
||||||
|
│ │ │Copilot │ │Agent │ │CI/CD │ │AIOps │ │校验Agent │ │ │
|
||||||
|
│ │ └──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.1 中心间交互关系
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────┐
|
||||||
|
│ 前端网关 │
|
||||||
|
│ (统一入口) │
|
||||||
|
└────────┬────────┘
|
||||||
|
│
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌───────▼──────┐ ┌────▼─────┐ ┌─────▼──────┐
|
||||||
|
│ 认证身份中心 │ │ 应用资源 │ │ 系统资源 │
|
||||||
|
│ (认证鉴权) │ │ 管理中心 │ │ 管理中心 │
|
||||||
|
└───────┬──────┘ └────┬─────┘ └─────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
└──────┬───────┴──────┬──────┘
|
||||||
|
│ │
|
||||||
|
┌──────▼──────────────▼──────┐
|
||||||
|
│ 审计中心 │
|
||||||
|
│ (接收全量操作事件) │
|
||||||
|
└────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**核心数据流**:
|
||||||
|
1. 用户请求 → 前端网关 → 认证身份中心(登录认证)→ 颁发令牌
|
||||||
|
2. 用户携带令牌 → 应用资源管理中心(SSO接入应用)/ 系统资源管理中心(SSO接入堡垒机)
|
||||||
|
3. 全量操作事件 → 异步写入 → 审计中心(ES存储)
|
||||||
|
4. 审计中心 → 合规规则引擎 → 异常告警/报表生成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 两个月研发计划
|
||||||
|
|
||||||
|
```
|
||||||
|
Week 1-2: 基础设施搭建 + 认证身份中心核心 (账号模型 + 密码认证 + JWT)
|
||||||
|
Week 3-4: 多因素认证接入 (人脸/SIM/短信) + 令牌管理
|
||||||
|
Week 5-6: 应用资源管理中心 (SSO网关 + 应用注册 + 基本授权)
|
||||||
|
Week 7: 系统资源管理中心 + 堡垒机SSO原型
|
||||||
|
Week 8: 审计中心MVP + AI测试 + 与泰岳版比对验证
|
||||||
|
```
|
||||||
|
|
||||||
|
### 交付原则
|
||||||
|
|
||||||
|
- **每两周一个可演示版本**:第一周结束出认证登录页,第二周结束出SSO跳转
|
||||||
|
- **AI优先**:能用AI生成的代码、测试用例、部署脚本绝不手写
|
||||||
|
- **模块化**:每个中心独立可部署,降低集成风险
|
||||||
|
- **记录一致**:所有反推结论/接口规范写入文档,作为与泰岳谈判的技术基线
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. AI全流程兜底闭环策略
|
||||||
|
|
||||||
|
详见 `ai-cicd.md`,核心思路:
|
||||||
|
|
||||||
|
1. **开发阶段**:AI辅助生成全部业务代码(CRUD + API + 前端),人工聚焦架构和安全性审查
|
||||||
|
2. **测试阶段**:AI Agent自动生成测试用例、执行回归测试、比对与泰岳版的行为差异
|
||||||
|
3. **部署阶段**:AI编排部署流水线(Ansible/Terraform),自动处理配置差异
|
||||||
|
4. **运维阶段**:AI监控日志异常、自动排查一致性偏差、推送修复建议
|
||||||
|
5. **验收阶段**:AI驱动功能覆盖度扫描,自动生成"自研vs泰岳"比对报告
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 与泰岳版比对策略
|
||||||
|
|
||||||
|
| 比对维度 | 方法 | 产出物 |
|
||||||
|
|---------|------|-------|
|
||||||
|
| 功能覆盖 | 逐功能点人工标记 + AI辅助扫描 | 功能覆盖矩阵表 |
|
||||||
|
| 性能基准 | 相同硬件上运行压测脚本 | 响应时间/并发量对比曲线 |
|
||||||
|
| 认证流程 | 录制泰岳版操作流程 → 生成测试脚本 → 在自研版本执行 | 流程一致度报告 |
|
||||||
|
| 数据模型 | 反推泰岳库结构 → 与自研模型逐字段比对 | 数据模型差异表 |
|
||||||
|
| 安全等级 | 相同安全基线扫描工具 | 安全差距分析 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 风险与对策
|
||||||
|
|
||||||
|
| 风险 | 概率 | 影响 | 对策 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 两个月内功能覆盖不足 | 高 | 中 | 聚焦核心链路(SSO+账户管理),非核心功能标记为"二期" |
|
||||||
|
| 泰岳接口反向推导不完整 | 中 | 高 | 重点攻克登录/授权关键接口,优先保证自研体系内部闭环 |
|
||||||
|
| 团队人力分散 | 高 | 中 | AI最大化替代重复劳动,每个中心配1-2人即可启动 |
|
||||||
|
| 与现网系统对接兼容问题 | 中 | 高 | 提前识别对接点,使用适配器模式隔离变更 |
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
# 应用资源管理中心 + 系统资源管理中心(含堡垒集群)
|
||||||
|
|
||||||
|
> 本文件涵盖2个中心的设计,考虑到它们共享权限模型和SSO机制,合并在同一份文档中。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第一部分:应用资源管理中心
|
||||||
|
|
||||||
|
## 1. 总体定位
|
||||||
|
|
||||||
|
应用资源管理中心负责企业内**业务应用的单点登录接入和权限管理**。核心职责:
|
||||||
|
|
||||||
|
- 提供统一SSO网关,对接各类业务系统(B/S架构为主)
|
||||||
|
- 管理应用注册、元数据、接入配置
|
||||||
|
- 提供权限模型和授权规则引擎
|
||||||
|
- 参考亚信安全接口规范(IAM/SSO相关)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. SSO网关设计
|
||||||
|
|
||||||
|
### 2.1 支持的协议
|
||||||
|
|
||||||
|
| 协议 | 成熟度 | 适用场景 | 优先级 |
|
||||||
|
|------|--------|---------|--------|
|
||||||
|
| OAuth2.0 + OIDC | 高 | 现代应用(RESTful API) | P0 |
|
||||||
|
| SAML 2.0 | 高 | 传统企业应用、ERP系统 | P0 |
|
||||||
|
| CAS | 中 | 部分遗留Java应用 | P1 |
|
||||||
|
| 自定义Token透传 | 低 | 老系统改造过渡 | P2 |
|
||||||
|
|
||||||
|
### 2.2 SSO流程(OIDC Authorization Code)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────┐ ┌──────────┐ ┌──────────────┐ ┌──────────┐
|
||||||
|
│ 用户 │ │ 业务应用 │ │ 认证身份中心 │ │SSO网关 │
|
||||||
|
│ │ │ (Client) │ │ (Auth Server) │ │ │
|
||||||
|
└──┬───┘ └─────┬────┘ └──────┬───────┘ └─────┬────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ 访问应用 │ │ │
|
||||||
|
│───────────────>│ │ │
|
||||||
|
│ │ 未登录,跳转SSO │ │
|
||||||
|
│<───────────────│ │ │
|
||||||
|
│ 302重定向 │ │ │
|
||||||
|
│──────────────────────────────────>│ │
|
||||||
|
│ │ │ SSO网关代收 │
|
||||||
|
│ │ │ 认证请求,返回 │
|
||||||
|
│ 登录页 │ │ 授权码 │
|
||||||
|
│<──────────────────────────────────│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 提交凭证 │ │ │
|
||||||
|
│──────────────────────────────────>│ │
|
||||||
|
│ │ │ 验证通过 │
|
||||||
|
│ │ │ 返回授权码 │
|
||||||
|
│<──────────────────────────────────│ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 携带授权码回调 │ │ │
|
||||||
|
│──────────────────────────────────────────────> │
|
||||||
|
│ │ │ SSO网关 │
|
||||||
|
│ │ │ 校验+与身份中心 │
|
||||||
|
│ │ │ 交换AccessToken │
|
||||||
|
│ │ <───────────────────────────────────│
|
||||||
|
│ │ │ │
|
||||||
|
│ 登录成功 │ │ │
|
||||||
|
│<───────────────│ │ │
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 应用注册模型
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE T_APP (
|
||||||
|
app_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
app_name VARCHAR(128) NOT NULL COMMENT '应用名称',
|
||||||
|
app_key VARCHAR(64) NOT NULL UNIQUE COMMENT '应用唯一标识',
|
||||||
|
app_secret VARCHAR(256) NOT NULL COMMENT '应用密钥(加密存储)',
|
||||||
|
app_type TINYINT NOT NULL DEFAULT 1 COMMENT '应用类型:1-B/S 2-C/S 3-API 4-移动端',
|
||||||
|
-- SSO配置
|
||||||
|
sso_protocol VARCHAR(16) NOT NULL DEFAULT 'oidc' COMMENT 'SSO协议:oidc/saml/cas',
|
||||||
|
callback_urls JSON NOT NULL COMMENT '允许的回调URL列表',
|
||||||
|
logout_url VARCHAR(512) COMMENT '登出回调URL',
|
||||||
|
token_endpoint_auth_method VARCHAR(32) DEFAULT 'client_secret_basic',
|
||||||
|
-- 应用访问控制
|
||||||
|
access_level TINYINT NOT NULL DEFAULT 1 COMMENT '访问等级:1-普通 2-敏感 3-高敏感',
|
||||||
|
require_mfa TINYINT NOT NULL DEFAULT 0 COMMENT '是否需要MFA',
|
||||||
|
allowed_ips JSON COMMENT 'IP白名单',
|
||||||
|
allowed_domains JSON COMMENT '域名白名单',
|
||||||
|
-- 权限模型
|
||||||
|
permission_mode VARCHAR(16) DEFAULT 'rbac' COMMENT '权限模式:rbac/abac/hybrid',
|
||||||
|
-- 状态
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0-停用 1-启用',
|
||||||
|
-- 审计
|
||||||
|
created_by VARCHAR(64) NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
INDEX idx_app_key (app_key),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='应用注册表';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 权限模型(参考亚信接口规范)
|
||||||
|
|
||||||
|
### 3.1 RBAC + ABAC 混合模型
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┐
|
||||||
|
│ 用户 │
|
||||||
|
│ (User) │
|
||||||
|
└────┬─────┘
|
||||||
|
│
|
||||||
|
┌─────────┼─────────┐
|
||||||
|
│ │ │
|
||||||
|
┌─────▼──┐ ┌────▼───┐ ┌────▼───┐
|
||||||
|
│ 组织(Org)│ │ 角色 │ │ 属性 │
|
||||||
|
│ (ABAC) │ │ (Role) │ │(Attr) │
|
||||||
|
└────┬───┘ └────┬───┘ └────┬───┘
|
||||||
|
│ │ │
|
||||||
|
│ ┌─────▼──────┐ │
|
||||||
|
│ │ 权限集 │ │
|
||||||
|
└────┤ (Permission)├──┘
|
||||||
|
│ +策略规则 │
|
||||||
|
└─────┬──────┘
|
||||||
|
│
|
||||||
|
┌─────▼──────┐
|
||||||
|
│ 资源实例 │
|
||||||
|
│ (Resource) │
|
||||||
|
└────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**权限判定引擎流程**:
|
||||||
|
|
||||||
|
```
|
||||||
|
输入: 用户U, 应用A, 操作OP, 资源R, 上下文C
|
||||||
|
|
||||||
|
1. 检查U是否在A的应用白名单中(组织级) → 否→拒绝
|
||||||
|
2. 获取U在A中的角色列表 → 获取角色对应的权限集
|
||||||
|
3. 检查权限集是否包含 (OP, R) → 是→允许
|
||||||
|
4. ABAC规则评估:检查上下文C(时间/IP/设备/MFA等级)
|
||||||
|
→ ABAC规则不满足 → 降级/拒绝
|
||||||
|
5. 返回判定结果
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 核心权限表结构
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 角色表
|
||||||
|
CREATE TABLE T_ROLE (
|
||||||
|
role_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
app_id BIGINT NOT NULL COMMENT '所属应用',
|
||||||
|
role_name VARCHAR(64) NOT NULL COMMENT '角色名',
|
||||||
|
role_code VARCHAR(64) NOT NULL COMMENT '角色编码(应用内唯一)',
|
||||||
|
description VARCHAR(256),
|
||||||
|
is_system TINYINT NOT NULL DEFAULT 0 COMMENT '系统预置角色',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_app_role (app_id, role_code)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
|
||||||
|
|
||||||
|
-- 权限资源表
|
||||||
|
CREATE TABLE T_PERMISSION (
|
||||||
|
perm_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
app_id BIGINT NOT NULL COMMENT '所属应用',
|
||||||
|
resource VARCHAR(128) NOT NULL COMMENT '资源标识: menu:/user/list',
|
||||||
|
action VARCHAR(32) NOT NULL COMMENT '操作: read/write/delete/admin',
|
||||||
|
description VARCHAR(256),
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_app_resource_action (app_id, resource, action)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限资源表';
|
||||||
|
|
||||||
|
-- 角色-权限关联
|
||||||
|
CREATE TABLE T_ROLE_PERMISSION (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
role_id BIGINT NOT NULL,
|
||||||
|
perm_id BIGINT NOT NULL,
|
||||||
|
effect TINYINT NOT NULL DEFAULT 1 COMMENT '1-允许 0-拒绝',
|
||||||
|
UNIQUE KEY uk_role_perm (role_id, perm_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
|
||||||
|
|
||||||
|
-- 用户-应用角色分配
|
||||||
|
CREATE TABLE T_USER_APP_ROLE (
|
||||||
|
id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
account_id BIGINT NOT NULL COMMENT '用户账号ID',
|
||||||
|
app_id BIGINT NOT NULL COMMENT '应用ID',
|
||||||
|
role_id BIGINT NOT NULL COMMENT '角色ID',
|
||||||
|
grant_type VARCHAR(16) DEFAULT 'direct' COMMENT 'direct-直接分配 org-继承',
|
||||||
|
start_date DATETIME,
|
||||||
|
end_date DATETIME COMMENT '有效期',
|
||||||
|
granted_by VARCHAR(64),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_account_app_role (account_id, app_id, role_id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户应用角色分配表';
|
||||||
|
|
||||||
|
-- ABAC策略规则表
|
||||||
|
CREATE TABLE T_ABAC_POLICY (
|
||||||
|
policy_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
app_id BIGINT NOT NULL,
|
||||||
|
policy_name VARCHAR(128) NOT NULL,
|
||||||
|
policy_type VARCHAR(16) NOT NULL COMMENT 'allow/deny',
|
||||||
|
subject_attr JSON COMMENT '主体属性条件',
|
||||||
|
resource_attr JSON COMMENT '资源属性条件',
|
||||||
|
action_cond JSON COMMENT '操作条件',
|
||||||
|
context_cond JSON COMMENT '上下文条件(时间/IP/设备等)',
|
||||||
|
priority INT NOT NULL DEFAULT 0 COMMENT '优先级(数字越大越优先)',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='ABAC策略规则表';
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 与应用系统的权限接口规范
|
||||||
|
|
||||||
|
参考亚信安全接口规范,提供以下核心接口供业务系统对接:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
授权查询接口:
|
||||||
|
GET /api/v1/authorization/check
|
||||||
|
参数:
|
||||||
|
- app_key: 应用标识
|
||||||
|
- uid: 用户ID
|
||||||
|
- resource: 资源路径
|
||||||
|
- action: 操作
|
||||||
|
- context: 上下文(可选JSON)
|
||||||
|
返回: { allowed: true/false, reason: "xxx" }
|
||||||
|
|
||||||
|
用户角色列表:
|
||||||
|
GET /api/v1/apps/{app_key}/users/{uid}/roles
|
||||||
|
返回: [{role_id, role_code, role_name, grant_type, valid_until}]
|
||||||
|
|
||||||
|
用户权限列表:
|
||||||
|
GET /api/v1/apps/{app_key}/users/{uid}/permissions
|
||||||
|
返回: [{resource, action, effect}]
|
||||||
|
|
||||||
|
权限批量校验:
|
||||||
|
POST /api/v1/authorization/batch-check
|
||||||
|
请求体: [{app_key, uid, resource, action, context}, ...]
|
||||||
|
返回: [{allowed, reason}, ...]
|
||||||
|
|
||||||
|
权限同步通知(服务端推送):
|
||||||
|
POST /webhook/app/{app_key}/permission-sync
|
||||||
|
请求体: {event_type: "role_assign"|"role_revoke"|"perm_change",
|
||||||
|
uid, changed_roles, timestamp}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 授权流程
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
|
||||||
|
│ 资源申请者 │ │ 审批人 │ │ 权限中心 │ │ 业务应用 │
|
||||||
|
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
|
||||||
|
│ │ │ │
|
||||||
|
│ 提交权限申请 │ │ │
|
||||||
|
│────────────────>│ │ │
|
||||||
|
│ │ │ │
|
||||||
|
│ 审批通过/拒绝 │ │
|
||||||
|
│ │────────────────>│ │
|
||||||
|
│ │ │ 分配角色 │
|
||||||
|
│ │ │────────────────>│
|
||||||
|
│ │ │ (授权生效通知) │
|
||||||
|
│ 申请结果通知 │ │ │
|
||||||
|
│<───────────────────────────────────────────────────│
|
||||||
|
│ │ │ │
|
||||||
|
│ 使用授权 │ │ │
|
||||||
|
│───────────────────────────────────────────────────>│
|
||||||
|
│ │ │ 鉴权校验 │
|
||||||
|
│<───────────────────────────────────────────────────│
|
||||||
|
```
|
||||||
|
|
||||||
|
**授权模式**支持:
|
||||||
|
1. **手动分配**:管理员为指定用户分配角色
|
||||||
|
2. **申请审批**:用户提交申请 → 上级/安全管理员审批 → 自动生效
|
||||||
|
3. **组织继承**:自动继承所属组织的默认角色集
|
||||||
|
4. **临时授权**:设定生效时间窗口,到期自动回收
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二部分:系统资源管理中心(含堡垒集群)
|
||||||
|
|
||||||
|
## 5. 总体定位
|
||||||
|
|
||||||
|
系统资源管理中心负责**服务器、网络设备、数据库等基础设施资源的权限管理**,包含堡垒机集群集成。核心职责:
|
||||||
|
|
||||||
|
- 管理系统资源的访问权限(SSH/RDP/数据库连接)
|
||||||
|
- 提供堡垒机单点登录和会话录制
|
||||||
|
- 参考泰岳管理模型(反推库结构和接口)
|
||||||
|
- 管理登录凭证、密钥、授权策略
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 参考泰岳管理模型 - 反推策略
|
||||||
|
|
||||||
|
### 6.1 泰岳系统资源管理模型推断
|
||||||
|
|
||||||
|
基于现网泰岳版4A的运维经验和操作界面观察,反推其数据模型:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 推断的泰岳资产表结构
|
||||||
|
-- T_ASSET
|
||||||
|
-- asset_id BIGINT PK
|
||||||
|
-- asset_name VARCHAR(128) 资产名称
|
||||||
|
-- asset_ip VARCHAR(64) 管理IP
|
||||||
|
-- asset_type TINYINT 资产类型:1-Linux 2-Windows 3-网络设备 4-数据库
|
||||||
|
-- asset_group VARCHAR(128) 所属资产组
|
||||||
|
-- protocol VARCHAR(16) 管理协议:ssh/rdp/telnet
|
||||||
|
-- port INT 端口
|
||||||
|
-- account_auto TINYINT 1-纳管账号 0-手动密码
|
||||||
|
-- auth_mode TINYINT 1-密码 2-密钥 3-双因子
|
||||||
|
-- status TINYINT
|
||||||
|
|
||||||
|
-- 推断的泰岳授权表
|
||||||
|
-- T_ASSET_AUTH
|
||||||
|
-- auth_id BIGINT PK
|
||||||
|
-- asset_id BIGINT FK -> T_ASSET
|
||||||
|
-- account_id BIGINT FK (泰岳内部账号)
|
||||||
|
-- system_account VARCHAR(64) 目标系统账号(root/oracle/...)
|
||||||
|
-- access_level TINYINT 权限等级:1-普通 2-运维 3-管理员
|
||||||
|
-- start_time DATETIME 授权开始
|
||||||
|
-- end_time DATETIME 授权结束
|
||||||
|
-- approver VARCHAR(64)
|
||||||
|
```
|
||||||
|
|
||||||
|
**反推方法论**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────────┐
|
||||||
|
│ 反推方法 │
|
||||||
|
│ │
|
||||||
|
│ 1. 界面观察法 │
|
||||||
|
│ - 录屏泰岳操作界面,记录所有字段名和排列顺序 │
|
||||||
|
│ - 观察搜索/筛选条件字段(通常映射到DB索引字段) │
|
||||||
|
│ - 导出功能里的列名(CSV/xls导出列名常等于DB列名) │
|
||||||
|
│ │
|
||||||
|
│ 2. API嗅探法 │
|
||||||
|
│ - 浏览器开发者工具抓取泰岳管理的API请求/响应JSON │
|
||||||
|
│ - 关注字段名映射(下划线命名 vs 驼峰命名规律) │
|
||||||
|
│ - 分页参数(limit/offset习惯暗示底层DB) │
|
||||||
|
│ │
|
||||||
|
│ 3. 日志分析法 │
|
||||||
|
│ - 查看泰岳版的操作日志/审计日志字段定义 │
|
||||||
|
│ - 从报错信息中提取表名和字段名(典型:Constraint violation) │
|
||||||
|
│ │
|
||||||
|
│ 4. 行为验证法 │
|
||||||
|
│ - 设置特定值 → 观察界面呈现 → 推断字段类型/约束 │
|
||||||
|
│ - 创建/修改/删除操作 → 推断关联关系和约束条件 │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.2 自研系统资源模型
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 资产主表
|
||||||
|
CREATE TABLE T_SYSTEM_ASSET (
|
||||||
|
asset_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
asset_name VARCHAR(128) NOT NULL COMMENT '资产名称',
|
||||||
|
asset_ip VARCHAR(64) NOT NULL COMMENT '管理IP',
|
||||||
|
asset_type TINYINT NOT NULL COMMENT '1-Linux 2-Windows 3-网络设备 4-数据库 5-中间件',
|
||||||
|
asset_group_id BIGINT COMMENT '所属资产组',
|
||||||
|
-- 连接信息
|
||||||
|
protocol VARCHAR(16) NOT NULL DEFAULT 'ssh' COMMENT '管理协议:ssh/rdp/telnet/mysql/oracle',
|
||||||
|
port INT NOT NULL DEFAULT 22,
|
||||||
|
-- 凭据管理
|
||||||
|
credential_mode TINYINT NOT NULL DEFAULT 1 COMMENT '1-纳管密码 2-纳管密钥 3-手动输入 4-动态令牌',
|
||||||
|
-- 纳管账号(系统层面的账号,非4A用户账号)
|
||||||
|
managed_account VARCHAR(64) COMMENT '纳管系统账号(root/oracle等)',
|
||||||
|
managed_password_enc VARCHAR(512) COMMENT '纳管密码(AES-256加密)',
|
||||||
|
managed_key_enc TEXT COMMENT '纳管私钥(AES-256加密)',
|
||||||
|
-- 访问控制
|
||||||
|
access_level TINYINT NOT NULL DEFAULT 2 COMMENT '访问等级:1-低 2-中 3-高',
|
||||||
|
allowed_users JSON COMMENT '允许操作的白名单用户列表',
|
||||||
|
-- 堡垒机集成
|
||||||
|
bastion_enabled TINYINT NOT NULL DEFAULT 1 COMMENT '是否通过堡垒机接入',
|
||||||
|
-- 状态
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '0-停用 1-启用 2-维护',
|
||||||
|
-- 元数据
|
||||||
|
department VARCHAR(128) COMMENT '所属部门',
|
||||||
|
location VARCHAR(128) COMMENT '物理位置',
|
||||||
|
vendor VARCHAR(64) COMMENT '厂商',
|
||||||
|
model VARCHAR(64) COMMENT '型号',
|
||||||
|
os_version VARCHAR(64) COMMENT '操作系统版本',
|
||||||
|
remark TEXT,
|
||||||
|
-- 审计
|
||||||
|
created_by VARCHAR(64),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
deleted TINYINT NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
INDEX idx_asset_ip (asset_ip),
|
||||||
|
INDEX idx_asset_type (asset_type),
|
||||||
|
INDEX idx_group (asset_group_id),
|
||||||
|
INDEX idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统资产表';
|
||||||
|
|
||||||
|
-- 资产授权表
|
||||||
|
CREATE TABLE T_ASSET_AUTHORIZATION (
|
||||||
|
auth_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
asset_id BIGINT NOT NULL,
|
||||||
|
account_id BIGINT NOT NULL COMMENT '4A用户ID',
|
||||||
|
-- 目标系统身份
|
||||||
|
target_username VARCHAR(64) NOT NULL COMMENT '目标系统账号(root/appuser)',
|
||||||
|
-- 授权信息
|
||||||
|
access_level TINYINT NOT NULL DEFAULT 1 COMMENT '1-只读查看 2-普通操作 3-管理员操作',
|
||||||
|
-- 时间窗口
|
||||||
|
grant_type VARCHAR(16) NOT NULL DEFAULT 'permanent' COMMENT 'permanent-永久 temporary-临时',
|
||||||
|
valid_from DATETIME NOT NULL,
|
||||||
|
valid_until DATETIME,
|
||||||
|
-- 审批信息
|
||||||
|
approval_status TINYINT NOT NULL DEFAULT 0 COMMENT '0-待审批 1-已批准 2-已拒绝 3-已回收',
|
||||||
|
approver_id BIGINT,
|
||||||
|
approved_at DATETIME,
|
||||||
|
-- 行为约束
|
||||||
|
command_filter TEXT COMMENT '允许执行的命令白名单(正则)',
|
||||||
|
file_transfer TINYINT NOT NULL DEFAULT 0 COMMENT '0-禁止 1-允许上传 2-允许下载 3-双向',
|
||||||
|
-- 审计
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
revoked_at DATETIME,
|
||||||
|
revoked_by BIGINT,
|
||||||
|
|
||||||
|
INDEX idx_asset (asset_id),
|
||||||
|
INDEX idx_account (account_id),
|
||||||
|
INDEX idx_valid (valid_from, valid_until),
|
||||||
|
INDEX idx_status (approval_status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资产授权表';
|
||||||
|
|
||||||
|
-- 资产组表
|
||||||
|
CREATE TABLE T_ASSET_GROUP (
|
||||||
|
group_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
group_name VARCHAR(128) NOT NULL,
|
||||||
|
parent_id BIGINT,
|
||||||
|
org_code VARCHAR(64) COMMENT '所属组织',
|
||||||
|
description VARCHAR(256),
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='资产组表';
|
||||||
|
|
||||||
|
-- 账号密码自动轮换配置
|
||||||
|
CREATE TABLE T_CREDENTIAL_ROTATION (
|
||||||
|
rotation_id BIGINT PRIMARY KEY AUTO_INCREMENT,
|
||||||
|
asset_id BIGINT NOT NULL,
|
||||||
|
target_account VARCHAR(64) NOT NULL COMMENT '目标系统账号',
|
||||||
|
rotation_schedule VARCHAR(32) NOT NULL DEFAULT '30d' COMMENT '轮换周期:7d/14d/30d/90d',
|
||||||
|
password_rule JSON COMMENT '密码生成规则',
|
||||||
|
last_rotation DATETIME,
|
||||||
|
next_rotation DATETIME,
|
||||||
|
auto_execute TINYINT NOT NULL DEFAULT 1 COMMENT '是否自动执行',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uk_asset_account (asset_id, target_account)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='密码轮换配置表';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 堡垒集群集成方案
|
||||||
|
|
||||||
|
### 7.1 架构选择
|
||||||
|
|
||||||
|
采用**代理模式**(非网关代理,而是独立部署堡垒机节点转发流量):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────┐
|
||||||
|
│ 4A管控平台 │
|
||||||
|
│ (认证+授权) │
|
||||||
|
└──────┬───────┘
|
||||||
|
│ API
|
||||||
|
┌──────────────┼──────────────┐
|
||||||
|
│ │ │
|
||||||
|
┌────────▼───┐ ┌──────▼──────┐ ┌────▼────────┐
|
||||||
|
│ 堡垒节点1 │ │ 堡垒节点2 │ │ 堡垒节点3 │
|
||||||
|
│ (区域A) │ │ (区域B) │ │ (区域C) │
|
||||||
|
│ ┌────────┐ │ │ ┌────────┐ │ │ ┌────────┐ │
|
||||||
|
│ │SSH/RDP │ │ │ │SSH/RDP │ │ │ │SSH/RDP │ │
|
||||||
|
│ │代理 │ │ │ │代理 │ │ │ │代理 │ │
|
||||||
|
│ └────────┘ │ │ └────────┘ │ │ └────────┘ │
|
||||||
|
└──────┬──────┘ └──────┬──────┘ └──────┬──────┘
|
||||||
|
│ │ │
|
||||||
|
│ (网络隔离,策略控制) │
|
||||||
|
│ │ │
|
||||||
|
┌──────▼──────┐ ┌─────▼───────┐ ┌────▼──────┐
|
||||||
|
│ 目标服务器1 │ │ 目标服务器2 │ │ 目标服务器3 │
|
||||||
|
│ 10.0.1.10 │ │ 10.0.2.20 │ │ 10.0.3.30 │
|
||||||
|
└─────────────┘ └─────────────┘ └───────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 堡垒机核心功能
|
||||||
|
|
||||||
|
| 功能 | 说明 | 实现方案 |
|
||||||
|
|------|------|---------|
|
||||||
|
| **SSO登录** | 用户从4A平台认证后直接跳转堡垒机,无需再次认证 | JWT令牌 → 堡垒节点验证 → 建立会话 |
|
||||||
|
| **会话录制** | 记录所有操作步骤(键盘输入 + 屏幕输出) | ttyrec/cast 格式录制,定期归档至对象存储 |
|
||||||
|
| **命令审计** | 记录执行命令,匹配高危命令规则 | 实时监控bash/windows shell日志 |
|
||||||
|
| **文件传输审计** | 追踪SCP/SFTP传输的文件名、大小、路径 | 代理层拦截审计 |
|
||||||
|
| **命令拦截** | 高危命令(rm -rf /, shutdown等)实时阻断 | 自定义shell profile + 命令过滤器 |
|
||||||
|
| **会话同步** | 多人同时监控同一会话 | WebSocket实时同步 |
|
||||||
|
| **事后回放** | 回放录制的会话 | 基于录制文件+时间轴播放器 |
|
||||||
|
|
||||||
|
### 7.3 堡垒节点技术选型
|
||||||
|
|
||||||
|
| 组件 | 推荐 | 备选 |
|
||||||
|
|------|------|------|
|
||||||
|
| 代理核心 | Apache Guacamole(已支持SSH/RDP/VNC) | teleport/ssh-proxy 自研 |
|
||||||
|
| 会话录制 | asciinema (ttyrec格式) | script + scriptreplay |
|
||||||
|
| 命令拦截 | 自定义shell profile (bash_prompt wrapper) | pam_exec模块钩子 |
|
||||||
|
| 文件传输审计 | SFTP proxy (在openssh中集成ForceCommand) | rssh/scponly |
|
||||||
|
| 会话存储 | 本地文件 → 定时上传至MinIO | Ceph RGW |
|
||||||
|
| 会话回放 | asciinema player 前端组件 | 自研播放器组件 |
|
||||||
|
|
||||||
|
### 7.4 堡垒机SSO流程
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 用户从4A平台认证 → 获取JWT (payload含allowed_assets)
|
||||||
|
2. 用户选择目标资产 → 4A平台验证授权
|
||||||
|
3. 4A平台生成一次性堡垒令牌(有效期60秒,含用户+资产+目标账号)
|
||||||
|
4. 用户浏览器跳转至堡垒节点URL(带令牌)
|
||||||
|
5. 堡垒节点验证令牌 → 获取目标账号的真实密码/密钥(加密传输)
|
||||||
|
6. 堡垒节点建立SSH/RDP连接目标资产
|
||||||
|
7. 代理层开始录制和审计
|
||||||
|
8. 会话结束后录制上传至对象存储
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 与泰岳版比对关注点
|
||||||
|
|
||||||
|
| 项 | 自研方案 | 泰岳方案(推测) | 验证方法 |
|
||||||
|
|----|---------|-----------------|---------|
|
||||||
|
| 资产模型 | 通用资产表+扩展属性 | 可能按类型分多表 | 创建各类型资产对比信息完整度 |
|
||||||
|
| 授权粒度 | 命令级黑白名单+时间窗口+文件传输控制 | 类似 | 设置复杂授权策略对比生效情况 |
|
||||||
|
| 堡垒节点 | 基于开源组件(Guacamole) | 自研代理 | 并发连接数对比 |
|
||||||
|
| 密码轮换 | 定时间隔+自动执行 | 类似 | 轮换成功率+时间对比 |
|
||||||
|
| 会话录制 | asciinema格式 | 未知格式 | 存储空间占用+回放体验对比 |
|
||||||
|
| 命令拦截 | shell profile级 | 代理级更底层 | 高危命令漏报率对比 |
|
||||||
@@ -103,10 +103,10 @@ hugo server -t terminal
|
|||||||
|
|
||||||
### 品牌与图标
|
### 品牌与图标
|
||||||
|
|
||||||
- [ ] FK 手写书法风格图标,黑底 `#111`,白字
|
- [x] FK 手写书法风格图标,黑底 `#111`,白字
|
||||||
- [x] 浅色/深色双版图标(`logo.svg` / `logo-dark.svg`)
|
- [x] 浅色/深色双版图标(`logo.svg` / `logo-dark.svg`)
|
||||||
- [x] 导航栏仅显示图标,隐藏标题文字,40×40px
|
- [x] 导航栏仅显示图标,隐藏标题文字,40×40px
|
||||||
- [x] 多平台 favicon 适配
|
- [ ] 多平台 favicon 适配
|
||||||
- [ ] 自定义 OG 社交分享图(1200×630)
|
- [ ] 自定义 OG 社交分享图(1200×630)
|
||||||
- [ ] iOS/Android 触屏图标
|
- [ ] iOS/Android 触屏图标
|
||||||
|
|
||||||
@@ -115,7 +115,7 @@ hugo server -t terminal
|
|||||||
- [x] Hero 标题:**FuKun**
|
- [x] Hero 标题:**FuKun**
|
||||||
- [x] 副标题:*FuKun.Net — Tech notes & life*
|
- [x] 副标题:*FuKun.Net — Tech notes & life*
|
||||||
- [x] 最新文章列表(6 篇)
|
- [x] 最新文章列表(6 篇)
|
||||||
- [x] **Slogan 重写** — 参照 Dan Koe 的文案风格:
|
- [ ] **Slogan 重写** — 参照 Dan Koe 的文案风格:
|
||||||
- 一句话价值主张(如 "The Art of..." / "How to..." 句式)
|
- 一句话价值主张(如 "The Art of..." / "How to..." 句式)
|
||||||
- 个人使命宣言
|
- 个人使命宣言
|
||||||
- 读者能获得什么(结果导向,而非信息罗列)
|
- 读者能获得什么(结果导向,而非信息罗列)
|
||||||
|
|||||||
+2
-25
@@ -85,8 +85,7 @@ html.dark .theme-toggle-light {
|
|||||||
width: 0.6em;
|
width: 0.6em;
|
||||||
height: 1.1em;
|
height: 1.1em;
|
||||||
background: #999;
|
background: #999;
|
||||||
vertical-align: text-bottom;
|
vertical-align: text-bottom; transform: translateY(-2px);
|
||||||
transform: translateY(-2px);
|
|
||||||
margin-left: 1px;
|
margin-left: 1px;
|
||||||
animation: vim-blink 0.8s step-end infinite;
|
animation: vim-blink 0.8s step-end infinite;
|
||||||
}
|
}
|
||||||
@@ -103,26 +102,4 @@ html.dark .theme-toggle-light {
|
|||||||
background: #ccc;
|
background: #ccc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Hide current page title in breadcrumb */
|
||||||
/* Center footer copyright on all screens */
|
|
||||||
justify-content: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/* Footer copyright always centered */
|
|
||||||
.hextra-footer > div:last-child {
|
|
||||||
justify-content: center !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Remove top border from sidebar bottom section */
|
|
||||||
.hextra-sidebar-container div[data-toggle-animation] {
|
|
||||||
border-top: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Keep cursor blinking even with reduced motion */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
#typewriter-text::after {
|
|
||||||
animation-duration: 0.8s !important;
|
|
||||||
animation-iteration-count: infinite !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+2024
File diff suppressed because one or more lines are too long
+6
-6
@@ -2,14 +2,14 @@
|
|||||||
title: FuKun
|
title: FuKun
|
||||||
layout: hextra-home
|
layout: hextra-home
|
||||||
---
|
---
|
||||||
{{< hextra/hero-subtitle >}}
|
{{< hextra-hero-subtitle >}}
|
||||||
Fukun.Net
|
Fukun.Net
|
||||||
{{< /hextra/hero-subtitle >}}
|
{{< /hextra-hero-subtitle >}}
|
||||||
|
|
||||||
{{< hextra/hero-headline >}}
|
{{< hextra-hero-headline >}}
|
||||||
晴耕雨读 心灯不夜
|
晴耕雨读 心灯不夜
|
||||||
{{< /hextra/hero-headline >}}
|
{{< /hextra-hero-headline >}}
|
||||||
|
|
||||||
{{< hextra/hero-subtitle >}}
|
{{< hextra-hero-subtitle >}}
|
||||||
一周更新两次,让脑袋泡泡冰浴
|
一周更新两次,让脑袋泡泡冰浴
|
||||||
{{< /hextra/hero-subtitle >}}
|
{{< /hextra-hero-subtitle >}}
|
||||||
|
|||||||
+16
-8
@@ -1,23 +1,31 @@
|
|||||||
---
|
---
|
||||||
---
|
---
|
||||||
{{< hextra/hero-subtitle >}}
|
{{< hextra-hero-subtitle >}}
|
||||||
Fukun.Net/about
|
Fukun.Net/about
|
||||||
{{< /hextra/hero-subtitle >}}
|
{{< /hextra-hero-subtitle >}}
|
||||||
|
|
||||||
我是傅堃。
|
我是傅堃。
|
||||||
|
|
||||||
个人开发者,央企TL,新晋奶爸。当然大部分时间精力贡献给了体制内职业发展,IT运维智能化、营业计费系统建设、大规模IT资源池安全能力建设、企业身份账号权限治理,挺有意思的。
|
央企TL,个人开发者,新晋奶爸。当然大部分时间精力还是贡献给了体制内的职业发展,干过IT运维智能化、营业计费系统建设、大规模IT资源池安全能力建设、企业身份账号权限治理,挺有意思的。
|
||||||
|
|
||||||
这个站点与大家探讨的重点不是道与术,更多是去探索**自我**的存在。人其实很难承认被所谓靠努力成为的角色所定义,你我在本质上与小时候有多大区别呢。如果你也想在日常的繁杂中找一个属于自我的空间,挑战落地一些奇思妙想,或是研究一些稀奇古怪,这里的文章会给你启发。
|
这个站点主要讨论的不是道与术,是本应凌驾于身份角色之上的**自我**。总觉得人其实很难承认自己被所谓靠自己努力成为的身份角色定义,从出生起人的单一身份很难未维持超过5年,情绪有所触动时与五岁时其实没多大区别。
|
||||||
|
|
||||||
由于公司管理制度,我不在业余取酬,不回复商务合作邮件。
|
如果你也想在日常的繁杂中找一个属于自我的空间,挑战落地一些奇思妙想,或是研究一些稀奇古怪,这里的文章会给你启发。
|
||||||
|
|
||||||
|
由于公司管理制度,我不可以参与盈利项目,所以无法接洽商务合作。
|
||||||
|
|
||||||
|
|
||||||
|
驾驶教练机拉杆起飞的那一刻/宇治城的小溪边被斜阳刺痛双眼的那一刻/在广汕国道上骑自行车听到路旁大爷大喊加油的那一刻/我清楚的感受到我是我自己
|
||||||
|
|
||||||
|
|
||||||
Hey, I'm FuKun.
|
Hey, I'm FuKun.
|
||||||
|
|
||||||
Solo developer. IT Team-leader at a state-owned enterprise. New dad. Most of my time and energy goes to my career — AIOPS, CRM, IAM, Security,Identity governance. It's been genuinely interesting.
|
Solo developer. Ex-product manager. China state-owned enterprise manager. New dad. Most of my waking hours go to my career — I've done IT operations automation at scale, built billing systems handling massive transaction volumes, architected security for enormous resource pools, and untangled enterprise identity governance. It's been fun.
|
||||||
|
|
||||||
This site isn't only about methodology or technique. It's about exploring the **SOUL** that exists beneath all the roles. It's hard to admit how much we're defined by what we've fought to become — honestly, are you really that different from who you were as a kid? If you're also trying to carve out space for your own self amid the chaos, chasing down wild ideas or researching obscure things, the writing here might spark something.
|
This site isn't about methodology or technique. It's about the **self** that should sit above all the roles we play. I think people struggle to admit how much their identity gets defined by their roles — and yet, no identity you've held has lasted more than five years since birth. When emotions hit, you still react the same way you did at five.
|
||||||
|
|
||||||
Due to company policy, I don't take paid work on the side and don't reply to business collaboration emails.
|
If you're trying to carve out space for your own self amid the chaos — chasing down a wild idea, researching something obscure, building something that's just yours — the writing here might spark something.
|
||||||
|
|
||||||
|
A note on logistics: due to company policy, I can't participate in for-profit projects, so I don't take on business collaborations.
|
||||||
|
|
||||||
|
The moment when I pulled back the Cessena's stick. The moment setting sun stinged my eyes by a stream in Uji.The moment an old sir shouted "COME ON!" as I was crossing South-China by bike. Without question, that I was aware WHOIAM.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
linkTitle: FuKun.Net/Blog
|
linkTitle: FuKun.Net/Blog
|
||||||
---
|
---
|
||||||
{{< hextra/hero-subtitle >}}
|
{{< hextra-hero-subtitle >}}
|
||||||
Fukun.Net/blog
|
Fukun.Net/blog
|
||||||
{{< /hextra/hero-subtitle >}}
|
{{< /hextra-hero-subtitle >}}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
---
|
||||||
|
title: "善假说"
|
||||||
|
date: 2026-05-16T01:30:00+08:00
|
||||||
|
draft: false
|
||||||
|
poet: |
|
||||||
|
君子生非异也,
|
||||||
|
善假于物也。
|
||||||
|
——《荀子·劝学》
|
||||||
|
description: "近以AI智能体为幕僚,五日亲历,颇有所得。录之以证荀子之言。"
|
||||||
|
tags:
|
||||||
|
- AI
|
||||||
|
- 效率
|
||||||
|
- 杂文
|
||||||
|
---
|
||||||
|
|
||||||
|
{{< figure src="/images/fusheng.jpg" caption="杜堇《伏生授经图》,明,大都会艺术博物馆藏" alt="杜堇《伏生授经图》" >}}
|
||||||
|
|
||||||
|
荀子说,君子生非异也,善假于物也。我以前读完也就过了,没细想。
|
||||||
|
|
||||||
|
AI 这两年用得不少,ChatGPT、Claude、DeepSeek 都试过。问问题、写代码、润文字,确实方便。但模式始终是单向的——它等你问,你问它答,问完结束。
|
||||||
|
|
||||||
|
直到我用上一个能自己干活的智能体。它叫"爪子",跑在腾讯云一台轻量服务器上,4 核 4G,配置不高。搭起来很简单:在服务器上装了一个叫 OpenClaw 的智能体框架,绑好微信,配上 Todoist 和 Git 的 API 密钥,就能远程使唤了。这东西驻扎在服务器上,能直接执行 Shell 命令、读写文件、操作 Git 仓库、浏览网页。从有想法到跑起来,花了半晚上。
|
||||||
|
|
||||||
|
## 役物和任人
|
||||||
|
|
||||||
|
同样是 AI,心态不同,用法天差地别。
|
||||||
|
|
||||||
|
**役物**——问一句答一句,答错了你纠,纠完再问。你是指挥,还得盯着每一步。
|
||||||
|
|
||||||
|
**任人**——说清楚方向,交给它去做。做完验收就行,中间过程不必过问。
|
||||||
|
|
||||||
|
前者把你绑在操作台前,后者让你从执行中抽身。区别不在技术,在心法。
|
||||||
|
|
||||||
|
## 三件事
|
||||||
|
|
||||||
|
试了五天,挑三件说说。
|
||||||
|
|
||||||
|
### 博客
|
||||||
|
|
||||||
|
午间忽想建站,交代了一句。它 SSH 上服务器就开始干活。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[我:建个博客] --> B[SSH 登录服务器]
|
||||||
|
B --> C[安装 Hugo + Nginx]
|
||||||
|
C --> D[配置 HTTPS]
|
||||||
|
D --> E{测试通过?}
|
||||||
|
E -->|否| F[自修权限问题]
|
||||||
|
F --> D
|
||||||
|
E -->|是| G[回复就绪]
|
||||||
|
```
|
||||||
|
|
||||||
|
装 Hugo、配 Nginx、设 HTTPS、修 404 错误,全是它自己弄的。后来主题换了三次——PaperMod、terminal、Hextra——都是半夜一句话的事。第二天醒来网站已经跑在新主题上了。
|
||||||
|
|
||||||
|
### 资讯推送
|
||||||
|
|
||||||
|
每天早上想看国际新闻、科技动态和通信消息。我让它写了个定时脚本,每天 8:30 推送微信。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[Cron 8:30] --> B[拉取待办 + 新闻]
|
||||||
|
B --> C[编译简报]
|
||||||
|
C --> D[推送微信]
|
||||||
|
D --> E{成功?}
|
||||||
|
E -->|失败| F[自查超时]
|
||||||
|
F --> G[延长时限重发]
|
||||||
|
G --> D
|
||||||
|
E -->|成功| H[记入日志]
|
||||||
|
```
|
||||||
|
|
||||||
|
第三天推送没来。没等我问,它已经查了日志,发现 DeepSeek API 响应变慢导致超时,自动调长时限重发了。
|
||||||
|
|
||||||
|
### 工单管理
|
||||||
|
|
||||||
|
工作对话截图丢过去,说一句"入待办"。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[我:发聊天截图] --> B[读取并提取信息]
|
||||||
|
B --> C[创建 Todoist 任务]
|
||||||
|
C --> D[标优先级 + 截止日]
|
||||||
|
D --> E[回复确认]
|
||||||
|
```
|
||||||
|
|
||||||
|
读图、识人、提取待办、写入 Todoist、标优先级、定截止期。原来要折腾一分钟的事,现在十秒。
|
||||||
|
|
||||||
|
## 信任是试出来的
|
||||||
|
|
||||||
|
一开始不放心,只让它看看日志、查查状态。后来发现出错能自愈、操作有日志可查、半夜发消息也能秒回——慢慢就放手了。
|
||||||
|
|
||||||
|
**邮件过滤。** 信任更进一步,我给了它 IMAP 权限,让它每天扫收件箱。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A[每日扫描收件箱] --> B{发给我但<br>没给下属?}
|
||||||
|
B -->|是| C[建文件夹存档]
|
||||||
|
C --> D[附件存 / 正文转 md]
|
||||||
|
D --> E[撰写摘要]
|
||||||
|
E --> F[关联已有文件信息]
|
||||||
|
F --> G[入待办跟进]
|
||||||
|
B -->|否| H[跳过]
|
||||||
|
```
|
||||||
|
|
||||||
|
筛出发给我但没抄送下属的邮件,拆附件、正文转 markdown、写摘要、关联已有资料,分类归档到我本地工作目录的 inbox。第二天上班打开文件夹,摘要和附件已经摆好了。
|
||||||
|
|
||||||
|
## 写在最后
|
||||||
|
|
||||||
|
有人问:这和 ChatGPT 有什么区别?
|
||||||
|
|
||||||
|
我说:**ChatGPT 是字典,这是秘书。** 字典再全,不会替你写材料。秘书不一定比你懂行,但会替你跑腿。
|
||||||
|
|
||||||
|
需要什么投入?一台低配云服务器,一套 API 凭证,花半晚上搭环境。门槛不高。
|
||||||
|
|
||||||
|
回头看荀子那句话——"善假于物"。最好的工具不是功能最强的,而是能让你从琐事中脱身的。用到某个程度你会发现,真正费脑子的不是"怎么问 AI",而是"什么可以交给 AI"。
|
||||||
|
|
||||||
|
这念头一转,效率的瓶颈就从工具变成了自己的想象力。
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
---
|
||||||
|
title: "AI 生产力的唯一落点"
|
||||||
|
date: 2026-05-17T18:10:00+08:00
|
||||||
|
draft: false
|
||||||
|
poet: |
|
||||||
|
We are stuck with technology when what we really want is just stuff that works.
|
||||||
|
— Douglas Adams, UK, 1952–2001
|
||||||
|
description: "和炎明吃饭聊起 AI 实际应用,感叹一个事实:到今天为止,AI 真正有生产力的应用场景只有 coding 一项。"
|
||||||
|
tags:
|
||||||
|
- AI
|
||||||
|
- 思考
|
||||||
|
---
|
||||||
|
|
||||||
|
晚上和炎明吃饭,聊到 AI 的实际应用。几杯酒下去,话题从宏大叙事落到日常体验。我俩有个共同感受:
|
||||||
|
|
||||||
|
**AI 目前能有生产力的,只有 coding 这一项。**
|
||||||
|
|
||||||
|
这是一个残酷但诚实的判断。
|
||||||
|
|
||||||
|
## 为什么只有 Coding
|
||||||
|
|
||||||
|
仔细想想,Coding 和其他场景有什么本质不同?
|
||||||
|
|
||||||
|
**第一,输出可验证。** 代码跑不跑得通,一运行就知道。结果客观、即时、无歧义。AI 写对了就是对了,错了就是错了,不存在"写得好不好"的主观拉扯。
|
||||||
|
|
||||||
|
**第二,反馈闭环极短。** 写一段代码 → 编译/运行 → 看到结果 → 修改,这个循环可以秒级完成。AI 和开发者之间形成了高频的"对话-验证-迭代"机制。
|
||||||
|
|
||||||
|
**第三,错误可容忍。** 代码写错了不会死人。你可以反复试错,直到跑通为止。这种低代价的试错环境,是 AI 学习和人类协作最舒服的区间。
|
||||||
|
|
||||||
|
**第四,人类判断力最低介入。** 代码的逻辑正确性由机器判定,不需要专家逐行审阅。非技术类任务(写文章、做设计、决策建议)都需要人的判断力做最终把关——这恰恰是最稀缺的。
|
||||||
|
|
||||||
|
## 其他场景为什么不行
|
||||||
|
|
||||||
|
- **写文章** → 需要人类判断风格、语气、事实准确度,改了半个小时不如自己写
|
||||||
|
- **数据分析** → AI 能跑数,但"问对问题"才是关键,而这个恰恰是 AI 做不到的
|
||||||
|
- **创意设计** → 生成一堆选项,人类从中选一个最不差的,本质是降本不是提效
|
||||||
|
- **聊天陪伴** → 有情绪价值,但没有产出,不算生产力
|
||||||
|
|
||||||
|
这些场景的共同问题:**判断成本转移了,但没有消失。** 写文章的时候,AI 帮你省了打字的力气,但审稿的时间一点没少。效率提升只是把体力活变成了脑力活。
|
||||||
|
|
||||||
|
## Coding 也是例外,也是范式
|
||||||
|
|
||||||
|
但仔细想,**未来真正有生产力的 AI 场景,都会呈现出某种"可验证性"**——无论是否在写代码。
|
||||||
|
|
||||||
|
比如合同审查:如果有一套明确的合规规则,AI 可以逐条校验;比如医疗影像:如果有标注好的数据集,AI 可以辅助诊断。这些场景的共同特征都是:**输出可以被客观判定对错。**
|
||||||
|
|
||||||
|
而那些依赖人类主观判断的领域——策略、审美、人情世故——AI 很难产出真正的生产力。不是技术问题,是评价标准的问题。
|
||||||
|
|
||||||
|
## 写在最后
|
||||||
|
|
||||||
|
和炎明的一顿饭,让我更清楚了一件事:**AI 不是万能工具,它是一把专门在"可验证"领域发力的扳手。**
|
||||||
|
|
||||||
|
承认它的边界,比鼓吹它的全能,更有意义。
|
||||||
|
|
||||||
|
而当前这把扳手最趁手的活,就是 coding。这也是为什么从大厂到个人开发者,都在往这个方向猛押注——不是跟风,是因为这里真的能看到产出。
|
||||||
@@ -0,0 +1,532 @@
|
|||||||
|
---
|
||||||
|
title: "用宝可梦解释 Prolog 基础"
|
||||||
|
date: 2026-05-18T13:30:00+08:00
|
||||||
|
draft: false
|
||||||
|
description: "通过宝可梦对战系统理解逻辑编程语言 Prolog 的核心概念——事实、规则、查询,以及它为什么比 SQL 更灵活。"
|
||||||
|
tags:
|
||||||
|
- Prolog
|
||||||
|
- 宝可梦
|
||||||
|
- 编程语言
|
||||||
|
- 翻译
|
||||||
|
---
|
||||||
|
|
||||||
|
# 用宝可梦解释 Prolog 基础
|
||||||
|
|
||||||
|
2026年1月5日
|
||||||
|
|
||||||
|
启发这篇文章的项目有点傻——我将要详细描述一个儿童电子游戏的机制——但正是这个特定问题最终让我真正理解了 Prolog,这是自从我读了 Bruce Tate 的《七周七语言》以来一直在追寻的顿悟。
|
||||||
|
|
||||||
|
这个练习教会了我很多关于我正在[更实用的领域](https://alexanderpetros.com/triptych/)尝试构建的接口类型。对于某些类型的关系,逻辑编程是我用过的最简洁、最具表现力的编程系统。
|
||||||
|
|
||||||
|
要理解为什么,让我们来聊聊宝可梦(Pokémon)。
|
||||||
|
|
||||||
|
## 宝可梦(Pokémon)基础
|
||||||
|
|
||||||
|
宝可梦是一个电子游戏系列/多媒体特许经营/生活方式品牌,设定在一个人类与各种色彩缤纷的动物角色共存的世界。
|
||||||
|
|
||||||
|
"Pokémon" 既是这个系列的名称,也是这些动物角色本身的统称,每个角色都有自己的物种名称。从 Bulbasaur(妙蛙种子,[#1](https://bulbapedia.bulbagarden.net/wiki/Bulbasaur_(Pok%C3%A9mon)))到 Pecharunt(桃歹郎,[#1025](https://bulbapedia.bulbagarden.net/wiki/Pecharunt_(game))),共有超过一千种不同的宝可梦物种。
|
||||||
|
|
||||||
|
受欢迎的宝可梦包括(从左到右):Pikachu(皮卡丘,[#25](https://bulbapedia.bulbagarden.net/wiki/Pikachu_(Pok%C3%A9mon)))、Archeops(始祖大鸟,[#567](https://bulbapedia.bulbagarden.net/wiki/Archeops_(Pok%C3%A9mon))) 和 Dipplin(裹蜜虫,[#1101](https://bulbapedia.bulbagarden.net/wiki/Dipplin_(Pok%C3%A9mon)))。
|
||||||
|
|
||||||
|
我很少在这个博客里放图片,能在这篇文章里放图让我非常兴奋。
|
||||||
|
|
||||||
|
现在有各种各样的宝可梦游戏,但主系列始终围绕着捕捉和对战。在对战中,你的六只宝可梦队伍与另一支队伍交锋。每只宝可梦配备四个招式,可以选择用来(通常)对对手造成伤害。你需要将对方所有宝可梦的 HP(体力值)减到零,同时防止对方先对你做同样的事。
|
||||||
|
|
||||||
|
每只宝可梦都有独特的特性影响对战表现。它们有一套基础能力值、大量可学的招式、若干特性和属性组合。你马上就会看到,这里庞大的组合数量正是试图用软件来追踪这些信息的动机。
|
||||||
|
|
||||||
|
Scizor(巨钳螳螂)是虫/钢属性,攻击力高但速度低(数据来自 [Smogon](https://www.smogon.com/dex/bw/pokemon/scizor/))
|
||||||
|
|
||||||
|
速度决定哪个招式先出手;攻击和特攻分别影响物理招式和特殊招式的伤害;防御和特防影响受到的伤害。
|
||||||
|
|
||||||
|
属性尤其重要。招式有属性,比如火或岩石,而宝可梦可以拥有最多两种属性。如果招式的属性对对方宝可梦效果绝佳(Super Effective),会造成双倍伤害;效果不佳(Not Very Effective)则只造成一半伤害。
|
||||||
|
|
||||||
|
用例子来说明会更直观一些。火属性的招式喷射火焰(Flamethrower)对草属性宝可梦造成 2 倍伤害,因为草弱火,但水属性的冲浪(Surf)对它们只造成 ½ 伤害,因为草抗水。
|
||||||
|
|
||||||
|
[Lunatone(月石)](https://bulbapedia.bulbagarden.net/wiki/Lunatone_(Pok%C3%A9mon))是岩石/超能力属性。岩石弱水,超能力对水中性,所以冲浪会造成 2 倍伤害。
|
||||||
|
|
||||||
|
属性修正可以叠加。[Scizor(巨钳螳螂)](https://bulbapedia.bulbagarden.net/wiki/Scizor_(Pok%C3%A9mon))是虫/钢属性,虫和钢都弱火,所以火属性招式对 Scizor 会造成 4 倍伤害。电弱水,但地面免疫电,所以如果你对水/地面的 [Swampert(巨沼怪)](https://bulbapedia.bulbagarden.net/wiki/Swampert_(Pok%C3%A9mon))使用电属性招式,伤害为零,因为 0×2 还是 0。
|
||||||
|
|
||||||
|
自然而然地,有一张图表来帮你记忆。
|
||||||
|
|
||||||
|
宝可梦属性相克表(来自 [Wikimedia](https://commons.wikimedia.org/wiki/File:Pokemon_Type_Chart.svg))
|
||||||
|
|
||||||
|
这些基本上就是我 8 岁时理解的宝可梦电子游戏机制。点击招式造成伤害,尽量选择属性相克有利的招式。这些游戏是为儿童设计的,表面上看并不难。
|
||||||
|
|
||||||
|
## Prolog 基础
|
||||||
|
|
||||||
|
在解释宝可梦机制底层有多复杂之前,我需要先解释一下逻辑编程的工作原理。宝可梦非常适合逻辑编程,因为宝可梦对战本质上是一个极其精密的规则引擎。
|
||||||
|
|
||||||
|
让我们先创建一个包含一堆事实的文件。
|
||||||
|
|
||||||
|
```
|
||||||
|
pokemon(bulbasaur).
|
||||||
|
pokemon(ivysaur).
|
||||||
|
pokemon(venusaur).
|
||||||
|
pokemon(charmander).
|
||||||
|
pokemon(charmeleon).
|
||||||
|
pokemon(charizard).
|
||||||
|
pokemon(squirtle).
|
||||||
|
pokemon(wartortle).
|
||||||
|
pokemon(blastoise).
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Prolog 中,我们声明"谓词"(predicates)。谓词定义关系:bulbasaur 是一只宝可梦,charmander 是一只宝可梦,以此类推。我们称这个谓词为 pokemon/1,因为谓词名称是 pokemon,它有一个参数。
|
||||||
|
|
||||||
|
这些事实被加载到一个叫做"顶层"(top-level)的交互式提示符中。你通过在提示符中输入语句来查询;Prolog 尝试找到使该语句成立的所有方式。当有多个可能的解时,顶层会显示第一个解,然后等待用户输入。你可以让它再显示一个解、显示所有解,或者完全停止。
|
||||||
|
|
||||||
|
在第一个例子中,我们输入 `pokemon(squirtle).` 并回车。顶层回复 true。[Squirtle(杰尼龟)](https://bulbapedia.bulbagarden.net/wiki/Squirtle_(Pok%C3%A9mon))确实是一只宝可梦。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- pokemon(squirtle).
|
||||||
|
true.
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你想在家跟着做,查看[这个仓库](https://github.com/alexpetros/prologdex),里面有快速设置说明。
|
||||||
|
|
||||||
|
不是所有东西都是宝可梦。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- pokemon(alex).
|
||||||
|
false.
|
||||||
|
```
|
||||||
|
|
||||||
|
让我们用谓词 type/2 来加入宝可梦属性。
|
||||||
|
|
||||||
|
```
|
||||||
|
type(bulbasaur, grass).
|
||||||
|
type(bulbasaur, poison).
|
||||||
|
type(ivysaur, grass).
|
||||||
|
type(ivysaur, poison).
|
||||||
|
type(venusaur, grass).
|
||||||
|
type(venusaur, poison).
|
||||||
|
type(charmander, fire).
|
||||||
|
type(charmeleon, fire).
|
||||||
|
type(charizard, fire).
|
||||||
|
type(charizard, flying).
|
||||||
|
type(squirtle, water).
|
||||||
|
type(wartortle, water).
|
||||||
|
type(blastoise, water).
|
||||||
|
```
|
||||||
|
|
||||||
|
实际数据集包含了宝可梦的不同形态,比如 Mega 进化,因为它们有不同的能力值和属性。这里为了清晰起见省略了这些。
|
||||||
|
|
||||||
|
回顾一下,有些宝可梦只有一种属性,而另有两种。对于后一种情况,用两个 type 事实来建模。[Bulbasaur(妙蛙种子)](https://bulbapedia.bulbagarden.net/wiki/Bulbasaur_(Pok%C3%A9mon))是草属性,同时 Bulbasaur 也是毒属性;两个都为真。这种范式类似于 SQL 数据库中的一对多关系。
|
||||||
|
|
||||||
|
交互式地,我们可以确认 Squirtle 是不是水属性。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- type(squirtle, water).
|
||||||
|
true.
|
||||||
|
```
|
||||||
|
|
||||||
|
我们能不能说 Squirtle 是草属性?
|
||||||
|
|
||||||
|
```
|
||||||
|
?- type(squirtle, grass).
|
||||||
|
false.
|
||||||
|
```
|
||||||
|
|
||||||
|
不能,因为 Squirtle 是水属性。
|
||||||
|
|
||||||
|
假设我们不知道 Squirtle 是什么属性。我们可以问!
|
||||||
|
|
||||||
|
```
|
||||||
|
?- type(squirtle, Type).
|
||||||
|
Type = water.
|
||||||
|
```
|
||||||
|
|
||||||
|
在 Prolog 中,以大写字母开头的名字是变量。Prolog 尝试将谓词与变量的所有可能匹配进行"合一"(unify)。不过这个特定谓词只有一种方式可以为真:Type 必须是 water,因为 Squirtle 的唯一属性就是水。
|
||||||
|
|
||||||
|
对于有两种属性的宝可梦,谓词会合一两次。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- type(venusaur, Type).
|
||||||
|
Type = grass
|
||||||
|
; Type = poison.
|
||||||
|
```
|
||||||
|
|
||||||
|
语义上,第三行的分号表示"或"。当 Type = grass 或 Type = poison 时,type(venusaur, Type) 为真。
|
||||||
|
|
||||||
|
任何参数都可以是变量,这意味着我们可以从任意方向提问。所有草属性有哪些?只需让第一个参数是变量,第二个参数设为 grass。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- type(Pokemon, grass).
|
||||||
|
Pokemon = bulbasaur
|
||||||
|
; Pokemon = ivysaur
|
||||||
|
; Pokemon = venusaur
|
||||||
|
; Pokemon = oddish
|
||||||
|
; Pokemon = gloom
|
||||||
|
; Pokemon = vileplume
|
||||||
|
; Pokemon = paras
|
||||||
|
; Pokemon = parasect
|
||||||
|
; Pokemon = bellsprout
|
||||||
|
; ... .
|
||||||
|
```
|
||||||
|
|
||||||
|
这个输出是我输入查询后,按了几次"n"得到前几个解,然后按回车退出的结果。我截断了,但提示符会愉快地列出全部 164 个。
|
||||||
|
|
||||||
|
逗号可以用来列出多个谓词——Prolog 会统一变量,使得所有谓词同时为真。列出所有水/冰属性只需问:哪些宝可梦同时与水和冰两种属性合一。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- type(Pokemon, water), type(Pokemon, ice).
|
||||||
|
Pokemon = dewgong
|
||||||
|
; Pokemon = cloyster
|
||||||
|
; Pokemon = lapras
|
||||||
|
; Pokemon = laprasgmax
|
||||||
|
; Pokemon = spheal
|
||||||
|
; Pokemon = sealeo
|
||||||
|
; Pokemon = walrein
|
||||||
|
; Pokemon = arctovish
|
||||||
|
; Pokemon = ironbundle
|
||||||
|
; false.
|
||||||
|
```
|
||||||
|
|
||||||
|
不要被解以"或 false"结尾这件事困扰。这是搜索算法工作方式的结果;求解器寻找更多解,然后失败了。我承认,我不完全理解为什么它有时候这样有时候不这样,但这是预期的行为。
|
||||||
|
|
||||||
|
尽管 Pokemon 是一个变量,在查询的上下文中,它的两个实例必须是相同的(就像代数一样)。查询只对 Pokemon 的值进行合一,使得这两个谓词都成立。例如,水/冰属性的 [Dewgong(白海狮)](https://bulbapedia.bulbagarden.net/wiki/Dewgong_(Pok%C3%A9mon))是一个解,因为我们的程序包含以下两个事实:
|
||||||
|
|
||||||
|
```
|
||||||
|
type(dewgong, water).
|
||||||
|
type(dewgong, ice).
|
||||||
|
```
|
||||||
|
|
||||||
|
因此,用 dewgong 替换 Pokemon 变量满足查询。相比之下,Squirtle 只是水属性:存在 pokemon(squirtle, water),但没有 pokemon(squirtle, ice)。查询要求两者都合一,所以 squirtle 不是 Pokemon 的可能取值。
|
||||||
|
|
||||||
|
宝可梦有很多数据可以玩。[Iron Bundle(铁包袱)](https://bulbapedia.bulbagarden.net/wiki/Iron_Bundle_(Pok%C3%A9mon))是一只很强力的水/冰属性宝可梦,特攻很高。到底有多高?
|
||||||
|
|
||||||
|
```
|
||||||
|
?- pokemon_spa(ironbundle, SpA).
|
||||||
|
SpA = 124.
|
||||||
|
```
|
||||||
|
|
||||||
|
特攻这么高,我们想利用强力的特殊招式。Iron Bundle 会哪些特殊招式?
|
||||||
|
|
||||||
|
```
|
||||||
|
?- learns(ironbundle, Move), move_category(Move, special).
|
||||||
|
Move = aircutter
|
||||||
|
; Move = blizzard
|
||||||
|
; Move = chillingwater
|
||||||
|
; Move = freezedry
|
||||||
|
; Move = hydropump
|
||||||
|
; Move = hyperbeam
|
||||||
|
; Move = icebeam
|
||||||
|
; Move = icywind
|
||||||
|
; Move = powdersnow
|
||||||
|
; Move = swift
|
||||||
|
; Move = terablast
|
||||||
|
; Move = waterpulse
|
||||||
|
; Move = whirlpool.
|
||||||
|
```
|
||||||
|
|
||||||
|
Freeze-Dry(冷冻干燥)是一个特别好的特殊招式。下面是一个查询,找出所有特攻大于 120、会 Freeze-Dry 的冰属性宝可梦。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- pokemon_spa(Pokemon, SpA), SpA #> 120, learns(Pokemon, freezedry), type(Pokemon, ice).
|
||||||
|
Pokemon = glaceon, SpA = 130
|
||||||
|
; Pokemon = kyurem, SpA = 130
|
||||||
|
; Pokemon = kyuremwhite, SpA = 170
|
||||||
|
; Pokemon = ironbundle, SpA = 124
|
||||||
|
; false.
|
||||||
|
```
|
||||||
|
|
||||||
|
在继续之前,最后一个概念:规则(Rules)。规则有头部(head)和主体(body),如果主体为真,则规则合一。
|
||||||
|
|
||||||
|
如果一个招式是物理招式或特殊招式,它就被认为是一个伤害招式。谓词 damaging_move/2 定义了所有造成直接伤害的招式。
|
||||||
|
|
||||||
|
```
|
||||||
|
damaging_move(Move) :-
|
||||||
|
move_category(Move, physical)
|
||||||
|
; move_category(Move, special).
|
||||||
|
```
|
||||||
|
|
||||||
|
这将与任何造成直接伤害的招式合一。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- damaging_move(tackle).
|
||||||
|
true.
|
||||||
|
?- damaging_move(rest).
|
||||||
|
false.
|
||||||
|
```
|
||||||
|
|
||||||
|
## SQL 对比
|
||||||
|
|
||||||
|
到目前为止,我展示的内容在逻辑上并不复杂——只是关于各种事实的"与"和"或"语句。它本质上是一个被美化的查找表。不过,请花一点时间来体会一下,查询这个数据库比常见的替代方案(比如 SQL)要舒服多少。
|
||||||
|
|
||||||
|
对于到目前为止我们看到的事实,我可能会这样设置 SQL 表:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 省略其他能力值以保持简洁
|
||||||
|
CREATE TABLE pokemon (pokemon_name TEXT, special_attack INTEGER);
|
||||||
|
CREATE TABLE pokemon_types(pokemon_name TEXT, type TEXT);
|
||||||
|
CREATE TABLE pokemon_moves(pokemon_name TEXT, move TEXT, category TEXT);
|
||||||
|
```
|
||||||
|
|
||||||
|
把属性放在单独的表中而不是用 type_1 和 type_2 列,可能看起来有点刻意。这取决于具体用例,但由于哪个属性在前并不影响,你每次检查特定属性时都得查两个字段,我觉得这更容易出错。
|
||||||
|
|
||||||
|
然后像这样查询:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT DISTINCT pokemon, special_attack
|
||||||
|
FROM pokemon as p
|
||||||
|
WHERE
|
||||||
|
p.special_attack > 120
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pokemon_moves as pm
|
||||||
|
WHERE p.pokemon_name = pm.pokemon_name AND move = 'freezedry'
|
||||||
|
)
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM pokemon_types as pt
|
||||||
|
WHERE p.pokemon_name = pt.pokemon_name AND type = 'ice'
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
作为对比,再次给出等效的 Prolog 查询:
|
||||||
|
|
||||||
|
```
|
||||||
|
?- pokemon_spa(Pokemon, SpA),
|
||||||
|
SpA #> 120,
|
||||||
|
learns(Pokemon, freezedry),
|
||||||
|
type(Pokemon, ice).
|
||||||
|
```
|
||||||
|
|
||||||
|
我不是在黑 SQL——我很喜欢 SQL——但这是大多数人接触过的最好的声明式查询语言了。Prolog 版本如此简单和灵活,真是令人惊叹。如果我们继续添加更多条件,SQL 查询会变得难以管理,而 Prolog 查询仍然易于阅读和编辑(一旦你掌握了变量的用法)。
|
||||||
|
|
||||||
|
## 升级
|
||||||
|
|
||||||
|
基础讲完了,现在说说我正在做的项目的背景。
|
||||||
|
|
||||||
|
宝可梦对战拥有数量惊人的机制,它们以复杂且概率性的方式相互影响。这些游戏的部分吸引力在于徒劳地试图比对手更好地记住所有机制,利用这些信息来预测和智取对方的计划。这有点像一种很傻的扑克。
|
||||||
|
|
||||||
|
我尚未提及的一小部分游戏机制:
|
||||||
|
|
||||||
|
- 某些招式有一定概率落空,不造成伤害。
|
||||||
|
- 某些招式会提升或降低宝可梦的能力值。
|
||||||
|
- 宝可梦可以携带具有各种效果的物品。
|
||||||
|
- 伤害计算不是恒定的;招式的伤害在计算范围内呈正态分布。
|
||||||
|
- 宝可梦可以被冰冻、灼伤、麻痹、中毒或陷入睡眠;这些都有各种负面效果。
|
||||||
|
- 各种场地效果(如天气、场地、戏法空间)会改变招式伤害、出手顺序等。
|
||||||
|
- 每只宝可梦都有一个特性,具有各种效果,例如:漂浮(Levitate)使你对地面招式免疫,降雨(Drizzle)在宝可梦上场时把天气变成下雨,强行(Sheer Force)禁用招式的副作用但将伤害乘以 1.3 倍。
|
||||||
|
- 玩家在游戏前会(不可见地)为每只宝可梦分配努力值,以提升选定能力值。根据他们如何构建队伍,每只宝可梦可能比你预期的造成更多伤害或更能抗伤。
|
||||||
|
|
||||||
|
如果你想为这个游戏构建软件,挑战在于要在不疯掉的情况下对所有复杂性进行建模。Prolog 在这方面出奇地擅长,主要有两个原因:
|
||||||
|
|
||||||
|
看看这个[伤害计算器](https://calc.pokemonshowdown.com/),你就明白我的意思了。
|
||||||
|
|
||||||
|
- 查询模型擅长描述临时组合。
|
||||||
|
- 数据模型非常适合以一致的方式分层叠加规则。
|
||||||
|
|
||||||
|
听众中的逻辑学家大概会想让我指出,查询模型和数据模型实际上是完全相同的。
|
||||||
|
|
||||||
|
为了说明这一点,这里是我如何为我的宝可梦选秀联赛实现优先度招式的。
|
||||||
|
|
||||||
|
宝可梦选秀大致就是字面意思。宝可梦根据实力被赋予一个分值,每个玩家获得一定数量的分数,你们轮流选直到每个玩家花完所有分数。你的队伍最终会有大约 8-11 只宝可梦,每周你和联赛中的另一个人正面交锋。几年前,我的朋友兼 [WMI](https://wemakeinter.net/) 合作者 [Morry](https://wttdotm.com/) 邀请我加入他的联赛,从那以后我就迷上了这个赛制。
|
||||||
|
|
||||||
|
比赛是 6v6,所以对战的一大部分是为你对手可能带来的所有六只组合做好准备,并组合出你自己的六只来应对所有情况。
|
||||||
|
|
||||||
|
当然,你只能用你选到的宝可梦来组建队伍。我只是用我的名字做了谓词:alex/1。
|
||||||
|
|
||||||
|
```
|
||||||
|
alex(meowscarada).
|
||||||
|
alex(weezinggalar).
|
||||||
|
alex(swampertmega).
|
||||||
|
alex(latios).
|
||||||
|
alex(volcarona).
|
||||||
|
alex(tornadus).
|
||||||
|
alex(politoed).
|
||||||
|
alex(archaludon).
|
||||||
|
alex(beartic).
|
||||||
|
alex(dusclops).
|
||||||
|
```
|
||||||
|
|
||||||
|
我可不自豪选了一个雨天队。这大概是我开始以来最糟糕的选秀;这种事偶尔会发生。
|
||||||
|
|
||||||
|
我的宝可梦里有哪些会 Freeze-Dry?
|
||||||
|
|
||||||
|
```
|
||||||
|
?- alex(Pokemon), learns(Pokemon, freezedry).
|
||||||
|
false.
|
||||||
|
```
|
||||||
|
|
||||||
|
一个都没有。唉。
|
||||||
|
|
||||||
|
非常重要的一类招式为优先度招式。之前我提到速度能力值决定哪只宝可梦先出手。这里有一些细微之处:使用最高优先度招式的宝可梦先出手,如果它们都选择了相同优先度的招式,那么速度更快的一方先出手。
|
||||||
|
|
||||||
|
大多数招式的优先度为零。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- move_priority(Move, P).
|
||||||
|
Move = '10000000voltthunderbolt', P = 0
|
||||||
|
; Move = absorb, P = 0
|
||||||
|
; Move = accelerock, P = 1
|
||||||
|
; Move = acid, P = 0
|
||||||
|
; Move = acidarmor, P = 0
|
||||||
|
; Move = aciddownpour, P = 0
|
||||||
|
; Move = acidspray, P = 0
|
||||||
|
; Move = acrobatics, P = 0
|
||||||
|
; Move = acupressure, P = 0
|
||||||
|
; Move = aerialace, P = 0
|
||||||
|
; Move = aeroblast, P = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
啊,但不是全部!Accelerock(冲岩)的优先度为 1。使用 Accelerock 的宝可梦会先于任何使用优先度 0(或更低)招式的宝可梦行动,即使后者有更高的速度能力值。
|
||||||
|
|
||||||
|
我定义了一个 learns_priority/3 谓词,它合一于一只宝可梦、它学会的优先度招式以及该招式的优先度。
|
||||||
|
|
||||||
|
```
|
||||||
|
learns_priority(Pokemon, Move, P) :-
|
||||||
|
learns(Pokemon, Move),
|
||||||
|
move_priority(Move, P),
|
||||||
|
P #> 0.
|
||||||
|
```
|
||||||
|
|
||||||
|
一个简单的查询——"我的队伍学会了哪些优先度招式"——返回了大量答案。
|
||||||
|
|
||||||
|
```
|
||||||
|
?- alex(Pokemon), learns_priority(Pokemon, Move, Priority).
|
||||||
|
Pokemon = meowscarada, Move = endure, Priority = 4
|
||||||
|
; Pokemon = meowscarada, Move = helpinghand, Priority = 5
|
||||||
|
; Pokemon = meowscarada, Move = protect, Priority = 4
|
||||||
|
; Pokemon = meowscarada, Move = quickattack, Priority = 1
|
||||||
|
; Pokemon = meowscarada, Move = allyswitch, Priority = 2
|
||||||
|
; Pokemon = meowscarada, Move = suckerpunch, Priority = 1
|
||||||
|
; Pokemon = weezinggalar, Move = endure, Priority = 4
|
||||||
|
; Pokemon = weezinggalar, Move = protect, Priority = 4
|
||||||
|
; Pokemon = swampertmega, Move = bide, Priority = 1
|
||||||
|
; Pokemon = swampertmega, Move = endure, Priority = 4
|
||||||
|
; Pokemon = swampertmega, Move = helpinghand, Priority = 5
|
||||||
|
; Pokemon = swampertmega, Move = protect, Priority = 4
|
||||||
|
; Pokemon = swampertmega, Move = wideguard, Priority = 3
|
||||||
|
; Pokemon = latios, Move = allyswitch, Priority = 2
|
||||||
|
; Pokemon = latios, Move = endure, Priority = 4
|
||||||
|
; Pokemon = latios, Move = helpinghand, Priority = 5
|
||||||
|
; Pokemon = latios, Move = magiccoat, Priority = 4
|
||||||
|
; Pokemon = latios, Move = protect, Priority = 4
|
||||||
|
; Pokemon = volcarona, Move = endure, Priority = 4
|
||||||
|
; Pokemon = volcarona, Move = protect, Priority = 4
|
||||||
|
; Pokemon = volcarona, Move = ragepowder, Priority = 2
|
||||||
|
; Pokemon = tornadus, Move = endure, Priority = 4
|
||||||
|
; Pokemon = tornadus, Move = protect, Priority = 4
|
||||||
|
; Pokemon = politoed, Move = detect, Priority = 4
|
||||||
|
; Pokemon = politoed, Move = endure, Priority = 4
|
||||||
|
; Pokemon = politoed, Move = helpinghand, Priority = 5
|
||||||
|
; Pokemon = politoed, Move = protect, Priority = 4
|
||||||
|
; Pokemon = politoed, Move = bide, Priority = 1
|
||||||
|
; Pokemon = archaludon, Move = endure, Priority = 4
|
||||||
|
; Pokemon = archaludon, Move = protect, Priority = 4
|
||||||
|
; Pokemon = beartic, Move = aquajet, Priority = 1
|
||||||
|
; Pokemon = beartic, Move = bide, Priority = 1
|
||||||
|
; Pokemon = beartic, Move = endure, Priority = 4
|
||||||
|
; Pokemon = beartic, Move = protect, Priority = 4
|
||||||
|
; Pokemon = dusclops, Move = allyswitch, Priority = 2
|
||||||
|
; Pokemon = dusclops, Move = endure, Priority = 4
|
||||||
|
; Pokemon = dusclops, Move = helpinghand, Priority = 5
|
||||||
|
; Pokemon = dusclops, Move = protect, Priority = 4
|
||||||
|
; Pokemon = dusclops, Move = shadowsneak, Priority = 1
|
||||||
|
; Pokemon = dusclops, Move = snatch, Priority = 4
|
||||||
|
; Pokemon = dusclops, Move = suckerpunch, Priority = 1
|
||||||
|
; false.
|
||||||
|
```
|
||||||
|
|
||||||
|
虽然这在技术上是对的(最好的一种对),但大多数答案实际上没什么用。帮助手(Helping Hand)和交换场地(Ally Switch)优先度很高,但它们只在双打对战中有用,而我不是在玩那个模式。
|
||||||
|
|
||||||
|
为了解决这个问题,我定义了所有双打招式并将它们排除。我也会排除招式变圆(Bide),它在功能上没用。\\+/1 谓词的意思是"如果这个目标失败则为真",而 dif/2 表示"这两个项不同"。
|
||||||
|
|
||||||
|
```
|
||||||
|
learns_priority(Mon, Move, Priority) :-
|
||||||
|
learns(Mon, Move),
|
||||||
|
\+ doubles_move(Move),
|
||||||
|
dif(Move, bide),
|
||||||
|
move_priority(Move, Priority),
|
||||||
|
Priority #> 0.
|
||||||
|
|
||||||
|
doubles_move(helpinghand).
|
||||||
|
doubles_move(allyswitch).
|
||||||
|
doubles_move(ragepowder).
|
||||||
|
doubles_move(widesuard).
|
||||||
|
doubles_move(snatch).
|
||||||
|
doubles_move(magiccoat).
|
||||||
|
```
|
||||||
|
|
||||||
|
搞定!这个查询只返回我的队伍相关的优先度招式。
|
||||||
|
|
||||||
|
## 组队
|
||||||
|
|
||||||
|
在实践中重要的事情是组建一个实际的六人队伍。队伍由你选到的宝可梦构成,好的队伍是对称的:如果你的一只宝可梦弱于某种属性,你队伍里的其他宝可梦应该能够应对那种属性。
|
||||||
|
|
||||||
|
你可以非常自然地用 Prolog 规则来编程这类约束,然后迭代它们,混合搭配组合,直到找到一个感觉对的方案。
|
||||||
|
|
||||||
|
我是这样定义队伍的:
|
||||||
|
|
||||||
|
```
|
||||||
|
team_size([_,_,_,_,_,_]).
|
||||||
|
```
|
||||||
|
|
||||||
|
这只与恰好有六个元素的列表合一。在谓词里数到六有点烦人,但我把它当作一个练习数数的机会。
|
||||||
|
|
||||||
|
好的队伍需要平衡。这是一个定义平衡的谓词:
|
||||||
|
|
||||||
|
```prolog
|
||||||
|
balanced_team(Team) :-
|
||||||
|
team_size(Team),
|
||||||
|
member(Pokemon1, Team),
|
||||||
|
member(Pokemon2, Team),
|
||||||
|
Pokemon1 \= Pokemon2,
|
||||||
|
type(Pokemon1, Type11),
|
||||||
|
type(Pokemon2, Type21),
|
||||||
|
weak_to(Type11, Type21).
|
||||||
|
```
|
||||||
|
|
||||||
|
等等,这不对。实际上 Prolog 对什么为真什么为假非常严格,所以让我更仔细地想想……
|
||||||
|
|
||||||
|
```prolog
|
||||||
|
balanced_team(Team) :-
|
||||||
|
team_size(Team),
|
||||||
|
% 对于队伍中的所有 Pokemon1
|
||||||
|
findall(Type1, (member(P, Team), type(P, Type1)), Types1),
|
||||||
|
% 检查 P1 的每个属性弱点是否都有某个 P2 抵抗它
|
||||||
|
forall(
|
||||||
|
(member(P1, Team), type(P1, T1), weak_to(T1, T2)),
|
||||||
|
(member(P2, Team), P1 \= P2, type(P2, T2))
|
||||||
|
).
|
||||||
|
```
|
||||||
|
|
||||||
|
好吧,我不会假装我是 Prolog 专家——这些代码大部分第一次都没跑通——但 Prolog 的美妙之处在于,一旦你把事实搞对了,逻辑往往就水到渠成了。
|
||||||
|
|
||||||
|
## Prolog 的特别之处
|
||||||
|
|
||||||
|
我在 Python、JavaScript、SQL 和各种函数式语言上花了很多时间,Prolog 确实感觉与众不同。
|
||||||
|
|
||||||
|
我最大的领悟是:**Prolog 迫使你思考什么是真的,而不是做什么。** 在命令式编程中,你大部分精力花在控制流上——数据如何移动、以什么顺序执行。在 Prolog 中,你只需陈述什么是真的,然后让求解器来处理剩下的事情。
|
||||||
|
|
||||||
|
对于宝可梦对战——本质上是一个有着数千条互动规则的巨大规则引擎——这简直是天启。不用写 if/else 链来检查属性相克,你只需声明属性表,让 Prolog 处理其余部分。
|
||||||
|
|
||||||
|
例如,要找出你的哪只宝可梦能够对水/地面属性的 Swampert 造成效果绝佳的伤害:
|
||||||
|
|
||||||
|
```
|
||||||
|
?- alex(P), learns(P, Move), move_type(Move, Type), super_effective(Type, water), super_effective(Type, ground).
|
||||||
|
```
|
||||||
|
|
||||||
|
这一个查询就把你的队伍名单、招式池数据、招式属性和属性相克表组合成了一个简洁的语句。用 Python 试试看。
|
||||||
|
|
||||||
|
## 最后的思考
|
||||||
|
|
||||||
|
这个项目始于一个玩笑——"如果我拿 Prolog 来做宝可梦组队会怎样"——但它教给我的逻辑编程知识比任何教科书都多。
|
||||||
|
|
||||||
|
如果你感兴趣,我强烈推荐:
|
||||||
|
1. 安装 SWI-Prolog 并玩玩 [prologdex](https://github.com/alexpetros/prologdex) 数据集
|
||||||
|
2. 阅读 [Adventure in Prolog](http://www.amzi.com/AdventureInProlog/) 教程
|
||||||
|
3. 尝试用 Prolog 对你熟悉的领域建模——它会彻底改变你对数据和关系的思考方式
|
||||||
|
|
||||||
|
祝你编码愉快,愿你的属性相克永远是效果绝佳。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **译者注:**
|
||||||
|
> 原文作者:Alexander Petros
|
||||||
|
> 原文标题:Prolog Basics Explained with Pokémon
|
||||||
|
> 原文链接:https://unplannedobsolescence.com/blog/prolog-basics-pokemon/
|
||||||
|
> 本文为中文翻译转载。
|
||||||
@@ -136,6 +136,10 @@ params:
|
|||||||
enable: true
|
enable: true
|
||||||
lineNumbers: false
|
lineNumbers: false
|
||||||
|
|
||||||
|
# Mermaid 流程图(本地加载,避免 CDN 连接问题)
|
||||||
|
mermaid:
|
||||||
|
js: js/mermaid.min.js
|
||||||
|
|
||||||
# Open Graph / SEO 增强
|
# Open Graph / SEO 增强
|
||||||
opengraph:
|
opengraph:
|
||||||
enable: true
|
enable: true
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
@@ -0,0 +1,19 @@
|
|||||||
|
{{- if site.Params.banner }}
|
||||||
|
<div class="hextra-banner hx:max-md:sticky hx:top-0 hx:z-20 hx:px-6 hx:text-center hx:text-slate-50 hx:dark:text-white hx:bg-neutral-900 hx:dark:bg-neutral-800 hx:print:[display:none]">
|
||||||
|
<div class="hx:relative hx:flex hx:items-center hx:justify-center hx:font-medium hx:text-sm hx:py-2.5">
|
||||||
|
{{- with partial "custom/banner.html" . -}}
|
||||||
|
{{- . -}}
|
||||||
|
{{- else -}}
|
||||||
|
<div style="white-space: pre-wrap" class="hx:px-8">
|
||||||
|
{{- site.Params.banner.message | default "🎉 Welcome! This is a banner message." | .RenderString -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
<button
|
||||||
|
class="hextra-banner-close-button hx:cursor-pointer hx:absolute hx:right-0 hx:text-white hx:font-bold hx:leading-none hx:hover:opacity-75 hx:transition hx:w-10 hx:h-10 hx:-mr-2 hx:md:mr-0 hx:flex hx:items-center hx:justify-center"
|
||||||
|
aria-label="{{ (T "closeBanner") | default "Close banner" }}"
|
||||||
|
>
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "x" "attributes" "height=16") -}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{- $page := .page -}}
|
||||||
|
{{- $enable := .enable -}}
|
||||||
|
{{- if (default $enable $page.Params.breadcrumbs) -}}
|
||||||
|
<div class="hx:mt-1.5 hx:flex hx:items-center hx:gap-1 hx:overflow-hidden hx:text-sm hx:text-gray-500 hx:dark:text-gray-400 hx:contrast-more:text-current">
|
||||||
|
{{- range $page.Ancestors.Reverse }}
|
||||||
|
{{- if not .IsHome }}
|
||||||
|
<div class="hx:whitespace-nowrap hx:transition-colors hx:min-w-[24px] hx:overflow-hidden hx:text-ellipsis hx:hover:text-gray-900 hx:dark:hover:text-gray-100">
|
||||||
|
<a href="{{ .RelPermalink }}" class="hx:inline-block hx:rounded-sm hx:hextra-focus-visible-inset">{{- partial "utils/title" . -}}</a>
|
||||||
|
</div>
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-right" "attributes" "class=\"hx:w-3.5 hx:shrink-0 hx:rtl:-rotate-180\"") -}}
|
||||||
|
{{ end -}}
|
||||||
|
{{ end -}}
|
||||||
|
<div class="hx:whitespace-nowrap hx:transition-colors hx:font-medium hx:text-gray-700 hx:contrast-more:font-bold hx:contrast-more:text-current hx:dark:text-gray-100 hx:contrast-more:dark:text-current">
|
||||||
|
{{- partial "utils/title" $page -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{- if hugo.IsProduction -}}
|
||||||
|
|
||||||
|
<!-- Google Analytics -->
|
||||||
|
{{- if .Site.Config.Services.GoogleAnalytics.ID }}
|
||||||
|
<link rel="preconnect" href="https://www.googletagmanager.com" crossorigin />
|
||||||
|
{{ partial "google-analytics.html" . -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<!-- Umami -->
|
||||||
|
{{- if .Site.Params.analytics.umami -}}
|
||||||
|
{{ partial "components/analytics/umami.html" . }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<!-- Matomo -->
|
||||||
|
{{- if .Site.Params.analytics.matomo -}}
|
||||||
|
{{ partial "components/analytics/matomo.html" . }}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
<!-- GoatCounter -->
|
||||||
|
{{- if .Site.Params.analytics.goatCounter -}}
|
||||||
|
{{ partial "components/analytics/goat-counter.html" . }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{- with .Site.Params.analytics.goatCounter -}}
|
||||||
|
{{- if not .code -}}
|
||||||
|
{{- errorf "Missing GoatCounter 'code' configuration. See https://imfing.github.io/hextra/versions/latest/docs/guide/configuration/#goatcounter-analytics" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<script
|
||||||
|
data-goatcounter="https://{{ .code }}.goatcounter.com/count"
|
||||||
|
data-goatcounter-settings='
|
||||||
|
{
|
||||||
|
"no_onload":{{ .noOnload | default false }},
|
||||||
|
"no_events":{{ .noEvents | default false }},
|
||||||
|
"allow_local":{{ .allowLocal | default false }},
|
||||||
|
"allow_frame":{{ .allowFrame | default false }}
|
||||||
|
}
|
||||||
|
'
|
||||||
|
async src="//gc.zgo.at/count.js"></script>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{{- with site.Config.Services.GoogleAnalytics.ID }}
|
||||||
|
<!-- Global site tag (gtag.js) - Google Analytics -->
|
||||||
|
<script async src="https://www.googletagmanager.com/gtag/js?id={{ . }}"></script>
|
||||||
|
<script>
|
||||||
|
window.dataLayer = window.dataLayer || [];
|
||||||
|
function gtag() {
|
||||||
|
dataLayer.push(arguments);
|
||||||
|
}
|
||||||
|
gtag("js", new Date());
|
||||||
|
|
||||||
|
gtag("config", "{{ . }}");
|
||||||
|
</script>
|
||||||
|
{{ end -}}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{{- /*
|
||||||
|
Matomo Analytics.
|
||||||
|
https://developer.matomo.org/guides/tracking-javascript-guide
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- with .Site.Params.analytics.matomo -}}
|
||||||
|
|
||||||
|
{{- if not .serverURL }}
|
||||||
|
{{- errorf "Missing Matomo 'serverURL' configuration. See https://imfing.github.io/hextra/versions/latest/docs/guide/configuration/#matomo-analytics" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if not .websiteID }}
|
||||||
|
{{- errorf "Missing Matomo 'websiteID' configuration. See https://imfing.github.io/hextra/versions/latest/docs/guide/configuration/#matomo-analytics" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<!-- Matomo -->
|
||||||
|
<script type="text/javascript">
|
||||||
|
var _paq = window._paq = window._paq || [];
|
||||||
|
_paq.push(['trackPageView']);
|
||||||
|
_paq.push(['enableLinkTracking']);
|
||||||
|
(function() {
|
||||||
|
var u="//{{ .serverURL }}/";
|
||||||
|
_paq.push(['setTrackerUrl', u+'matomo.php']);
|
||||||
|
_paq.push(['setSiteId', {{ .websiteID }}]);
|
||||||
|
var d=document, g=d.createElement('script'), s=d.getElementsByTagName('script')[0];
|
||||||
|
g.type='text/javascript'; g.async=true; g.src=u+'matomo.js'; s.parentNode.insertBefore(g,s);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<!-- End Matomo Code -->
|
||||||
|
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{{- /*
|
||||||
|
Umami Analytics
|
||||||
|
https://umami.is/docs/tracker-configuration
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- with .Site.Params.analytics.umami -}}
|
||||||
|
|
||||||
|
{{- if not .serverURL }}
|
||||||
|
{{- errorf "Missing Umami 'serverURL' configuration. See https://imfing.github.io/hextra/versions/latest/docs/guide/configuration/#umami-analytics" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if not .websiteID }}
|
||||||
|
{{- errorf "Missing Umami 'websiteID' configuration. See https://imfing.github.io/hextra/versions/latest/docs/guide/configuration/#umami-analytics" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $attributes := newScratch -}}
|
||||||
|
|
||||||
|
{{- $attributes.SetInMap "umami" "src" (printf "%s/%s" .serverURL (.scriptName | default "script.js")) -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-website-id" .websiteID -}}
|
||||||
|
|
||||||
|
{{- if .hostURL -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-host-url */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-host-url" .hostURL -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .autoTrack -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-auto-track */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-auto-track" .autoTrack -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .tag -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-tag */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-tag" .tag -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .excludeSearch -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-exclude-search */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-exclude-search" .excludeSearch -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .excludeHash -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-exclude-hash */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-exclude-hash" .excludeHash -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .doNotTrack -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-do-not-track */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-do-not-track" .doNotTrack -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .domains -}}
|
||||||
|
{{- /* https://umami.is/docs/tracker-configuration#data-domains */ -}}
|
||||||
|
{{- $attributes.SetInMap "umami" "data-domains" .domains -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<script async defer {{ range $k, $v := ($attributes.Get "umami" ) }} {{ (printf `%s=%q` $k $v) | safeHTMLAttr }}{{- end -}}></script>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{/*
|
||||||
|
Blog pagination component for list pages (e.g., blog list, category list)
|
||||||
|
|
||||||
|
Usage: {{ partial "components/blog-pager.html" $paginator }}
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- . (context): Hugo paginator object
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{- $paginator := . -}}
|
||||||
|
{{- $prevText := (T "previous") | default "Prev" -}}
|
||||||
|
{{- $nextText := (T "next") | default "Next" -}}
|
||||||
|
{{- $prevLabel := printf "%s %d/%d" $prevText (sub $paginator.PageNumber 1) $paginator.TotalPages -}}
|
||||||
|
{{- $nextLabel := printf "%s %d/%d" $nextText (add $paginator.PageNumber 1) $paginator.TotalPages -}}
|
||||||
|
|
||||||
|
{{- if or $paginator.HasPrev $paginator.HasNext -}}
|
||||||
|
<div class="hx:mb-8 hx:flex hx:items-center hx:border-t hx:pt-8 hx:border-gray-200 hx:dark:border-neutral-800 hx:contrast-more:border-neutral-400 hx:dark:contrast-more:border-neutral-400 hx:print:hidden">
|
||||||
|
{{- if $paginator.HasPrev -}}
|
||||||
|
<a
|
||||||
|
href="{{ $paginator.Prev.URL }}"
|
||||||
|
title="{{ $prevLabel }}"
|
||||||
|
class="hx:flex hx:max-w-[50%] hx:items-center hx:gap-1 hx:py-4 hx:text-base hx:font-medium hx:text-gray-600 hx:transition-colors [word-break:break-word] hx:hover:text-primary-600 hx:dark:text-gray-300 hx:md:text-lg hx:ltr:pr-4 hx:rtl:pl-4"
|
||||||
|
>
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-right" "attributes" "class=\"hx:inline hx:h-5 hx:shrink-0 hx:ltr:rotate-180\"") -}}
|
||||||
|
{{ $prevLabel }}
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $paginator.HasNext -}}
|
||||||
|
<a
|
||||||
|
href="{{ $paginator.Next.URL }}"
|
||||||
|
title="{{ $nextLabel }}"
|
||||||
|
class="hx:flex hx:max-w-[50%] hx:items-center hx:gap-1 hx:py-4 hx:text-base hx:font-medium hx:text-gray-600 hx:transition-colors [word-break:break-word] hx:hover:text-primary-600 hx:dark:text-gray-300 hx:md:text-lg hx:ltr:ml-auto hx:ltr:pl-4 hx:ltr:text-right hx:rtl:mr-auto hx:rtl:pr-4 hx:rtl:text-left"
|
||||||
|
>
|
||||||
|
{{ $nextLabel }}
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-right" "attributes" "class=\"hx:inline hx:h-5 hx:shrink-0 hx:rtl:-rotate-180\"") -}}
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{{/* TODO: remove filename variable */}}
|
||||||
|
{{- $filename := .filename | default "" -}}
|
||||||
|
{{- $display := site.Params.highlight.copy.display | default "hover" -}}
|
||||||
|
{{- $copyCode := (T "copyCode") | default "Copy code" -}}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="hextra-code-copy-btn-container {{ if eq $display `hover` }}hx:opacity-0{{ end }} hx:transition hx:group-hover/code:opacity-100 hx:flex hx:gap-1 hx:absolute hx:m-[11px] hx:right-0 {{ if $filename }}hx:top-8{{ else }}hx:top-0{{ end }}">
|
||||||
|
<button
|
||||||
|
class="hextra-code-copy-btn hx:group/copybtn hx:cursor-pointer hx:transition-all hx:active:opacity-50 hx:bg-primary-700/5 hx:border hx:border-black/5 hx:text-gray-600 hx:hover:text-gray-900 hx:rounded-md hx:p-1.5 hx:dark:bg-primary-300/10 hx:dark:border-white/10 hx:dark:text-gray-400 hx:dark:hover:text-gray-50"
|
||||||
|
title="{{ $copyCode }}"
|
||||||
|
aria-label="{{ $copyCode }}"
|
||||||
|
data-copied-label="{{ (T "copied") | default "Copied!" }}"
|
||||||
|
>
|
||||||
|
<div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:h-4 hx:w-4"></div>
|
||||||
|
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:h-4 hx:w-4"></div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{{ $filename := .filename | default "" -}}
|
||||||
|
{{ $base_url := .base_url | default "" -}}
|
||||||
|
{{ $lang := .lang | default "" }}
|
||||||
|
{{ $content := .content }}
|
||||||
|
{{ $options := .options | default (dict) }}
|
||||||
|
|
||||||
|
{{- if $filename -}}
|
||||||
|
<div class="hextra-code-filename not-prose" dir="auto">
|
||||||
|
{{- if $base_url -}}
|
||||||
|
|
||||||
|
{{- $base_url = strings.TrimSuffix "/" $base_url -}}
|
||||||
|
{{- $filename = strings.TrimPrefix "/" $filename -}}
|
||||||
|
{{- $file_url := urls.JoinPath $base_url $filename -}}
|
||||||
|
|
||||||
|
<a class="hx:no-underline hx:inline-flex hx:items-center hx:gap-1" href="{{ $file_url }}" target="_blank" rel="noopener noreferrer">
|
||||||
|
<span>{{- $filename -}}</span>
|
||||||
|
{{- partial "utils/icon" (dict "name" "external-link" "attributes" "height=1em") -}}
|
||||||
|
</a>
|
||||||
|
{{- else -}}
|
||||||
|
{{- $filename -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if transform.CanHighlight $lang -}}
|
||||||
|
<div>{{- highlight $content $lang $options -}}</div>
|
||||||
|
{{- else -}}
|
||||||
|
<div><pre><code>{{ $content }}</code></pre></div>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{- $enableComments := site.Params.comments.enable | default false -}}
|
||||||
|
|
||||||
|
{{ if not (eq .Params.comments nil) }}
|
||||||
|
{{ $enableComments = .Params.comments }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{- if $enableComments -}}
|
||||||
|
{{- if eq site.Params.comments.type "giscus" -}}
|
||||||
|
{{ partial "components/giscus.html" . }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
{{- $lang := site.Language.Lang | default `en` -}}
|
||||||
|
{{- if hasPrefix $lang "zh" -}}
|
||||||
|
{{- /* See: https://github.com/giscus/giscus/tree/main/locales */}}
|
||||||
|
{{- $lang = partial "utils/hugo-compat/language-locale.html" site.Language | default `zh-CN` -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- with site.Params.comments.giscus -}}
|
||||||
|
<script>
|
||||||
|
function getGiscusTheme() {
|
||||||
|
const giscusTheme = '{{ .theme }}';
|
||||||
|
if (giscusTheme === 'light' || giscusTheme === 'dark') {
|
||||||
|
return giscusTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hugoTheme = localStorage.getItem("color-theme");
|
||||||
|
if (hugoTheme === 'light' || hugoTheme === 'dark') {
|
||||||
|
return hugoTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hugoTheme === 'system') {
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultTheme = '{{ site.Params.theme.default }}';
|
||||||
|
if (defaultTheme === 'light' || defaultTheme === 'dark') {
|
||||||
|
return defaultTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGiscusTheme() {
|
||||||
|
const iframe = document.querySelector('iframe.giscus-frame');
|
||||||
|
if (!iframe) return;
|
||||||
|
|
||||||
|
const msg = {
|
||||||
|
giscus: {
|
||||||
|
setConfig: {
|
||||||
|
theme: getGiscusTheme(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
iframe.contentWindow.postMessage(msg, 'https://giscus.app');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const giscusAttributes = {
|
||||||
|
"src": "https://giscus.app/client.js",
|
||||||
|
"data-repo": "{{ .repo }}",
|
||||||
|
"data-repo-id": "{{ .repoId }}",
|
||||||
|
"data-category": "{{ .category }}",
|
||||||
|
"data-category-id": "{{ .categoryId }}",
|
||||||
|
"data-mapping": "{{ .mapping | default `pathname` }}",
|
||||||
|
"data-strict": "{{ (string .strict) | default 0 }}",
|
||||||
|
"data-reactions-enabled": "{{ (string .reactionsEnabled) | default 1 }}",
|
||||||
|
"data-emit-metadata": "{{ (string .emitMetadata) | default 0 }}",
|
||||||
|
"data-input-position": "{{ .inputPosition | default `top` }}",
|
||||||
|
"data-theme": getGiscusTheme(),
|
||||||
|
"data-lang": "{{ .lang | default $lang }}",
|
||||||
|
"crossorigin": "anonymous",
|
||||||
|
"async": "",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dynamically create script tag
|
||||||
|
const giscusScript = document.createElement("script");
|
||||||
|
Object.entries(giscusAttributes).forEach(([key, value]) => giscusScript.setAttribute(key, value));
|
||||||
|
// Random hash id to avoid conflicts with titles inside pages.
|
||||||
|
document.getElementById('giscus-hextra-bb112b9f807c37c1752e5da6a1652a29').appendChild(giscusScript);
|
||||||
|
|
||||||
|
// Listen for system theme changes
|
||||||
|
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", setGiscusTheme);
|
||||||
|
|
||||||
|
// Update giscus theme when theme switcher is clicked.
|
||||||
|
const themeToggleOptions = document.querySelectorAll(".hextra-theme-toggle-options [data-item]");
|
||||||
|
if (themeToggleOptions) {
|
||||||
|
themeToggleOptions.forEach(toggle => {
|
||||||
|
toggle.addEventListener('click', () => {
|
||||||
|
setTimeout(setGiscusTheme, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div id="giscus-hextra-bb112b9f807c37c1752e5da6a1652a29"></div>
|
||||||
|
{{- else -}}
|
||||||
|
{{ warnf "giscus is not configured" }}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{{- $content := .content -}}
|
||||||
|
{{- $alertType := .alertType -}}
|
||||||
|
{{- $alertTitle := .alertTitle -}}
|
||||||
|
|
||||||
|
{{- $styles := newScratch -}}
|
||||||
|
{{- $styles.Set "default" (dict
|
||||||
|
"icon" "light-bulb"
|
||||||
|
"style" "hx:border-green-200 hx:bg-green-100 hx:text-green-900 hx:dark:border-green-200/30 hx:dark:bg-green-900/30 hx:dark:text-green-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "note" (dict
|
||||||
|
"icon" "information-circle"
|
||||||
|
"style" "hx:border-blue-200 hx:bg-blue-100 hx:text-blue-900 hx:dark:border-blue-200/30 hx:dark:bg-blue-900/30 hx:dark:text-blue-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "tip" (dict
|
||||||
|
"icon" "light-bulb"
|
||||||
|
"style" "hx:border-green-200 hx:bg-green-100 hx:text-green-900 hx:dark:border-green-200/30 hx:dark:bg-green-900/30 hx:dark:text-green-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "important" (dict
|
||||||
|
"icon" "information-circle"
|
||||||
|
"style" "hx:border-purple-200 hx:bg-purple-100 hx:text-purple-900 hx:dark:border-purple-200/30 hx:dark:bg-purple-900/30 hx:dark:text-purple-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "warning" (dict
|
||||||
|
"icon" "exclamation"
|
||||||
|
"style" "hx:border-amber-200 hx:bg-amber-100 hx:text-amber-900 hx:dark:border-amber-200/30 hx:dark:bg-amber-900/30 hx:dark:text-amber-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "caution" (dict
|
||||||
|
"icon" "exclamation-circle"
|
||||||
|
"style" "hx:border-red-200 hx:bg-red-100 hx:text-red-900 hx:dark:border-red-200/30 hx:dark:bg-red-900/30 hx:dark:text-red-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
|
||||||
|
{{- $style := or ($styles.Get $alertType) ($styles.Get "default") -}}
|
||||||
|
{{- $title := or $alertTitle (or (i18n $alertType) (title $alertType)) -}}
|
||||||
|
|
||||||
|
<div class="hx:overflow-x-auto hx:mt-6 hx:flex hx:flex-col hx:rounded-lg hx:border hx:py-4 hx:px-4 hx:border-gray-200 hx:contrast-more:border-current hx:contrast-more:dark:border-current {{ $style.style }}">
|
||||||
|
<p class="hx:flex hx:items-center hx:font-medium">
|
||||||
|
{{- with $style.icon -}}
|
||||||
|
{{- partial "utils/icon.html" (dict "name" . "attributes" `height=16px class="hx:inline-block hx:align-middle hx:mr-2"`) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $title -}}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="hx:w-full hx:min-w-0 hx:leading-7">
|
||||||
|
<div class="hx:mt-6 hx:leading-7 hx:first:mt-0">
|
||||||
|
{{- $content -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{- $lastUpdated := (T "lastUpdated") | default "Last updated on" -}}
|
||||||
|
{{- $page := . -}}
|
||||||
|
|
||||||
|
{{- if site.Params.displayUpdatedDate -}}
|
||||||
|
{{- with .Lastmod -}}
|
||||||
|
{{ $datetime := (time.Format "2006-01-02T15:04:05.000Z" .) }}
|
||||||
|
<div class="hx:mt-12 hx:mb-8 hx:block hx:text-xs hx:text-gray-500 hx:ltr:text-right hx:rtl:text-left hx:dark:text-gray-400">
|
||||||
|
{{ $lastUpdated }} <time datetime="{{ $datetime }}">{{ partial "utils/format-date" . }}</time>
|
||||||
|
{{- if site.Params.displayUpdatedAuthor -}}
|
||||||
|
{{- with $page.GitInfo -}}
|
||||||
|
{{ print " • " .AuthorName | safeHTML }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- else -}}
|
||||||
|
<div class="hx:mt-16"></div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
<div class="hx:mt-16"></div>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{{- $enableGlobal := site.Params.page.contextMenu.enable | default false -}}
|
||||||
|
{{- $enablePage := .Params.contextMenu -}}
|
||||||
|
{{- $enable := cond (ne $enablePage nil) $enablePage $enableGlobal -}}
|
||||||
|
{{- $customLinks := site.Params.page.contextMenu.links | default slice -}}
|
||||||
|
|
||||||
|
{{- if $enable -}}
|
||||||
|
{{- with .OutputFormats.Get "markdown" -}}
|
||||||
|
{{- $markdownURL := .Permalink -}}
|
||||||
|
{{- $pageURL := $.Permalink -}}
|
||||||
|
{{- $pageTitle := $.Title -}}
|
||||||
|
<div class="hextra-page-context-menu hx:relative hx:inline-flex hx:shrink-0">
|
||||||
|
<div class="hx:inline-flex hx:rounded-lg hx:border hx:border-gray-200 hx:transition-colors hx:hover:border-gray-300 hx:dark:border-neutral-800 hx:dark:hover:border-neutral-700">
|
||||||
|
<button
|
||||||
|
class="hextra-page-context-menu-copy hx:group/copybtn hx:inline-flex hx:cursor-pointer hx:items-center hx:gap-1.5 hx:bg-transparent hx:px-2.5 hx:py-1 hx:text-sm hx:font-medium hx:text-gray-700 hx:transition-colors hx:hover:bg-slate-50 hx:dark:text-gray-300 hx:dark:hover:bg-neutral-900 hx:ltr:rounded-l-lg hx:rtl:rounded-r-lg hx:hextra-focus-visible-inset"
|
||||||
|
data-url="{{ $markdownURL }}"
|
||||||
|
title="{{ i18n "copyAsMarkdown" }}"
|
||||||
|
aria-label="{{ i18n "copyAsMarkdown" }}"
|
||||||
|
>
|
||||||
|
<div class="hextra-copy-icon hx:group-[.copied]/copybtn:hidden hx:pointer-events-none hx:size-4">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "copy" "attributes" "height=16 width=16") -}}
|
||||||
|
</div>
|
||||||
|
<div class="hextra-success-icon hx:hidden hx:group-[.copied]/copybtn:block hx:pointer-events-none hx:size-4">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "check" "attributes" "height=16 width=16") -}}
|
||||||
|
</div>
|
||||||
|
<span>{{ i18n "copyPage" }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="hextra-page-context-menu-toggle hx:inline-flex hx:cursor-pointer hx:items-center hx:justify-center hx:bg-transparent hx:px-1.5 hx:py-1 hx:text-gray-500 hx:transition-colors hx:hover:bg-slate-50 hx:hover:text-gray-700 hx:dark:text-gray-400 hx:dark:hover:bg-neutral-900 hx:dark:hover:text-gray-300 hx:ltr:rounded-r-lg hx:rtl:rounded-l-lg hx:hextra-focus-visible-inset"
|
||||||
|
data-state="closed"
|
||||||
|
aria-label="{{ (T "togglePageContextMenu") | default "Toggle page context menu" }}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<div class="hx:size-4 hx:transition-transform hx:duration-200" data-chevron>
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-down" "attributes" "height=16 width=16") -}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul class="hextra-page-context-menu-dropdown not-prose hx:hidden hx:absolute hx:top-full hx:left-0 hx:sm:left-auto hx:sm:right-0 hx:mt-1 hx:z-20 hx:max-h-64 hx:overflow-auto hx:rounded-lg hx:border hx:border-gray-200 hx:bg-white hx:p-1 hx:text-sm hx:shadow-lg hx:dark:border-neutral-700 hx:dark:bg-neutral-900" role="menu">
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
data-action="copy"
|
||||||
|
role="menuitem"
|
||||||
|
class="hx:flex hx:w-full hx:cursor-pointer hx:select-none hx:items-center hx:gap-2 hx:whitespace-nowrap hx:rounded-sm hx:px-2 hx:py-1.5 hx:text-sm hx:text-gray-700 hx:outline-none hx:transition-colors hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:text-gray-300 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
<div class="hx:size-4 hx:shrink-0 hx:text-gray-500 hx:dark:text-gray-400">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "copy" "attributes" "height=16 width=16") -}}
|
||||||
|
</div>
|
||||||
|
{{ i18n "copyAsMarkdown" }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
data-action="view"
|
||||||
|
data-url="{{ $markdownURL }}"
|
||||||
|
role="menuitem"
|
||||||
|
class="hx:flex hx:w-full hx:cursor-pointer hx:select-none hx:items-center hx:gap-2 hx:whitespace-nowrap hx:rounded-sm hx:px-2 hx:py-1.5 hx:text-sm hx:text-gray-700 hx:outline-none hx:transition-colors hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:text-gray-300 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
<div class="hx:size-4 hx:shrink-0 hx:text-gray-500 hx:dark:text-gray-400">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "markdown" "attributes" "height=16 width=16") -}}
|
||||||
|
</div>
|
||||||
|
{{ i18n "viewAsMarkdown" }}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{- if $customLinks -}}
|
||||||
|
<li class="hx:my-1 hx:h-px hx:bg-gray-200 hx:dark:bg-neutral-700" role="separator"></li>
|
||||||
|
{{- range $customLinks -}}
|
||||||
|
{{- $linkURL := partial "utils/template-url.html" (dict "template" .url "values" (dict "url" $pageURL "title" $pageTitle "markdown_url" $markdownURL)) -}}
|
||||||
|
<li role="none">
|
||||||
|
<a
|
||||||
|
href="{{ $linkURL }}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
role="menuitem"
|
||||||
|
class="hx:flex hx:w-full hx:cursor-pointer hx:select-none hx:items-center hx:gap-2 hx:whitespace-nowrap hx:rounded-sm hx:px-2 hx:py-1.5 hx:text-sm hx:text-gray-700 hx:outline-none hx:transition-colors hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:text-gray-300 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100"
|
||||||
|
>
|
||||||
|
{{- with .icon -}}
|
||||||
|
<div class="hx:size-4 hx:shrink-0 hx:text-gray-500 hx:dark:text-gray-400">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" . "attributes" "height=16 width=16") -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{ .name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
{{/* Article navigation on the footer of the article */}}
|
||||||
|
|
||||||
|
{{- $reversePagination := .Store.Get "reversePagination" | default false -}}
|
||||||
|
|
||||||
|
{{- $prev := cond $reversePagination .PrevInSection .NextInSection -}}
|
||||||
|
{{- $next := cond $reversePagination .NextInSection .PrevInSection -}}
|
||||||
|
|
||||||
|
{{- if eq .Params.prev false }}
|
||||||
|
{{- if $reversePagination }}{{ $next = false }}{{ else }}{{ $prev = false }}{{ end -}}
|
||||||
|
{{ else }}
|
||||||
|
{{- with .Params.prev -}}
|
||||||
|
{{- with $.Site.GetPage . -}}
|
||||||
|
{{- if $reversePagination }}{{ $next = . }}{{ else }}{{ $prev = . }}{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if eq .Params.next false }}
|
||||||
|
{{- if $reversePagination }}{{ $prev = false }}{{ else }}{{ $next = false }}{{ end -}}
|
||||||
|
{{ else }}
|
||||||
|
{{- with .Params.next -}}
|
||||||
|
{{- with $.Site.GetPage . -}}
|
||||||
|
{{- if $reversePagination }}{{ $prev = . }}{{ else }}{{ $next = . }}{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if or $prev $next -}}
|
||||||
|
<div class="hx:mb-8 hx:flex hx:items-center hx:border-t hx:pt-8 hx:border-gray-200 hx:dark:border-neutral-800 hx:contrast-more:border-neutral-400 hx:dark:contrast-more:border-neutral-400 hx:print:hidden">
|
||||||
|
{{- if $prev -}}
|
||||||
|
{{- $linkTitle := partial "utils/title" $prev -}}
|
||||||
|
<a
|
||||||
|
href="{{ $prev.RelPermalink }}"
|
||||||
|
title="{{ $linkTitle }}"
|
||||||
|
class="hx:flex hx:max-w-[50%] hx:items-center hx:gap-1 hx:py-4 hx:text-base hx:font-medium hx:text-gray-600 hx:transition-colors [word-break:break-word] hx:hover:text-primary-600 hx:dark:text-gray-300 hx:md:text-lg hx:ltr:pr-4 hx:rtl:pl-4"
|
||||||
|
>
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-right" "attributes" "class=\"hx:inline hx:h-5 hx:shrink-0 hx:ltr:rotate-180\"") -}}
|
||||||
|
{{- $linkTitle -}}
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $next -}}
|
||||||
|
{{- $linkTitle := partial "utils/title" $next -}}
|
||||||
|
<a
|
||||||
|
href="{{ $next.RelPermalink }}"
|
||||||
|
title="{{ $linkTitle }}"
|
||||||
|
class="hx:flex hx:max-w-[50%] hx:items-center hx:gap-1 hx:py-4 hx:text-base hx:font-medium hx:text-gray-600 hx:transition-colors [word-break:break-word] hx:hover:text-primary-600 hx:dark:text-gray-300 hx:md:text-lg hx:ltr:ml-auto hx:ltr:pl-4 hx:ltr:text-right hx:rtl:mr-auto hx:rtl:pr-4 hx:rtl:text-left"
|
||||||
|
>
|
||||||
|
{{- $linkTitle -}}
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-right" "attributes" "class=\"hx:inline hx:h-5 hx:shrink-0 hx:rtl:-rotate-180\"") -}}
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<link rel="icon shortcut" href="{{ "favicon.ico" | relURL }}" sizes="32x32" />
|
||||||
|
<link rel="icon" href="{{ "favicon.svg" | relURL }}" type="image/svg+xml" id="favicon-svg" />
|
||||||
|
<link rel="icon" href="{{ "favicon-16x16.png" | relURL }}" type="image/png" sizes="16x16" />
|
||||||
|
<link rel="icon" href="{{ "favicon-32x32.png" | relURL }}" type="image/png" sizes="32x32" />
|
||||||
|
<link rel="apple-touch-icon" href="{{ "apple-touch-icon.png" | relURL }}" sizes="180x180" />
|
||||||
|
<link fetchpriority="low" href="{{ "site.webmanifest" | relURL }}" rel="manifest" />
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
{{- $enableFooterSwitches := .Store.Get "enableFooterSwitches" | default false -}}
|
||||||
|
{{- $displayThemeToggle := site.Params.theme.displayToggle | default true -}}
|
||||||
|
{{- $footerSwitchesVisible := and $enableFooterSwitches (or (gt (len site.Languages) 1) $displayThemeToggle) -}}
|
||||||
|
{{- $copyrightSectionVisible := or (.Site.Params.footer.displayPoweredBy | default true) .Site.Params.footer.displayCopyright -}}
|
||||||
|
|
||||||
|
{{- $copyright := (T "copyright") | default "© 2024 Hextra." -}}
|
||||||
|
{{- $poweredBy := (T "poweredBy") | default "Powered by Hextra" -}}
|
||||||
|
|
||||||
|
|
||||||
|
<footer class="hextra-footer hx:bg-gray-100 hx:pb-[env(safe-area-inset-bottom)] hx:dark:bg-neutral-900 hx:print:bg-transparent">
|
||||||
|
{{- if $footerSwitchesVisible -}}
|
||||||
|
<div class="hx:mx-auto hx:flex hx:gap-2 hx:py-2 hx:px-4 hextra-max-footer-width">
|
||||||
|
{{- partial "language-switch.html" (dict "context" .) -}}
|
||||||
|
{{- with $displayThemeToggle }}{{ partial "theme-toggle.html" }}{{ end -}}
|
||||||
|
</div>
|
||||||
|
{{- if or (gt (len site.Languages) 1) $displayThemeToggle -}}
|
||||||
|
<hr class="hx:border-gray-200 hx:dark:border-neutral-800" />
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
<div class="hextra-custom-footer hextra-max-footer-width hx:mx-auto hx:pl-[max(env(safe-area-inset-left),1.5rem)] hx:pr-[max(env(safe-area-inset-right),1.5rem)] hx:text-gray-600 hx:dark:text-gray-400">
|
||||||
|
{{- partial "custom/footer.html" (dict "context" . "switchesVisible" $footerSwitchesVisible "copyrightVisible" $copyrightSectionVisible) -}}
|
||||||
|
</div>
|
||||||
|
{{- if $copyrightSectionVisible -}}
|
||||||
|
<div
|
||||||
|
class="hextra-max-footer-width hx:mx-auto hx:flex hx:justify-center hx:py-12 hx:pl-[max(env(safe-area-inset-left),1.5rem)] hx:pr-[max(env(safe-area-inset-right),1.5rem)] hx:text-gray-600 hx:dark:text-gray-400 hx:md:justify-start"
|
||||||
|
>
|
||||||
|
<div class="hx:flex hx:w-full hx:flex-col hx:items-center hx:sm:items-start">
|
||||||
|
{{- if (.Site.Params.footer.displayPoweredBy | default true) }}<div class="hx:font-semibold">{{ template "theme-credit" $poweredBy }}</div>{{- end -}}
|
||||||
|
{{- if .Site.Params.footer.displayCopyright }}<div class="hx:mt-6 hx:text-xs">{{ $copyright | markdownify }}</div>{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
{{- define "theme-credit" -}}
|
||||||
|
<a class="hx:flex hx:text-sm hx:items-center hx:gap-1 hx:text-current" target="_blank" rel="noopener noreferrer" title="Hextra GitHub Homepage" href="https://github.com/imfing/hextra">
|
||||||
|
<span>
|
||||||
|
{{- . | markdownify -}}
|
||||||
|
{{- if strings.Contains . "Hextra" -}}
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "hextra" "attributes" `height=1em class="hx:inline-block hx:ltr:ml-1 hx:rtl:mr-1 hx:align-[-2.5px]"`) -}}
|
||||||
|
{{- end -}}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{{- /* Only for compatibility. */ -}}
|
||||||
|
{{- partial "components/analytics/google-analytics.html" . -}}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
{{- $noindex := .Params.noindex | default false -}}
|
||||||
|
{{ if and (hugo.IsProduction) (not $noindex) -}}
|
||||||
|
<meta name="robots" content="index, follow" />
|
||||||
|
{{ else -}}
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
{{ end -}}
|
||||||
|
{{ partialCached "favicons.html" . -}}
|
||||||
|
<title>
|
||||||
|
{{- if .IsHome -}}
|
||||||
|
{{ .Site.Title -}}
|
||||||
|
{{ else -}}
|
||||||
|
{{ with .Title }}{{ . }} – {{ end -}}
|
||||||
|
{{ .Site.Title -}}
|
||||||
|
{{ end -}}
|
||||||
|
</title>
|
||||||
|
<meta name="description" content="{{ partial "utils/page-description.html" . }}" />
|
||||||
|
|
||||||
|
{{- with .Params.canonical -}}
|
||||||
|
<link rel="canonical" href="{{ . }}" itemprop="url" />
|
||||||
|
{{- else -}}
|
||||||
|
<link rel="canonical" href="{{ .Permalink }}" itemprop="url" />
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- partial "opengraph.html" . -}}
|
||||||
|
{{- partial "schema.html" . -}}
|
||||||
|
{{- partial "twitter_cards.html" . -}}
|
||||||
|
|
||||||
|
{{- $mainCss := resources.Get "css/compiled/main.css" -}}
|
||||||
|
{{- $customCss := resources.Get "css/custom.css" -}}
|
||||||
|
{{- $variablesCss := resources.Get "css/variables.css" | resources.ExecuteAsTemplate "css/variables.css" . -}}
|
||||||
|
|
||||||
|
{{- /* Production build */ -}}
|
||||||
|
{{- if hugo.IsProduction }}
|
||||||
|
{{- $styles := slice $variablesCss $mainCss $customCss | resources.Concat "css/compiled/main.css" | minify | fingerprint }}
|
||||||
|
<link rel="preload" href="{{ $styles.RelPermalink }}" as="style" integrity="{{ $styles.Data.Integrity }}" />
|
||||||
|
<link href="{{ $styles.RelPermalink }}" rel="stylesheet" integrity="{{ $styles.Data.Integrity }}" />
|
||||||
|
|
||||||
|
{{- /* Theme development mode (non-production + theme environment) */ -}}
|
||||||
|
{{- else if eq hugo.Environment "theme" }}
|
||||||
|
{{- $devStyles := resources.Get "css/styles.css" | postCSS (dict "inlineImports" true) }}
|
||||||
|
<link href="{{ $devStyles.RelPermalink }}" rel="stylesheet" />
|
||||||
|
<link href="{{ $variablesCss.RelPermalink }}" rel="stylesheet" />
|
||||||
|
<link href="{{ $customCss.RelPermalink }}" rel="stylesheet" />
|
||||||
|
|
||||||
|
{{- /* User local development */ -}}
|
||||||
|
{{- else }}
|
||||||
|
{{- $styles := resources.Get "css/compiled/main.css" -}}
|
||||||
|
<link href="{{ $styles.RelPermalink }}" rel="stylesheet" />
|
||||||
|
<link href="{{ $variablesCss.RelPermalink }}" rel="stylesheet" />
|
||||||
|
<link href="{{ $customCss.RelPermalink }}" rel="stylesheet" />
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
{{ partial "components/analytics/analytics.html" . }}
|
||||||
|
|
||||||
|
{{- $scriptsHead := slice -}}
|
||||||
|
{{- range resources.Match "js/head/*.js" -}}
|
||||||
|
{{ $scriptsHead = $scriptsHead | append (resources.ExecuteAsTemplate .Name $ .) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $scripts := $scriptsHead | resources.Concat "js/main-head.js" -}}
|
||||||
|
|
||||||
|
{{- if hugo.IsProduction -}}
|
||||||
|
{{- $scripts = $scripts | minify | fingerprint -}}
|
||||||
|
{{- end -}}
|
||||||
|
<script src="{{ $scripts.RelPermalink }}" integrity="{{ $scripts.Data.Integrity }}"></script>
|
||||||
|
|
||||||
|
<!-- Math engine -->
|
||||||
|
{{ $noop := .WordCount -}}
|
||||||
|
{{- $engine := site.Params.math.engine | default "katex" -}}
|
||||||
|
{{ if and (.Page.Store.Get "hasMath") (eq $engine "katex") -}}
|
||||||
|
{{ partialCached "scripts/katex.html" . -}}
|
||||||
|
{{ else if and (.Page.Store.Get "hasMath") (eq $engine "mathjax") -}}
|
||||||
|
{{ partialCached "scripts/mathjax.html" . -}}
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{ partial "utils/page-width-override.html" . }}
|
||||||
|
{{ partial "custom/head-end.html" . -}}
|
||||||
|
</head>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{{- $page := .context -}}
|
||||||
|
{{- $iconName := .iconName | default "globe-alt" -}}
|
||||||
|
{{- $iconHeight := .iconHeight | default 12 -}}
|
||||||
|
{{- $location := .location -}}
|
||||||
|
|
||||||
|
{{- $class := .class | default "hx:h-7 hx:px-2 hx:text-xs hx:text-gray-600 hx:transition-colors hx:dark:text-gray-400 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:hover:bg-primary-100/5 hx:dark:hover:text-gray-50" -}}
|
||||||
|
|
||||||
|
{{- $grow := .grow -}}
|
||||||
|
{{- $hideLabel := .hideLabel | default false -}}
|
||||||
|
|
||||||
|
{{- $changeLanguage := (T "changeLanguage") | default "Change language" -}}
|
||||||
|
{{- $currentLanguageLang := site.Language.Lang -}}
|
||||||
|
{{- $currentLanguageLabel := partial "utils/hugo-compat/language-label.html" site.Language -}}
|
||||||
|
|
||||||
|
{{- $isML := gt (len site.Languages) 1 -}}{{- if $isML -}}
|
||||||
|
<div class="hx:flex hx:justify-items-start {{ if $grow }}hx:grow{{ end }}">
|
||||||
|
<button
|
||||||
|
title="{{ $changeLanguage }}"
|
||||||
|
data-state="closed"
|
||||||
|
data-location="{{ $location }}"
|
||||||
|
class="hextra-language-switcher hx:cursor-pointer hx:rounded-md hx:text-left hx:font-medium {{ $class }} hx:grow"
|
||||||
|
type="button"
|
||||||
|
aria-label="{{ $changeLanguage }}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<div class="hx:flex hx:items-center hx:gap-2 hx:capitalize">
|
||||||
|
{{- partial "utils/icon" (dict "name" $iconName "attributes" (printf "height=%d" $iconHeight)) -}}
|
||||||
|
{{- if not $hideLabel }}<span>{{ $currentLanguageLabel }}</span>{{ end -}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
class="hextra-language-options hx:hidden hx:z-20 hx:max-h-64 hx:overflow-auto hx:rounded-lg hx:border hx:border-gray-200 hx:bg-white hx:p-1 hx:text-sm hx:shadow-lg hx:dark:border-neutral-700 hx:dark:bg-neutral-900"
|
||||||
|
style="position: fixed; inset: auto auto 0px 0px; margin: 0px; min-width: 100px;"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{{ range partial "utils/hugo-compat/sites.html" . }}
|
||||||
|
{{- $language := .Language -}}
|
||||||
|
{{ $link := partial "utils/lang-link" (dict "lang" $language.Lang "context" $page) }}
|
||||||
|
{{- $languageLabel := partial "utils/hugo-compat/language-label.html" $language -}}
|
||||||
|
<li role="none" class="hx:flex hx:flex-col">
|
||||||
|
<a
|
||||||
|
href="{{ $link }}"
|
||||||
|
role="menuitem"
|
||||||
|
class="hx:text-gray-700 hx:dark:text-gray-300 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100 hx:relative hx:cursor-pointer hx:whitespace-nowrap hx:rounded-sm hx:py-1.5 hx:transition-colors hx:ltr:pl-3 hx:ltr:pr-9 hx:rtl:pr-3 hx:rtl:pl-9"
|
||||||
|
>
|
||||||
|
{{- $languageLabel -}}
|
||||||
|
{{- if eq $language.Lang $currentLanguageLang -}}
|
||||||
|
<span class="hx:absolute hx:inset-y-0 hx:flex hx:items-center hx:ltr:right-3 hx:rtl:left-3">
|
||||||
|
{{- partial "utils/icon" (dict "name" "check" "attributes" "height=1em width=1em") -}}
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{ end -}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{- $currentPage := .currentPage -}}
|
||||||
|
{{- $link := .link -}}
|
||||||
|
{{- $item := .item -}}
|
||||||
|
{{- $icon := .icon -}}
|
||||||
|
{{- $external := .external -}}
|
||||||
|
|
||||||
|
{{- $active := or ($currentPage.HasMenuCurrent "main" $item) ($currentPage.IsMenuCurrent "main" $item) -}}
|
||||||
|
{{- /* Additional check for section landing pages in multilingual sites (normalize trailing slashes) */ -}}
|
||||||
|
{{- if and (not $active) $link -}}
|
||||||
|
{{- $currentPath := strings.TrimSuffix "/" $currentPage.RelPermalink -}}
|
||||||
|
{{- $linkPath := strings.TrimSuffix "/" $link -}}
|
||||||
|
{{- if eq $currentPath $linkPath -}}
|
||||||
|
{{- $active = true -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $activeClass := cond $active "hx:font-medium" "hx:text-gray-600 hx:hover:text-gray-800 hx:dark:text-gray-400 hx:dark:hover:text-gray-200" -}}
|
||||||
|
|
||||||
|
{{- if $item.HasChildren -}}
|
||||||
|
{{- /* Dropdown menu for items with children */ -}}
|
||||||
|
<div class="hx:relative hx:hidden hx:md:inline-block">
|
||||||
|
<button
|
||||||
|
title="{{ or (T $item.Identifier) $item.Name | safeHTML }}"
|
||||||
|
data-state="closed"
|
||||||
|
class="hextra-nav-menu-toggle hx:cursor-pointer hx:text-sm hx:contrast-more:text-gray-700 hx:contrast-more:dark:text-gray-100 hx:relative hx:-ml-2 hx:whitespace-nowrap hx:p-2 hx:flex hx:items-center hx:gap-1 {{ $activeClass }}"
|
||||||
|
type="button"
|
||||||
|
aria-label="{{ or (T $item.Identifier) $item.Name | safeHTML }}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
{{- if $icon -}}
|
||||||
|
<span class="hx:inline-flex hx:items-center">
|
||||||
|
{{- partial "utils/icon" (dict "name" $icon "attributes" `height="1em" class="hx:inline-block"`) -}}
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
|
<span class="hx:text-center">
|
||||||
|
{{- or (T $item.Identifier) $item.Name | safeHTML -}}
|
||||||
|
</span>
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "chevron-down" "attributes" "height=12 class=\"hx:transition-transform hx:duration-200 hx:ease-in-out\"") -}}
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
class="hextra-nav-menu-items hx:hidden hx:z-20 hx:max-h-64 hx:overflow-auto hx:rounded-lg hx:border hx:border-gray-200 hx:bg-white hx:p-1 hx:text-sm hx:shadow-lg hx:dark:border-neutral-700 hx:dark:bg-neutral-900"
|
||||||
|
style="min-width: 100px;"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
{{ range $item.Children }}
|
||||||
|
{{- $link := .URL -}}
|
||||||
|
{{- $external := strings.HasPrefix $link "http" -}}
|
||||||
|
{{- with .PageRef -}}
|
||||||
|
{{- if hasPrefix . "/" -}}
|
||||||
|
{{- $link = relLangURL (strings.TrimPrefix "/" .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
<li role="none" class="hextra-nav-menu-item hx:flex hx:flex-col">
|
||||||
|
<a
|
||||||
|
href="{{ $link }}"
|
||||||
|
{{ if $external }}target="_blank" rel="noreferrer"{{ end }}
|
||||||
|
role="menuitem"
|
||||||
|
class="hx:text-gray-600 hx:hover:text-gray-800 hx:dark:text-gray-400 hx:dark:hover:text-gray-200 hx:relative hx:cursor-pointer hx:whitespace-nowrap hx:rounded-sm hx:py-1.5 hx:transition-colors hx:ltr:pl-3 hx:ltr:pr-9 hx:rtl:pr-3 hx:rtl:pl-9 hx:flex hx:items-center hx:gap-1 hx:hover:bg-gray-100 hx:dark:hover:bg-neutral-800"
|
||||||
|
>
|
||||||
|
{{- if and (eq .Params.type "link") .Params.icon -}}
|
||||||
|
<span class="hx:inline-flex hx:items-center">
|
||||||
|
{{- partial "utils/icon" (dict "name" .Params.icon "attributes" `height="1em" class="hx:inline-block"`) -}}
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
|
{{- or (T .Identifier) .Name | safeHTML -}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{- else -}}
|
||||||
|
{{- /* Regular menu item without children */ -}}
|
||||||
|
<a
|
||||||
|
title="{{ or (T .Identifier) .Name | safeHTML }}"
|
||||||
|
href="{{ $link }}"
|
||||||
|
{{ if $external }}target="_blank" rel="noreferrer"{{ end }}
|
||||||
|
class="hx:text-sm hx:contrast-more:text-gray-700 hx:contrast-more:dark:text-gray-100 hx:relative hx:-ml-2 hx:hidden hx:whitespace-nowrap hx:p-2 hx:md:inline-flex hx:items-center hx:gap-1 {{ $activeClass }}"
|
||||||
|
>
|
||||||
|
{{- if $icon -}}
|
||||||
|
<span class="hx:inline-flex hx:items-center">
|
||||||
|
{{- partial "utils/icon" (dict "name" $icon "attributes" `height="1em" class="hx:inline-block"`) -}}
|
||||||
|
</span>
|
||||||
|
{{- end -}}
|
||||||
|
<span class="hx:text-center">
|
||||||
|
{{- or (T $item.Identifier) $item.Name | safeHTML -}}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{- $logoPath := .Site.Params.navbar.logo.path | default "images/logo.svg" -}}
|
||||||
|
{{- $logoLink := .Site.Params.navbar.logo.link | default .Site.Home.RelPermalink -}}
|
||||||
|
{{- $logoWidth := .Site.Params.navbar.logo.width | default "20" -}}
|
||||||
|
{{- $logoHeight := .Site.Params.navbar.logo.height | default "20" -}}
|
||||||
|
{{- $logoDarkPath := .Site.Params.navbar.logo.dark | default $logoPath -}}
|
||||||
|
|
||||||
|
<a class="hx:flex hx:items-center hx:hover:opacity-75 hx:ltr:mr-auto hx:rtl:ml-auto" href="{{ $logoLink }}">
|
||||||
|
{{- $displayTitle := (.Site.Params.navbar.displayTitle | default true) }}
|
||||||
|
{{- if (.Site.Params.navbar.displayLogo | default true) }}
|
||||||
|
<img class="hx:mr-2 hx:block hx:dark:hidden" src="{{ $logoPath | relURL }}" alt="{{ cond $displayTitle `Logo` .Site.Title }}" height="{{ $logoHeight }}" width="{{ $logoWidth }}" />
|
||||||
|
<img class="hx:mr-2 hx:hidden hx:dark:block" src="{{ $logoDarkPath | relURL }}" alt="{{ cond $displayTitle `Dark Logo` .Site.Title }}" height="{{ $logoHeight }}" width="{{ $logoWidth }}" />
|
||||||
|
{{- end }}
|
||||||
|
{{- if $displayTitle }}
|
||||||
|
<span class="hx:mr-2 hx:font-extrabold hx:inline hx:select-none">{{- .Site.Title -}}</span>
|
||||||
|
{{- end }}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
{{- $navWidth := "hx:max-w-[90rem]" -}}
|
||||||
|
{{- with .Site.Params.navbar.width -}}
|
||||||
|
{{ if eq . "normal" -}}
|
||||||
|
{{ $navWidth = "hx:max-w-screen-xl" -}}
|
||||||
|
{{ else if eq . "full" -}}
|
||||||
|
{{ $navWidth = "max-w-full" -}}
|
||||||
|
{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $page := . -}}
|
||||||
|
{{- $iconHeight := 24 -}}
|
||||||
|
|
||||||
|
<div class="hextra-nav-container hx:sticky hx:top-0 hx:z-20 hx:w-full hx:bg-transparent hx:print:hidden">
|
||||||
|
<div
|
||||||
|
class="hextra-nav-container-blur hx:pointer-events-none hx:absolute hx:z-[-1] hx:h-full hx:w-full hx:bg-white hx:dark:bg-dark hx:shadow-[0_2px_4px_rgba(0,0,0,.02),0_1px_0_rgba(0,0,0,.06)] hx:contrast-more:shadow-[0_0_0_1px_#000] hx:dark:shadow-[0_-1px_0_rgba(255,255,255,.1)_inset] hx:contrast-more:dark:shadow-[0_0_0_1px_#fff]"
|
||||||
|
></div>
|
||||||
|
|
||||||
|
<nav class="hextra-max-navbar-width hx:mx-auto hx:flex hx:items-center hx:justify-end hx:gap-2 hx:h-16 hx:px-6">
|
||||||
|
{{ partial "navbar-title.html" . }}
|
||||||
|
|
||||||
|
{{- $currentPage := . -}}
|
||||||
|
{{- range .Site.Menus.main -}}
|
||||||
|
{{- if eq .Params.type "search" -}}
|
||||||
|
{{- partial "search.html" (dict "params" .Params "location" "navbar") -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $link := .URL -}}
|
||||||
|
{{- $external := strings.HasPrefix $link "http" -}}
|
||||||
|
{{- with .PageRef -}}
|
||||||
|
{{- if hasPrefix . "/" -}}
|
||||||
|
{{- $link = relLangURL (strings.TrimPrefix "/" .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if eq .Params.type "link" -}}
|
||||||
|
{{- partial "navbar-link.html" (dict "currentPage" $currentPage "link" $link "external" $external "item" . "icon" .Params.icon) -}}
|
||||||
|
{{- else if eq .Params.type "theme-toggle" -}}
|
||||||
|
{{- partial "theme-toggle.html" (dict "iconHeight" $iconHeight "hideLabel" (not .Params.label) "iconHeight" $iconHeight "location" "top" "class" "hx:p-2") -}}
|
||||||
|
{{- else if eq .Params.type "language-switch" -}}
|
||||||
|
{{- partial "language-switch" (dict "context" $page "grow" false "hideLabel" (not .Params.label) "iconName" (.Params.icon | default "translate") "iconHeight" $iconHeight "location" "top" "class" "hx:p-2") -}}
|
||||||
|
{{- else if .Params.icon -}}
|
||||||
|
{{- /* Display icon menu item */ -}}
|
||||||
|
{{- if not $link -}}{{ warnf "Icon menu item '%s' has no URL" .Name }}{{- end -}}
|
||||||
|
{{- $rel := cond (eq .Params.icon "mastodon") "noreferrer me" "noreferrer" }}
|
||||||
|
<a class="hx:p-2 hx:text-current" {{ if $external }}target="_blank" rel="{{ $rel }}"{{ end }} href="{{ $link }}" title="{{ or (T .Identifier) .Name | safeHTML }}">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" .Params.icon "attributes" (printf "height=%d" $iconHeight)) -}}
|
||||||
|
<span class="hx:sr-only">{{ or (T .Identifier) .Name | safeHTML }}</span>
|
||||||
|
</a>
|
||||||
|
{{- else -}}
|
||||||
|
{{- partial "navbar-link.html" (dict "currentPage" $currentPage "link" $link "external" $external "item" .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
|
||||||
|
<button type="button" aria-label="{{ (T "menu") | default "Menu" }}" aria-expanded="false" class="hextra-hamburger-menu hx:cursor-pointer hx:-mr-2 hx:rounded-sm hx:p-2 hx:active:bg-gray-400/20 hx:md:hidden hx:hextra-focus-visible-inset">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "hamburger-menu" "attributes" (printf "height=%d" $iconHeight)) -}}
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
{{/* Adapted from https://github.com/gohugoio/hugo/blob/v0.149.0/docs/layouts/_partials/opengraph/opengraph.html */}}
|
||||||
|
|
||||||
|
<meta property="og:title" content="{{ .Title }}">
|
||||||
|
<meta
|
||||||
|
property="og:description"
|
||||||
|
content="{{ with .Description }}
|
||||||
|
{{ . }}
|
||||||
|
{{ else }}
|
||||||
|
{{ if .IsPage }}
|
||||||
|
{{ .Summary }}
|
||||||
|
{{ else }}
|
||||||
|
{{ with .Site.Params.description }}{{ . }}{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}">
|
||||||
|
<meta
|
||||||
|
property="og:type"
|
||||||
|
content="{{ if .IsPage }}
|
||||||
|
article
|
||||||
|
{{ else }}
|
||||||
|
website
|
||||||
|
{{ end }}">
|
||||||
|
<meta property="og:url" content="{{ .Permalink }}">
|
||||||
|
|
||||||
|
{{- with $.Params.images -}}
|
||||||
|
{{- range first 6 . }}
|
||||||
|
{{- with $.Resources.GetMatch . }}
|
||||||
|
<!-- If the string matches a page resource, use that -->
|
||||||
|
<meta property="og:image" content="{{ .Permalink }}">
|
||||||
|
{{- else }}
|
||||||
|
<!-- Otherwise treat it as a site/global path -->
|
||||||
|
{{- $image := . -}}
|
||||||
|
{{- if hasPrefix $image "/" -}}
|
||||||
|
{{- $image = relURL (strings.TrimPrefix "/" $image) -}}
|
||||||
|
{{- end -}}
|
||||||
|
<meta property="og:image" content="{{ $image | absURL }}">
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else -}}
|
||||||
|
{{- with $.Site.Params.images }}
|
||||||
|
{{- $image := index . 0 -}}
|
||||||
|
{{- if hasPrefix $image "/" -}}
|
||||||
|
{{- $image = relURL (strings.TrimPrefix "/" $image) -}}
|
||||||
|
{{- end -}}
|
||||||
|
<meta property="og:image" content="{{ $image | absURL }}">
|
||||||
|
{{- end }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
|
||||||
|
{{- if .IsPage }}
|
||||||
|
{{- $iso8601 := "2006-01-02T15:04:05-07:00" -}}
|
||||||
|
<meta property="article:section" content="{{ .Section }}">
|
||||||
|
{{ with .PublishDate }}
|
||||||
|
<meta
|
||||||
|
property="article:published_time"
|
||||||
|
{{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }}>
|
||||||
|
{{ end }}
|
||||||
|
{{ with .Lastmod }}
|
||||||
|
<meta
|
||||||
|
property="article:modified_time"
|
||||||
|
{{ .Format $iso8601 | printf "content=%q" | safeHTMLAttr }}>
|
||||||
|
{{ end }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- with .Params.audio }}<meta property="og:audio" content="{{ . }}">{{ end }}
|
||||||
|
{{- with .Params.locale }}
|
||||||
|
<meta property="og:locale" content="{{ . }}">
|
||||||
|
{{ end }}
|
||||||
|
{{- with .Site.Params.title }}
|
||||||
|
<meta property="og:site_name" content="{{ . }}">
|
||||||
|
{{ end }}
|
||||||
|
{{- with .Params.videos }}
|
||||||
|
{{- range . }}
|
||||||
|
<meta property="og:video" content="{{ . | absURL }}">
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{- /* If it is part of a series, link to related articles */}}
|
||||||
|
{{- $permalink := .Permalink }}
|
||||||
|
{{- $siteSeries := .Site.Taxonomies.series }}
|
||||||
|
{{ with .Params.series }}
|
||||||
|
{{- range $name := . }}
|
||||||
|
{{- $series := index $siteSeries ($name | urlize) }}
|
||||||
|
{{- range $page := first 6 $series.Pages }}
|
||||||
|
{{- if ne $page.Permalink $permalink }}
|
||||||
|
<meta property="og:see_also" content="{{ $page.Permalink }}">
|
||||||
|
{{ end }}
|
||||||
|
{{- end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{- /* Facebook Page Admin ID for Domain Insights */}}
|
||||||
|
{{- with site.Params.social.facebook_admin }}
|
||||||
|
<meta property="fb:admins" content="{{ . }}">
|
||||||
|
{{ end }}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{/* Core scripts (theme, menu, tabs, etc.) */}}
|
||||||
|
{{- partial "scripts/core.html" . -}}
|
||||||
|
|
||||||
|
{{/* Search */}}
|
||||||
|
{{- partial "scripts/search.html" . -}}
|
||||||
|
|
||||||
|
{{/* Mermaid */}}
|
||||||
|
{{- if (.Store.Get "hasMermaid") -}}
|
||||||
|
{{- partial "scripts/mermaid.html" . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/* Asciinema */}}
|
||||||
|
{{- if (.Store.Get "hasAsciinema") -}}
|
||||||
|
{{- partial "scripts/asciinema.html" . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/* Medium Zoom */}}
|
||||||
|
{{- if (.Store.Get "hasImageZoom") -}}
|
||||||
|
{{- partial "scripts/medium-zoom.html" . -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
{{- /* Asciinema */ -}}
|
||||||
|
|
||||||
|
{{- $asciinemaBase := "" -}}
|
||||||
|
{{- $useDefaultCdn := true -}}
|
||||||
|
{{- with site.Params.asciinema.base -}}
|
||||||
|
{{- $asciinemaBase = . -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $asciinemaJsAsset := "" -}}
|
||||||
|
{{- with site.Params.asciinema.js -}}
|
||||||
|
{{- $asciinemaJsAsset = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $asciinemaCssAsset := "" -}}
|
||||||
|
{{- with site.Params.asciinema.css -}}
|
||||||
|
{{- $asciinemaCssAsset = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* If only js/css is set without base, use local asset loading */ -}}
|
||||||
|
{{- if and $useDefaultCdn (or (ne $asciinemaJsAsset "") (ne $asciinemaCssAsset "")) -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Set default CDN base if needed */ -}}
|
||||||
|
{{- if $useDefaultCdn -}}
|
||||||
|
{{- $asciinemaBase = "https://cdn.jsdelivr.net/npm/asciinema-player@latest/dist/bundle" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $isRemoteBase := or (strings.HasPrefix $asciinemaBase "http://") (strings.HasPrefix $asciinemaBase "https://") -}}
|
||||||
|
{{- $minSuffix := cond hugo.IsProduction ".min" "" -}}
|
||||||
|
|
||||||
|
{{- /* CSS retrieval: get raw CSS from either local asset or remote, then process */ -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $cssPath := cond (ne $asciinemaCssAsset "") $asciinemaCssAsset "asciinema-player.css" -}}
|
||||||
|
{{- $asciinemaCssUrl := urls.JoinPath $asciinemaBase $cssPath -}}
|
||||||
|
{{- with resources.GetRemote $asciinemaCssUrl -}}
|
||||||
|
{{- with resources.Copy "css/asciinema-player.css" . -}}
|
||||||
|
{{- $asciinemaCss := . | fingerprint -}}
|
||||||
|
<link rel="stylesheet" href="{{ $asciinemaCss.RelPermalink }}" integrity="{{ $asciinemaCss.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if $asciinemaCssAsset -}}
|
||||||
|
{{- with resources.Get $asciinemaCssAsset -}}
|
||||||
|
{{- $asciinemaCss := . | fingerprint -}}
|
||||||
|
<link rel="stylesheet" href="{{ $asciinemaCss.RelPermalink }}" integrity="{{ $asciinemaCss.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Asciinema css asset not found at %q" $asciinemaCssAsset -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* JS retrieval: get raw JS from either local asset or remote, then process */ -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $jsPath := cond (ne $asciinemaJsAsset "") $asciinemaJsAsset (printf "asciinema-player%s.js" $minSuffix) -}}
|
||||||
|
{{- $asciinemaJsUrl := urls.JoinPath $asciinemaBase $jsPath -}}
|
||||||
|
{{- with resources.GetRemote $asciinemaJsUrl -}}
|
||||||
|
{{- with resources.Copy (printf "js/asciinema-player%s.js" $minSuffix) . -}}
|
||||||
|
{{- $asciinemaJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $asciinemaJs.RelPermalink }}" integrity="{{ $asciinemaJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if $asciinemaJsAsset -}}
|
||||||
|
{{- with resources.Get $asciinemaJsAsset -}}
|
||||||
|
{{- $asciinemaJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $asciinemaJs.RelPermalink }}" integrity="{{ $asciinemaJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Asciinema js asset not found at %q" $asciinemaJsAsset -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<script data-playback-time="{{ (T "playbackTime") | default "Playback time" }}">
|
||||||
|
const playbackTimeLabel =
|
||||||
|
document.currentScript?.getAttribute("data-playback-time") || "Playback time";
|
||||||
|
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const observers = [];
|
||||||
|
|
||||||
|
const applyTimerA11y = (container) => {
|
||||||
|
container.querySelectorAll(".ap-timer[role='textbox']").forEach((timer) => {
|
||||||
|
if (!timer.getAttribute("aria-label")) {
|
||||||
|
timer.setAttribute("aria-label", playbackTimeLabel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fix play button position issue
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
.ap-player .ap-overlay-start .ap-play-button span > svg {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
// Initialize asciinema players
|
||||||
|
document.querySelectorAll(".asciinema-player").forEach((el) => {
|
||||||
|
const castFile = el.dataset.castFile;
|
||||||
|
const theme = el.dataset.theme || "asciinema";
|
||||||
|
const speed = parseFloat(el.dataset.speed) || 1;
|
||||||
|
const autoplay = el.dataset.autoplay === "true";
|
||||||
|
const loop = el.dataset.loop === "true";
|
||||||
|
const poster = el.dataset.poster || "";
|
||||||
|
const markers = el.dataset.markers ? JSON.parse(el.dataset.markers) : [];
|
||||||
|
|
||||||
|
// Create asciinema player
|
||||||
|
if (window.AsciinemaPlayer) {
|
||||||
|
window.AsciinemaPlayer.create(castFile, el, {
|
||||||
|
theme: theme,
|
||||||
|
speed: speed,
|
||||||
|
autoplay: autoplay,
|
||||||
|
loop: loop,
|
||||||
|
poster: poster || undefined,
|
||||||
|
markers: markers.length > 0 ? markers : undefined,
|
||||||
|
controls: true, // Always show user controls (bottom control bar)
|
||||||
|
idleTimeLimit: 2, // Limit terminal inactivity to 2 seconds (compress pauses longer than 2s)
|
||||||
|
});
|
||||||
|
applyTimerA11y(el);
|
||||||
|
const observer = new MutationObserver(() => applyTimerA11y(el));
|
||||||
|
observer.observe(el, { childList: true, subtree: true });
|
||||||
|
observers.push(observer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent lingering observers when navigating away.
|
||||||
|
window.addEventListener(
|
||||||
|
"pagehide",
|
||||||
|
() => {
|
||||||
|
observers.forEach((observer) => observer.disconnect());
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{- $scriptsBody := slice }}
|
||||||
|
{{- range resources.Match "js/core/*.js" -}}
|
||||||
|
{{ $scriptsBody = $scriptsBody | append (resources.ExecuteAsTemplate .Name $ .) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $scripts := $scriptsBody | resources.Concat "js/main.js" -}}
|
||||||
|
{{- if hugo.IsProduction -}}
|
||||||
|
{{- $scripts = $scripts | minify | fingerprint -}}
|
||||||
|
{{- end -}}
|
||||||
|
<script defer src="{{ $scripts.RelPermalink }}" integrity="{{ $scripts.Data.Integrity }}"></script>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{- /* KaTeX CSS loader
|
||||||
|
|
||||||
|
Behavior (driven by site.params.math.katex):
|
||||||
|
- base (remote URL) + optional css:
|
||||||
|
- Construct remote CSS URL: "{{ base }}/{{ css | default "katex[.min].css" }}".
|
||||||
|
- Fetch via resources.GetRemote, rewrite font URLs to "{{ base }}/fonts/...".
|
||||||
|
- Build and fingerprint; emit <link rel="stylesheet" integrity>.
|
||||||
|
- base (local path or not set) + css (asset path):
|
||||||
|
- Read CSS from Hugo assets via resources.Get; DO NOT rewrite font URLs.
|
||||||
|
- Build and fingerprint; emit <link rel="stylesheet" integrity>.
|
||||||
|
- base (local path) only (no css):
|
||||||
|
- Link directly to "{{ base }}/katex[.min].css" (no processing).
|
||||||
|
- Nothing set:
|
||||||
|
- Default to CDN latest base; same as remote path above.
|
||||||
|
|
||||||
|
Additional:
|
||||||
|
- assets: optional list to publish extra assets. CSS/JS get tags with integrity (JS loads async).
|
||||||
|
*/ -}}
|
||||||
|
{{- $noop := .WordCount -}}
|
||||||
|
|
||||||
|
{{- $katexBase := "" -}}
|
||||||
|
{{- with site.Params.math.katex.base -}}
|
||||||
|
{{- $katexBase = . -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- if not site.Params.math.katex.css -}}
|
||||||
|
{{- $katexBase = "https://cdn.jsdelivr.net/npm/katex@latest/dist" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $katexCssAsset := "" -}}
|
||||||
|
{{- with site.Params.math.katex.css -}}
|
||||||
|
{{- $katexCssAsset = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $s := newScratch -}}
|
||||||
|
{{- $isRemoteBase := or (strings.HasPrefix $katexBase "http://") (strings.HasPrefix $katexBase "https://") -}}
|
||||||
|
|
||||||
|
{{- /* CSS retrieval consolidated: get raw CSS from either local asset or remote, then process once */ -}}
|
||||||
|
{{- $minSuffix := cond hugo.IsProduction ".min" "" -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $cssPath := cond (ne $katexCssAsset "") $katexCssAsset (printf "katex%s.css" $minSuffix) -}}
|
||||||
|
{{- $katexCssUrl := urls.JoinPath $katexBase $cssPath -}}
|
||||||
|
{{- with resources.GetRemote $katexCssUrl -}}
|
||||||
|
{{- $s.Set "katexCssValue" .Content -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if $katexCssAsset -}}
|
||||||
|
{{- with resources.Get $katexCssAsset -}}
|
||||||
|
{{- $s.Set "katexCssValue" .Content -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "KaTeX css asset not found at %q" $katexCssAsset -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- with $s.Get "katexCssValue" -}}
|
||||||
|
{{- $cssContent := . -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $fontPattern := "url(fonts/" -}}
|
||||||
|
{{- $fontSub := printf "url(%s/" (urls.JoinPath $katexBase "fonts") -}}
|
||||||
|
{{- $cssContent = strings.Replace $cssContent $fontPattern $fontSub -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- with resources.FromString (printf "css/katex%s.css" $minSuffix) $cssContent -}}
|
||||||
|
{{- $css := . | fingerprint "sha512" -}}
|
||||||
|
<link rel="stylesheet" href="{{ $css.RelPermalink }}" integrity="{{ $css.Data.Integrity }}" />
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- if not $isRemoteBase -}}
|
||||||
|
{{- $cssPath := cond (ne $katexCssAsset "") $katexCssAsset (printf "katex%s.css" $minSuffix) -}}
|
||||||
|
<link rel="stylesheet" href="{{ urls.JoinPath $katexBase $cssPath }}" />
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Optionally publish files (fonts, css, js, etc.) from assets and emit tags for css/js with integrity and crossorigin */ -}}
|
||||||
|
{{- with site.Params.math.katex.assets -}}
|
||||||
|
{{- range . -}}
|
||||||
|
{{- with resources.Get . -}}
|
||||||
|
{{- $name := .Name | lower -}}
|
||||||
|
{{- if strings.HasSuffix $name ".css" -}}
|
||||||
|
{{- $built := . | fingerprint "sha512" -}}
|
||||||
|
<link rel="stylesheet" href="{{ $built.RelPermalink }}" integrity="{{ $built.Data.Integrity }}" crossorigin="anonymous" />
|
||||||
|
{{- else if or (strings.HasSuffix $name ".js") (strings.HasSuffix $name ".mjs") -}}
|
||||||
|
{{- $built := . | fingerprint "sha512" -}}
|
||||||
|
<script src="{{ $built.RelPermalink }}" async integrity="{{ $built.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- .Publish -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{/* MathJax */}}
|
||||||
|
{{ $mathjaxJsUrl := "https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js" -}}
|
||||||
|
<script defer id="MathJax-script" src="{{ $mathjaxJsUrl }}" crossorigin="anonymous" async></script>
|
||||||
|
<script>
|
||||||
|
MathJax = {
|
||||||
|
loader: {
|
||||||
|
load: ["ui/safe"],
|
||||||
|
},
|
||||||
|
tex: {
|
||||||
|
displayMath: [
|
||||||
|
["\\[", "\\]"],
|
||||||
|
["$$", "$$"],
|
||||||
|
],
|
||||||
|
inlineMath: [
|
||||||
|
["\\(", "\\)"],
|
||||||
|
["$", "$"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
{{- /* Medium Zoom */ -}}
|
||||||
|
|
||||||
|
{{- $zoomBase := "" -}}
|
||||||
|
{{- $useDefaultCdn := true -}}
|
||||||
|
{{- with site.Params.imageZoom.base -}}
|
||||||
|
{{- $zoomBase = . -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $zoomJsAsset := "" -}}
|
||||||
|
{{- with site.Params.imageZoom.js -}}
|
||||||
|
{{- $zoomJsAsset = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* If only js is set without base, use local asset loading */ -}}
|
||||||
|
{{- if and $useDefaultCdn (ne $zoomJsAsset "") -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Set default CDN base if needed */ -}}
|
||||||
|
{{- if $useDefaultCdn -}}
|
||||||
|
{{- $zoomBase = "https://cdn.jsdelivr.net/npm/medium-zoom@latest/dist" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $isRemoteBase := or (strings.HasPrefix $zoomBase "http://") (strings.HasPrefix $zoomBase "https://") -}}
|
||||||
|
{{- $minSuffix := cond hugo.IsProduction ".min" "" -}}
|
||||||
|
|
||||||
|
{{- /* JS retrieval: get raw JS from either local asset or remote, then process */ -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $jsPath := cond (ne $zoomJsAsset "") $zoomJsAsset (printf "medium-zoom%s.js" $minSuffix) -}}
|
||||||
|
{{- $zoomJsUrl := urls.JoinPath $zoomBase $jsPath -}}
|
||||||
|
{{- with resources.GetRemote $zoomJsUrl -}}
|
||||||
|
{{- with resources.Copy (printf "js/medium-zoom%s.js" $minSuffix) . -}}
|
||||||
|
{{- $zoomJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $zoomJs.RelPermalink }}" integrity="{{ $zoomJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- else if $zoomJsAsset -}}
|
||||||
|
{{- with resources.Get $zoomJsAsset -}}
|
||||||
|
{{- $zoomJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $zoomJs.RelPermalink }}" integrity="{{ $zoomJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Medium Zoom js asset not found at %q" $zoomJsAsset -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
if (!window.mediumZoom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getOverlay = () => {
|
||||||
|
return document.documentElement.classList.contains("dark")
|
||||||
|
? "rgba(17, 17, 17, 0.98)"
|
||||||
|
: "rgba(255, 255, 255, 0.98)";
|
||||||
|
};
|
||||||
|
|
||||||
|
const zoom = window.mediumZoom("[data-zoomable]", {
|
||||||
|
background: getOverlay(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.textContent = `
|
||||||
|
.medium-zoom-overlay {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.medium-zoom-image--opened {
|
||||||
|
z-index: 1001;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
|
||||||
|
new MutationObserver(() => {
|
||||||
|
zoom.update({ background: getOverlay() });
|
||||||
|
}).observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
{{- /* Mermaid */ -}}
|
||||||
|
|
||||||
|
{{- $mermaidBase := "" -}}
|
||||||
|
{{- $useDefaultCdn := true -}}
|
||||||
|
{{- with site.Params.mermaid.base -}}
|
||||||
|
{{- $mermaidBase = . -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $mermaidJsAsset := "" -}}
|
||||||
|
{{- with site.Params.mermaid.js -}}
|
||||||
|
{{- $mermaidJsAsset = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* If only js is set without base, use local asset loading */ -}}
|
||||||
|
{{- if and $useDefaultCdn (ne $mermaidJsAsset "") -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Set default CDN base if needed */ -}}
|
||||||
|
{{- if $useDefaultCdn -}}
|
||||||
|
{{- $mermaidBase = "https://cdn.jsdelivr.net/npm/mermaid@latest/dist" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $isRemoteBase := or (strings.HasPrefix $mermaidBase "http://") (strings.HasPrefix $mermaidBase "https://") -}}
|
||||||
|
{{- $minSuffix := cond hugo.IsProduction ".min" "" -}}
|
||||||
|
|
||||||
|
{{- /* JS retrieval: get raw JS from either local asset or remote, then process */ -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $jsPath := cond (ne $mermaidJsAsset "") $mermaidJsAsset (printf "mermaid%s.js" $minSuffix) -}}
|
||||||
|
{{- $mermaidJsUrl := urls.JoinPath $mermaidBase $jsPath -}}
|
||||||
|
{{- with resources.GetRemote $mermaidJsUrl -}}
|
||||||
|
{{- with resources.Copy (printf "js/mermaid%s.js" $minSuffix) . -}}
|
||||||
|
{{- $mermaidJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $mermaidJs.RelPermalink }}" integrity="{{ $mermaidJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- else if $mermaidJsAsset -}}
|
||||||
|
{{- with resources.Get $mermaidJsAsset -}}
|
||||||
|
{{- $mermaidJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $mermaidJs.RelPermalink }}" integrity="{{ $mermaidJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Mermaid js asset not found at %q" $mermaidJsAsset -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
// Store original mermaid code for each diagram
|
||||||
|
document.querySelectorAll(".mermaid").forEach((el) => {
|
||||||
|
el.dataset.original = el.innerHTML;
|
||||||
|
});
|
||||||
|
|
||||||
|
const theme = document.documentElement.classList.contains("dark") ? "dark" : "default";
|
||||||
|
mermaid.initialize({ startOnLoad: true, theme: theme });
|
||||||
|
|
||||||
|
let timeout;
|
||||||
|
new MutationObserver(() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
const theme = document.documentElement.classList.contains("dark") ? "dark" : "default";
|
||||||
|
document.querySelectorAll(".mermaid").forEach((el) => {
|
||||||
|
// Reset to original content, preserving HTML
|
||||||
|
el.innerHTML = el.dataset.original;
|
||||||
|
el.removeAttribute("data-processed");
|
||||||
|
});
|
||||||
|
mermaid.initialize({ startOnLoad: true, theme: theme });
|
||||||
|
mermaid.init();
|
||||||
|
}, 150);
|
||||||
|
}).observe(document.documentElement, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["class"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{{/* Search */}}
|
||||||
|
{{- if (site.Params.search.enable | default true) -}}
|
||||||
|
{{- $searchType := site.Params.search.type | default "flexsearch" -}}
|
||||||
|
{{- if eq $searchType "flexsearch" -}}
|
||||||
|
{{- $jsSearchScript := printf "%s.search.js" .Language.Lang -}}
|
||||||
|
{{- $jsSearch := resources.Get "js/flexsearch.js" | resources.ExecuteAsTemplate $jsSearchScript . -}}
|
||||||
|
{{- if hugo.IsProduction -}}
|
||||||
|
{{- $jsSearch = $jsSearch | minify | fingerprint -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $flexSearchBase := "" -}}
|
||||||
|
{{- $useDefaultCdn := true -}}
|
||||||
|
{{- with site.Params.search.flexsearch.base -}}
|
||||||
|
{{- $flexSearchBase = . -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $flexSearchJsAsset := "" -}}
|
||||||
|
{{- with site.Params.search.flexsearch.js -}}
|
||||||
|
{{- $flexSearchJsAsset = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* If only js is set without base, use local asset loading. */ -}}
|
||||||
|
{{- if and $useDefaultCdn (ne $flexSearchJsAsset "") -}}
|
||||||
|
{{- $useDefaultCdn = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $bundleSuffix := cond hugo.IsProduction ".min" ".debug" -}}
|
||||||
|
{{- if $useDefaultCdn -}}
|
||||||
|
{{- $flexSearchVersion := site.Params.search.flexsearch.version | default "0.8.143" -}}
|
||||||
|
{{- $flexSearchBase = printf "https://cdn.jsdelivr.net/npm/flexsearch@%s/dist" $flexSearchVersion -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $isRemoteBase := or (strings.HasPrefix $flexSearchBase "http://") (strings.HasPrefix $flexSearchBase "https://") -}}
|
||||||
|
{{- if $isRemoteBase -}}
|
||||||
|
{{- $jsPath := cond (ne $flexSearchJsAsset "") $flexSearchJsAsset (printf "flexsearch.bundle%s.js" $bundleSuffix) -}}
|
||||||
|
{{- $flexSearchJsUrl := urls.JoinPath $flexSearchBase $jsPath -}}
|
||||||
|
{{- with resources.GetRemote $flexSearchJsUrl -}}
|
||||||
|
{{- with resources.Copy "js/flexsearch.js" . -}}
|
||||||
|
{{- $flexSearchJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $flexSearchJs.RelPermalink }}" integrity="{{ $flexSearchJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- else if $flexSearchJsAsset -}}
|
||||||
|
{{- with resources.Get $flexSearchJsAsset -}}
|
||||||
|
{{- $flexSearchJs := . | fingerprint -}}
|
||||||
|
<script defer src="{{ $flexSearchJs.RelPermalink }}" integrity="{{ $flexSearchJs.Data.Integrity }}" crossorigin="anonymous"></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "FlexSearch js asset not found at %q" $flexSearchJsAsset -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if not $useDefaultCdn -}}
|
||||||
|
{{- errorf "FlexSearch local loading requires params.search.flexsearch.js when using non-remote base %q" $flexSearchBase -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<script defer src="{{ $jsSearch.RelPermalink }}" integrity="{{ $jsSearch.Data.Integrity }}"></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- warnf `search type "%s" is not supported` $searchType -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{- $placeholder := (T "searchPlaceholder") | default "Search..." -}}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="hextra-search-wrapper hx:relative hx:md:w-64">
|
||||||
|
<div class="hx:relative hx:flex hx:items-center hx:text-gray-900 hx:contrast-more:text-gray-800 hx:dark:text-gray-300 hx:contrast-more:dark:text-gray-300">
|
||||||
|
<input
|
||||||
|
placeholder="{{ $placeholder }}"
|
||||||
|
aria-label="{{ $placeholder }}"
|
||||||
|
class="hextra-search-input hx:hextra-focus-visible hx:block hx:w-full hx:appearance-none hx:rounded-lg hx:px-3 hx:py-2 hx:transition-colors hx:text-base hx:leading-tight hx:md:text-sm hx:bg-black/[.05] hx:dark:bg-gray-50/10 hx:focus-visible:bg-white hx:dark:focus-visible:bg-dark hx:placeholder:text-gray-500 hx:dark:placeholder:text-gray-400 hx:contrast-more:border hx:contrast-more:border-current"
|
||||||
|
type="search"
|
||||||
|
autocomplete="off"
|
||||||
|
value=""
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
<kbd
|
||||||
|
class="hx:absolute hx:my-1.5 hx:select-none hx:ltr:right-1.5 hx:rtl:left-1.5 hx:h-5 hx:rounded-sm hx:bg-white hx:px-1.5 hx:font-mono hx:text-[10px] hx:font-medium hx:text-gray-500 hx:border hx:border-gray-200 hx:dark:border-gray-100/20 hx:dark:bg-dark/50 hx:contrast-more:border-current hx:contrast-more:text-current hx:contrast-more:dark:border-current hx:items-center hx:gap-1 hx:transition-opacity hx:pointer-events-none hx:hidden hx:sm:flex"
|
||||||
|
>
|
||||||
|
CTRL K
|
||||||
|
</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<ul
|
||||||
|
class="hextra-search-results hextra-scrollbar hx:hidden hx:border hx:border-gray-200 hx:bg-white hx:text-gray-100 hx:dark:border-neutral-800 hx:dark:bg-neutral-900 hx:absolute hx:top-full hx:z-20 hx:mt-2 hx:overflow-auto hx:overscroll-contain hx:rounded-xl hx:py-2.5 hx:shadow-xl hx:max-h-[min(calc(50vh-11rem-env(safe-area-inset-bottom)),400px)] hx:md:max-h-[min(calc(100vh-5rem-env(safe-area-inset-bottom)),400px)] hx:inset-x-0 hx:ltr:md:left-auto hx:rtl:md:right-auto hx:contrast-more:border hx:contrast-more:border-gray-900 hx:contrast-more:dark:border-gray-50 hx:w-screen hx:min-h-[100px] hx:max-w-[min(calc(100vw-2rem),calc(100%+20rem))]"
|
||||||
|
aria-label="{{ (T "searchResults") | default "Search results" }}"
|
||||||
|
style="transition: max-height 0.2s ease 0s;"
|
||||||
|
></ul>
|
||||||
|
<div class="hextra-search-status hx:sr-only" aria-live="polite" role="status"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
{{- $content := .content -}}
|
||||||
|
{{- $color := .color | default .type | default "" -}}{{- /* Compatibility with previous parameter. */ -}}
|
||||||
|
{{- $class := .class | default "" -}}
|
||||||
|
{{- $border := .border | default false -}}
|
||||||
|
{{- $icon := .icon | default "" -}}
|
||||||
|
|
||||||
|
{{- /* Compatibility with previous names. */ -}}
|
||||||
|
{{- $mapping := (dict
|
||||||
|
"default" "gray"
|
||||||
|
"tip" "green"
|
||||||
|
"info" "blue"
|
||||||
|
"warning" "yellow"
|
||||||
|
"error" "red"
|
||||||
|
"important" "purple"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $color = index $mapping $color | default $color | default "gray" -}}
|
||||||
|
|
||||||
|
{{- $styleClass := newScratch -}}
|
||||||
|
{{- $styleClass.Set "gray" "hx:text-gray-600 hx:bg-gray-100 hx:dark:bg-neutral-800 hx:dark:text-neutral-200 hx:border-gray-200 hx:dark:border-neutral-700" -}}
|
||||||
|
{{- $styleClass.Set "purple" "hx:border-purple-200 hx:bg-purple-100 hx:text-purple-900 hx:dark:border-purple-200/30 hx:dark:bg-purple-900/30 hx:dark:text-purple-200" -}}
|
||||||
|
{{- $styleClass.Set "indigo" "hx:border-indigo-200 hx:bg-indigo-100 hx:text-indigo-900 hx:dark:border-indigo-200/30 hx:dark:bg-indigo-900/30 hx:dark:text-indigo-200" -}}
|
||||||
|
{{- $styleClass.Set "blue" "hx:border-blue-200 hx:bg-blue-100 hx:text-blue-900 hx:dark:border-blue-200/30 hx:dark:bg-blue-900/30 hx:dark:text-blue-200" -}}
|
||||||
|
{{- $styleClass.Set "green" "hx:border-green-200 hx:bg-green-100 hx:text-green-900 hx:dark:border-green-200/30 hx:dark:bg-green-900/30 hx:dark:text-green-200" -}}
|
||||||
|
{{- $styleClass.Set "yellow" "hx:border-yellow-100 hx:bg-yellow-50 hx:text-yellow-900 hx:dark:border-yellow-200/30 hx:dark:bg-yellow-700/30 hx:dark:text-yellow-200" -}}
|
||||||
|
{{- $styleClass.Set "orange" "hx:border-orange-100 hx:bg-orange-50 hx:text-orange-800 hx:dark:border-orange-400/30 hx:dark:bg-orange-400/20 hx:dark:text-orange-300" -}}
|
||||||
|
{{- $styleClass.Set "amber" "hx:border-amber-200 hx:bg-amber-100 hx:text-amber-900 hx:dark:border-amber-200/30 hx:dark:bg-amber-900/30 hx:dark:text-amber-200" -}}
|
||||||
|
{{- $styleClass.Set "red" "hx:border-red-200 hx:bg-red-100 hx:text-red-900 hx:dark:border-red-200/30 hx:dark:bg-red-900/30 hx:dark:text-red-200" -}}
|
||||||
|
|
||||||
|
{{- $borderClass := cond (eq $border true) "hx:border" "" -}}
|
||||||
|
{{- $badgeClass := or ($styleClass.Get $color) ($styleClass.Get "gray") -}}
|
||||||
|
<div class="hextra-badge {{ $class }}">
|
||||||
|
<div class="hx:inline-flex hx:gap-1 hx:items-center hx:rounded-full hx:px-2.5 hx:leading-6 hx:text-[.65rem] {{ $borderClass }} {{ $badgeClass }}">
|
||||||
|
{{- with $icon -}}{{- partial "utils/icon" (dict "name" . "attributes" "height=12") -}}{{- end -}}
|
||||||
|
{{- $content -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- /* Strip trailing newline. */ -}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{- $content := .content -}}
|
||||||
|
{{- $emoji := .emoji -}}
|
||||||
|
{{- $icon := .icon -}}
|
||||||
|
|
||||||
|
{{- $defaultClass := "hx:border-orange-100 hx:bg-orange-50 hx:text-orange-800 hx:dark:border-orange-400/30 hx:dark:bg-orange-400/20 hx:dark:text-orange-300" -}}
|
||||||
|
|
||||||
|
{{- $class := .class | default $defaultClass -}}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="hx:overflow-x-auto hx:mt-6 hx:flex hx:rounded-lg hx:border hx:py-2 hx:ltr:pr-4 hx:rtl:pl-4 hx:contrast-more:border-current hx:contrast-more:dark:border-current {{ $class }}">
|
||||||
|
<div class="hx:ltr:pl-3 hx:ltr:pr-2 hx:rtl:pr-3 hx:rtl:pl-2">
|
||||||
|
{{- with $emoji -}}
|
||||||
|
<div class="hx:select-none hx:text-xl" style="font-family: 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';">
|
||||||
|
{{- . -}}
|
||||||
|
</div>
|
||||||
|
{{- else -}}
|
||||||
|
{{- with $icon -}}
|
||||||
|
{{ partial "utils/icon.html" (dict "name" . "attributes" `height=1.2em class="hx:inline-block hx:align-middle"`) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hx:w-full hx:min-w-0 hx:leading-7">
|
||||||
|
<div class="hx:mt-6 hx:leading-7 hx:first:mt-0">
|
||||||
|
{{- $content -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
{{- $link := .link -}}
|
||||||
|
{{- $title := .title -}}
|
||||||
|
{{- $icon := .icon -}}
|
||||||
|
{{- $subtitle := .subtitle -}}
|
||||||
|
{{- $image := .image -}}
|
||||||
|
{{- $alt := .alt | default $title -}}
|
||||||
|
{{- $width := .width -}}
|
||||||
|
{{- $height := .height -}}
|
||||||
|
{{- $imageStyle := .imageStyle -}}
|
||||||
|
{{- $tag := .tag -}}
|
||||||
|
{{- $tagColor := .tagColor | default .tagType | default "" -}}{{- /* Compatibility with previous parameter. */ -}}
|
||||||
|
{{- $tagBorder := not (eq .tagBorder false) | default true }}
|
||||||
|
{{- $tagIcon := .tagIcon -}}
|
||||||
|
|
||||||
|
{{ $linkClass := "hx:hover:border-gray-300 hx:bg-transparent hx:shadow-xs hx:dark:border-neutral-800 hx:hover:bg-slate-50 hx:hover:shadow-md hx:dark:hover:border-neutral-700 hx:dark:hover:bg-neutral-900" }}
|
||||||
|
{{- with $image -}}
|
||||||
|
{{ $linkClass = "hx:hover:border-gray-300 hx:bg-gray-100 hx:shadow-sm hx:dark:border-neutral-700 hx:dark:bg-neutral-800 hx:dark:text-gray-50 hx:hover:shadow-lg hx:dark:hover:border-neutral-500 hx:dark:hover:bg-neutral-700" }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $external := strings.HasPrefix $link "http" -}}
|
||||||
|
{{- $href := cond (strings.HasPrefix $link "/") ($link | relURL) $link -}}
|
||||||
|
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="hextra-card hx:group hx:flex hx:flex-col hx:justify-start hx:overflow-hidden hx:rounded-lg hx:border hx:border-gray-200 hx:text-current hx:no-underline hx:dark:shadow-none hx:hover:shadow-gray-100 hx:dark:hover:shadow-none hx:shadow-gray-100 hx:active:shadow-sm hx:active:shadow-gray-200 hx:transition-all hx:duration-200 {{ $linkClass }}"
|
||||||
|
{{- if $link -}}
|
||||||
|
href="{{ $href }}"
|
||||||
|
{{ with $external }}target="_blank" rel="noreferrer"{{ end -}}
|
||||||
|
{{- end -}}
|
||||||
|
>
|
||||||
|
{{- with $image -}}
|
||||||
|
<img
|
||||||
|
alt="{{ $alt }}"
|
||||||
|
class="hextra-card-image"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
src="{{ $image | safeURL }}"
|
||||||
|
{{ with $width }}width="{{ . }}"{{ end }}
|
||||||
|
{{ with $height }}height="{{ . }}"{{ end }}
|
||||||
|
{{ with $imageStyle }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
/>
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $padding := "hx:p-4" -}}
|
||||||
|
{{- with $subtitle -}}
|
||||||
|
{{- $padding = "hx:pt-4 hx:px-4" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<div class="hx:mt-auto">
|
||||||
|
<span class="hextra-card-icon hx:flex hx:font-semibold hx:items-start hx:gap-2 {{ $padding }} hx:text-gray-700 hx:hover:text-gray-900 hx:dark:text-neutral-200 hx:dark:hover:text-neutral-50">
|
||||||
|
{{- with $icon }}{{ partial "utils/icon.html" (dict "name" $icon) -}}{{- end -}}
|
||||||
|
{{- $title -}}
|
||||||
|
</span>
|
||||||
|
{{- with $subtitle -}}
|
||||||
|
<div class="hextra-card-subtitle hx:line-clamp-3 hx:text-sm hx:font-normal hx:text-gray-500 hx:dark:text-gray-400 hx:px-4 hx:mb-4 hx:mt-2">{{- $subtitle | markdownify -}}</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{- if $tag }}
|
||||||
|
{{- partial "shortcodes/badge.html" (dict
|
||||||
|
"content" $tag
|
||||||
|
"color" $tagColor
|
||||||
|
"class" "hextra-card-tag"
|
||||||
|
"border" $tagBorder
|
||||||
|
"icon" $tagIcon
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- end -}}
|
||||||
|
</a>
|
||||||
|
{{- /* Strip trailing newline. */ -}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{- $cols := .cols | default 3 -}}
|
||||||
|
{{- $content := .content -}}
|
||||||
|
|
||||||
|
<div class="hextra-cards hx:mt-4 hx:gap-4 hx:grid not-prose" style="--hextra-cards-grid-cols: {{ $cols }};">
|
||||||
|
{{- $content -}}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
{{- $tabsID := .id }}
|
||||||
|
|
||||||
|
{{- /*
|
||||||
|
The `tabs` parameter is a list of dict with the following keys:
|
||||||
|
- `id`: (int) the ID of the tab (the Ordinal of the tab shortcode).
|
||||||
|
- `name`: (string) the name of the tab (the title).
|
||||||
|
- `icon`: (string) the icon of the tab.
|
||||||
|
- `content`: (string) the content of the tab.
|
||||||
|
- `selected`: (bool) whether the tab is selected.
|
||||||
|
*/ -}}
|
||||||
|
{{- $tabs := .tabs }}
|
||||||
|
|
||||||
|
{{- if eq (len $tabs) 0 -}}
|
||||||
|
{{ errorf "tabs must have at least one tab" }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $enableSync := .enableSync }}
|
||||||
|
|
||||||
|
{{- /* Create group data for syncing and select the first tab if none is selected. */ -}}
|
||||||
|
{{- $selectedIndex := 0 -}}
|
||||||
|
{{ $dataTabGroup := slice -}}
|
||||||
|
|
||||||
|
{{- range $i, $item := $tabs -}}
|
||||||
|
{{- $dataTabGroup = $dataTabGroup | append ($item.name) -}}
|
||||||
|
|
||||||
|
{{- if $item.selected -}}
|
||||||
|
{{- $selectedIndex = $i -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Generate a unique ID for each tab group. */ -}}
|
||||||
|
{{- $globalID := printf "tabs-%02v" $tabsID -}}
|
||||||
|
|
||||||
|
{{- /* Keep HTML on single lines to avoid `>` being parsed as blockquote when nested in steps (#876) */ -}}
|
||||||
|
<div class="hextra-scrollbar hx:overflow-x-auto hx:overflow-y-hidden hx:overscroll-x-contain">
|
||||||
|
<div class="hx:mt-4 hx:flex hx:w-max hx:min-w-full hx:border-b hx:border-gray-200 hx:pb-px hx:dark:border-neutral-800" role="tablist"{{- if $enableSync }} data-tab-group="{{ delimit $dataTabGroup `,` }}"{{- end }}>
|
||||||
|
{{- range $i, $item := $tabs -}}
|
||||||
|
<button class="hextra-tabs-toggle hx:cursor-pointer hx:data-[state=selected]:border-primary-500 hx:data-[state=selected]:text-primary-600 hx:data-[state=selected]:dark:border-primary-500 hx:data-[state=selected]:dark:text-primary-600 hx:mr-2 hx:rounded-t hx:p-2 hx:font-medium hx:leading-5 hx:transition-colors hx:-mb-0.5 hx:select-none hx:border-b-2 hx:border-transparent hx:text-gray-600 hx:hover:border-gray-200 hx:hover:text-black hx:dark:text-gray-200 hx:dark:hover:border-neutral-800 hx:dark:hover:text-white hx:hextra-focus-visible-inset" id="tabs-tab-{{ $globalID }}-{{ $item.id }}" role="tab" type="button" aria-controls="tabs-panel-{{ $globalID }}-{{ $item.id }}" aria-selected="{{ if eq $i $selectedIndex }}true{{ else }}false{{ end }}" tabindex="{{ if eq $i $selectedIndex }}0{{ else }}-1{{ end }}"{{- if eq $i $selectedIndex }} data-state="selected"{{- end }}><span class="hx:inline-flex hx:items-center hx:gap-1.5">{{- with $item.icon -}}{{- partial "utils/icon.html" (dict "name" . "attributes" `height=1em class="hx:inline-block hx:shrink-0" aria-hidden="true"`) -}}{{- end -}}<span>{{- $item.name -}}</span></span></button>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{{- range $i, $item := $tabs -}}
|
||||||
|
<div class="hextra-tabs-panel hx:rounded-sm hx:pt-6 hx:hidden hx:data-[state=selected]:block" id="tabs-panel-{{ $globalID }}-{{ $item.id }}" role="tabpanel" aria-labelledby="tabs-tab-{{ $globalID }}-{{ $item.id }}" aria-hidden="{{ if eq $i $selectedIndex }}false{{ else }}true{{ end }}"{{- if eq $i $selectedIndex }} tabindex="0" data-state="selected"{{- end }}>
|
||||||
|
{{- $item.content | markdownify -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
{{- $context := .context -}}
|
||||||
|
|
||||||
|
{{- $disableSidebar := .disableSidebar | default false -}}
|
||||||
|
{{- $displayPlaceholder := .displayPlaceholder | default false -}}
|
||||||
|
|
||||||
|
{{- $navRoot := cond (eq site.Home.Type "docs") site.Home $context.FirstSection -}}
|
||||||
|
{{- $pageURL := $context.RelPermalink -}}
|
||||||
|
|
||||||
|
{{- if .context.Params.sidebar.hide -}}
|
||||||
|
{{- $disableSidebar = true -}}
|
||||||
|
{{- $displayPlaceholder = false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $sidebarClass := "hx:md:sticky" -}}
|
||||||
|
{{- if $disableSidebar -}}
|
||||||
|
{{- if $displayPlaceholder -}}
|
||||||
|
{{- $sidebarClass = "hx:md:hidden hx:xl:block" -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $sidebarClass = "hx:md:hidden" -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<aside class="hextra-sidebar-container hx:flex hx:flex-col hx:print:hidden hx:md:top-16 hx:md:shrink-0 hx:md:w-64 hx:md:self-start hx:max-md:[transform:translate3d(0,-100%,0)] {{ $sidebarClass }}">
|
||||||
|
{{- if (site.Params.search.enable | default true) -}}
|
||||||
|
<!-- Search bar on small screen -->
|
||||||
|
<div class="hx:px-4 hx:pt-4 hx:md:hidden">
|
||||||
|
{{ partial "search.html" (dict "location" "sidebar") }}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
<div class="hextra-scrollbar hx:overflow-y-auto hx:overflow-x-hidden hx:p-4 hx:grow hx:md:h-[calc(100vh-var(--navbar-height)-var(--menu-height))]">
|
||||||
|
<ul class="hx:flex hx:flex-col hx:gap-1 hx:md:hidden">
|
||||||
|
<!-- Nav -->
|
||||||
|
{{ template "sidebar-main" (dict "context" site.Home "pageURL" $pageURL "page" $context "toc" true) -}}
|
||||||
|
{{ template "sidebar-footer" }}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<!-- Sidebar on large screen -->
|
||||||
|
{{- if $disableSidebar -}}
|
||||||
|
{{- if $displayPlaceholder }}<div class="hx:max-xl:hidden hx:h-0 hx:w-64 hx:shrink-0"></div>{{ end -}}
|
||||||
|
{{ .context.Store.Set "enableFooterSwitches" true }}
|
||||||
|
{{- else -}}
|
||||||
|
<ul class="hx:flex hx:flex-col hx:gap-1 hx:max-md:hidden">
|
||||||
|
{{ template "sidebar-main" (dict "context" $navRoot "page" $context "pageURL" $pageURL) }}
|
||||||
|
{{ template "sidebar-footer" }}
|
||||||
|
</ul>
|
||||||
|
{{ end -}}
|
||||||
|
</div>
|
||||||
|
{{/* Hide theme switch when sidebar is disabled */}}
|
||||||
|
{{ $switchesClass := cond $disableSidebar "hx:md:hidden" "" -}}
|
||||||
|
{{ $displayThemeToggle := (site.Params.theme.displayToggle | default true) -}}
|
||||||
|
|
||||||
|
{{ $isMultilingual := gt (len site.Languages) 1 }}
|
||||||
|
{{ if or $isMultilingual $displayThemeToggle }}
|
||||||
|
<div class="{{ $switchesClass }} {{ with $isMultilingual }}hx:justify-end{{ end }} hx:sticky hx:bottom-0 hx:max-h-(--menu-height) hx:bg-white hx:dark:bg-dark hx:mx-4 hx:py-4 hx:shadow-[0_-12px_16px_#fff] hx:flex hx:items-center hx:gap-2 hx:border-gray-200 hx:dark:border-neutral-800 hx:dark:shadow-[0_-12px_16px_#111] hx:contrast-more:border-neutral-400 hx:contrast-more:shadow-none hx:contrast-more:dark:shadow-none hx:border-t" data-toggle-animation="show">
|
||||||
|
{{- with $isMultilingual -}}
|
||||||
|
{{- partial "language-switch" (dict "context" $context "grow" true) -}}
|
||||||
|
{{- with $displayThemeToggle }}{{ partial "theme-toggle" (dict "hideLabel" true "location" "bottom-right") }}{{ end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- with $displayThemeToggle -}}
|
||||||
|
<div class="hx:flex hx:grow hx:flex-col">{{ partial "theme-toggle" }}</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{{- define "sidebar-main" -}}
|
||||||
|
{{ template "sidebar-tree" (dict "context" .context "level" 0 "page" .page "pageURL" .pageURL "toc" (.toc | default false)) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sidebar-tree" -}}
|
||||||
|
{{- if ge .level 4 -}}
|
||||||
|
{{- return -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $context := .context -}}
|
||||||
|
{{- $page := .page }}
|
||||||
|
{{- $pageURL := .page.RelPermalink -}}
|
||||||
|
{{- $level := .level -}}
|
||||||
|
{{- $toc := .toc | default false -}}
|
||||||
|
{{- $useMainMenu := and (eq $level 0) $toc -}}
|
||||||
|
{{- $mainMenuEntries := slice -}}
|
||||||
|
|
||||||
|
{{- $items := where (union .context.RegularPages .context.Sections) "Params.sidebar.exclude" "!=" true -}}
|
||||||
|
{{- if $useMainMenu -}}
|
||||||
|
{{- range $menuItem := site.Menus.main -}}
|
||||||
|
{{- $menuType := $menuItem.Params.type | default "" -}}
|
||||||
|
{{- $isIconOnly := and $menuItem.Params.icon (ne $menuType "link") -}}
|
||||||
|
{{- /* Keep only navigation links in the mobile sidebar. */ -}}
|
||||||
|
{{- if or (eq $menuType "search") (eq $menuType "theme-toggle") (eq $menuType "language-switch") $isIconOnly -}}
|
||||||
|
{{- continue -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $menuTitle := or (T $menuItem.Identifier) $menuItem.Name -}}
|
||||||
|
{{- /* Dropdown parents mirror navbar behavior: render a labeled group of child links. */ -}}
|
||||||
|
{{- if $menuItem.HasChildren -}}
|
||||||
|
{{- $childEntries := slice -}}
|
||||||
|
{{- range $childItem := $menuItem.Children -}}
|
||||||
|
{{- $childType := $childItem.Params.type | default "" -}}
|
||||||
|
{{- $childIsIconOnly := and $childItem.Params.icon (ne $childType "link") -}}
|
||||||
|
{{- if or (eq $childType "search") (eq $childType "theme-toggle") (eq $childType "language-switch") $childIsIconOnly -}}
|
||||||
|
{{- continue -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $childTitle := or (T $childItem.Identifier) $childItem.Name -}}
|
||||||
|
{{- $childPage := $childItem.Page -}}
|
||||||
|
{{- with $childItem.PageRef -}}
|
||||||
|
{{- with $page.Site.GetPage . -}}
|
||||||
|
{{- $childPage = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- with $childPage -}}
|
||||||
|
{{- if ne .Params.sidebar.exclude true -}}
|
||||||
|
{{- $childEntries = $childEntries | append (dict "title" $childTitle "link" .RelPermalink) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- continue -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $childLink := $childItem.URL -}}
|
||||||
|
{{- with $childItem.PageRef -}}
|
||||||
|
{{- if hasPrefix . "/" -}}
|
||||||
|
{{- $childLink = relLangURL (strings.TrimPrefix "/" .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $childLink -}}
|
||||||
|
{{- $childEntries = $childEntries | append (dict "title" $childTitle "link" $childLink) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if gt (len $childEntries) 0 -}}
|
||||||
|
{{- $mainMenuEntries = $mainMenuEntries | append (dict "type" "group" "title" $menuTitle "children" $childEntries) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- continue -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Normalize page-backed entries so we keep nested tree behavior. */ -}}
|
||||||
|
{{- $menuPage := $menuItem.Page -}}
|
||||||
|
{{- with $menuItem.PageRef -}}
|
||||||
|
{{- with $page.Site.GetPage . -}}
|
||||||
|
{{- $menuPage = . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- with $menuPage -}}
|
||||||
|
{{- if ne .Params.sidebar.exclude true -}}
|
||||||
|
{{- $mainMenuEntries = $mainMenuEntries | append (dict "type" "page" "item" . "title" $menuTitle) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- continue -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $link := $menuItem.URL -}}
|
||||||
|
{{- with $menuItem.PageRef -}}
|
||||||
|
{{- if hasPrefix . "/" -}}
|
||||||
|
{{- $link = relLangURL (strings.TrimPrefix "/" .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if $link -}}
|
||||||
|
{{- $mainMenuEntries = $mainMenuEntries | append (dict "type" "url" "link" $link "title" $menuTitle) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $useMainMenuEntries := and $useMainMenu (gt (len $mainMenuEntries) 0) -}}
|
||||||
|
{{- $hasItems := or (gt (len $items) 0) $useMainMenuEntries -}}
|
||||||
|
|
||||||
|
{{- if $hasItems -}}
|
||||||
|
{{- if eq $level 0 -}}
|
||||||
|
{{- if $useMainMenuEntries -}}
|
||||||
|
{{- /* Mixed list: page entries render trees; url entries render leaf links. */ -}}
|
||||||
|
{{- range $entry := $mainMenuEntries -}}
|
||||||
|
{{- if eq (index $entry "type") "page" -}}
|
||||||
|
{{- $item := index $entry "item" -}}
|
||||||
|
{{- if $item.Params.sidebar.separator -}}
|
||||||
|
<li class="[word-break:break-word] hx:mt-5 hx:mb-2 hx:px-2 hx:py-1.5 hx:text-sm hx:font-semibold hx:text-gray-900 hx:first:mt-0 hx:dark:text-gray-100">
|
||||||
|
<span class="hx:cursor-default">{{ index $entry "title" }}</span>
|
||||||
|
</li>
|
||||||
|
{{- else -}}
|
||||||
|
{{- $active := eq (strings.TrimSuffix "/" $pageURL) (strings.TrimSuffix "/" $item.RelPermalink) -}}
|
||||||
|
{{- $shouldOpen := or ($item.Params.sidebar.open) ($item.IsAncestor $page) $active | default true }}
|
||||||
|
<li class="{{ if $shouldOpen }}open{{ end }}">
|
||||||
|
{{- template "sidebar-item-link" dict "context" $item "active" $active "open" $shouldOpen "title" (index $entry "title") "link" $item.RelPermalink -}}
|
||||||
|
{{- if and $toc $active (ne $item.Params.toc false) -}}
|
||||||
|
{{- template "sidebar-toc" dict "page" $item -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- template "sidebar-tree" dict "context" $item "page" $page "pageURL" $pageURL "level" (add $level 1) "toc" $toc -}}
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if eq (index $entry "type") "group" -}}
|
||||||
|
<li class="open">
|
||||||
|
<div class="hextra-sidebar-item hx:group hx:relative hx:flex hx:items-center">
|
||||||
|
<span class="hx:flex hx:grow hx:cursor-default hx:px-2 hx:py-1.5 hx:text-sm hx:font-semibold hx:text-gray-900 hx:dark:text-gray-100">
|
||||||
|
{{- index $entry "title" -}}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="hextra-sidebar-children hx:ltr:pr-0 hx:rtl:pl-0 hx:overflow-hidden">
|
||||||
|
<ul class='hx:relative hx:flex hx:flex-col hx:gap-1 hx:before:absolute hx:before:inset-y-1 hx:before:w-px hx:before:bg-gray-200 hx:before:content-[""] hx:ltr:ml-3 hx:ltr:pl-3 hx:ltr:before:left-0 hx:rtl:mr-3 hx:rtl:pr-3 hx:rtl:before:right-0 hx:dark:before:bg-neutral-800'>
|
||||||
|
{{- range $child := index $entry "children" -}}
|
||||||
|
{{- $link := index $child "link" -}}
|
||||||
|
{{- $active := eq (strings.TrimSuffix "/" $pageURL) (strings.TrimSuffix "/" $link) -}}
|
||||||
|
<li class="hx:flex hx:flex-col">
|
||||||
|
{{- template "sidebar-item-link" dict "active" $active "open" false "title" (index $child "title") "link" $link -}}
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{- else -}}
|
||||||
|
{{- $link := index $entry "link" -}}
|
||||||
|
{{- $active := eq (strings.TrimSuffix "/" $pageURL) (strings.TrimSuffix "/" $link) -}}
|
||||||
|
<li>{{ template "sidebar-item-link" dict "active" $active "open" false "title" (index $entry "title") "link" $link }}</li>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- range $items.ByWeight }}
|
||||||
|
{{- if .Params.sidebar.separator -}}
|
||||||
|
<li class="[word-break:break-word] hx:mt-5 hx:mb-2 hx:px-2 hx:py-1.5 hx:text-sm hx:font-semibold hx:text-gray-900 hx:first:mt-0 hx:dark:text-gray-100">
|
||||||
|
<span class="hx:cursor-default">{{ partial "utils/title" . }}</span>
|
||||||
|
</li>
|
||||||
|
{{- else -}}
|
||||||
|
{{- $active := eq $pageURL .RelPermalink -}}
|
||||||
|
{{- $shouldOpen := or (.Params.sidebar.open) (.IsAncestor $page) $active | default true }}
|
||||||
|
<li class="{{ if $shouldOpen }}open{{ end }}">
|
||||||
|
{{- $linkTitle := partial "utils/title" . -}}
|
||||||
|
{{- template "sidebar-item-link" dict "context" . "active" $active "open" $shouldOpen "title" $linkTitle "link" .RelPermalink -}}
|
||||||
|
{{- if and $toc $active (ne .Params.toc false) -}}
|
||||||
|
{{- template "sidebar-toc" dict "page" . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- template "sidebar-tree" dict "context" . "page" $page "pageURL" $pageURL "level" (add $level 1) "toc" $toc -}}
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
<div class="hextra-sidebar-children hx:ltr:pr-0 hx:rtl:pl-0 hx:overflow-hidden">
|
||||||
|
<ul class='hx:relative hx:flex hx:flex-col hx:gap-1 hx:before:absolute hx:before:inset-y-1 hx:before:w-px hx:before:bg-gray-200 hx:before:content-[""] hx:ltr:ml-3 hx:ltr:pl-3 hx:ltr:before:left-0 hx:rtl:mr-3 hx:rtl:pr-3 hx:rtl:before:right-0 hx:dark:before:bg-neutral-800'>
|
||||||
|
{{- range $items.ByWeight }}
|
||||||
|
{{- $active := eq $pageURL .RelPermalink -}}
|
||||||
|
{{- $shouldOpen := or (.Params.sidebar.open) (.IsAncestor $page) $active | default true }}
|
||||||
|
{{- $linkTitle := partial "utils/title" . -}}
|
||||||
|
<li class="hx:flex hx:flex-col {{ if $shouldOpen }}open{{ end }}">
|
||||||
|
{{- template "sidebar-item-link" dict "context" . "active" $active "open" $shouldOpen "title" $linkTitle "link" .RelPermalink -}}
|
||||||
|
{{- if and $toc $active (ne .Params.toc false) -}}
|
||||||
|
{{ template "sidebar-toc" dict "page" . }}
|
||||||
|
{{- end }}
|
||||||
|
{{ template "sidebar-tree" dict "context" . "page" $page "pageURL" $pageURL "level" (add $level 1) "toc" $toc }}
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sidebar-toc" -}}
|
||||||
|
{{ $page := .page }}
|
||||||
|
{{ with $page.Fragments.Headings }}
|
||||||
|
<ul class='hx:flex hx:flex-col hx:gap-1 hx:relative hx:before:absolute hx:before:inset-y-1 hx:before:w-px hx:before:bg-gray-200 hx:before:content-[""] hx:dark:before:bg-neutral-800 hx:ltr:pl-3 hx:ltr:before:left-0 hx:rtl:pr-3 hx:rtl:before:right-0 hx:ltr:ml-3 hx:rtl:mr-3'>
|
||||||
|
{{- range . }}
|
||||||
|
{{- with .Headings }}
|
||||||
|
{{- range . -}}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#{{ anchorize .ID }}"
|
||||||
|
class="hx:flex hx:rounded-sm hx:px-2 hx:py-1.5 hx:text-sm hx:transition-colors [word-break:break-word] hx:cursor-pointer [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] hx:contrast-more:border hx:gap-2 hx:before:opacity-25 hx:before:content-['#'] hx:text-gray-500 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:text-neutral-400 hx:dark:hover:bg-primary-100/5 hx:dark:hover:text-gray-50 hx:contrast-more:text-gray-900 hx:contrast-more:dark:text-gray-50 hx:contrast-more:border-transparent hx:contrast-more:hover:border-gray-900 hx:contrast-more:dark:hover:border-gray-50"
|
||||||
|
>
|
||||||
|
{{- .Title | safeHTML | plainify | htmlUnescape -}}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{ end -}}
|
||||||
|
{{ end -}}
|
||||||
|
{{ end -}}
|
||||||
|
</ul>
|
||||||
|
{{ end }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sidebar-footer" -}}
|
||||||
|
{{- range site.Menus.sidebar -}}
|
||||||
|
{{- $name := or (T .Identifier) .Name -}}
|
||||||
|
{{ if eq .Params.type "separator" }}
|
||||||
|
<li class="[word-break:break-word] hx:mt-5 hx:mb-2 hx:px-2 hx:py-1.5 hx:text-sm hx:font-semibold hx:text-gray-900 hx:first:mt-0 hx:dark:text-gray-100">
|
||||||
|
<span class="hx:cursor-default">{{ $name }}</span>
|
||||||
|
</li>
|
||||||
|
{{ else }}
|
||||||
|
{{- $link := .URL -}}
|
||||||
|
{{- with .PageRef -}}
|
||||||
|
{{- if hasPrefix . "/" -}}
|
||||||
|
{{- $link = relLangURL (strings.TrimPrefix "/" .) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
<li>{{ template "sidebar-item-link" dict "active" false "open" false "title" $name "link" $link }}</li>
|
||||||
|
{{ end }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sidebar-item-link" -}}
|
||||||
|
{{- $external := strings.HasPrefix .link "http" -}}
|
||||||
|
{{- $open := .open | default true -}}
|
||||||
|
{{- $hasChildren := false -}}
|
||||||
|
{{- $linkClass := "hx:flex hx:items-center hx:justify-between hx:gap-2 hx:grow hx:cursor-pointer hx:rounded-sm hx:px-2 hx:py-1.5 hx:text-sm hx:transition-colors [-webkit-tap-highlight-color:transparent] [-webkit-touch-callout:none] hx:hextra-focus-visible-inset" -}}
|
||||||
|
{{- with .context }}{{ if or .RegularPages .Sections }}{{ $hasChildren = true }}{{ end }}{{ end -}}
|
||||||
|
{{- if $hasChildren -}}
|
||||||
|
{{- $linkClass = printf "%s hx:ltr:pr-8 hx:rtl:pl-8" $linkClass -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- if .active -}}
|
||||||
|
{{- $linkClass = printf "%s hextra-sidebar-active-item hx:bg-primary-100 hx:font-semibold hx:text-primary-800 hx:contrast-more:border hx:contrast-more:border-primary-500 hx:dark:bg-primary-400/10 hx:dark:text-primary-600 hx:contrast-more:dark:border-primary-500" $linkClass -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $linkClass = printf "%s hx:text-gray-500 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:contrast-more:border hx:contrast-more:border-transparent hx:contrast-more:text-gray-900 hx:contrast-more:hover:border-gray-900 hx:dark:text-neutral-400 hx:dark:hover:bg-primary-100/5 hx:dark:hover:text-gray-50 hx:contrast-more:dark:text-gray-50 hx:contrast-more:dark:hover:border-gray-50" $linkClass -}}
|
||||||
|
{{- end -}}
|
||||||
|
<div class="hextra-sidebar-item hx:group hx:relative hx:flex hx:items-center" data-active="{{ if .active }}true{{ else }}false{{ end }}">
|
||||||
|
<a
|
||||||
|
class="{{ $linkClass }}"
|
||||||
|
href="{{ .link }}"
|
||||||
|
{{ if $external }}target="_blank" rel="noreferrer"{{ end }}
|
||||||
|
>
|
||||||
|
<span class="hx:min-w-0 [word-break:break-word]">{{- .title -}}</span>
|
||||||
|
</a>
|
||||||
|
{{- if $hasChildren }}
|
||||||
|
<button type="button" class="hextra-sidebar-collapsible-button hx:absolute hx:top-1/2 hx:-translate-y-1/2 hx:ltr:right-2 hx:rtl:left-2 hx:shrink-0 hx:cursor-pointer hx:p-0 hx:text-gray-500 hx:dark:text-neutral-400 hx:group-hover:text-gray-900 hx:dark:group-hover:text-gray-50 hx:group-data-[active=true]:text-primary-800 hx:group-data-[active=true]:dark:text-primary-600 hx:hextra-focus-visible-inset" aria-label="{{ (T "toggleSection") | default "Toggle section" }}" aria-expanded="{{ if $open }}true{{ else }}false{{ end }}">
|
||||||
|
{{- template "sidebar-collapsible-button" -}}
|
||||||
|
</button>
|
||||||
|
{{- end }}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- define "sidebar-collapsible-button" -}}
|
||||||
|
<svg fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true" focusable="false" class="hx:h-[18px] hx:min-w-[18px] hx:rounded-xs hx:p-0.5 hx:hover:bg-gray-800/5 hx:dark:hover:bg-gray-100/5"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" class="hx:origin-center hx:transition-transform hx:rtl:-rotate-180"></path></svg>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{- $context := .context -}}
|
||||||
|
|
||||||
|
{{- range $tag := $context.Params.tags -}}
|
||||||
|
{{- with $context.Site.GetPage (printf "/tags/%s" $tag) -}}
|
||||||
|
<a class="hx:inline-block hx:whitespace-nowrap hx:mr-2 hx:text-gray-500 hx:hover:text-gray-900 hx:dark:text-gray-400 hx:dark:hover:text-gray-100 hx:contrast-more:text-gray-800 hx:contrast-more:dark:text-gray-50" href="{{ .RelPermalink }}">#{{ $tag }}</a>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{- $hideLabel := .hideLabel -}}
|
||||||
|
{{- $iconHeight := .iconHeight | default 12 -}}
|
||||||
|
{{- $class := .class | default "hx:h-7 hx:px-2 hx:text-xs hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:hover:bg-primary-100/5 hx:dark:hover:text-gray-50 hx:font-medium hx:text-gray-600 hx:transition-colors hx:dark:text-gray-400" -}}
|
||||||
|
{{- $location := .location | default "bottom" -}}
|
||||||
|
|
||||||
|
{{- $changeTheme := (T "changeTheme") | default "Change theme" -}}
|
||||||
|
{{- $light := (T "light") | default "Light" -}}
|
||||||
|
{{- $dark := (T "dark") | default "Dark" -}}
|
||||||
|
{{- $system := (T "system") | default "System" -}}
|
||||||
|
|
||||||
|
<div class="hx:flex hx:justify-items-start hx:group" data-theme="light">
|
||||||
|
<button
|
||||||
|
title="{{ $changeTheme }}"
|
||||||
|
data-state="closed"
|
||||||
|
data-location="{{ $location }}"
|
||||||
|
class="hextra-theme-toggle hx:cursor-pointer hx:rounded-md hx:text-left hx:font-medium {{ $class }} hx:grow"
|
||||||
|
type="button"
|
||||||
|
aria-label="{{ $changeTheme }}"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
>
|
||||||
|
<div class="hx:flex hx:items-center hx:gap-2 hx:capitalize">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "sun" "attributes" (printf `height=%d class="hx:group-data-[theme=dark]:hidden hx:group-data-[theme=system]:hidden"` $iconHeight)) -}}
|
||||||
|
{{- if not $hideLabel }}<span class="hx:group-data-[theme=dark]:hidden hx:group-data-[theme=system]:hidden">{{ $light }}</span>{{ end -}}
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "moon" "attributes" (printf `height=%d class="hx:group-data-[theme=light]:hidden hx:group-data-[theme=system]:hidden"` $iconHeight)) -}}
|
||||||
|
{{- if not $hideLabel }}<span class="hx:group-data-[theme=light]:hidden hx:group-data-[theme=system]:hidden">{{ $dark }}</span>{{ end -}}
|
||||||
|
{{- partial "utils/icon.html" (dict "name" "contrast" "attributes" (printf `height=%d class="hx:group-data-[theme=dark]:hidden hx:group-data-[theme=light]:hidden"` $iconHeight)) -}}
|
||||||
|
{{- if not $hideLabel }}<span class="hx:group-data-[theme=dark]:hidden hx:group-data-[theme=light]:hidden">{{ $system }}</span>{{ end -}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<ul
|
||||||
|
class="hextra-theme-toggle-options hx:hidden hx:z-20 hx:max-h-64 hx:overflow-auto hx:rounded-lg hx:border hx:border-gray-200 hx:bg-white hx:p-1 hx:text-sm hx:shadow-lg hx:dark:border-neutral-700 hx:dark:bg-neutral-900"
|
||||||
|
style="position: fixed; inset: auto auto 0px 0px; margin: 0px; min-width: 100px;"
|
||||||
|
data-theme="light"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<li role="none" class="hx:flex hx:flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked="true"
|
||||||
|
tabindex="-1"
|
||||||
|
data-item="light"
|
||||||
|
class="hx:text-gray-700 hx:dark:text-gray-300 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100 hx:relative hx:cursor-pointer hx:whitespace-nowrap hx:rounded-sm hx:py-1.5 hx:transition-colors hx:ltr:pl-3 hx:ltr:pr-9 hx:rtl:pr-3 hx:rtl:pl-9 hx:text-left hx:w-full hx:bg-transparent hx:border-0"
|
||||||
|
>
|
||||||
|
{{ $light }}
|
||||||
|
<span class="hx:absolute hx:inset-y-0 hx:flex hx:items-center hx:ltr:right-3 hx:rtl:left-3 hx:group-data-[theme=dark]:hidden hx:group-data-[theme=system]:hidden">
|
||||||
|
{{- partial "utils/icon" (dict "name" "check" "attributes" "height=1em width=1em") -}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="none" class="hx:flex hx:flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked="false"
|
||||||
|
tabindex="-1"
|
||||||
|
data-item="dark"
|
||||||
|
class="hx:text-gray-700 hx:dark:text-gray-300 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100 hx:relative hx:cursor-pointer hx:whitespace-nowrap hx:rounded-sm hx:py-1.5 hx:transition-colors hx:ltr:pl-3 hx:ltr:pr-9 hx:rtl:pr-3 hx:rtl:pl-9 hx:text-left hx:w-full hx:bg-transparent hx:border-0"
|
||||||
|
>
|
||||||
|
{{ $dark }}
|
||||||
|
<span class="hx:absolute hx:inset-y-0 hx:flex hx:items-center hx:ltr:right-3 hx:rtl:left-3 hx:group-data-[theme=light]:hidden hx:group-data-[theme=system]:hidden">
|
||||||
|
{{- partial "utils/icon" (dict "name" "check" "attributes" "height=1em width=1em") -}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li role="none" class="hx:flex hx:flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemradio"
|
||||||
|
aria-checked="false"
|
||||||
|
tabindex="-1"
|
||||||
|
data-item="system"
|
||||||
|
class="hx:text-gray-700 hx:dark:text-gray-300 hx:hover:bg-gray-100 hx:hover:text-gray-900 hx:dark:hover:bg-neutral-800 hx:dark:hover:text-gray-100 hx:relative hx:cursor-pointer hx:whitespace-nowrap hx:rounded-sm hx:py-1.5 hx:transition-colors hx:ltr:pl-3 hx:ltr:pr-9 hx:rtl:pr-3 hx:rtl:pl-9 hx:text-left hx:w-full hx:bg-transparent hx:border-0"
|
||||||
|
>
|
||||||
|
{{ $system }}
|
||||||
|
<span class="hx:absolute hx:inset-y-0 hx:flex hx:items-center hx:ltr:right-3 hx:rtl:left-3 hx:group-data-[theme=dark]:hidden hx:group-data-[theme=light]:hidden">
|
||||||
|
{{- partial "utils/icon" (dict "name" "check" "attributes" "height=1em width=1em") -}}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
{{/* Table of Contents */}}
|
||||||
|
{{/* TODO: toc bottom part should be able to hide */}}
|
||||||
|
{{- $toc := .Params.toc | default true -}}
|
||||||
|
{{- $onThisPage := (T "onThisPage") | default "On this page"}}
|
||||||
|
{{- $tags := (T "tags") | default "Tags"}}
|
||||||
|
{{- $editThisPage := (T "editThisPage") | default "Edit this page"}}
|
||||||
|
{{- $backToTop := (T "backToTop") | default "Scroll to top" -}}
|
||||||
|
|
||||||
|
<nav class="hextra-toc hx:order-last hx:hidden hx:w-64 hx:shrink-0 hx:xl:block hx:print:hidden hx:px-4" aria-label="{{ (T "tableOfContents") | default "Table of contents" }}">
|
||||||
|
{{- if $toc }}
|
||||||
|
<div class="hextra-scrollbar hx:sticky hx:top-16 hx:overflow-y-auto hx:pr-4 hx:pt-6 hx:text-sm [hyphens:auto] hx:max-h-[calc(100vh-var(--navbar-height)-env(safe-area-inset-bottom))] hx:ltr:-mr-4 hx:rtl:-ml-4">
|
||||||
|
{{- with .Fragments.Headings -}}
|
||||||
|
<p class="hx:mb-4 hx:font-semibold hx:tracking-tight">{{ $onThisPage }}</p>
|
||||||
|
{{- range . -}}
|
||||||
|
<ul>
|
||||||
|
{{- with .Headings -}}{{ template "toc-subheading" (dict "headings" . "level" 0) }}{{- end -}}
|
||||||
|
</ul>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $borderClass := "hx:mt-8 hx:border-t hx:bg-white hx:pt-8 hx:shadow-[0_-12px_16px_white] hx:dark:bg-dark hx:dark:shadow-[0_-12px_16px_#111]" -}}
|
||||||
|
|
||||||
|
{{- if not .Fragments.Headings -}}
|
||||||
|
{{- $borderClass = "" -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{/* TOC bottom part */}}
|
||||||
|
<div class="{{ $borderClass }} hx:sticky hx:bottom-0 hx:flex hx:flex-col hx:items-start hx:gap-2 hx:pb-8 hx:border-gray-200 hx:dark:border-neutral-800 hx:contrast-more:border-t hx:contrast-more:border-neutral-400 hx:contrast-more:shadow-none hx:contrast-more:dark:border-neutral-400">
|
||||||
|
{{- if and site.Params.toc.displayTags .Params.tags -}}
|
||||||
|
<div class="hx:flex hx:items-start hx:gap-x-2 hx:font-medium hx:text-xs">
|
||||||
|
<div class="hx:text-gray-500 hx:dark:text-gray-400 hx:contrast-more:text-gray-800 hx:contrast-more:dark:text-gray-50">{{ $tags }}</div>
|
||||||
|
<div class="hx:flex hx:flex-wrap hx:gap-y-1">
|
||||||
|
{{ partial "tags.html" (dict "context" .) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- if site.Params.editURL.enable -}}
|
||||||
|
{{- $editURL := site.Params.editURL.base | default "" -}}
|
||||||
|
{{- with .Params.editURL -}}
|
||||||
|
{{/* if `editURL` is set in the front matter */}}
|
||||||
|
{{- $editURL = . -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- with .File -}}
|
||||||
|
{{/* `.FileInfo.Meta.SourceRoot` is a Hugo internal field, e.g. `/path/to/repo/content/en/` */}}
|
||||||
|
{{- $sourceDir := replace (strings.TrimPrefix .FileInfo.Meta.BaseDir .FileInfo.Meta.SourceRoot) "\\" "/" -}}
|
||||||
|
{{- $sourceDir = strings.TrimPrefix "/content" $sourceDir -}}
|
||||||
|
{{- $path := replace .Path "\\" "/" -}}
|
||||||
|
{{- $editURL = urls.JoinPath $editURL $sourceDir $path -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
<a class="hx:inline-block hx:rounded-sm hx:text-xs hx:font-medium hx:text-gray-500 hx:hover:text-gray-900 hx:dark:text-gray-400 hx:dark:hover:text-gray-100 hx:contrast-more:text-gray-800 hx:contrast-more:dark:text-gray-50 hx:hextra-focus-visible-inset" href="{{ $editURL }}" target="_blank" rel="noreferrer">{{ $editThisPage }}</a>
|
||||||
|
{{- end -}}
|
||||||
|
{{/* Scroll To Top */}}
|
||||||
|
<button id="backToTop" tabindex="-1" class="hx:cursor-pointer hx:transition-all hx:duration-75 hx:opacity-0 hx:text-xs hx:font-medium hx:text-gray-500 hx:hover:text-gray-900 hx:dark:text-gray-400 hx:dark:hover:text-gray-100 hx:contrast-more:text-gray-800 hx:contrast-more:dark:text-gray-50">
|
||||||
|
<span>
|
||||||
|
{{- $backToTop -}}
|
||||||
|
</span>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" focusable="false" class="hx:inline hx:ltr:ml-1 hx:rtl:mr-1 hx:h-3.5 hx:w-3.5 hx:rounded-full hx:border hx:border-gray-500 hx:hover:border-gray-900 hx:dark:border-gray-400 hx:dark:hover:border-gray-100 hx:contrast-more:border-gray-800 hx:contrast-more:dark:border-gray-50">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 15.75l7.5-7.5 7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{{/* TOC subheadings component. This is a recursive component that renders a list of headings. */}}
|
||||||
|
{{- define "toc-subheading" -}}
|
||||||
|
{{- $headings := .headings -}}
|
||||||
|
{{- $level := .level -}}
|
||||||
|
{{- if ge $level 6 -}}
|
||||||
|
{{ return }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $padding := (mul $level 4) -}}
|
||||||
|
{{- $class := cond (eq $level 0) "hx:font-medium" (printf "hx:ltr:pl-%d hx:rtl:pr-%d" $padding $padding) -}}
|
||||||
|
|
||||||
|
{{- range $headings }}
|
||||||
|
{{- if .Title }}
|
||||||
|
<li class="hx:my-2 hx:scroll-my-6 hx:scroll-py-6">
|
||||||
|
<a class="{{ $class }} hx:inline-block hx:rounded-sm hx:text-gray-500 hx:hover:text-gray-900 hx:dark:text-gray-400 hx:dark:hover:text-gray-300 hx:contrast-more:text-gray-900 hx:contrast-more:underline hx:contrast-more:dark:text-gray-50 hx:w-full hx:wrap-break-word hx:hextra-focus-visible-inset" href="#{{ anchorize .ID }}">
|
||||||
|
{{- .Title | safeHTML | plainify | htmlUnescape }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{- end -}}
|
||||||
|
{{- with .Headings -}}
|
||||||
|
{{ template "toc-subheading" (dict "headings" . "level" (add $level 1)) }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{{- /*
|
||||||
|
Extracts all headings from a page and adds them to the scratchpad.
|
||||||
|
|
||||||
|
The keys can be obtained from the scratchpad by using the "keys" key.
|
||||||
|
The titles can be obtained from the scratchpad by using the "titles" key.
|
||||||
|
|
||||||
|
The scratchpad must be initialized with empty slices before calling this function for the keys "keys" and "titles"
|
||||||
|
|
||||||
|
@param {any} target The element to extract headings from.
|
||||||
|
@param {any} scratch The scratchpad to add the keys and titles to.
|
||||||
|
|
||||||
|
@example {{ partial "utils/extract-headings.html" (dict "target" $h1 "scratch" $s) }}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- range $heading := index .target.Headings -}}
|
||||||
|
{{- if and (eq $heading.Level 0) (not $heading.Title) -}}
|
||||||
|
{{- $.scratch.Add "keys" (slice $heading.Title) -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $key := (printf "%s#%s" $heading.ID $heading.Title) -}}
|
||||||
|
{{- $.scratch.Add "keys" (slice $key) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $title := (printf "<h%d>%s" $heading.Level $heading.Title) | htmlUnescape -}}
|
||||||
|
{{- $.scratch.Add "titles" (slice $title) -}}
|
||||||
|
|
||||||
|
{{- partial "utils/extract-headings.html" (dict
|
||||||
|
"target" $heading
|
||||||
|
"scratch" $.scratch
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{/* This utility is used to get the file path from absolute, relative path or URL. */}}
|
||||||
|
|
||||||
|
{{- $path := .path -}}
|
||||||
|
{{- $page := .page -}}
|
||||||
|
|
||||||
|
{{- $isLocal := not (urls.Parse $path).Scheme -}}
|
||||||
|
{{- $isPage := and (eq $page.Kind "page") (not $page.BundleType) -}}
|
||||||
|
{{- $startsWithSlash := hasPrefix $path "/" -}}
|
||||||
|
{{- $startsWithRelative := hasPrefix $path "../" -}}
|
||||||
|
|
||||||
|
{{- if and $path $isLocal -}}
|
||||||
|
{{- if $startsWithSlash -}}
|
||||||
|
{{/* File under static directory */}}
|
||||||
|
{{- $path = (relURL (strings.TrimPrefix "/" $path)) -}}
|
||||||
|
{{- else if and $isPage (not $startsWithRelative) -}}
|
||||||
|
{{/* File is a sibling to the individual page file */}}
|
||||||
|
{{ $path = (printf "../%s" $path) }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $path -}}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{{- with . -}}
|
||||||
|
{{- . | time.Format (site.Params.dateFormat | default ":date_long") -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
{{- /*
|
||||||
|
fragments.html - Split page content into searchable fragments
|
||||||
|
|
||||||
|
This partial processes a Hugo page and splits its content into fragments based on headings,
|
||||||
|
creating a data structure suitable for search indexing. It supports different fragment types
|
||||||
|
and handles hierarchical heading structures (h1, h2).
|
||||||
|
|
||||||
|
Parameters:
|
||||||
|
- .context (Page): The Hugo page to process
|
||||||
|
- .type (string): Fragment type - "content" (default), "heading", "title", or "summary"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
- dict: Map of heading keys to content fragments
|
||||||
|
|
||||||
|
Example:
|
||||||
|
Input page with content:
|
||||||
|
# Introduction
|
||||||
|
This is the intro text.
|
||||||
|
## Setup
|
||||||
|
Setup instructions here.
|
||||||
|
# Configuration
|
||||||
|
Config details here.
|
||||||
|
|
||||||
|
Output (type "content"):
|
||||||
|
{
|
||||||
|
"": "This is the intro text.",
|
||||||
|
"intro#Introduction": "This is the intro text. Setup instructions here.",
|
||||||
|
"setup#Setup": "Setup instructions here.",
|
||||||
|
"config#Configuration": "Config details here."
|
||||||
|
}
|
||||||
|
|
||||||
|
Fragment types:
|
||||||
|
- "content": Splits page content by headings (default)
|
||||||
|
- "heading": Returns heading keys with empty content
|
||||||
|
- "title": Returns empty content (title handled elsewhere)
|
||||||
|
- "summary": Returns page summary only
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- /* Extract page context and fragment type */ -}}
|
||||||
|
{{- $page := .context -}}
|
||||||
|
{{- $type := .type | default "content" -}}
|
||||||
|
|
||||||
|
{{- /* Process all headings */ -}}
|
||||||
|
{{- $s := newScratch -}}
|
||||||
|
{{- $s.Set "keys" slice -}}
|
||||||
|
{{- $s.Set "titles" slice -}}
|
||||||
|
|
||||||
|
{{- partial "utils/extract-headings.html" (dict "target" $page.Fragments "scratch" $s) -}}
|
||||||
|
|
||||||
|
{{- $headingKeys := $s.Get "keys" -}}
|
||||||
|
{{- $headingTitles := $s.Get "titles" -}}
|
||||||
|
|
||||||
|
{{- $content := $page.Content | htmlUnescape -}}
|
||||||
|
{{- $len := len $headingKeys -}}
|
||||||
|
{{- $data := dict -}}
|
||||||
|
|
||||||
|
{{ if eq $type "content" }}
|
||||||
|
{{/* Include full content of the page */}}
|
||||||
|
{{ if eq $len 0 }}
|
||||||
|
{{ $data = $data | merge (dict "" ($page.Plain | htmlUnescape | strings.TrimSpace)) }}
|
||||||
|
{{ else }}
|
||||||
|
{{/* Split the raw content from bottom to top */}}
|
||||||
|
{{ range seq $len }}
|
||||||
|
{{ $i := sub $len . }}
|
||||||
|
{{ $headingKey := index $headingKeys $i }}
|
||||||
|
{{ $headingTitle := index $headingTitles $i }}
|
||||||
|
|
||||||
|
{{ if eq $i 0 }}
|
||||||
|
{{ $data = $data | merge (dict $headingKey ($content | plainify | htmlUnescape | strings.TrimSpace)) }}
|
||||||
|
{{ else }}
|
||||||
|
{{ $parts := split $content (printf "%s" $headingTitle) }}
|
||||||
|
{{ $lastPart := index $parts (sub (len $parts) 1) }}
|
||||||
|
|
||||||
|
{{ $data = $data | merge (dict $headingKey ($lastPart | plainify | htmlUnescape | strings.TrimSpace)) }}
|
||||||
|
{{ $content = strings.TrimSuffix $lastPart $content }}
|
||||||
|
{{ $content = strings.TrimSuffix (printf "%s" $headingTitle) $content }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ else if (eq $type "heading" ) }}
|
||||||
|
{{/* Put heading keys with empty content to the data object */}}
|
||||||
|
{{ $data = dict "" "" }}
|
||||||
|
{{ range $headingKeys }}
|
||||||
|
{{ $data = $data | merge (dict . "") }}
|
||||||
|
{{ end }}
|
||||||
|
{{ else if (eq $type "title") }}
|
||||||
|
{{/* Use empty data object since title is included in search-data.json */}}
|
||||||
|
{{ $data = $data | merge (dict "" "") }}
|
||||||
|
{{ else if (eq $type "summary" ) }}
|
||||||
|
{{ $data = $data | merge (dict "" ($page.Summary | plainify | htmlUnescape | strings.TrimSpace)) }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ return $data }}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{/*
|
||||||
|
Returns the language direction using the supported Hugo API for the running version.
|
||||||
|
|
||||||
|
Hugo v0.158.0 deprecated Language.LanguageDirection in favor of Language.Direction.
|
||||||
|
Keep the fallback so Hextra can continue supporting Hugo >= 0.146.0.
|
||||||
|
*/}}
|
||||||
|
{{- $language := . -}}
|
||||||
|
{{- $direction := "" -}}
|
||||||
|
{{- if ge (hugo.Version) "0.158.0" -}}
|
||||||
|
{{- $direction = $language.Direction -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $direction = $language.LanguageDirection -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $direction -}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{/*
|
||||||
|
Returns the language label using the supported Hugo API for the running version.
|
||||||
|
|
||||||
|
Hugo v0.158.0 deprecated Language.LanguageName in favor of Language.Label.
|
||||||
|
Keep the fallback so Hextra can continue supporting Hugo >= 0.146.0.
|
||||||
|
*/}}
|
||||||
|
{{- $language := . -}}
|
||||||
|
{{- $label := "" -}}
|
||||||
|
{{- if ge (hugo.Version) "0.158.0" -}}
|
||||||
|
{{- $label = $language.Label -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $label = $language.LanguageName -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $label -}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{/*
|
||||||
|
Returns the language locale using the supported Hugo API for the running version.
|
||||||
|
|
||||||
|
Hugo v0.158.0 deprecated Language.LanguageCode in favor of Language.Locale.
|
||||||
|
Keep the fallback so Hextra can continue supporting Hugo >= 0.146.0.
|
||||||
|
*/}}
|
||||||
|
{{- $language := . -}}
|
||||||
|
{{- $locale := "" -}}
|
||||||
|
{{- if ge (hugo.Version) "0.158.0" -}}
|
||||||
|
{{- $locale = $language.Locale -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $locale = $language.LanguageCode -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $locale -}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{/*
|
||||||
|
Returns site data using the supported Hugo API for the running version.
|
||||||
|
|
||||||
|
Hugo v0.156.0 deprecated site.Data / .Site.Data in favor of hugo.Data.
|
||||||
|
Keep the fallback so Hextra can continue supporting Hugo >= 0.146.0.
|
||||||
|
*/}}
|
||||||
|
{{- $siteData := dict -}}
|
||||||
|
{{- if ge (hugo.Version) "0.156.0" -}}
|
||||||
|
{{- $siteData = hugo.Data -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $siteData = site.Data -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $siteData -}}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{{/*
|
||||||
|
Returns all sites using the supported Hugo API for the running version.
|
||||||
|
|
||||||
|
Hugo v0.156.0 deprecated site.Sites / page.Sites in favor of hugo.Sites.
|
||||||
|
Keep the fallback so Hextra can continue supporting Hugo >= 0.146.0.
|
||||||
|
*/}}
|
||||||
|
{{- $sites := slice -}}
|
||||||
|
{{- if ge (hugo.Version) "0.156.0" -}}
|
||||||
|
{{- $sites = hugo.Sites -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $sites = site.Sites -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $sites -}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{/* Render raw svg icon from site data */}}
|
||||||
|
{{- $siteData := partial "utils/hugo-compat/site-data.html" . -}}
|
||||||
|
{{- $icon := index $siteData.icons .name -}}
|
||||||
|
|
||||||
|
{{- if not $icon -}}
|
||||||
|
{{ errorf "icon %q not found" .name }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $icon = $icon | safeHTML -}}
|
||||||
|
|
||||||
|
{{- if .attributes -}}
|
||||||
|
{{- $icon = replaceRE "<svg" (printf "<svg %s" .attributes) $icon -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return ($icon | safeHTML) -}}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{/* Get relative link of a page for given language */}}
|
||||||
|
{{/* If not found, return the homepage of the language page */}}
|
||||||
|
|
||||||
|
{{ $page := .context }}
|
||||||
|
{{ $lang := .lang }}
|
||||||
|
|
||||||
|
{{ $link := false }}
|
||||||
|
|
||||||
|
{{ range $page.AllTranslations }}
|
||||||
|
{{ if eq .Language.Lang $lang }}
|
||||||
|
{{ $link = .RelPermalink }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if not $link }}
|
||||||
|
{{ range where (partial "utils/hugo-compat/sites.html" .) ".Language.Lang" $lang }}
|
||||||
|
{{ $link = .Home.RelPermalink }}
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ if not $link }}
|
||||||
|
{{ $link = site.Home.RelPermalink }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ return $link }}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{ with .Description | plainify | htmlUnescape -}}
|
||||||
|
{{ . -}}
|
||||||
|
{{ else -}}
|
||||||
|
{{ if .IsHome -}}
|
||||||
|
{{ with .Site.Params.description | plainify | htmlUnescape -}}
|
||||||
|
{{ . -}}
|
||||||
|
{{ end -}}
|
||||||
|
{{ else -}}
|
||||||
|
{{ .Summary | plainify | htmlUnescape | chomp -}}
|
||||||
|
{{ end -}}
|
||||||
|
{{ end -}}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{{- with .Params.width -}}
|
||||||
|
{{- $pageWidthValues := dict "normal" "80rem" "wide" "90rem" "full" "100%" -}}
|
||||||
|
{{- $pageWidth := . -}}
|
||||||
|
{{- $maxPageWidth := (index $pageWidthValues $pageWidth) | default (index $pageWidthValues "normal") -}}
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--hextra-max-page-width: {{ $maxPageWidth }};
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{{- $page := .page -}}
|
||||||
|
{{- $by := .by | default "weight" -}}
|
||||||
|
{{- $order := .order | default "asc" -}}
|
||||||
|
|
||||||
|
{{- $pages := slice }}
|
||||||
|
|
||||||
|
{{- if eq $by "weight" }}
|
||||||
|
{{- $pages = $page.Pages.ByWeight }}
|
||||||
|
{{- else if eq $by "date" }}
|
||||||
|
{{- $pages = $page.Pages.ByDate }}
|
||||||
|
{{- else if eq $by "title" }}
|
||||||
|
{{- $pages = $page.Pages.ByTitle }}
|
||||||
|
{{- else if eq $by "expiryDate" }}
|
||||||
|
{{- $pages = $page.Pages.ByExpiryDate }}
|
||||||
|
{{- else if eq $by "publishDate" }}
|
||||||
|
{{- $pages = $page.Pages.ByPublishDate }}
|
||||||
|
{{- else if eq $by "lastmod" }}
|
||||||
|
{{- $pages = $page.Pages.ByLastmod }}
|
||||||
|
{{- else if eq $by "linkTitle" }}
|
||||||
|
{{- $pages = $page.Pages.ByLinkTitle }}
|
||||||
|
{{- else if eq $by "length" }}
|
||||||
|
{{- $pages = $page.Pages.ByLength }}
|
||||||
|
{{- else }}
|
||||||
|
{{- warnf "sort-pages: unknown sort field %q" $by -}}
|
||||||
|
{{- $pages = $page.Pages }}
|
||||||
|
{{ end -}}
|
||||||
|
|
||||||
|
{{- if eq $order "desc" }}
|
||||||
|
{{- $pages = $pages.Reverse }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $pages -}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{{/*
|
||||||
|
This utility replaces placeholders in a URL template string.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{{ partial "utils/template-url.html" (dict "template" .url "values" (dict "url" $pageURL "title" $pageTitle "markdown_url" $markdownURL)) }}
|
||||||
|
|
||||||
|
Placeholders use the format {key} and values are URL-encoded automatically.
|
||||||
|
*/}}
|
||||||
|
{{- $template := .template -}}
|
||||||
|
{{- $values := .values | default dict -}}
|
||||||
|
|
||||||
|
{{- range $key, $value := $values -}}
|
||||||
|
{{- $placeholder := printf "{%s}" $key -}}
|
||||||
|
{{- $encoded := $value | urlquery -}}
|
||||||
|
{{- $template = replace $template $placeholder $encoded -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- return $template -}}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{{/*
|
||||||
|
This utility is used to retrieve the title of a page or section.
|
||||||
|
If no title is set, it falls back to using the directory or file name.
|
||||||
|
|
||||||
|
Based on https://github.com/thegeeklab/hugo-geekdoc/blob/v0.44.0/layouts/partials/utils/title.html
|
||||||
|
*/}}
|
||||||
|
{{- $title := "" }}
|
||||||
|
|
||||||
|
{{ if .LinkTitle }}
|
||||||
|
{{ $title = .LinkTitle }}
|
||||||
|
{{ else if .Title }}
|
||||||
|
{{ $title = .Title }}
|
||||||
|
{{ else if and .IsSection .File }}
|
||||||
|
{{ $title = path.Base .File.Dir | humanize | title }}
|
||||||
|
{{ else if and .IsPage .File }}
|
||||||
|
{{ $title = .File.BaseFileName | humanize | title }}
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ return $title -}}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{- /* Get parameters */ -}}
|
||||||
|
{{- $castFile := .Get "file" | default (.Get 0) -}}
|
||||||
|
{{- $theme := .Get "theme" | default "asciinema" -}}
|
||||||
|
{{- $speed := .Get "speed" | default 1 -}}
|
||||||
|
{{- $autoplay := .Get "autoplay" | default false -}}
|
||||||
|
{{- $loop := .Get "loop" | default false -}}
|
||||||
|
{{- $poster := .Get "poster" | default "" -}}
|
||||||
|
{{- $markers := .Get "markers" | default "" -}}
|
||||||
|
|
||||||
|
{{- /* Handle file path: support local files, absolute paths, and remote URLs */ -}}
|
||||||
|
{{- $isLocal := not (urls.Parse $castFile).Scheme -}}
|
||||||
|
{{- $isPage := and (eq .Page.Kind "page") (not .Page.BundleType) -}}
|
||||||
|
|
||||||
|
{{- if $isLocal -}}
|
||||||
|
{{- /* Local file handling */ -}}
|
||||||
|
{{- $found := false -}}
|
||||||
|
|
||||||
|
{{- /* Try page resources first */ -}}
|
||||||
|
{{- if not $isPage -}}
|
||||||
|
{{- with .Page.Resources.Get $castFile -}}
|
||||||
|
{{- $castFile = .RelPermalink -}}
|
||||||
|
{{- $found = true -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Try global resources if not found in page resources */ -}}
|
||||||
|
{{- if not $found -}}
|
||||||
|
{{- with resources.Get $castFile -}}
|
||||||
|
{{- $castFile = .RelPermalink -}}
|
||||||
|
{{- $found = true -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Try static files if not found in resources */ -}}
|
||||||
|
{{- if not $found -}}
|
||||||
|
{{- if hasPrefix $castFile "/" -}}
|
||||||
|
{{- $castFile = relURL (strings.TrimPrefix "/" $castFile) -}}
|
||||||
|
{{- $found = true -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- /* For relative paths, assume they're in static directory */ -}}
|
||||||
|
{{- $castFile = relURL $castFile -}}
|
||||||
|
{{- $found = true -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* If still not found, raise an error */ -}}
|
||||||
|
{{- if not $found -}}
|
||||||
|
{{- errorf "Asciinema cast file not found: %s. Please ensure the file exists in your assets, static/, or provide a valid remote URL." $castFile -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Build marker configuration */ -}}
|
||||||
|
{{- $markerConfig := "" -}}
|
||||||
|
{{- if $markers -}}
|
||||||
|
{{- $markerParts := slice -}}
|
||||||
|
{{- range (split $markers ",") -}}
|
||||||
|
{{- $item := trim . " " -}}
|
||||||
|
{{- $colonIndex := findRE ":" $item -}}
|
||||||
|
{{- if $colonIndex -}}
|
||||||
|
{{- /* Marker with label */ -}}
|
||||||
|
{{- $pair := split $item ":" -}}
|
||||||
|
{{- if ge (len $pair) 2 -}}
|
||||||
|
{{- $time := printf "%.1f" (float (trim (index $pair 0) " ")) -}}
|
||||||
|
{{- $label := trim (index $pair 1) " " -}}
|
||||||
|
{{- $markerParts = $markerParts | append (printf "[%s,\"%s\"]" $time $label) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- /* Simple marker */ -}}
|
||||||
|
{{- $markerParts = $markerParts | append (printf "%.1f" (float $item)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $markerConfig = printf "[%s]" (delimit $markerParts ",") -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- /* Mark page as using asciinema */ -}}
|
||||||
|
{{- .Page.Store.Set "hasAsciinema" true -}}
|
||||||
|
|
||||||
|
<div class="asciinema-player"
|
||||||
|
role="region"
|
||||||
|
aria-label="{{ (T "terminalRecording") | default "Terminal recording" }}"
|
||||||
|
data-cast-file="{{ $castFile }}"
|
||||||
|
data-theme="{{ $theme }}"
|
||||||
|
data-speed="{{ $speed }}"
|
||||||
|
data-autoplay="{{ $autoplay }}"
|
||||||
|
data-loop="{{ $loop }}"
|
||||||
|
{{- if ne $poster "" -}}data-poster="{{ $poster | safeURL }}"{{- end -}}
|
||||||
|
{{- if $markerConfig -}}data-markers="{{ $markerConfig | safeJS }}"{{- end -}}>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode to create a badge.
|
||||||
|
|
||||||
|
@param {string} content The content of the badge.
|
||||||
|
@param {string} color The color of the badge.
|
||||||
|
@param {string} class The class of the badge.
|
||||||
|
@param {string} link The link of the badge.
|
||||||
|
@param {string} icon The icon of the badge.
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
@param {string} 0 The content of the badge.
|
||||||
|
|
||||||
|
@example {{< badge content="Badge" color="blue" >}}
|
||||||
|
@example {{< badge "Badge" >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- if .IsNamedParams -}}
|
||||||
|
{{- $content := .Get "content" -}}
|
||||||
|
{{- $color := .Get "color" | default (.Get "type") | default "" -}}{{- /* Compatibility with previous parameter. */ -}}
|
||||||
|
{{- $class := .Get "class" | default "" -}}
|
||||||
|
{{- $link := .Get "link" | default "" -}}
|
||||||
|
{{- $icon := .Get "icon" | default "" -}}
|
||||||
|
{{- $border := not (eq (.Get "border") false) | default true }}
|
||||||
|
|
||||||
|
{{- if $link -}}
|
||||||
|
<a href="{{ $link }}" title="{{ $content | plainify }}" target="_blank" class="not-prose hx:inline-flex hx:align-middle hx:no-underline hover:hx:no-underline">
|
||||||
|
{{- partial "shortcodes/badge.html" (dict
|
||||||
|
"content" $content
|
||||||
|
"color" $color
|
||||||
|
"class" $class
|
||||||
|
"border" $border
|
||||||
|
"icon" $icon
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
</a>
|
||||||
|
{{- else -}}
|
||||||
|
{{- partial "shortcodes/badge.html" (dict
|
||||||
|
"content" $content
|
||||||
|
"color" $color
|
||||||
|
"class" $class
|
||||||
|
"border" $border
|
||||||
|
"icon" $icon
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $content := .Get 0 -}}
|
||||||
|
{{- partial "shortcodes/badge.html" (dict
|
||||||
|
"content" $content
|
||||||
|
"border" true
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode to create a callout.
|
||||||
|
|
||||||
|
@param {string} type The type of the callout (default, info, warning, error, important).
|
||||||
|
@param {string} content The content of the callout.
|
||||||
|
@param {string} emoji The emoji of the callout.
|
||||||
|
@param {string} icon The icon of the callout (related to type or can be a custom icon).
|
||||||
|
|
||||||
|
@example {{< callout type="info" >}}Content{{< /callout >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $type := .Get "type" | default "default" -}}
|
||||||
|
{{- $emoji := .Get "emoji" -}}
|
||||||
|
{{- $icon := .Get "icon" -}}
|
||||||
|
|
||||||
|
{{- $styles := newScratch -}}
|
||||||
|
{{- $styles.Set "default" (dict
|
||||||
|
"icon" "light-bulb"
|
||||||
|
"style" "hx:border-green-200 hx:bg-green-100 hx:text-green-900 hx:dark:border-green-200/30 hx:dark:bg-green-900/30 hx:dark:text-green-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "info" (dict
|
||||||
|
"icon" "information-circle"
|
||||||
|
"style" "hx:border-blue-200 hx:bg-blue-100 hx:text-blue-900 hx:dark:border-blue-200/30 hx:dark:bg-blue-900/30 hx:dark:text-blue-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "warning" (dict
|
||||||
|
"icon" "exclamation"
|
||||||
|
"style" "hx:border-amber-200 hx:bg-amber-100 hx:text-amber-900 hx:dark:border-amber-200/30 hx:dark:bg-amber-900/30 hx:dark:text-amber-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "error" (dict
|
||||||
|
"icon" "ban"
|
||||||
|
"style" "hx:border-red-200 hx:bg-red-100 hx:text-red-900 hx:dark:border-red-200/30 hx:dark:bg-red-900/30 hx:dark:text-red-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
{{- $styles.Set "important" (dict
|
||||||
|
"icon" "exclamation-circle"
|
||||||
|
"style" "hx:border-purple-200 hx:bg-purple-100 hx:text-purple-900 hx:dark:border-purple-200/30 hx:dark:bg-purple-900/30 hx:dark:text-purple-200"
|
||||||
|
)
|
||||||
|
-}}
|
||||||
|
|
||||||
|
{{- $style := or ($styles.Get $type) ($styles.Get "default") -}}
|
||||||
|
|
||||||
|
{{- if and (not $emoji) (not $icon) -}}
|
||||||
|
{{- $icon = $style.icon -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $content := .InnerDeindent | markdownify -}}
|
||||||
|
|
||||||
|
{{- partial "shortcodes/callout.html" (dict
|
||||||
|
"content" $content
|
||||||
|
"emoji" $emoji
|
||||||
|
"icon" $icon
|
||||||
|
"class" $style.style
|
||||||
|
)
|
||||||
|
-}}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode to create a card.
|
||||||
|
|
||||||
|
@param {string} link The link to the card.
|
||||||
|
@param {string} title The title of the card.
|
||||||
|
@param {string} icon The icon of the card.
|
||||||
|
@param {string} subtitle The subtitle of the card.
|
||||||
|
@param {string} tag The tag of the card.
|
||||||
|
@param {string} tagColor The color of the tag.
|
||||||
|
@param {string} image The image of the card.
|
||||||
|
@param {string} alt The alt text for the image (defaults to title if not provided).
|
||||||
|
@param {string} method The method to process the image.
|
||||||
|
@param {string} options The options to process the image.
|
||||||
|
@param {string} imageStyle The style of the image.
|
||||||
|
|
||||||
|
@example {{< card link="/" title="Image Card"
|
||||||
|
}}
|
||||||
|
*/ -}}
|
||||||
|
{{- $link := .Get "link" -}}
|
||||||
|
{{- $title := .Get "title" -}}
|
||||||
|
{{- $icon := .Get "icon" -}}
|
||||||
|
{{- $subtitle := .Get "subtitle" -}}
|
||||||
|
{{- $image := .Get "image" -}}
|
||||||
|
{{- $alt := .Get "alt" | default $title -}}
|
||||||
|
{{- $width := 0 -}}
|
||||||
|
{{- $height := 0 -}}
|
||||||
|
{{- $imageStyle := .Get "imageStyle" -}}
|
||||||
|
{{- $tag := .Get "tag" -}}
|
||||||
|
{{- $tagColor := .Get "tagColor" | default (.Get "tagType") | default "" -}}{{- /* Compatibility with previous parameter. */ -}}
|
||||||
|
{{- $tagBorder := not (eq (.Get "tagBorder") false) | default true }}
|
||||||
|
{{- $tagIcon := .Get "tagIcon" | default "" -}}
|
||||||
|
|
||||||
|
{{/* Image processing options */}}
|
||||||
|
{{- $method := .Get "method" | default "Resize" | humanize -}}
|
||||||
|
{{- $options := .Get "options" | default "800x webp q80" -}}
|
||||||
|
{{- $process := .Get "process" | default (printf "%s %s" $method $options) -}}
|
||||||
|
|
||||||
|
{{- if and $image (not (urls.Parse $image).Scheme) -}}
|
||||||
|
{{- with or (.Page.Resources.Get $image) (resources.Get $image) -}}
|
||||||
|
{{/* .Process does not work on svgs */}}
|
||||||
|
{{- if (not (eq .MediaType.SubType "svg")) -}}
|
||||||
|
{{/* Retrieve the $image resource from local or global resources */}}
|
||||||
|
{{- $processed := .Process $process -}}
|
||||||
|
{{- $width = $processed.Width -}}
|
||||||
|
{{- $height = $processed.Height -}}
|
||||||
|
{{- $image = $processed.RelPermalink -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{ else }}
|
||||||
|
{{/* Otherwise, use relative link of the image */}}
|
||||||
|
{{- if hasPrefix $image "/" -}}
|
||||||
|
{{- $image = relURL (strings.TrimPrefix "/" $image) -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- partial "shortcodes/card" (dict
|
||||||
|
"page" .Page
|
||||||
|
"link" $link
|
||||||
|
"title" $title
|
||||||
|
"icon" $icon
|
||||||
|
"subtitle" $subtitle
|
||||||
|
"image" $image
|
||||||
|
"alt" $alt
|
||||||
|
"width" $width
|
||||||
|
"height" $height
|
||||||
|
"imageStyle" $imageStyle
|
||||||
|
"tag" $tag
|
||||||
|
"tagType" $tagColor
|
||||||
|
"tagBorder" $tagBorder
|
||||||
|
"tagIcon" $tagIcon
|
||||||
|
)
|
||||||
|
-}}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for creating cards.
|
||||||
|
|
||||||
|
@param {string} cols The number of columns.
|
||||||
|
|
||||||
|
@example {{< cards cols="3" >}}{{< /cards >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $cols := .Get "cols" | default 3 -}}
|
||||||
|
|
||||||
|
{{- partial "shortcodes/cards" (dict "cols" $cols "content" .Inner) -}}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{- /*
|
||||||
|
A built-in component to display a collapsible content.
|
||||||
|
|
||||||
|
@param {string} title The title of the details.
|
||||||
|
@param {string} closed Whether the details are closed or not (default: false).
|
||||||
|
|
||||||
|
@example {{% details title="Details" %}}Content{{% /details %}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $title := .Get "title" | default "" -}}
|
||||||
|
{{- $closed := eq (.Get "closed") "true" | default false -}}
|
||||||
|
|
||||||
|
<details class="hx:last-of-type:mb-0 hx:rounded-lg hx:bg-neutral-50 hx:dark:bg-neutral-800 hx:p-2 hx:mt-4 hx:group" {{ if not $closed }}open{{ end }}>
|
||||||
|
<summary class="hx:flex hx:items-center hx:cursor-pointer hx:select-none hx:list-none hx:p-1 hx:rounded-sm hx:transition-colors hx:hover:bg-gray-100 hx:dark:hover:bg-neutral-800 hx:before:mr-1 hx:before:inline-block hx:before:transition-transform hx:before:content-[''] hx:dark:before:invert hx:rtl:before:rotate-180 hx:group-open:before:rotate-90">
|
||||||
|
<strong class="hx:text-lg">{{ $title | markdownify }}</strong>
|
||||||
|
</summary>
|
||||||
|
<div class="hx:p-2 hx:overflow-hidden">
|
||||||
|
{{ .InnerDeindent | $.Page.RenderString (dict "display" "block") }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for displaying a feature card.
|
||||||
|
|
||||||
|
@param {string} title The title of the card.
|
||||||
|
@param {string} subtitle The subtitle of the card.
|
||||||
|
@param {string} class The class of the card.
|
||||||
|
@param {string} image The image of the card.
|
||||||
|
@param {string} imageClass The class of the image.
|
||||||
|
@param {string} style The style of the card.
|
||||||
|
@param {string} icon The icon of the card.
|
||||||
|
@param {string} link The link of the card.
|
||||||
|
|
||||||
|
@example {{< hextra/feature-card title="Feature Card" subtitle="This is a feature card." >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $title := .Get "title" -}}
|
||||||
|
{{- $subtitle := .Get "subtitle" -}}
|
||||||
|
{{- $class := .Get "class" -}}
|
||||||
|
{{- $image := .Get "image" -}}
|
||||||
|
{{- $imageClass := .Get "imageClass" -}}
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
{{- $icon := .Get "icon" -}}
|
||||||
|
{{- $link := .Get "link" -}}
|
||||||
|
|
||||||
|
{{- $external := hasPrefix $link "http" -}}
|
||||||
|
{{- $href := cond (strings.HasPrefix $link "/") ($link | relURL) $link -}}
|
||||||
|
|
||||||
|
{{- if hasPrefix $image "/" -}}
|
||||||
|
{{- $image = relURL (strings.TrimPrefix "/" $image) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<a
|
||||||
|
{{ with $link }}href="{{ $href }}" {{ with $external }} target="_blank" rel="noreferrer"{{ end }}{{ end }}
|
||||||
|
{{ with $style }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
class="{{ $class }} hextra-feature-card not-prose hx:block hx:relative hx:overflow-hidden hx:rounded-3xl hx:border hx:border-gray-200 hx:hover:border-gray-300 hx:dark:border-neutral-800 hx:dark:hover:border-neutral-700 hx:before:pointer-events-none hx:before:absolute hx:before:inset-0 hx:before:bg-glass-gradient"
|
||||||
|
>
|
||||||
|
<div class="hx:relative hx:w-full hx:p-6">
|
||||||
|
<h3 class="hx:text-2xl hx:font-medium hx:leading-6 hx:mb-2 hx:flex hx:items-center">
|
||||||
|
{{ with $icon -}}
|
||||||
|
<span class="hx:pr-2">
|
||||||
|
{{- partial "utils/icon.html" (dict "name" . "attributes" "height=1.5rem") -}}
|
||||||
|
</span>
|
||||||
|
{{ end -}}
|
||||||
|
<span>{{ $title }}</span>
|
||||||
|
</h3>
|
||||||
|
<p class="hx:text-gray-500 hx:dark:text-gray-400 hx:text-sm hx:leading-6">{{ $subtitle | markdownify }}</p>
|
||||||
|
</div>
|
||||||
|
{{- with $image -}}
|
||||||
|
<img src="{{ . }}" class="hx:absolute hx:max-w-none {{ $imageClass }}" alt="{{ $title }}" />
|
||||||
|
{{- end -}}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for displaying a feature grid.
|
||||||
|
|
||||||
|
@param {string} cols The number of columns.
|
||||||
|
@param {string} style The style of the grid.
|
||||||
|
|
||||||
|
@example {{< hextra/feature-grid cols="3" >}}{{< /hextra/feature-grid >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $cols := .Get "cols" | default 3 -}}
|
||||||
|
{{- $style := .Get "style" | default "" -}}
|
||||||
|
|
||||||
|
{{- $css := printf "--hextra-feature-grid-cols: %v; %s" $cols $style -}}
|
||||||
|
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hextra-feature-grid hx:grid hx:sm:max-lg:grid-cols-2 hx:max-sm:grid-cols-1 hx:gap-4 hx:w-full not-prose"
|
||||||
|
{{ with $css }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
>
|
||||||
|
{{ .Inner }}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for rendering a badge with a link.
|
||||||
|
|
||||||
|
@param {string} link The link of the badge.
|
||||||
|
@param {string} class The class of the badge.
|
||||||
|
@param {string} style The style of the badge.
|
||||||
|
|
||||||
|
@example {{< hextra/hero-badge >}}{{< /hextra/hero-badge >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $link := .Get "link" -}}
|
||||||
|
{{- $external := hasPrefix $link "http" -}}
|
||||||
|
{{- $href := cond (hasPrefix $link "/") ($link | relURL) $link -}}
|
||||||
|
{{- $class := .Get "class" }}
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
|
||||||
|
<a
|
||||||
|
{{ if $link }}href="{{ $href }}"{{ end }}
|
||||||
|
class="{{ $class }} not-prose hx:inline-flex hx:items-center hx:rounded-full hx:gap-2 hx:px-3 hx:py-1 hx:text-xs hx:text-gray-600 hx:dark:text-gray-400 hx:bg-gray-100 hx:dark:bg-neutral-800 hx:border-gray-200 hx:dark:border-neutral-800 hx:border hx:hover:border-gray-400 hx:dark:hover:text-gray-50 hx:dark:hover:border-gray-600 hx:transition-all hx:ease-in hx:duration-200"
|
||||||
|
{{ with $style }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
{{ if $external }}target="_blank" rel="noreferrer"{{ end -}}
|
||||||
|
>
|
||||||
|
{{ .Inner | markdownify }}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for rendering a button with a link.
|
||||||
|
|
||||||
|
@param {string} link The link of the button.
|
||||||
|
@param {string} text The text of the button.
|
||||||
|
@param {string} style The style of the button.
|
||||||
|
|
||||||
|
@example {{< hextra/hero-button text="Get Started" link="docs" >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $link := .Get "link" -}}
|
||||||
|
{{- $text := .Get "text" -}}
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
|
||||||
|
{{- $external := hasPrefix $link "http" -}}
|
||||||
|
{{- $href := cond (hasPrefix $link "/") ($link | relURL) $link -}}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="{{ $href }}"
|
||||||
|
class="not-prose hx:font-medium hx:cursor-pointer hx:px-6 hx:py-3 hx:rounded-full hx:text-center hx:text-white hx:inline-block hx:bg-primary-600 hx:hover:bg-primary-700 hx:hextra-focus-visible hx:dark:bg-primary-600 hx:dark:hover:bg-primary-700 hx:transition-all hx:ease-in hx:duration-200"
|
||||||
|
{{ with $style }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
{{ if $external }}target="_blank" rel="noreferrer"{{ end -}}
|
||||||
|
>
|
||||||
|
{{- $text -}}
|
||||||
|
</a>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
{{- /*
|
||||||
|
A simple hero container with an image on the left side.
|
||||||
|
|
||||||
|
@param {string} class The class of the container.
|
||||||
|
@param {string} cols The number of columns (default: 2).
|
||||||
|
@param {string} image The image of the container.
|
||||||
|
@param {bool} imageCard Whether to display the image as a card (default: false).
|
||||||
|
@param {string} imageClass The class of the image.
|
||||||
|
@param {string} imageLink The link of the image.
|
||||||
|
@param {string} imageStyle The style of the image.
|
||||||
|
@param {string} imageTitle The title of the image.
|
||||||
|
@param {int} imageWidth The width of the image (default: 350).
|
||||||
|
@param {int} imageHeight The height of the image (default: 350).
|
||||||
|
@param {string} style The style of the container.
|
||||||
|
|
||||||
|
@example {{< hextra/hero-container image="image.png" imageLink="https://example.com" imageTitle="Example Image" >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $class := .Get "class" -}}
|
||||||
|
{{- $cols := .Get "cols" | default 2 -}}
|
||||||
|
{{- $image := .Get "image" -}}
|
||||||
|
{{- $imageCard := .Get "imageCard" | default false -}}
|
||||||
|
{{- $imageClass := .Get "imageClass" -}}
|
||||||
|
{{- $imageLink := .Get "imageLink" -}}
|
||||||
|
{{- $imageLinkExternal := hasPrefix $imageLink "http" -}}
|
||||||
|
{{- $imageStyle := .Get "imageStyle" -}}
|
||||||
|
{{- $imageTitle := .Get "imageTitle" -}}
|
||||||
|
{{- $imageWidth := .Get "imageWidth" | default 350 -}}
|
||||||
|
{{- $imageHeight := .Get "imageHeight" | default 350 -}}
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
|
||||||
|
{{- $css := printf "--hextra-feature-grid-cols: %v; %s" $cols $style -}}
|
||||||
|
{{- $href := cond (hasPrefix $imageLink "/") ($imageLink | relURL) $imageLink -}}
|
||||||
|
{{- if hasPrefix $image "/" -}}
|
||||||
|
{{- $image = relURL (strings.TrimPrefix "/" $image) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="{{ $class }} hextra-feature-grid hx:grid hx:sm:max-lg:grid-cols-2 hx:max-sm:grid-cols-1 hx:gap-4 hx:w-full not-prose"
|
||||||
|
{{ with $css }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
>
|
||||||
|
<div class="hx:w-full">
|
||||||
|
{{ .Inner }}
|
||||||
|
</div>
|
||||||
|
{{- with $image }}
|
||||||
|
<div class="hx:mx-auto">
|
||||||
|
<a
|
||||||
|
{{ with $imageLink }}href="{{ $href }}" {{ with $imageLinkExternal }} target="_blank" rel="noreferrer"{{ end }}{{ end }}
|
||||||
|
{{ with $imageStyle }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
class="{{ $imageClass }} {{ if $imageCard }}hextra-feature-card not-prose hx:block hx:relative hx:p-6 hx:overflow-hidden hx:rounded-3xl hx:border hx:border-gray-200 hx:hover:border-gray-300 hx:dark:border-neutral-800 hx:dark:hover:border-neutral-700 hx:before:pointer-events-none hx:before:absolute hx:before:inset-0 hx:before:bg-glass-gradient{{ end }}"
|
||||||
|
>
|
||||||
|
<img src="{{ $image }}" width="{{ $imageWidth }}" height="{{ $imageHeight }}" {{ with $imageTitle }}alt="{{ $imageTitle }}"{{ end }}/>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for displaying a hero headline.
|
||||||
|
|
||||||
|
@param {string} style The style of the headline.
|
||||||
|
|
||||||
|
@example {{< hextra/hero-headline >}}{{< /hextra/hero-headline >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
|
||||||
|
<h1
|
||||||
|
class="not-prose hx:text-4xl hx:font-bold hx:leading-none hx:tracking-tighter hx:md:text-5xl hx:py-2 hx:bg-clip-text hx:text-transparent hx:bg-gradient-to-r hx:from-gray-900 hx:to-gray-600 hx:dark:from-gray-100 hx:dark:to-gray-400"
|
||||||
|
{{ with $style }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
>
|
||||||
|
{{ .Inner | markdownify }}
|
||||||
|
</h1>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{{- /*
|
||||||
|
A simple hero section with a heading and optional style.
|
||||||
|
|
||||||
|
@param {string} heading The heading level (default: h2).
|
||||||
|
@param {string} style The style of the heading.
|
||||||
|
@param {string} content The content of the heading.
|
||||||
|
|
||||||
|
@example {{< hextra/hero-section heading="h3" >}}{{< /hextra/hero-section >}}>
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
{{- $heading := int (strings.TrimPrefix "h" (.Get "heading" | default "h2")) -}}
|
||||||
|
{{- $size := cond (ge $heading 4) "xl" (cond (eq $heading 3) "2xl" "4xl") -}}
|
||||||
|
|
||||||
|
<h{{ $heading }}
|
||||||
|
class="not-prose hx:text-{{ $size }} hx:font-bold hx:leading-none hx:tracking-tighter hx:md:text-3xl hx:py-2 hx:bg-clip-text hx:text-transparent hx:bg-gradient-to-r hx:from-gray-900 hx:to-gray-600 hx:dark:from-gray-100 hx:dark:to-gray-400"
|
||||||
|
{{ with $style }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
>
|
||||||
|
{{ .Inner | markdownify }}
|
||||||
|
</h{{ $heading }}>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for displaying a hero subtitle.
|
||||||
|
|
||||||
|
@param {string} style The style of the subtitle.
|
||||||
|
|
||||||
|
@example {{< hextra/hero-subtitle >}}{{< /hextra/hero-subtitle >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $style := .Get "style" -}}
|
||||||
|
|
||||||
|
<p
|
||||||
|
class="not-prose hx:text-xl hx:text-gray-600 hx:dark:text-gray-400 hx:sm:text-xl"
|
||||||
|
{{ with $style }}style="{{ . | safeCSS }}"{{ end }}
|
||||||
|
>
|
||||||
|
{{ .Inner | markdownify }}
|
||||||
|
</p>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{- /*
|
||||||
|
Create an icon.
|
||||||
|
|
||||||
|
@param {string} name The name of the icon.
|
||||||
|
@param {string} attributes The attributes of the icon.
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
@param {string} 0 The name of the icon.
|
||||||
|
|
||||||
|
@example {{< icon name="github" >}}
|
||||||
|
@example {{< icon "github" >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $name := .Get "name" | default (.Get 0) -}}
|
||||||
|
{{- $siteData := partial "utils/hugo-compat/site-data.html" . -}}
|
||||||
|
{{- $icon := index $siteData.icons $name -}}
|
||||||
|
{{- $attributes := .Get "attributes" | default "height=1em"}}
|
||||||
|
|
||||||
|
{{- if not $icon -}}
|
||||||
|
{{ errorf "icon %q not found" $name }}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $icon = replaceRE "<svg" (printf "<svg %s" $attributes) $icon -}}
|
||||||
|
|
||||||
|
<span class="hx:inline-block hx:align-text-bottom hextra-icon">
|
||||||
|
{{- $icon | safeHTML -}}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{- /*
|
||||||
|
https://github.com/gohugoio/gohugoioTheme/blob/master/layouts/shortcodes/include.html
|
||||||
|
|
||||||
|
Renders the page using the RenderShortcode method on the Page object.
|
||||||
|
|
||||||
|
You must call this shortcode using the {{% %}} notation.
|
||||||
|
|
||||||
|
@param {string} (positional parameter 0) The path to the page, relative to the content directory.
|
||||||
|
@returns template.HTML
|
||||||
|
|
||||||
|
@example {{% include "functions/_common/glob-patterns" %}}
|
||||||
|
*/}}
|
||||||
|
|
||||||
|
{{- with .Get 0 }}
|
||||||
|
{{- with site.GetPage . }}
|
||||||
|
{{- .RenderShortcodes }}
|
||||||
|
{{- else }}
|
||||||
|
{{- errorf "The %q shortcode was unable to find %q. See %s" $.Name . $.Position }}
|
||||||
|
{{- end }}
|
||||||
|
{{- else }}
|
||||||
|
{{- errorf "The %q shortcode requires a positional parameter indicating the path of the file to include. See %s" .Name .Position }}
|
||||||
|
{{- end }}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
{{- /*
|
||||||
|
Render Jupyter Notebook
|
||||||
|
|
||||||
|
@param {string} 0 The path of the Jupyter Notebook.
|
||||||
|
|
||||||
|
@example {{% jupyter "notebook.ipynb" %}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $path := .Get 0 -}}
|
||||||
|
{{- $data := "" -}}
|
||||||
|
{{- $page := .Page -}}
|
||||||
|
|
||||||
|
{{- $isLocal := not (urls.Parse $path).Scheme -}}
|
||||||
|
{{- $isPage := and (eq .Page.Kind "page") (not .Page.BundleType) -}}
|
||||||
|
|
||||||
|
{{/* https://gohugo.io/functions/transform/unmarshal/ */}}
|
||||||
|
{{- if (not $isLocal) -}}
|
||||||
|
{{- with resources.GetRemote $path -}}
|
||||||
|
{{- with unmarshal .Content -}}{{- $data = . -}}{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Remote resource not found: %s" $path -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if (not $isPage) -}}
|
||||||
|
{{- with .Page.Resources.Get $path -}}
|
||||||
|
{{- with unmarshal .Content -}}{{- $data = . -}}{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Local resource not found: %s" $path -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- with resources.Get $path -}}
|
||||||
|
{{- with unmarshal .Content -}}{{- $data = . -}}{{- end -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- errorf "Local resource not found: %s" $path -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $language := index $data "metadata" "language_info" "name" | default "python" -}}
|
||||||
|
|
||||||
|
{{- with index $data "cells" -}}
|
||||||
|
{{- range $cell := . -}}
|
||||||
|
{{- if eq (index $cell "cell_type") "code" -}}
|
||||||
|
{{- $source := index $cell "source" -}}
|
||||||
|
{{- $sourceContent := (cond (reflect.IsSlice $source) (delimit $source "") $source) -}}
|
||||||
|
{{- with ($sourceContent | strings.Chomp) -}}
|
||||||
|
{{ (printf "\n\n```%s\n%s\n```\n" $language .) | safeHTML -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
<div class="hextra-jupyter-code-cell hextra-scrollbar">
|
||||||
|
{{- $outputs := index $cell "outputs" -}}
|
||||||
|
{{- with $outputs -}}
|
||||||
|
<div class="hextra-jupyter-code-cell-outputs-container">
|
||||||
|
<div class="hextra-jupyter-code-cell-outputs" tabindex="0">
|
||||||
|
{{- range $output := . -}}
|
||||||
|
{{- if eq (index $output "output_type") "display_data" -}}
|
||||||
|
{{- $data := index $output "data" -}}
|
||||||
|
{{- $image := index $data "image/png" -}}
|
||||||
|
{{- if $image -}}
|
||||||
|
<img src="data:image/png;base64,{{- $image -}}" alt="image" />
|
||||||
|
{{- end -}}
|
||||||
|
{{- else if eq (index $output "output_type") "stream" -}}
|
||||||
|
{{- $text := index $output "text" -}}
|
||||||
|
{{- $textContent := (cond (reflect.IsSlice $text) (delimit $text "") $text) -}}
|
||||||
|
<pre class="not-prose">{{- $textContent -}}</pre>
|
||||||
|
{{- else if eq (index $output "output_type") "execute_result" -}}
|
||||||
|
{{- $data := index $output "data" -}}
|
||||||
|
{{- $text := index $data "text/plain" -}}
|
||||||
|
{{- $textContent := (cond (reflect.IsSlice $text) (delimit $text "") $text) -}}
|
||||||
|
<pre class="not-prose">{{- $textContent -}}</pre>
|
||||||
|
{{- $html := index $data "text/html" -}}
|
||||||
|
{{- if $html -}}
|
||||||
|
{{- $htmlText := delimit $html "" -}}
|
||||||
|
<div>
|
||||||
|
{{- $htmlText | safeHTML -}}
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end -}}
|
||||||
|
</div>
|
||||||
|
{{- else if eq (index $cell "cell_type") "markdown" -}}
|
||||||
|
{{- $source := index $cell "source" }}
|
||||||
|
{{- $sourceContent := (cond (reflect.IsSlice $source) (delimit $source "") $source) }}
|
||||||
|
{{ (printf "\n%s\n" $sourceContent) | safeHTML }}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{- /*
|
||||||
|
Shortcode to include a PDF file in a page.
|
||||||
|
|
||||||
|
@param {string} 0 The path to the PDF file.
|
||||||
|
|
||||||
|
@example {{< pdf "path/to/file.pdf" >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $path := .Get 0 -}}
|
||||||
|
{{- $url := partial "utils/file-path" (dict "page" .Page "path" $path) -}}
|
||||||
|
|
||||||
|
|
||||||
|
<div class="hextra-pdf">
|
||||||
|
<iframe src="{{ $url | safeURL }}" width="100%" style="min-height: 32rem;" frameborder="0" title="{{ (T "pdfViewer") | default "PDF viewer" }}"></iframe>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{{- /*
|
||||||
|
A shortcode for creating a step list.
|
||||||
|
|
||||||
|
@example {{% steps %}}{{% /steps %}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
<div class="hextra-steps hx:ml-4 hx:mb-12 hx:ltr:border-l hx:rtl:border-r hx:border-gray-200 hx:ltr:pl-6 hx:rtl:pr-6 hx:dark:border-neutral-800 [counter-reset:step]">
|
||||||
|
{{- .Inner -}}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{- /*
|
||||||
|
Create a tab.
|
||||||
|
|
||||||
|
@param {string} name The name of the tab.
|
||||||
|
@param {string} icon The icon of the tab.
|
||||||
|
@param {string} selected Whether the tab is selected.
|
||||||
|
|
||||||
|
@example {{< tab name="Foo" icon="document-text" selected=true >}}content{{< /tab >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- $name := .Get "name" | default (printf "Tab %d" .Ordinal) -}}
|
||||||
|
|
||||||
|
{{- $icon := .Get "icon" -}}
|
||||||
|
|
||||||
|
{{- $selected := .Get "selected" -}}
|
||||||
|
{{- if .Parent.Get "defaultIndex" -}}
|
||||||
|
{{- $selected = eq .Ordinal (int (.Parent.Get "defaultIndex")) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $tabs := .Parent.Store.Get "tabs" | default slice -}}
|
||||||
|
{{ .Parent.Store.Set "tabs" ($tabs | append (dict
|
||||||
|
"id" .Ordinal
|
||||||
|
"name" $name
|
||||||
|
"icon" $icon
|
||||||
|
"content" .InnerDeindent
|
||||||
|
"selected" $selected
|
||||||
|
))
|
||||||
|
-}}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
{{- /*
|
||||||
|
Create a tabbed interface with the given items.
|
||||||
|
|
||||||
|
@example {{< tabs >}}...{{< /tabs >}}
|
||||||
|
*/ -}}
|
||||||
|
|
||||||
|
{{- /* Unused, but required for the shortcode to work. */ -}}
|
||||||
|
{{- .Inner -}}
|
||||||
|
|
||||||
|
{{- /* Enable syncing of tabs across the page. */ -}}
|
||||||
|
{{- $enableSync := false -}}
|
||||||
|
{{- if or (eq .Page.Params.tabs.sync false) (eq .Page.Params.tabs.sync true) -}}
|
||||||
|
{{- $enableSync = .Page.Params.tabs.sync -}}
|
||||||
|
{{- else -}}
|
||||||
|
{{- $enableSync = site.Params.page.tabs.sync | default false -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $tabs := ($.Store.Get "tabs") | default slice -}}
|
||||||
|
|
||||||
|
{{- /* Compatibility with previous parameter "items". */ -}}
|
||||||
|
{{- if .Get "defaultIndex" -}}
|
||||||
|
{{- warnf "The 'defaultIndex' parameter of the 'tabs' shortcode is deprecated. Please use 'selected' on 'tab' instead." -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- if .Get "items" -}}
|
||||||
|
{{- warnf "The 'items' parameter of the 'tabs' shortcode is deprecated. Please use 'name' on 'tab' instead." -}}
|
||||||
|
|
||||||
|
{{- $items := split (.Get "items") "," -}}
|
||||||
|
|
||||||
|
{{- $temp := slice -}}
|
||||||
|
{{- range $i, $item := $items -}}
|
||||||
|
{{- $tab := index $tabs $i -}}
|
||||||
|
{{- $temp = $temp | append (merge $tab (dict "name" $item)) -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- $tabs = $temp -}}
|
||||||
|
{{- end -}}
|
||||||
|
|
||||||
|
{{- partial "shortcodes/tabs" (dict "tabs" $tabs "enableSync" $enableSync "id" .Ordinal) -}}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user