diff --git a/scripts/build-runtime.js b/scripts/build-runtime.js new file mode 100644 index 0000000..ad9b8c4 --- /dev/null +++ b/scripts/build-runtime.js @@ -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(); + diff --git a/scripts/format-check-changed.js b/scripts/format-check-changed.js index c44aec3..a8fe787 100644 --- a/scripts/format-check-changed.js +++ b/scripts/format-check-changed.js @@ -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/']; diff --git a/src/README.md b/src/README.md index c72f4ba..81bebf6 100644 --- a/src/README.md +++ b/src/README.md @@ -2,112 +2,487 @@ ## 目录 -- [目录概述](#目录概述) -- [源代码结构分工](#源代码结构分工) - - [根目录函数(核心功能)](#根目录函数核心功能) - - [helpers 目录函数(辅助功能)](#helpers-目录函数辅助功能) -- [函数职责分工](#函数职责分工) - - [核心函数(根目录)](#核心函数根目录) - - [辅助函数(helpers目录)](#辅助函数helpers目录) -- [扩展和修改指南](#扩展和修改指南) - - [添加新的核心功能](#添加新的核心功能) - - [添加新的辅助函数](#添加新的辅助函数) +- [架构概述](#架构概述) +- [目录结构](#目录结构) + - [生成端(generator)](#生成端generator) + - [运行时(runtime)](#运行时runtime) + - [辅助函数(helpers)](#辅助函数helpers) +- [模块化开发规范](#模块化开发规范) + - [职责单一性原则](#职责单一性原则) + - [文件大小规范](#文件大小规范) + - [命名规范](#命名规范) + - [依赖管理](#依赖管理) +- [开发指南](#开发指南) + - [添加新模块](#添加新模块) + - [修改现有模块](#修改现有模块) + - [测试要求](#测试要求) -## 目录概述 +## 架构概述 -`src` 目录包含 MeNav 项目的所有源代码,按照功能和职责进行了明确的分层组织: +MeNav 采用**模块化架构**,将代码按职责拆分为独立的、可维护的模块。整体分为三个主要部分: -- 根目录:核心功能实现 -- `helpers` 子目录:辅助功能实现 +1. **生成端(generator)**:构建期代码,负责将配置转换为静态 HTML +2. **运行时(runtime)**:浏览器端代码,负责用户交互和动态功能 +3. **辅助函数(helpers)**:Handlebars 模板辅助函数 -## 源代码结构分工 +### 架构原则 -### 根目录函数(核心功能) +- ✅ **职责单一**:每个模块只负责一件事 +- ✅ **高内聚低耦合**:模块内部紧密相关,模块间松散依赖 +- ✅ **可测试性**:每个模块都可以独立测试 +- ✅ **可维护性**:清晰的目录结构和命名规范 -根目录下的js文件实现了应用的核心功能,每个文件负责特定领域: +## 目录结构 -- **generator.js**: 站点生成器的核心实现 - - 负责将配置文件转换为HTML网站 - - 处理模板渲染、配置解析和文件输出 - - 控制整个生成流程 +```text +src/ +├── generator.js # 生成端薄入口(14行) +├── generator/ # 生成端实现 +│ ├── main.js # 主流程控制(301行) +│ ├── cache/ # 缓存处理 +│ │ ├── articles.js # 文章缓存(159行) +│ │ └── projects.js # 项目缓存(135行) +│ ├── config/ # 配置管理 +│ │ ├── index.js # 配置入口(61行) +│ │ ├── loader.js # 配置加载(89行) +│ │ ├── validator.js # 配置验证(90行) +│ │ ├── resolver.js # 配置解析(146行) +│ │ └── slugs.js # Slug 生成(43行) +│ ├── html/ # HTML 生成 +│ │ ├── 404.js # 404 页面(94行) +│ │ ├── components.js # 组件生成(208行) +│ │ ├── fonts.js # 字体处理(155行) +│ │ └── page-data.js # 页面数据准备(161行) +│ ├── template/ # 模板引擎 +│ │ └── engine.js # Handlebars 引擎(120行) +│ └── utils/ # 工具函数 +│ ├── html.js # HTML 工具(17行) +│ ├── pageMeta.js # 页面元信息(101行) +│ └── sites.js # 站点处理(35行) +├── runtime/ # 运行时实现 +│ ├── index.js # 运行时入口(18行) +│ ├── shared.js # 共享工具(142行) +│ ├── tooltip.js # 提示框(115行) +│ ├── app/ # 应用逻辑 +│ │ ├── index.js # 应用入口(112行) +│ │ ├── routing.js # 路由管理(403行) +│ │ ├── search.js # 搜索功能(440行) +│ │ ├── searchEngines.js # 搜索引擎(25行) +│ │ ├── ui.js # UI 交互(181行) +│ │ └── search/ +│ │ └── highlight.js # 搜索高亮(90行) +│ ├── menav/ # 扩展 API +│ │ ├── index.js # API 入口(70行) +│ │ ├── addElement.js # 添加元素(351行) +│ │ ├── updateElement.js # 更新元素(166行) +│ │ ├── removeElement.js # 删除元素(26行) +│ │ ├── getAllElements.js# 获取元素(11行) +│ │ ├── getConfig.js # 获取配置(25行) +│ │ └── events.js # 事件系统(34行) +│ └── nested/ # 嵌套书签 +│ └── index.js # 嵌套功能(230行) +└── helpers/ # Handlebars 辅助函数 + ├── index.js # 辅助函数注册 + ├── formatters.js # 格式化函数 + ├── conditions.js # 条件判断 + └── utils.js # 工具函数 +``` -- **script.js**: 客户端功能的核心实现 - - 处理用户界面交互逻辑 - - 实现搜索、导航、主题切换等功能 - - 管理页面状态和响应式布局 +## 生成端(generator) -- **bookmark-processor.js**: 书签处理工具 - - 转换浏览器书签为MeNav配置格式 - - 提供书签导入功能 +### 生成端职责 +将 YAML 配置文件转换为静态 HTML 网站。 -### helpers 目录函数(辅助功能) +### 生成端核心模块 -`helpers` 目录下的函数提供辅助性支持,主要服务于Handlebars模板系统: +#### config/ - 配置管理 -- **formatters.js**: 格式化函数 - - 日期格式化 - - 文本处理 - - 内容展示辅助 +- **loader.js**:从文件系统加载 YAML 配置 +- **validator.js**:验证配置合法性,填充默认值 +- **resolver.js**:解析配置,准备渲染数据 +- **slugs.js**:为分类生成唯一标识符 -- **conditions.js**: 条件判断函数 - - 比较操作 - - 逻辑运算 - - 条件检查 +#### html/ - HTML 生成 -- **utils.js**: 工具函数 - - 数组处理 - - 对象操作 - - 通用辅助方法 +- **page-data.js**:准备页面渲染数据(处理 projects/articles/bookmarks 特殊逻辑) +- **components.js**:生成导航、分类、社交链接等组件 +- **fonts.js**:处理字体链接和 CSS +- **404.js**:生成 404 页面 -- **index.js**: 助手函数注册中心 - - 统一导入所有助手函数 - - 提供注册函数到Handlebars实例的方法 - - 定义核心HTML处理函数 +#### cache/ - 缓存处理 -## 函数职责分工 +- **articles.js**:处理 RSS 文章缓存 +- **projects.js**:处理 GitHub 项目缓存 -### 核心函数(根目录) +#### template/ - 模板引擎 -根目录下的函数负责"做什么"——实现具体的业务逻辑和功能: +- **engine.js**:Handlebars 模板加载和渲染 -- 应用入口和流程控制 -- 用户交互响应 -- 数据处理和转换 -- 文件生成和输出 +#### utils/ - 工具函数 -这些函数通常: -- 直接面向最终用户需求 -- 控制程序主流程 -- 调用辅助函数完成特定任务 +- **pageMeta.js**:获取页面元信息(git 时间戳等) +- **sites.js**:递归收集站点数据 +- **html.js**:HTML 处理工具 -### 辅助函数(helpers目录) +### 生成端入口文件 -helpers目录下的函数负责"怎么做"——提供可重用的工具方法: +- **generator.js**:薄入口,re-export `generator/main.js` +- **main.js**:主流程控制,协调各模块完成构建 -- 为模板渲染提供扩展能力 -- 处理数据格式化和转换 -- 提供通用的条件判断逻辑 -- 实现常见的工具操作 +## 运行时(runtime) -这些函数通常: -- 高度重用性和通用性 -- 专注于单一职责 -- 被核心函数调用 +### 运行时职责 -## 扩展和修改指南 +在浏览器中提供用户交互功能和扩展 API。 -### 添加新的核心功能 +### 运行时核心模块 -如需添加新的核心功能,应在src根目录创建新的js文件或修改现有文件,确保: -- 文件名清晰反映功能用途 -- 保持单一责任原则 -- 适当调用辅助函数,避免重复实现通用功能 +#### app/ - 应用逻辑 -### 添加新的辅助函数 +- **routing.js**:页面路由、URL 处理、页面切换 +- **search.js**:搜索索引、搜索逻辑、搜索引擎切换 +- **ui.js**:UI 交互(侧边栏、主题切换、滚动等) +- **searchEngines.js**:外部搜索引擎配置 -如需添加新的辅助函数,应在helpers目录下相应文件中添加,并在index.js中注册: -- 按功能类型放入正确的文件(formatters/conditions/utils) -- 编写清晰的JSDoc注释 -- 更新README.md文档 -- 在index.js中添加导出和注册 \ No newline at end of file +#### 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) diff --git a/src/generator.js b/src/generator.js index 9140a0a..df567e2 100644 --- a/src/generator.js +++ b/src/generator.js @@ -1,1961 +1,14 @@ -const fs = require('fs'); -const yaml = require('js-yaml'); -const path = require('path'); -const { execFileSync } = require('child_process'); -const Handlebars = require('handlebars'); +// 生成端薄入口:保持对外导出稳定,内部实现位于 src/generator/main.js +const impl = require('./generator/main'); -// 导入Handlebars助手函数 -const { registerAllHelpers } = require('./helpers'); - -// 扩展配置输出:独立静态文件(按需加载) -const MENAV_EXTENSION_CONFIG_FILE = 'menav-config.json'; - -// 注册Handlebars实例和辅助函数 -const handlebars = Handlebars.create(); -registerAllHelpers(handlebars); - -// 加载和注册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)) { - 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); - }); - } else { - throw new Error('Layouts directory not found. Cannot proceed without layout templates.'); - } - - // 加载组件模板 - const componentsDir = path.join(templatesDir, 'components'); - if (fs.existsSync(componentsDir)) { - 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); - }); - } else { - throw new Error('Components directory not found. Cannot proceed without component templates.'); - } - - // 识别并检查默认布局模板是否存在 - const defaultLayoutPath = path.join(layoutsDir, 'default.hbs'); - if (!fs.existsSync(defaultLayoutPath)) { - throw new Error('Default layout template not found. Cannot proceed without default layout.'); - } -} - -/** - * 获取默认布局模板 - * @returns {Object} 包含模板路径和编译的模板函数 - */ -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}`); - } -} - -// 渲染Handlebars模板函数 -function renderTemplate(templateName, data, useLayout = true) { - const templatePath = path.join(process.cwd(), 'templates', 'pages', `${templateName}.hbs`); - - // 检查模板是否存在 - if (!fs.existsSync(templatePath)) { - // 尝试使用通用模板 page.hbs - const genericTemplatePath = path.join(process.cwd(), 'templates', 'pages', 'page.hbs'); - - if (fs.existsSync(genericTemplatePath)) { - console.log(`模板 ${templateName}.hbs 不存在,使用通用模板 page.hbs 代替`); - const genericTemplateContent = fs.readFileSync(genericTemplatePath, 'utf8'); - const genericTemplate = handlebars.compile(genericTemplateContent); - - // 添加 pageId 到数据中,以便通用模板使用(优先保留原 pageId,避免回退时语义错位) - const enhancedData = { - ...data, - pageId: data && data.pageId ? data.pageId : templateName, - }; - - // 渲染页面内容 - const pageContent = genericTemplate(enhancedData); - - // 如果不使用布局,直接返回页面内容 - if (!useLayout) { - return pageContent; - } - - try { - // 使用辅助函数获取默认布局模板 - const { template: layoutTemplate } = getDefaultLayoutTemplate(); - - // 准备布局数据,包含页面内容 - const layoutData = { - ...enhancedData, - body: pageContent, - }; - - // 渲染完整页面 - return layoutTemplate(layoutData); - } catch (layoutError) { - throw new Error(`Error rendering layout for ${templateName}: ${layoutError.message}`); - } - } else { - throw new Error( - `Template ${templateName}.hbs not found and generic template page.hbs not found. Cannot proceed without template.` - ); - } - } - - try { - const templateContent = fs.readFileSync(templatePath, 'utf8'); - const template = handlebars.compile(templateContent); - - // 渲染页面内容 - const pageContent = template(data); - - // 如果不使用布局,直接返回页面内容 - if (!useLayout) { - return pageContent; - } - - try { - // 使用辅助函数获取默认布局模板 - const { template: layoutTemplate } = getDefaultLayoutTemplate(); - - // 准备布局数据,包含页面内容 - const layoutData = { - ...data, - body: pageContent, - }; - - // 渲染完整页面 - return layoutTemplate(layoutData); - } catch (layoutError) { - throw new Error(`Error rendering layout for ${templateName}: ${layoutError.message}`); - } - } catch (error) { - throw new Error(`Error rendering template ${templateName}: ${error.message}`); - } -} - -// HTML转义函数,防止XSS攻击 -function escapeHtml(unsafe) { - if (unsafe === undefined || unsafe === null) { - return ''; - } - return String(unsafe) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -/** - * 统一处理配置文件加载错误 - * @param {string} filePath 配置文件路径 - * @param {Error} error 错误对象 - */ -function handleConfigLoadError(filePath, error) { - console.error(`Error loading configuration from ${filePath}:`, error); -} - -/** - * 安全地加载YAML配置文件 - * @param {string} filePath 配置文件路径 - * @returns {Object|null} 配置对象,如果文件不存在或加载失败则返回null - */ -function safeLoadYamlConfig(filePath) { - if (!fs.existsSync(filePath)) { - return null; - } - - try { - const fileContent = fs.readFileSync(filePath, 'utf8'); - // 使用 loadAll 而不是 load 来支持多文档 YAML 文件 - 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; - } -} - -/** - * 加载模块化配置目录 - * @param {string} dirPath 配置目录路径 - * @returns {Object|null} 配置对象,如果目录不存在或加载失败则返回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) { - // 将site.yml中的内容分配到正确的配置字段 - config.site = siteConfig; - - // 提取特殊字段到顶层配置 - if (siteConfig.fonts) config.fonts = siteConfig.fonts; - if (siteConfig.profile) config.profile = siteConfig.profile; - if (siteConfig.social) config.social = siteConfig.social; - // 图标配置(icons.mode)需要作为顶层字段供模板/运行时读取 - if (siteConfig.icons) config.icons = siteConfig.icons; - - // 优先使用site.yml中的navigation配置 - 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; -} - -/** - * 确保配置对象具有必要的默认值 - * @param {Object} config 配置对象 - * @returns {Object} 处理后的配置对象 - */ -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 || {}; - // icons.mode: manual | favicon, 默认 favicon - result.icons.mode = result.icons.mode || 'favicon'; - // icons.region: com | cn, 默认 com(优先使用 gstatic.com,失败后回退到 gstatic.cn) - 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 = result.profile || {}; - 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; - } - - // 递归处理多级结构(categories/subcategories/groups/subgroups)下的 sites 默认值 - 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; - - // 传统结构:categories -> sites - if (Array.isArray(pageConfig.categories)) { - pageConfig.categories.forEach(processCategoryDefaults); - } - - // 扁平结构:sites(用于 friends/articles 等“无层级并列卡片”页面) - if (Array.isArray(pageConfig.sites)) { - pageConfig.sites.forEach(processSiteDefaults); - } - }); - - return result; -} - -/** - * 验证配置是否有效 - * @param {Object} config 配置对象 - * @returns {boolean} 配置是否有效 - */ -function validateConfig(config) { - // 基本结构检查 - if (!config || typeof config !== 'object') { - console.error('配置无效: 配置必须是一个对象'); - return false; - } - - // 所有其他验证被移除,因为它们只是检查但没有实际操作 - // 配置默认值和数据修复已经在ensureConfigDefaults函数中处理 - - return true; -} - -/** - * 获取导航项的子菜单数据 - * @param {Object} navItem 导航项对象 - * @param {Object} config 配置对象 - * @returns {Array|null} 子菜单数据数组或null - */ -function getSubmenuForNavItem(navItem, config) { - if (!navItem || !navItem.id || !config) { - return null; - } - - // 通用处理:任意页面的子菜单生成(基于 pages/.yml 的 categories) - if (config[navItem.id] && Array.isArray(config[navItem.id].categories)) - return config[navItem.id].categories; - - return null; -} - -function makeCategorySlugBase(name) { - const raw = typeof name === 'string' ? name : String(name ?? ''); - const trimmed = raw.trim(); - if (!trimmed) return 'category'; - - // 规则:尽量可读、跨平台稳定;保留字母/数字/下划线/短横线,其它字符替换为短横线 - // 注意:分类名允许中文等非 ASCII 字符,Node 18+ 支持 Unicode 属性类 - 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); - } - }); -} - -/** - * 将 JSON 字符串安全嵌入到 ` 结束标签导致脚本块被提前终止。 - * 说明:返回值仍是合法 JSON,JSON.parse 后数据不变。 - * @param {string} jsonString JSON 字符串 - * @returns {string} 安全的 JSON 字符串 - */ -function makeJsonSafeForHtmlScript(jsonString) { - if (typeof jsonString !== 'string') { - return ''; - } - - return jsonString.replace(/<\/script/gi, '<\\/script'); -} - -/** - * 解析页面配置文件路径(优先 user,回退 _default) - * 注意:仅用于构建期读取文件元信息,不会把路径注入到页面/扩展配置中。 - * @param {string} pageId 页面ID(与 pages/.yml 文件名对应) - * @returns {string|null} 文件路径或 null - */ -function resolvePageConfigFilePath(pageId) { - if (!pageId) return null; - - const candidates = [ - path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yml`), - path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yaml`), - path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yml`), - path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yaml`), - ]; - - for (const filePath of candidates) { - try { - if (fs.existsSync(filePath)) return filePath; - } catch (e) { - // 忽略 IO 异常,继续尝试下一个候选 - } - } - - return null; -} - -/** - * 尝试获取文件最后一次 git 提交时间(ISO 字符串) - * @param {string} filePath 文件路径 - * @returns {string|null} ISO 字符串(UTC),失败返回 null - */ -function tryGetGitLastCommitIso(filePath) { - if (!filePath) return null; - - try { - const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/'); - const output = execFileSync('git', ['log', '-1', '--format=%cI', '--', relativePath], { - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'ignore'], - }); - const raw = String(output || '').trim(); - if (!raw) return null; - - const date = new Date(raw); - if (Number.isNaN(date.getTime())) return null; - - return date.toISOString(); - } catch (e) { - return null; - } -} - -/** - * 获取文件 mtime(ISO 字符串) - * @param {string} filePath 文件路径 - * @returns {string|null} ISO 字符串(UTC),失败返回 null - */ -function tryGetFileMtimeIso(filePath) { - if (!filePath) return null; - - try { - const stats = fs.statSync(filePath); - const mtime = stats && stats.mtime ? stats.mtime : null; - if (!(mtime instanceof Date) || Number.isNaN(mtime.getTime())) return null; - return mtime.toISOString(); - } catch (e) { - return null; - } -} - -/** - * 计算页面配置文件“内容更新时间”(优先 git,回退 mtime) - * @param {string} pageId 页面ID - * @returns {{updatedAt: string, updatedAtSource: 'git'|'mtime'}|null} - */ -function getPageConfigUpdatedAtMeta(pageId) { - const filePath = resolvePageConfigFilePath(pageId); - if (!filePath) return null; - - const gitIso = tryGetGitLastCommitIso(filePath); - if (gitIso) { - return { updatedAt: gitIso, updatedAtSource: 'git' }; - } - - const mtimeIso = tryGetFileMtimeIso(filePath); - if (mtimeIso) { - return { updatedAt: mtimeIso, updatedAtSource: 'mtime' }; - } - - return null; -} - -/** - * 读取 articles 页面 RSS 缓存(Phase 2) - * - 缓存默认放在 dev/(仓库默认 gitignore) - * - 构建端只读缓存:缓存缺失/损坏时回退到 Phase 1(渲染来源站点分类) - * @param {string} pageId 页面ID(用于支持多个 articles 页面的独立缓存) - * @param {Object} config 全站配置(用于读取 site.rss.cacheDir) - * @returns {{items: Array, 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; - } -} - -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); - }); - } -} - -/** - * articles Phase 2:按页面配置的“分类”聚合文章展示 - * - 规则:某篇文章的 sourceUrl/source 归属到其来源站点(pages/articles.yml 中配置的站点)所在的分类 - * - 兼容:旧缓存缺少 sourceUrl 时回退使用 source(站点名称)匹配 - * @param {Array} categories 页面配置 categories(可包含更深层级) - * @param {Array} articlesItems Phase 2 文章条目(来自缓存) - * @returns {Array<{name: string, icon: string, items: Array}>} - */ -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; -} - -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); -} - -/** - * 计算页面实际使用的模板名(与 renderPage 的规则保持一致) - * @param {string} pageId 页面ID - * @param {Object} config 已标准化的配置(prepareRenderData 的 renderData) - * @returns {string} 模板名 - */ -function resolveTemplateNameForPage(pageId, config) { - if (!pageId) return 'page'; - - const pageConfig = config && config[pageId] ? config[pageId] : null; - const explicitTemplate = - pageConfig && typeof pageConfig.template === 'string' ? pageConfig.template.trim() : ''; - if (explicitTemplate) return explicitTemplate; - - const inferredTemplatePath = path.join(process.cwd(), 'templates', 'pages', `${pageId}.hbs`); - if (fs.existsSync(inferredTemplatePath)) return pageId; - - return 'page'; -} - -/** - * 构建“扩展专用配置”(避免把整站配置/书签数据注入到 index.html) - * - 页面内仅注入必要元信息(版本/配置 URL/少量运行时参数) - * - 扩展如需更多信息,可按需请求 dist/menav-config.json - * @param {Object} renderData prepareRenderData 生成的渲染数据 - * @returns {Object} 扩展配置对象(可直接 JSON.stringify 输出到文件) - */ -function buildExtensionConfig(renderData) { - const version = - (renderData && renderData._meta && renderData._meta.version) || - process.env.npm_package_version || - '1.0.0'; - - const pageTemplates = {}; - if (Array.isArray(renderData && 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, - }, - }; -} - -/** - * 准备渲染数据,添加模板所需的特殊属性 - * @param {Object} config 配置对象 - * @returns {Object} 增强的渲染数据 - */ -function prepareRenderData(config) { - // 创建渲染数据对象,包含原始配置 - const renderData = { ...config }; - - // 添加额外渲染数据 - renderData._meta = { - generated_at: new Date(), - version: process.env.npm_package_version || '1.0.0', - generator: 'MeNav', - }; - - // 确保navigation是数组 - 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, // 保持旧模板兼容(由顺序决定,不读取配置的 active 字段) - }; - - // 使用辅助函数获取子菜单 - const submenu = getSubmenuForNavItem(navItem, renderData); - if (submenu) { - navItem.submenu = submenu; - } - - return navItem; - }); - } - - // 首页(默认页)规则:navigation 顺序第一项即首页 - renderData.homePageId = - renderData.navigation && renderData.navigation[0] ? renderData.navigation[0].id : null; - - // 为每个页面的分类生成稳定锚点 slug(解决重名/空格/特殊字符导致的 hash 冲突) - 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()); - } - }); - } - - // P1-7:避免把整站配置(尤其 pages/categories/sites)注入到 index.html,减少体积并明确前端暴露边界 - // - dist/menav-config.json:扩展专用配置(按需加载) - // - menav-config-data:仅注入必要元信息(版本/配置 URL/少量运行时参数) - const extensionConfig = buildExtensionConfig(renderData); - renderData.extensionConfig = extensionConfig; - renderData.extensionConfigUrl = `./${MENAV_EXTENSION_CONFIG_FILE}`; - renderData.configJSON = makeJsonSafeForHtmlScript( - JSON.stringify({ - ...extensionConfig, - configUrl: renderData.extensionConfigUrl, - }) - ); - - // 为Handlebars模板特别准备navigationData数组 - renderData.navigationData = renderData.navigation; - - // 确保social数据格式正确 - if (Array.isArray(renderData.social)) { - renderData.socialLinks = renderData.social; // 兼容模板中的不同引用名 - } - - return renderData; -} - -// 读取配置文件 -function loadConfig() { - // 初始化空配置对象 - let config = { - site: {}, - navigation: [], - fonts: {}, - profile: {}, - social: [], - }; - - // 检查模块化配置来源是否存在 - const hasUserModularConfig = fs.existsSync('config/user'); - const hasDefaultModularConfig = fs.existsSync('config/_default'); - - // 根据优先级顺序选择最高优先级的配置 - if (hasUserModularConfig) { - // 配置采用“完全替换”策略:一旦存在 config/user/,将不会回退到 config/_default/ - 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/,再按需修改。'); - } - - // 1. 最高优先级: config/user/ 目录 - config = loadModularConfig('config/user'); - } else if (hasDefaultModularConfig) { - // 2. 次高优先级: config/_default/ 目录 - 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)) { - // 移除警告日志,保留函数调用 - } - - // 准备渲染数据 - const renderData = prepareRenderData(config); - - return renderData; -} - -// 生成导航菜单 -function generateNavigation(navigation, config) { - return navigation - .map((nav) => { - // 根据页面ID获取对应的子菜单项(分类) - let submenuItems = ''; - - // 使用辅助函数获取子菜单数据 - const submenu = getSubmenuForNavItem(nav, config); - - // 如果存在子菜单,生成HTML - if (submenu && Array.isArray(submenu)) { - submenuItems = ` - `; - } - - return ` - `; - }) - .join('\n'); -} - -// 生成网站卡片HTML -function generateSiteCards(sites) { - if (!sites || !Array.isArray(sites) || sites.length === 0) { - return `

暂无网站

`; - } - - return sites - .map( - (site) => ` - - -

${escapeHtml(site.name || '未命名站点')}

-

${escapeHtml(site.description || '')}

-
` - ) - .join('\n'); -} - -// 生成分类板块 -function generateCategories(categories) { - if (!categories || !Array.isArray(categories) || categories.length === 0) { - return ` -
-

暂无分类

-

请在配置文件中添加分类

-
`; - } - - return categories - .map( - (category) => ` -
-

${escapeHtml(category.name)}

-
- ${generateSiteCards(category.sites)} -
-
` - ) - .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) => ` - ` - ) - .join('\n'); -} - -// 生成页面内容(包括首页和其他页面) -function generatePageContent(pageId, data) { - // 确保数据对象存在 - if (!data) { - console.error(`Missing data for page: ${pageId}`); - return ` -
-
-

页面未配置

-

请配置 ${pageId} 页面

-
-
`; - } - - // 首页使用profile数据,其他页面使用自身数据 - if (pageId === 'home') { - const profile = data.profile || {}; - - return ` -
-
-

${escapeHtml(profile.title || '欢迎使用')}

-

${escapeHtml(profile.subtitle || '个人导航站')}

-
-
-${generateCategories(data.categories)}`; - } else { - // 其他页面使用通用结构 - const title = data.title || `${pageId} 页面`; - const subtitle = data.subtitle || ''; - const categories = data.categories || []; - - return ` -
-
-

${escapeHtml(title)}

-

${escapeHtml(subtitle)}

-
-
- ${generateCategories(categories)}`; - } -} - -// 生成搜索结果页面 -function generateSearchResultsPage(config) { - // 获取所有导航页面ID - const pageIds = config.navigation.map((nav) => nav.id); - - // 生成所有页面的搜索结果区域 - const searchSections = 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 ` - `; - }) - .join('\n'); - - return ` - -
-
-
-

搜索结果

-

在所有页面中找到的匹配项

-
-
-${searchSections} -
`; -} - -/** - * 将 CSS 文本安全嵌入到 ` 结束标签导致样式块被提前终止。 - * @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 ``; - - return [ - ``, - ``, - ].join('\n'); -} - -// 生成字体相关 -function generateFontLinks(config) { - const fonts = getNormalizedFontsConfig(config); - const links = []; - - // 全站基础字体:按配置加载 - if (fonts.source === 'css' && fonts.cssUrl) { - const origin = tryGetUrlOrigin(fonts.cssUrl); - if (origin) { - links.push(``); - } - links.push(buildStylesheetLinkTag(fonts.cssUrl, fonts.preload)); - } - - if (fonts.source === 'google' && fonts.family) { - links.push(''); - links.push(''); - - 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); -} - -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}`, - }, - }; -} - -/** - * 渲染单个页面 - * @param {string} pageId 页面ID - * @param {Object} config 配置数据 - * @returns {string} 渲染后的HTML - */ -function renderPage(pageId, config) { - // 准备页面数据 - const data = { - ...(config || {}), - currentPage: pageId, - pageId, // 同时保留pageId字段,用于通用模板 - }; - - // 确保navigation是数组 - if (!Array.isArray(config.navigation)) { - console.warn('Warning: config.navigation is not an array in renderPage. Using empty array.'); - data.navigation = []; - } else { - // 设置当前页面为活动页,其他页面为非活动 - data.navigation = 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; - }); - } - - // 确保socialLinks字段存在 - data.socialLinks = Array.isArray(config.social) ? config.social : []; - - // 确保navigationData可用(针对模板使用) - data.navigationData = data.navigation; - - // 页面特定的额外数据 - if (config[pageId]) { - // 使用已经经过ensureConfigDefaults处理的配置数据 - Object.assign(data, config[pageId]); - } - - // 页面配置缺失时也尽量给出可用的默认值,避免渲染空标题/undefined - 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 explicitTemplate = typeof data.template === 'string' ? data.template.trim() : ''; - let templateName = explicitTemplate || pageId; - // 未显式指定模板时:若 pages/.hbs 不存在,则默认使用通用 page 模板(避免依赖回退日志) - if (!explicitTemplate) { - const inferredTemplatePath = path.join( - process.cwd(), - 'templates', - 'pages', - `${templateName}.hbs` - ); - if (!fs.existsSync(inferredTemplatePath)) { - templateName = 'page'; - } - } - - // 页面级卡片风格开关(用于差异化) - if (templateName === 'projects') { - 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); - } - } - } - - // friends/articles:允许顶层 sites(历史/兼容),自动转换为一个分类容器以保持页面结构一致 - // 注意:模板名可能被统一为 page(例如 friends/home 取消专属模板后),因此这里同时按 pageId 判断。 - 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, - }, - ]; - } - - // articles 模板页面:Phase 2 若存在 RSS 缓存,则注入 articlesItems(缓存缺失/损坏则回退 Phase 1) - if (templateName === 'articles') { - const cache = tryLoadArticlesFeedCache(pageId, config); - data.articlesItems = cache && Array.isArray(cache.items) ? cache.items : []; - data.articlesMeta = cache ? cache.meta : null; - // Phase 2:按页面配置分类聚合展示(用于模板渲染只读文章列表) - data.articlesCategories = data.articlesItems.length - ? buildArticlesCategoriesByPageCategories(data.categories, data.articlesItems) - : []; - } - - // bookmarks 模板页面:注入配置文件“内容更新时间”(优先 git,回退 mtime) - if (templateName === 'bookmarks') { - const updatedAtMeta = getPageConfigUpdatedAtMeta(pageId); - if (updatedAtMeta) { - data.pageMeta = { ...updatedAtMeta }; - } - } - - // 首页标题规则:使用 site.yml 的 profile 覆盖首页(导航第一项)的 title/subtitle 显示 - 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; - } - - // 分类锚点:为当前页面分类生成稳定 slug(用于 id/hash,避免重名/特殊字符冲突) - if (Array.isArray(data.categories) && data.categories.length > 0) { - assignCategorySlugs(data.categories, new Map()); - } - - if (config[pageId] && config[pageId].template) { - console.log(`页面 ${pageId} 使用指定模板: ${templateName}`); - } - - // 直接渲染页面内容,不使用layout布局(因为layout会在generateHTML中统一应用) - 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); - } - - // 复制JavaScript文件 - try { - if (!tryMinifyStaticAsset('src/script.js', 'dist/script.js', 'js')) { - fs.copyFileSync('src/script.js', 'dist/script.js'); - } - } catch (e) { - console.error('Error copying script.js:', e); - } - - // 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); - } - } -} - -// 生成 GitHub Pages 的 404 回跳页:将 / 形式的路径深链接转换为 /?page= -function generate404Html(config) { - const siteTitle = - config && config.site && typeof config.site.title === 'string' ? config.site.title : 'MeNav'; - const safeTitle = escapeHtml(siteTitle); - - return ` - - - - - - ${safeTitle} - 页面未找到 - - - - -
-

页面未找到

-

若你访问的是“页面路径深链接”,系统将自动回跳到 ?page= 形式的可用地址。

-

返回首页

-
- - -`; -} - -// 主函数 -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 静态路由回退:用于支持 / 形式的路径深链接 - fs.writeFileSync('dist/404.html', generate404Html(config)); - - // 复制静态文件 - copyStaticFiles(config); - } catch (e) { - console.error('Error in main function:', e); - process.exit(1); - } -} +module.exports = impl; if (require.main === module) { - main(); + if (typeof impl.main === 'function') { + impl.main(); + } else { + console.error('generator main() 未导出,无法直接执行。'); + process.exitCode = 1; + } } -// 导出供测试使用的函数 -module.exports = { - loadConfig, - generateHTML, - generate404Html, - copyStaticFiles, - generateNavigation, - generateCategories, - loadHandlebarsTemplates, - renderTemplate, - generateAllPagesHTML, -}; diff --git a/src/generator/cache/articles.js b/src/generator/cache/articles.js new file mode 100644 index 0000000..e582a9f --- /dev/null +++ b/src/generator/cache/articles.js @@ -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, 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} categories 页面配置 categories(可包含更深层级) + * @param {Array} articlesItems Phase 2 文章条目(来自缓存) + * @returns {Array<{name: string, icon: string, items: Array}>} + */ +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, +}; + diff --git a/src/generator/cache/projects.js b/src/generator/cache/projects.js new file mode 100644 index 0000000..cf211b8 --- /dev/null +++ b/src/generator/cache/projects.js @@ -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, +}; + diff --git a/src/generator/config/index.js b/src/generator/config/index.js new file mode 100644 index 0000000..a04c774 --- /dev/null +++ b/src/generator/config/index.js @@ -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, +}; diff --git a/src/generator/config/loader.js b/src/generator/config/loader.js new file mode 100644 index 0000000..b4fd998 --- /dev/null +++ b/src/generator/config/loader.js @@ -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, +}; diff --git a/src/generator/config/resolver.js b/src/generator/config/resolver.js new file mode 100644 index 0000000..48acb8b --- /dev/null +++ b/src/generator/config/resolver.js @@ -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, +}; diff --git a/src/generator/config/slugs.js b/src/generator/config/slugs.js new file mode 100644 index 0000000..c7d153f --- /dev/null +++ b/src/generator/config/slugs.js @@ -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, +}; diff --git a/src/generator/config/validator.js b/src/generator/config/validator.js new file mode 100644 index 0000000..efd834c --- /dev/null +++ b/src/generator/config/validator.js @@ -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, +}; diff --git a/src/generator/html/404.js b/src/generator/html/404.js new file mode 100644 index 0000000..0143f36 --- /dev/null +++ b/src/generator/html/404.js @@ -0,0 +1,94 @@ +const { escapeHtml } = require('../utils/html'); + +// 生成 GitHub Pages 的 404 回跳页:将 / 形式的路径深链接转换为 /?page= +function generate404Html(config) { + const siteTitle = config && config.site && typeof config.site.title === 'string' ? config.site.title : 'MeNav'; + const safeTitle = escapeHtml(siteTitle); + + return ` + + + + + + ${safeTitle} - 页面未找到 + + + + +
+

页面未找到

+

若你访问的是“页面路径深链接”,系统将自动回跳到 ?page= 形式的可用地址。

+

返回首页

+
+ + +`; +} + +module.exports = { + generate404Html, +}; diff --git a/src/generator/html/components.js b/src/generator/html/components.js new file mode 100644 index 0000000..34e85ca --- /dev/null +++ b/src/generator/html/components.js @@ -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 = ` + `; + } + + return ` + `; + }) + .join('\n'); +} + +// 生成网站卡片HTML +function generateSiteCards(sites) { + if (!sites || !Array.isArray(sites) || sites.length === 0) { + return `

暂无网站

`; + } + + return sites + .map( + (site) => ` + + +

${escapeHtml(site.name || '未命名站点')}

+

${escapeHtml(site.description || '')}

+
` + ) + .join('\n'); +} + +// 生成分类板块 +function generateCategories(categories) { + if (!categories || !Array.isArray(categories) || categories.length === 0) { + return ` +
+

暂无分类

+

请在配置文件中添加分类

+
`; + } + + return categories + .map( + (category) => ` +
+

${escapeHtml(category.name)}

+
+ ${generateSiteCards(category.sites)} +
+
` + ) + .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) => ` + ` + ) + .join('\n'); +} + +// 生成页面内容(包括首页和其他页面) +function generatePageContent(pageId, data) { + // 确保数据对象存在 + if (!data) { + console.error(`Missing data for page: ${pageId}`); + return ` +
+
+

页面未配置

+

请配置 ${pageId} 页面

+
+
`; + } + + // 首页使用 profile 数据,其他页面使用自身数据 + if (pageId === 'home') { + const profile = data.profile || {}; + + return ` +
+
+

${escapeHtml(profile.title || '欢迎使用')}

+

${escapeHtml(profile.subtitle || '个人导航站')}

+
+
+${generateCategories(data.categories)}`; + } else { + // 其他页面使用通用结构 + const title = data.title || `${pageId} 页面`; + const subtitle = data.subtitle || ''; + const categories = data.categories || []; + + return ` +
+
+

${escapeHtml(title)}

+

${escapeHtml(subtitle)}

+
+
+ ${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 ` + `; + }) + .join('\n'); + + return ` + +
+
+
+

搜索结果

+

在所有页面中找到的匹配项

+
+
+${sections} +
`; +} + +module.exports = { + generateNavigation, + generateSiteCards, + generateCategories, + generateSocialLinks, + generatePageContent, + generateSearchResultsPage, +}; + diff --git a/src/generator/html/fonts.js b/src/generator/html/fonts.js new file mode 100644 index 0000000..bbea747 --- /dev/null +++ b/src/generator/html/fonts.js @@ -0,0 +1,155 @@ +const { escapeHtml } = require('../utils/html'); + +/** + * 将 CSS 文本安全嵌入到 ` 结束标签导致样式块被提前终止。 + * @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 ``; + + return [ + ``, + ``, + ].join('\n'); +} + +// 生成字体相关 +function generateFontLinks(config) { + const fonts = getNormalizedFontsConfig(config); + const links = []; + + // 全站基础字体:按配置加载 + if (fonts.source === 'css' && fonts.cssUrl) { + const origin = tryGetUrlOrigin(fonts.cssUrl); + if (origin) { + links.push(``); + } + links.push(buildStylesheetLinkTag(fonts.cssUrl, fonts.preload)); + } + + if (fonts.source === 'google' && fonts.family) { + links.push(''); + links.push(''); + + 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, +}; diff --git a/src/generator/html/page-data.js b/src/generator/html/page-data.js new file mode 100644 index 0000000..52c95c5 --- /dev/null +++ b/src/generator/html/page-data.js @@ -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, +}; diff --git a/src/generator/main.js b/src/generator/main.js new file mode 100644 index 0000000..fa53817 --- /dev/null +++ b/src/generator/main.js @@ -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 静态路由回退:用于支持 / 形式的路径深链接 + 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, +}; diff --git a/src/generator/template/engine.js b/src/generator/template/engine.js new file mode 100644 index 0000000..1e63f2e --- /dev/null +++ b/src/generator/template/engine.js @@ -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, +}; diff --git a/src/generator/utils/html.js b/src/generator/utils/html.js new file mode 100644 index 0000000..d59186b --- /dev/null +++ b/src/generator/utils/html.js @@ -0,0 +1,17 @@ +// HTML 转义函数,防止 XSS 攻击 +function escapeHtml(unsafe) { + if (unsafe === undefined || unsafe === null) { + return ''; + } + return String(unsafe) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/\"/g, '"') + .replace(/'/g, '''); +} + +module.exports = { + escapeHtml, +}; + diff --git a/src/generator/utils/pageMeta.js b/src/generator/utils/pageMeta.js new file mode 100644 index 0000000..918788b --- /dev/null +++ b/src/generator/utils/pageMeta.js @@ -0,0 +1,101 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); + +/** + * 解析页面配置文件路径(优先 user,回退 _default) + * 注意:仅用于构建期读取文件元信息,不会把路径注入到页面/扩展配置中。 + * @param {string} pageId 页面ID(与 pages/.yml 文件名对应) + * @returns {string|null} 文件路径或 null + */ +function resolvePageConfigFilePath(pageId) { + if (!pageId) return null; + + const candidates = [ + path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yml`), + path.join(process.cwd(), 'config', 'user', 'pages', `${pageId}.yaml`), + path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yml`), + path.join(process.cwd(), 'config', '_default', 'pages', `${pageId}.yaml`), + ]; + + for (const filePath of candidates) { + try { + if (fs.existsSync(filePath)) return filePath; + } catch (e) { + // 忽略 IO 异常,继续尝试下一个候选 + } + } + + return null; +} + +/** + * 尝试获取文件最后一次 git 提交时间(ISO 字符串) + * @param {string} filePath 文件路径 + * @returns {string|null} ISO 字符串(UTC),失败返回 null + */ +function tryGetGitLastCommitIso(filePath) { + if (!filePath) return null; + + try { + const relativePath = path.relative(process.cwd(), filePath).replace(/\\/g, '/'); + const output = execFileSync('git', ['log', '-1', '--format=%cI', '--', relativePath], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + const raw = String(output || '').trim(); + if (!raw) return null; + + const date = new Date(raw); + if (Number.isNaN(date.getTime())) return null; + + return date.toISOString(); + } catch (e) { + return null; + } +} + +/** + * 获取文件 mtime(ISO 字符串) + * @param {string} filePath 文件路径 + * @returns {string|null} ISO 字符串(UTC),失败返回 null + */ +function tryGetFileMtimeIso(filePath) { + if (!filePath) return null; + + try { + const stats = fs.statSync(filePath); + const mtime = stats && stats.mtime ? stats.mtime : null; + if (!(mtime instanceof Date) || Number.isNaN(mtime.getTime())) return null; + return mtime.toISOString(); + } catch (e) { + return null; + } +} + +/** + * 计算页面配置文件“内容更新时间”(优先 git,回退 mtime) + * @param {string} pageId 页面ID + * @returns {{updatedAt: string, updatedAtSource: 'git'|'mtime'}|null} + */ +function getPageConfigUpdatedAtMeta(pageId) { + const filePath = resolvePageConfigFilePath(pageId); + if (!filePath) return null; + + const gitIso = tryGetGitLastCommitIso(filePath); + if (gitIso) { + return { updatedAt: gitIso, updatedAtSource: 'git' }; + } + + const mtimeIso = tryGetFileMtimeIso(filePath); + if (mtimeIso) { + return { updatedAt: mtimeIso, updatedAtSource: 'mtime' }; + } + + return null; +} + +module.exports = { + getPageConfigUpdatedAtMeta, +}; + diff --git a/src/generator/utils/sites.js b/src/generator/utils/sites.js new file mode 100644 index 0000000..90de9a7 --- /dev/null +++ b/src/generator/utils/sites.js @@ -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, +}; + diff --git a/src/runtime/app/index.js b/src/runtime/app/index.js new file mode 100644 index 0000000..bf66266 --- /dev/null +++ b/src/runtime/app/index.js @@ -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 }); +}); + diff --git a/src/runtime/app/routing.js b/src/runtime/app/routing.js new file mode 100644 index 0000000..1f7d7d0 --- /dev/null +++ b/src/runtime/app/routing.js @@ -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= 作为页面深链接(兼容 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= 直接打开对应页面;无效时回退到首页 + 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=# + 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 }; +}; + diff --git a/src/runtime/app/search.js b/src/runtime/app/search.js new file mode 100644 index 0000000..18bb22e --- /dev/null +++ b/src/runtime/app/search.js @@ -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, + }; +}; diff --git a/src/runtime/app/search/highlight.js b/src/runtime/app/search/highlight.js new file mode 100644 index 0000000..925c052 --- /dev/null +++ b/src/runtime/app/search/highlight.js @@ -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'); + } +}; + diff --git a/src/runtime/app/searchEngines.js b/src/runtime/app/searchEngines.js new file mode 100644 index 0000000..c3e4850 --- /dev/null +++ b/src/runtime/app/searchEngines.js @@ -0,0 +1,25 @@ +module.exports = { + local: { + name: '本地搜索', + iconSvg: ``, + url: null, // 本地搜索不需要URL + }, + google: { + name: 'Google搜索', + iconSvg: ``, + url: 'https://www.google.com/search?q=', + }, + bing: { + name: 'Bing搜索', + iconSvg: ``, + url: 'https://www.bing.com/search?q=', + }, + duckduckgo: { + name: 'DuckDuckGo搜索', + shortName: 'duckgo', + // DuckDuckGo 使用内联 SVG,避免依赖不存在的 Font Awesome 品牌图标 + iconSvg: ``, + url: 'https://duckduckgo.com/?q=', + }, +}; + diff --git a/src/runtime/app/ui.js b/src/runtime/app/ui.js new file mode 100644 index 0000000..119e96e --- /dev/null +++ b/src/runtime/app/ui.js @@ -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, + }; +}; + diff --git a/src/runtime/index.js b/src/runtime/index.js new file mode 100644 index 0000000..efb32aa --- /dev/null +++ b/src/runtime/index.js @@ -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'); + diff --git a/src/runtime/menav/addElement.js b/src/runtime/menav/addElement.js new file mode 100644 index 0000000..1761c92 --- /dev/null +++ b/src/runtime/menav/addElement.js @@ -0,0 +1,351 @@ +const { menavExtractDomain, menavSanitizeClassList, menavSanitizeUrl } = require('../shared'); + +// 添加新元素 +module.exports = function addElement(type, parentId, data) { + if (type === 'site') { + // 查找父级分类 + const parent = document.querySelector(`[data-type="category"][data-name="${parentId}"]`); + if (!parent) return null; + + // 添加站点卡片到分类 + const sitesContainer = parent.querySelector('[data-container="sites"]'); + if (!sitesContainer) return null; + + // 站点卡片样式:根据“页面模板”决定(friends/articles/projects 等) + let siteCardStyle = ''; + try { + const pageEl = parent.closest('.page'); + const pageId = pageEl && pageEl.id ? String(pageEl.id).trim() : ''; + const cfg = + window.MeNav && typeof window.MeNav.getConfig === 'function' ? window.MeNav.getConfig() : null; + + let templateName = ''; + + const pageTemplates = + cfg && cfg.data && cfg.data.pageTemplates && typeof cfg.data.pageTemplates === 'object' + ? cfg.data.pageTemplates + : null; + + const templateFromMap = + pageTemplates && pageId && pageTemplates[pageId] ? String(pageTemplates[pageId]).trim() : ''; + + // 兼容旧版:cfg.data[pageId].template + const legacyPageConfig = cfg && cfg.data && pageId ? cfg.data[pageId] : null; + const templateFromLegacy = + legacyPageConfig && legacyPageConfig.template ? String(legacyPageConfig.template).trim() : ''; + + if (templateFromMap) { + templateName = templateFromMap; + } else if (templateFromLegacy) { + templateName = templateFromLegacy; + } + + // projects 模板使用代码仓库风格卡片(与生成端 templates/components/site-card.hbs 保持一致) + if (templateName === 'projects') siteCardStyle = 'repo'; + } catch (e) { + siteCardStyle = ''; + } + + // 创建新的站点卡片 + const newSite = document.createElement('a'); + newSite.className = siteCardStyle ? `site-card site-card-${siteCardStyle}` : 'site-card'; + + const siteName = data.name || '未命名站点'; + const siteUrl = data.url || '#'; + const siteIcon = data.icon || 'fas fa-link'; + const siteDescription = data.description || (data.url ? menavExtractDomain(data.url) : ''); + const siteFaviconUrl = data && data.faviconUrl ? String(data.faviconUrl).trim() : ''; + const siteForceIconModeRaw = data && data.forceIconMode ? String(data.forceIconMode).trim() : ''; + const siteForceIconMode = + siteForceIconModeRaw === 'manual' || siteForceIconModeRaw === 'favicon' ? siteForceIconModeRaw : ''; + + const safeSiteUrl = menavSanitizeUrl(siteUrl, 'addElement(site).url'); + const safeSiteIcon = menavSanitizeClassList(siteIcon, 'addElement(site).icon'); + + newSite.setAttribute('href', safeSiteUrl); + newSite.title = siteName + (siteDescription ? ' - ' + siteDescription : ''); + newSite.setAttribute('data-tooltip', siteName + (siteDescription ? ' - ' + siteDescription : '')); + if (/^https?:\/\//i.test(safeSiteUrl)) { + newSite.target = '_blank'; + newSite.rel = 'noopener'; + } + + // 设置数据属性 + newSite.setAttribute('data-type', 'site'); + newSite.setAttribute('data-name', siteName); + // 保留原始 URL(data-url)供扩展/调试读取;href 仍会做安全降级 + newSite.setAttribute('data-url', String(data.url || '').trim()); + newSite.setAttribute('data-icon', safeSiteIcon); + if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl); + if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode); + newSite.setAttribute('data-description', siteDescription); + + // projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑) + if (siteCardStyle === 'repo') { + const repoHeader = document.createElement('div'); + repoHeader.className = 'repo-header'; + + const repoIcon = document.createElement('i'); + repoIcon.className = `${safeSiteIcon || 'fas fa-code'} repo-icon`; + repoIcon.setAttribute('aria-hidden', 'true'); + + const repoTitle = document.createElement('div'); + repoTitle.className = 'repo-title'; + repoTitle.textContent = siteName; + + repoHeader.appendChild(repoIcon); + repoHeader.appendChild(repoTitle); + + const repoDesc = document.createElement('div'); + repoDesc.className = 'repo-desc'; + repoDesc.textContent = siteDescription; + + newSite.appendChild(repoHeader); + newSite.appendChild(repoDesc); + + const hasStats = data && (data.language || data.stars || data.forks || data.issues); + + if (hasStats) { + const repoStats = document.createElement('div'); + repoStats.className = 'repo-stats'; + + if (data.language) { + const languageItem = document.createElement('div'); + languageItem.className = 'stat-item'; + + const langDot = document.createElement('span'); + langDot.className = 'lang-dot'; + langDot.style.backgroundColor = data.languageColor || '#909296'; + + languageItem.appendChild(langDot); + languageItem.appendChild(document.createTextNode(String(data.language))); + repoStats.appendChild(languageItem); + } + + if (data.stars) { + const starsItem = document.createElement('div'); + starsItem.className = 'stat-item'; + + const starIcon = document.createElement('i'); + starIcon.className = 'far fa-star'; + starIcon.setAttribute('aria-hidden', 'true'); + starsItem.appendChild(starIcon); + starsItem.appendChild(document.createTextNode(String(data.stars))); + repoStats.appendChild(starsItem); + } + + if (data.forks) { + const forksItem = document.createElement('div'); + forksItem.className = 'stat-item'; + + const forkIcon = document.createElement('i'); + forkIcon.className = 'fas fa-code-branch'; + forkIcon.setAttribute('aria-hidden', 'true'); + forksItem.appendChild(forkIcon); + forksItem.appendChild(document.createTextNode(String(data.forks))); + repoStats.appendChild(forksItem); + } + + if (data.issues) { + const issuesItem = document.createElement('div'); + issuesItem.className = 'stat-item'; + + const issueIcon = document.createElement('i'); + issueIcon.className = 'fas fa-exclamation-circle'; + issueIcon.setAttribute('aria-hidden', 'true'); + issuesItem.appendChild(issueIcon); + issuesItem.appendChild(document.createTextNode(String(data.issues))); + repoStats.appendChild(issuesItem); + } + + newSite.appendChild(repoStats); + } + } else { + // 普通站点卡片:复用现有结构(支持 favicon) + const siteCardIcon = document.createElement('div'); + siteCardIcon.className = 'site-card-icon'; + + const iconEl = document.createElement('i'); + iconEl.className = safeSiteIcon || 'fas fa-link'; + iconEl.setAttribute('aria-hidden', 'true'); + + // 添加内容(根据图标模式渲染,避免 innerHTML 注入) + siteCardIcon.appendChild(iconEl); + + const titleEl = document.createElement('h3'); + titleEl.textContent = siteName; + + const descEl = document.createElement('p'); + descEl.textContent = siteDescription; + + newSite.appendChild(siteCardIcon); + newSite.appendChild(titleEl); + newSite.appendChild(descEl); + + // favicon 模式:优先加载 faviconUrl;否则按 url 生成 + try { + const cfg = + window.MeNav && typeof window.MeNav.getConfig === 'function' ? window.MeNav.getConfig() : null; + const iconsMode = cfg && cfg.icons && cfg.icons.mode ? String(cfg.icons.mode).trim() : 'favicon'; + const iconsRegion = + cfg && cfg.icons && cfg.icons.region ? String(cfg.icons.region).trim() : 'com'; + + const forceMode = siteForceIconMode || ''; + const shouldUseFavicon = forceMode ? forceMode === 'favicon' : iconsMode === 'favicon'; + + if (shouldUseFavicon) { + const faviconImg = document.createElement('img'); + faviconImg.className = 'site-icon'; + faviconImg.loading = 'lazy'; + faviconImg.alt = siteName; + + const fallbackToIcon = () => { + faviconImg.remove(); + iconEl.classList.add('icon-fallback'); + }; + + // 超时处理:5秒后如果还没加载成功,显示回退图标 + const timeoutId = setTimeout(() => { + fallbackToIcon(); + }, 5000); + + faviconImg.onerror = () => { + clearTimeout(timeoutId); + fallbackToIcon(); + }; + faviconImg.onload = () => { + clearTimeout(timeoutId); + iconEl.remove(); + }; + + if (siteFaviconUrl) { + faviconImg.src = siteFaviconUrl; + } else { + const urlToUse = String(data.url || '').trim(); + if (urlToUse) { + // 根据 icons.region 配置决定优先使用哪个域名 + const urls = []; + const comUrl = `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent( + urlToUse + )}&size=32`; + const cnUrl = `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent( + urlToUse + )}&size=32`; + if (iconsRegion === 'cn') { + urls.push(cnUrl, comUrl); + } else { + urls.push(comUrl, cnUrl); + } + + let idx = 0; + faviconImg.src = urls[idx]; + + // 超时处理:3秒后如果还没加载成功,尝试回退 URL 或显示 Font Awesome 图标 + const fallbackTimeoutId = setTimeout(() => { + idx += 1; + if (idx < urls.length) { + faviconImg.src = urls[idx]; + } else { + fallbackToIcon(); + } + }, 3000); + + const cleanup = () => clearTimeout(fallbackTimeoutId); + faviconImg.onload = () => { + clearTimeout(timeoutId); + cleanup(); + iconEl.remove(); + }; + faviconImg.onerror = () => { + clearTimeout(timeoutId); + cleanup(); + idx += 1; + if (idx < urls.length) { + faviconImg.src = urls[idx]; + } else { + fallbackToIcon(); + } + }; + } + } + + siteCardIcon.insertBefore(faviconImg, iconEl); + } else { + iconEl.classList.add('icon-fallback'); + } + } catch (e) { + iconEl.classList.add('icon-fallback'); + } + } + + // 添加到 DOM + sitesContainer.appendChild(newSite); + + // 移除“暂无网站”提示(如果存在) + const emptySites = sitesContainer.querySelector('.empty-sites'); + if (emptySites) { + emptySites.remove(); + } + + // 触发元素添加事件 + this.events.emit('elementAdded', { + id: siteName, + type: 'site', + parentId: parentId, + data: data, + }); + + return siteName; + } else if (type === 'category') { + // 查找父级页面容器 + const parent = document.querySelector(`[data-page="${parentId}"]`); + if (!parent) return null; + + // 创建新的分类 + const newCategory = document.createElement('section'); + newCategory.className = 'category'; + newCategory.setAttribute('data-type', 'category'); + newCategory.setAttribute('data-name', data.name || '未命名分类'); + if (data.icon) { + newCategory.setAttribute( + 'data-icon', + menavSanitizeClassList(data.icon, 'addElement(category).data-icon') + ); + } + + // 设置数据属性 + newCategory.setAttribute('data-level', '1'); + + // 添加内容(用 DOM API 构建,避免 innerHTML 注入) + const titleEl = document.createElement('h2'); + const iconEl = document.createElement('i'); + iconEl.className = menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).icon'); + titleEl.appendChild(iconEl); + titleEl.appendChild(document.createTextNode(' ' + String(data.name || '未命名分类'))); + + const sitesGrid = document.createElement('div'); + sitesGrid.className = 'sites-grid'; + sitesGrid.setAttribute('data-container', 'sites'); + const emptyEl = document.createElement('p'); + emptyEl.className = 'empty-sites'; + emptyEl.textContent = '暂无网站'; + sitesGrid.appendChild(emptyEl); + + newCategory.appendChild(titleEl); + newCategory.appendChild(sitesGrid); + + // 添加到 DOM + parent.appendChild(newCategory); + + // 触发元素添加事件 + this.events.emit('elementAdded', { + id: data.name, + type: 'category', + data: data, + }); + + return data.name; + } + + return null; +}; diff --git a/src/runtime/menav/events.js b/src/runtime/menav/events.js new file mode 100644 index 0000000..042bdce --- /dev/null +++ b/src/runtime/menav/events.js @@ -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; + }, + }; +}; diff --git a/src/runtime/menav/getAllElements.js b/src/runtime/menav/getAllElements.js new file mode 100644 index 0000000..99cf548 --- /dev/null +++ b/src/runtime/menav/getAllElements.js @@ -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, + }; + }); +}; diff --git a/src/runtime/menav/getConfig.js b/src/runtime/menav/getConfig.js new file mode 100644 index 0000000..244b73e --- /dev/null +++ b/src/runtime/menav/getConfig.js @@ -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; +}; diff --git a/src/runtime/menav/index.js b/src/runtime/menav/index.js new file mode 100644 index 0000000..b93f8ab --- /dev/null +++ b/src/runtime/menav/index.js @@ -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; diff --git a/src/runtime/menav/removeElement.js b/src/runtime/menav/removeElement.js new file mode 100644 index 0000000..6692119 --- /dev/null +++ b/src/runtime/menav/removeElement.js @@ -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; +}; diff --git a/src/runtime/menav/updateElement.js b/src/runtime/menav/updateElement.js new file mode 100644 index 0000000..f3d3bd5 --- /dev/null +++ b/src/runtime/menav/updateElement.js @@ -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; +}; diff --git a/src/runtime/nested/index.js b/src/runtime/nested/index.js new file mode 100644 index 0000000..4f61dfc --- /dev/null +++ b/src/runtime/nested/index.js @@ -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, +}; diff --git a/src/runtime/shared.js b/src/runtime/shared.js new file mode 100644 index 0000000..1406771 --- /dev/null +++ b/src/runtime/shared.js @@ -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, +}; + diff --git a/src/runtime/tooltip.js b/src/runtime/tooltip.js new file mode 100644 index 0000000..6a751ff --- /dev/null +++ b/src/runtime/tooltip.js @@ -0,0 +1,115 @@ +// Tooltip functionality for truncated text(仅桌面端/支持悬停设备启用) +document.addEventListener('DOMContentLoaded', () => { + const hoverMedia = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)'); + + if (!hoverMedia) { + return; + } + + let cleanupTooltip = null; + + function enableTooltip() { + if (cleanupTooltip) return; + + // Create tooltip element + const tooltip = document.createElement('div'); + tooltip.className = 'custom-tooltip'; + document.body.appendChild(tooltip); + + let activeElement = null; + + function updateTooltipPosition() { + if (!activeElement) return; + + const rect = activeElement.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + const gap = 10; // 卡片与Tooltip的间距 + + // 默认显示在卡片下方 + let top = rect.bottom + gap; + // 水平居中对齐 + let left = rect.left + (rect.width - tooltipRect.width) / 2; + + const winWidth = window.innerWidth; + const winHeight = window.innerHeight; + + // 垂直边界检查:如果下方空间不足,尝试显示在上方 + if (top + tooltipRect.height > winHeight - gap) { + top = rect.top - tooltipRect.height - gap; + } + + // 水平边界检查:防止溢出屏幕左右边界 + if (left < gap) { + left = gap; + } else if (left + tooltipRect.width > winWidth - gap) { + left = winWidth - tooltipRect.width - gap; + } + + tooltip.style.left = left + 'px'; + tooltip.style.top = top + 'px'; + } + + // Show tooltip on hover + function onMouseOver(e) { + const target = e.target.closest('[data-tooltip]'); + if (!target) return; + + const tooltipText = target.getAttribute('data-tooltip'); + if (!tooltipText) return; + + activeElement = target; + tooltip.textContent = tooltipText; + tooltip.classList.add('visible'); + // 先显示元素让浏览器计算尺寸,然后立即更新位置 + updateTooltipPosition(); + } + + // Hide tooltip on mouse out + function onMouseOut(e) { + const target = e.target.closest('[data-tooltip]'); + if (!target || target !== activeElement) return; + + // Check if we really left the element (not just went to a child) + if (target.contains(e.relatedTarget)) return; + + activeElement = null; + tooltip.classList.remove('visible'); + } + + document.addEventListener('mouseover', onMouseOver); + document.addEventListener('mouseout', onMouseOut); + + cleanupTooltip = () => { + document.removeEventListener('mouseover', onMouseOver); + document.removeEventListener('mouseout', onMouseOut); + + activeElement = null; + tooltip.classList.remove('visible'); + tooltip.remove(); + }; + } + + function disableTooltip() { + if (!cleanupTooltip) return; + cleanupTooltip(); + cleanupTooltip = null; + } + + function syncTooltipEnabled() { + if (hoverMedia.matches) { + enableTooltip(); + } else { + disableTooltip(); + } + } + + syncTooltipEnabled(); + + // 兼容旧版 Safari:addListener/removeListener + if (hoverMedia.addEventListener) { + hoverMedia.addEventListener('change', syncTooltipEnabled); + } else if (hoverMedia.addListener) { + hoverMedia.addListener(syncTooltipEnabled); + } +}); + diff --git a/src/script.js b/src/script.js deleted file mode 100644 index 330a080..0000000 --- a/src/script.js +++ /dev/null @@ -1,2374 +0,0 @@ -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) { - // 既不是可识别的绝对 URL,也不是允许的相对 URL - 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`); -} - -menavUpdateAppHeight(); -window.addEventListener('resize', menavUpdateAppHeight); -if (window.visualViewport) { - window.visualViewport.addEventListener('resize', menavUpdateAppHeight); -} - -// 配置数据缓存:避免浏览器扩展/站点脚本频繁 JSON.parse -let menavConfigCacheReady = false; -let menavConfigCacheRaw = null; -let menavConfigCacheValue = null; - -// 全局MeNav对象 - 用于浏览器扩展 -window.MeNav = { - version: menavDetectVersion(), - - // 获取配置数据 - getConfig: function (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; - }, - - // 获取元素的唯一标识符 - _getElementId: function (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'); - } - }, - - // 根据类型和ID查找元素 - _findElement: function (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); - }, - - // 更新DOM元素 - updateElement: function (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; - }, - - // 添加新元素 - addElement: function (type, parentId, data) { - let parent; - - if (type === 'site') { - // 查找父级分类 - parent = document.querySelector(`[data-type="category"][data-name="${parentId}"]`); - if (!parent) return null; - - // 添加站点卡片到分类 - const sitesGrid = parent.querySelector('[data-container="sites"]'); - if (!sitesGrid) return null; - - // 站点卡片样式:根据“页面模板”决定(friends/articles/projects 等) - let siteCardStyle = ''; - try { - const pageEl = parent.closest('.page'); - const pageId = pageEl && pageEl.id ? String(pageEl.id) : ''; - let templateName = pageId; - - const cfg = - window.MeNav && typeof window.MeNav.getConfig === 'function' - ? window.MeNav.getConfig() - : null; - 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 : '') - ); // 添加自定义 tooltip - if (/^https?:\/\//i.test(safeSiteUrl)) { - newSite.target = '_blank'; - newSite.rel = 'noopener'; - } - - // 设置数据属性 - newSite.setAttribute('data-type', 'site'); - newSite.setAttribute('data-name', siteName); - // 保留原始 URL(data-url)供扩展/调试读取;href 仍会做安全降级 - newSite.setAttribute('data-url', String(data.url || '').trim()); - newSite.setAttribute('data-icon', safeSiteIcon); - if (siteFaviconUrl) newSite.setAttribute('data-favicon-url', siteFaviconUrl); - if (siteForceIconMode) newSite.setAttribute('data-force-icon-mode', siteForceIconMode); - newSite.setAttribute('data-description', siteDescription); - - // projects repo 风格:与模板中的 repo 结构保持一致(不走 favicon 逻辑) - if (siteCardStyle === 'repo') { - const repoHeader = document.createElement('div'); - repoHeader.className = 'repo-header'; - - const repoIcon = document.createElement('i'); - repoIcon.className = `${safeSiteIcon || 'fas fa-code'} repo-icon`; - repoIcon.setAttribute('aria-hidden', 'true'); - - const repoTitle = document.createElement('div'); - repoTitle.className = 'repo-title'; - repoTitle.textContent = siteName; - - repoHeader.appendChild(repoIcon); - repoHeader.appendChild(repoTitle); - - const repoDesc = document.createElement('div'); - repoDesc.className = 'repo-desc'; - repoDesc.textContent = siteDescription; - - newSite.appendChild(repoHeader); - newSite.appendChild(repoDesc); - - const hasStats = data && (data.language || data.stars || data.forks || data.issues); - - if (hasStats) { - const repoStats = document.createElement('div'); - repoStats.className = 'repo-stats'; - - if (data.language) { - const languageItem = document.createElement('div'); - languageItem.className = 'stat-item'; - - const langDot = document.createElement('span'); - langDot.className = 'lang-dot'; - langDot.style.backgroundColor = data.languageColor || '#909296'; - - languageItem.appendChild(langDot); - languageItem.appendChild(document.createTextNode(String(data.language))); - repoStats.appendChild(languageItem); - } - - if (data.stars) { - const starsItem = document.createElement('div'); - starsItem.className = 'stat-item'; - - const starIcon = document.createElement('i'); - starIcon.className = 'far fa-star'; - starIcon.setAttribute('aria-hidden', 'true'); - starsItem.appendChild(starIcon); - starsItem.appendChild(document.createTextNode(` ${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(` ${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(` ${data.issues}`)); - repoStats.appendChild(issuesItem); - } - - newSite.appendChild(repoStats); - } - } else { - // 添加内容(根据图标模式渲染,避免 innerHTML 注入) - const iconWrapper = document.createElement('div'); - iconWrapper.className = 'site-card-icon'; - iconWrapper.setAttribute('aria-hidden', 'true'); - - const contentWrapper = document.createElement('div'); - contentWrapper.className = 'site-card-content'; - - const titleEl = document.createElement('h3'); - titleEl.textContent = siteName; - - const descEl = document.createElement('p'); - descEl.textContent = siteDescription; - - contentWrapper.appendChild(titleEl); - contentWrapper.appendChild(descEl); - - let iconsMode = 'favicon'; - let iconsRegion = 'com'; - try { - const cfg = - window.MeNav && typeof window.MeNav.getConfig === 'function' - ? window.MeNav.getConfig() - : null; - iconsMode = (cfg && (cfg.data?.icons?.mode || cfg.icons?.mode)) || 'favicon'; - iconsRegion = (cfg && (cfg.data?.icons?.region || cfg.icons?.region)) || 'com'; - } catch (e) { - iconsMode = 'favicon'; - iconsRegion = 'com'; - } - - const shouldUseCustomFavicon = Boolean(siteFaviconUrl); - const effectiveIconsMode = siteForceIconMode || iconsMode; - - if (shouldUseCustomFavicon) { - const iconContainer = document.createElement('div'); - iconContainer.className = 'icon-container'; - - const placeholder = document.createElement('i'); - placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder'; - placeholder.setAttribute('aria-hidden', 'true'); - - const fallback = document.createElement('i'); - fallback.className = `${safeSiteIcon} icon-fallback`; - fallback.setAttribute('aria-hidden', 'true'); - - const favicon = document.createElement('img'); - favicon.className = 'favicon-icon'; - favicon.src = siteFaviconUrl; - favicon.alt = `${siteName} favicon`; - favicon.loading = 'lazy'; - - // 超时处理:5秒后如果还没加载成功,显示回退图标 - let loadTimeout = setTimeout(() => { - if (!favicon.classList.contains('loaded')) { - favicon.classList.add('error'); - placeholder.classList.add('hidden'); - fallback.classList.add('visible'); - } - }, 5000); - - favicon.addEventListener('load', () => { - clearTimeout(loadTimeout); - favicon.classList.add('loaded'); - placeholder.classList.add('hidden'); - }); - favicon.addEventListener('error', () => { - clearTimeout(loadTimeout); - favicon.classList.add('error'); - placeholder.classList.add('hidden'); - fallback.classList.add('visible'); - }); - - iconContainer.appendChild(placeholder); - iconContainer.appendChild(favicon); - iconContainer.appendChild(fallback); - iconWrapper.appendChild(iconContainer); - } else if ( - effectiveIconsMode === 'favicon' && - safeSiteUrl && - /^https?:\/\//i.test(safeSiteUrl) - ) { - // 根据 icons.region 配置决定优先使用哪个域名 - const faviconUrlPrimary = - iconsRegion === 'cn' - ? `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32` - : `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`; - const faviconUrlFallback = - iconsRegion === 'cn' - ? `https://t3.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32` - : `https://t3.gstatic.cn/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${encodeURIComponent(safeSiteUrl)}&size=32`; - - const iconContainer = document.createElement('div'); - iconContainer.className = 'icon-container'; - - const placeholder = document.createElement('i'); - placeholder.className = 'fas fa-circle-notch fa-spin icon-placeholder'; - placeholder.setAttribute('aria-hidden', 'true'); - - const fallback = document.createElement('i'); - fallback.className = `${safeSiteIcon} icon-fallback`; - fallback.setAttribute('aria-hidden', 'true'); - - const favicon = document.createElement('img'); - favicon.className = 'favicon-icon'; - favicon.src = faviconUrlPrimary; - favicon.alt = `${siteName} favicon`; - favicon.loading = 'lazy'; - let faviconFallbackTried = false; - let loadTimeout = null; - - // 超时处理:3秒后如果还没加载成功,尝试回退 URL 或显示 Font Awesome 图标 - const startTimeout = () => { - if (loadTimeout) clearTimeout(loadTimeout); - loadTimeout = setTimeout(() => { - if (!favicon.classList.contains('loaded')) { - if (!faviconFallbackTried) { - faviconFallbackTried = true; - favicon.src = faviconUrlFallback; - startTimeout(); // 为 fallback URL 也设置超时 - } else { - favicon.classList.add('error'); - placeholder.classList.add('hidden'); - fallback.classList.add('visible'); - } - } - }, 3000); - }; - startTimeout(); - - favicon.addEventListener('load', () => { - if (loadTimeout) clearTimeout(loadTimeout); - favicon.classList.add('loaded'); - placeholder.classList.add('hidden'); - }); - favicon.addEventListener('error', () => { - if (loadTimeout) clearTimeout(loadTimeout); - if (!faviconFallbackTried) { - faviconFallbackTried = true; - favicon.src = faviconUrlFallback; - startTimeout(); // 为 fallback URL 也设置超时 - return; - } - - favicon.classList.add('error'); - placeholder.classList.add('hidden'); - fallback.classList.add('visible'); - }); - - iconContainer.appendChild(placeholder); - iconContainer.appendChild(favicon); - iconContainer.appendChild(fallback); - iconWrapper.appendChild(iconContainer); - } else { - const iconEl = document.createElement('i'); - iconEl.className = `${safeSiteIcon} site-icon`; - iconEl.setAttribute('aria-hidden', 'true'); - iconWrapper.appendChild(iconEl); - } - - newSite.appendChild(iconWrapper); - newSite.appendChild(contentWrapper); - } - - // 添加到DOM - sitesGrid.appendChild(newSite); - - // 移除"暂无网站"提示(如果存在) - const emptyMessage = sitesGrid.querySelector('.empty-sites'); - if (emptyMessage) { - emptyMessage.remove(); - } - - // 触发元素添加事件 - this.events.emit('elementAdded', { - id: siteName, - type: 'site', - parentId: parentId, - data: data, - }); - - return siteName; - } else if (type === 'category') { - // 查找父级页面容器 - parent = document.querySelector(`[data-container="categories"]`); - if (!parent) return null; - - // 创建新的分类 - const newCategory = document.createElement('section'); - newCategory.className = 'category'; - - // 设置数据属性 - newCategory.setAttribute('data-type', 'category'); - newCategory.setAttribute('data-name', data.name || '未命名分类'); - newCategory.setAttribute( - 'data-icon', - menavSanitizeClassList(data.icon || 'fas fa-folder', 'addElement(category).data-icon') - ); - newCategory.setAttribute('data-container', 'categories'); - - // 添加内容(用 DOM API 构建,避免 innerHTML 注入) - const titleEl = document.createElement('h2'); - titleEl.setAttribute('data-editable', 'category-name'); - 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; - }, - - // 删除元素 - removeElement: function (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; - }, - - // 获取所有元素 - getAllElements: function (type) { - return Array.from(document.querySelectorAll(`[data-type="${type}"]`)).map((el) => { - const id = this._getElementId(el); - return { - id: id, - type: type, - element: el, - }; - }); - }, - - // 事件系统 - events: { - 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; - }, - }, -}; - -// 多层级嵌套书签功能 -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; -} - -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'); - } -}; - -// 更新分类切换按钮图标 -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', '展开分类'); - } -} - -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; -}; - -// 切换嵌套元素 -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; -} - -document.addEventListener('DOMContentLoaded', () => { - // 先声明所有状态变量 - let isSearchActive = false; - // 首页不再固定为 "home":以导航顺序第一项为准 - const homePageId = (() => { - // 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'; - })(); - - let currentPageId = homePageId; - let isInitialLoad = true; - let isSidebarOpen = false; - let isLightTheme = false; // 主题状态 - let isSidebarCollapsed = false; // 侧边栏折叠状态 - let pages; // 页面元素的全局引用 - let currentSearchEngine = 'local'; // 当前选择的搜索引擎 - - // 搜索索引,用于提高搜索效率 - let searchIndex = { - initialized: false, - items: [], - }; - - // 搜索引擎配置 - const searchEngines = { - local: { - name: '本地搜索', - iconSvg: ``, - url: null, // 本地搜索不需要URL - }, - google: { - name: 'Google搜索', - iconSvg: ``, - url: 'https://www.google.com/search?q=', - }, - bing: { - name: 'Bing搜索', - iconSvg: ``, - url: 'https://www.bing.com/search?q=', - }, - duckduckgo: { - name: 'DuckDuckGo搜索', - shortName: 'duckgo', - // DuckDuckGo 使用内联 SVG,避免依赖不存在的 Font Awesome 品牌图标 - iconSvg: ``, - url: 'https://duckduckgo.com/?q=', - }, - }; - - // 获取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'); - - // 移除预加载类,允许CSS过渡效果 - document.documentElement.classList.remove('preload'); - - // 应用从localStorage读取的主题设置 - if (document.documentElement.classList.contains('theme-preload')) { - document.documentElement.classList.remove('theme-preload'); - document.body.classList.add('light-theme'); - isLightTheme = true; - } - - // 应用从localStorage读取的侧边栏状态 - if (document.documentElement.classList.contains('sidebar-collapsed-preload')) { - document.documentElement.classList.remove('sidebar-collapsed-preload'); - sidebar.classList.add('collapsed'); - content.classList.add('expanded'); - isSidebarCollapsed = true; - } - - // 即时移除loading类,确保侧边栏可见 - document.body.classList.remove('loading'); - document.body.classList.add('loaded'); - - // 侧边栏折叠功能 - function toggleSidebarCollapse() { - // 仅在交互时启用布局相关动画,避免首屏闪烁 - document.documentElement.classList.add('with-anim'); - - isSidebarCollapsed = !isSidebarCollapsed; - - // 使用 requestAnimationFrame 确保平滑过渡 - requestAnimationFrame(() => { - sidebar.classList.toggle('collapsed', isSidebarCollapsed); - content.classList.toggle('expanded', isSidebarCollapsed); - - // 保存折叠状态到localStorage - localStorage.setItem('sidebarCollapsed', isSidebarCollapsed ? 'true' : 'false'); - }); - } - - // 初始化侧边栏折叠状态 - 已在页面加载前处理,此处仅完成图标状态初始化等次要任务 - function initSidebarState() { - // 从localStorage获取侧边栏状态 - const savedState = localStorage.getItem('sidebarCollapsed'); - - // 图标状态与折叠状态保持一致 - if (savedState === 'true' && !isMobile()) { - isSidebarCollapsed = true; - } else { - isSidebarCollapsed = false; - } - } - - // 侧边栏折叠按钮点击事件 - if (sidebarToggle) { - sidebarToggle.addEventListener('click', toggleSidebarCollapse); - } - - // 主题切换功能 - function toggleTheme() { - isLightTheme = !isLightTheme; - document.body.classList.toggle('light-theme', isLightTheme); - - // 更新图标 - if (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', isLightTheme ? 'light' : 'dark'); - } - - // 初始化主题 - 已在页面加载前处理,此处仅完成图标状态初始化等次要任务 - function initTheme() { - // 从localStorage获取主题偏好 - const savedTheme = localStorage.getItem('theme'); - - // 更新图标状态以匹配当前主题 - if (savedTheme === 'light') { - isLightTheme = true; - themeIcon.classList.remove('fa-moon'); - themeIcon.classList.add('fa-sun'); - } else { - isLightTheme = false; - themeIcon.classList.remove('fa-sun'); - themeIcon.classList.add('fa-moon'); - } - } - - // 主题切换按钮点击事件 - themeToggle.addEventListener('click', toggleTheme); - - // 初始化搜索索引 - function initSearchIndex() { - if (searchIndex.initialized) return; - - searchIndex.items = []; - - try { - // 为每个页面创建索引 - if (!pages) { - pages = document.querySelectorAll('.page'); - } - - 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 || - ''; - - // 将卡片信息添加到索引中 - searchIndex.items.push({ - pageId, - title, - description, - url, - icon, - element: card, - // 预先计算搜索文本,提高搜索效率 - searchText: (title + ' ' + description).toLowerCase(), - }); - } catch (cardError) { - console.error('Error processing card:', cardError); - } - }); - }); - - searchIndex.initialized = true; - } catch (error) { - console.error('Error initializing search index:', error); - searchIndex.initialized = true; // 防止反复尝试初始化 - } - } - - // 移动端菜单切换 - function toggleSidebar() { - isSidebarOpen = !isSidebarOpen; - sidebar.classList.toggle('active', isSidebarOpen); - overlay.classList.toggle('active', isSidebarOpen); - } - - // 移动端:搜索框常驻显示(CSS 控制),无需“搜索面板”开关;点击仅聚焦输入框 - function toggleSearch() { - searchInput && searchInput.focus(); - } - - // 关闭所有移动端面板 - function closeAllPanels() { - if (isSidebarOpen) { - toggleSidebar(); - } - } - - // 移动端事件监听 - 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(); - }); - - // 检查是否是移动设备 - function isMobile() { - return window.innerWidth <= 768; - } - - // 窗口大小改变时处理 - window.addEventListener('resize', () => { - if (!isMobile()) { - sidebar.classList.remove('active'); - searchContainer.classList.remove('active'); - overlay.classList.remove('active'); - isSidebarOpen = false; - } else { - // 在移动设备下,重置侧边栏折叠状态 - sidebar.classList.remove('collapsed'); - content.classList.remove('expanded'); - } - }); - - // 页面切换功能 - function showPage(pageId, skipSearchReset = false) { - if (currentPageId === pageId && !skipSearchReset && !isInitialLoad) return; - - currentPageId = pageId; - - // 使用 RAF 确保动画流畅 - requestAnimationFrame(() => { - if (!pages) { - pages = document.querySelectorAll('.page'); - } - - pages.forEach((page) => { - const shouldBeActive = page.id === pageId; - if (shouldBeActive !== page.classList.contains('active')) { - page.classList.toggle('active', shouldBeActive); - } - }); - - // 初始加载完成后设置标志 - if (isInitialLoad) { - isInitialLoad = false; - document.body.classList.add('loaded'); - } - }); - - // 重置滚动位置并更新进度条 - content.scrollTop = 0; - - // 只有在非搜索状态下才重置搜索 - if (!skipSearchReset) { - searchInput.value = ''; - resetSearch(); - } - } - - // 搜索功能 - function performSearch(searchTerm) { - // 确保搜索索引已初始化 - if (!searchIndex.initialized) { - initSearchIndex(); - } - - searchTerm = searchTerm.toLowerCase().trim(); - - // 如果搜索框为空,重置所有内容 - if (!searchTerm) { - resetSearch(); - return; - } - - if (!isSearchActive) { - isSearchActive = true; - } - - try { - // 使用搜索索引进行搜索 - const searchResults = new Map(); - let hasResults = false; - - // 使用更高效的搜索算法 - const matchedItems = 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 (currentPageId !== 'search-results') { - currentPageId = 'search-results'; - 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 highlightSearchTerm(card, searchTerm) { - if (!card || !searchTerm) return; - - 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'); - } - } - - // 转义正则表达式特殊字符 - function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - } - - // 重置搜索状态 - function resetSearch() { - if (!isSearchActive) return; - - 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 && currentPageId !== targetPageId) { - currentPageId = targetPageId; - pages.forEach((page) => { - page.classList.toggle('active', page.id === targetPageId); - }); - } - } else { - // 如果没有激活的导航项,默认显示首页 - currentPageId = homePageId; - pages.forEach((page) => { - page.classList.toggle('active', page.id === 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 (currentSearchEngine === 'local') { - debouncedSearch(e.target.value); - } else { - // 对于非本地搜索,重置之前的本地搜索结果(如果有) - if (isSearchActive) { - resetSearch(); - } - } - }); - - // 初始化搜索引擎设置 - function initSearchEngine() { - // 从本地存储获取上次选择的搜索引擎 - const savedEngine = localStorage.getItem('searchEngine'); - if (savedEngine && searchEngines[savedEngine]) { - 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') === currentSearchEngine) { - option.classList.add('active'); - } - - option.addEventListener('click', (e) => { - e.stopPropagation(); - - // 获取选中的搜索引擎 - const engine = option.getAttribute('data-engine'); - - // 更新当前搜索引擎 - if (engine && searchEngines[engine]) { - // 如果搜索引擎变更,且之前有活跃的本地搜索结果,重置搜索状态 - if (currentSearchEngine !== engine && isSearchActive) { - resetSearch(); - } - - 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'); - } - }); - } - - // 更新搜索引擎UI显示 - function updateSearchEngineUI() { - // 移除所有选项的激活状态 - searchEngineOptions.forEach((option) => { - option.classList.remove('active'); - - // 如果是当前选中的搜索引擎,添加激活状态 - if (option.getAttribute('data-engine') === currentSearchEngine) { - option.classList.add('active'); - } - }); - - // 更新搜索引擎按钮(方案B:前缀按钮显示当前引擎) - const engine = searchEngines[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 executeSearch(searchTerm) { - if (!searchTerm.trim()) return; - - // 根据当前搜索引擎执行搜索 - if (currentSearchEngine === 'local') { - // 执行本地搜索 - performSearch(searchTerm); - } else { - // 使用外部搜索引擎 - const engine = searchEngines[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(); - } - }); - - // 初始化 - window.addEventListener('load', () => { - // 获取可能在HTML生成后才存在的DOM元素 - const siteCards = document.querySelectorAll('.site-card'); - const categories = document.querySelectorAll('.category'); - const navItems = document.querySelectorAll('.nav-item'); - const navItemWrappers = document.querySelectorAll('.nav-item-wrapper'); - const submenuItems = document.querySelectorAll('.submenu-item'); - pages = document.querySelectorAll('.page'); - - // 方案 A:用 ?page= 作为页面深链接(兼容 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; - }; - - // 初始化主题 - initTheme(); - - // 初始化侧边栏状态 - initSidebarState(); - - // 初始化搜索引擎选择 - 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= 直接打开对应页面;无效时回退到首页 - const rawPageIdFromUrl = getRawPageIdFromUrl(); - const validatedPageIdFromUrl = getPageIdFromUrl(); - const initialPageId = validatedPageIdFromUrl || (isValidPageId(homePageId) ? homePageId : 'home'); - - setActiveNavByPageId(initialPageId); - showPage(initialPageId); - - // 当输入了不存在的 page id 时,自动纠正 URL,避免“内容回退但地址栏仍错误” - if (rawPageIdFromUrl && !validatedPageIdFromUrl) { - setUrlState({ pageId: initialPageId }, { replace: true }); - } - - // 初始深链接:支持 ?page=# - 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 = currentPageId; - showPage(pageId); - // 切换页面时同步 URL(清空旧 hash,避免跨页残留) - if (normalizeText(prevPageId) !== normalizeText(pageId)) { - setUrlState({ pageId, hash: '' }, { replace: true }); - } - - // 在移动端视图下点击导航项后自动收起侧边栏 - if (isMobile() && isSidebarOpen && !hasSubmenu) { - 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 (isMobile() && isSidebarOpen) { - closeAllPanels(); - } - } - }); - }); - - // 初始化嵌套分类功能 - 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(() => initSearchIndex()); - } else { - setTimeout(initSearchIndex, 1000); - } - }); -}); - -// Tooltip functionality for truncated text(仅桌面端/支持悬停设备启用) -document.addEventListener('DOMContentLoaded', () => { - const hoverMedia = window.matchMedia && window.matchMedia('(hover: hover) and (pointer: fine)'); - - if (!hoverMedia) { - return; - } - - let cleanupTooltip = null; - - function enableTooltip() { - if (cleanupTooltip) return; - - // Create tooltip element - const tooltip = document.createElement('div'); - tooltip.className = 'custom-tooltip'; - document.body.appendChild(tooltip); - - let activeElement = null; - - function updateTooltipPosition() { - if (!activeElement) return; - - const rect = activeElement.getBoundingClientRect(); - const tooltipRect = tooltip.getBoundingClientRect(); - const gap = 10; // 卡片与Tooltip的间距 - - // 默认显示在卡片下方 - let top = rect.bottom + gap; - // 水平居中对齐 - let left = rect.left + (rect.width - tooltipRect.width) / 2; - - const winWidth = window.innerWidth; - const winHeight = window.innerHeight; - - // 垂直边界检查:如果下方空间不足,尝试显示在上方 - if (top + tooltipRect.height > winHeight - gap) { - top = rect.top - tooltipRect.height - gap; - } - - // 水平边界检查:防止溢出屏幕左右边界 - if (left < gap) { - left = gap; - } else if (left + tooltipRect.width > winWidth - gap) { - left = winWidth - tooltipRect.width - gap; - } - - tooltip.style.left = left + 'px'; - tooltip.style.top = top + 'px'; - } - - // Show tooltip on hover - function onMouseOver(e) { - const target = e.target.closest('[data-tooltip]'); - if (!target) return; - - const tooltipText = target.getAttribute('data-tooltip'); - if (!tooltipText) return; - - activeElement = target; - tooltip.textContent = tooltipText; - tooltip.classList.add('visible'); - // 先显示元素让浏览器计算尺寸,然后立即更新位置 - updateTooltipPosition(); - } - - // Hide tooltip on mouse out - function onMouseOut(e) { - const target = e.target.closest('[data-tooltip]'); - if (!target || target !== activeElement) return; - - // Check if we really left the element (not just went to a child) - if (target.contains(e.relatedTarget)) return; - - activeElement = null; - tooltip.classList.remove('visible'); - } - - document.addEventListener('mouseover', onMouseOver); - document.addEventListener('mouseout', onMouseOut); - - cleanupTooltip = () => { - document.removeEventListener('mouseover', onMouseOver); - document.removeEventListener('mouseout', onMouseOut); - - activeElement = null; - tooltip.classList.remove('visible'); - tooltip.remove(); - }; - } - - function disableTooltip() { - if (!cleanupTooltip) return; - cleanupTooltip(); - cleanupTooltip = null; - } - - function syncTooltipEnabled() { - if (hoverMedia.matches) { - enableTooltip(); - } else { - disableTooltip(); - } - } - - syncTooltipEnabled(); - - // 兼容旧版 Safari:addListener/removeListener - if (hoverMedia.addEventListener) { - hoverMedia.addEventListener('change', syncTooltipEnabled); - } else if (hoverMedia.addListener) { - hoverMedia.addListener(syncTooltipEnabled); - } -});