diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..1014ba7 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes index dfe0770..f423252 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7d34698 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 664bd84..e60695d 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..132f5de --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "printWidth": 100, + "singleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "useTabs": false +} diff --git a/README.md b/README.md index 641a3a5..82d687e 100644 --- a/README.md +++ b/README.md @@ -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 +``` + ## 部署方式 @@ -413,7 +430,8 @@ MeNav使用模块化配置方式,将配置分散到多个文件中,更易于 * **创建配置目录**: - 在`config/user/`目录下创建您的自定义配置文件 - 可以参考项目结构中的`config/_default/`目录结构 - - 至少需要创建`site.yml` + - 至少需要创建`site.yml`(缺失时构建会直接报错退出,避免生成空白站点) + - 首次使用建议先完整复制 `config/_default/` 到 `config/user/`,再按需修改(因为配置采用“完全替换”策略,不会从默认配置补齐缺失项) ### 配置详解 @@ -745,6 +763,8 @@ MeNav支持从浏览器导入书签,快速批量添加网站链接,无需手 +> 💡 *版本管理建议*:若你希望每次导入书签生成的 `bookmarks.yml` 内容保持确定性(避免因时间戳导致的无意义 diff),可在运行导入时设置环境变量:`MENAV_BOOKMARKS_DETERMINISTIC=1`。 + > 生成的配置可在`config/user/pages/bookmarks.yml`中查看和编辑 ## 常见问题 diff --git a/package-lock.json b/package-lock.json index 3f91664..f286b5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 0e5c5ad..d83bff6 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/bookmark-processor.js b/src/bookmark-processor.js index 178d3c6..37c4039 100644 --- a/src/bookmark-processor.js +++ b/src/bookmark-processor.js @@ -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, +}; diff --git a/src/generator.js b/src/generator.js index a5603e7..59e021b 100644 --- a/src/generator.js +++ b/src/generator.js @@ -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) { diff --git a/test/bookmark-processor.node-test.js b/test/bookmark-processor.node-test.js new file mode 100644 index 0000000..c238fd1 --- /dev/null +++ b/test/bookmark-processor.node-test.js @@ -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 = ` + + +
+
+
+
+
+
+`; + + 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); + } +}); +