refactor: 模块化重构 generator 和 runtime
This commit is contained in:
62
scripts/build-runtime.js
Normal file
62
scripts/build-runtime.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function ensureDir(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let esbuild;
|
||||
try {
|
||||
esbuild = require('esbuild');
|
||||
} catch (error) {
|
||||
console.error('未找到 esbuild,请先执行 npm install。');
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const projectRoot = path.resolve(__dirname, '..');
|
||||
const entry = path.join(projectRoot, 'src', 'runtime', 'index.js');
|
||||
const outFile = path.join(projectRoot, 'dist', 'script.js');
|
||||
|
||||
if (!fs.existsSync(entry)) {
|
||||
console.error(`运行时入口不存在:${path.relative(projectRoot, entry)}`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
ensureDir(path.dirname(outFile));
|
||||
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entry],
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'iife',
|
||||
target: ['es2018'],
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
legalComments: 'none',
|
||||
metafile: true,
|
||||
logLevel: 'info',
|
||||
});
|
||||
|
||||
const outputs = result && result.metafile && result.metafile.outputs ? result.metafile.outputs : null;
|
||||
const outKey = outputs ? Object.keys(outputs).find((k) => k.endsWith('dist/script.js')) : '';
|
||||
const bytes = outKey && outputs && outputs[outKey] ? outputs[outKey].bytes : 0;
|
||||
if (bytes) {
|
||||
console.log(`✅ runtime bundle 完成:dist/script.js (${bytes} bytes)`);
|
||||
} else {
|
||||
console.log('✅ runtime bundle 完成:dist/script.js');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ runtime bundle 失败(禁止回退旧产物):', error && error.message ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -149,8 +149,8 @@ function shouldCheckFile(filePath) {
|
||||
|
||||
if (normalized === 'package-lock.json') return false;
|
||||
|
||||
// 这两个文件历史上未统一为 Prettier 风格;避免为了启用检查产生巨量格式化 diff
|
||||
if (normalized === 'src/generator.js' || normalized === 'src/script.js') return false;
|
||||
// 这些文件历史上未统一为 Prettier 风格;避免为了启用检查产生巨量格式化 diff
|
||||
if (normalized === 'src/generator/main.js' || normalized === 'src/runtime/index.js') return false;
|
||||
|
||||
// 与现有 npm scripts 的检查范围对齐:不检查 docs/ 与 templates/
|
||||
const allowedRoots = ['src/', 'scripts/', 'test/', '.github/', 'config/'];
|
||||
|
||||
533
src/README.md
533
src/README.md
@@ -2,112 +2,487 @@
|
||||
|
||||
## 目录
|
||||
|
||||
- [目录概述](#目录概述)
|
||||
- [源代码结构分工](#源代码结构分工)
|
||||
- [根目录函数(核心功能)](#根目录函数核心功能)
|
||||
- [helpers 目录函数(辅助功能)](#helpers-目录函数辅助功能)
|
||||
- [函数职责分工](#函数职责分工)
|
||||
- [核心函数(根目录)](#核心函数根目录)
|
||||
- [辅助函数(helpers目录)](#辅助函数helpers目录)
|
||||
- [扩展和修改指南](#扩展和修改指南)
|
||||
- [添加新的核心功能](#添加新的核心功能)
|
||||
- [添加新的辅助函数](#添加新的辅助函数)
|
||||
- [架构概述](#架构概述)
|
||||
- [目录结构](#目录结构)
|
||||
- [生成端(generator)](#生成端generator)
|
||||
- [运行时(runtime)](#运行时runtime)
|
||||
- [辅助函数(helpers)](#辅助函数helpers)
|
||||
- [模块化开发规范](#模块化开发规范)
|
||||
- [职责单一性原则](#职责单一性原则)
|
||||
- [文件大小规范](#文件大小规范)
|
||||
- [命名规范](#命名规范)
|
||||
- [依赖管理](#依赖管理)
|
||||
- [开发指南](#开发指南)
|
||||
- [添加新模块](#添加新模块)
|
||||
- [修改现有模块](#修改现有模块)
|
||||
- [测试要求](#测试要求)
|
||||
|
||||
## 目录概述
|
||||
## 架构概述
|
||||
|
||||
`src` 目录包含 MeNav 项目的所有源代码,按照功能和职责进行了明确的分层组织:
|
||||
MeNav 采用**模块化架构**,将代码按职责拆分为独立的、可维护的模块。整体分为三个主要部分:
|
||||
|
||||
- 根目录:核心功能实现
|
||||
- `helpers` 子目录:辅助功能实现
|
||||
1. **生成端(generator)**:构建期代码,负责将配置转换为静态 HTML
|
||||
2. **运行时(runtime)**:浏览器端代码,负责用户交互和动态功能
|
||||
3. **辅助函数(helpers)**:Handlebars 模板辅助函数
|
||||
|
||||
## 源代码结构分工
|
||||
### 架构原则
|
||||
|
||||
### 根目录函数(核心功能)
|
||||
- ✅ **职责单一**:每个模块只负责一件事
|
||||
- ✅ **高内聚低耦合**:模块内部紧密相关,模块间松散依赖
|
||||
- ✅ **可测试性**:每个模块都可以独立测试
|
||||
- ✅ **可维护性**:清晰的目录结构和命名规范
|
||||
|
||||
根目录下的js文件实现了应用的核心功能,每个文件负责特定领域:
|
||||
## 目录结构
|
||||
|
||||
- **generator.js**: 站点生成器的核心实现
|
||||
- 负责将配置文件转换为HTML网站
|
||||
- 处理模板渲染、配置解析和文件输出
|
||||
- 控制整个生成流程
|
||||
```text
|
||||
src/
|
||||
├── generator.js # 生成端薄入口(14行)
|
||||
├── generator/ # 生成端实现
|
||||
│ ├── main.js # 主流程控制(301行)
|
||||
│ ├── cache/ # 缓存处理
|
||||
│ │ ├── articles.js # 文章缓存(159行)
|
||||
│ │ └── projects.js # 项目缓存(135行)
|
||||
│ ├── config/ # 配置管理
|
||||
│ │ ├── index.js # 配置入口(61行)
|
||||
│ │ ├── loader.js # 配置加载(89行)
|
||||
│ │ ├── validator.js # 配置验证(90行)
|
||||
│ │ ├── resolver.js # 配置解析(146行)
|
||||
│ │ └── slugs.js # Slug 生成(43行)
|
||||
│ ├── html/ # HTML 生成
|
||||
│ │ ├── 404.js # 404 页面(94行)
|
||||
│ │ ├── components.js # 组件生成(208行)
|
||||
│ │ ├── fonts.js # 字体处理(155行)
|
||||
│ │ └── page-data.js # 页面数据准备(161行)
|
||||
│ ├── template/ # 模板引擎
|
||||
│ │ └── engine.js # Handlebars 引擎(120行)
|
||||
│ └── utils/ # 工具函数
|
||||
│ ├── html.js # HTML 工具(17行)
|
||||
│ ├── pageMeta.js # 页面元信息(101行)
|
||||
│ └── sites.js # 站点处理(35行)
|
||||
├── runtime/ # 运行时实现
|
||||
│ ├── index.js # 运行时入口(18行)
|
||||
│ ├── shared.js # 共享工具(142行)
|
||||
│ ├── tooltip.js # 提示框(115行)
|
||||
│ ├── app/ # 应用逻辑
|
||||
│ │ ├── index.js # 应用入口(112行)
|
||||
│ │ ├── routing.js # 路由管理(403行)
|
||||
│ │ ├── search.js # 搜索功能(440行)
|
||||
│ │ ├── searchEngines.js # 搜索引擎(25行)
|
||||
│ │ ├── ui.js # UI 交互(181行)
|
||||
│ │ └── search/
|
||||
│ │ └── highlight.js # 搜索高亮(90行)
|
||||
│ ├── menav/ # 扩展 API
|
||||
│ │ ├── index.js # API 入口(70行)
|
||||
│ │ ├── addElement.js # 添加元素(351行)
|
||||
│ │ ├── updateElement.js # 更新元素(166行)
|
||||
│ │ ├── removeElement.js # 删除元素(26行)
|
||||
│ │ ├── getAllElements.js# 获取元素(11行)
|
||||
│ │ ├── getConfig.js # 获取配置(25行)
|
||||
│ │ └── events.js # 事件系统(34行)
|
||||
│ └── nested/ # 嵌套书签
|
||||
│ └── index.js # 嵌套功能(230行)
|
||||
└── helpers/ # Handlebars 辅助函数
|
||||
├── index.js # 辅助函数注册
|
||||
├── formatters.js # 格式化函数
|
||||
├── conditions.js # 条件判断
|
||||
└── utils.js # 工具函数
|
||||
```
|
||||
|
||||
- **script.js**: 客户端功能的核心实现
|
||||
- 处理用户界面交互逻辑
|
||||
- 实现搜索、导航、主题切换等功能
|
||||
- 管理页面状态和响应式布局
|
||||
## 生成端(generator)
|
||||
|
||||
- **bookmark-processor.js**: 书签处理工具
|
||||
- 转换浏览器书签为MeNav配置格式
|
||||
- 提供书签导入功能
|
||||
### 生成端职责
|
||||
|
||||
将 YAML 配置文件转换为静态 HTML 网站。
|
||||
|
||||
### helpers 目录函数(辅助功能)
|
||||
### 生成端核心模块
|
||||
|
||||
`helpers` 目录下的函数提供辅助性支持,主要服务于Handlebars模板系统:
|
||||
#### config/ - 配置管理
|
||||
|
||||
- **formatters.js**: 格式化函数
|
||||
- 日期格式化
|
||||
- 文本处理
|
||||
- 内容展示辅助
|
||||
- **loader.js**:从文件系统加载 YAML 配置
|
||||
- **validator.js**:验证配置合法性,填充默认值
|
||||
- **resolver.js**:解析配置,准备渲染数据
|
||||
- **slugs.js**:为分类生成唯一标识符
|
||||
|
||||
- **conditions.js**: 条件判断函数
|
||||
- 比较操作
|
||||
- 逻辑运算
|
||||
- 条件检查
|
||||
#### html/ - HTML 生成
|
||||
|
||||
- **utils.js**: 工具函数
|
||||
- 数组处理
|
||||
- 对象操作
|
||||
- 通用辅助方法
|
||||
- **page-data.js**:准备页面渲染数据(处理 projects/articles/bookmarks 特殊逻辑)
|
||||
- **components.js**:生成导航、分类、社交链接等组件
|
||||
- **fonts.js**:处理字体链接和 CSS
|
||||
- **404.js**:生成 404 页面
|
||||
|
||||
- **index.js**: 助手函数注册中心
|
||||
- 统一导入所有助手函数
|
||||
- 提供注册函数到Handlebars实例的方法
|
||||
- 定义核心HTML处理函数
|
||||
#### cache/ - 缓存处理
|
||||
|
||||
## 函数职责分工
|
||||
- **articles.js**:处理 RSS 文章缓存
|
||||
- **projects.js**:处理 GitHub 项目缓存
|
||||
|
||||
### 核心函数(根目录)
|
||||
#### template/ - 模板引擎
|
||||
|
||||
根目录下的函数负责"做什么"——实现具体的业务逻辑和功能:
|
||||
- **engine.js**:Handlebars 模板加载和渲染
|
||||
|
||||
- 应用入口和流程控制
|
||||
- 用户交互响应
|
||||
- 数据处理和转换
|
||||
- 文件生成和输出
|
||||
#### utils/ - 工具函数
|
||||
|
||||
这些函数通常:
|
||||
- 直接面向最终用户需求
|
||||
- 控制程序主流程
|
||||
- 调用辅助函数完成特定任务
|
||||
- **pageMeta.js**:获取页面元信息(git 时间戳等)
|
||||
- **sites.js**:递归收集站点数据
|
||||
- **html.js**:HTML 处理工具
|
||||
|
||||
### 辅助函数(helpers目录)
|
||||
### 生成端入口文件
|
||||
|
||||
helpers目录下的函数负责"怎么做"——提供可重用的工具方法:
|
||||
- **generator.js**:薄入口,re-export `generator/main.js`
|
||||
- **main.js**:主流程控制,协调各模块完成构建
|
||||
|
||||
- 为模板渲染提供扩展能力
|
||||
- 处理数据格式化和转换
|
||||
- 提供通用的条件判断逻辑
|
||||
- 实现常见的工具操作
|
||||
## 运行时(runtime)
|
||||
|
||||
这些函数通常:
|
||||
- 高度重用性和通用性
|
||||
- 专注于单一职责
|
||||
- 被核心函数调用
|
||||
### 运行时职责
|
||||
|
||||
## 扩展和修改指南
|
||||
在浏览器中提供用户交互功能和扩展 API。
|
||||
|
||||
### 添加新的核心功能
|
||||
### 运行时核心模块
|
||||
|
||||
如需添加新的核心功能,应在src根目录创建新的js文件或修改现有文件,确保:
|
||||
- 文件名清晰反映功能用途
|
||||
- 保持单一责任原则
|
||||
- 适当调用辅助函数,避免重复实现通用功能
|
||||
#### app/ - 应用逻辑
|
||||
|
||||
### 添加新的辅助函数
|
||||
- **routing.js**:页面路由、URL 处理、页面切换
|
||||
- **search.js**:搜索索引、搜索逻辑、搜索引擎切换
|
||||
- **ui.js**:UI 交互(侧边栏、主题切换、滚动等)
|
||||
- **searchEngines.js**:外部搜索引擎配置
|
||||
|
||||
如需添加新的辅助函数,应在helpers目录下相应文件中添加,并在index.js中注册:
|
||||
- 按功能类型放入正确的文件(formatters/conditions/utils)
|
||||
- 编写清晰的JSDoc注释
|
||||
- 更新README.md文档
|
||||
- 在index.js中添加导出和注册
|
||||
#### menav/ - 扩展 API
|
||||
|
||||
提供 `window.MeNav` API,供浏览器扩展使用:
|
||||
|
||||
- **addElement.js**:添加站点/分类/页面
|
||||
- **updateElement.js**:更新元素属性
|
||||
- **removeElement.js**:删除元素
|
||||
- **getAllElements.js**:获取所有元素
|
||||
- **getConfig.js**:获取配置
|
||||
- **events.js**:事件系统(on/off/emit)
|
||||
|
||||
#### nested/ - 嵌套书签
|
||||
|
||||
- **index.js**:嵌套书签功能(展开/折叠/结构管理)
|
||||
|
||||
### 运行时入口文件
|
||||
|
||||
- **index.js**:运行时入口,初始化所有模块
|
||||
- **shared.js**:共享工具函数(URL 校验、class 清洗等)
|
||||
- **tooltip.js**:站点卡片悬停提示
|
||||
|
||||
### 构建流程
|
||||
|
||||
运行时代码通过 `scripts/build-runtime.js` 打包:
|
||||
|
||||
- 入口:`src/runtime/index.js`
|
||||
- 输出:`dist/script.js`(IIFE 格式,单文件)
|
||||
- 工具:esbuild
|
||||
|
||||
## 辅助函数(helpers)
|
||||
|
||||
### 辅助函数职责
|
||||
|
||||
为 Handlebars 模板提供辅助函数。
|
||||
|
||||
### 辅助函数模块
|
||||
|
||||
- **formatters.js**:日期格式化、文本处理
|
||||
- **conditions.js**:条件判断、逻辑运算
|
||||
- **utils.js**:数组处理、对象操作、URL 校验
|
||||
- **index.js**:注册所有辅助函数到 Handlebars
|
||||
|
||||
## 模块化开发规范
|
||||
|
||||
### 职责单一性原则
|
||||
|
||||
**定义**:一个模块应该只负责一件事情,只有一个改变的理由。
|
||||
|
||||
**判断方法**:
|
||||
|
||||
1. 能用一句话描述模块职责
|
||||
2. 只有一个理由会修改这个模块
|
||||
3. 函数/变量名清晰反映职责
|
||||
|
||||
**示例**:
|
||||
|
||||
✅ **好的拆分**:
|
||||
|
||||
```javascript
|
||||
// config/loader.js - 只负责加载配置
|
||||
function loadModularConfig(dirPath) { /* ... */ }
|
||||
|
||||
// config/validator.js - 只负责验证配置
|
||||
function validateConfig(config) { /* ... */ }
|
||||
|
||||
// config/resolver.js - 只负责解析配置
|
||||
function prepareRenderData(config) { /* ... */ }
|
||||
```
|
||||
|
||||
❌ **不好的拆分**:
|
||||
|
||||
```javascript
|
||||
// config.js - 职责混杂
|
||||
function processConfig(config) {
|
||||
// 加载、验证、解析、转换... 400 行代码
|
||||
}
|
||||
```
|
||||
|
||||
### 文件大小规范
|
||||
|
||||
**目标**:
|
||||
|
||||
- 源码文件:≤ 500 行
|
||||
- 理想大小:100-300 行
|
||||
- 入口文件:≤ 100 行(薄入口)
|
||||
|
||||
**超过 500 行时**:
|
||||
|
||||
1. 检查是否职责混杂
|
||||
2. 拆分为多个子模块
|
||||
3. 提取可复用的工具函数
|
||||
|
||||
**当前状态**:
|
||||
|
||||
- 最大文件:440 行(search.js)
|
||||
- 平均文件:~130 行
|
||||
- 33 个模块文件
|
||||
|
||||
### 命名规范
|
||||
|
||||
#### 文件命名
|
||||
|
||||
- 使用 kebab-case:`page-data.js`、`search-engine.js`
|
||||
- 名称反映职责:`loader.js`、`validator.js`、`resolver.js`
|
||||
- 入口文件:`index.js`
|
||||
|
||||
#### 函数命名
|
||||
|
||||
- 使用 camelCase:`loadConfig`、`validateConfig`
|
||||
- 动词开头:`get`、`set`、`load`、`validate`、`prepare`
|
||||
- 清晰描述功能:`preparePageData`、`assignCategorySlugs`
|
||||
|
||||
#### 目录命名
|
||||
|
||||
- 使用 kebab-case:`page-data/`、`search-engine/`
|
||||
- 名称反映功能域:`config/`、`cache/`、`html/`
|
||||
|
||||
### 依赖管理
|
||||
|
||||
#### 导入规范
|
||||
|
||||
```javascript
|
||||
// 1. Node 内置模块
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
// 2. 第三方依赖
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
// 3. 项目内部模块(相对路径)
|
||||
const { loadConfig } = require('./config');
|
||||
const { renderTemplate } = require('./template/engine');
|
||||
```
|
||||
|
||||
#### 导出规范
|
||||
|
||||
```javascript
|
||||
// 单个导出
|
||||
module.exports = function initSearch(state, dom) { /* ... */ };
|
||||
|
||||
// 多个导出
|
||||
module.exports = {
|
||||
loadConfig,
|
||||
validateConfig,
|
||||
prepareRenderData,
|
||||
};
|
||||
```
|
||||
|
||||
#### 循环依赖
|
||||
|
||||
- ❌ 避免循环依赖
|
||||
- ✅ 通过依赖注入解决
|
||||
- ✅ 提取共享代码到独立模块
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 添加新模块
|
||||
|
||||
#### 1. 确定模块位置
|
||||
|
||||
**生成端**(构建期代码):
|
||||
|
||||
```text
|
||||
src/generator/
|
||||
├── cache/ # 缓存处理
|
||||
├── config/ # 配置管理
|
||||
├── html/ # HTML 生成
|
||||
├── template/ # 模板引擎
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
|
||||
**运行时**(浏览器代码):
|
||||
|
||||
```text
|
||||
src/runtime/
|
||||
├── app/ # 应用逻辑
|
||||
├── menav/ # 扩展 API
|
||||
└── nested/ # 嵌套书签
|
||||
```
|
||||
|
||||
#### 2. 创建模块文件
|
||||
|
||||
```javascript
|
||||
// src/generator/config/new-module.js
|
||||
|
||||
/**
|
||||
* 模块描述
|
||||
* @param {Object} param - 参数说明
|
||||
* @returns {Object} 返回值说明
|
||||
*/
|
||||
function newFunction(param) {
|
||||
// 实现逻辑
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
newFunction,
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. 更新入口文件
|
||||
|
||||
```javascript
|
||||
// src/generator/config/index.js
|
||||
const { newFunction } = require('./new-module');
|
||||
|
||||
module.exports = {
|
||||
// ... 其他导出
|
||||
newFunction,
|
||||
};
|
||||
```
|
||||
|
||||
#### 4. 添加测试
|
||||
|
||||
```javascript
|
||||
// test/new-module.test.js
|
||||
const { newFunction } = require('../src/generator/config/new-module');
|
||||
|
||||
test('newFunction 应该...', () => {
|
||||
const result = newFunction(input);
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
```
|
||||
|
||||
### 修改现有模块
|
||||
|
||||
#### 1. 理解模块职责
|
||||
|
||||
- 阅读模块注释
|
||||
- 查看函数签名
|
||||
- 理解依赖关系
|
||||
|
||||
#### 2. 保持职责单一
|
||||
|
||||
- 不要在模块中添加无关功能
|
||||
- 如果功能不匹配,创建新模块
|
||||
|
||||
#### 3. 运行测试
|
||||
|
||||
```bash
|
||||
npm test # 运行所有测试
|
||||
npm run build # 验证构建
|
||||
```
|
||||
|
||||
#### 4. 更新文档
|
||||
|
||||
- 更新函数注释
|
||||
- 更新 README(如有必要)
|
||||
|
||||
### 测试要求
|
||||
|
||||
#### 单元测试
|
||||
|
||||
- 每个模块都应有对应的测试文件
|
||||
- 测试覆盖核心功能路径
|
||||
- 使用 Node.js 内置测试框架
|
||||
|
||||
#### 集成测试
|
||||
|
||||
- 测试模块间的协作
|
||||
- 验证构建产物
|
||||
- 检查扩展契约
|
||||
|
||||
#### 测试命令
|
||||
|
||||
```bash
|
||||
npm test # 运行所有测试
|
||||
npm run lint # 代码检查
|
||||
npm run format:check # 格式检查
|
||||
```
|
||||
|
||||
## 最佳实践
|
||||
|
||||
### 1. 先读后写
|
||||
|
||||
- 修改代码前,先用 Read 工具读取文件
|
||||
- 理解现有逻辑再进行修改
|
||||
|
||||
### 2. 小步迭代
|
||||
|
||||
- 每次只改一个模块
|
||||
- 改完立即测试
|
||||
- 确认无误后再继续
|
||||
|
||||
### 3. 保持一致性
|
||||
|
||||
- 遵循现有的代码风格
|
||||
- 使用相同的命名规范
|
||||
- 保持目录结构一致
|
||||
|
||||
### 4. 文档同步
|
||||
|
||||
- 代码变更时更新注释
|
||||
- 重要改动更新 README
|
||||
- 保持文档与代码一致
|
||||
|
||||
### 5. 测试先行
|
||||
|
||||
- 修改前运行测试(确保基线)
|
||||
- 修改后运行测试(验证功能)
|
||||
- 新功能添加测试(保证质量)
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 如何判断代码应该放在哪个模块?
|
||||
|
||||
**A**: 问自己三个问题:
|
||||
|
||||
1. 这段代码的职责是什么?(加载/验证/解析/渲染...)
|
||||
2. 它属于哪个功能域?(配置/缓存/HTML/模板...)
|
||||
3. 它在构建期还是运行时执行?(generator/runtime)
|
||||
|
||||
### Q: 模块太大了怎么办?
|
||||
|
||||
**A**: 拆分步骤:
|
||||
|
||||
1. 识别模块中的不同职责
|
||||
2. 为每个职责创建独立模块
|
||||
3. 更新入口文件的导入/导出
|
||||
4. 运行测试验证
|
||||
|
||||
### Q: 如何避免循环依赖?
|
||||
|
||||
**A**: 三种方法:
|
||||
|
||||
1. 提取共享代码到独立模块
|
||||
2. 使用依赖注入
|
||||
3. 重新设计模块边界
|
||||
|
||||
### Q: 什么时候应该创建新目录?
|
||||
|
||||
**A**: 当满足以下条件时:
|
||||
|
||||
1. 有 3 个以上相关模块
|
||||
2. 这些模块属于同一功能域
|
||||
3. 需要独立的入口文件(index.js)
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [P2-1 模块化重构文档](../../docs/2025-12-29_修复清单.md#p2-1-文件过大)
|
||||
- [职责单一性原则](https://en.wikipedia.org/wiki/Single-responsibility_principle)
|
||||
- [Node.js 模块系统](https://nodejs.org/api/modules.html)
|
||||
|
||||
1965
src/generator.js
1965
src/generator.js
File diff suppressed because it is too large
Load Diff
159
src/generator/cache/articles.js
vendored
Normal file
159
src/generator/cache/articles.js
vendored
Normal file
@@ -0,0 +1,159 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { collectSitesRecursively, normalizeUrlKey } = require('../utils/sites');
|
||||
|
||||
/**
|
||||
* 读取 articles 页面 RSS 缓存(Phase 2)
|
||||
* - 缓存默认放在 dev/(仓库默认 gitignore)
|
||||
* - 构建端只读缓存:缓存缺失/损坏时回退到 Phase 1(渲染来源站点分类)
|
||||
* @param {string} pageId 页面ID(用于支持多个 articles 页面的独立缓存)
|
||||
* @param {Object} config 全站配置(用于读取 site.rss.cacheDir)
|
||||
* @returns {{items: Array<Object>, meta: Object}|null}
|
||||
*/
|
||||
function tryLoadArticlesFeedCache(pageId, config) {
|
||||
if (!pageId) return null;
|
||||
|
||||
const cacheDirFromEnv = process.env.RSS_CACHE_DIR ? String(process.env.RSS_CACHE_DIR) : '';
|
||||
const cacheDirFromConfig =
|
||||
config && config.site && config.site.rss && config.site.rss.cacheDir
|
||||
? String(config.site.rss.cacheDir)
|
||||
: '';
|
||||
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
||||
|
||||
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
||||
const cachePath = path.join(cacheBaseDir, `${pageId}.feed-cache.json`);
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const articles = Array.isArray(parsed.articles) ? parsed.articles : [];
|
||||
const items = articles
|
||||
.map((a) => {
|
||||
const title = a && a.title ? String(a.title) : '';
|
||||
const url = a && a.url ? String(a.url) : '';
|
||||
if (!title || !url) return null;
|
||||
|
||||
return {
|
||||
// 兼容 site-card partial 字段
|
||||
name: title,
|
||||
url,
|
||||
icon: a && a.icon ? String(a.icon) : 'fas fa-pen',
|
||||
description: a && a.summary ? String(a.summary) : '',
|
||||
|
||||
// Phase 2 文章元信息(只读展示)
|
||||
publishedAt: a && a.publishedAt ? String(a.publishedAt) : '',
|
||||
source: a && a.source ? String(a.source) : '',
|
||||
// 文章来源站点首页 URL(用于按分类聚合展示;旧缓存可能缺失)
|
||||
sourceUrl: a && a.sourceUrl ? String(a.sourceUrl) : '',
|
||||
|
||||
// 文章链接通常应在新标签页打开
|
||||
external: true,
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
items,
|
||||
meta: {
|
||||
pageId: parsed.pageId || pageId,
|
||||
generatedAt: parsed.generatedAt || '',
|
||||
total:
|
||||
parsed.stats && Number.isFinite(parsed.stats.totalArticles)
|
||||
? parsed.stats.totalArticles
|
||||
: items.length,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[WARN] articles 缓存读取失败:${cachePath}(将回退 Phase 1)`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* articles Phase 2:按页面配置的“分类”聚合文章展示
|
||||
* - 规则:某篇文章的 sourceUrl/source 归属到其来源站点(pages/articles.yml 中配置的站点)所在的分类
|
||||
* - 兼容:旧缓存缺少 sourceUrl 时回退使用 source(站点名称)匹配
|
||||
* @param {Array<Object>} categories 页面配置 categories(可包含更深层级)
|
||||
* @param {Array<Object>} articlesItems Phase 2 文章条目(来自缓存)
|
||||
* @returns {Array<{name: string, icon: string, items: Array<Object>}>}
|
||||
*/
|
||||
function buildArticlesCategoriesByPageCategories(categories, articlesItems) {
|
||||
const safeItems = Array.isArray(articlesItems) ? articlesItems : [];
|
||||
const safeCategories = Array.isArray(categories) ? categories : [];
|
||||
|
||||
// 若页面未配置分类,则回退为单一分类容器
|
||||
if (safeCategories.length === 0) {
|
||||
return [
|
||||
{
|
||||
name: '最新文章',
|
||||
icon: 'fas fa-rss',
|
||||
items: safeItems,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const categoryIndex = safeCategories.map((category) => {
|
||||
const sites = [];
|
||||
collectSitesRecursively(category, sites);
|
||||
|
||||
const siteUrlKeys = new Set();
|
||||
const siteNameKeys = new Set();
|
||||
sites.forEach((site) => {
|
||||
const urlKey = normalizeUrlKey(site && site.url ? String(site.url) : '');
|
||||
if (urlKey) siteUrlKeys.add(urlKey);
|
||||
const nameKey = site && site.name ? String(site.name).trim().toLowerCase() : '';
|
||||
if (nameKey) siteNameKeys.add(nameKey);
|
||||
});
|
||||
|
||||
return { category, siteUrlKeys, siteNameKeys };
|
||||
});
|
||||
|
||||
const buckets = categoryIndex.map(() => []);
|
||||
const uncategorized = [];
|
||||
|
||||
safeItems.forEach((item) => {
|
||||
const sourceUrlKey = normalizeUrlKey(item && item.sourceUrl ? String(item.sourceUrl) : '');
|
||||
const sourceNameKey = item && item.source ? String(item.source).trim().toLowerCase() : '';
|
||||
|
||||
let matchedIndex = -1;
|
||||
if (sourceUrlKey) {
|
||||
matchedIndex = categoryIndex.findIndex((idx) => idx.siteUrlKeys.has(sourceUrlKey));
|
||||
}
|
||||
if (matchedIndex < 0 && sourceNameKey) {
|
||||
matchedIndex = categoryIndex.findIndex((idx) => idx.siteNameKeys.has(sourceNameKey));
|
||||
}
|
||||
|
||||
if (matchedIndex < 0) {
|
||||
uncategorized.push(item);
|
||||
return;
|
||||
}
|
||||
|
||||
buckets[matchedIndex].push(item);
|
||||
});
|
||||
|
||||
const displayCategories = categoryIndex.map((idx, i) => ({
|
||||
name: idx.category && idx.category.name ? String(idx.category.name) : '未命名分类',
|
||||
icon: idx.category && idx.category.icon ? String(idx.category.icon) : 'fas fa-rss',
|
||||
items: buckets[i],
|
||||
}));
|
||||
|
||||
if (uncategorized.length > 0) {
|
||||
displayCategories.push({
|
||||
name: '其他',
|
||||
icon: 'fas fa-ellipsis-h',
|
||||
items: uncategorized,
|
||||
});
|
||||
}
|
||||
|
||||
return displayCategories;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tryLoadArticlesFeedCache,
|
||||
buildArticlesCategoriesByPageCategories,
|
||||
};
|
||||
|
||||
135
src/generator/cache/projects.js
vendored
Normal file
135
src/generator/cache/projects.js
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function tryLoadProjectsRepoCache(pageId, config) {
|
||||
if (!pageId) return null;
|
||||
|
||||
const cacheDirFromEnv = process.env.PROJECTS_CACHE_DIR ? String(process.env.PROJECTS_CACHE_DIR) : '';
|
||||
const cacheDirFromConfig =
|
||||
config && config.site && config.site.github && config.site.github.cacheDir
|
||||
? String(config.site.github.cacheDir)
|
||||
: '';
|
||||
const cacheDir = cacheDirFromEnv || cacheDirFromConfig || 'dev';
|
||||
|
||||
const cacheBaseDir = path.isAbsolute(cacheDir) ? cacheDir : path.join(process.cwd(), cacheDir);
|
||||
const cachePath = path.join(cacheBaseDir, `${pageId}.repo-cache.json`);
|
||||
if (!fs.existsSync(cachePath)) return null;
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(cachePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
|
||||
const repos = Array.isArray(parsed.repos) ? parsed.repos : [];
|
||||
const map = new Map();
|
||||
repos.forEach((r) => {
|
||||
const url = r && r.url ? String(r.url) : '';
|
||||
if (!url) return;
|
||||
map.set(url, {
|
||||
language: r && r.language ? String(r.language) : '',
|
||||
languageColor: r && r.languageColor ? String(r.languageColor) : '',
|
||||
stars: Number.isFinite(r && r.stars) ? r.stars : null,
|
||||
forks: Number.isFinite(r && r.forks) ? r.forks : null,
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
map,
|
||||
meta: {
|
||||
pageId: parsed.pageId || pageId,
|
||||
generatedAt: parsed.generatedAt || '',
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[WARN] projects 缓存读取失败:${cachePath}(将仅展示标题与描述)`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGithubRepoUrl(url) {
|
||||
if (!url) return '';
|
||||
try {
|
||||
const u = new URL(String(url));
|
||||
if (u.hostname.toLowerCase() !== 'github.com') return '';
|
||||
const parts = u.pathname.split('/').filter(Boolean);
|
||||
if (parts.length < 2) return '';
|
||||
const owner = parts[0];
|
||||
const repo = parts[1].replace(/\\.git$/i, '');
|
||||
if (!owner || !repo) return '';
|
||||
return `https://github.com/${owner}/${repo}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function applyRepoMetaToCategories(categories, repoMetaMap) {
|
||||
if (!Array.isArray(categories) || !(repoMetaMap instanceof Map)) return;
|
||||
|
||||
const walk = (node) => {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(walk);
|
||||
if (Array.isArray(node.groups)) node.groups.forEach(walk);
|
||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(walk);
|
||||
|
||||
if (Array.isArray(node.sites)) {
|
||||
node.sites.forEach((site) => {
|
||||
if (!site || typeof site !== 'object' || !site.url) return;
|
||||
const canonical = normalizeGithubRepoUrl(site.url);
|
||||
if (!canonical) return;
|
||||
const meta = repoMetaMap.get(canonical);
|
||||
if (!meta) return;
|
||||
|
||||
site.language = meta.language || '';
|
||||
site.languageColor = meta.languageColor || '';
|
||||
site.stars = meta.stars;
|
||||
site.forks = meta.forks;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
categories.forEach(walk);
|
||||
}
|
||||
|
||||
function normalizeGithubHeatmapColor(input) {
|
||||
const raw = String(input || '')
|
||||
.trim()
|
||||
.replace(/^#/, '');
|
||||
const color = raw.toLowerCase();
|
||||
if (/^[0-9a-f]{6}$/.test(color)) return color;
|
||||
if (/^[0-9a-f]{3}$/.test(color)) return color;
|
||||
return '339af0';
|
||||
}
|
||||
|
||||
function getGithubUsernameFromConfig(config) {
|
||||
const username =
|
||||
config && config.site && config.site.github && config.site.github.username
|
||||
? String(config.site.github.username).trim()
|
||||
: '';
|
||||
return username;
|
||||
}
|
||||
|
||||
function buildProjectsMeta(config) {
|
||||
const username = getGithubUsernameFromConfig(config);
|
||||
if (!username) return null;
|
||||
|
||||
const color = normalizeGithubHeatmapColor(
|
||||
config && config.site && config.site.github && config.site.github.heatmapColor
|
||||
? config.site.github.heatmapColor
|
||||
: '339af0'
|
||||
);
|
||||
|
||||
return {
|
||||
heatmap: {
|
||||
username,
|
||||
profileUrl: `https://github.com/${username}`,
|
||||
imageUrl: `https://ghchart.rshah.org/${color}/${username}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
tryLoadProjectsRepoCache,
|
||||
applyRepoMetaToCategories,
|
||||
buildProjectsMeta,
|
||||
};
|
||||
|
||||
61
src/generator/config/index.js
Normal file
61
src/generator/config/index.js
Normal file
@@ -0,0 +1,61 @@
|
||||
const fs = require('node:fs');
|
||||
const { loadModularConfig } = require('./loader');
|
||||
const { ensureConfigDefaults, validateConfig } = require('./validator');
|
||||
const { prepareRenderData, MENAV_EXTENSION_CONFIG_FILE, getSubmenuForNavItem, resolveTemplateNameForPage, buildExtensionConfig } = require('./resolver');
|
||||
const { assignCategorySlugs } = require('./slugs');
|
||||
|
||||
function loadConfig() {
|
||||
let config = {
|
||||
site: {},
|
||||
navigation: [],
|
||||
fonts: {},
|
||||
profile: {},
|
||||
social: [],
|
||||
};
|
||||
|
||||
const hasUserModularConfig = fs.existsSync('config/user');
|
||||
const hasDefaultModularConfig = fs.existsSync('config/_default');
|
||||
|
||||
if (hasUserModularConfig) {
|
||||
if (!fs.existsSync('config/user/site.yml')) {
|
||||
console.error('[ERROR] 检测到 config/user/ 目录,但缺少 config/user/site.yml。');
|
||||
console.error(
|
||||
'[ERROR] 由于配置采用"完全替换"策略,系统不会从 config/_default/ 补齐缺失配置。'
|
||||
);
|
||||
console.error('[ERROR] 解决方法:先完整复制 config/_default/ 到 config/user/,再按需修改。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!fs.existsSync('config/user/pages')) {
|
||||
console.warn('[WARN] 检测到 config/user/ 目录,但缺少 config/user/pages/。部分页面内容可能为空。');
|
||||
console.warn('[WARN] 建议:复制 config/_default/pages/ 到 config/user/pages/,再按需修改。');
|
||||
}
|
||||
|
||||
config = loadModularConfig('config/user');
|
||||
} else if (hasDefaultModularConfig) {
|
||||
config = loadModularConfig('config/_default');
|
||||
} else {
|
||||
console.error('[ERROR] 未找到可用配置:缺少 config/user/ 或 config/_default/。');
|
||||
console.error('[ERROR] 本版本已不再支持旧版单文件配置(config.yml / config.yaml)。');
|
||||
console.error('[ERROR] 解决方法:使用模块化配置目录(建议从 config/_default/ 复制到 config/user/ 再修改)。');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config = ensureConfigDefaults(config);
|
||||
|
||||
if (!validateConfig(config)) {
|
||||
// 保留函数调用
|
||||
}
|
||||
|
||||
return prepareRenderData(config);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MENAV_EXTENSION_CONFIG_FILE,
|
||||
loadConfig,
|
||||
prepareRenderData,
|
||||
resolveTemplateNameForPage,
|
||||
buildExtensionConfig,
|
||||
getSubmenuForNavItem,
|
||||
assignCategorySlugs,
|
||||
};
|
||||
89
src/generator/config/loader.js
Normal file
89
src/generator/config/loader.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
function handleConfigLoadError(filePath, error) {
|
||||
console.error(`Error loading configuration from ${filePath}:`, error);
|
||||
}
|
||||
|
||||
function safeLoadYamlConfig(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileContent = fs.readFileSync(filePath, 'utf8');
|
||||
const docs = yaml.loadAll(fileContent);
|
||||
|
||||
if (docs.length === 1) {
|
||||
return docs[0];
|
||||
}
|
||||
|
||||
if (docs.length > 1) {
|
||||
console.warn(
|
||||
`Warning: Multiple documents found in ${filePath}. Using the first document only.`
|
||||
);
|
||||
return docs[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
handleConfigLoadError(filePath, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function loadModularConfig(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const config = {
|
||||
site: {},
|
||||
navigation: [],
|
||||
fonts: {},
|
||||
profile: {},
|
||||
social: [],
|
||||
categories: [],
|
||||
};
|
||||
|
||||
const siteConfigPath = path.join(dirPath, 'site.yml');
|
||||
const siteConfig = safeLoadYamlConfig(siteConfigPath);
|
||||
if (siteConfig) {
|
||||
config.site = siteConfig;
|
||||
|
||||
if (siteConfig.fonts) config.fonts = siteConfig.fonts;
|
||||
if (siteConfig.profile) config.profile = siteConfig.profile;
|
||||
if (siteConfig.social) config.social = siteConfig.social;
|
||||
if (siteConfig.icons) config.icons = siteConfig.icons;
|
||||
|
||||
if (siteConfig.navigation) {
|
||||
config.navigation = siteConfig.navigation;
|
||||
console.log('使用 site.yml 中的导航配置');
|
||||
}
|
||||
}
|
||||
|
||||
const pagesPath = path.join(dirPath, 'pages');
|
||||
if (fs.existsSync(pagesPath)) {
|
||||
const files = fs
|
||||
.readdirSync(pagesPath)
|
||||
.filter((file) => file.endsWith('.yml') || file.endsWith('.yaml'));
|
||||
|
||||
files.forEach((file) => {
|
||||
const filePath = path.join(pagesPath, file);
|
||||
const fileConfig = safeLoadYamlConfig(filePath);
|
||||
|
||||
if (fileConfig) {
|
||||
const configKey = path.basename(file, path.extname(file));
|
||||
config[configKey] = fileConfig;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
safeLoadYamlConfig,
|
||||
loadModularConfig,
|
||||
};
|
||||
146
src/generator/config/resolver.js
Normal file
146
src/generator/config/resolver.js
Normal file
@@ -0,0 +1,146 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { assignCategorySlugs } = require('./slugs');
|
||||
|
||||
const MENAV_EXTENSION_CONFIG_FILE = 'menav-config.json';
|
||||
|
||||
function getSubmenuForNavItem(navItem, config) {
|
||||
if (!navItem || !navItem.id || !config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (config[navItem.id] && Array.isArray(config[navItem.id].categories))
|
||||
return config[navItem.id].categories;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function makeJsonSafeForHtmlScript(jsonString) {
|
||||
if (typeof jsonString !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return jsonString.replace(/<\/script/gi, '<\\/script');
|
||||
}
|
||||
|
||||
function resolveTemplateNameForPage(pageId, config) {
|
||||
if (!pageId) return 'page';
|
||||
|
||||
const pageConfig = config && config[pageId] ? config[pageId] : null;
|
||||
const explicit =
|
||||
pageConfig && pageConfig.template ? String(pageConfig.template).trim() : '';
|
||||
if (explicit) return explicit;
|
||||
|
||||
const candidatePath = path.join(process.cwd(), 'templates', 'pages', `${pageId}.hbs`);
|
||||
if (fs.existsSync(candidatePath)) return pageId;
|
||||
|
||||
return 'page';
|
||||
}
|
||||
|
||||
function buildExtensionConfig(renderData) {
|
||||
const version =
|
||||
renderData &&
|
||||
renderData._meta &&
|
||||
renderData._meta.version &&
|
||||
String(renderData._meta.version).trim()
|
||||
? String(renderData._meta.version).trim()
|
||||
: process.env.npm_package_version || '1.0.0';
|
||||
|
||||
const pageTemplates = {};
|
||||
if (renderData && Array.isArray(renderData.navigation)) {
|
||||
renderData.navigation.forEach((navItem) => {
|
||||
const pageId = navItem && navItem.id ? String(navItem.id).trim() : '';
|
||||
if (!pageId) return;
|
||||
pageTemplates[pageId] = resolveTemplateNameForPage(pageId, renderData);
|
||||
});
|
||||
}
|
||||
|
||||
const allowedSchemes =
|
||||
renderData &&
|
||||
renderData.site &&
|
||||
renderData.site.security &&
|
||||
Array.isArray(renderData.site.security.allowedSchemes)
|
||||
? renderData.site.security.allowedSchemes
|
||||
: null;
|
||||
|
||||
return {
|
||||
version,
|
||||
timestamp: new Date().toISOString(),
|
||||
icons: renderData && renderData.icons ? renderData.icons : undefined,
|
||||
data: {
|
||||
homePageId: renderData && renderData.homePageId ? renderData.homePageId : null,
|
||||
pageTemplates,
|
||||
site: allowedSchemes ? { security: { allowedSchemes } } : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function prepareRenderData(config) {
|
||||
const renderData = { ...config };
|
||||
|
||||
renderData._meta = {
|
||||
generated_at: new Date(),
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
generator: 'MeNav',
|
||||
};
|
||||
|
||||
if (!Array.isArray(renderData.navigation)) {
|
||||
renderData.navigation = [];
|
||||
}
|
||||
|
||||
if (Array.isArray(renderData.navigation)) {
|
||||
renderData.navigation = renderData.navigation.map((item, index) => {
|
||||
const navItem = {
|
||||
...item,
|
||||
isActive: index === 0,
|
||||
id: item.id || `nav-${index}`,
|
||||
active: index === 0,
|
||||
};
|
||||
|
||||
const submenu = getSubmenuForNavItem(navItem, renderData);
|
||||
if (submenu) {
|
||||
navItem.submenu = submenu;
|
||||
}
|
||||
|
||||
return navItem;
|
||||
});
|
||||
}
|
||||
|
||||
renderData.homePageId =
|
||||
renderData.navigation && renderData.navigation[0] ? renderData.navigation[0].id : null;
|
||||
|
||||
if (Array.isArray(renderData.navigation)) {
|
||||
renderData.navigation.forEach((navItem) => {
|
||||
const pageConfig = renderData[navItem.id];
|
||||
if (pageConfig && Array.isArray(pageConfig.categories)) {
|
||||
assignCategorySlugs(pageConfig.categories, new Map());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const extensionConfig = buildExtensionConfig(renderData);
|
||||
renderData.extensionConfig = extensionConfig;
|
||||
renderData.extensionConfigUrl = `./${MENAV_EXTENSION_CONFIG_FILE}`;
|
||||
renderData.configJSON = makeJsonSafeForHtmlScript(
|
||||
JSON.stringify({
|
||||
...extensionConfig,
|
||||
configUrl: renderData.extensionConfigUrl,
|
||||
})
|
||||
);
|
||||
|
||||
renderData.navigationData = renderData.navigation;
|
||||
|
||||
if (Array.isArray(renderData.social)) {
|
||||
renderData.socialLinks = renderData.social;
|
||||
}
|
||||
|
||||
return renderData;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MENAV_EXTENSION_CONFIG_FILE,
|
||||
getSubmenuForNavItem,
|
||||
resolveTemplateNameForPage,
|
||||
buildExtensionConfig,
|
||||
prepareRenderData,
|
||||
};
|
||||
43
src/generator/config/slugs.js
Normal file
43
src/generator/config/slugs.js
Normal file
@@ -0,0 +1,43 @@
|
||||
function makeCategorySlugBase(name) {
|
||||
const raw = typeof name === 'string' ? name : String(name ?? '');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return 'category';
|
||||
|
||||
const normalized = trimmed
|
||||
.replace(/\s+/g, '-')
|
||||
.toLowerCase()
|
||||
.replace(/[^\p{L}\p{N}_-]+/gu, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return normalized || 'category';
|
||||
}
|
||||
|
||||
function makeUniqueSlug(base, usedSlugs) {
|
||||
const current = usedSlugs.get(base) || 0;
|
||||
const next = current + 1;
|
||||
usedSlugs.set(base, next);
|
||||
return next === 1 ? base : `${base}-${next}`;
|
||||
}
|
||||
|
||||
function assignCategorySlugs(categories, usedSlugs) {
|
||||
if (!Array.isArray(categories)) return;
|
||||
|
||||
categories.forEach((category) => {
|
||||
if (!category || typeof category !== 'object') return;
|
||||
|
||||
const base = makeCategorySlugBase(category.name);
|
||||
const uniqueSlug = makeUniqueSlug(base, usedSlugs);
|
||||
category.slug = uniqueSlug;
|
||||
|
||||
if (Array.isArray(category.subcategories)) {
|
||||
assignCategorySlugs(category.subcategories, usedSlugs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
makeCategorySlugBase,
|
||||
makeUniqueSlug,
|
||||
assignCategorySlugs,
|
||||
};
|
||||
90
src/generator/config/validator.js
Normal file
90
src/generator/config/validator.js
Normal file
@@ -0,0 +1,90 @@
|
||||
function ensureConfigDefaults(config) {
|
||||
const result = { ...config };
|
||||
|
||||
result.site = result.site || {};
|
||||
result.navigation = result.navigation || [];
|
||||
|
||||
result.fonts = result.fonts && typeof result.fonts === 'object' ? result.fonts : {};
|
||||
result.fonts.source = result.fonts.source || 'css';
|
||||
result.fonts.family = result.fonts.family || 'LXGW WenKai';
|
||||
result.fonts.weight = result.fonts.weight || 'normal';
|
||||
result.fonts.cssUrl = result.fonts.cssUrl || 'https://fontsapi.zeoseven.com/292/main/result.css';
|
||||
|
||||
result.profile = result.profile || {};
|
||||
result.social = result.social || [];
|
||||
|
||||
result.icons = result.icons || {};
|
||||
result.icons.mode = result.icons.mode || 'favicon';
|
||||
result.icons.region = result.icons.region || 'com';
|
||||
|
||||
result.site.title = result.site.title || 'MeNav导航';
|
||||
result.site.description = result.site.description || '个人网络导航站';
|
||||
result.site.author = result.site.author || 'MeNav User';
|
||||
result.site.logo_text = result.site.logo_text || '导航站';
|
||||
result.site.favicon = result.site.favicon || 'menav.svg';
|
||||
result.site.logo = result.site.logo || null;
|
||||
result.site.footer = result.site.footer || '';
|
||||
result.site.theme = result.site.theme || {
|
||||
primary: '#4a89dc',
|
||||
background: '#f5f7fa',
|
||||
modeToggle: true,
|
||||
};
|
||||
|
||||
result.profile.title = result.profile.title || '欢迎使用';
|
||||
result.profile.subtitle = result.profile.subtitle || 'MeNav个人导航系统';
|
||||
|
||||
function processSiteDefaults(site) {
|
||||
site.name = site.name || '未命名站点';
|
||||
site.url = site.url || '#';
|
||||
site.description = site.description || '';
|
||||
site.icon = site.icon || 'fas fa-link';
|
||||
site.external = typeof site.external === 'boolean' ? site.external : true;
|
||||
}
|
||||
|
||||
function processNodeSitesRecursively(node) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (Array.isArray(node.sites)) {
|
||||
node.sites.forEach(processSiteDefaults);
|
||||
}
|
||||
|
||||
if (Array.isArray(node.subcategories)) node.subcategories.forEach(processNodeSitesRecursively);
|
||||
if (Array.isArray(node.groups)) node.groups.forEach(processNodeSitesRecursively);
|
||||
if (Array.isArray(node.subgroups)) node.subgroups.forEach(processNodeSitesRecursively);
|
||||
}
|
||||
|
||||
function processCategoryDefaults(category) {
|
||||
category.name = category.name || '未命名分类';
|
||||
category.sites = category.sites || [];
|
||||
processNodeSitesRecursively(category);
|
||||
}
|
||||
|
||||
Object.keys(result).forEach((key) => {
|
||||
const pageConfig = result[key];
|
||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||
|
||||
if (Array.isArray(pageConfig.categories)) {
|
||||
pageConfig.categories.forEach(processCategoryDefaults);
|
||||
}
|
||||
|
||||
if (Array.isArray(pageConfig.sites)) {
|
||||
pageConfig.sites.forEach(processSiteDefaults);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateConfig(config) {
|
||||
if (!config || typeof config !== 'object') {
|
||||
console.error('配置无效: 配置必须是一个对象');
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ensureConfigDefaults,
|
||||
validateConfig,
|
||||
};
|
||||
94
src/generator/html/404.js
Normal file
94
src/generator/html/404.js
Normal file
@@ -0,0 +1,94 @@
|
||||
const { escapeHtml } = require('../utils/html');
|
||||
|
||||
// 生成 GitHub Pages 的 404 回跳页:将 /<id> 形式的路径深链接转换为 /?page=<id>
|
||||
function generate404Html(config) {
|
||||
const siteTitle = config && config.site && typeof config.site.title === 'string' ? config.site.title : 'MeNav';
|
||||
const safeTitle = escapeHtml(siteTitle);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex" />
|
||||
<title>${safeTitle} - 页面未找到</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 40px 16px;
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue',
|
||||
Arial, 'Noto Sans', 'Liberation Sans', sans-serif;
|
||||
background: #0b1020;
|
||||
color: #e6e6e6;
|
||||
}
|
||||
.container {
|
||||
max-width: 760px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 22px;
|
||||
}
|
||||
p {
|
||||
margin: 8px 0;
|
||||
line-height: 1.6;
|
||||
color: rgba(230, 230, 230, 0.9);
|
||||
}
|
||||
a {
|
||||
color: #74c0fc;
|
||||
}
|
||||
code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
var l = window.location;
|
||||
var pathname = l.pathname || '';
|
||||
var segments = pathname.split('/').filter(Boolean);
|
||||
|
||||
// 用户站点:/<id>
|
||||
// 仓库站点:/<repo>/<id>
|
||||
var repoBase = '';
|
||||
var pageId = '';
|
||||
if (segments.length === 1) {
|
||||
pageId = segments[0];
|
||||
} else if (segments.length === 2) {
|
||||
repoBase = '/' + segments[0];
|
||||
pageId = segments[1];
|
||||
} else {
|
||||
repoBase = segments.length > 1 ? '/' + segments[0] : '';
|
||||
pageId = segments.length ? segments[segments.length - 1] : '';
|
||||
}
|
||||
|
||||
if (!pageId) {
|
||||
l.replace(repoBase + '/');
|
||||
return;
|
||||
}
|
||||
|
||||
var target = repoBase + '/?page=' + encodeURIComponent(pageId) + (l.hash || '');
|
||||
l.replace(target);
|
||||
} catch (e) {
|
||||
// 兜底:回到首页
|
||||
window.location.replace('./');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>页面未找到</h1>
|
||||
<p>若你访问的是“页面路径深链接”,系统将自动回跳到 <code>?page=</code> 形式的可用地址。</p>
|
||||
<p><a href="./">返回首页</a></p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generate404Html,
|
||||
};
|
||||
208
src/generator/html/components.js
Normal file
208
src/generator/html/components.js
Normal file
@@ -0,0 +1,208 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { handlebars } = require('../template/engine');
|
||||
const { getSubmenuForNavItem } = require('../config');
|
||||
const { escapeHtml } = require('../utils/html');
|
||||
|
||||
// 生成导航菜单
|
||||
function generateNavigation(navigation, config) {
|
||||
return navigation
|
||||
.map((nav) => {
|
||||
// 根据页面ID获取对应的子菜单项(分类)
|
||||
let submenuItems = '';
|
||||
|
||||
// 使用辅助函数获取子菜单数据
|
||||
const submenu = getSubmenuForNavItem(nav, config);
|
||||
|
||||
// 如果存在子菜单,生成HTML
|
||||
if (submenu && Array.isArray(submenu)) {
|
||||
submenuItems = `
|
||||
<div class="submenu">
|
||||
${submenu
|
||||
.map(
|
||||
(category) => `
|
||||
<a href="#${category.name}" class="submenu-item" data-page="${nav.id}" data-category="${category.name}">
|
||||
<i class="${escapeHtml(category.icon)}"></i>
|
||||
<span>${escapeHtml(category.name)}</span>
|
||||
</a>
|
||||
`
|
||||
)
|
||||
.join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="nav-item-wrapper">
|
||||
<a href="#" class="nav-item${nav.active ? ' active' : ''}" data-page="${escapeHtml(nav.id)}">
|
||||
<div class="icon-container">
|
||||
<i class="${escapeHtml(nav.icon)}"></i>
|
||||
</div>
|
||||
<span class="nav-text">${escapeHtml(nav.name)}</span>
|
||||
${submenuItems ? '<i class="fas fa-chevron-down submenu-toggle"></i>' : ''}
|
||||
</a>
|
||||
${submenuItems}
|
||||
</div>`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成网站卡片HTML
|
||||
function generateSiteCards(sites) {
|
||||
if (!sites || !Array.isArray(sites) || sites.length === 0) {
|
||||
return `<p class="empty-sites">暂无网站</p>`;
|
||||
}
|
||||
|
||||
return sites
|
||||
.map(
|
||||
(site) => `
|
||||
<a href="${escapeHtml(site.url)}" class="site-card" title="${escapeHtml(site.name)} - ${escapeHtml(site.description || '')}">
|
||||
<i class="${escapeHtml(site.icon || 'fas fa-link')}"></i>
|
||||
<h3>${escapeHtml(site.name || '未命名站点')}</h3>
|
||||
<p>${escapeHtml(site.description || '')}</p>
|
||||
</a>`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成分类板块
|
||||
function generateCategories(categories) {
|
||||
if (!categories || !Array.isArray(categories) || categories.length === 0) {
|
||||
return `
|
||||
<section class="category">
|
||||
<h2><i class="fas fa-info-circle"></i> 暂无分类</h2>
|
||||
<p>请在配置文件中添加分类</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
return categories
|
||||
.map(
|
||||
(category) => `
|
||||
<section class="category" id="${escapeHtml(category.name)}">
|
||||
<h2><i class="${escapeHtml(category.icon)}"></i> ${escapeHtml(category.name)}</h2>
|
||||
<div class="sites-grid">
|
||||
${generateSiteCards(category.sites)}
|
||||
</div>
|
||||
</section>`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成社交链接HTML
|
||||
function generateSocialLinks(social) {
|
||||
if (!social || !Array.isArray(social) || social.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// 尝试使用 Handlebars 模板
|
||||
try {
|
||||
const socialLinksPath = path.join(process.cwd(), 'templates', 'components', 'social-links.hbs');
|
||||
if (fs.existsSync(socialLinksPath)) {
|
||||
const templateContent = fs.readFileSync(socialLinksPath, 'utf8');
|
||||
const template = handlebars.compile(templateContent);
|
||||
// 确保数据格式正确
|
||||
return template(social); // 社交链接模板直接接收数组
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error rendering social-links template:', error);
|
||||
// 出错时回退到原始生成方法
|
||||
}
|
||||
|
||||
// 回退到原始生成方法
|
||||
return social
|
||||
.map(
|
||||
(link) => `
|
||||
<a href="${escapeHtml(link.url)}" class="social-icon" target="_blank" rel="noopener" title="${escapeHtml(link.name || '社交链接')}" aria-label="${escapeHtml(link.name || '社交链接')}" data-type="social-link" data-name="${escapeHtml(link.name || '社交链接')}" data-url="${escapeHtml(link.url)}" data-icon="${escapeHtml(link.icon || 'fas fa-link')}">
|
||||
<i class="${escapeHtml(link.icon || 'fas fa-link')}" aria-hidden="true"></i>
|
||||
<span class="nav-text visually-hidden" data-editable="social-link-name">${escapeHtml(link.name || '社交链接')}</span>
|
||||
</a>`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// 生成页面内容(包括首页和其他页面)
|
||||
function generatePageContent(pageId, data) {
|
||||
// 确保数据对象存在
|
||||
if (!data) {
|
||||
console.error(`Missing data for page: ${pageId}`);
|
||||
return `
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-section-main">
|
||||
<h2>页面未配置</h2>
|
||||
<p class="subtitle">请配置 ${pageId} 页面</p>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 首页使用 profile 数据,其他页面使用自身数据
|
||||
if (pageId === 'home') {
|
||||
const profile = data.profile || {};
|
||||
|
||||
return `
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-section-main">
|
||||
<h2>${escapeHtml(profile.title || '欢迎使用')}</h2>
|
||||
<h3>${escapeHtml(profile.subtitle || '个人导航站')}</h3>
|
||||
</div>
|
||||
</div>
|
||||
${generateCategories(data.categories)}`;
|
||||
} else {
|
||||
// 其他页面使用通用结构
|
||||
const title = data.title || `${pageId} 页面`;
|
||||
const subtitle = data.subtitle || '';
|
||||
const categories = data.categories || [];
|
||||
|
||||
return `
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-section-main">
|
||||
<h2>${escapeHtml(title)}</h2>
|
||||
<p class="subtitle">${escapeHtml(subtitle)}</p>
|
||||
</div>
|
||||
</div>
|
||||
${generateCategories(categories)}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 生成搜索结果页面
|
||||
function generateSearchResultsPage(config) {
|
||||
// 获取所有导航页面ID
|
||||
const pageIds = config.navigation.map((nav) => nav.id);
|
||||
|
||||
// 生成所有页面的搜索结果区域
|
||||
const sections = pageIds
|
||||
.map((pageId) => {
|
||||
// 根据页面ID获取对应的图标和名称
|
||||
const navItem = config.navigation.find((nav) => nav.id === pageId);
|
||||
const icon = navItem ? navItem.icon : 'fas fa-file';
|
||||
const name = navItem ? navItem.name : pageId;
|
||||
|
||||
return `
|
||||
<section class="category search-section" data-section="${escapeHtml(pageId)}" style="display: none;">
|
||||
<h2><i class="${escapeHtml(icon)}"></i> ${escapeHtml(name)}匹配项</h2>
|
||||
<div class="sites-grid"></div>
|
||||
</section>`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
return `
|
||||
<!-- 搜索结果页 -->
|
||||
<div class="page" id="search-results">
|
||||
<div class="welcome-section">
|
||||
<div class="welcome-section-main">
|
||||
<h2>搜索结果</h2>
|
||||
<p class="subtitle">在所有页面中找到的匹配项</p>
|
||||
</div>
|
||||
</div>
|
||||
${sections}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateNavigation,
|
||||
generateSiteCards,
|
||||
generateCategories,
|
||||
generateSocialLinks,
|
||||
generatePageContent,
|
||||
generateSearchResultsPage,
|
||||
};
|
||||
|
||||
155
src/generator/html/fonts.js
Normal file
155
src/generator/html/fonts.js
Normal file
@@ -0,0 +1,155 @@
|
||||
const { escapeHtml } = require('../utils/html');
|
||||
|
||||
/**
|
||||
* 将 CSS 文本安全嵌入到 <style> 中,避免出现 `</style>` 结束标签导致样式块被提前终止。
|
||||
* @param {string} cssText CSS 文本
|
||||
* @returns {string} 安全的 CSS 文本
|
||||
*/
|
||||
function makeCssSafeForHtmlStyleTag(cssText) {
|
||||
if (typeof cssText !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return cssText.replace(/<\/style/gi, '<\\/style');
|
||||
}
|
||||
|
||||
function normalizeFontWeight(input) {
|
||||
if (input === undefined || input === null) return 'normal';
|
||||
|
||||
if (typeof input === 'number' && Number.isFinite(input)) {
|
||||
return String(input);
|
||||
}
|
||||
|
||||
const raw = String(input).trim();
|
||||
if (!raw) return 'normal';
|
||||
|
||||
if (/^(normal|bold|bolder|lighter)$/i.test(raw)) return raw.toLowerCase();
|
||||
if (/^[1-9]00$/.test(raw)) return raw;
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
function normalizeFontFamilyForCss(input) {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return '';
|
||||
|
||||
const generics = new Set([
|
||||
'serif',
|
||||
'sans-serif',
|
||||
'monospace',
|
||||
'cursive',
|
||||
'fantasy',
|
||||
'system-ui',
|
||||
'ui-serif',
|
||||
'ui-sans-serif',
|
||||
'ui-monospace',
|
||||
'ui-rounded',
|
||||
'emoji',
|
||||
'math',
|
||||
'fangsong',
|
||||
]);
|
||||
|
||||
return raw
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean)
|
||||
.map((part) => {
|
||||
const unquoted = part.replace(/^['"]|['"]$/g, '').trim();
|
||||
if (!unquoted) return '';
|
||||
if (generics.has(unquoted)) return unquoted;
|
||||
|
||||
const needsQuotes = /\s/.test(unquoted);
|
||||
if (!needsQuotes) return unquoted;
|
||||
|
||||
return `"${unquoted.replace(/"/g, '\\"')}"`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function normalizeFontSource(input) {
|
||||
const raw = String(input || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (raw === 'css' || raw === 'google' || raw === 'system') return raw;
|
||||
return 'system';
|
||||
}
|
||||
|
||||
function getNormalizedFontsConfig(config) {
|
||||
const fonts = config && config.fonts && typeof config.fonts === 'object' ? config.fonts : {};
|
||||
|
||||
return {
|
||||
source: normalizeFontSource(fonts.source),
|
||||
family: normalizeFontFamilyForCss(fonts.family),
|
||||
weight: normalizeFontWeight(fonts.weight),
|
||||
cssUrl: String(fonts.cssUrl || fonts.href || '').trim(),
|
||||
preload: Boolean(fonts.preload),
|
||||
};
|
||||
}
|
||||
|
||||
function tryGetUrlOrigin(input) {
|
||||
const raw = String(input || '').trim();
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildStylesheetLinkTag(href, preload) {
|
||||
const safeHref = escapeHtml(href);
|
||||
if (!preload) return `<link rel="stylesheet" href="${safeHref}">`;
|
||||
|
||||
return [
|
||||
`<link rel="preload" href="${safeHref}" as="style" onload="this.onload=null;this.rel='stylesheet'">`,
|
||||
`<noscript><link rel="stylesheet" href="${safeHref}"></noscript>`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
// 生成字体相关 <link>
|
||||
function generateFontLinks(config) {
|
||||
const fonts = getNormalizedFontsConfig(config);
|
||||
const links = [];
|
||||
|
||||
// 全站基础字体:按配置加载
|
||||
if (fonts.source === 'css' && fonts.cssUrl) {
|
||||
const origin = tryGetUrlOrigin(fonts.cssUrl);
|
||||
if (origin) {
|
||||
links.push(`<link rel="preconnect" href="${escapeHtml(origin)}" crossorigin>`);
|
||||
}
|
||||
links.push(buildStylesheetLinkTag(fonts.cssUrl, fonts.preload));
|
||||
}
|
||||
|
||||
if (fonts.source === 'google' && fonts.family) {
|
||||
links.push('<link rel="preconnect" href="https://fonts.googleapis.com">');
|
||||
links.push('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
|
||||
|
||||
const familyNoQuotes = fonts.family.replace(/[\"']/g, '').split(',')[0].trim();
|
||||
const weight = /^[1-9]00$/.test(fonts.weight) ? fonts.weight : '400';
|
||||
const familyParam = encodeURIComponent(familyNoQuotes).replace(/%20/g, '+');
|
||||
links.push(
|
||||
buildStylesheetLinkTag(
|
||||
`https://fonts.googleapis.com/css2?family=${familyParam}:wght@${weight}&display=swap`,
|
||||
fonts.preload
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return links.join('\n');
|
||||
}
|
||||
|
||||
// 生成字体 CSS 变量(单一字体配置)
|
||||
function generateFontCss(config) {
|
||||
const fonts = getNormalizedFontsConfig(config);
|
||||
const family = fonts.family || 'system-ui, sans-serif';
|
||||
const weight = fonts.weight || 'normal';
|
||||
|
||||
const css = `:root {\n --font-body: ${family};\n --font-weight-body: ${weight};\n}\n`;
|
||||
return makeCssSafeForHtmlStyleTag(css);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
generateFontLinks,
|
||||
generateFontCss,
|
||||
};
|
||||
161
src/generator/html/page-data.js
Normal file
161
src/generator/html/page-data.js
Normal file
@@ -0,0 +1,161 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { getSubmenuForNavItem, assignCategorySlugs } = require('../config');
|
||||
const { tryLoadArticlesFeedCache, buildArticlesCategoriesByPageCategories } = require('../cache/articles');
|
||||
const { tryLoadProjectsRepoCache, applyRepoMetaToCategories, buildProjectsMeta } = require('../cache/projects');
|
||||
const { getPageConfigUpdatedAtMeta } = require('../utils/pageMeta');
|
||||
|
||||
function prepareNavigationData(pageId, config) {
|
||||
if (!Array.isArray(config.navigation)) {
|
||||
console.warn('Warning: config.navigation is not an array in renderPage. Using empty array.');
|
||||
return [];
|
||||
}
|
||||
|
||||
return config.navigation.map((nav) => {
|
||||
const navItem = {
|
||||
...nav,
|
||||
isActive: nav.id === pageId,
|
||||
active: nav.id === pageId,
|
||||
};
|
||||
|
||||
const submenu = getSubmenuForNavItem(navItem, config);
|
||||
if (submenu) {
|
||||
navItem.submenu = submenu;
|
||||
}
|
||||
|
||||
return navItem;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveTemplateName(pageId, data) {
|
||||
const explicitTemplate = typeof data.template === 'string' ? data.template.trim() : '';
|
||||
let templateName = explicitTemplate || pageId;
|
||||
|
||||
if (!explicitTemplate) {
|
||||
const inferredTemplatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`);
|
||||
if (!fs.existsSync(inferredTemplatePath)) {
|
||||
templateName = 'page';
|
||||
}
|
||||
}
|
||||
|
||||
return templateName;
|
||||
}
|
||||
|
||||
function applyProjectsData(data, pageId, config) {
|
||||
data.siteCardStyle = 'repo';
|
||||
data.projectsMeta = buildProjectsMeta(config);
|
||||
if (Array.isArray(data.categories)) {
|
||||
const repoCache = tryLoadProjectsRepoCache(pageId, config);
|
||||
if (repoCache && repoCache.map) {
|
||||
applyRepoMetaToCategories(data.categories, repoCache.map);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function applyArticlesData(data, pageId, config) {
|
||||
const cache = tryLoadArticlesFeedCache(pageId, config);
|
||||
data.articlesItems = cache && Array.isArray(cache.items) ? cache.items : [];
|
||||
data.articlesMeta = cache ? cache.meta : null;
|
||||
data.articlesCategories = data.articlesItems.length
|
||||
? buildArticlesCategoriesByPageCategories(data.categories, data.articlesItems)
|
||||
: [];
|
||||
}
|
||||
|
||||
function applyBookmarksData(data, pageId) {
|
||||
const updatedAtMeta = getPageConfigUpdatedAtMeta(pageId);
|
||||
if (updatedAtMeta) {
|
||||
data.pageMeta = { ...updatedAtMeta };
|
||||
}
|
||||
}
|
||||
|
||||
function convertTopLevelSitesToCategory(data, pageId, templateName) {
|
||||
const isFriendsPage = pageId === 'friends' || templateName === 'friends';
|
||||
const isArticlesPage = pageId === 'articles' || templateName === 'articles';
|
||||
|
||||
if (
|
||||
(isFriendsPage || isArticlesPage) &&
|
||||
(!Array.isArray(data.categories) || data.categories.length === 0) &&
|
||||
Array.isArray(data.sites) &&
|
||||
data.sites.length > 0
|
||||
) {
|
||||
const implicitName = isFriendsPage ? '全部友链' : '全部来源';
|
||||
data.categories = [
|
||||
{
|
||||
name: implicitName,
|
||||
icon: 'fas fa-link',
|
||||
sites: data.sites,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
function applyHomePageTitles(data, pageId, config) {
|
||||
const homePageId =
|
||||
config.homePageId ||
|
||||
(Array.isArray(config.navigation) && config.navigation[0] ? config.navigation[0].id : null) ||
|
||||
'home';
|
||||
|
||||
data.homePageId = homePageId;
|
||||
|
||||
if (pageId === homePageId && config.profile) {
|
||||
if (config.profile.title !== undefined) data.title = config.profile.title;
|
||||
if (config.profile.subtitle !== undefined) data.subtitle = config.profile.subtitle;
|
||||
}
|
||||
}
|
||||
|
||||
function preparePageData(pageId, config) {
|
||||
const data = {
|
||||
...(config || {}),
|
||||
currentPage: pageId,
|
||||
pageId,
|
||||
};
|
||||
|
||||
data.navigation = prepareNavigationData(pageId, config);
|
||||
data.socialLinks = Array.isArray(config.social) ? config.social : [];
|
||||
data.navigationData = data.navigation;
|
||||
|
||||
if (config[pageId]) {
|
||||
Object.assign(data, config[pageId]);
|
||||
}
|
||||
|
||||
if (data.title === undefined) {
|
||||
const navItem = Array.isArray(config.navigation)
|
||||
? config.navigation.find((nav) => nav.id === pageId)
|
||||
: null;
|
||||
if (navItem && navItem.name !== undefined) data.title = navItem.name;
|
||||
}
|
||||
if (data.subtitle === undefined) data.subtitle = '';
|
||||
if (!Array.isArray(data.categories)) data.categories = [];
|
||||
|
||||
const templateName = resolveTemplateName(pageId, data);
|
||||
|
||||
if (templateName === 'projects') {
|
||||
applyProjectsData(data, pageId, config);
|
||||
}
|
||||
|
||||
convertTopLevelSitesToCategory(data, pageId, templateName);
|
||||
|
||||
if (templateName === 'articles') {
|
||||
applyArticlesData(data, pageId, config);
|
||||
}
|
||||
|
||||
if (templateName === 'bookmarks') {
|
||||
applyBookmarksData(data, pageId);
|
||||
}
|
||||
|
||||
applyHomePageTitles(data, pageId, config);
|
||||
|
||||
if (Array.isArray(data.categories) && data.categories.length > 0) {
|
||||
assignCategorySlugs(data.categories, new Map());
|
||||
}
|
||||
|
||||
if (config[pageId] && config[pageId].template) {
|
||||
console.log(`页面 ${pageId} 使用指定模板: ${templateName}`);
|
||||
}
|
||||
|
||||
return { data, templateName };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
preparePageData,
|
||||
};
|
||||
301
src/generator/main.js
Normal file
301
src/generator/main.js
Normal file
@@ -0,0 +1,301 @@
|
||||
// 生成端主实现(由 src/generator.js 薄入口加载并 re-export)
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
const { loadHandlebarsTemplates, getDefaultLayoutTemplate, renderTemplate } = require('./template/engine');
|
||||
const { MENAV_EXTENSION_CONFIG_FILE, loadConfig, getSubmenuForNavItem } = require('./config');
|
||||
|
||||
const { generateNavigation, generateCategories, generateSocialLinks } = require('./html/components');
|
||||
const { generate404Html } = require('./html/404');
|
||||
const { generateFontLinks, generateFontCss } = require('./html/fonts');
|
||||
const { preparePageData } = require('./html/page-data');
|
||||
const { collectSitesRecursively } = require('./utils/sites');
|
||||
|
||||
/**
|
||||
* 渲染单个页面
|
||||
* @param {string} pageId 页面ID
|
||||
* @param {Object} config 配置数据
|
||||
* @returns {string} 渲染后的HTML
|
||||
*/
|
||||
function renderPage(pageId, config) {
|
||||
const { data, templateName } = preparePageData(pageId, config);
|
||||
return renderTemplate(templateName, data, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成所有页面的HTML内容
|
||||
* @param {Object} config 配置对象
|
||||
* @returns {Object} 包含所有页面HTML的对象
|
||||
*/
|
||||
function generateAllPagesHTML(config) {
|
||||
// 页面内容集合
|
||||
const pages = {};
|
||||
|
||||
// 渲染配置中定义的所有页面
|
||||
if (Array.isArray(config.navigation)) {
|
||||
config.navigation.forEach((navItem) => {
|
||||
const pageId = navItem.id;
|
||||
|
||||
// 渲染页面内容
|
||||
pages[pageId] = renderPage(pageId, config);
|
||||
});
|
||||
}
|
||||
|
||||
// 确保搜索结果页存在
|
||||
if (!pages['search-results']) {
|
||||
pages['search-results'] = renderPage('search-results', config);
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成完整的HTML
|
||||
* @param {Object} config 配置对象
|
||||
* @returns {string} 完整HTML
|
||||
*/
|
||||
function generateHTML(config) {
|
||||
// 获取所有页面内容
|
||||
const pages = generateAllPagesHTML(config);
|
||||
|
||||
// 获取当前年份
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
// 准备导航数据,添加 submenu 字段
|
||||
const navigationData = config.navigation.map((nav) => {
|
||||
const navItem = { ...nav };
|
||||
|
||||
// 使用辅助函数获取子菜单
|
||||
const submenu = getSubmenuForNavItem(navItem, config);
|
||||
if (submenu) {
|
||||
navItem.submenu = submenu;
|
||||
}
|
||||
|
||||
return navItem;
|
||||
});
|
||||
|
||||
// 准备字体链接与 CSS 变量
|
||||
const fontLinks = generateFontLinks(config);
|
||||
const fontCss = generateFontCss(config);
|
||||
|
||||
// 准备社交链接
|
||||
const socialLinks = generateSocialLinks(config.social);
|
||||
|
||||
// 使用主布局模板
|
||||
const layoutData = {
|
||||
...config,
|
||||
pages,
|
||||
fontLinks,
|
||||
fontCss,
|
||||
navigationData,
|
||||
currentYear,
|
||||
socialLinks,
|
||||
navigation: generateNavigation(config.navigation, config), // 兼容旧版
|
||||
social: Array.isArray(config.social) ? config.social : [], // 兼容旧版
|
||||
|
||||
// 确保配置数据可用于浏览器扩展
|
||||
configJSON: config.configJSON, // 从 prepareRenderData 函数中获取的配置数据
|
||||
};
|
||||
|
||||
try {
|
||||
// 使用辅助函数获取默认布局模板
|
||||
const { template: layoutTemplate } = getDefaultLayoutTemplate();
|
||||
|
||||
// 渲染模板
|
||||
return layoutTemplate(layoutData);
|
||||
} catch (error) {
|
||||
console.error('Error rendering main HTML template:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function tryMinifyStaticAsset(srcPath, destPath, loader) {
|
||||
let esbuild;
|
||||
try {
|
||||
esbuild = require('esbuild');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const source = fs.readFileSync(srcPath, 'utf8');
|
||||
const result = esbuild.transformSync(source, {
|
||||
loader,
|
||||
minify: true,
|
||||
charset: 'utf8',
|
||||
});
|
||||
fs.writeFileSync(destPath, result.code);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error minifying ${srcPath}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 复制静态文件
|
||||
function copyStaticFiles(config) {
|
||||
// 确保 dist 目录存在
|
||||
if (!fs.existsSync('dist')) {
|
||||
fs.mkdirSync('dist', { recursive: true });
|
||||
}
|
||||
|
||||
// 复制 CSS 文件
|
||||
try {
|
||||
if (!tryMinifyStaticAsset('assets/style.css', 'dist/style.css', 'css')) {
|
||||
fs.copyFileSync('assets/style.css', 'dist/style.css');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error copying style.css:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!tryMinifyStaticAsset('assets/pinyin-match.js', 'dist/pinyin-match.js', 'js')) {
|
||||
fs.copyFileSync('assets/pinyin-match.js', 'dist/pinyin-match.js');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error copying pinyin-match.js:', e);
|
||||
}
|
||||
|
||||
// dist/script.js 由构建阶段 runtime bundle 产出(scripts/build-runtime.js),这里不再复制/覆盖
|
||||
|
||||
// faviconUrl(站点级自定义图标):若使用本地路径(建议以 assets/ 开头),则复制到 dist 下同路径
|
||||
try {
|
||||
const copied = new Set();
|
||||
|
||||
const copyLocalAsset = (rawUrl) => {
|
||||
const raw = String(rawUrl || '').trim();
|
||||
if (!raw) return;
|
||||
if (/^https?:\/\//i.test(raw)) return;
|
||||
|
||||
const rel = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\//, '');
|
||||
if (!rel.startsWith('assets/')) return;
|
||||
|
||||
const normalized = path.posix.normalize(rel);
|
||||
if (!normalized.startsWith('assets/')) return;
|
||||
if (copied.has(normalized)) return;
|
||||
copied.add(normalized);
|
||||
|
||||
const srcPath = path.join(process.cwd(), normalized);
|
||||
const destPath = path.join(process.cwd(), 'dist', normalized);
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.warn(`[WARN] faviconUrl 本地文件不存在:${normalized}`);
|
||||
return;
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
};
|
||||
|
||||
if (config && Array.isArray(config.navigation)) {
|
||||
config.navigation.forEach((navItem) => {
|
||||
const pageId = navItem && navItem.id ? String(navItem.id) : '';
|
||||
if (!pageId) return;
|
||||
const pageConfig = config[pageId];
|
||||
if (!pageConfig || typeof pageConfig !== 'object') return;
|
||||
|
||||
if (Array.isArray(pageConfig.sites)) {
|
||||
pageConfig.sites.forEach((site) => {
|
||||
if (!site || typeof site !== 'object') return;
|
||||
copyLocalAsset(site.faviconUrl);
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(pageConfig.categories)) {
|
||||
const sites = [];
|
||||
pageConfig.categories.forEach((category) => collectSitesRecursively(category, sites));
|
||||
sites.forEach((site) => {
|
||||
if (!site || typeof site !== 'object') return;
|
||||
copyLocalAsset(site.faviconUrl);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error copying faviconUrl assets:', e);
|
||||
}
|
||||
|
||||
// 如果配置了 favicon,确保文件存在并复制
|
||||
if (config.site.favicon) {
|
||||
try {
|
||||
if (fs.existsSync(`assets/${config.site.favicon}`)) {
|
||||
fs.copyFileSync(`assets/${config.site.favicon}`, `dist/${path.basename(config.site.favicon)}`);
|
||||
} else if (fs.existsSync(config.site.favicon)) {
|
||||
fs.copyFileSync(config.site.favicon, `dist/${path.basename(config.site.favicon)}`);
|
||||
} else {
|
||||
console.warn(`Warning: Favicon file not found: ${config.site.favicon}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error copying favicon:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
function main() {
|
||||
const config = loadConfig();
|
||||
|
||||
try {
|
||||
// 确保 dist 目录存在
|
||||
if (!fs.existsSync('dist')) {
|
||||
fs.mkdirSync('dist', { recursive: true });
|
||||
}
|
||||
|
||||
// 初始化 Handlebars 模板系统
|
||||
loadHandlebarsTemplates();
|
||||
|
||||
// 使用 generateHTML 函数生成完整的 HTML
|
||||
const htmlContent = generateHTML(config);
|
||||
|
||||
// 生成 HTML
|
||||
fs.writeFileSync('dist/index.html', htmlContent);
|
||||
|
||||
// 扩展专用配置:独立静态文件(按需加载)
|
||||
try {
|
||||
const extensionConfig =
|
||||
config && config.extensionConfig ? JSON.stringify(config.extensionConfig, null, 2) : '';
|
||||
if (extensionConfig) {
|
||||
fs.writeFileSync(path.join('dist', MENAV_EXTENSION_CONFIG_FILE), extensionConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error writing extension config file:', error);
|
||||
}
|
||||
|
||||
// GitHub Pages 静态路由回退:用于支持 /<id> 形式的路径深链接
|
||||
fs.writeFileSync('dist/404.html', generate404Html(config));
|
||||
|
||||
// 构建运行时脚本(bundle → dist/script.js)
|
||||
try {
|
||||
execFileSync(process.execPath, [path.join(process.cwd(), 'scripts', 'build-runtime.js')], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error bundling runtime script:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// 复制静态文件
|
||||
copyStaticFiles(config);
|
||||
} catch (e) {
|
||||
console.error('Error in main function:', e);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
// 导出供测试使用的函数
|
||||
module.exports = {
|
||||
main,
|
||||
loadConfig,
|
||||
generateHTML,
|
||||
generate404Html,
|
||||
copyStaticFiles,
|
||||
generateNavigation,
|
||||
generateCategories,
|
||||
loadHandlebarsTemplates,
|
||||
renderTemplate,
|
||||
generateAllPagesHTML,
|
||||
};
|
||||
120
src/generator/template/engine.js
Normal file
120
src/generator/template/engine.js
Normal file
@@ -0,0 +1,120 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const Handlebars = require('handlebars');
|
||||
|
||||
const { registerAllHelpers } = require('../../helpers');
|
||||
|
||||
const handlebars = Handlebars.create();
|
||||
registerAllHelpers(handlebars);
|
||||
|
||||
function loadHandlebarsTemplates() {
|
||||
const templatesDir = path.join(process.cwd(), 'templates');
|
||||
|
||||
if (!fs.existsSync(templatesDir)) {
|
||||
throw new Error('Templates directory not found. Cannot proceed without templates.');
|
||||
}
|
||||
|
||||
const layoutsDir = path.join(templatesDir, 'layouts');
|
||||
if (!fs.existsSync(layoutsDir)) {
|
||||
throw new Error('Layouts directory not found. Cannot proceed without layout templates.');
|
||||
}
|
||||
|
||||
fs.readdirSync(layoutsDir)
|
||||
.filter((file) => file.endsWith('.hbs'))
|
||||
.sort()
|
||||
.forEach((file) => {
|
||||
const layoutName = path.basename(file, '.hbs');
|
||||
const layoutPath = path.join(layoutsDir, file);
|
||||
const layoutContent = fs.readFileSync(layoutPath, 'utf8');
|
||||
handlebars.registerPartial(layoutName, layoutContent);
|
||||
});
|
||||
|
||||
const componentsDir = path.join(templatesDir, 'components');
|
||||
if (!fs.existsSync(componentsDir)) {
|
||||
throw new Error('Components directory not found. Cannot proceed without component templates.');
|
||||
}
|
||||
|
||||
fs.readdirSync(componentsDir)
|
||||
.filter((file) => file.endsWith('.hbs'))
|
||||
.sort()
|
||||
.forEach((file) => {
|
||||
const componentName = path.basename(file, '.hbs');
|
||||
const componentPath = path.join(componentsDir, file);
|
||||
const componentContent = fs.readFileSync(componentPath, 'utf8');
|
||||
handlebars.registerPartial(componentName, componentContent);
|
||||
});
|
||||
|
||||
const defaultLayoutPath = path.join(layoutsDir, 'default.hbs');
|
||||
if (!fs.existsSync(defaultLayoutPath)) {
|
||||
throw new Error('Default layout template not found. Cannot proceed without default layout.');
|
||||
}
|
||||
}
|
||||
|
||||
function getDefaultLayoutTemplate() {
|
||||
const defaultLayoutPath = path.join(process.cwd(), 'templates', 'layouts', 'default.hbs');
|
||||
|
||||
if (!fs.existsSync(defaultLayoutPath)) {
|
||||
throw new Error('Default layout template not found. Cannot proceed without default layout.');
|
||||
}
|
||||
|
||||
try {
|
||||
const layoutContent = fs.readFileSync(defaultLayoutPath, 'utf8');
|
||||
const layoutTemplate = handlebars.compile(layoutContent);
|
||||
|
||||
return {
|
||||
path: defaultLayoutPath,
|
||||
template: layoutTemplate,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(`Error loading default layout template: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTemplate(templateName, data, useLayout = true) {
|
||||
const templatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`);
|
||||
|
||||
if (!fs.existsSync(templatePath)) {
|
||||
const genericTemplatePath = path.join(process.cwd(), 'templates', 'pages', 'page.hbs');
|
||||
|
||||
if (!fs.existsSync(genericTemplatePath)) {
|
||||
throw new Error(
|
||||
`Template ${templateName}.hbs not found and generic template page.hbs not found. Cannot proceed without template.`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`模板 ${templateName}.hbs 不存在,使用通用模板 page.hbs 代替`);
|
||||
const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8');
|
||||
const genericTemplate = handlebars.compile(genericTemplateContent);
|
||||
|
||||
const enhancedData = {
|
||||
...data,
|
||||
pageId: data && data.pageId ? data.pageId : templateName,
|
||||
};
|
||||
|
||||
const pageContent = genericTemplate(enhancedData);
|
||||
if (!useLayout) return pageContent;
|
||||
|
||||
const { template: layoutTemplate } = getDefaultLayoutTemplate();
|
||||
return layoutTemplate({ ...enhancedData, body: pageContent });
|
||||
}
|
||||
|
||||
try {
|
||||
const templateContent = fs.readFileSync(templatePath, 'utf8');
|
||||
const template = handlebars.compile(templateContent);
|
||||
|
||||
const pageContent = template(data);
|
||||
if (!useLayout) return pageContent;
|
||||
|
||||
const { template: layoutTemplate } = getDefaultLayoutTemplate();
|
||||
return layoutTemplate({ ...data, body: pageContent });
|
||||
} catch (error) {
|
||||
throw new Error(`Error rendering template ${templateName}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handlebars,
|
||||
loadHandlebarsTemplates,
|
||||
getDefaultLayoutTemplate,
|
||||
renderTemplate,
|
||||
};
|
||||
17
src/generator/utils/html.js
Normal file
17
src/generator/utils/html.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// HTML 转义函数,防止 XSS 攻击
|
||||
function escapeHtml(unsafe) {
|
||||
if (unsafe === undefined || unsafe === null) {
|
||||
return '';
|
||||
}
|
||||
return String(unsafe)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/\"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
escapeHtml,
|
||||
};
|
||||
|
||||
101
src/generator/utils/pageMeta.js
Normal file
101
src/generator/utils/pageMeta.js
Normal file
@@ -0,0 +1,101 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
|
||||
/**
|
||||
* 解析页面配置文件路径(优先 user,回退 _default)
|
||||
* 注意:仅用于构建期读取文件元信息,不会把路径注入到页面/扩展配置中。
|
||||
* @param {string} pageId 页面ID(与 pages/<id>.yml 文件名对应)
|
||||
* @returns {string|null} 文件路径或 null
|
||||
*/
|
||||
function resolvePageConfigFilePath(pageId) {
|
||||
if (!pageId) return null;
|
||||
|
||||
const candidates = [
|
||||
path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yml`),
|
||||
path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yaml`),
|
||||
path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yml`),
|
||||
path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yaml`),
|
||||
];
|
||||
|
||||
for (const filePath of candidates) {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) return filePath;
|
||||
} catch (e) {
|
||||
// 忽略 IO 异常,继续尝试下一个候选
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试获取文件最后一次 git 提交时间(ISO 字符串)
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string|null} ISO 字符串(UTC),失败返回 null
|
||||
*/
|
||||
function tryGetGitLastCommitIso(filePath) {
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/');
|
||||
const output = execFileSync('git', ['log', '-1', '--format=%cI', '--', relativePath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
});
|
||||
const raw = String(output || '').trim();
|
||||
if (!raw) return null;
|
||||
|
||||
const date = new Date(raw);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
return date.toISOString();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件 mtime(ISO 字符串)
|
||||
* @param {string} filePath 文件路径
|
||||
* @returns {string|null} ISO 字符串(UTC),失败返回 null
|
||||
*/
|
||||
function tryGetFileMtimeIso(filePath) {
|
||||
if (!filePath) return null;
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(filePath);
|
||||
const mtime = stats && stats.mtime ? stats.mtime : null;
|
||||
if (!(mtime instanceof Date) || Number.isNaN(mtime.getTime())) return null;
|
||||
return mtime.toISOString();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算页面配置文件“内容更新时间”(优先 git,回退 mtime)
|
||||
* @param {string} pageId 页面ID
|
||||
* @returns {{updatedAt: string, updatedAtSource: 'git'|'mtime'}|null}
|
||||
*/
|
||||
function getPageConfigUpdatedAtMeta(pageId) {
|
||||
const filePath = resolvePageConfigFilePath(pageId);
|
||||
if (!filePath) return null;
|
||||
|
||||
const gitIso = tryGetGitLastCommitIso(filePath);
|
||||
if (gitIso) {
|
||||
return { updatedAt: gitIso, updatedAtSource: 'git' };
|
||||
}
|
||||
|
||||
const mtimeIso = tryGetFileMtimeIso(filePath);
|
||||
if (mtimeIso) {
|
||||
return { updatedAt: mtimeIso, updatedAtSource: 'mtime' };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPageConfigUpdatedAtMeta,
|
||||
};
|
||||
|
||||
35
src/generator/utils/sites.js
Normal file
35
src/generator/utils/sites.js
Normal file
@@ -0,0 +1,35 @@
|
||||
function normalizeUrlKey(input) {
|
||||
if (!input) return '';
|
||||
try {
|
||||
const u = new URL(String(input));
|
||||
const origin = u.origin;
|
||||
let pathname = u.pathname || '/';
|
||||
// 统一去掉末尾斜杠(根路径除外),避免 https://a.com 与 https://a.com/ 不匹配
|
||||
if (pathname !== '/' && pathname.endsWith('/')) pathname = pathname.slice(0, -1);
|
||||
return `${origin}${pathname}`;
|
||||
} catch {
|
||||
return String(input).trim();
|
||||
}
|
||||
}
|
||||
|
||||
function collectSitesRecursively(node, output) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
|
||||
if (Array.isArray(node.subcategories))
|
||||
node.subcategories.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.groups)) node.groups.forEach((child) => collectSitesRecursively(child, output));
|
||||
if (Array.isArray(node.subgroups))
|
||||
node.subgroups.forEach((child) => collectSitesRecursively(child, output));
|
||||
|
||||
if (Array.isArray(node.sites)) {
|
||||
node.sites.forEach((site) => {
|
||||
if (site && typeof site === 'object') output.push(site);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeUrlKey,
|
||||
collectSitesRecursively,
|
||||
};
|
||||
|
||||
112
src/runtime/app/index.js
Normal file
112
src/runtime/app/index.js
Normal file
@@ -0,0 +1,112 @@
|
||||
const initUi = require('./ui');
|
||||
const initSearch = require('./search');
|
||||
const initRouting = require('./routing');
|
||||
|
||||
function detectHomePageId() {
|
||||
// 首页不再固定为 "home":以导航顺序第一项为准
|
||||
// 1) 优先从生成端注入的配置数据读取(保持与实际导航顺序一致)
|
||||
try {
|
||||
const config =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function' ? window.MeNav.getConfig() : null;
|
||||
const injectedHomePageId =
|
||||
config && config.data && config.data.homePageId ? String(config.data.homePageId).trim() : '';
|
||||
if (injectedHomePageId) return injectedHomePageId;
|
||||
const nav = config && config.data && Array.isArray(config.data.navigation) ? config.data.navigation : null;
|
||||
const firstId = nav && nav[0] && nav[0].id ? String(nav[0].id).trim() : '';
|
||||
if (firstId) return firstId;
|
||||
} catch (error) {
|
||||
// 忽略解析错误,继续使用 DOM 推断
|
||||
}
|
||||
|
||||
// 2) 回退到 DOM:取首个导航项的 data-page
|
||||
const firstNavItem = document.querySelector('.nav-item[data-page]');
|
||||
if (firstNavItem) {
|
||||
const id = String(firstNavItem.getAttribute('data-page') || '').trim();
|
||||
if (id) return id;
|
||||
}
|
||||
|
||||
// 3) 最后兜底:取首个页面容器 id
|
||||
const firstPage = document.querySelector('.page[id]');
|
||||
if (firstPage && firstPage.id) return firstPage.id;
|
||||
|
||||
return 'home';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const homePageId = detectHomePageId();
|
||||
|
||||
const state = {
|
||||
homePageId,
|
||||
currentPageId: homePageId,
|
||||
isInitialLoad: true,
|
||||
isSidebarOpen: false,
|
||||
isLightTheme: false,
|
||||
isSidebarCollapsed: false,
|
||||
pages: null,
|
||||
currentSearchEngine: 'local',
|
||||
isSearchActive: false,
|
||||
searchIndex: {
|
||||
initialized: false,
|
||||
items: [],
|
||||
},
|
||||
};
|
||||
|
||||
// 获取 DOM 元素 - 基本元素
|
||||
const searchInput = document.getElementById('search');
|
||||
const searchBox = document.querySelector('.search-box');
|
||||
const searchResultsPage = document.getElementById('search-results');
|
||||
const searchSections = searchResultsPage.querySelectorAll('.search-section');
|
||||
|
||||
// 搜索引擎相关元素
|
||||
const searchEngineToggle = document.querySelector('.search-engine-toggle');
|
||||
const searchEngineToggleIcon = searchEngineToggle
|
||||
? searchEngineToggle.querySelector('.search-engine-icon')
|
||||
: null;
|
||||
const searchEngineToggleLabel = searchEngineToggle
|
||||
? searchEngineToggle.querySelector('.search-engine-label')
|
||||
: null;
|
||||
const searchEngineDropdown = document.querySelector('.search-engine-dropdown');
|
||||
const searchEngineOptions = document.querySelectorAll('.search-engine-option');
|
||||
|
||||
// 移动端元素
|
||||
const menuToggle = document.querySelector('.menu-toggle');
|
||||
const searchToggle = document.querySelector('.search-toggle');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
const searchContainer = document.querySelector('.search-container');
|
||||
const overlay = document.querySelector('.overlay');
|
||||
|
||||
// 侧边栏折叠功能
|
||||
const sidebarToggle = document.querySelector('.sidebar-toggle');
|
||||
const content = document.querySelector('.content');
|
||||
|
||||
// 主题切换元素
|
||||
const themeToggle = document.querySelector('.theme-toggle');
|
||||
const themeIcon = themeToggle.querySelector('i');
|
||||
|
||||
const dom = {
|
||||
searchInput,
|
||||
searchBox,
|
||||
searchResultsPage,
|
||||
searchSections,
|
||||
searchEngineToggle,
|
||||
searchEngineToggleIcon,
|
||||
searchEngineToggleLabel,
|
||||
searchEngineDropdown,
|
||||
searchEngineOptions,
|
||||
menuToggle,
|
||||
searchToggle,
|
||||
sidebar,
|
||||
searchContainer,
|
||||
overlay,
|
||||
sidebarToggle,
|
||||
content,
|
||||
themeToggle,
|
||||
themeIcon,
|
||||
};
|
||||
|
||||
const ui = initUi(state, dom);
|
||||
const search = initSearch(state, dom);
|
||||
|
||||
initRouting(state, dom, { ui, search });
|
||||
});
|
||||
|
||||
403
src/runtime/app/routing.js
Normal file
403
src/runtime/app/routing.js
Normal file
@@ -0,0 +1,403 @@
|
||||
const nested = require('../nested');
|
||||
|
||||
module.exports = function initRouting(state, dom, api) {
|
||||
const { ui, search } = api;
|
||||
const { searchInput, content } = dom;
|
||||
|
||||
function showPage(pageId, skipSearchReset = false) {
|
||||
if (state.currentPageId === pageId && !skipSearchReset && !state.isInitialLoad) return;
|
||||
|
||||
state.currentPageId = pageId;
|
||||
|
||||
// 使用 RAF 确保动画流畅
|
||||
requestAnimationFrame(() => {
|
||||
if (!state.pages) {
|
||||
state.pages = document.querySelectorAll('.page');
|
||||
}
|
||||
|
||||
state.pages.forEach((page) => {
|
||||
const shouldBeActive = page.id === pageId;
|
||||
if (shouldBeActive !== page.classList.contains('active')) {
|
||||
page.classList.toggle('active', shouldBeActive);
|
||||
}
|
||||
});
|
||||
|
||||
// 初始加载完成后设置标志
|
||||
if (state.isInitialLoad) {
|
||||
state.isInitialLoad = false;
|
||||
document.body.classList.add('loaded');
|
||||
}
|
||||
});
|
||||
|
||||
// 重置滚动位置并更新进度条
|
||||
content.scrollTop = 0;
|
||||
|
||||
// 只有在非搜索状态下才重置搜索
|
||||
if (!skipSearchReset) {
|
||||
searchInput.value = '';
|
||||
search.resetSearch();
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化(在 window load 时执行)
|
||||
window.addEventListener('load', () => {
|
||||
// 获取可能在 HTML 生成后才存在的 DOM 元素
|
||||
const categories = document.querySelectorAll('.category');
|
||||
const navItems = document.querySelectorAll('.nav-item');
|
||||
const navItemWrappers = document.querySelectorAll('.nav-item-wrapper');
|
||||
const submenuItems = document.querySelectorAll('.submenu-item');
|
||||
state.pages = document.querySelectorAll('.page');
|
||||
|
||||
// 方案 A:用 ?page=<id> 作为页面深链接(兼容 GitHub Pages 静态托管)
|
||||
const normalizeText = (value) => String(value === null || value === undefined ? '' : value).trim();
|
||||
|
||||
const isValidPageId = (pageId) => {
|
||||
const id = normalizeText(pageId);
|
||||
if (!id) return false;
|
||||
const el = document.getElementById(id);
|
||||
return Boolean(el && el.classList && el.classList.contains('page'));
|
||||
};
|
||||
|
||||
const getRawPageIdFromUrl = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
return normalizeText(url.searchParams.get('page'));
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getPageIdFromUrl = () => {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
const pageId = normalizeText(url.searchParams.get('page'));
|
||||
return isValidPageId(pageId) ? pageId : '';
|
||||
} catch (error) {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const setUrlState = (next, options = {}) => {
|
||||
const { replace = true } = options;
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (next && typeof next.pageId === 'string') {
|
||||
const pageId = normalizeText(next.pageId);
|
||||
if (pageId) {
|
||||
url.searchParams.set('page', pageId);
|
||||
} else {
|
||||
url.searchParams.delete('page');
|
||||
}
|
||||
}
|
||||
|
||||
if (next && Object.prototype.hasOwnProperty.call(next, 'hash')) {
|
||||
const hash = normalizeText(next.hash);
|
||||
url.hash = hash ? `#${hash}` : '';
|
||||
}
|
||||
|
||||
const nextUrl = `${url.pathname}${url.search}${url.hash}`;
|
||||
const fn = replace ? history.replaceState : history.pushState;
|
||||
fn.call(history, null, '', nextUrl);
|
||||
} catch (error) {
|
||||
// 忽略 URL/History API 异常,避免影响主流程
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveNavByPageId = (pageId) => {
|
||||
const id = normalizeText(pageId);
|
||||
let activeItem = null;
|
||||
|
||||
navItems.forEach((nav) => {
|
||||
const isActive = nav.getAttribute('data-page') === id;
|
||||
nav.classList.toggle('active', isActive);
|
||||
if (isActive) activeItem = nav;
|
||||
});
|
||||
|
||||
// 同步子菜单展开状态:只展开当前激活项
|
||||
navItemWrappers.forEach((wrapper) => {
|
||||
const nav = wrapper.querySelector('.nav-item');
|
||||
if (!nav) return;
|
||||
const hasSubmenu = Boolean(wrapper.querySelector('.submenu'));
|
||||
const shouldExpand = hasSubmenu && nav === activeItem;
|
||||
wrapper.classList.toggle('expanded', shouldExpand);
|
||||
});
|
||||
};
|
||||
|
||||
const escapeSelector = (value) => {
|
||||
if (value === null || value === undefined) return '';
|
||||
const text = String(value);
|
||||
if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(text);
|
||||
// 回退:尽量避免打断选择器(不追求完全覆盖所有边界字符)
|
||||
return text.replace(/[^a-zA-Z0-9_\u00A0-\uFFFF-]/g, '\\$&');
|
||||
};
|
||||
|
||||
const escapeAttrValue = (value) => {
|
||||
if (value === null || value === undefined) return '';
|
||||
return String(value).replace(/\\/g, '\\\\').replace(/\"/g, '\\"');
|
||||
};
|
||||
|
||||
const getHashFromUrl = () => {
|
||||
const rawHash = window.location.hash ? String(window.location.hash).slice(1) : '';
|
||||
if (!rawHash) return '';
|
||||
try {
|
||||
return decodeURIComponent(rawHash).trim();
|
||||
} catch (error) {
|
||||
return rawHash.trim();
|
||||
}
|
||||
};
|
||||
|
||||
const scrollToCategoryInPage = (pageId, options = {}) => {
|
||||
const id = normalizeText(pageId);
|
||||
if (!id) return false;
|
||||
|
||||
const targetPage = document.getElementById(id);
|
||||
if (!targetPage) return false;
|
||||
|
||||
const categoryId = normalizeText(options.categoryId);
|
||||
const categoryName = normalizeText(options.categoryName);
|
||||
|
||||
let targetCategory = null;
|
||||
|
||||
// 优先使用 slug/data-id 精准定位(解决重复命名始终命中第一个的问题)
|
||||
if (categoryId) {
|
||||
const escapedId = escapeSelector(categoryId);
|
||||
targetCategory =
|
||||
targetPage.querySelector(`#${escapedId}`) ||
|
||||
targetPage.querySelector(`[data-type="category"][data-id="${escapeAttrValue(categoryId)}"]`);
|
||||
}
|
||||
|
||||
// 回退:旧逻辑按文本包含匹配(兼容旧页面/旧数据)
|
||||
if (!targetCategory && categoryName) {
|
||||
targetCategory = Array.from(targetPage.querySelectorAll('.category h2')).find((heading) =>
|
||||
heading.textContent.trim().includes(categoryName)
|
||||
);
|
||||
}
|
||||
|
||||
if (!targetCategory) return false;
|
||||
|
||||
// 优化的滚动实现:滚动到使目标分类位于视口 1/4 处(更靠近顶部位置)
|
||||
try {
|
||||
// 直接获取所需元素和属性,减少重复查询
|
||||
const contentElement = document.querySelector('.content');
|
||||
|
||||
if (contentElement && contentElement.scrollHeight > contentElement.clientHeight) {
|
||||
// 获取目标元素相对于内容区域的位置
|
||||
const rect = targetCategory.getBoundingClientRect();
|
||||
const containerRect = contentElement.getBoundingClientRect();
|
||||
|
||||
// 计算目标应该在视口中的位置(视口高度的 1/4 处)
|
||||
const desiredPosition = containerRect.height / 4;
|
||||
|
||||
// 计算需要滚动的位置
|
||||
const scrollPosition = contentElement.scrollTop + rect.top - containerRect.top - desiredPosition;
|
||||
|
||||
// 执行滚动
|
||||
contentElement.scrollTo({
|
||||
top: scrollPosition,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
} else {
|
||||
// 回退到基本滚动方式
|
||||
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error during scroll:', error);
|
||||
// 回退到基本滚动方式
|
||||
targetCategory.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
// 初始化主题
|
||||
ui.initTheme();
|
||||
|
||||
// 初始化侧边栏状态
|
||||
ui.initSidebarState();
|
||||
|
||||
// 初始化搜索引擎选择
|
||||
search.initSearchEngine();
|
||||
|
||||
// 初始化 MeNav 对象版本信息
|
||||
try {
|
||||
const config = window.MeNav.getConfig();
|
||||
if (config && config.version) {
|
||||
window.MeNav.version = config.version;
|
||||
console.log('MeNav API initialized with version:', config.version);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing MeNav API:', error);
|
||||
}
|
||||
|
||||
// 立即执行初始化,不再使用 requestAnimationFrame 延迟
|
||||
// 支持 ?page=<id> 直接打开对应页面;无效时回退到首页
|
||||
const rawPageIdFromUrl = getRawPageIdFromUrl();
|
||||
const validatedPageIdFromUrl = getPageIdFromUrl();
|
||||
const initialPageId = validatedPageIdFromUrl || (isValidPageId(state.homePageId) ? state.homePageId : 'home');
|
||||
|
||||
setActiveNavByPageId(initialPageId);
|
||||
showPage(initialPageId);
|
||||
|
||||
// 当输入了不存在的 page id 时,自动纠正 URL,避免“内容回退但地址栏仍错误”
|
||||
if (rawPageIdFromUrl && !validatedPageIdFromUrl) {
|
||||
setUrlState({ pageId: initialPageId }, { replace: true });
|
||||
}
|
||||
|
||||
// 初始深链接:支持 ?page=<id>#<categorySlug>
|
||||
const initialHash = getHashFromUrl();
|
||||
if (initialHash) {
|
||||
setTimeout(() => {
|
||||
const found = scrollToCategoryInPage(initialPageId, {
|
||||
categoryId: initialHash,
|
||||
categoryName: initialHash,
|
||||
});
|
||||
|
||||
// hash 存在但未命中时,不做强制修正,避免误伤其他用途的 hash
|
||||
if (!found) return;
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 添加载入动画
|
||||
categories.forEach((category, index) => {
|
||||
setTimeout(() => {
|
||||
category.style.opacity = '1';
|
||||
}, index * 100);
|
||||
});
|
||||
|
||||
// 初始展开当前页面的子菜单:高亮项如果有子菜单,需要同步展开
|
||||
document.querySelectorAll('.nav-item.active').forEach((activeItem) => {
|
||||
const activeWrapper = activeItem.closest('.nav-item-wrapper');
|
||||
if (!activeWrapper) return;
|
||||
|
||||
const hasSubmenu = activeWrapper.querySelector('.submenu');
|
||||
if (hasSubmenu) {
|
||||
activeWrapper.classList.add('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// 导航项点击效果
|
||||
navItems.forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
if (item.getAttribute('target') === '_blank') return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
// 获取当前项的父级 wrapper
|
||||
const wrapper = item.closest('.nav-item-wrapper');
|
||||
const hasSubmenu = wrapper && wrapper.querySelector('.submenu');
|
||||
|
||||
// 处理子菜单展开/折叠
|
||||
if (hasSubmenu) {
|
||||
// 如果点击的导航项已经激活且有子菜单,则切换子菜单展开状态
|
||||
if (item.classList.contains('active')) {
|
||||
wrapper.classList.toggle('expanded');
|
||||
} else {
|
||||
// 关闭所有已展开的子菜单
|
||||
navItemWrappers.forEach((navWrapper) => {
|
||||
if (navWrapper !== wrapper) {
|
||||
navWrapper.classList.remove('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// 展开当前子菜单
|
||||
wrapper.classList.add('expanded');
|
||||
}
|
||||
}
|
||||
|
||||
// 激活导航项
|
||||
navItems.forEach((nav) => {
|
||||
nav.classList.toggle('active', nav === item);
|
||||
});
|
||||
|
||||
const pageId = item.getAttribute('data-page');
|
||||
if (pageId) {
|
||||
const prevPageId = state.currentPageId;
|
||||
showPage(pageId);
|
||||
|
||||
// 切换页面时同步 URL(清空旧 hash,避免跨页残留)
|
||||
if (normalizeText(prevPageId) !== normalizeText(pageId)) {
|
||||
setUrlState({ pageId, hash: '' }, { replace: true });
|
||||
}
|
||||
|
||||
// 在移动端视图下点击导航项后自动收起侧边栏
|
||||
if (ui.isMobile() && state.isSidebarOpen && !hasSubmenu) {
|
||||
ui.closeAllPanels();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 子菜单项点击效果
|
||||
submenuItems.forEach((item) => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// 获取页面 ID 和分类名称
|
||||
const pageId = item.getAttribute('data-page');
|
||||
const categoryName = item.getAttribute('data-category');
|
||||
const categoryId = item.getAttribute('data-category-id');
|
||||
|
||||
if (pageId) {
|
||||
// 清除所有子菜单项的激活状态
|
||||
submenuItems.forEach((subItem) => {
|
||||
subItem.classList.remove('active');
|
||||
});
|
||||
|
||||
// 激活当前子菜单项
|
||||
item.classList.add('active');
|
||||
|
||||
// 激活相应的导航项
|
||||
navItems.forEach((nav) => {
|
||||
nav.classList.toggle('active', nav.getAttribute('data-page') === pageId);
|
||||
});
|
||||
|
||||
// 显示对应页面
|
||||
showPage(pageId);
|
||||
// 先同步 page 参数并清空旧 hash,避免跨页残留;后续若找到分类再写入新的 hash
|
||||
setUrlState({ pageId, hash: '' }, { replace: true });
|
||||
|
||||
// 等待页面切换完成后滚动到对应分类
|
||||
setTimeout(() => {
|
||||
const found = scrollToCategoryInPage(pageId, { categoryId, categoryName });
|
||||
if (!found) return;
|
||||
|
||||
// 由于对子菜单 click 做了 preventDefault,这里手动同步 hash(不触发浏览器默认跳转)
|
||||
const nextHash = normalizeText(categoryId) || normalizeText(categoryName);
|
||||
if (nextHash) {
|
||||
setUrlState({ pageId, hash: nextHash }, { replace: true });
|
||||
}
|
||||
}, 25); // 延迟时间
|
||||
|
||||
// 在移动端视图下点击子菜单项后自动收起侧边栏
|
||||
if (ui.isMobile() && state.isSidebarOpen) {
|
||||
ui.closeAllPanels();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 初始化嵌套分类功能
|
||||
nested.initializeNestedCategories();
|
||||
|
||||
// 初始化分类切换按钮
|
||||
const categoryToggleBtn = document.getElementById('category-toggle');
|
||||
if (categoryToggleBtn) {
|
||||
categoryToggleBtn.addEventListener('click', function () {
|
||||
window.MeNav.toggleCategories();
|
||||
});
|
||||
} else {
|
||||
console.error('Category toggle button not found');
|
||||
}
|
||||
|
||||
// 初始化搜索索引(使用 requestIdleCallback 或 setTimeout 延迟初始化,避免影响页面加载)
|
||||
if ('requestIdleCallback' in window) {
|
||||
requestIdleCallback(() => search.initSearchIndex());
|
||||
} else {
|
||||
setTimeout(search.initSearchIndex, 1000);
|
||||
}
|
||||
});
|
||||
|
||||
return { showPage };
|
||||
};
|
||||
|
||||
440
src/runtime/app/search.js
Normal file
440
src/runtime/app/search.js
Normal file
@@ -0,0 +1,440 @@
|
||||
const searchEngines = require('./searchEngines');
|
||||
const highlightSearchTerm = require('./search/highlight');
|
||||
|
||||
module.exports = function initSearch(state, dom) {
|
||||
const {
|
||||
searchInput,
|
||||
searchBox,
|
||||
searchResultsPage,
|
||||
searchSections,
|
||||
searchEngineToggle,
|
||||
searchEngineToggleIcon,
|
||||
searchEngineToggleLabel,
|
||||
searchEngineDropdown,
|
||||
searchEngineOptions,
|
||||
} = dom;
|
||||
|
||||
if (!state.searchIndex) {
|
||||
state.searchIndex = { initialized: false, items: [] };
|
||||
}
|
||||
if (!state.currentSearchEngine) {
|
||||
state.currentSearchEngine = 'local';
|
||||
}
|
||||
if (typeof state.isSearchActive !== 'boolean') {
|
||||
state.isSearchActive = false;
|
||||
}
|
||||
|
||||
// 初始化搜索索引
|
||||
function initSearchIndex() {
|
||||
if (state.searchIndex.initialized) return;
|
||||
|
||||
state.searchIndex.items = [];
|
||||
|
||||
try {
|
||||
// 为每个页面创建索引
|
||||
if (!state.pages) {
|
||||
state.pages = document.querySelectorAll('.page');
|
||||
}
|
||||
|
||||
state.pages.forEach((page) => {
|
||||
if (page.id === 'search-results') return;
|
||||
|
||||
const pageId = page.id;
|
||||
|
||||
page.querySelectorAll('.site-card').forEach((card) => {
|
||||
try {
|
||||
// 排除“扩展写回影子结构”等不应参与搜索的卡片
|
||||
if (card.closest('[data-search-exclude="true"]')) return;
|
||||
|
||||
// 兼容不同页面/卡片样式:优先取可见文本,其次回退到 data-*(确保 projects repo 卡片也能被搜索)
|
||||
const dataTitle = card.dataset?.name || card.getAttribute('data-name') || '';
|
||||
const dataDescription = card.dataset?.description || card.getAttribute('data-description') || '';
|
||||
|
||||
const titleText =
|
||||
card.querySelector('h3')?.textContent || card.querySelector('.repo-title')?.textContent || dataTitle;
|
||||
const descriptionText =
|
||||
card.querySelector('p')?.textContent || card.querySelector('.repo-desc')?.textContent || dataDescription;
|
||||
|
||||
const title = String(titleText || '').toLowerCase();
|
||||
const description = String(descriptionText || '').toLowerCase();
|
||||
const url = card.href || card.getAttribute('href') || '#';
|
||||
const icon =
|
||||
card.querySelector('i.icon-fallback')?.className || card.querySelector('i')?.className || '';
|
||||
|
||||
// 将卡片信息添加到索引中
|
||||
state.searchIndex.items.push({
|
||||
pageId,
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
icon,
|
||||
element: card,
|
||||
// 预先计算搜索文本,提高搜索效率
|
||||
searchText: (title + ' ' + description).toLowerCase(),
|
||||
});
|
||||
} catch (cardError) {
|
||||
console.error('Error processing card:', cardError);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
state.searchIndex.initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Error initializing search index:', error);
|
||||
state.searchIndex.initialized = true; // 防止反复尝试初始化
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索功能
|
||||
function performSearch(searchTerm) {
|
||||
// 确保搜索索引已初始化
|
||||
if (!state.searchIndex.initialized) {
|
||||
initSearchIndex();
|
||||
}
|
||||
|
||||
searchTerm = searchTerm.toLowerCase().trim();
|
||||
|
||||
// 如果搜索框为空,重置所有内容
|
||||
if (!searchTerm) {
|
||||
resetSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
state.isSearchActive = true;
|
||||
}
|
||||
|
||||
try {
|
||||
// 使用搜索索引进行搜索
|
||||
const searchResults = new Map();
|
||||
let hasResults = false;
|
||||
|
||||
// 使用更高效的搜索算法
|
||||
const matchedItems = state.searchIndex.items.filter((item) => {
|
||||
return item.searchText.includes(searchTerm) || PinyinMatch.match(item.searchText, searchTerm);
|
||||
});
|
||||
|
||||
// 按页面分组结果
|
||||
matchedItems.forEach((item) => {
|
||||
if (!searchResults.has(item.pageId)) {
|
||||
searchResults.set(item.pageId, []);
|
||||
}
|
||||
// 克隆元素以避免修改原始 DOM
|
||||
searchResults.get(item.pageId).push(item.element.cloneNode(true));
|
||||
hasResults = true;
|
||||
});
|
||||
|
||||
// 使用 requestAnimationFrame 批量更新 DOM,减少重排重绘
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
// 清空并隐藏所有搜索区域
|
||||
searchSections.forEach((section) => {
|
||||
try {
|
||||
const grid = section.querySelector('.sites-grid');
|
||||
if (grid) {
|
||||
grid.innerHTML = ''; // 使用 innerHTML 清空,比 removeChild 更高效
|
||||
}
|
||||
section.style.display = 'none';
|
||||
} catch (sectionError) {
|
||||
console.error('Error clearing search section');
|
||||
}
|
||||
});
|
||||
|
||||
// 使用 DocumentFragment 批量添加 DOM 元素,减少重排
|
||||
searchResults.forEach((matches, pageId) => {
|
||||
const section = searchResultsPage.querySelector(`[data-section="${pageId}"]`);
|
||||
if (section) {
|
||||
try {
|
||||
const grid = section.querySelector('.sites-grid');
|
||||
if (grid) {
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
matches.forEach((card) => {
|
||||
// 高亮匹配文本
|
||||
highlightSearchTerm(card, searchTerm);
|
||||
fragment.appendChild(card);
|
||||
});
|
||||
|
||||
grid.appendChild(fragment);
|
||||
section.style.display = 'block';
|
||||
}
|
||||
} catch (gridError) {
|
||||
console.error('Error updating search results grid');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 更新搜索结果页面状态
|
||||
const subtitle = searchResultsPage.querySelector('.subtitle');
|
||||
if (subtitle) {
|
||||
subtitle.textContent = hasResults
|
||||
? `在所有页面中找到 ${matchedItems.length} 个匹配项`
|
||||
: '未找到匹配的结果';
|
||||
}
|
||||
|
||||
// 显示搜索结果页面
|
||||
if (state.currentPageId !== 'search-results') {
|
||||
state.currentPageId = 'search-results';
|
||||
if (!state.pages) state.pages = document.querySelectorAll('.page');
|
||||
state.pages.forEach((page) => {
|
||||
page.classList.toggle('active', page.id === 'search-results');
|
||||
});
|
||||
}
|
||||
|
||||
// 更新搜索状态样式
|
||||
searchBox.classList.toggle('has-results', hasResults);
|
||||
searchBox.classList.toggle('no-results', !hasResults);
|
||||
} catch (uiError) {
|
||||
console.error('Error updating search UI');
|
||||
}
|
||||
});
|
||||
} catch (searchError) {
|
||||
console.error('Error performing search');
|
||||
}
|
||||
}
|
||||
|
||||
// 重置搜索状态
|
||||
function resetSearch() {
|
||||
if (!state.isSearchActive) return;
|
||||
|
||||
state.isSearchActive = false;
|
||||
|
||||
try {
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
// 清空搜索结果
|
||||
searchSections.forEach((section) => {
|
||||
try {
|
||||
const grid = section.querySelector('.sites-grid');
|
||||
if (grid) {
|
||||
while (grid.firstChild) {
|
||||
grid.removeChild(grid.firstChild);
|
||||
}
|
||||
}
|
||||
section.style.display = 'none';
|
||||
} catch (sectionError) {
|
||||
console.error('Error clearing search section');
|
||||
}
|
||||
});
|
||||
|
||||
// 移除搜索状态样式
|
||||
searchBox.classList.remove('has-results', 'no-results');
|
||||
|
||||
// 恢复到当前激活的页面
|
||||
const currentActiveNav = document.querySelector('.nav-item.active');
|
||||
if (currentActiveNav) {
|
||||
const targetPageId = currentActiveNav.getAttribute('data-page');
|
||||
|
||||
if (targetPageId && state.currentPageId !== targetPageId) {
|
||||
state.currentPageId = targetPageId;
|
||||
if (!state.pages) state.pages = document.querySelectorAll('.page');
|
||||
state.pages.forEach((page) => {
|
||||
page.classList.toggle('active', page.id === targetPageId);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// 如果没有激活的导航项,默认显示首页
|
||||
state.currentPageId = state.homePageId;
|
||||
if (!state.pages) state.pages = document.querySelectorAll('.page');
|
||||
state.pages.forEach((page) => {
|
||||
page.classList.toggle('active', page.id === state.homePageId);
|
||||
});
|
||||
}
|
||||
} catch (resetError) {
|
||||
console.error('Error resetting search UI');
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in resetSearch');
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索输入事件(使用防抖)
|
||||
const debounce = (fn, delay) => {
|
||||
let timer = null;
|
||||
return (...args) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
fn.apply(this, args);
|
||||
timer = null;
|
||||
}, delay);
|
||||
};
|
||||
};
|
||||
|
||||
const debouncedSearch = debounce(performSearch, 300);
|
||||
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
// 只有在选择了本地搜索时,才在输入时实时显示本地搜索结果
|
||||
if (state.currentSearchEngine === 'local') {
|
||||
debouncedSearch(e.target.value);
|
||||
} else {
|
||||
// 对于非本地搜索,重置之前的本地搜索结果(如果有)
|
||||
if (state.isSearchActive) {
|
||||
resetSearch();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 更新搜索引擎 UI 显示
|
||||
function updateSearchEngineUI() {
|
||||
// 移除所有选项的激活状态
|
||||
searchEngineOptions.forEach((option) => {
|
||||
option.classList.remove('active');
|
||||
|
||||
// 如果是当前选中的搜索引擎,添加激活状态
|
||||
if (option.getAttribute('data-engine') === state.currentSearchEngine) {
|
||||
option.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
// 更新搜索引擎按钮(方案 B:前缀按钮显示当前引擎)
|
||||
const engine = searchEngines[state.currentSearchEngine];
|
||||
if (!engine) return;
|
||||
const displayName = engine.shortName || engine.name.replace(/搜索$/, '');
|
||||
|
||||
if (searchEngineToggleIcon) {
|
||||
if (engine.iconSvg) {
|
||||
searchEngineToggleIcon.className = 'search-engine-icon search-engine-icon-svg';
|
||||
searchEngineToggleIcon.innerHTML = engine.iconSvg;
|
||||
} else {
|
||||
searchEngineToggleIcon.innerHTML = '';
|
||||
searchEngineToggleIcon.className = `search-engine-icon ${engine.icon}`;
|
||||
}
|
||||
}
|
||||
if (searchEngineToggleLabel) {
|
||||
searchEngineToggleLabel.textContent = displayName;
|
||||
}
|
||||
if (searchEngineToggle) {
|
||||
searchEngineToggle.setAttribute('aria-label', `当前搜索引擎:${engine.name},点击切换`);
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化搜索引擎设置
|
||||
function initSearchEngine() {
|
||||
// 从本地存储获取上次选择的搜索引擎
|
||||
const savedEngine = localStorage.getItem('searchEngine');
|
||||
if (savedEngine && searchEngines[savedEngine]) {
|
||||
state.currentSearchEngine = savedEngine;
|
||||
}
|
||||
|
||||
// 设置当前搜索引擎的激活状态及图标
|
||||
updateSearchEngineUI();
|
||||
|
||||
// 初始化搜索引擎下拉菜单事件
|
||||
const toggleEngineDropdown = () => {
|
||||
if (!searchEngineDropdown) return;
|
||||
const next = !searchEngineDropdown.classList.contains('active');
|
||||
searchEngineDropdown.classList.toggle('active', next);
|
||||
if (searchBox) {
|
||||
searchBox.classList.toggle('dropdown-open', next);
|
||||
}
|
||||
if (searchEngineToggle) {
|
||||
searchEngineToggle.setAttribute('aria-expanded', String(next));
|
||||
}
|
||||
};
|
||||
|
||||
if (searchEngineToggle) {
|
||||
searchEngineToggle.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleEngineDropdown();
|
||||
});
|
||||
|
||||
// 键盘可访问性:Enter/Space 触发
|
||||
searchEngineToggle.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleEngineDropdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 点击搜索引擎选项
|
||||
searchEngineOptions.forEach((option) => {
|
||||
// 初始化激活状态
|
||||
if (option.getAttribute('data-engine') === state.currentSearchEngine) {
|
||||
option.classList.add('active');
|
||||
}
|
||||
|
||||
option.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// 获取选中的搜索引擎
|
||||
const engine = option.getAttribute('data-engine');
|
||||
|
||||
// 更新当前搜索引擎
|
||||
if (engine && searchEngines[engine]) {
|
||||
// 如果搜索引擎变更,且之前有活跃的本地搜索结果,重置搜索状态
|
||||
if (state.currentSearchEngine !== engine && state.isSearchActive) {
|
||||
resetSearch();
|
||||
}
|
||||
|
||||
state.currentSearchEngine = engine;
|
||||
localStorage.setItem('searchEngine', engine);
|
||||
|
||||
// 更新 UI 显示
|
||||
updateSearchEngineUI();
|
||||
|
||||
// 关闭下拉菜单
|
||||
if (searchEngineDropdown) {
|
||||
searchEngineDropdown.classList.remove('active');
|
||||
}
|
||||
if (searchBox) {
|
||||
searchBox.classList.remove('dropdown-open');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 点击页面其他位置关闭下拉菜单
|
||||
document.addEventListener('click', () => {
|
||||
if (!searchEngineDropdown) return;
|
||||
searchEngineDropdown.classList.remove('active');
|
||||
if (searchBox) {
|
||||
searchBox.classList.remove('dropdown-open');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 执行搜索(根据选择的搜索引擎)
|
||||
function executeSearch(searchTerm) {
|
||||
if (!searchTerm.trim()) return;
|
||||
|
||||
// 根据当前搜索引擎执行搜索
|
||||
if (state.currentSearchEngine === 'local') {
|
||||
// 执行本地搜索
|
||||
performSearch(searchTerm);
|
||||
} else {
|
||||
// 使用外部搜索引擎
|
||||
const engine = searchEngines[state.currentSearchEngine];
|
||||
if (engine && engine.url) {
|
||||
// 打开新窗口进行搜索
|
||||
window.open(engine.url + encodeURIComponent(searchTerm), '_blank');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索框事件处理
|
||||
searchInput.addEventListener('keyup', (e) => {
|
||||
if (e.key === 'Escape') {
|
||||
searchInput.value = '';
|
||||
resetSearch();
|
||||
} else if (e.key === 'Enter') {
|
||||
executeSearch(searchInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
// 阻止搜索框的回车默认行为
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
initSearchIndex,
|
||||
initSearchEngine,
|
||||
resetSearch,
|
||||
performSearch,
|
||||
};
|
||||
};
|
||||
90
src/runtime/app/search/highlight.js
Normal file
90
src/runtime/app/search/highlight.js
Normal file
@@ -0,0 +1,90 @@
|
||||
module.exports = function highlightSearchTerm(card, searchTerm) {
|
||||
if (!card || !searchTerm) return;
|
||||
|
||||
const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
try {
|
||||
// 兼容 projects repo 卡片:title/desc 不一定是 h3/p
|
||||
const titleElement = card.querySelector('h3') || card.querySelector('.repo-title');
|
||||
const descriptionElement = card.querySelector('p') || card.querySelector('.repo-desc');
|
||||
|
||||
const hasPinyinMatch =
|
||||
typeof PinyinMatch !== 'undefined' && PinyinMatch && typeof PinyinMatch.match === 'function';
|
||||
|
||||
const applyRangeHighlight = (element, start, end) => {
|
||||
const text = element.textContent || '';
|
||||
const safeStart = Math.max(0, Math.min(text.length, start));
|
||||
const safeEnd = Math.max(safeStart, Math.min(text.length - 1, end));
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
fragment.appendChild(document.createTextNode(text.slice(0, safeStart)));
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'highlight';
|
||||
span.textContent = text.slice(safeStart, safeEnd + 1);
|
||||
fragment.appendChild(span);
|
||||
|
||||
fragment.appendChild(document.createTextNode(text.slice(safeEnd + 1)));
|
||||
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
element.appendChild(fragment);
|
||||
};
|
||||
|
||||
const highlightInElement = (element) => {
|
||||
if (!element) return;
|
||||
|
||||
const rawText = element.textContent || '';
|
||||
const lowerText = rawText.toLowerCase();
|
||||
if (!rawText) return;
|
||||
|
||||
if (lowerText.includes(searchTerm)) {
|
||||
const regex = new RegExp(`(${escapeRegExp(searchTerm)})`, 'gi');
|
||||
const fragment = document.createDocumentFragment();
|
||||
let lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(rawText)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
fragment.appendChild(document.createTextNode(rawText.substring(lastIndex, match.index)));
|
||||
}
|
||||
|
||||
const span = document.createElement('span');
|
||||
span.className = 'highlight';
|
||||
span.textContent = match[0];
|
||||
fragment.appendChild(span);
|
||||
|
||||
lastIndex = match.index + match[0].length;
|
||||
|
||||
// 防止无限循环
|
||||
if (regex.lastIndex === 0) break;
|
||||
}
|
||||
|
||||
if (lastIndex < rawText.length) {
|
||||
fragment.appendChild(document.createTextNode(rawText.substring(lastIndex)));
|
||||
}
|
||||
|
||||
while (element.firstChild) {
|
||||
element.removeChild(element.firstChild);
|
||||
}
|
||||
element.appendChild(fragment);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPinyinMatch) {
|
||||
const arr = PinyinMatch.match(rawText, searchTerm);
|
||||
if (Array.isArray(arr) && arr.length >= 2) {
|
||||
const [start, end] = arr;
|
||||
applyRangeHighlight(element, start, end);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
highlightInElement(titleElement);
|
||||
highlightInElement(descriptionElement);
|
||||
} catch (error) {
|
||||
console.error('Error highlighting search term');
|
||||
}
|
||||
};
|
||||
|
||||
25
src/runtime/app/searchEngines.js
Normal file
25
src/runtime/app/searchEngines.js
Normal file
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
local: {
|
||||
name: '本地搜索',
|
||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" focusable="false"><path fill="#616161" d="M29.171,32.001L32,29.172l12.001,12l-2.828,2.828L29.171,32.001z"></path><path fill="#616161" d="M36,20c0,8.837-7.163,16-16,16S4,28.837,4,20S11.163,4,20,4S36,11.163,36,20"></path><path fill="#37474f" d="M32.476,35.307l2.828-2.828l8.693,8.693L41.17,44L32.476,35.307z"></path><path fill="#64b5f6" d="M7,20c0-7.18,5.82-13,13-13s13,5.82,13,13s-5.82,13-13,13S7,27.18,7,20"></path></svg>`,
|
||||
url: null, // 本地搜索不需要URL
|
||||
},
|
||||
google: {
|
||||
name: 'Google搜索',
|
||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" focusable="false"><path fill="#FFC107" d="M43.611,20.083H42V20H24v8h11.303c-1.649,4.657-6.08,8-11.303,8c-6.627,0-12-5.373-12-12c0-6.627,5.373-12,12-12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C12.955,4,4,12.955,4,24c0,11.045,8.955,20,20,20c11.045,0,20-8.955,20-20C44,22.659,43.862,21.35,43.611,20.083z"></path><path fill="#FF3D00" d="M6.306,14.691l6.571,4.819C14.655,15.108,18.961,12,24,12c3.059,0,5.842,1.154,7.961,3.039l5.657-5.657C34.046,6.053,29.268,4,24,4C16.318,4,9.656,8.337,6.306,14.691z"></path><path fill="#4CAF50" d="M24,44c5.166,0,9.86-1.977,13.409-5.192l-6.19-5.238C29.211,35.091,26.715,36,24,36c-5.202,0-9.619-3.317-11.283-7.946l-6.522,5.025C9.505,39.556,16.227,44,24,44z"></path><path fill="#1976D2" d="M43.611,20.083H42V20H24v8h11.303c-0.792,2.237-2.231,4.166-4.087,5.571c0.001-0.001,0.002-0.001,0.003-0.002l6.19,5.238C36.971,39.205,44,34,44,24C44,22.659,43.862,21.35,43.611,20.083z"></path></svg>`,
|
||||
url: 'https://www.google.com/search?q=',
|
||||
},
|
||||
bing: {
|
||||
name: 'Bing搜索',
|
||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" focusable="false"><g fill="#2877fb" fill-rule="nonzero" stroke="none" stroke-width="1" stroke-linecap="butt" stroke-linejoin="miter" stroke-miterlimit="10" stroke-dasharray="" stroke-dashoffset="0" font-family="none" font-weight="none" font-size="none" text-anchor="none" style="mix-blend-mode: normal"><g transform="scale(5.12,5.12)"><path d="M45,26.10156v-5.10156c0,-0.89844 -0.60156,-1.69922 -1.39844,-1.89844l-4.60156,-1.40234c-5.30078,-1.59766 -10.30078,-3 -15.60156,-4.69922h-0.09766c-0.80078,-0.19922 -1.60156,0.69922 -1.19922,1.5c1.89844,3.89844 3.89844,9.5 3.89844,9.5l6.69922,2.60156c-0.30078,0 -21.69922,11.39844 -21.69922,11.39844l9,-8v-23c0,-0.89844 -0.60156,-1.80078 -1.39844,-2c0,0 -4.90234,-1.89844 -8,-2.89844c-0.20312,-0.10156 -0.40234,-0.10156 -0.60156,-0.10156c-0.39844,0 -0.80078,0.10156 -1.19922,0.39844c-0.5,0.40234 -0.80078,1 -0.80078,1.60156v34.69922c0,0.69922 0.30078,1.30078 0.89844,1.60156c2.10156,1.5 4.30078,3 6.40234,4.5l3,2.09766c0.30078,0.20313 0.69922,0.40234 1.09766,0.40234c0.40234,0 0.70313,-0.10156 1,-0.30078c4.30078,-2.60156 8.70313,-5.19922 13,-7.80078l10.60156,-6.30078c0.60156,-0.39844 1,-1 1,-1.69922z"></path></g></g></svg>`,
|
||||
url: 'https://www.bing.com/search?q=',
|
||||
},
|
||||
duckduckgo: {
|
||||
name: 'DuckDuckGo搜索',
|
||||
shortName: 'duckgo',
|
||||
// DuckDuckGo 使用内联 SVG,避免依赖不存在的 Font Awesome 品牌图标
|
||||
iconSvg: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 4 40 40" focusable="false"><path fill="#ff3d00" d="M44,24c0,11-9,20-20,20S4,35,4,24S13,4,24,4S44,13,44,24z"></path><path fill="#fff" d="M26,16.2c-0.6-0.6-1.5-0.9-2.5-1.1c-0.4-0.5-1-1-1.9-1.5c-1.6-0.8-3.5-1.2-5.3-0.9h-0.4 c-0.1,0-0.2,0.1-0.4,0.1c0.2,0,1,0.4,1.6,0.6c-0.3,0.2-0.8,0.2-1.1,0.4c0,0,0,0-0.1,0L15.7,14c-0.1,0.2-0.2,0.4-0.2,0.5 c1.3-0.1,3.2,0,4.6,0.4C19,15,18,15.3,17.3,15.7c-0.5,0.3-1,0.6-1.3,1.1c-1.2,1.3-1.7,3.5-1.3,5.9c0.5,2.7,2.4,11.4,3.4,16.3 l0.3,1.6c0,0,3.5,0.4,5.6,0.4c1.2,0,3.2,0.3,3.7-0.2c-0.1,0-0.6-0.6-0.8-1.1c-0.5-1-1-1.9-1.4-2.6c-1.2-2.5-2.5-5.9-1.9-8.1 c0.1-0.4,0.1-2.1,0.4-2.3c2.6-1.7,2.4-0.1,3.5-0.8c0.5-0.4,1-0.9,1.2-1.5C29.4,22.1,27.8,18,26,16.2z"></path><path fill="#fff" d="M24,42c-9.9,0-18-8.1-18-18c0-9.9,8.1-18,18-18c9.9,0,18,8.1,18,18C42,33.9,33.9,42,24,42z M24,8 C15.2,8,8,15.2,8,24s7.2,16,16,16s16-7.2,16-16S32.8,8,24,8z"></path><path fill="#0277bd" d="M19,21.1c-0.6,0-1.2,0.5-1.2,1.2c0,0.6,0.5,1.2,1.2,1.2c0.6,0,1.2-0.5,1.2-1.2 C20.1,21.7,19.6,21.1,19,21.1z M19.5,22.2c-0.2,0-0.3-0.1-0.3-0.3c0-0.2,0.1-0.3,0.3-0.3s0.3,0.1,0.3,0.3 C19.8,22.1,19.6,22.2,19.5,22.2z M26.8,20.6c-0.6,0-1,0.5-1,1c0,0.6,0.5,1,1,1c0.6,0,1-0.5,1-1S27.3,20.6,26.8,20.6z M27.2,21.5 c-0.1,0-0.3-0.1-0.3-0.3c0-0.1,0.1-0.3,0.3-0.3c0.1,0,0.3,0.1,0.3,0.3S27.4,21.5,27.2,21.5z M19.3,18.9c0,0-0.9-0.4-1.7,0.1 c-0.9,0.5-0.8,1.1-0.8,1.1s-0.5-1,0.8-1.5C18.7,18.1,19.3,18.9,19.3,18.9 M27.4,18.8c0,0-0.6-0.4-1.1-0.4c-1,0-1.3,0.5-1.3,0.5 s0.2-1.1,1.5-0.9C27.1,18.2,27.4,18.8,27.4,18.8"></path><path fill="#8bc34a" d="M23.3,35.7c0,0-4.3-2.3-4.4-1.4c-0.1,0.9,0,4.7,0.5,5s4.1-1.9,4.1-1.9L23.3,35.7z M25,35.6 c0,0,2.9-2.2,3.6-2.1c0.6,0.1,0.8,4.7,0.2,4.9c-0.6,0.2-3.9-1.2-3.9-1.2L25,35.6z"></path><path fill="#689f38" d="M22.5,35.7c0,1.5-0.2,2.1,0.4,2.3c0.6,0.1,1.9,0,2.3-0.3c0.4-0.3,0.1-2.2-0.1-2.6 C25,34.8,22.5,35.1,22.5,35.7"></path><path fill="#ffca28" d="M22.3,26.8c0.1-0.7,2-2.1,3.3-2.2c1.3-0.1,1.7-0.1,2.8-0.3c1.1-0.3,3.9-1,4.7-1.3 c0.8-0.4,4.1,0.2,1.8,1.5c-1,0.6-3.7,1.6-5.7,2.2c-1.9,0.6-3.1-0.6-3.8,0.4c-0.5,0.8-0.1,1.8,2.2,2c3.1,0.3,6.2-1.4,6.5-0.5 c0.3,0.9-2.7,2-4.6,2.1c-1.8,0-5.6-1.2-6.1-1.6C22.9,28.7,22.2,27.8,22.3,26.8"></path></svg>`,
|
||||
url: 'https://duckduckgo.com/?q=',
|
||||
},
|
||||
};
|
||||
|
||||
181
src/runtime/app/ui.js
Normal file
181
src/runtime/app/ui.js
Normal file
@@ -0,0 +1,181 @@
|
||||
module.exports = function initUi(state, dom) {
|
||||
const {
|
||||
searchInput,
|
||||
searchBox,
|
||||
menuToggle,
|
||||
searchToggle,
|
||||
sidebar,
|
||||
searchContainer,
|
||||
overlay,
|
||||
sidebarToggle,
|
||||
content,
|
||||
themeToggle,
|
||||
themeIcon,
|
||||
} = dom;
|
||||
|
||||
// 移除预加载类,允许 CSS 过渡效果
|
||||
document.documentElement.classList.remove('preload');
|
||||
|
||||
// 应用从 localStorage 读取的主题设置(预加载阶段已写入 class)
|
||||
if (document.documentElement.classList.contains('theme-preload')) {
|
||||
document.documentElement.classList.remove('theme-preload');
|
||||
document.body.classList.add('light-theme');
|
||||
state.isLightTheme = true;
|
||||
}
|
||||
|
||||
// 应用从 localStorage 读取的侧边栏状态(预加载阶段已写入 class)
|
||||
if (document.documentElement.classList.contains('sidebar-collapsed-preload')) {
|
||||
document.documentElement.classList.remove('sidebar-collapsed-preload');
|
||||
sidebar.classList.add('collapsed');
|
||||
content.classList.add('expanded');
|
||||
state.isSidebarCollapsed = true;
|
||||
}
|
||||
|
||||
// 即时移除 loading 类,确保侧边栏可见
|
||||
document.body.classList.remove('loading');
|
||||
document.body.classList.add('loaded');
|
||||
|
||||
function isMobile() {
|
||||
return window.innerWidth <= 768;
|
||||
}
|
||||
|
||||
// 侧边栏折叠功能
|
||||
function toggleSidebarCollapse() {
|
||||
// 仅在交互时启用布局相关动画,避免首屏闪烁
|
||||
document.documentElement.classList.add('with-anim');
|
||||
|
||||
state.isSidebarCollapsed = !state.isSidebarCollapsed;
|
||||
|
||||
// 使用 requestAnimationFrame 确保平滑过渡
|
||||
requestAnimationFrame(() => {
|
||||
sidebar.classList.toggle('collapsed', state.isSidebarCollapsed);
|
||||
content.classList.toggle('expanded', state.isSidebarCollapsed);
|
||||
|
||||
// 保存折叠状态到 localStorage
|
||||
localStorage.setItem('sidebarCollapsed', state.isSidebarCollapsed ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
// 初始化侧边栏折叠状态 - 已在页面加载前处理,此处仅完成图标状态初始化等次要任务
|
||||
function initSidebarState() {
|
||||
// 从 localStorage 获取侧边栏状态
|
||||
const savedState = localStorage.getItem('sidebarCollapsed');
|
||||
|
||||
// 图标状态与折叠状态保持一致
|
||||
if (savedState === 'true' && !isMobile()) {
|
||||
state.isSidebarCollapsed = true;
|
||||
} else {
|
||||
state.isSidebarCollapsed = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 主题切换功能
|
||||
function toggleTheme() {
|
||||
state.isLightTheme = !state.isLightTheme;
|
||||
document.body.classList.toggle('light-theme', state.isLightTheme);
|
||||
|
||||
// 更新图标
|
||||
if (state.isLightTheme) {
|
||||
themeIcon.classList.remove('fa-moon');
|
||||
themeIcon.classList.add('fa-sun');
|
||||
} else {
|
||||
themeIcon.classList.remove('fa-sun');
|
||||
themeIcon.classList.add('fa-moon');
|
||||
}
|
||||
|
||||
// 保存主题偏好到 localStorage
|
||||
localStorage.setItem('theme', state.isLightTheme ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
// 初始化主题 - 已在页面加载前处理,此处仅完成图标状态初始化等次要任务
|
||||
function initTheme() {
|
||||
// 从 localStorage 获取主题偏好
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
|
||||
// 更新图标状态以匹配当前主题
|
||||
if (savedTheme === 'light') {
|
||||
state.isLightTheme = true;
|
||||
themeIcon.classList.remove('fa-moon');
|
||||
themeIcon.classList.add('fa-sun');
|
||||
} else {
|
||||
state.isLightTheme = false;
|
||||
themeIcon.classList.remove('fa-sun');
|
||||
themeIcon.classList.add('fa-moon');
|
||||
}
|
||||
}
|
||||
|
||||
// 移动端菜单切换
|
||||
function toggleSidebar() {
|
||||
state.isSidebarOpen = !state.isSidebarOpen;
|
||||
sidebar.classList.toggle('active', state.isSidebarOpen);
|
||||
overlay.classList.toggle('active', state.isSidebarOpen);
|
||||
}
|
||||
|
||||
// 移动端:搜索框常驻显示(CSS 控制),无需“搜索面板”开关;点击仅聚焦输入框
|
||||
function toggleSearch() {
|
||||
searchInput && searchInput.focus();
|
||||
}
|
||||
|
||||
// 关闭所有移动端面板
|
||||
function closeAllPanels() {
|
||||
if (state.isSidebarOpen) {
|
||||
toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
// 侧边栏折叠按钮点击事件
|
||||
if (sidebarToggle) {
|
||||
sidebarToggle.addEventListener('click', toggleSidebarCollapse);
|
||||
}
|
||||
|
||||
// 主题切换按钮点击事件
|
||||
themeToggle.addEventListener('click', toggleTheme);
|
||||
|
||||
// 移动端事件监听
|
||||
menuToggle.addEventListener('click', toggleSidebar);
|
||||
searchToggle.addEventListener('click', toggleSearch);
|
||||
overlay.addEventListener('click', closeAllPanels);
|
||||
|
||||
// 全局快捷键:Ctrl/Cmd + K 聚焦搜索
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const key = (e.key || '').toLowerCase();
|
||||
if (key !== 'k') return;
|
||||
if ((!e.ctrlKey && !e.metaKey) || e.altKey) return;
|
||||
|
||||
const target = e.target;
|
||||
const isTypingTarget =
|
||||
target &&
|
||||
(target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable);
|
||||
|
||||
if (isTypingTarget && target !== searchInput) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
searchInput && searchInput.focus();
|
||||
});
|
||||
|
||||
// 窗口大小改变时处理
|
||||
window.addEventListener('resize', () => {
|
||||
if (!isMobile()) {
|
||||
sidebar.classList.remove('active');
|
||||
searchContainer.classList.remove('active');
|
||||
overlay.classList.remove('active');
|
||||
state.isSidebarOpen = false;
|
||||
} else {
|
||||
// 在移动设备下,重置侧边栏折叠状态
|
||||
sidebar.classList.remove('collapsed');
|
||||
content.classList.remove('expanded');
|
||||
}
|
||||
});
|
||||
|
||||
// 仅用于静态检查:确保未用变量不被 lint 报错(未来可用于搜索 UI 状态)
|
||||
void searchBox;
|
||||
|
||||
return {
|
||||
isMobile,
|
||||
closeAllPanels,
|
||||
initTheme,
|
||||
initSidebarState,
|
||||
};
|
||||
};
|
||||
|
||||
18
src/runtime/index.js
Normal file
18
src/runtime/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
// 运行时入口(由构建阶段打包输出 dist/script.js)
|
||||
const { menavUpdateAppHeight } = require('./shared');
|
||||
|
||||
// 让页面在不同视口(含移动端地址栏变化)下保持正确高度
|
||||
menavUpdateAppHeight();
|
||||
window.addEventListener('resize', menavUpdateAppHeight);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener('resize', menavUpdateAppHeight);
|
||||
}
|
||||
|
||||
// 扩展契约:先初始化 window.MeNav,再挂载 nested API 与应用逻辑
|
||||
require('./menav');
|
||||
require('./nested');
|
||||
require('./app');
|
||||
|
||||
// tooltip 独立模块:内部会按需监听 DOMContentLoaded
|
||||
require('./tooltip');
|
||||
|
||||
351
src/runtime/menav/addElement.js
Normal file
351
src/runtime/menav/addElement.js
Normal file
@@ -0,0 +1,351 @@
|
||||
const { menavExtractDomain, menavSanitizeClassList, menavSanitizeUrl } = require('../shared');
|
||||
|
||||
// 添加新元素
|
||||
module.exports = function addElement(type, parentId, data) {
|
||||
if (type === 'site') {
|
||||
// 查找父级分类
|
||||
const parent = document.querySelector(`[data-type="category"][data-name="${parentId}"]`);
|
||||
if (!parent) return null;
|
||||
|
||||
// 添加站点卡片到分类
|
||||
const sitesContainer = parent.querySelector('[data-container="sites"]');
|
||||
if (!sitesContainer) return null;
|
||||
|
||||
// 站点卡片样式:根据“页面模板”决定(friends/articles/projects 等)
|
||||
let siteCardStyle = '';
|
||||
try {
|
||||
const pageEl = parent.closest('.page');
|
||||
const pageId = pageEl && pageEl.id ? String(pageEl.id).trim() : '';
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function' ? window.MeNav.getConfig() : null;
|
||||
|
||||
let templateName = '';
|
||||
|
||||
const pageTemplates =
|
||||
cfg && cfg.data && cfg.data.pageTemplates && typeof cfg.data.pageTemplates === 'object'
|
||||
? cfg.data.pageTemplates
|
||||
: null;
|
||||
|
||||
const templateFromMap =
|
||||
pageTemplates && pageId && pageTemplates[pageId] ? String(pageTemplates[pageId]).trim() : '';
|
||||
|
||||
// 兼容旧版:cfg.data[pageId].template
|
||||
const legacyPageConfig = cfg && cfg.data && pageId ? cfg.data[pageId] : null;
|
||||
const templateFromLegacy =
|
||||
legacyPageConfig && legacyPageConfig.template ? String(legacyPageConfig.template).trim() : '';
|
||||
|
||||
if (templateFromMap) {
|
||||
templateName = templateFromMap;
|
||||
} else if (templateFromLegacy) {
|
||||
templateName = templateFromLegacy;
|
||||
}
|
||||
|
||||
// projects 模板使用代码仓库风格卡片(与生成端 templates/components/site-card.hbs 保持一致)
|
||||
if (templateName === 'projects') siteCardStyle = 'repo';
|
||||
} catch (e) {
|
||||
siteCardStyle = '';
|
||||
}
|
||||
|
||||
// 创建新的站点卡片
|
||||
const newSite = document.createElement('a');
|
||||
newSite.className = siteCardStyle ? `site-card site-card-${siteCardStyle}` : 'site-card';
|
||||
|
||||
const siteName = data.name || '未命名站点';
|
||||
const siteUrl = data.url || '#';
|
||||
const siteIcon = data.icon || 'fas fa-link';
|
||||
const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : '');
|
||||
const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : '';
|
||||
const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : '';
|
||||
const siteForceIconMode =
|
||||
siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon' ? siteForceIconModeRaw : '';
|
||||
|
||||
const safeSiteUrl = menavSanitizeUrl(siteUrl, 'addElement(site).url');
|
||||
const safeSiteIcon = menavSanitizeClassList(siteIcon, 'addElement(site).icon');
|
||||
|
||||
newSite.setAttribute('href', safeSiteUrl);
|
||||
newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : '');
|
||||
newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : ''));
|
||||
if (/^https?:\/\//i.test(safeSiteUrl)) {
|
||||
newSite.target = '_blank';
|
||||
newSite.rel = 'noopener';
|
||||
}
|
||||
|
||||
// 设置数据属性
|
||||
newSite.setAttribute('data-type', 'site');
|
||||
newSite.setAttribute('data-name', siteName);
|
||||
// 保留原始 URL(data-url)供扩展/调试读取;href 仍会做安全降级
|
||||
newSite.setAttribute('data-url', String(data.url || '').trim());
|
||||
newSite.setAttribute('data-icon', safeSiteIcon);
|
||||
if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl);
|
||||
if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode);
|
||||
newSite.setAttribute('data-description', siteDescription);
|
||||
|
||||
// projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑)
|
||||
if (siteCardStyle === 'repo') {
|
||||
const repoHeader = document.createElement('div');
|
||||
repoHeader.className = 'repo-header';
|
||||
|
||||
const repoIcon = document.createElement('i');
|
||||
repoIcon.className = `${safeSiteIcon || 'fas fa-code'} repo-icon`;
|
||||
repoIcon.setAttribute('aria-hidden', 'true');
|
||||
|
||||
const repoTitle = document.createElement('div');
|
||||
repoTitle.className = 'repo-title';
|
||||
repoTitle.textContent = siteName;
|
||||
|
||||
repoHeader.appendChild(repoIcon);
|
||||
repoHeader.appendChild(repoTitle);
|
||||
|
||||
const repoDesc = document.createElement('div');
|
||||
repoDesc.className = 'repo-desc';
|
||||
repoDesc.textContent = siteDescription;
|
||||
|
||||
newSite.appendChild(repoHeader);
|
||||
newSite.appendChild(repoDesc);
|
||||
|
||||
const hasStats = data && (data.language || data.stars || data.forks || data.issues);
|
||||
|
||||
if (hasStats) {
|
||||
const repoStats = document.createElement('div');
|
||||
repoStats.className = 'repo-stats';
|
||||
|
||||
if (data.language) {
|
||||
const languageItem = document.createElement('div');
|
||||
languageItem.className = 'stat-item';
|
||||
|
||||
const langDot = document.createElement('span');
|
||||
langDot.className = 'lang-dot';
|
||||
langDot.style.backgroundColor = data.languageColor || '#909296';
|
||||
|
||||
languageItem.appendChild(langDot);
|
||||
languageItem.appendChild(document.createTextNode(String(data.language)));
|
||||
repoStats.appendChild(languageItem);
|
||||
}
|
||||
|
||||
if (data.stars) {
|
||||
const starsItem = document.createElement('div');
|
||||
starsItem.className = 'stat-item';
|
||||
|
||||
const starIcon = document.createElement('i');
|
||||
starIcon.className = 'far fa-star';
|
||||
starIcon.setAttribute('aria-hidden', 'true');
|
||||
starsItem.appendChild(starIcon);
|
||||
starsItem.appendChild(document.createTextNode(String(data.stars)));
|
||||
repoStats.appendChild(starsItem);
|
||||
}
|
||||
|
||||
if (data.forks) {
|
||||
const forksItem = document.createElement('div');
|
||||
forksItem.className = 'stat-item';
|
||||
|
||||
const forkIcon = document.createElement('i');
|
||||
forkIcon.className = 'fas fa-code-branch';
|
||||
forkIcon.setAttribute('aria-hidden', 'true');
|
||||
forksItem.appendChild(forkIcon);
|
||||
forksItem.appendChild(document.createTextNode(String(data.forks)));
|
||||
repoStats.appendChild(forksItem);
|
||||
}
|
||||
|
||||
if (data.issues) {
|
||||
const issuesItem = document.createElement('div');
|
||||
issuesItem.className = 'stat-item';
|
||||
|
||||
const issueIcon = document.createElement('i');
|
||||
issueIcon.className = 'fas fa-exclamation-circle';
|
||||
issueIcon.setAttribute('aria-hidden', 'true');
|
||||
issuesItem.appendChild(issueIcon);
|
||||
issuesItem.appendChild(document.createTextNode(String(data.issues)));
|
||||
repoStats.appendChild(issuesItem);
|
||||
}
|
||||
|
||||
newSite.appendChild(repoStats);
|
||||
}
|
||||
} else {
|
||||
// 普通站点卡片:复用现有结构(支持 favicon)
|
||||
const siteCardIcon = document.createElement('div');
|
||||
siteCardIcon.className = 'site-card-icon';
|
||||
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = safeSiteIcon || 'fas fa-link';
|
||||
iconEl.setAttribute('aria-hidden', 'true');
|
||||
|
||||
// 添加内容(根据图标模式渲染,避免 innerHTML 注入)
|
||||
siteCardIcon.appendChild(iconEl);
|
||||
|
||||
const titleEl = document.createElement('h3');
|
||||
titleEl.textContent = siteName;
|
||||
|
||||
const descEl = document.createElement('p');
|
||||
descEl.textContent = siteDescription;
|
||||
|
||||
newSite.appendChild(siteCardIcon);
|
||||
newSite.appendChild(titleEl);
|
||||
newSite.appendChild(descEl);
|
||||
|
||||
// favicon 模式:优先加载 faviconUrl;否则按 url 生成
|
||||
try {
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function' ? window.MeNav.getConfig() : null;
|
||||
const iconsMode = cfg && cfg.icons && cfg.icons.mode ? String(cfg.icons.mode).trim() : 'favicon';
|
||||
const iconsRegion =
|
||||
cfg && cfg.icons && cfg.icons.region ? String(cfg.icons.region).trim() : 'com';
|
||||
|
||||
const forceMode = siteForceIconMode || '';
|
||||
const shouldUseFavicon = forceMode ? forceMode === 'favicon' : iconsMode === 'favicon';
|
||||
|
||||
if (shouldUseFavicon) {
|
||||
const faviconImg = document.createElement('img');
|
||||
faviconImg.className = 'site-icon';
|
||||
faviconImg.loading = 'lazy';
|
||||
faviconImg.alt = siteName;
|
||||
|
||||
const fallbackToIcon = () => {
|
||||
faviconImg.remove();
|
||||
iconEl.classList.add('icon-fallback');
|
||||
};
|
||||
|
||||
// 超时处理:5秒后如果还没加载成功,显示回退图标
|
||||
const timeoutId = setTimeout(() => {
|
||||
fallbackToIcon();
|
||||
}, 5000);
|
||||
|
||||
faviconImg.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
fallbackToIcon();
|
||||
};
|
||||
faviconImg.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
iconEl.remove();
|
||||
};
|
||||
|
||||
if (siteFaviconUrl) {
|
||||
faviconImg.src = siteFaviconUrl;
|
||||
} else {
|
||||
const urlToUse = String(data.url || '').trim();
|
||||
if (urlToUse) {
|
||||
// 根据 icons.region 配置决定优先使用哪个域名
|
||||
const urls = [];
|
||||
const comUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(
|
||||
urlToUse
|
||||
)}&size=32`;
|
||||
const cnUrl = `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(
|
||||
urlToUse
|
||||
)}&size=32`;
|
||||
if (iconsRegion === 'cn') {
|
||||
urls.push(cnUrl, comUrl);
|
||||
} else {
|
||||
urls.push(comUrl, cnUrl);
|
||||
}
|
||||
|
||||
let idx = 0;
|
||||
faviconImg.src = urls[idx];
|
||||
|
||||
// 超时处理:3秒后如果还没加载成功,尝试回退 URL 或显示 Font Awesome 图标
|
||||
const fallbackTimeoutId = setTimeout(() => {
|
||||
idx += 1;
|
||||
if (idx < urls.length) {
|
||||
faviconImg.src = urls[idx];
|
||||
} else {
|
||||
fallbackToIcon();
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
const cleanup = () => clearTimeout(fallbackTimeoutId);
|
||||
faviconImg.onload = () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup();
|
||||
iconEl.remove();
|
||||
};
|
||||
faviconImg.onerror = () => {
|
||||
clearTimeout(timeoutId);
|
||||
cleanup();
|
||||
idx += 1;
|
||||
if (idx < urls.length) {
|
||||
faviconImg.src = urls[idx];
|
||||
} else {
|
||||
fallbackToIcon();
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
siteCardIcon.insertBefore(faviconImg, iconEl);
|
||||
} else {
|
||||
iconEl.classList.add('icon-fallback');
|
||||
}
|
||||
} catch (e) {
|
||||
iconEl.classList.add('icon-fallback');
|
||||
}
|
||||
}
|
||||
|
||||
// 添加到 DOM
|
||||
sitesContainer.appendChild(newSite);
|
||||
|
||||
// 移除“暂无网站”提示(如果存在)
|
||||
const emptySites = sitesContainer.querySelector('.empty-sites');
|
||||
if (emptySites) {
|
||||
emptySites.remove();
|
||||
}
|
||||
|
||||
// 触发元素添加事件
|
||||
this.events.emit('elementAdded', {
|
||||
id: siteName,
|
||||
type: 'site',
|
||||
parentId: parentId,
|
||||
data: data,
|
||||
});
|
||||
|
||||
return siteName;
|
||||
} else if (type === 'category') {
|
||||
// 查找父级页面容器
|
||||
const parent = document.querySelector(`[data-page="${parentId}"]`);
|
||||
if (!parent) return null;
|
||||
|
||||
// 创建新的分类
|
||||
const newCategory = document.createElement('section');
|
||||
newCategory.className = 'category';
|
||||
newCategory.setAttribute('data-type', 'category');
|
||||
newCategory.setAttribute('data-name', data.name || '未命名分类');
|
||||
if (data.icon) {
|
||||
newCategory.setAttribute(
|
||||
'data-icon',
|
||||
menavSanitizeClassList(data.icon, 'addElement(category).data-icon')
|
||||
);
|
||||
}
|
||||
|
||||
// 设置数据属性
|
||||
newCategory.setAttribute('data-level', '1');
|
||||
|
||||
// 添加内容(用 DOM API 构建,避免 innerHTML 注入)
|
||||
const titleEl = document.createElement('h2');
|
||||
const iconEl = document.createElement('i');
|
||||
iconEl.className = menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).icon');
|
||||
titleEl.appendChild(iconEl);
|
||||
titleEl.appendChild(document.createTextNode(' ' + String(data.name || '未命名分类')));
|
||||
|
||||
const sitesGrid = document.createElement('div');
|
||||
sitesGrid.className = 'sites-grid';
|
||||
sitesGrid.setAttribute('data-container', 'sites');
|
||||
const emptyEl = document.createElement('p');
|
||||
emptyEl.className = 'empty-sites';
|
||||
emptyEl.textContent = '暂无网站';
|
||||
sitesGrid.appendChild(emptyEl);
|
||||
|
||||
newCategory.appendChild(titleEl);
|
||||
newCategory.appendChild(sitesGrid);
|
||||
|
||||
// 添加到 DOM
|
||||
parent.appendChild(newCategory);
|
||||
|
||||
// 触发元素添加事件
|
||||
this.events.emit('elementAdded', {
|
||||
id: data.name,
|
||||
type: 'category',
|
||||
data: data,
|
||||
});
|
||||
|
||||
return data.name;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
34
src/runtime/menav/events.js
Normal file
34
src/runtime/menav/events.js
Normal file
@@ -0,0 +1,34 @@
|
||||
module.exports = function createMenavEvents() {
|
||||
return {
|
||||
listeners: {},
|
||||
|
||||
// 添加事件监听器
|
||||
on: function (event, callback) {
|
||||
if (!this.listeners[event]) {
|
||||
this.listeners[event] = [];
|
||||
}
|
||||
this.listeners[event].push(callback);
|
||||
return this;
|
||||
},
|
||||
|
||||
// 触发事件
|
||||
emit: function (event, data) {
|
||||
if (this.listeners[event]) {
|
||||
this.listeners[event].forEach((callback) => callback(data));
|
||||
}
|
||||
return this;
|
||||
},
|
||||
|
||||
// 移除事件监听器
|
||||
off: function (event, callback) {
|
||||
if (this.listeners[event]) {
|
||||
if (callback) {
|
||||
this.listeners[event] = this.listeners[event].filter((cb) => cb !== callback);
|
||||
} else {
|
||||
delete this.listeners[event];
|
||||
}
|
||||
}
|
||||
return this;
|
||||
},
|
||||
};
|
||||
};
|
||||
11
src/runtime/menav/getAllElements.js
Normal file
11
src/runtime/menav/getAllElements.js
Normal file
@@ -0,0 +1,11 @@
|
||||
// 获取所有元素
|
||||
module.exports = function getAllElements(type) {
|
||||
return Array.from(document.querySelectorAll(`[data-type="${type}"]`)).map((el) => {
|
||||
const id = this._getElementId(el);
|
||||
return {
|
||||
id: id,
|
||||
type: type,
|
||||
element: el,
|
||||
};
|
||||
});
|
||||
};
|
||||
25
src/runtime/menav/getConfig.js
Normal file
25
src/runtime/menav/getConfig.js
Normal file
@@ -0,0 +1,25 @@
|
||||
// 配置数据缓存:避免浏览器扩展/站点脚本频繁 JSON.parse
|
||||
let menavConfigCacheReady = false;
|
||||
let menavConfigCacheRaw = null;
|
||||
let menavConfigCacheValue = null;
|
||||
|
||||
module.exports = function getConfig(options) {
|
||||
const configData = document.getElementById('menav-config-data');
|
||||
if (!configData) return null;
|
||||
|
||||
const raw = configData.textContent || '';
|
||||
if (!menavConfigCacheReady || menavConfigCacheRaw !== raw) {
|
||||
menavConfigCacheValue = JSON.parse(raw);
|
||||
menavConfigCacheRaw = raw;
|
||||
menavConfigCacheReady = true;
|
||||
}
|
||||
|
||||
if (options && options.clone) {
|
||||
if (typeof structuredClone === 'function') {
|
||||
return structuredClone(menavConfigCacheValue);
|
||||
}
|
||||
return JSON.parse(JSON.stringify(menavConfigCacheValue));
|
||||
}
|
||||
|
||||
return menavConfigCacheValue;
|
||||
};
|
||||
70
src/runtime/menav/index.js
Normal file
70
src/runtime/menav/index.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const { menavDetectVersion } = require('../shared');
|
||||
|
||||
const createMenavEvents = require('./events');
|
||||
const getConfig = require('./getConfig');
|
||||
const updateElement = require('./updateElement');
|
||||
const addElement = require('./addElement');
|
||||
const removeElement = require('./removeElement');
|
||||
const getAllElements = require('./getAllElements');
|
||||
|
||||
function getDefaultElementId(element) {
|
||||
const type = element.getAttribute('data-type');
|
||||
if (type === 'nav-item') {
|
||||
return element.getAttribute('data-id');
|
||||
} else if (type === 'social-link') {
|
||||
return element.getAttribute('data-url');
|
||||
} else {
|
||||
// 优先使用 data-id(例如分类 slug),回退 data-name(兼容旧扩展/旧页面)
|
||||
return element.getAttribute('data-id') || element.getAttribute('data-name');
|
||||
}
|
||||
}
|
||||
|
||||
function findDefaultElement(type, id) {
|
||||
let selector;
|
||||
if (type === 'nav-item') {
|
||||
selector = `[data-type="${type}"][data-id="${id}"]`;
|
||||
} else if (type === 'social-link') {
|
||||
selector = `[data-type="${type}"][data-url="${id}"]`;
|
||||
} else if (type === 'site') {
|
||||
// 站点:优先用 data-url(更稳定),回退 data-id/data-name
|
||||
return (
|
||||
document.querySelector(`[data-type="${type}"][data-url="${id}"]`) ||
|
||||
document.querySelector(`[data-type="${type}"][data-id="${id}"]`) ||
|
||||
document.querySelector(`[data-type="${type}"][data-name="${id}"]`)
|
||||
);
|
||||
} else {
|
||||
// 其他:优先 data-id(例如分类 slug),回退 data-name(兼容旧扩展/旧页面)
|
||||
return (
|
||||
document.querySelector(`[data-type="${type}"][data-id="${id}"]`) ||
|
||||
document.querySelector(`[data-type="${type}"][data-name="${id}"]`)
|
||||
);
|
||||
}
|
||||
return document.querySelector(selector);
|
||||
}
|
||||
|
||||
// 全局 MeNav 对象 - 用于浏览器扩展
|
||||
const existing = window.MeNav && typeof window.MeNav === 'object' ? window.MeNav : {};
|
||||
const events = existing.events && typeof existing.events === 'object' ? existing.events : createMenavEvents();
|
||||
|
||||
window.MeNav = Object.assign(existing, {
|
||||
version: menavDetectVersion(),
|
||||
|
||||
getConfig: getConfig,
|
||||
|
||||
// 获取元素的唯一标识符
|
||||
_getElementId: getDefaultElementId,
|
||||
|
||||
// 根据类型和ID查找元素
|
||||
_findElement: findDefaultElement,
|
||||
|
||||
// 元素操作
|
||||
updateElement: updateElement,
|
||||
addElement: addElement,
|
||||
removeElement: removeElement,
|
||||
getAllElements: getAllElements,
|
||||
|
||||
// 事件系统
|
||||
events: events,
|
||||
});
|
||||
|
||||
module.exports = window.MeNav;
|
||||
26
src/runtime/menav/removeElement.js
Normal file
26
src/runtime/menav/removeElement.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// 删除元素
|
||||
module.exports = function removeElement(type, id) {
|
||||
const element = this._findElement(type, id);
|
||||
if (!element) return false;
|
||||
|
||||
// 获取父级容器(如果是站点卡片)
|
||||
let parentId = null;
|
||||
if (type === 'site') {
|
||||
const categoryElement = element.closest('[data-type="category"]');
|
||||
if (categoryElement) {
|
||||
parentId = categoryElement.getAttribute('data-name');
|
||||
}
|
||||
}
|
||||
|
||||
// 删除元素
|
||||
element.remove();
|
||||
|
||||
// 触发元素删除事件
|
||||
this.events.emit('elementRemoved', {
|
||||
id: id,
|
||||
type: type,
|
||||
parentId: parentId,
|
||||
});
|
||||
|
||||
return true;
|
||||
};
|
||||
166
src/runtime/menav/updateElement.js
Normal file
166
src/runtime/menav/updateElement.js
Normal file
@@ -0,0 +1,166 @@
|
||||
const { menavSanitizeClassList, menavSanitizeUrl } = require('../shared');
|
||||
|
||||
// 更新 DOM 元素
|
||||
module.exports = function updateElement(type, id, newData) {
|
||||
const element = this._findElement(type, id);
|
||||
if (!element) return false;
|
||||
|
||||
if (type === 'site') {
|
||||
// 更新站点卡片
|
||||
if (newData.url) {
|
||||
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(site).url');
|
||||
element.setAttribute('href', safeUrl);
|
||||
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
|
||||
element.setAttribute('data-url', String(newData.url).trim());
|
||||
}
|
||||
if (newData.name) {
|
||||
element.querySelector('h3').textContent = newData.name;
|
||||
element.setAttribute('data-name', newData.name);
|
||||
}
|
||||
if (newData.description) {
|
||||
element.querySelector('p').textContent = newData.description;
|
||||
element.setAttribute('data-description', newData.description);
|
||||
}
|
||||
if (newData.icon) {
|
||||
const iconElement =
|
||||
element.querySelector('i.icon-fallback') ||
|
||||
element.querySelector('i.site-icon') ||
|
||||
element.querySelector('.site-card-icon i') ||
|
||||
element.querySelector('i');
|
||||
|
||||
if (iconElement) {
|
||||
const nextIconClass = menavSanitizeClassList(newData.icon, 'updateElement(site).icon');
|
||||
const preservedClasses = [];
|
||||
|
||||
if (iconElement.classList.contains('icon-fallback')) {
|
||||
preservedClasses.push('icon-fallback');
|
||||
}
|
||||
if (iconElement.classList.contains('site-icon')) {
|
||||
preservedClasses.push('site-icon');
|
||||
}
|
||||
|
||||
if (nextIconClass) {
|
||||
iconElement.className = nextIconClass;
|
||||
preservedClasses.forEach((cls) => iconElement.classList.add(cls));
|
||||
}
|
||||
}
|
||||
element.setAttribute(
|
||||
'data-icon',
|
||||
menavSanitizeClassList(newData.icon, 'updateElement(site).data-icon')
|
||||
);
|
||||
}
|
||||
if (newData.title) element.title = newData.title;
|
||||
|
||||
// 触发元素更新事件
|
||||
this.events.emit('elementUpdated', {
|
||||
id: id,
|
||||
type: 'site',
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
} else if (type === 'category') {
|
||||
// 更新分类
|
||||
if (newData.name) {
|
||||
const titleElement = element.querySelector('h2');
|
||||
if (titleElement) {
|
||||
const iconElement = titleElement.querySelector('i');
|
||||
const iconClass = iconElement ? iconElement.className : '';
|
||||
const nextIcon = menavSanitizeClassList(
|
||||
newData.icon || iconClass,
|
||||
'updateElement(category).icon'
|
||||
);
|
||||
|
||||
// 用 DOM API 重建标题,避免 innerHTML 注入
|
||||
titleElement.textContent = '';
|
||||
const nextIconEl = document.createElement('i');
|
||||
if (nextIcon) nextIconEl.className = nextIcon;
|
||||
titleElement.appendChild(nextIconEl);
|
||||
titleElement.appendChild(document.createTextNode(' ' + String(newData.name)));
|
||||
}
|
||||
element.setAttribute('data-name', newData.name);
|
||||
}
|
||||
if (newData.icon) {
|
||||
element.setAttribute(
|
||||
'data-icon',
|
||||
menavSanitizeClassList(newData.icon, 'updateElement(category).data-icon')
|
||||
);
|
||||
}
|
||||
|
||||
// 触发元素更新事件
|
||||
this.events.emit('elementUpdated', {
|
||||
id: id,
|
||||
type: 'category',
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
} else if (type === 'nav-item') {
|
||||
// 更新导航项
|
||||
if (newData.name) {
|
||||
const textElement = element.querySelector('.nav-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = newData.name;
|
||||
}
|
||||
element.setAttribute('data-name', newData.name);
|
||||
}
|
||||
if (newData.icon) {
|
||||
const iconElement = element.querySelector('i');
|
||||
if (iconElement) {
|
||||
iconElement.className = menavSanitizeClassList(newData.icon, 'updateElement(nav-item).icon');
|
||||
}
|
||||
element.setAttribute(
|
||||
'data-icon',
|
||||
menavSanitizeClassList(newData.icon, 'updateElement(nav-item).data-icon')
|
||||
);
|
||||
}
|
||||
|
||||
// 触发元素更新事件
|
||||
this.events.emit('elementUpdated', {
|
||||
id: id,
|
||||
type: 'nav-item',
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
} else if (type === 'social-link') {
|
||||
// 更新社交链接
|
||||
if (newData.url) {
|
||||
const safeUrl = menavSanitizeUrl(newData.url, 'updateElement(social-link).url');
|
||||
element.setAttribute('href', safeUrl);
|
||||
// 保留原始 URL 供扩展/调试读取,但点击行为以 href 的安全降级为准
|
||||
element.setAttribute('data-url', String(newData.url).trim());
|
||||
}
|
||||
if (newData.name) {
|
||||
const textElement = element.querySelector('.nav-text');
|
||||
if (textElement) {
|
||||
textElement.textContent = newData.name;
|
||||
}
|
||||
element.setAttribute('data-name', newData.name);
|
||||
}
|
||||
if (newData.icon) {
|
||||
const iconElement = element.querySelector('i');
|
||||
if (iconElement) {
|
||||
iconElement.className = menavSanitizeClassList(
|
||||
newData.icon,
|
||||
'updateElement(social-link).icon'
|
||||
);
|
||||
}
|
||||
element.setAttribute(
|
||||
'data-icon',
|
||||
menavSanitizeClassList(newData.icon, 'updateElement(social-link).data-icon')
|
||||
);
|
||||
}
|
||||
|
||||
// 触发元素更新事件
|
||||
this.events.emit('elementUpdated', {
|
||||
id: id,
|
||||
type: 'social-link',
|
||||
data: newData,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
230
src/runtime/nested/index.js
Normal file
230
src/runtime/nested/index.js
Normal file
@@ -0,0 +1,230 @@
|
||||
// 多层级嵌套书签功能
|
||||
function getCollapsibleNestedContainers(root) {
|
||||
if (!root) return [];
|
||||
const headers = root.querySelectorAll(
|
||||
'.category > .category-header[data-toggle="category"], .group > .group-header[data-toggle="group"]'
|
||||
);
|
||||
return Array.from(headers)
|
||||
.map((header) => header.parentElement)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function isNestedContainerCollapsible(container) {
|
||||
if (!container) return false;
|
||||
|
||||
if (container.classList.contains('category')) {
|
||||
return Boolean(container.querySelector(':scope > .category-header[data-toggle="category"]'));
|
||||
}
|
||||
|
||||
if (container.classList.contains('group')) {
|
||||
return Boolean(container.querySelector(':scope > .group-header[data-toggle="group"]'));
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// 更新分类切换按钮图标
|
||||
function updateCategoryToggleIcon(state) {
|
||||
const toggleBtn = document.getElementById('category-toggle');
|
||||
if (!toggleBtn) return;
|
||||
|
||||
const icon = toggleBtn.querySelector('i');
|
||||
if (!icon) return;
|
||||
|
||||
if (state === 'up') {
|
||||
icon.className = 'fas fa-angle-double-up';
|
||||
toggleBtn.setAttribute('aria-label', '收起分类');
|
||||
} else {
|
||||
icon.className = 'fas fa-angle-double-down';
|
||||
toggleBtn.setAttribute('aria-label', '展开分类');
|
||||
}
|
||||
}
|
||||
|
||||
// 切换嵌套元素
|
||||
function toggleNestedElement(container) {
|
||||
if (!isNestedContainerCollapsible(container)) return;
|
||||
const isCollapsed = container.classList.contains('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
container.classList.remove('collapsed');
|
||||
saveToggleState(container, 'expanded');
|
||||
} else {
|
||||
container.classList.add('collapsed');
|
||||
saveToggleState(container, 'collapsed');
|
||||
}
|
||||
|
||||
// 触发自定义事件
|
||||
const event = new CustomEvent('nestedToggle', {
|
||||
detail: {
|
||||
element: container,
|
||||
type: container.dataset.type,
|
||||
name: container.dataset.name,
|
||||
isCollapsed: !isCollapsed,
|
||||
},
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
}
|
||||
|
||||
// 保存切换状态
|
||||
function saveToggleState(element, state) {
|
||||
const type = element.dataset.type;
|
||||
const name = element.dataset.name;
|
||||
const level = element.dataset.level || '1';
|
||||
const key = `menav-toggle-${type}-${level}-${name}`;
|
||||
localStorage.setItem(key, state);
|
||||
}
|
||||
|
||||
// 恢复切换状态
|
||||
function restoreToggleState(element) {
|
||||
const type = element.dataset.type;
|
||||
const name = element.dataset.name;
|
||||
const level = element.dataset.level || '1';
|
||||
const key = `menav-toggle-${type}-${level}-${name}`;
|
||||
const savedState = localStorage.getItem(key);
|
||||
|
||||
if (savedState === 'collapsed') {
|
||||
element.classList.add('collapsed');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化嵌套分类
|
||||
function initializeNestedCategories() {
|
||||
// 为所有可折叠元素添加切换功能
|
||||
document.querySelectorAll('[data-toggle="category"], [data-toggle="group"]').forEach((header) => {
|
||||
header.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const container = this.parentElement;
|
||||
toggleNestedElement(container);
|
||||
});
|
||||
|
||||
// 恢复保存的状态
|
||||
restoreToggleState(header.parentElement);
|
||||
});
|
||||
}
|
||||
|
||||
// 提取嵌套数据
|
||||
function extractNestedData(element) {
|
||||
const data = {
|
||||
name: element.dataset.name,
|
||||
type: element.dataset.type,
|
||||
level: element.dataset.level,
|
||||
isCollapsed: element.classList.contains('collapsed'),
|
||||
};
|
||||
|
||||
// 提取子元素数据
|
||||
const subcategories = element.querySelectorAll(
|
||||
':scope > .category-content > .subcategories-container > .category'
|
||||
);
|
||||
if (subcategories.length > 0) {
|
||||
data.subcategories = Array.from(subcategories).map((sub) => extractNestedData(sub));
|
||||
}
|
||||
|
||||
const groups = element.querySelectorAll(
|
||||
':scope > .category-content > .groups-container > .group'
|
||||
);
|
||||
if (groups.length > 0) {
|
||||
data.groups = Array.from(groups).map((group) => extractNestedData(group));
|
||||
}
|
||||
|
||||
const subgroups = element.querySelectorAll(
|
||||
':scope > .group-content > .subgroups-container > .group'
|
||||
);
|
||||
if (subgroups.length > 0) {
|
||||
data.subgroups = Array.from(subgroups).map((subgroup) => extractNestedData(subgroup));
|
||||
}
|
||||
|
||||
const sites = element.querySelectorAll(
|
||||
':scope > .category-content > .sites-grid > .site-card, :scope > .group-content > .sites-grid > .site-card'
|
||||
);
|
||||
if (sites.length > 0) {
|
||||
data.sites = Array.from(sites).map((site) => ({
|
||||
name: site.dataset.name,
|
||||
url: site.dataset.url,
|
||||
icon: site.dataset.icon,
|
||||
description: site.dataset.description,
|
||||
}));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function registerNestedApi() {
|
||||
if (!window.MeNav) {
|
||||
// runtime 入口会先初始化 MeNav;这里兜底避免报错
|
||||
window.MeNav = {};
|
||||
}
|
||||
|
||||
window.MeNav.expandAll = function () {
|
||||
const activePage = document.querySelector('.page.active');
|
||||
if (activePage) {
|
||||
getCollapsibleNestedContainers(activePage).forEach((element) => {
|
||||
element.classList.remove('collapsed');
|
||||
saveToggleState(element, 'expanded');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.MeNav.collapseAll = function () {
|
||||
const activePage = document.querySelector('.page.active');
|
||||
if (activePage) {
|
||||
getCollapsibleNestedContainers(activePage).forEach((element) => {
|
||||
element.classList.add('collapsed');
|
||||
saveToggleState(element, 'collapsed');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 智能切换分类展开/收起状态
|
||||
window.MeNav.toggleCategories = function () {
|
||||
const activePage = document.querySelector('.page.active');
|
||||
if (!activePage) return;
|
||||
|
||||
const allElements = getCollapsibleNestedContainers(activePage);
|
||||
const collapsedElements = allElements.filter((element) => element.classList.contains('collapsed'));
|
||||
if (allElements.length === 0) return;
|
||||
|
||||
// 如果收起的数量 >= 总数的一半,执行展开;否则执行收起
|
||||
if (collapsedElements.length >= allElements.length / 2) {
|
||||
window.MeNav.expandAll();
|
||||
updateCategoryToggleIcon('up');
|
||||
} else {
|
||||
window.MeNav.collapseAll();
|
||||
updateCategoryToggleIcon('down');
|
||||
}
|
||||
};
|
||||
|
||||
window.MeNav.toggleCategory = function (
|
||||
categoryName,
|
||||
subcategoryName = null,
|
||||
groupName = null,
|
||||
subgroupName = null
|
||||
) {
|
||||
let selector = `[data-name="${categoryName}"]`;
|
||||
|
||||
if (subcategoryName) selector += ` [data-name="${subcategoryName}"]`;
|
||||
if (groupName) selector += ` [data-name="${groupName}"]`;
|
||||
if (subgroupName) selector += ` [data-name="${subgroupName}"]`;
|
||||
|
||||
const element = document.querySelector(selector);
|
||||
if (element) {
|
||||
toggleNestedElement(element);
|
||||
}
|
||||
};
|
||||
|
||||
window.MeNav.getNestedStructure = function () {
|
||||
// 返回完整的嵌套结构数据
|
||||
const categories = [];
|
||||
document.querySelectorAll('.category-level-1').forEach((cat) => {
|
||||
categories.push(extractNestedData(cat));
|
||||
});
|
||||
return categories;
|
||||
};
|
||||
}
|
||||
|
||||
registerNestedApi();
|
||||
|
||||
module.exports = {
|
||||
initializeNestedCategories,
|
||||
updateCategoryToggleIcon,
|
||||
extractNestedData,
|
||||
};
|
||||
142
src/runtime/shared.js
Normal file
142
src/runtime/shared.js
Normal file
@@ -0,0 +1,142 @@
|
||||
function menavExtractDomain(url) {
|
||||
if (!url) return '';
|
||||
|
||||
try {
|
||||
// 移除协议部分 (http://, https://, etc.)
|
||||
let domain = String(url).replace(/^[a-zA-Z]+:\/\//, '');
|
||||
|
||||
// 移除路径、查询参数和锚点
|
||||
domain = domain.split('/')[0].split('?')[0].split('#')[0];
|
||||
|
||||
// 移除端口号(如果有)
|
||||
domain = domain.split(':')[0];
|
||||
|
||||
return domain;
|
||||
} catch (e) {
|
||||
return String(url);
|
||||
}
|
||||
}
|
||||
|
||||
// URL 安全策略:默认仅允许 http/https(可加 mailto/tel)与相对链接;其他 scheme 降级为 '#'
|
||||
function menavGetAllowedUrlSchemes() {
|
||||
try {
|
||||
const cfg =
|
||||
window.MeNav && typeof window.MeNav.getConfig === 'function'
|
||||
? window.MeNav.getConfig()
|
||||
: null;
|
||||
const fromConfig =
|
||||
cfg &&
|
||||
cfg.data &&
|
||||
cfg.data.site &&
|
||||
cfg.data.site.security &&
|
||||
cfg.data.site.security.allowedSchemes;
|
||||
if (Array.isArray(fromConfig) && fromConfig.length > 0) {
|
||||
return fromConfig
|
||||
.map((s) =>
|
||||
String(s || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/:$/, '')
|
||||
)
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch (e) {
|
||||
// 忽略,回退默认
|
||||
}
|
||||
return ['http', 'https', 'mailto', 'tel'];
|
||||
}
|
||||
|
||||
function menavIsRelativeUrl(url) {
|
||||
const s = String(url || '').trim();
|
||||
return (
|
||||
s.startsWith('#') ||
|
||||
s.startsWith('/') ||
|
||||
s.startsWith('./') ||
|
||||
s.startsWith('../') ||
|
||||
s.startsWith('?')
|
||||
);
|
||||
}
|
||||
|
||||
function menavSanitizeUrl(rawUrl, contextLabel) {
|
||||
if (rawUrl === undefined || rawUrl === null) return '#';
|
||||
const url = String(rawUrl).trim();
|
||||
if (!url) return '#';
|
||||
|
||||
if (menavIsRelativeUrl(url)) return url;
|
||||
|
||||
// 明确拒绝协议相对 URL(//example.com),避免意外绕过策略
|
||||
if (url.startsWith('//')) {
|
||||
console.warn(`[MeNav][安全] 已拦截不安全 URL(协议相对形式):${contextLabel || ''}`, url);
|
||||
return '#';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
const scheme = String(parsed.protocol || '')
|
||||
.toLowerCase()
|
||||
.replace(/:$/, '');
|
||||
const allowed = menavGetAllowedUrlSchemes();
|
||||
if (allowed.includes(scheme)) return url;
|
||||
console.warn(`[MeNav][安全] 已拦截不安全 URL scheme:${contextLabel || ''}`, url);
|
||||
return '#';
|
||||
} catch (e) {
|
||||
console.warn(`[MeNav][安全] 已拦截无法解析的 URL:${contextLabel || ''}`, url);
|
||||
return '#';
|
||||
}
|
||||
}
|
||||
|
||||
// class token 清洗:仅允许字母/数字/下划线/中划线与空格分隔,避免属性/事件注入
|
||||
function menavSanitizeClassList(rawClassList, contextLabel) {
|
||||
const input = String(rawClassList || '').trim();
|
||||
if (!input) return '';
|
||||
|
||||
const tokens = input
|
||||
.split(/\s+/g)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)
|
||||
.map((t) => t.replace(/[^\w-]/g, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
const sanitized = tokens.join(' ');
|
||||
if (sanitized !== input) {
|
||||
console.warn(`[MeNav][安全] 已清洗不安全的 icon class:${contextLabel || ''}`, rawClassList);
|
||||
}
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// 版本号统一来源:优先读取 meta[menav-version],回退到 menav-config-data.version
|
||||
function menavDetectVersion() {
|
||||
try {
|
||||
const meta = document.querySelector('meta[name="menav-version"]');
|
||||
const v = meta ? String(meta.getAttribute('content') || '').trim() : '';
|
||||
if (v) return v;
|
||||
} catch (e) {
|
||||
// 忽略
|
||||
}
|
||||
|
||||
try {
|
||||
const configData = document.getElementById('menav-config-data');
|
||||
const raw = configData ? String(configData.textContent || '').trim() : '';
|
||||
if (!raw) return '1.0.0';
|
||||
const parsed = JSON.parse(raw);
|
||||
const v = parsed && parsed.version ? String(parsed.version).trim() : '';
|
||||
return v || '1.0.0';
|
||||
} catch (e) {
|
||||
return '1.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
// 修复移动端 `100vh` 视口高度问题:用实际可视高度驱动布局,避免侧边栏/内容区底部被浏览器 UI 遮挡
|
||||
function menavUpdateAppHeight() {
|
||||
const viewportHeight = window.visualViewport ? window.visualViewport.height : window.innerHeight;
|
||||
document.documentElement.style.setProperty('--app-height', `${Math.round(viewportHeight)}px`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
menavExtractDomain,
|
||||
menavSanitizeUrl,
|
||||
menavSanitizeClassList,
|
||||
menavDetectVersion,
|
||||
menavUpdateAppHeight,
|
||||
};
|
||||
|
||||
115
src/runtime/tooltip.js
Normal file
115
src/runtime/tooltip.js
Normal file
@@ -0,0 +1,115 @@
|
||||
// Tooltip functionality for truncated text(仅桌面端/支持悬停设备启用)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const hoverMedia = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)');
|
||||
|
||||
if (!hoverMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
let cleanupTooltip = null;
|
||||
|
||||
function enableTooltip() {
|
||||
if (cleanupTooltip) return;
|
||||
|
||||
// Create tooltip element
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'custom-tooltip';
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
let activeElement = null;
|
||||
|
||||
function updateTooltipPosition() {
|
||||
if (!activeElement) return;
|
||||
|
||||
const rect = activeElement.getBoundingClientRect();
|
||||
const tooltipRect = tooltip.getBoundingClientRect();
|
||||
const gap = 10; // 卡片与Tooltip的间距
|
||||
|
||||
// 默认显示在卡片下方
|
||||
let top = rect.bottom + gap;
|
||||
// 水平居中对齐
|
||||
let left = rect.left + (rect.width - tooltipRect.width) / 2;
|
||||
|
||||
const winWidth = window.innerWidth;
|
||||
const winHeight = window.innerHeight;
|
||||
|
||||
// 垂直边界检查:如果下方空间不足,尝试显示在上方
|
||||
if (top + tooltipRect.height > winHeight - gap) {
|
||||
top = rect.top - tooltipRect.height - gap;
|
||||
}
|
||||
|
||||
// 水平边界检查:防止溢出屏幕左右边界
|
||||
if (left < gap) {
|
||||
left = gap;
|
||||
} else if (left + tooltipRect.width > winWidth - gap) {
|
||||
left = winWidth - tooltipRect.width - gap;
|
||||
}
|
||||
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
}
|
||||
|
||||
// Show tooltip on hover
|
||||
function onMouseOver(e) {
|
||||
const target = e.target.closest('[data-tooltip]');
|
||||
if (!target) return;
|
||||
|
||||
const tooltipText = target.getAttribute('data-tooltip');
|
||||
if (!tooltipText) return;
|
||||
|
||||
activeElement = target;
|
||||
tooltip.textContent = tooltipText;
|
||||
tooltip.classList.add('visible');
|
||||
// 先显示元素让浏览器计算尺寸,然后立即更新位置
|
||||
updateTooltipPosition();
|
||||
}
|
||||
|
||||
// Hide tooltip on mouse out
|
||||
function onMouseOut(e) {
|
||||
const target = e.target.closest('[data-tooltip]');
|
||||
if (!target || target !== activeElement) return;
|
||||
|
||||
// Check if we really left the element (not just went to a child)
|
||||
if (target.contains(e.relatedTarget)) return;
|
||||
|
||||
activeElement = null;
|
||||
tooltip.classList.remove('visible');
|
||||
}
|
||||
|
||||
document.addEventListener('mouseover', onMouseOver);
|
||||
document.addEventListener('mouseout', onMouseOut);
|
||||
|
||||
cleanupTooltip = () => {
|
||||
document.removeEventListener('mouseover', onMouseOver);
|
||||
document.removeEventListener('mouseout', onMouseOut);
|
||||
|
||||
activeElement = null;
|
||||
tooltip.classList.remove('visible');
|
||||
tooltip.remove();
|
||||
};
|
||||
}
|
||||
|
||||
function disableTooltip() {
|
||||
if (!cleanupTooltip) return;
|
||||
cleanupTooltip();
|
||||
cleanupTooltip = null;
|
||||
}
|
||||
|
||||
function syncTooltipEnabled() {
|
||||
if (hoverMedia.matches) {
|
||||
enableTooltip();
|
||||
} else {
|
||||
disableTooltip();
|
||||
}
|
||||
}
|
||||
|
||||
syncTooltipEnabled();
|
||||
|
||||
// 兼容旧版 Safari:addListener/removeListener
|
||||
if (hoverMedia.addEventListener) {
|
||||
hoverMedia.addEventListener('change', syncTooltipEnabled);
|
||||
} else if (hoverMedia.addListener) {
|
||||
hoverMedia.addListener(syncTooltipEnabled);
|
||||
}
|
||||
});
|
||||
|
||||
2374
src/script.js
2374
src/script.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user