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: ',
+ ].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
+ }
+ }
+ });
+});