feat(templates):新增 Markdown 内容页支持

新增 template: content:构建期使用 markdown-it 将本地Markdown 渲染为 HTML(禁用 raw HTML/图片),并按MeNav的URLscheme白名单策略对链接做安全降级
This commit is contained in:
rbetree
2026-01-20 17:43:06 +08:00
parent f773b9e290
commit 280d376bac
13 changed files with 642 additions and 0 deletions

1
.gitignore vendored
View File

@@ -40,3 +40,4 @@ tests/fixtures/
.specstory/.what-is-this.md
AGENTS.md
/.claude
/discord-style-navstation

View File

@@ -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;
}

View File

@@ -205,6 +205,40 @@ categories:
- 若历史配置仍使用顶层 `sites`(旧结构),系统会自动映射为一个分类容器以保持页面结构一致(当前仅对 friends/articles 提供该兼容)。
#### 内容页template: content
内容页用于承载“关于 / 帮助 / 使用说明 / 更新日志 / 迁移指南 / 隐私说明”等纯文本内容。
配置要点:
- `template: content`
- `content.file`:指向本地 Markdown 文件路径(推荐放在 `content/` 下)
- Markdown 会在**构建期**渲染为 HTML不是运行时 fetch
- 当前约束:
- 禁止 raw HTML避免 XSS
- 禁止图片(`![]()` 不会输出 `<img>`;本期不支持图片/附件)
- 链接会按 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 层嵌套,用于更好组织大量站点。建议直接参考默认示例:

View File

@@ -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

View File

@@ -121,3 +121,6 @@ navigation:
- name: 书签
icon: fas fa-bookmark
id: bookmarks
- name: 关于
icon: fas fa-file-alt
id: content

65
content/about.md Normal file
View File

@@ -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
```
(会依次执行语法检查、测试与构建)

60
package-lock.json generated
View File

@@ -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",

View File

@@ -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": {

View File

@@ -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: 等链接,并导致其不被渲染为 <a>
// 我们这里统一“允许渲染,但在 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,
};

View File

@@ -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) {

View File

@@ -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/<homePageId>.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

View File

@@ -0,0 +1,8 @@
{{!-- content.hbs - 内容页Markdown -> HTML构建期渲染 --}}
<div class="page-template page-template-{{pageId}} page-template-content">
{{> page-header}}
<article class="content-page" data-content-file="{{contentFile}}">
{{{contentHtml}}}
</article>
</div>

View File

@@ -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('<h1>About</h1>'));
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('<img'), '本期不支持图片markdown 渲染不应输出 <img>');
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
}
});
});