feat(templates):新增 Markdown 内容页支持
新增 template: content:构建期使用 markdown-it 将本地Markdown 渲染为 HTML(禁用 raw HTML/图片),并按MeNav的URLscheme白名单策略对链接做安全降级
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -40,3 +40,4 @@ tests/fixtures/
|
||||
.specstory/.what-is-this.md
|
||||
AGENTS.md
|
||||
/.claude
|
||||
/discord-style-navstation
|
||||
|
||||
194
assets/style.css
194
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;
|
||||
}
|
||||
|
||||
@@ -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 层嵌套,用于更好组织大量站点。建议直接参考默认示例:
|
||||
|
||||
12
config/_default/pages/content.yml
Normal file
12
config/_default/pages/content.yml
Normal 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
|
||||
@@ -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
65
content/about.md
Normal 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
60
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
108
src/generator/html/markdown.js
Normal file
108
src/generator/html/markdown.js
Normal 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,
|
||||
};
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
8
templates/pages/content.hbs
Normal file
8
templates/pages/content.hbs
Normal 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>
|
||||
91
test/content-page-markdown.node-test.js
Normal file
91
test/content-page-markdown.node-test.js
Normal 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: ',
|
||||
].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
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user