【T3 样式即数据】
- content/site/theme.yaml 九组白名单旋钮(accent 明暗两值 / radius_img /
text_h1-h3 / prose_size_reading|tech / leading_reading|tech /
space_3-6 / flow_p 与 flow_h2_post / dur);--measure、断点、纸墨、
code 配色按方案 §3.6 红线不开放
- src/lib/theme.ts:getTheme() 钳制 + 单调性校验(h1>h2>h3、
space3<4<5<6 任一不满足整组回落默认);themeVarsCss() 产出
`:root:root{…}` 与 `[data-theme="dark"]:root:root{…}`,靠特异性压过
tokens 的 :root,不依赖注入顺序
- BaseLayout head 内联 `<style is:inline set:html={themeStyle}>`:构建期
静态、运行时零 JS 零请求
- scripts/build-perf/contrast-check.mjs 读产物断言正文/强调 × 亮暗四项
≥4.5:1,不达标 exit 1;build.sh 守卫挂载([ -f ] 守卫先行)
实测:默认 12.40/10.53/6.30/7.06 全过;负例 accent #cccccc → 1.61:1 exit 1
两坑入档:tokens 的 --bg 是 3 位简写需归一化;选 tokens CSS 要用
`--bg:#` 定义断言(includes('--bg') 会误命中 var(--bg-soft) 引用)
- 后台 theme 单例 17 字段全中文 hint + 取值范围(分组约束写进 hint)
【T4 首页去重与细化】
- 新增 src/components/pages/HomePage.astro 承接全部区块(名片区富文本
tagline/subtitle/社交、公告、文章列表、最新影像、热力图、项目墙、
心跳),locale 一统;en 轨吃 postsEn 译题映射、图库链走 /en/gallery/,
心跳仍仅 zh(零回归)
- 两 index.astro 缩成 4 行薄壳,双份漂移隐患消除
- home.yaml 三字段:projectsLimit(1-99 夹取,缺省 6)、announcement
(双语 markdown 走 renderRich,空则不渲染)、postsTracks(三轨开关,
在 HomePage 内过滤而非改 PostList,守边界)
验证: 构建 48 页 exit 0;contrast-check 四项 PASS;E2E 37/37(快照口径);
实测 theme 变量内联、公告 markdown 粗体渲染、projectsLimit 限流、
photo 轨关闭生效;验收数据已还原
Spec: 2026-11-02_blog-5g-pages (T3/T4)
Co-authored-by: GLM-5.3
408 lines
18 KiB
TypeScript
408 lines
18 KiB
TypeScript
// 站点文案单例访问层(cms 车道 A):后台改 content/site/*.yaml → 页面读此处
|
||
// 兜底纪律:文件缺 → 用默认文案;字段缺/空串 → 用默认文案(构建绝不因文案炸)
|
||
// 注意:整文件被删会先在 file() loader 层报错(误删级),此处守的是字段级缺失/空值
|
||
import { getEntry } from 'astro:content';
|
||
import type { ImageMetadata } from 'astro';
|
||
import type { Locale } from './locale';
|
||
|
||
export interface Bi {
|
||
zh: string;
|
||
en: string;
|
||
}
|
||
|
||
/** 与仓库现状逐字一致的默认文案:yaml 全空时产物不变(零回归可视验证锚点) */
|
||
export const SITE_DEFAULTS = {
|
||
homeTagline: {
|
||
zh: '写作者 · 读者 · 快门按压者,拥抱开源。',
|
||
en: 'Writer · Reader · Shutter-presser — embracing open source.',
|
||
},
|
||
aboutIntro: {
|
||
zh: '写作者 · 读者 · 快门按压者,拥抱开源。这里是我三条轨道的公开工作台:读过的书留下评注,写过的代码进项目墙,按下的快门收入图库。',
|
||
en: 'Writer · Reader · Shutter-presser, embracing open source. This is the public workbench of my three tracks — books that left notes, code that reaches the project wall, and shutters I pressed.',
|
||
},
|
||
opensource: {
|
||
blurb: { zh: '本站本身即开源:', en: 'This site is itself open source: ' },
|
||
blurbAfter: {
|
||
zh: '—— Astro SSG、双写作流、构建发布全链皆在仓中。以下是我在自托管 gitea 与 GitHub 上的全部公开仓库,以及最近一年的贡献活动。',
|
||
en: '— the Astro SSG, dual-writing workflow, and the full build & deploy pipeline live in the repo. Below are all my public repositories on self-hosted gitea and GitHub, plus contributions from the past year.',
|
||
},
|
||
repoUrl: 'https://git.wait4x.online/wait4x/blog',
|
||
repoLabel: 'git.wait4x.online/wait4x/blog',
|
||
pinned: ['blog'] as string[],
|
||
},
|
||
} as const;
|
||
|
||
/** 字段级回落:yaml 值非空才采信(空白串视为空) */
|
||
const pick = (value: string | undefined, fallback: string): string =>
|
||
value && value.trim() ? value : fallback;
|
||
|
||
const pickBi = (value: { zh?: string; en?: string } | undefined, fallback: Bi, locale: Locale): string =>
|
||
pick(value?.[locale], fallback[locale]);
|
||
|
||
/** 主页 tagline(zh/en) */
|
||
export async function homeTagline(locale: Locale): Promise<string> {
|
||
const entry = await getEntry('siteHome', 'home');
|
||
return pickBi(entry?.data.tagline, SITE_DEFAULTS.homeTagline, locale);
|
||
}
|
||
|
||
/** 关于页身份陈述段(zh/en) */
|
||
export async function aboutIntro(locale: Locale): Promise<string> {
|
||
const entry = await getEntry('siteAbout', 'about');
|
||
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
|
||
}
|
||
|
||
export interface OpenSourceCopy {
|
||
blurb: string;
|
||
blurbAfter: string;
|
||
repoUrl: string;
|
||
repoLabel: string;
|
||
/** 置顶仓短名(顺序即顺序);yaml 空数组/缺省 → 默认 ['blog'] */
|
||
pinned: string[];
|
||
}
|
||
|
||
/** 开源页叙述 + 置顶名单 */
|
||
export async function openSourceCopy(locale: Locale): Promise<OpenSourceCopy> {
|
||
const entry = await getEntry('siteOpenSource', 'opensource');
|
||
const d = SITE_DEFAULTS.opensource;
|
||
const pinned = entry?.data.pinned?.filter((n) => n.trim()) ?? [];
|
||
return {
|
||
blurb: pickBi(entry?.data.blurb, d.blurb, locale),
|
||
blurbAfter: pickBi(entry?.data.blurb_after, d.blurbAfter, locale),
|
||
repoUrl: pick(entry?.data.repo_url, d.repoUrl),
|
||
repoLabel: pick(entry?.data.repo_label, d.repoLabel),
|
||
pinned: pinned.length ? pinned : [...d.pinned],
|
||
};
|
||
}
|
||
|
||
// ---- 车道「后台能力扩展」:社交链接 / 头像 / 站点图标 / markdown 正文 ----
|
||
|
||
export interface SocialLink {
|
||
label: string;
|
||
url: string;
|
||
/** 内置图标键:github / rss / email / bilibili / x;未知或空 → 纯文字按钮 */
|
||
icon?: string;
|
||
}
|
||
|
||
export const DEFAULT_SOCIALS: SocialLink[] = [
|
||
{ label: 'GitHub', url: 'https://github.com/wait4xx', icon: 'github' },
|
||
{ label: 'RSS', url: '/rss.xml', icon: 'rss' },
|
||
{ label: '邮件', url: 'mailto:admin@wait4x.online', icon: 'email' },
|
||
];
|
||
|
||
/** 社交链接(顺序即展示顺序);yaml 空/缺省 → 默认三条(与历史硬编码一致) */
|
||
export async function homeSocials(): Promise<SocialLink[]> {
|
||
const entry = await getEntry('siteHome', 'home');
|
||
const list = entry?.data.socials?.filter((s) => s.label?.trim() && s.url?.trim()) ?? [];
|
||
return list.length ? list : DEFAULT_SOCIALS;
|
||
}
|
||
|
||
/** 头像(Astro 图片管线的 ImageMetadata);未配置 → null,调用方回退渐变占位 */
|
||
export async function homeAvatar(): Promise<ImageMetadata | null> {
|
||
const entry = await getEntry('siteHome', 'home');
|
||
return entry?.data.avatar ?? null;
|
||
}
|
||
|
||
/** 站点图标路径:yaml 值非空即采信,否则 /favicon.svg;值不带前导 / 时自动补 */
|
||
export async function siteFavicon(): Promise<{ href: string; type: string }> {
|
||
const entry = await getEntry('siteHome', 'home');
|
||
const raw = pick(entry?.data.favicon, '/favicon.svg');
|
||
const href = raw.startsWith('/') || raw.startsWith('http') ? raw : `/${raw}`;
|
||
const type = href.endsWith('.svg')
|
||
? 'image/svg+xml'
|
||
: href.endsWith('.ico')
|
||
? 'image/x-icon'
|
||
: href.endsWith('.png')
|
||
? 'image/png'
|
||
: '';
|
||
return { href, type };
|
||
}
|
||
|
||
/** markdown 字符串 → HTML(进程内单例处理器,构建期复用) */
|
||
let mdProcessor: Promise<import('@astrojs/markdown-remark').MarkdownProcessor> | null = null;
|
||
export async function renderMarkdown(md: string): Promise<string> {
|
||
const { createMarkdownProcessor } = await import('@astrojs/markdown-remark');
|
||
// 与 astro.config.mjs 的文章管线同序:raw → 内容净化(标签/属性/协议)→
|
||
// class 白名单 → 链接卡片。此前这里 createMarkdownProcessor() 无参,页面
|
||
// 文案的 raw HTML 直通 set:html(方案 10 §7.1 实锤漏洞:script/style/on* 不剥)。
|
||
mdProcessor ??= (async () => {
|
||
const [{ default: rehypeRaw }, { default: rehypeContentSanitize }, { default: rehypeClassWhitelist }, { default: rehypeLinkCards }] =
|
||
await Promise.all([
|
||
import('rehype-raw'),
|
||
import('./rehype-content-sanitize'),
|
||
import('./rehype-class-whitelist'),
|
||
import('./rehype-link-cards'),
|
||
]);
|
||
return createMarkdownProcessor({
|
||
rehypePlugins: [rehypeRaw, rehypeContentSanitize, rehypeClassWhitelist, rehypeLinkCards],
|
||
});
|
||
})();
|
||
const { code } = await (await mdProcessor).render(md);
|
||
return code;
|
||
}
|
||
|
||
/**
|
||
* 页面文案富文本统一入口(层 1):所有站点单例文案字段(tagline/subtitle/blurb/blurb_after/intro…)
|
||
* 都走同一条 markdown + raw + 内容净化 + class 白名单 + 链接卡片管线,
|
||
* 后台因此可在这些字段里写 `<span class="font-kai c-blue">`。
|
||
* 硬编码界面外壳(导航/页脚按钮)不走此路径——它们属 UI 而非内容。
|
||
*/
|
||
export async function renderRich(md: string, opts: { inline?: boolean } = {}): Promise<string> {
|
||
const html = await renderMarkdown(md);
|
||
if (!opts.inline) return html;
|
||
// 行内场景(tagline / blurb 这类已在外层 <p> 里的短文本):剥掉 markdown 包的外层
|
||
// <p>,否则 <p> 套 <p> 会被浏览器解析拆散,富文本 span 逃出容器(实测坑)
|
||
return html.replace(/^\s*<p>/i, '').replace(/<\/p>\s*$/i, '').trim();
|
||
}
|
||
|
||
/**
|
||
* 页面级排版覆盖(层 2):读单例的 typography 字段,产出该页容器要用的
|
||
* 类名与 CSS 变量(--page-font / --accent / --page-scale)。
|
||
* 只作用于该页内容容器,不写全站 tokens——其它页面零影响。
|
||
*/
|
||
export interface PageTypography {
|
||
className: string;
|
||
style: string;
|
||
}
|
||
|
||
const ALLOWED_PAGE_FONTS = new Set([
|
||
'font-serif', 'font-kai', 'font-sans', 'font-mono',
|
||
'font-song', 'font-kai-sys', 'font-hei-sys', 'font-fangsong',
|
||
'font-huiwen', 'font-kuaile',
|
||
]);
|
||
const ALLOWED_PAGE_ACCENTS = new Set([
|
||
'ink-accent', 'ink-accent-soft',
|
||
...['blue', 'teal', 'green', 'yellow', 'orange', 'red', 'pink', 'purple'].flatMap((h) => [`c-${h}`, `c-${h}-soft`]),
|
||
]);
|
||
|
||
export async function pageTypography(
|
||
collection: 'siteHome' | 'siteAbout' | 'siteOpenSource',
|
||
id: string,
|
||
): Promise<PageTypography> {
|
||
const entry = await getEntry(collection, id);
|
||
const t = entry?.data?.typography as { font?: string; accent?: string; scale?: number } | undefined;
|
||
const font = t?.font && ALLOWED_PAGE_FONTS.has(t.font) ? t.font : '';
|
||
const accent = t?.accent && ALLOWED_PAGE_ACCENTS.has(t.accent) ? t.accent : '';
|
||
const scale = typeof t?.scale === 'number' && t.scale >= 0.8 && t.scale <= 1.25 ? t.scale : 1;
|
||
// 白名单外的值直接忽略(与 rehype 白名单同纪律:只认清单内的类)
|
||
const vars: string[] = [];
|
||
if (font) vars.push(`--page-font: ${fontVarFor(font)}`);
|
||
if (accent) vars.push(`--page-accent: ${accentVarFor(accent)}`);
|
||
if (scale !== 1) vars.push(`--page-scale: ${scale}`);
|
||
return { className: font || accent || scale !== 1 ? 'page-typo' : '', style: vars.join('; ') };
|
||
}
|
||
|
||
function fontVarFor(cls: string): string {
|
||
const map: Record<string, string> = {
|
||
'font-serif': 'var(--font-serif)', 'font-kai': 'var(--font-kai)',
|
||
'font-sans': 'var(--font-sans)', 'font-mono': 'var(--font-mono)',
|
||
'font-song': 'var(--font-serif)', 'font-kai-sys': 'var(--font-kai)',
|
||
'font-hei-sys': 'var(--font-sans)', 'font-fangsong': '"FangSong", STFangsong, serif',
|
||
'font-huiwen': '"WF-Hwmc", var(--font-serif)', 'font-kuaile': '"ZCOOL KuaiLe", var(--font-serif)',
|
||
};
|
||
return map[cls] ?? 'inherit';
|
||
}
|
||
function accentVarFor(cls: string): string {
|
||
if (cls === 'ink-accent') return 'var(--accent)';
|
||
if (cls === 'ink-accent-soft') return 'var(--accent-soft-text)';
|
||
const m = /^c-([a-z]+)(-soft)?$/.exec(cls);
|
||
return m ? `var(--c-${m[1]}${m[2] ? '-soft' : ''})` : 'var(--accent)';
|
||
}
|
||
|
||
/** 关于页正文(markdown,未渲染);空 → 默认文案 */
|
||
export async function aboutIntroMarkdown(locale: Locale): Promise<string> {
|
||
const entry = await getEntry('siteAbout', 'about');
|
||
return pickBi(entry?.data.intro, SITE_DEFAULTS.aboutIntro, locale);
|
||
}
|
||
|
||
// ---- 车道「首页+开源页后台扩展」:首页四项可配 + 项目墙可见性 + README 开篇 ----
|
||
|
||
export interface HomeSections {
|
||
tracks: boolean;
|
||
photos: boolean;
|
||
heatmap: boolean;
|
||
projects: boolean;
|
||
}
|
||
|
||
export interface HomeTracks {
|
||
reading: boolean;
|
||
tech: boolean;
|
||
photo: boolean;
|
||
}
|
||
|
||
export interface HomeConfig {
|
||
/** 副标题 markdown 原文;空串 = 不渲染 */
|
||
subtitle: string;
|
||
/** 公告 markdown 原文;空串 = 不渲染 */
|
||
announcement: string;
|
||
/** 首页项目墙条数(1-99,缺省 6) */
|
||
projectsLimit: number;
|
||
/** 轨细分开关(缺省全开) */
|
||
postsTracks: HomeTracks;
|
||
/** 最新影像张数:0 = 隐藏区块 */
|
||
latestPhotos: number;
|
||
/** 每轨文章条数 */
|
||
perTrackLimit: number;
|
||
sections: HomeSections;
|
||
}
|
||
|
||
const clampInt = (value: number | undefined, min: number, max: number, fallback: number): number => {
|
||
if (typeof value !== 'number' || Number.isNaN(value)) return fallback;
|
||
return Math.min(max, Math.max(min, Math.trunc(value)));
|
||
};
|
||
|
||
/** 首页可配项(字段级回落:零配置 = 现有形态;越界值夹取而非报错) */
|
||
export async function homeConfig(locale: Locale): Promise<HomeConfig> {
|
||
const entry = await getEntry('siteHome', 'home');
|
||
const d = entry?.data;
|
||
return {
|
||
subtitle: pickBi(d?.subtitle, { zh: '', en: '' }, locale),
|
||
announcement: pickBi(d?.announcement, { zh: '', en: '' }, locale),
|
||
projectsLimit: clampInt(d?.projectsLimit, 1, 99, 6),
|
||
postsTracks: {
|
||
reading: d?.postsTracks?.reading ?? true,
|
||
tech: d?.postsTracks?.tech ?? true,
|
||
photo: d?.postsTracks?.photo ?? true,
|
||
},
|
||
latestPhotos: clampInt(d?.latestPhotos, 0, 6, 4),
|
||
perTrackLimit: clampInt(d?.perTrackLimit, 4, 8, 6),
|
||
sections: {
|
||
tracks: d?.sections?.tracks ?? true,
|
||
photos: d?.sections?.photos ?? true,
|
||
heatmap: d?.sections?.heatmap ?? true,
|
||
projects: d?.sections?.projects ?? true,
|
||
},
|
||
};
|
||
}
|
||
|
||
export type ProjectMode = 'all' | 'only' | 'exclude';
|
||
export type ProjectSort = 'pinned-first' | 'stars' | 'updated';
|
||
export type ProjectSource = 'both' | 'github' | 'gitea';
|
||
|
||
export interface OpenSourceOptions {
|
||
mode: ProjectMode;
|
||
include: string[];
|
||
exclude: string[];
|
||
sort: ProjectSort;
|
||
perSource: ProjectSource;
|
||
introFrom: 'off' | 'github-readme';
|
||
}
|
||
|
||
/** 开源页项目墙选项(缺省 = 现状:all / pinned-first / 双源) */
|
||
export async function openSourceOptions(): Promise<OpenSourceOptions> {
|
||
const entry = await getEntry('siteOpenSource', 'opensource');
|
||
const d = entry?.data;
|
||
return {
|
||
mode: d?.mode ?? 'all',
|
||
include: (d?.include ?? []).filter((n) => n.trim()),
|
||
exclude: (d?.exclude ?? []).filter((n) => n.trim()),
|
||
sort: d?.sort ?? 'pinned-first',
|
||
perSource: d?.perSource ?? 'both',
|
||
introFrom: d?.intro_from ?? 'off',
|
||
};
|
||
}
|
||
|
||
/** GitHub profile README 缓存:取不到网就吃缓存,都无则调用方回落 yaml blurb */
|
||
const README_CACHE = 'content/.github-readme.md';
|
||
const PROFILE_REPO = 'wait4xx/wait4xx';
|
||
/** README 请求上限:构建期不能被单个慢端点或半开连接卡住 */
|
||
const README_TIMEOUT_MS = 10_000;
|
||
|
||
/**
|
||
* 构建期取 profile 仓 README(Accept: raw),成功后回写 content/.github-readme.md(入仓缓存)。
|
||
*
|
||
* 回落矩阵(四级,每一级都以 console.warn 点名原因,构建日志即可定位是哪级兜住的):
|
||
* 1. 新鲜网络 → 内容有变才回写缓存(console.log)。
|
||
* 2. token 缺位 / HTTP 非 2xx / 空内容 / 超时 / 网络异常 → 吃缓存 `content/.github-readme.md`。
|
||
* 3. 缓存缺失或不可读 → 返回 null,调用方回落 yaml blurb。
|
||
* 4. 缓存回写失败(构建仓只读挂载等)→ 仅告警,本次渲染照常。
|
||
*
|
||
* 已知限制:README 内的**相对链接不重写**(如 `./img/x.png`、`docs/foo.md`)——它们相对 GitHub
|
||
* 仓根解析,在本站没有对应产物,点到即 404。保原样比猜一个基址可信;该限制在 docs/管理台.md
|
||
* “开源页开篇”一节向用户明示。危险 HTML 标签已由 renderMarkdown 管线里的
|
||
* rehype-content-sanitize 统一剥离,此处不再重复过滤。
|
||
*
|
||
* @returns README markdown;四级回落见上
|
||
*/
|
||
export async function githubReadmeMarkdown(): Promise<string | null> {
|
||
const { readFileSync, writeFileSync, existsSync } = await import('node:fs');
|
||
const { join } = await import('node:path');
|
||
const cachePath = join(process.cwd(), README_CACHE);
|
||
|
||
/** 静默读缓存:缺失/不可读/空 → null(不打印,供回落函数与回写比对复用) */
|
||
const readCacheQuiet = (): string | null => {
|
||
try {
|
||
const md = existsSync(cachePath) ? readFileSync(cachePath, 'utf8') : null;
|
||
return md && md.trim() ? md : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
};
|
||
|
||
/** 第 2/3 级回落:先报原因,再报缓存有无 */
|
||
const fallback = (cause: string): string | null => {
|
||
const cached = readCacheQuiet();
|
||
if (!cached) {
|
||
console.warn(`[opensource] github-readme:${cause};缓存 ${README_CACHE} 亦缺,回落 yaml blurb`);
|
||
return null;
|
||
}
|
||
console.warn(`[opensource] github-readme:${cause};改用缓存 ${README_CACHE}(${cached.length} 字符,可能陈旧)`);
|
||
return hardenReadmeImages(cached);
|
||
};
|
||
|
||
let token: string | null = null;
|
||
try {
|
||
token = readFileSync(join(process.env.HOME ?? '', 'docker_apps/blog/github-token.txt'), 'utf8').trim() || null;
|
||
} catch {
|
||
token = null;
|
||
}
|
||
if (!token) return fallback('token 缺位(~/docker_apps/blog/github-token.txt 不存在)');
|
||
|
||
try {
|
||
const res = await fetch(`https://api.github.com/repos/${PROFILE_REPO}/readme`, {
|
||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github.raw', 'User-Agent': 'wait4x-blog-build' },
|
||
signal: AbortSignal.timeout(README_TIMEOUT_MS),
|
||
});
|
||
if (!res.ok) return fallback(`${PROFILE_REPO} 取 README 失败 HTTP ${res.status}`);
|
||
const md = (await res.text()).trim();
|
||
if (!md) return fallback('README 内容为空');
|
||
if (readCacheQuiet() !== md) {
|
||
try {
|
||
writeFileSync(cachePath, `${md}\n`, 'utf8');
|
||
console.log(`[opensource] github-readme:已刷新缓存 ${README_CACHE}(${md.length} 字符)`);
|
||
} catch (err) {
|
||
// 构建仓可能是只读挂载:缓存写不进不该拖垮本次渲染
|
||
console.warn(`[opensource] github-readme:缓存回写失败(${(err as Error).message}),本次渲染不受影响`);
|
||
}
|
||
}
|
||
return hardenReadmeImages(md);
|
||
} catch (err) {
|
||
const e = err as Error;
|
||
const cause = e.name === 'TimeoutError' || e.name === 'AbortError'
|
||
? `请求超时(>${README_TIMEOUT_MS}ms)`
|
||
: `请求异常(${e.message})`;
|
||
return fallback(cause);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* README 里的 <img>(shields.io 徽章 / readme-typing-svg 之类)加固:惰性加载 + alt 兜底。
|
||
*
|
||
* 为何只动 `loading` 与 `alt`:README 走 renderMarkdown 管线,出的 HTML 还要过
|
||
* rehype-content-sanitize 的白名单(img 只保留 src/alt/width/height/loading),
|
||
* 写在 markdown 层的 `decoding`/`referrerpolicy` 会被剥掉。惰性加载是徽章不挤压首屏的
|
||
* 主力手段:下方徽章不占首屏带宽、不拖慢 loading 事件;破图时还有空 alt 兜底不留噪声。
|
||
* 尺寸约束已由页面 scoped 样式 `img { max-width: 100%; height: auto }` 扣住。
|
||
*
|
||
* 幂等:已存在 loading 属性时覆写为同一策略,不会重复叠加。
|
||
* @param md README markdown 原文
|
||
* @returns 加固后的 markdown(缓存仍写原始内容,保证缓存与上游可比对)
|
||
*/
|
||
function hardenReadmeImages(md: string): string {
|
||
return md.replace(/<img\b([^>]*)>/gi, (_match, attrs: string) => {
|
||
let out = attrs;
|
||
if (!/\salt=/i.test(out)) out += ' alt=""';
|
||
out = /\sloading\b/i.test(out) ? out.replace(/\sloading="[^"]*"/i, ' loading="lazy"') : `${out} loading="lazy"`;
|
||
return `<img${out}>`;
|
||
});
|
||
}
|