chore: 安全升级并完善 CI/测试

- 升级 js-yaml 修复生产依赖漏洞
- 新增 CI:lint/test/build
- 增加书签处理单测与可测性导出"- 生成器补充 config/user 缺失提示
- 增加 lint/format/check 脚本与 Prettier 配置
- 统一行尾策略并支持书签确定性输出"
This commit is contained in:
rbetree
2025-12-22 00:44:51 +08:00
parent 7a7bf361e3
commit 47e4369b09
11 changed files with 286 additions and 18 deletions

12
.editorconfig Normal file
View File

@@ -0,0 +1,12 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false

15
.gitattributes vendored
View File

@@ -1,2 +1,15 @@
# Auto detect text files and perform LF normalization
* text=auto
*.js text eol=lf
*.json text eol=lf
*.md text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.css text eol=lf
*.html text eol=lf
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary

31
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
name: CI
on:
push:
branches: [ main ]
pull_request:
jobs:
build_test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '18'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Unit tests
run: npm test
- name: Build
run: npm run build

View File

@@ -50,6 +50,8 @@ jobs:
- name: Process bookmark files
if: steps.check_bookmark_files.outputs.found == 'true'
env:
MENAV_BOOKMARKS_DETERMINISTIC: '1'
run: |
echo "Processing bookmark files..."
node src/bookmark-processor.js
@@ -98,7 +100,16 @@ jobs:
git commit -m "Add bookmarks configuration from imported bookmarks"
fi
# Also check for navigation file changes
# Also check for site.yml changes导航已合并到 site.yml
if [ -f config/user/site.yml ]; then
if ! git diff --quiet config/user/site.yml; then
echo "config/user/site.yml has changes, committing..."
git add config/user/site.yml
git commit -m "Update site configuration for bookmarks"
fi
fi
# Also check for legacy navigation file changes
if [ -f config/user/navigation.yml ]; then
if ! git diff --quiet config/user/navigation.yml; then
echo "config/user/navigation.yml has changes, committing..."
@@ -194,4 +205,4 @@ jobs:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v4

7
.prettierrc.json Normal file
View File

@@ -0,0 +1,7 @@
{
"printWidth": 100,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"useTabs": false
}

View File

@@ -265,6 +265,10 @@ npm install
```bash
npm run import-bookmarks
```
- 若希望生成结果保持确定性(便于版本管理,减少时间戳导致的无意义 diff
```bash
MENAV_BOOKMARKS_DETERMINISTIC=1 npm run import-bookmarks
```
- 系统会自动将书签转换为配置文件保存到`config/user/pages/bookmarks.yml`
- **注意**`npm run dev`命令不会自动处理书签文件,必须先手动运行上述命令
@@ -281,6 +285,19 @@ npm run build
构建后的文件位于`dist`目录
6. 提交前检查(推荐)
```bash
# 一键检查(语法检查 + 单元测试 + 构建)
npm run check
```
(可选)格式化代码:
```bash
npm run format
```
</details>
## 部署方式
@@ -413,7 +430,8 @@ MeNav使用模块化配置方式将配置分散到多个文件中更易于
* **创建配置目录**:
- 在`config/user/`目录下创建您的自定义配置文件
- 可以参考项目结构中的`config/_default/`目录结构
- 至少需要创建`site.yml`
- 至少需要创建`site.yml`(缺失时构建会直接报错退出,避免生成空白站点)
- 首次使用建议先完整复制 `config/_default/` 到 `config/user/`,再按需修改(因为配置采用“完全替换”策略,不会从默认配置补齐缺失项)
### 配置详解
@@ -745,6 +763,8 @@ MeNav支持从浏览器导入书签快速批量添加网站链接无需手
</details>
> 💡 *版本管理建议*:若你希望每次导入书签生成的 `bookmarks.yml` 内容保持确定性(避免因时间戳导致的无意义 diff可在运行导入时设置环境变量`MENAV_BOOKMARKS_DETERMINISTIC=1`。
> 生成的配置可在`config/user/pages/bookmarks.yml`中查看和编辑
## 常见问题

31
package-lock.json generated
View File

@@ -1,13 +1,13 @@
{
"name": "menav",
"version": "1.2.3",
"version": "1.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "menav",
"version": "1.2.3",
"license": "MIT",
"version": "1.3.0",
"license": "AGPL-3.0-only",
"dependencies": {
"ansi-regex": "^6.0.1",
"ansi-styles": "^6.2.1",
@@ -15,11 +15,12 @@
"color-name": "^2.0.0",
"handlebars": "^4.7.8",
"has-flag": "^5.0.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"mime-db": "^1.52.0",
"supports-color": "^9.4.0"
},
"devDependencies": {
"prettier": "^3.4.2",
"serve": "^14.2.1"
}
},
@@ -646,9 +647,9 @@
"license": "ISC"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -821,6 +822,22 @@
"dev": true,
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@@ -9,7 +9,12 @@
"dev": "node src/generator.js && serve dist -l 5173",
"clean": "node ./scripts/clean.js",
"build": "npm run clean && npm run generate",
"import-bookmarks": "node src/bookmark-processor.js"
"import-bookmarks": "node src/bookmark-processor.js",
"test": "node --test test/*.js",
"lint": "node --check \"src/generator.js\" && node --check \"src/bookmark-processor.js\" && node --check \"src/script.js\"",
"format": "prettier --write \"src/**/*.js\" \"scripts/**/*.js\" \"test/**/*.js\" \".github/**/*.yml\" \"*.{md,json}\" \"config/**/*.md\" \"config/**/*.yml\"",
"format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"test/**/*.js\" \".github/**/*.yml\" \"*.{md,json}\" \"config/**/*.md\" \"config/**/*.yml\"",
"check": "npm run lint && npm test && npm run build"
},
"keywords": [
"navigation",
@@ -19,7 +24,7 @@
"author": "Your Name",
"license": "AGPL-3.0-only",
"dependencies": {
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"handlebars": "^4.7.8",
"ansi-regex": "^6.0.1",
"ansi-styles": "^6.2.1",
@@ -30,6 +35,7 @@
"mime-db": "^1.52.0"
},
"devDependencies": {
"prettier": "^3.4.2",
"serve": "^14.2.1"
}
}

View File

@@ -714,11 +714,15 @@ function generateBookmarksYaml(bookmarks) {
quotingType: '"'
});
// 添加注释
const yamlWithComment =
// 添加注释(可选确定性输出,方便版本管理)
const deterministic = process.env.MENAV_BOOKMARKS_DETERMINISTIC === '1';
const timestampLine = deterministic
? ''
: `# 由bookmark-processor.js生成于 ${new Date().toISOString()}\n`;
const yamlWithComment =
`# 自动生成的书签配置文件
# 由bookmark-processor.js生成于 ${new Date().toISOString()}
# 若要更新请将新的书签HTML文件放入bookmarks/目录
${timestampLine}# 若要更新请将新的书签HTML文件放入bookmarks/目录
# 此文件使用模块化配置格式位于config/user/pages/目录下
${yamlString}`;
@@ -914,4 +918,13 @@ if (require.main === module) {
console.error('Unhandled error in bookmark processing:', err);
process.exit(1);
});
}
}
module.exports = {
ensureUserConfigInitialized,
ensureUserSiteYmlExists,
upsertBookmarksNavInSiteYml,
parseBookmarks,
generateBookmarksYaml,
updateNavigationWithBookmarks,
};

View File

@@ -519,6 +519,19 @@ function loadConfig() {
// 根据优先级顺序选择最高优先级的配置
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) {

View File

@@ -0,0 +1,125 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const {
ensureUserConfigInitialized,
ensureUserSiteYmlExists,
upsertBookmarksNavInSiteYml,
parseBookmarks,
generateBookmarksYaml,
} = require('../src/bookmark-processor.js');
function stripYamlComments(yamlText) {
return yamlText
.split(/\r?\n/)
.filter(line => !/^\s*#/.test(line))
.join('\n')
.trim();
}
test('parseBookmarks解析书签栏、根目录书签与图标映射', () => {
const html = `
<!DOCTYPE NETSCAPE-Bookmark-file-1>
<META HTTP-EQUIV="Content-Type" CONTENT="text/html; charset=UTF-8">
<TITLE>Bookmarks</TITLE>
<H1>Bookmarks</H1>
<DL><p>
<DT><H3 PERSONAL_TOOLBAR_FOLDER="true">书签栏</H3>
<DL><p>
<DT><A HREF="https://github.com/">GitHub</A>
<DT><H3>工具</H3>
<DL><p>
<DT><A HREF="https://www.google.com/">Google</A>
</DL><p>
</DL><p>
</DL><p>
`;
const bookmarks = parseBookmarks(html);
assert.ok(bookmarks);
assert.ok(Array.isArray(bookmarks.categories));
assert.ok(bookmarks.categories.length >= 2);
// 根目录书签应该插入到首位
assert.equal(bookmarks.categories[0].name, '根目录书签');
assert.ok(Array.isArray(bookmarks.categories[0].sites));
assert.equal(bookmarks.categories[0].sites[0].name, 'GitHub');
assert.equal(bookmarks.categories[0].sites[0].icon, 'fab fa-github');
const tools = bookmarks.categories.find(c => c.name === '工具');
assert.ok(tools, '应解析出“工具”分类');
assert.ok(Array.isArray(tools.sites));
assert.equal(tools.sites[0].name, 'Google');
});
test('generateBookmarksYaml生成 YAML 且可被解析', () => {
const bookmarks = {
categories: [
{
name: '示例分类',
icon: 'fas fa-folder',
sites: [{ name: 'Example', url: 'https://example.com', icon: 'fas fa-link', description: '' }],
},
],
};
const yamlText = generateBookmarksYaml(bookmarks);
assert.ok(typeof yamlText === 'string');
assert.ok(yamlText.includes('# 自动生成的书签配置文件'));
assert.ok(yamlText.includes('categories:'));
const yaml = require('js-yaml');
const parsed = yaml.load(stripYamlComments(yamlText));
assert.equal(parsed.title, '我的书签');
assert.ok(Array.isArray(parsed.categories));
assert.equal(parsed.categories[0].name, '示例分类');
});
test('upsertBookmarksNavInSiteYml无 navigation 时追加并幂等', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
const filePath = path.join(tmp, 'site.yml');
fs.writeFileSync(
filePath,
`title: Test Site\n`,
'utf8',
);
const r1 = upsertBookmarksNavInSiteYml(filePath);
assert.equal(r1.updated, true);
const updated1 = fs.readFileSync(filePath, 'utf8');
assert.ok(updated1.includes('navigation:'));
assert.ok(updated1.includes('- name: 书签'));
assert.ok(updated1.includes('id: bookmarks'));
const r2 = upsertBookmarksNavInSiteYml(filePath);
assert.equal(r2.updated, false);
assert.equal(r2.reason, 'already_present');
});
test('ensureUserConfigInitialized/ensureUserSiteYmlExists可在空目录初始化用户配置', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-test-'));
const originalCwd = process.cwd();
process.chdir(tmp);
try {
fs.mkdirSync('config/_default/pages', { recursive: true });
fs.writeFileSync('config/_default/site.yml', 'title: Default\n', 'utf8');
fs.writeFileSync('config/_default/pages/home.yml', 'categories: []\n', 'utf8');
const init = ensureUserConfigInitialized();
assert.equal(init.initialized, true);
assert.ok(fs.existsSync('config/user/site.yml'));
assert.ok(fs.existsSync('config/user/pages/home.yml'));
// 若 site.yml 已存在,应直接返回 true
assert.equal(ensureUserSiteYmlExists(), true);
} finally {
process.chdir(originalCwd);
}
});