refactor: 模块化重构 generator 和 runtime

This commit is contained in:
rbetree
2026-01-15 21:08:26 +08:00
parent bcfa6e6316
commit 1a90f8fbe3
38 changed files with 4881 additions and 4411 deletions

62
scripts/build-runtime.js Normal file
View 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();

View File

@@ -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/'];

View File

@@ -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)

File diff suppressed because it is too large Load Diff

159
src/generator/cache/articles.js vendored Normal file
View 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
View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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
View 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,
};

View 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
View 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,
};

View 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
View 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,
};

View 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,
};

View File

@@ -0,0 +1,17 @@
// HTML 转义函数,防止 XSS 攻击
function escapeHtml(unsafe) {
if (unsafe === undefined || unsafe === null) {
return '';
}
return String(unsafe)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#039;');
}
module.exports = {
escapeHtml,
};

View 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;
}
}
/**
* 获取文件 mtimeISO 字符串)
* @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,
};

View 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
View 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
View 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
View 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,
};
};

View 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');
}
};

View 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
View 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
View 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');

View 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);
// 保留原始 URLdata-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;
};

View 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;
},
};
};

View 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,
};
});
};

View 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;
};

View 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;

View 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;
};

View 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
View 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
View 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
View 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();
// 兼容旧版 SafariaddListener/removeListener
if (hoverMedia.addEventListener) {
hoverMedia.addEventListener('change', syncTooltipEnabled);
} else if (hoverMedia.addListener) {
hoverMedia.addListener(syncTooltipEnabled);
}
});

File diff suppressed because it is too large Load Diff