Update: add Prolog translation, 4A system design, Mermaid, Hextra partials & blog articles

This commit is contained in:
claw
2026-05-18 14:09:14 +08:00
parent 826a74ed4e
commit 829313765e
101 changed files with 8087 additions and 44 deletions
+388
View File
@@ -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只能辅助分析,最终结论需人工验证
+477
View File
@@ -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播放器 | 自有格式播放器 | 回放流畅度+存储效率对比 |
+382
View File
@@ -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配置) | 可能硬编码 | 配置灵活度对比 |
| 账号同步 | 适配器模式对接多源 | 可能单源集中 | 同步延迟和完整性对比 |
+160
View File
@@ -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人即可启动 |
| 与现网系统对接兼容问题 | 中 | 高 | 提前识别对接点,使用适配器模式隔离变更 |
+532
View File
@@ -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级 | 代理级更底层 | 高危命令漏报率对比 |
+3 -3
View File
@@ -103,10 +103,10 @@ hugo server -t terminal
### 品牌与图标
- [ ] FK 手写书法风格图标,黑底 `#111`,白字
- [x] FK 手写书法风格图标,黑底 `#111`,白字
- [x] 浅色/深色双版图标(`logo.svg` / `logo-dark.svg`)
- [x] 导航栏仅显示图标,隐藏标题文字,40×40px
- [x] 多平台 favicon 适配
- [ ] 多平台 favicon 适配
- [ ] 自定义 OG 社交分享图(1200×630)
- [ ] iOS/Android 触屏图标
@@ -115,7 +115,7 @@ hugo server -t terminal
- [x] Hero 标题:**FuKun**
- [x] 副标题:*FuKun.Net — Tech notes & life*
- [x] 最新文章列表(6 篇)
- [x] **Slogan 重写** — 参照 Dan Koe 的文案风格:
- [ ] **Slogan 重写** — 参照 Dan Koe 的文案风格:
- 一句话价值主张(如 "The Art of..." / "How to..." 句式)
- 个人使命宣言
- 读者能获得什么(结果导向,而非信息罗列)
+2 -25
View File
@@ -85,8 +85,7 @@ html.dark .theme-toggle-light {
width: 0.6em;
height: 1.1em;
background: #999;
vertical-align: text-bottom;
transform: translateY(-2px);
vertical-align: text-bottom; transform: translateY(-2px);
margin-left: 1px;
animation: vim-blink 0.8s step-end infinite;
}
@@ -103,26 +102,4 @@ html.dark .theme-toggle-light {
background: #ccc;
}
/* 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;
}
}
/* Hide current page title in breadcrumb */
+2024
View File
File diff suppressed because one or more lines are too long
+6 -6
View File
@@ -2,14 +2,14 @@
title: FuKun
layout: hextra-home
---
{{< hextra/hero-subtitle >}}
{{< hextra-hero-subtitle >}}
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
View File
@@ -1,23 +1,31 @@
---
---
{{< hextra/hero-subtitle >}}
{{< hextra-hero-subtitle >}}
Fukun.Net/about
{{< /hextra/hero-subtitle >}}
{{< /hextra-hero-subtitle >}}
我是傅堃。
个人开发者,央企TL,新晋奶爸。当然大部分时间精力贡献给了体制内职业发展,IT运维智能化、营业计费系统建设、大规模IT资源池安全能力建设、企业身份账号权限治理,挺有意思的。
央企TL,个人开发者,新晋奶爸。当然大部分时间精力还是贡献给了体制内的职业发展,干过IT运维智能化、营业计费系统建设、大规模IT资源池安全能力建设、企业身份账号权限治理,挺有意思的。
这个站点与大家探讨的重点不是道与术,更多是去探索**自我**的存在。人其实很难承认被所谓靠努力成为的角色所定义,你我在本质上与小时候有多大区别呢。如果你也想在日常的繁杂中找一个属于自我的空间,挑战落地一些奇思妙想,或是研究一些稀奇古怪,这里的文章会给你启发。
这个站点主要讨论的不是道与术,是本应凌驾于身份角色之上的**自我**。总觉得人其实很难承认自己被所谓靠自己努力成为的身份角色定义,从出生起人的单一身份很难未维持超过5年,情绪有所触动时与五岁时其实没多大区别。
由于公司管理制度,我不在业余取酬,不回复商务合作邮件。
如果你也想在日常的繁杂中找一个属于自我的空间,挑战落地一些奇思妙想,或是研究一些稀奇古怪,这里的文章会给你启发。
由于公司管理制度,我不可以参与盈利项目,所以无法接洽商务合作。
驾驶教练机拉杆起飞的那一刻/宇治城的小溪边被斜阳刺痛双眼的那一刻/在广汕国道上骑自行车听到路旁大爷大喊加油的那一刻/我清楚的感受到我是我自己
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.
+2 -2
View File
@@ -1,6 +1,6 @@
---
linkTitle: FuKun.Net/Blog
---
{{< hextra/hero-subtitle >}}
{{< hextra-hero-subtitle >}}
Fukun.Net/blog
{{< /hextra/hero-subtitle >}}
{{< /hextra-hero-subtitle >}}
+116
View File
@@ -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。这也是为什么从大厂到个人开发者,都在往这个方向猛押注——不是跟风,是因为这里真的能看到产出。
+532
View File
@@ -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/
> 本文为中文翻译转载。
+4
View File
@@ -136,6 +136,10 @@ params:
enable: true
lineNumbers: false
# Mermaid 流程图(本地加载,避免 CDN 连接问题)
mermaid:
js: js/mermaid.min.js
# Open Graph / SEO 增强
opengraph:
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" . -}}
+81
View File
@@ -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>
+326
View File
@@ -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 -}}
+7
View File
@@ -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>
+91
View File
@@ -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 -}}
+15
View File
@@ -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>
+28
View File
@@ -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