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

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