feat(templates):新增 Markdown 内容页支持
新增 template: content:构建期使用 markdown-it 将本地Markdown 渲染为 HTML(禁用 raw HTML/图片),并按MeNav的URLscheme白名单策略对链接做安全降级
This commit is contained in:
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) {
|
||||
|
||||
Reference in New Issue
Block a user