me / src /generator /html /fonts.js
cheymin's picture
Upload 136 files
e1ae2c6 verified
const { escapeHtml } = require('../utils/html');
/**
* 将 CSS 文本安全嵌入到 <style> 中,避免出现 `</style>` 结束标签导致样式块被提前终止。
* @param {string} cssText CSS 文本
* @returns {string} 安全的 CSS 文本
*/
function makeCssSafeForHtmlStyleTag(cssText) {
if (typeof cssText !== 'string') {
return '';
}
return cssText.replace(/<\/style/gi, '<\\/style');
}
function normalizeFontWeight(input) {
if (input === undefined || input === null) return 'normal';
if (typeof input === 'number' && Number.isFinite(input)) {
return String(input);
}
const raw = String(input).trim();
if (!raw) return 'normal';
if (/^(normal|bold|bolder|lighter)$/i.test(raw)) return raw.toLowerCase();
if (/^[1-9]00$/.test(raw)) return raw;
return raw;
}
function normalizeFontFamilyForCss(input) {
const raw = String(input || '').trim();
if (!raw) return '';
const generics = new Set([
'serif',
'sans-serif',
'monospace',
'cursive',
'fantasy',
'system-ui',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'emoji',
'math',
'fangsong',
]);
return raw
.split(',')
.map((part) => part.trim())
.filter(Boolean)
.map((part) => {
const unquoted = part.replace(/^['"]|['"]$/g, '').trim();
if (!unquoted) return '';
if (generics.has(unquoted)) return unquoted;
const needsQuotes = /\s/.test(unquoted);
if (!needsQuotes) return unquoted;
return `"${unquoted.replace(/"/g, '\\"')}"`;
})
.filter(Boolean)
.join(', ');
}
function normalizeFontSource(input) {
const raw = String(input || '')
.trim()
.toLowerCase();
if (raw === 'css' || raw === 'google' || raw === 'system') return raw;
return 'system';
}
function getNormalizedFontsConfig(config) {
const fonts = config && config.fonts && typeof config.fonts === 'object' ? config.fonts : {};
return {
source: normalizeFontSource(fonts.source),
family: normalizeFontFamilyForCss(fonts.family),
weight: normalizeFontWeight(fonts.weight),
cssUrl: String(fonts.cssUrl || fonts.href || '').trim(),
preload: Boolean(fonts.preload),
};
}
function tryGetUrlOrigin(input) {
const raw = String(input || '').trim();
if (!raw) return '';
try {
return new URL(raw).origin;
} catch {
return '';
}
}
function buildStylesheetLinkTag(href, preload) {
const safeHref = escapeHtml(href);
if (!preload) return `<link rel="stylesheet" href="${safeHref}">`;
return [
`<link rel="preload" href="${safeHref}" as="style" onload="this.onload=null;this.rel='stylesheet'">`,
`<noscript><link rel="stylesheet" href="${safeHref}"></noscript>`,
].join('\n');
}
// 生成字体相关 <link>
function generateFontLinks(config) {
const fonts = getNormalizedFontsConfig(config);
const links = [];
// 全站基础字体:按配置加载
if (fonts.source === 'css' && fonts.cssUrl) {
const origin = tryGetUrlOrigin(fonts.cssUrl);
if (origin) {
links.push(`<link rel="preconnect" href="${escapeHtml(origin)}" crossorigin>`);
}
links.push(buildStylesheetLinkTag(fonts.cssUrl, fonts.preload));
}
if (fonts.source === 'google' && fonts.family) {
links.push('<link rel="preconnect" href="https://fonts.googleapis.com">');
links.push('<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>');
const familyNoQuotes = fonts.family.replace(/[\"']/g, '').split(',')[0].trim();
const weight = /^[1-9]00$/.test(fonts.weight) ? fonts.weight : '400';
const familyParam = encodeURIComponent(familyNoQuotes).replace(/%20/g, '+');
links.push(
buildStylesheetLinkTag(
`https://fonts.googleapis.com/css2?family=${familyParam}:wght@${weight}&display=swap`,
fonts.preload
)
);
}
return links.join('\n');
}
// 生成字体 CSS 变量(单一字体配置)
function generateFontCss(config) {
const fonts = getNormalizedFontsConfig(config);
const family = fonts.family || 'system-ui, sans-serif';
const weight = fonts.weight || 'normal';
const css = `:root {\n --font-body: ${family};\n --font-weight-body: ${weight};\n}\n`;
return makeCssSafeForHtmlStyleTag(css);
}
module.exports = {
generateFontLinks,
generateFontCss,
};