diff --git a/.gitignore b/.gitignore index 3c1252d..887c75f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,4 @@ tests/fixtures/ .specstory/.what-is-this.md AGENTS.md /.claude +/discord-style-navstation diff --git a/assets/style.css b/assets/style.css index b585354..a04c31d 100644 --- a/assets/style.css +++ b/assets/style.css @@ -3324,3 +3324,197 @@ body .content.expanded { display: none; } } + +/* ------------------------------------------------------------------ + Markdown Content Styling (GitHub-like) - Scoped to .content-page + ------------------------------------------------------------------ */ + +.content-page { + font-family: var( + --font-body, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + 'Noto Sans', + Helvetica, + Arial, + sans-serif, + 'Apple Color Emoji', + 'Segoe UI Emoji' + ); + font-size: 16px; + line-height: 1.6; + color: var(--text-color); + word-wrap: break-word; + padding-bottom: 2rem; +} + +.content-page h1, +.content-page h2, +.content-page h3, +.content-page h4, +.content-page h5, +.content-page h6 { + margin-top: 24px; + margin-bottom: 16px; + font-weight: 600; + line-height: 1.25; + color: var(--text-bright); +} + +.content-page h1 { + font-size: 2em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border-color); +} + +.content-page h2 { + font-size: 1.5em; + padding-bottom: 0.3em; + border-bottom: 1px solid var(--border-color); +} + +.content-page h3 { + font-size: 1.25em; +} +.content-page h4 { + font-size: 1em; +} +.content-page h5 { + font-size: 0.875em; +} +.content-page h6 { + font-size: 0.85em; + color: var(--text-muted); +} + +.content-page p { + margin-top: 0; + margin-bottom: 16px; +} + +.content-page blockquote { + margin: 0 0 16px; + padding: 0 1em; + color: var(--text-muted); + border-left: 0.25em solid var(--border-color); + background-color: transparent; +} + +.content-page ul, +.content-page ol { + margin-top: 0; + margin-bottom: 16px; + padding-left: 2em; +} + +.content-page li + li { + margin-top: 0.25em; +} + +/* Inline code */ +.content-page code { + font-family: + ui-monospace, + SFMono-Regular, + SF Mono, + Menlo, + Consolas, + Liberation Mono, + monospace; + font-size: 85%; + padding: 0.2em 0.4em; + margin: 0; + border-radius: var(--radius-sm); + background-color: rgba(127, 127, 127, 0.15); + color: var(--text-bright); +} + +/* Block code (pre) */ +.content-page pre { + padding: 16px; + overflow: auto; + font-size: 85%; + line-height: 1.45; + background-color: rgba(var(--card-bg-rgb), 0.5); + border-radius: var(--radius-md); + margin-top: 0; + margin-bottom: 16px; + border: 1px solid var(--border-color); +} + +.content-page pre code { + background-color: transparent; + padding: 0; + font-size: 100%; + color: inherit; + word-break: normal; + border-radius: 0; +} + +.content-page hr { + height: 0.25em; + padding: 0; + margin: 24px 0; + background-color: var(--border-color); + border: 0; +} + +/* Tables */ +.content-page table { + border-spacing: 0; + border-collapse: collapse; + margin-top: 0; + margin-bottom: 16px; + display: block; + width: max-content; + max-width: 100%; + overflow: auto; +} + +.content-page table th, +.content-page table td { + padding: 6px 13px; + border: 1px solid var(--border-color); +} + +.content-page table th { + font-weight: 600; + background-color: rgba(var(--card-bg-rgb), 0.3); +} + +.content-page table tr { + background-color: transparent; + border-top: 1px solid var(--border-color); +} + +.content-page table tr:nth-child(2n) { + background-color: rgba(127, 127, 127, 0.04); +} + +.content-page img { + max-width: 100%; + box-sizing: content-box; + background-color: transparent; + border-radius: var(--radius-sm); +} + +.content-page a { + color: var(--accent-color); + text-decoration: none; +} + +.content-page a:hover { + text-decoration: underline; +} + +/* Task lists */ +.content-page ul.contains-task-list { + list-style-type: none; + padding-left: 0; +} + +.content-page .task-list-item input { + margin-right: 0.5em; + vertical-align: middle; +} diff --git a/config/README.md b/config/README.md index 8fefc0d..0c1c106 100644 --- a/config/README.md +++ b/config/README.md @@ -205,6 +205,40 @@ categories: - 若历史配置仍使用顶层 `sites`(旧结构),系统会自动映射为一个分类容器以保持页面结构一致(当前仅对 friends/articles 提供该兼容)。 +#### 内容页(template: content) + +内容页用于承载“关于 / 帮助 / 使用说明 / 更新日志 / 迁移指南 / 隐私说明”等纯文本内容。 + +配置要点: + +- `template: content` +- `content.file`:指向本地 Markdown 文件路径(推荐放在 `content/` 下) +- Markdown 会在**构建期**渲染为 HTML(不是运行时 fetch) +- 当前约束: + - 禁止 raw HTML(避免 XSS) + - 禁止图片(`![]()` 不会输出 ``;本期不支持图片/附件) + - 链接会按 URL scheme 白名单策略处理: + - 默认允许:`http/https/mailto/tel` + 所有相对链接(`#`、`/`、`./`、`../`、`?` 开头) + - 其他 scheme 会被安全降级为 `#`(可用 `site.yml -> security.allowedSchemes` 显式放行) + +示例(以 about 页面为例): + +```yml +# config/user/pages/about.yml +title: 关于 +subtitle: 项目说明 +template: content + +content: + file: content/about.md +``` + +对应内容文件: + +```text +content/about.md +``` + ### 多层级嵌套配置(2-4层) 书签与分类支持 2~4 层嵌套,用于更好组织大量站点。建议直接参考默认示例: diff --git a/config/_default/pages/content.yml b/config/_default/pages/content.yml new file mode 100644 index 0000000..f395f2e --- /dev/null +++ b/config/_default/pages/content.yml @@ -0,0 +1,12 @@ +# 默认页面配置(请勿直接修改)。 +# 建议复制到 config/user/pages/content.yml 并按需调整。 +title: 关于 +subtitle: 项目说明 + +# 指定使用的模板文件名,现有页面模板可见 templates/pages(不含 .hbs) +# 内容页模板(templates/pages/content.hbs) +template: content + +# 本期仅支持文件模式:读取本地 markdown 文件 +content: + file: content/about.md diff --git a/config/_default/site.yml b/config/_default/site.yml index e62e3fc..7bd6564 100644 --- a/config/_default/site.yml +++ b/config/_default/site.yml @@ -121,3 +121,6 @@ navigation: - name: 书签 icon: fas fa-bookmark id: bookmarks + - name: 关于 + icon: fas fa-file-alt + id: content diff --git a/content/about.md b/content/about.md new file mode 100644 index 0000000..738d56a --- /dev/null +++ b/content/about.md @@ -0,0 +1,65 @@ +# 关于 MeNav + +MeNav 是一个用于生成**个人导航站**的项目: + +- **构建期**:使用 Node.js 作为静态站点生成器(SSG),把配置与内容渲染为 `dist/`。 +- **运行时**:输出一份轻量的浏览器 runtime,用于页面交互与增强。 + +这页用于放置项目的说明与使用要点(你也可以改成自己的“关于”页面)。 + +## 适合谁 + +- 想要一个**可自托管、可版本管理**的导航页/起始页 +- 希望用 **YAML + Markdown** 管理站点结构与内容 +- 更偏好“生成静态文件再部署”,而不是运行时依赖服务端 + +## 快速开始 + +```bash +npm install +npm run dev +``` + +- `npm run dev`:本地开发(生成站点并启动本地服务) +- `npm run build`:生成生产构建(输出到 `dist/`) + +## 项目结构(常用) + +- `src/generator/`:构建期生成器(Node.js) +- `src/runtime/`:浏览器 runtime(最终会被打包到 `dist/script.js`) +- `templates/`:Handlebars 模板 +- `config/`:站点配置(YAML) +- `content/`:内容页(Markdown),例如本文件 +- `dist/`:构建输出(可直接部署) +- `dev/`:网络缓存(gitignored) + +## 配置说明(概念) + +- MeNav 的站点配置以 `config/` 下的 YAML 为主。 +- **注意**:如果存在 `config/user/`,它会**完全替换** `config/_default/`(不是 merge)。 + +## 内容页(Markdown)说明 + +- 内容页的 Markdown 会在**构建期**渲染为 HTML。 +- 内容页通常用于:关于、帮助、使用说明、更新记录等。 + +## 安全与链接处理 + +MeNav 对链接会做安全处理(例如限制危险的 URL scheme),以降低把不安全链接渲染到页面上的风险。 + +如果你在导航数据或内容页里粘贴了外部链接,建议优先使用 `https://`。 + +## 部署 + +`npm run build` 后将生成的 `dist/` 部署到任意静态站点托管即可(例如 Nginx、GitHub Pages、Cloudflare Pages 等)。 + +## 维护建议 + +- 把你的配置、内容页都纳入 git 版本管理 +- 变更后跑一遍: + +```bash +npm run check +``` + +(会依次执行语法检查、测试与构建) diff --git a/package-lock.json b/package-lock.json index 46c345d..6a4e4f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "handlebars": "^4.7.8", "js-yaml": "^4.1.1", + "markdown-it": "^14.1.0", "rss-parser": "^3.13.0" }, "devDependencies": { @@ -1302,6 +1303,15 @@ "dev": true, "license": "MIT" }, + "node_modules/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "license": "MIT", + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/lint-staged": { "version": "16.2.7", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", @@ -1451,6 +1461,41 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/markdown-it": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", + "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.4.0", + "linkify-it": "^5.0.0", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, + "node_modules/markdown-it/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "license": "MIT" + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -1678,6 +1723,15 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/range-parser": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.0.tgz", @@ -2055,6 +2109,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT" + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmmirror.com/uglify-js/-/uglify-js-3.19.3.tgz", diff --git a/package.json b/package.json index 1ffe1f1..d1fbde3 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "handlebars": "^4.7.8", "js-yaml": "^4.1.1", + "markdown-it": "^14.1.0", "rss-parser": "^3.13.0" }, "devDependencies": { diff --git a/src/generator/html/markdown.js b/src/generator/html/markdown.js new file mode 100644 index 0000000..4b7f8cc --- /dev/null +++ b/src/generator/html/markdown.js @@ -0,0 +1,108 @@ +const MarkdownIt = require('markdown-it'); + +/** + * 构建期 Markdown 渲染器(用于内容页 template: content) + * + * 约束: + * - 禁止 raw HTML(避免 XSS) + * - 禁止图片(本期不支持图片/附件) + * - 链接 href 必须通过安全策略校验(沿用 safeUrl 的 scheme 白名单思想) + */ + +function normalizeAllowedSchemes(allowedSchemes) { + if (!Array.isArray(allowedSchemes) || allowedSchemes.length === 0) { + return ['http', 'https', 'mailto', 'tel']; + } + return allowedSchemes + .map((s) => + String(s || '') + .trim() + .toLowerCase() + .replace(/:$/, '') + ) + .filter(Boolean); +} + +function isRelativeUrl(url) { + const s = String(url || '').trim(); + return ( + s.startsWith('#') || + s.startsWith('/') || + s.startsWith('./') || + s.startsWith('../') || + s.startsWith('?') + ); +} + +/** + * 将 href 按 MeNav 安全策略清洗为可点击链接 + * @param {string} href 原始 href + * @param {string[]} allowedSchemes 允许的 scheme 列表(不含冒号) + * @returns {string} 安全 href(不安全时返回 '#' + */ +function sanitizeLinkHref(href, allowedSchemes) { + const raw = String(href || '').trim(); + if (!raw) return '#'; + if (isRelativeUrl(raw)) return raw; + + // 明确拒绝协议相对 URL(//example.com),避免绕过策略 + if (raw.startsWith('//')) return '#'; + + try { + const parsed = new URL(raw); + const scheme = String(parsed.protocol || '') + .toLowerCase() + .replace(/:$/, ''); + return allowedSchemes.includes(scheme) ? raw : '#'; + } catch { + return '#'; + } +} + +function createMarkdownIt({ allowedSchemes }) { + const md = new MarkdownIt({ + html: false, + linkify: true, + typographer: true, + }); + + // markdown-it 默认会拒绝 javascript: 等链接,并导致其不被渲染为 + // 我们这里统一“允许渲染,但在 renderer 层做 href 安全降级”。 + md.validateLink = () => true; + + // 本期明确不支持图片 + md.disable('image'); + + const normalizedSchemes = normalizeAllowedSchemes(allowedSchemes); + const defaultRender = md.renderer.rules.link_open; + + md.renderer.rules.link_open = function (tokens, idx, options, env, self) { + const token = tokens[idx]; + const hrefIndex = token.attrIndex('href'); + if (hrefIndex >= 0) { + const originalHref = token.attrs[hrefIndex][1]; + token.attrs[hrefIndex][1] = sanitizeLinkHref(originalHref, normalizedSchemes); + } + + return defaultRender + ? defaultRender(tokens, idx, options, env, self) + : self.renderToken(tokens, idx, options); + }; + + return md; +} + +/** + * @param {string} markdownText markdown 原文 + * @param {{allowedSchemes?: string[]}} opts + * @returns {string} HTML(不包含外层 layout) + */ +function renderMarkdownToHtml(markdownText, opts = {}) { + const md = createMarkdownIt({ allowedSchemes: opts.allowedSchemes }); + return md.render(String(markdownText || '')); +} + +module.exports = { + sanitizeLinkHref, + renderMarkdownToHtml, +}; diff --git a/src/generator/html/page-data.js b/src/generator/html/page-data.js index 035c778..df07c6f 100644 --- a/src/generator/html/page-data.js +++ b/src/generator/html/page-data.js @@ -12,6 +12,8 @@ const { } = require('../cache/projects'); const { getPageConfigUpdatedAtMeta } = require('../utils/pageMeta'); const { createLogger, isVerbose } = require('../utils/logger'); +const { ConfigError } = require('../utils/errors'); +const { renderMarkdownToHtml } = require('./markdown'); const log = createLogger('render'); @@ -83,6 +85,48 @@ function applyBookmarksData(data, pageId) { } } +function applyContentData(data, pageId, config) { + const content = data && data.content && typeof data.content === 'object' ? data.content : null; + const file = content && content.file ? String(content.file).trim() : ''; + if (!file) { + throw new ConfigError(`内容页缺少 content.file:${pageId}`, [ + `请在 config/*/pages/${pageId}.yml 中配置:`, + 'template: content', + 'content:', + ' file: path/to/file.md', + ]); + } + + // 本期仅支持读取本地 markdown 文件 + const fs = require('node:fs'); + const path = require('node:path'); + + // 允许用户以相对 repo root 的路径引用(推荐约定:content/*.md) + // 同时允许绝对路径(便于本地调试/临时文件),但不会自动复制资源到 dist。 + const normalized = file.replace(/\\/g, '/'); + const absPath = path.isAbsolute(normalized) + ? path.normalize(normalized) + : path.join(process.cwd(), normalized.replace(/^\//, '')); + if (!fs.existsSync(absPath)) { + throw new ConfigError(`内容页 markdown 文件不存在:${pageId}`, [ + `检查路径是否正确:${file}`, + '提示:路径相对于仓库根目录(process.cwd())解析', + ]); + } + + const markdownText = fs.readFileSync(absPath, 'utf8'); + const allowedSchemes = + config && + config.site && + config.site.security && + Array.isArray(config.site.security.allowedSchemes) + ? config.site.security.allowedSchemes + : null; + + data.contentFile = normalized; + data.contentHtml = renderMarkdownToHtml(markdownText, { allowedSchemes }); +} + function convertTopLevelSitesToCategory(data, pageId, templateName) { const isFriendsPage = pageId === 'friends' || templateName === 'friends'; const isArticlesPage = pageId === 'articles' || templateName === 'articles'; @@ -158,6 +202,10 @@ function preparePageData(pageId, config) { applyBookmarksData(data, pageId); } + if (templateName === 'content') { + applyContentData(data, pageId, config); + } + applyHomePageTitles(data, pageId, config); if (Array.isArray(data.categories) && data.categories.length > 0) { diff --git a/templates/README.md b/templates/README.md index 1f376d7..8d1bb0b 100644 --- a/templates/README.md +++ b/templates/README.md @@ -31,6 +31,7 @@ templates/ │ └── default.hbs # 默认布局 ├── pages/ # 页面模板 - 对应不同页面内容 │ ├── page.hbs # 通用页面模板(默认/回退模板;普通页面常用) +│ ├── content.hbs # 内容页(Markdown 内容页:构建期渲染) │ ├── projects.hbs # 项目页(repo 风格卡片) │ ├── articles.hbs # 文章页(RSS 聚合/只读文章条目) │ ├── bookmarks.hbs # 书签页 @@ -95,6 +96,7 @@ templates/ **主要页面**: - `page.hbs` - 通用页面模板(默认/回退模板;普通页面常用) +- `content.hbs` - 内容页(从本地 Markdown 文件构建期渲染为 HTML) - `bookmarks.hbs` - 书签页 - `projects.hbs` - 项目页 - `articles.hbs` - 文章页 @@ -104,6 +106,21 @@ templates/ > 说明:MeNav 不再依赖 `home.hbs` 作为首页模板。 > “首页/默认打开页”由 `site.yml -> navigation` 的**第一项**决定;首页可使用任意页面模板,具体取决于该页面配置(`pages/.yml` 的 `template` 字段与回退规则)。 +#### 内容页(template: content) + +内容页用于承载“关于 / 帮助 / 使用说明 / 更新日志 / 迁移指南”等纯文本内容。 + +- 内容来源:页面配置中的 `content.file` 指向本地 `.md` 文件(例如 `content/about.md`) +- 渲染时机:**构建期** Markdown -> HTML(不是运行时 fetch) +- 安全约束: + - 禁止 raw HTML + - 禁止图片(`![]()` 不会被渲染) + - 链接会按 `site.yml -> security.allowedSchemes` 白名单策略处理,不安全链接会被降级为 `#` + +对应模板文件:`templates/pages/content.hbs` + +> 配置写法示例见:`config/README.md` 的“内容页(template: content)”。 + **示例** (`page.hbs`): ```handlebars diff --git a/templates/pages/content.hbs b/templates/pages/content.hbs new file mode 100644 index 0000000..ff4d244 --- /dev/null +++ b/templates/pages/content.hbs @@ -0,0 +1,8 @@ +{{!-- content.hbs - 内容页(Markdown -> HTML,构建期渲染) --}} +
+ {{> page-header}} + +
+ {{{contentHtml}}} +
+
diff --git a/test/content-page-markdown.node-test.js b/test/content-page-markdown.node-test.js new file mode 100644 index 0000000..849d850 --- /dev/null +++ b/test/content-page-markdown.node-test.js @@ -0,0 +1,91 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { loadHandlebarsTemplates, generateAllPagesHTML } = require('../src/generator.js'); + +function withRepoRoot(fn) { + const originalCwd = process.cwd(); + process.chdir(path.join(__dirname, '..')); + try { + return fn(); + } finally { + process.chdir(originalCwd); + } +} + +test('content:构建期渲染 markdown 文件,并对链接做 scheme 安全降级', () => { + withRepoRoot(() => { + loadHandlebarsTemplates(); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'menav-content-page-')); + const mdPath = path.join(tmpDir, 'about.md'); + fs.writeFileSync( + mdPath, + [ + '# About', + '', + 'A normal link: [ok](https://example.com)', + '', + 'A bad link: [bad](javascript:alert(1))', + '', + 'Protocol-relative should be blocked: [pr](//example.com)', + '', + 'Image should be disabled: ![x](https://example.com/x.png)', + ].join('\n'), + 'utf8' + ); + + try { + const config = { + site: { + title: 'Test Site', + description: '', + author: '', + favicon: '', + logo_text: 'Test', + security: { allowedSchemes: ['http', 'https', 'mailto', 'tel'] }, + }, + profile: { title: 'PROFILE_TITLE', subtitle: 'PROFILE_SUBTITLE' }, + social: [], + navigation: [{ id: 'about', name: '关于', icon: 'fas fa-info' }], + about: { + title: '关于', + subtitle: '说明', + template: 'content', + content: { + file: mdPath, + }, + }, + }; + + const pages = generateAllPagesHTML(config); + const html = pages.about; + + assert.ok(typeof html === 'string' && html.length > 0); + assert.ok(html.includes('page-template-about')); + assert.ok(html.includes('page-template-content')); + assert.ok(html.includes('

About

')); + assert.ok(html.includes('A normal link')); + assert.ok(html.includes('href="https://example.com"')); + + // javascript: should be blocked + assert.ok(html.includes('A bad link')); + assert.ok(/href=['"]#['"]/.test(html), '不安全链接应降级为 href="#"'); + + // protocol-relative should be blocked + assert.ok(html.includes('Protocol-relative should be blocked')); + + // image should be disabled + assert.ok(!html.includes(''); + } finally { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // ignore + } + } + }); +});