电脑使用 - 博客园折叠框等洛谷新版显示功能配置详解

序

为了在博客园上渲染洛谷的折叠块,epigraph 和 align 功能,我使用 AI 工具的辅助编写了一个插件,并将其改写成了 Html,使其能在 SimpleMemory 这样的高度配置化且深度重构 Markdown 的主题下运行。

使用方法非常简便。你可以在我本人的博客园博客上查看渲染效果,里面的 GF 学习笔记应该是较为复杂的情况,其通过了测试。

目前插件版本 / 主要内容版本为 v3.6,Powered By Arena AI。插件不会访问洛谷上的一切内容,基于博客园内部配置,不会产生访问频率问题。

::::warning[关于 AI 使用说明]{open}

由于本人过于菜,这里的一切 Html 代码都是由 AI 生成的。我已经确认过其的本地数据安全性,保证不会产生数据泄露的问题。本插件(或博客园内部配置)一切数据都存储于本地。

文章内容(除代码)不含任何 AI 生成内容。

::::

配置方法简述

你可以选用两种方法:

  • 使用博客园内置的页脚 Html 功能来渲染。
  • 使用油猴插件渲染。

博客园内的配置要求:开通 JS 功能,且使用 SimpleMemory 的主题优化配置,打开 MathJax3。这是所需的全部已知配置。

页脚 Html 配置代码

::::success[代码内容]

<!-- 作者:Exfish | 版本:3.5 页脚版
将本文件全部内容追加到博客园「设置 → 页脚 HTML 代码」。
需要博客 JS 权限;请停用同功能油猴脚本,保留现有主题和 MathJax 配置。
-->
<!-- 作者:Exfish | 版本:3.6 页脚版
将本文件全部内容放入博客园「设置 → 页脚 HTML 代码」。
更新时请替换旧版渲染器,不要重复追加;保留其他主题代码。
需要博客 JS 权限;请停用同功能油猴脚本,保留现有 MathJax 配置。
-->
<script>
// 博客园洛谷语法渲染器 — 页脚版 3.6
// 作者:Exfish
// 使用页面已有 MathJax,不重复加载数学引擎。

(function() {
    'use strict';

    // 同一页面重复粘贴或加载脚本时,避免重复绑定事件和观察器。
    const installKey = '__exfishLuoguRenderer36Installed';
    if (window[installKey]) return;
    window[installKey] = true;

    // 原生页面样式注入,不依赖油猴 API。
    function addStyle(css) {
        const styleId = 'exfish-luogu-renderer-style';
        let style = document.getElementById(styleId);
        if (!style) {
            style = document.createElement('style');
            style.id = styleId;
            (document.head || document.documentElement).appendChild(style);
        }
        style.textContent = css;
    }

    const ICONS = {
        info: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"/></svg>`,
        warning: `<svg viewBox="0 0 576 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/></svg>`,
        success: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200zm90.729 158.343l-110.343 110.343c-4.686 4.686-12.284 4.686-16.971 0l-55.343-55.343c-4.686-4.686-4.686-12.284 0-16.971l22.627-22.627c4.686-4.686 12.284-4.686 16.971 0l24.686 24.686 79.686-79.686c4.686-4.686 12.284-4.686 16.971 0l22.627 22.627c4.686 4.686 4.686 12.284 0 16.971z"/></svg>`,
        error: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"/></svg>`
    };

    const CONFIG = {
        blockTypes: {
            info:     { color: '#3498db' },
            warning:  { color: '#ffc116' },
            success:  { color: '#52c41a' },
            error:    { color: '#e74c3c' },
        },
        alignTypes: {
            center: 'center',
            right: 'right',
            left: 'left',
        }
    };

    addStyle(`
        /* ===== 折叠框 ===== */
        .luogu-fold-block {
            border: none;
            border-left: 4px solid #ccc;
            border-radius: 4px;
            margin: 0.8em 0;
            font-size: 14px;
            overflow: visible !important;
            transition: all 0.2s ease;
        }
        .luogu-fold-block .fold-title {
            padding: 4px 11px;
            cursor: pointer;
            font-weight: 600;
            display: flex;
            align-items: center;
            justify-content: space-between;
            user-select: none;
            background: transparent !important;
            gap: 4px;
        }
        .luogu-fold-block .fold-title .fold-title-text {
            flex: 1;
            min-width: 0;
            text-align: left;
            text-indent: 0 !important;
            line-height: 1.6;
            overflow-wrap: break-word;
            word-break: break-word;
        }
        .luogu-fold-block .fold-title .fold-icon {
            flex-shrink: 0;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 1.1em;
            height: 1.1em;
            line-height: 1;
        }
        .luogu-fold-block .fold-title .fold-icon svg {
            width: 100%;
            height: 100%;
            display: block;
        }
        .luogu-fold-block .fold-title .fold-title-text mjx-container,
        .luogu-fold-block .fold-title .fold-title-text .MathJax,
        .luogu-fold-block .fold-title .fold-title-text .MathJax_Display {
            text-align: left !important;
            margin: 0 !important;
            max-width: none !important;
            overflow: visible !important;
            /* 保留 MathJax 自身的行内公式基线,不统一设置 vertical-align。 */
        }
        /* 保留 MathJax 原生块级布局;不把公式容器改为 flex,
           不覆盖内部 SVG、mjx-math 的尺寸或基线。 */
        .luogu-fold-block > .fold-title > .fold-title-text mjx-container[display="true"],
        .luogu-fold-block > .fold-title > .fold-title-text .MathJax_Display,
        .luogu-fold-block > .fold-title > .fold-title-text .MathJax_SVG_Display,
        .luogu-fold-block > .fold-title > .fold-title-text .MathJax_CHTML_Display {
            margin: 0 !important;
            text-indent: 0 !important;
            text-align: left !important;
        }
        .luogu-fold-block .fold-title .fold-arrow {
            transition: transform 0.2s;
            font-size: 12px;
            opacity: 0.6;
            flex-shrink: 0;
            margin-left: 4px;
        }
        .luogu-fold-block.open > .fold-title .fold-arrow {
            transform: rotate(90deg);
        }
        .luogu-fold-block > .fold-content {
            /* 用小间距替代标题后由 Markdown 换行产生的整行 <br>。 */
            padding: 0.25em 11px 6px;
            overflow: visible !important;
            display: none !important;
            line-height: 1.6;
        }
        .luogu-fold-block.open > .fold-content {
            display: block !important;
        }
        /* 避免主题给正文首段的上边距再次撑出一个空行。 */
        .luogu-fold-block > .fold-content > :first-child {
            margin-top: 0 !important;
        }
        /* 只解除排版外层的裁剪,不修改公式内部布局与辅助 MathML。 */
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph),
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
        :is(.fold-title, .fold-title-text, .epigraph-content, .epigraph-line,
            .epigraph-author) {
            overflow: visible !important;
        }
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
        :is(mjx-container, .MathJax_Display, .MathJax_SVG_Display,
            .MathJax_CHTML_Display) {
            max-width: none !important;
            overflow: visible !important;
        }
        /* 行内公式必须以完整宽度参与文字排版。只设置最外层,
           不改分式/根号内部的定位,也不把公式压进主题的 inline-flex。 */
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
        :is(mjx-container:not([display="true"]), .MathJax, .MathJax_CHTML, .MathJax_SVG) {
            display: inline-block !important;
            width: max-content !important;
            min-width: max-content !important;
            max-width: none !important;
            float: none !important;
            white-space: nowrap !important;
            overflow: visible !important;
        }
        .luogu-fold-block .fold-content .luogu-fold-block {
            margin: 0.3em 0;
            font-size: 13px;
        }
        ${Object.entries(CONFIG.blockTypes).map(([type, style]) => `
            .luogu-fold-block.${type} {
                border-left-color: ${style.color} !important;
            }
            .luogu-fold-block.${type} > .fold-title {
                color: ${style.color};
                background: transparent !important;
            }
        `).join('')}

        /* ===== 对齐排版 ===== */
        .luogu-align {
            margin: 1em 0;
        }
        .luogu-align.center {
            text-align: center;
        }
        .luogu-align.center > * {
            margin-left: auto;
            margin-right: auto;
        }
        .luogu-align.right {
            text-align: right;
        }
        .luogu-align.right > * {
            margin-left: auto;
            margin-right: 0;
        }
        .luogu-align.left {
            text-align: left;
        }
        .luogu-align.left > * {
            margin-left: 0;
            margin-right: auto;
        }
        .luogu-align.center > p,
        .luogu-align.center > div,
        .luogu-align.center > h1,
        .luogu-align.center > h2,
        .luogu-align.center > h3,
        .luogu-align.center > h4,
        .luogu-align.center > h5,
        .luogu-align.center > h6 {
            text-align: center;
        }
        .luogu-align.right > p,
        .luogu-align.right > div,
        .luogu-align.right > h1,
        .luogu-align.right > h2,
        .luogu-align.right > h3,
        .luogu-align.right > h4,
        .luogu-align.right > h5,
        .luogu-align.right > h6 {
            text-align: right;
        }
        .luogu-align.left > p,
        .luogu-align.left > div,
        .luogu-align.left > h1,
        .luogu-align.left > h2,
        .luogu-align.left > h3,
        .luogu-align.left > h4,
        .luogu-align.left > h5,
        .luogu-align.left > h6 {
            text-align: left;
        }
        .luogu-align img {
            display: block !important;
        }
        .luogu-align.center img {
            margin-left: auto !important;
            margin-right: auto !important;
        }
        .luogu-align.right img {
            margin-left: auto !important;
            margin-right: 0 !important;
        }
        .luogu-align.left img {
            margin-left: 0 !important;
            margin-right: auto !important;
        }

        /* ===== epigraph ===== */
        .luogu-epigraph {
            margin: 1.2em 0;
            padding: 0.4em 0;
            font-size: 14px;
            color: inherit;
            opacity: 0.9;
        }
        .luogu-epigraph .epigraph-content {
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            text-align: right;
            max-width: 20em;
            margin-left: auto;
            line-height: 1.7;
        }
        .luogu-epigraph .epigraph-content p {
            margin: 0;
            text-align: right;
        }
        .luogu-epigraph .epigraph-line {
            text-align: right;
        }
        .luogu-epigraph .epigraph-divider {
            width: 20em !important;
            max-width: 100% !important;
            height: 0 !important;
            border: none !important;
            border-top: 1px solid currentColor !important;
            opacity: 0.35;
            margin: 0.4em 0 0.4em auto !important;
            padding: 0 !important;
            background: transparent !important;
        }
        .luogu-epigraph .epigraph-author {
            text-align: right;
            color: inherit;
            opacity: 0.7;
            font-size: 13px;
            margin-left: auto;
            max-width: 20em;
        }
    `);

    // ========== MathJax 配置补全 ==========
    /**
     * 确保 MathJax v3 的 tex 配置里包含 $...$ 和 $$...$$
     * 博客园可能只配置了 \(...\),导致 $...$ 不被识别
     */
    function ensureMathJaxDollarSupport() {
        if (!window.MathJax) return false;
        if (window.MathJax.Hub && window.MathJax.Hub.Config) {
            const hub = window.MathJax.Hub;
            const config = hub.config.tex2jax || {};
            const inlineMath = (config.inlineMath || [['\\(', '\\)']]).slice();
            const displayMath = (config.displayMath || [['\\[', '\\]']]).slice();
            if (!inlineMath.some(p => p[0] === '$')) inlineMath.push(['$', '$']);
            if (!displayMath.some(p => p[0] === '$$')) displayMath.push(['$$', '$$']);
            hub.Config({tex2jax: {inlineMath, displayMath}});
            return true;
        }
        if (!window.MathJax.config) return false;
        const tex = window.MathJax.config.tex = window.MathJax.config.tex || {};

        tex.inlineMath = tex.inlineMath || [];
        if (!tex.inlineMath.some(p => p[0] === '$')) {
            tex.inlineMath.push(['$', '$']);
        }

        tex.displayMath = tex.displayMath || [];
        if (!tex.displayMath.some(p => p[0] === '$$')) {
            tex.displayMath.push(['$$', '$$']);
        }
        // 确保 \[...\] 也在
        if (!tex.displayMath.some(p => p[0] === '\\[')) {
            tex.displayMath.push(['\\[', '\\]']);
        }

        console.log('[洛谷渲染] MathJax inlineMath:', JSON.stringify(tex.inlineMath));
        console.log('[洛谷渲染] MathJax displayMath:', JSON.stringify(tex.displayMath));
        return true;
    }

    // ========== 预处理 ==========
    function preprocessHTML(html) {
        html = html.replace(/<p[^>]*>\s*(:{3,})/gi, '$1');
        html = html.replace(/(:{3,})\s*<\/p>/gi, '$1');
        html = html.replace(/<br\s*\/?>\s*(:{3,})/gi, '\n$1');
        html = html.replace(/(:{3,})\s*<br\s*\/?>/gi, '$1\n');
        // 此处处理的是 HTML 序列化字符串,而不是纯文本。
        // 必须保留 &lt;、&gt;、&amp;、&quot; 等实体,交给浏览器解析一次:
        // 若提前反转义,TeX 的 0<k 会被当成 HTML 标签,导致公式被吞掉;
        // 二次解码还会破坏原文中的字面量实体、属性和 aligned 的 &。
        html = html.replace(/&nbsp;/g, ' ')
                   .replace(/&colon;/g, ':');
        html = html.replace(/(:{3,})(\w+)\[([^\]]*)\]/g, (m, c, t, title) => {
            return c + t + '[' + title.trim() + ']';
        });
        return html;
    }

    function findColonLevels(text) {
        const levels = new Set();
        const re = /(:{3,})(?:\w+\[|align\{)/g;
        let m;
        while ((m = re.exec(text)) !== null) {
            levels.add(m[1].length);
        }
        return Array.from(levels).sort((a, b) => b - a);
    }

    function parseEpigraph(text, levels) {
        let result = text;

        for (const level of levels) {
            const colons = ':'.repeat(level);
            const regex = new RegExp(
                `(${colons})epigraph\\[([^\\]]*)\\]([\\s\\S]*?)\\1`,
                'g'
            );

            const matches = [];
            let match;
            while ((match = regex.exec(result)) !== null) {
                matches.push({
                    full: match[0],
                    colons: match[1],
                    author: match[2],
                    content: match[3],
                    index: match.index
                });
            }

            for (let i = matches.length - 1; i >= 0; i--) {
                const m = matches[i];
                let content = m.content.trim();
                content = content.replace(/<\/p>\s*<p>/g, '\n');
                content = content.replace(/^<p>/, '').replace(/<\/p>$/, '');

                const lines = content.split('\n').filter(l => l.trim() !== '');
                const contentHTML = lines.map(l => `<div class="epigraph-line">${l}</div>`).join('');

                const html = `<div class="luogu-epigraph">
                    <div class="epigraph-content">${contentHTML}</div>
                    <div class="epigraph-divider"></div>
                    <div class="epigraph-author">${m.author}</div>
                </div>`;

                result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
            }
        }

        return result;
    }

    function cleanFoldContent(content) {
        let html = content.trim();
        // 标记独占段落时,预处理会留下段落边界;避免浏览器修复成空段落。
        html = html.replace(/^<\/p\s*>\s*/i, '')
                   .replace(/\s*<p\b[^>]*>$/i, '');
        // 只移除紧邻起始标记的一个换行,保留正文内及用户额外添加的换行。
        html = html.replace(/^<br\b[^>]*>\s*/i, '');
        return html.trim();
    }

    function parseFoldBlocks(text, levels) {
        let result = text;

        for (const level of levels) {
            const colons = ':'.repeat(level);
            const regex = new RegExp(
                `(${colons})(\\w+)\\[([^\\]]*)\\](?:\\{(\\w+)\\})?([\\s\\S]*?)\\1`,
                'g'
            );

            const matches = [];
            let match;
            while ((match = regex.exec(result)) !== null) {
                matches.push({
                    full: match[0],
                    colons: match[1],
                    type: match[2],
                    title: match[3],
                    option: match[4],
                    content: match[5],
                    index: match.index
                });
            }

            for (let i = matches.length - 1; i >= 0; i--) {
                const m = matches[i];
                const typeClass = CONFIG.blockTypes[m.type] ? m.type : 'info';
                const openClass = m.option === 'open' ? ' open' : '';
                const arrow = '▶';
                const icon = ICONS[typeClass] || ICONS.info;

                const html = `<div class="luogu-fold-block ${typeClass}${openClass}" data-luogu-type="${m.type}" data-luogu-depth="${level}">
                    <div class="fold-title">
                        <span class="fold-icon">${icon}</span>
                        <span class="fold-title-text">${m.title}</span>
                        <span class="fold-arrow">${arrow}</span>
                    </div>
                    <div class="fold-content${m.option === 'open' ? '' : ' mathjax_ignore tex2jax_ignore'}">${cleanFoldContent(m.content)}</div>
                </div>`;

                result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
            }
        }

        return result;
    }

    function parseAlignBlocks(text, levels) {
        let result = text;

        for (const level of levels) {
            const colons = ':'.repeat(level);
            const regex = new RegExp(
                `(${colons})align\\{(\\w+)\\}([\\s\\S]*?)\\1`,
                'g'
            );

            const matches = [];
            let match;
            while ((match = regex.exec(result)) !== null) {
                matches.push({
                    full: match[0],
                    colons: match[1],
                    alignType: match[2],
                    content: match[3],
                    index: match.index
                });
            }

            for (let i = matches.length - 1; i >= 0; i--) {
                const m = matches[i];
                const alignClass = CONFIG.alignTypes[m.alignType] ? m.alignType : 'center';
                const html = `<div class="luogu-align ${alignClass}">${m.content.trim()}</div>`;
                result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
            }
        }

        return result;
    }

    // 一个队列处理本插件的所有排版任务,避免 v3 的 typesetPromise 并发。
    let mathQueue = Promise.resolve();
    let mathRetryTimer = null;
    const pendingMathRoots = new Set();
    const pendingRefreshRoots = new Set();

    function typesetElement(el, refresh = false) {
        if (!el || !el.isConnected) return;
        if (refresh) pendingRefreshRoots.add(el);
        for (const root of pendingMathRoots) {
            if (root === el || root.contains(el)) return;
            if (el.contains(root)) pendingMathRoots.delete(root);
        }
        pendingMathRoots.add(el);
        scheduleMath();
    }

    function scheduleMath(attempt = 0) {
        if (mathRetryTimer !== null) return;
        mathRetryTimer = setTimeout(() => {
            mathRetryTimer = null;
            const mj = window.MathJax;
            const isV2 = mj && mj.Hub && typeof mj.Hub.Queue === 'function';
            if (!mj || (!isV2 && typeof mj.typesetPromise !== 'function')) {
                // 最多等待 30 秒,避免页面未加载 MathJax 时无限轮询。
                if (attempt < 100) scheduleMath(attempt + 1);
                else {
                    pendingMathRoots.clear();
                    pendingRefreshRoots.clear();
                    console.warn('[洛谷渲染] 未检测到可用的 MathJax');
                }
                return;
            }
            mathQueue = mathQueue.then(async () => {
                if (mj.startup && mj.startup.promise) await mj.startup.promise;
                ensureMathJaxDollarSupport();
                const roots = Array.from(pendingMathRoots).filter(el => el.isConnected);
                pendingMathRoots.clear();
                const refreshRoots = Array.from(pendingRefreshRoots).filter(el => el.isConnected);
                pendingRefreshRoots.clear();
                if (!roots.length) return;
                if (isV2) {
                    // v2 的 Typeset 不会重算已存在公式;展开后必须 Rerender。
                    for (const root of roots) {
                        await new Promise(resolve => mj.Hub.Queue(['Typeset', mj.Hub, root], resolve));
                    }
                    const rerenderSources = new Set();
                    for (const root of refreshRoots) {
                        const items = mj.Hub.getAllJax(root).filter(jax => {
                            const source = jax.SourceElement();
                            return source && !source.closest('.fold-content.mathjax_ignore');
                        });
                        items.forEach(jax => rerenderSources.add(jax.SourceElement()));
                    }
                    if (rerenderSources.size) await new Promise(resolve => mj.Hub.Queue(
                        ['Rerender', mj.Hub, Array.from(rerenderSources)], resolve
                    ));
                } else {
                    refreshRegisteredMath(mj, refreshRoots);
                    await mj.typesetPromise(roots);
                }
            }).catch(err => {
                console.warn('[洛谷渲染] MathJax typeset 失败:', err);
            });
        }, attempt ? 300 : 0);
    }

    // typesetPromise() 只查找新公式,不会重算已渲染公式的尺寸。
    // 将本插件范围内的 TeX 从 MathJax 注册表安全还原,再在可见布局中排版。
    function refreshRegisteredMath(mj, roots) {
        if (typeof mj.getMathItemsWithin !== 'function' || typeof mj.typesetClear !== 'function') return;
        const seen = new Set();
        const entries = [];
        for (const root of roots) {
            for (const item of mj.getMathItemsWithin([root])) {
                const node = item.typesetRoot;
                if (!node || !node.isConnected || seen.has(node)) continue;
                if (!node.closest('.luogu-fold-block, .luogu-align, .luogu-epigraph')) continue;
                // 闭合的嵌套块等其自身展开时再测量,不能在 display:none 下重排。
                if (node.closest('.fold-content.mathjax_ignore')) continue;
                if (!item.inputJax || item.inputJax.name !== 'TeX') continue;
                seen.add(node);
                const left = item.start.delim || (item.display ? '$$' : '$');
                const right = item.end.delim || (item.display ? '$$' : '$');
                entries.push({node, text: left + item.math + right});
            }
        }
        if (!entries.length) return;
        mj.typesetClear(entries.map(entry => entry.node));
        for (const {node, text} of entries) node.replaceWith(node.ownerDocument.createTextNode(text));
    }

    // 解析前保护代码区域及已渲染公式。占位符中不包含指令文本,
    // 防止代码示例触发指令、提前结束外层块或与代码外的标记错误配对。
    // 解析后移回原节点,保留代码高亮、事件及 MathJax 的节点关联。
    function snapshotWithProtectedNodes(root) {
        const clone = root.cloneNode(true);
        const originalElements = Array.from(root.querySelectorAll('*'));
        const clonedElements = Array.from(clone.querySelectorAll('*'));
        const clones = new Map(originalElements.map((el, i) => [el, clonedElements[i]]));
        const selector = 'pre, code, kbd, samp, textarea, script, style, ' +
            '.cnblogs_code, .cnblogs_Highlighter, .syntaxhighlighter, ' +
            'mjx-container, .MathJax_Display, .MathJax_SVG_Display, ' +
            '.MathJax_CHTML_Display, .MJXc-display, .MathJax, .MathJax_SVG, .MathJax_CHTML';
        const candidates = Array.from(root.querySelectorAll(selector));
        const candidateSet = new Set(candidates);
        const nodes = candidates.filter(el => {
            for (let parent = el.parentElement; parent && parent !== root; parent = parent.parentElement) {
                if (candidateSet.has(parent)) return false;
            }
            return true;
        });
        // 仅操作克隆;如果只有代码示例而没有正文指令,原始 DOM 完全不变。
        const token = 'luogu-preserved-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
        nodes.forEach((el, i) => {
            const marker = root.ownerDocument.createElement('span');
            marker.setAttribute('data-luogu-preserved-token', token + '-' + i);
            clones.get(el).replaceWith(marker);
        });
        return {html: clone.innerHTML, nodes, token};
    }

    function bindFoldEvents(container) {
        container.querySelectorAll('.luogu-fold-block').forEach(block => {
            const titleEl = block.querySelector(':scope > .fold-title');
            if (!titleEl) return;
            if (titleEl.dataset.luoguBound === 'true') return;
            titleEl.dataset.luoguBound = 'true';
            titleEl.addEventListener('click', function(e) {
                e.stopPropagation();
                const wasOpen = block.classList.contains('open');
                block.classList.toggle('open');
                const contentEl = block.querySelector(':scope > .fold-content');
                if (contentEl) {
                    contentEl.classList.toggle('mathjax_ignore', wasOpen);
                    contentEl.classList.toggle('tex2jax_ignore', wasOpen);
                    if (!wasOpen) {
                        // 等浏览器应用展开后的布局后再测量,而不是仅重扫新公式。
                        requestAnimationFrame(() => typesetElement(contentEl, true));
                    }
                }
            });
        });
    }

    function findContentElement() {
        const contentSelectors = [
            '#cnblogs_post_body',
            '.postBody',
            '.post',
            '.blogpost-body',
            'article .entry-content',
            '.post-content',
            '#mainContent',
            '.article-content',
        ];

        for (const sel of contentSelectors) {
            const el = document.querySelector(sel);
            if (el) return el;
        }

        const iframes = document.querySelectorAll('iframe');
        for (const iframe of iframes) {
            try {
                const doc = iframe.contentDocument || iframe.contentWindow?.document;
                if (!doc) continue;
                for (const sel of contentSelectors) {
                    const el = doc.querySelector(sel);
                    if (el) return el;
                }
            } catch (e) {}
        }

        return null;
    }

    const processingRoots = new WeakSet();

    async function main() {
        const contentEl = findContentElement();

        if (!contentEl) {
            console.log('[洛谷渲染] 未找到文章内容区,跳过');
            return;
        }

        if (contentEl.dataset.luoguRendered === 'true' || processingRoots.has(contentEl)) {
            return;
        }

        processingRoots.add(contentEl);
        try {
            // 避免在 MathJax 首次排版尚未完成时替换文章 DOM。
            const startup = window.MathJax && window.MathJax.startup;
            if (startup && startup.promise) await startup.promise;
            if (!contentEl.isConnected) return;
            ensureMathJaxDollarSupport();

            const snapshot = snapshotWithProtectedNodes(contentEl);
            let processedHTML = preprocessHTML(snapshot.html);
            const levels = findColonLevels(processedHTML);
            if (!levels.length) return;
            const beforeParsing = processedHTML;
            processedHTML = parseEpigraph(processedHTML, levels);
            processedHTML = parseFoldBlocks(processedHTML, levels);
            processedHTML = parseAlignBlocks(processedHTML, levels);
            if (processedHTML === beforeParsing) return;

            const template = contentEl.ownerDocument.createElement('template');
            template.innerHTML = processedHTML;
            // 移回代码、高亮和公式的原节点,而不是通过 innerHTML 重新创建它们。
            snapshot.nodes.forEach((node, i) => {
                const marker = template.content.querySelector(
                    '[data-luogu-preserved-token="' + snapshot.token + '-' + i + '"]'
                );
                if (marker) marker.replaceWith(node);
            });
            contentEl.replaceChildren(template.content);
            contentEl.dataset.luoguRendered = 'true';
            bindFoldEvents(contentEl);
            // 初次只排版文章一次;不再同时全页排版和逐个展开块排版。
            typesetElement(contentEl, true);
            console.log('[洛谷渲染] 处理完成');
        } catch (err) {
            console.warn('[洛谷渲染] 处理失败:', err);
        } finally {
            processingRoots.delete(contentEl);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }

    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType === 1) {
                    const selectors = ['#cnblogs_post_body', '.postBody', 'article .entry-content', '.post'];
                    for (const sel of selectors) {
                        if (node.querySelector && (node.querySelector(sel) || (node.matches && node.matches(sel)))) {
                            setTimeout(main, 500);
                            return;
                        }
                    }
                }
            }
        }
    });

    observer.observe(document.body || document.documentElement, {
        childList: true,
        subtree: true
    });

})();
</script>


::::

注意要开通 JS 权限。

油猴插件版(原版)

本来以为页脚版用不了(事实上部分机器就是跑不起来),让 AI 写了个这个,下面也放一下源码。

::::success[代码内容]

// ==UserScript==
// @name         博客园文章 - 洛谷折叠块/特殊排版渲染
// @namespace    http://tampermonkey.net/
// @version      3.6
// @description  自动渲染博客园文章中的洛谷风格折叠框与特殊排版,兼容 SimpleMemory 主题,支持 MathJax 公式、嵌套折叠框、epigraph
// @author       Exfish
// @match        *://*.cnblogs.com/*
// @grant        GM_addStyle
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    const ICONS = {
        info: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.043 8 8 119.083 8 256c0 136.997 111.043 248 248 248s248-111.003 248-248C504 119.083 392.957 8 256 8zm0 110c23.196 0 42 18.804 42 42s-18.804 42-42 42-42-18.804-42-42 18.804-42 42-42zm56 254c0 6.627-5.373 12-12 12h-88c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h12v-64h-12c-6.627 0-12-5.373-12-12v-24c0-6.627 5.373-12 12-12h64c6.627 0 12 5.373 12 12v100h12c6.627 0 12 5.373 12 12v24z"/></svg>`,
        warning: `<svg viewBox="0 0 576 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M569.517 440.013C587.975 472.007 564.806 512 527.94 512H48.054c-36.937 0-59.999-40.055-41.577-71.987L246.423 23.985c18.467-32.009 64.72-31.951 83.154 0l239.94 416.028zM288 354c-25.405 0-46 20.595-46 46s20.595 46 46 46 46-20.595 46-46-20.595-46-46-46zm-43.673-165.346l7.418 136c.347 6.364 5.609 11.346 11.982 11.346h48.546c6.373 0 11.635-4.982 11.982-11.346l7.418-136c.375-6.874-5.098-12.654-11.982-12.654h-63.383c-6.884 0-12.356 5.78-11.981 12.654z"/></svg>`,
        success: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200zm90.729 158.343l-110.343 110.343c-4.686 4.686-12.284 4.686-16.971 0l-55.343-55.343c-4.686-4.686-4.686-12.284 0-16.971l22.627-22.627c4.686-4.686 12.284-4.686 16.971 0l24.686 24.686 79.686-79.686c4.686-4.686 12.284-4.686 16.971 0l22.627 22.627c4.686 4.686 4.686 12.284 0 16.971z"/></svg>`,
        error: `<svg viewBox="0 0 512 512" width="1em" height="1em" fill="currentColor" aria-hidden="true" focusable="false"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm121.6 313.1c4.7 4.7 4.7 12.3 0 17L338 377.6c-4.7 4.7-12.3 4.7-17 0L256 312l-65.1 65.6c-4.7 4.7-12.3 4.7-17 0L134.4 338c-4.7-4.7-4.7-12.3 0-17l65.6-65-65.6-65.1c-4.7-4.7-4.7-12.3 0-17l39.6-39.6c4.7-4.7 12.3-4.7 17 0l65 65.7 65.1-65.6c4.7-4.7 12.3-4.7 17 0l39.6 39.6c4.7 4.7 4.7 12.3 0 17L312 256l65.6 65.1z"/></svg>`
    };

    const CONFIG = {
        blockTypes: {
            info:     { color: '#3498db' },
            warning:  { color: '#ffc116' },
            success:  { color: '#52c41a' },
            error:    { color: '#e74c3c' },
        },
        alignTypes: {
            center: 'center',
            right: 'right',
            left: 'left',
        }
    };

    GM_addStyle(`
        /* ===== 折叠框 ===== */
        .luogu-fold-block {
            border: none;
            border-left: 4px solid #ccc;
            border-radius: 4px;
            margin: 0.8em 0;
            font-size: 14px;
            overflow: visible !important;
            transition: all 0.2s ease;
        }
        .luogu-fold-block .fold-title {
            padding: 4px 11px;
            cursor: pointer;
            font-weight: 600;
            display: flex;
            align-items: center;
            justify-content: space-between;
            user-select: none;
            background: transparent !important;
            gap: 4px;
        }
        .luogu-fold-block .fold-title .fold-title-text {
            flex: 1;
            min-width: 0;
            text-align: left;
            text-indent: 0 !important;
            line-height: 1.6;
            overflow-wrap: break-word;
            word-break: break-word;
        }
        .luogu-fold-block .fold-title .fold-icon {
            flex-shrink: 0;
            display: inline-flex;
            align-items: center;
            justify-content: center;
            width: 1.1em;
            height: 1.1em;
            line-height: 1;
        }
        .luogu-fold-block .fold-title .fold-icon svg {
            width: 100%;
            height: 100%;
            display: block;
        }
        .luogu-fold-block .fold-title .fold-title-text mjx-container,
        .luogu-fold-block .fold-title .fold-title-text .MathJax,
        .luogu-fold-block .fold-title .fold-title-text .MathJax_Display {
            text-align: left !important;
            margin: 0 !important;
            max-width: none !important;
            overflow: visible !important;
            /* 保留 MathJax 自身的行内公式基线,不统一设置 vertical-align。 */
        }
        /* 保留 MathJax 原生块级布局;不把公式容器改为 flex,
           不覆盖内部 SVG、mjx-math 的尺寸或基线。 */
        .luogu-fold-block > .fold-title > .fold-title-text mjx-container[display="true"],
        .luogu-fold-block > .fold-title > .fold-title-text .MathJax_Display,
        .luogu-fold-block > .fold-title > .fold-title-text .MathJax_SVG_Display,
        .luogu-fold-block > .fold-title > .fold-title-text .MathJax_CHTML_Display {
            margin: 0 !important;
            text-indent: 0 !important;
            text-align: left !important;
        }
        .luogu-fold-block .fold-title .fold-arrow {
            transition: transform 0.2s;
            font-size: 12px;
            opacity: 0.6;
            flex-shrink: 0;
            margin-left: 4px;
        }
        .luogu-fold-block.open > .fold-title .fold-arrow {
            transform: rotate(90deg);
        }
        .luogu-fold-block > .fold-content {
            /* 用小间距替代标题后由 Markdown 换行产生的整行 <br>。 */
            padding: 0.25em 11px 6px;
            overflow: visible !important;
            display: none !important;
            line-height: 1.6;
        }
        .luogu-fold-block.open > .fold-content {
            display: block !important;
        }
        /* 避免主题给正文首段的上边距再次撑出一个空行。 */
        .luogu-fold-block > .fold-content > :first-child {
            margin-top: 0 !important;
        }
        /* 只解除排版外层的裁剪,不修改公式内部布局与辅助 MathML。 */
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph),
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
        :is(.fold-title, .fold-title-text, .epigraph-content, .epigraph-line,
            .epigraph-author) {
            overflow: visible !important;
        }
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
        :is(mjx-container, .MathJax_Display, .MathJax_SVG_Display,
            .MathJax_CHTML_Display) {
            max-width: none !important;
            overflow: visible !important;
        }
        /* 行内公式必须以完整宽度参与文字排版。只设置最外层,
           不改分式/根号内部的定位,也不把公式压进主题的 inline-flex。 */
        :is(.luogu-fold-block, .luogu-align, .luogu-epigraph)
        :is(mjx-container:not([display="true"]), .MathJax, .MathJax_CHTML, .MathJax_SVG) {
            display: inline-block !important;
            width: max-content !important;
            min-width: max-content !important;
            max-width: none !important;
            float: none !important;
            white-space: nowrap !important;
            overflow: visible !important;
        }
        .luogu-fold-block .fold-content .luogu-fold-block {
            margin: 0.3em 0;
            font-size: 13px;
        }
        ${Object.entries(CONFIG.blockTypes).map(([type, style]) => `
            .luogu-fold-block.${type} {
                border-left-color: ${style.color} !important;
            }
            .luogu-fold-block.${type} > .fold-title {
                color: ${style.color};
                background: transparent !important;
            }
        `).join('')}

        /* ===== 对齐排版 ===== */
        .luogu-align {
            margin: 1em 0;
        }
        .luogu-align.center {
            text-align: center;
        }
        .luogu-align.center > * {
            margin-left: auto;
            margin-right: auto;
        }
        .luogu-align.right {
            text-align: right;
        }
        .luogu-align.right > * {
            margin-left: auto;
            margin-right: 0;
        }
        .luogu-align.left {
            text-align: left;
        }
        .luogu-align.left > * {
            margin-left: 0;
            margin-right: auto;
        }
        .luogu-align.center > p,
        .luogu-align.center > div,
        .luogu-align.center > h1,
        .luogu-align.center > h2,
        .luogu-align.center > h3,
        .luogu-align.center > h4,
        .luogu-align.center > h5,
        .luogu-align.center > h6 {
            text-align: center;
        }
        .luogu-align.right > p,
        .luogu-align.right > div,
        .luogu-align.right > h1,
        .luogu-align.right > h2,
        .luogu-align.right > h3,
        .luogu-align.right > h4,
        .luogu-align.right > h5,
        .luogu-align.right > h6 {
            text-align: right;
        }
        .luogu-align.left > p,
        .luogu-align.left > div,
        .luogu-align.left > h1,
        .luogu-align.left > h2,
        .luogu-align.left > h3,
        .luogu-align.left > h4,
        .luogu-align.left > h5,
        .luogu-align.left > h6 {
            text-align: left;
        }
        .luogu-align img {
            display: block !important;
        }
        .luogu-align.center img {
            margin-left: auto !important;
            margin-right: auto !important;
        }
        .luogu-align.right img {
            margin-left: auto !important;
            margin-right: 0 !important;
        }
        .luogu-align.left img {
            margin-left: 0 !important;
            margin-right: auto !important;
        }

        /* ===== epigraph ===== */
        .luogu-epigraph {
            margin: 1.2em 0;
            padding: 0.4em 0;
            font-size: 14px;
            color: inherit;
            opacity: 0.9;
        }
        .luogu-epigraph .epigraph-content {
            display: flex;
            flex-direction: column;
            align-items: flex-end;
            text-align: right;
            max-width: 20em;
            margin-left: auto;
            line-height: 1.7;
        }
        .luogu-epigraph .epigraph-content p {
            margin: 0;
            text-align: right;
        }
        .luogu-epigraph .epigraph-line {
            text-align: right;
        }
        .luogu-epigraph .epigraph-divider {
            width: 20em !important;
            max-width: 100% !important;
            height: 0 !important;
            border: none !important;
            border-top: 1px solid currentColor !important;
            opacity: 0.35;
            margin: 0.4em 0 0.4em auto !important;
            padding: 0 !important;
            background: transparent !important;
        }
        .luogu-epigraph .epigraph-author {
            text-align: right;
            color: inherit;
            opacity: 0.7;
            font-size: 13px;
            margin-left: auto;
            max-width: 20em;
        }
    `);

    // ========== MathJax 配置补全 ==========
    /**
     * 确保 MathJax v3 的 tex 配置里包含 $...$ 和 $$...$$
     * 博客园可能只配置了 \(...\),导致 $...$ 不被识别
     */
    function ensureMathJaxDollarSupport() {
        if (!window.MathJax) return false;
        if (window.MathJax.Hub && window.MathJax.Hub.Config) {
            const hub = window.MathJax.Hub;
            const config = hub.config.tex2jax || {};
            const inlineMath = (config.inlineMath || [['\\(', '\\)']]).slice();
            const displayMath = (config.displayMath || [['\\[', '\\]']]).slice();
            if (!inlineMath.some(p => p[0] === '$')) inlineMath.push(['$', '$']);
            if (!displayMath.some(p => p[0] === '$$')) displayMath.push(['$$', '$$']);
            hub.Config({tex2jax: {inlineMath, displayMath}});
            return true;
        }
        if (!window.MathJax.config) return false;
        const tex = window.MathJax.config.tex = window.MathJax.config.tex || {};

        tex.inlineMath = tex.inlineMath || [];
        if (!tex.inlineMath.some(p => p[0] === '$')) {
            tex.inlineMath.push(['$', '$']);
        }

        tex.displayMath = tex.displayMath || [];
        if (!tex.displayMath.some(p => p[0] === '$$')) {
            tex.displayMath.push(['$$', '$$']);
        }
        // 确保 \[...\] 也在
        if (!tex.displayMath.some(p => p[0] === '\\[')) {
            tex.displayMath.push(['\\[', '\\]']);
        }

        console.log('[洛谷渲染] MathJax inlineMath:', JSON.stringify(tex.inlineMath));
        console.log('[洛谷渲染] MathJax displayMath:', JSON.stringify(tex.displayMath));
        return true;
    }

    // ========== 预处理 ==========
    function preprocessHTML(html) {
        html = html.replace(/<p[^>]*>\s*(:{3,})/gi, '$1');
        html = html.replace(/(:{3,})\s*<\/p>/gi, '$1');
        html = html.replace(/<br\s*\/?>\s*(:{3,})/gi, '\n$1');
        html = html.replace(/(:{3,})\s*<br\s*\/?>/gi, '$1\n');
        // 此处处理的是 HTML 序列化字符串,而不是纯文本。
        // 必须保留 &lt;、&gt;、&amp;、&quot; 等实体,交给浏览器解析一次:
        // 若提前反转义,TeX 的 0<k 会被当成 HTML 标签,导致公式被吞掉;
        // 二次解码还会破坏原文中的字面量实体、属性和 aligned 的 &。
        html = html.replace(/&nbsp;/g, ' ')
                   .replace(/&colon;/g, ':');
        html = html.replace(/(:{3,})(\w+)\[([^\]]*)\]/g, (m, c, t, title) => {
            return c + t + '[' + title.trim() + ']';
        });
        return html;
    }

    function findColonLevels(text) {
        const levels = new Set();
        const re = /(:{3,})(?:\w+\[|align\{)/g;
        let m;
        while ((m = re.exec(text)) !== null) {
            levels.add(m[1].length);
        }
        return Array.from(levels).sort((a, b) => b - a);
    }

    function parseEpigraph(text, levels) {
        let result = text;

        for (const level of levels) {
            const colons = ':'.repeat(level);
            const regex = new RegExp(
                `(${colons})epigraph\\[([^\\]]*)\\]([\\s\\S]*?)\\1`,
                'g'
            );

            const matches = [];
            let match;
            while ((match = regex.exec(result)) !== null) {
                matches.push({
                    full: match[0],
                    colons: match[1],
                    author: match[2],
                    content: match[3],
                    index: match.index
                });
            }

            for (let i = matches.length - 1; i >= 0; i--) {
                const m = matches[i];
                let content = m.content.trim();
                content = content.replace(/<\/p>\s*<p>/g, '\n');
                content = content.replace(/^<p>/, '').replace(/<\/p>$/, '');

                const lines = content.split('\n').filter(l => l.trim() !== '');
                const contentHTML = lines.map(l => `<div class="epigraph-line">${l}</div>`).join('');

                const html = `<div class="luogu-epigraph">
                    <div class="epigraph-content">${contentHTML}</div>
                    <div class="epigraph-divider"></div>
                    <div class="epigraph-author">${m.author}</div>
                </div>`;

                result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
            }
        }

        return result;
    }

    function cleanFoldContent(content) {
        let html = content.trim();
        // 标记独占段落时,预处理会留下段落边界;避免浏览器修复成空段落。
        html = html.replace(/^<\/p\s*>\s*/i, '')
                   .replace(/\s*<p\b[^>]*>$/i, '');
        // 只移除紧邻起始标记的一个换行,保留正文内及用户额外添加的换行。
        html = html.replace(/^<br\b[^>]*>\s*/i, '');
        return html.trim();
    }

    function parseFoldBlocks(text, levels) {
        let result = text;

        for (const level of levels) {
            const colons = ':'.repeat(level);
            const regex = new RegExp(
                `(${colons})(\\w+)\\[([^\\]]*)\\](?:\\{(\\w+)\\})?([\\s\\S]*?)\\1`,
                'g'
            );

            const matches = [];
            let match;
            while ((match = regex.exec(result)) !== null) {
                matches.push({
                    full: match[0],
                    colons: match[1],
                    type: match[2],
                    title: match[3],
                    option: match[4],
                    content: match[5],
                    index: match.index
                });
            }

            for (let i = matches.length - 1; i >= 0; i--) {
                const m = matches[i];
                const typeClass = CONFIG.blockTypes[m.type] ? m.type : 'info';
                const openClass = m.option === 'open' ? ' open' : '';
                const arrow = '▶';
                const icon = ICONS[typeClass] || ICONS.info;

                const html = `<div class="luogu-fold-block ${typeClass}${openClass}" data-luogu-type="${m.type}" data-luogu-depth="${level}">
                    <div class="fold-title">
                        <span class="fold-icon">${icon}</span>
                        <span class="fold-title-text">${m.title}</span>
                        <span class="fold-arrow">${arrow}</span>
                    </div>
                    <div class="fold-content${m.option === 'open' ? '' : ' mathjax_ignore tex2jax_ignore'}">${cleanFoldContent(m.content)}</div>
                </div>`;

                result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
            }
        }

        return result;
    }

    function parseAlignBlocks(text, levels) {
        let result = text;

        for (const level of levels) {
            const colons = ':'.repeat(level);
            const regex = new RegExp(
                `(${colons})align\\{(\\w+)\\}([\\s\\S]*?)\\1`,
                'g'
            );

            const matches = [];
            let match;
            while ((match = regex.exec(result)) !== null) {
                matches.push({
                    full: match[0],
                    colons: match[1],
                    alignType: match[2],
                    content: match[3],
                    index: match.index
                });
            }

            for (let i = matches.length - 1; i >= 0; i--) {
                const m = matches[i];
                const alignClass = CONFIG.alignTypes[m.alignType] ? m.alignType : 'center';
                const html = `<div class="luogu-align ${alignClass}">${m.content.trim()}</div>`;
                result = result.slice(0, m.index) + html + result.slice(m.index + m.full.length);
            }
        }

        return result;
    }

    // 一个队列处理本插件的所有排版任务,避免 v3 的 typesetPromise 并发。
    let mathQueue = Promise.resolve();
    let mathRetryTimer = null;
    const pendingMathRoots = new Set();
    const pendingRefreshRoots = new Set();

    function typesetElement(el, refresh = false) {
        if (!el || !el.isConnected) return;
        if (refresh) pendingRefreshRoots.add(el);
        for (const root of pendingMathRoots) {
            if (root === el || root.contains(el)) return;
            if (el.contains(root)) pendingMathRoots.delete(root);
        }
        pendingMathRoots.add(el);
        scheduleMath();
    }

    function scheduleMath(attempt = 0) {
        if (mathRetryTimer !== null) return;
        mathRetryTimer = setTimeout(() => {
            mathRetryTimer = null;
            const mj = window.MathJax;
            const isV2 = mj && mj.Hub && typeof mj.Hub.Queue === 'function';
            if (!mj || (!isV2 && typeof mj.typesetPromise !== 'function')) {
                // 最多等待 30 秒,避免页面未加载 MathJax 时无限轮询。
                if (attempt < 100) scheduleMath(attempt + 1);
                else {
                    pendingMathRoots.clear();
                    pendingRefreshRoots.clear();
                    console.warn('[洛谷渲染] 未检测到可用的 MathJax');
                }
                return;
            }
            mathQueue = mathQueue.then(async () => {
                if (mj.startup && mj.startup.promise) await mj.startup.promise;
                ensureMathJaxDollarSupport();
                const roots = Array.from(pendingMathRoots).filter(el => el.isConnected);
                pendingMathRoots.clear();
                const refreshRoots = Array.from(pendingRefreshRoots).filter(el => el.isConnected);
                pendingRefreshRoots.clear();
                if (!roots.length) return;
                if (isV2) {
                    // v2 的 Typeset 不会重算已存在公式;展开后必须 Rerender。
                    for (const root of roots) {
                        await new Promise(resolve => mj.Hub.Queue(['Typeset', mj.Hub, root], resolve));
                    }
                    const rerenderSources = new Set();
                    for (const root of refreshRoots) {
                        const items = mj.Hub.getAllJax(root).filter(jax => {
                            const source = jax.SourceElement();
                            return source && !source.closest('.fold-content.mathjax_ignore');
                        });
                        items.forEach(jax => rerenderSources.add(jax.SourceElement()));
                    }
                    if (rerenderSources.size) await new Promise(resolve => mj.Hub.Queue(
                        ['Rerender', mj.Hub, Array.from(rerenderSources)], resolve
                    ));
                } else {
                    refreshRegisteredMath(mj, refreshRoots);
                    await mj.typesetPromise(roots);
                }
            }).catch(err => {
                console.warn('[洛谷渲染] MathJax typeset 失败:', err);
            });
        }, attempt ? 300 : 0);
    }

    // typesetPromise() 只查找新公式,不会重算已渲染公式的尺寸。
    // 将本插件范围内的 TeX 从 MathJax 注册表安全还原,再在可见布局中排版。
    function refreshRegisteredMath(mj, roots) {
        if (typeof mj.getMathItemsWithin !== 'function' || typeof mj.typesetClear !== 'function') return;
        const seen = new Set();
        const entries = [];
        for (const root of roots) {
            for (const item of mj.getMathItemsWithin([root])) {
                const node = item.typesetRoot;
                if (!node || !node.isConnected || seen.has(node)) continue;
                if (!node.closest('.luogu-fold-block, .luogu-align, .luogu-epigraph')) continue;
                // 闭合的嵌套块等其自身展开时再测量,不能在 display:none 下重排。
                if (node.closest('.fold-content.mathjax_ignore')) continue;
                if (!item.inputJax || item.inputJax.name !== 'TeX') continue;
                seen.add(node);
                const left = item.start.delim || (item.display ? '$$' : '$');
                const right = item.end.delim || (item.display ? '$$' : '$');
                entries.push({node, text: left + item.math + right});
            }
        }
        if (!entries.length) return;
        mj.typesetClear(entries.map(entry => entry.node));
        for (const {node, text} of entries) node.replaceWith(node.ownerDocument.createTextNode(text));
    }

    // 解析前保护代码区域及已渲染公式。占位符中不包含指令文本,
    // 防止代码示例触发指令、提前结束外层块或与代码外的标记错误配对。
    // 解析后移回原节点,保留代码高亮、事件及 MathJax 的节点关联。
    function snapshotWithProtectedNodes(root) {
        const clone = root.cloneNode(true);
        const originalElements = Array.from(root.querySelectorAll('*'));
        const clonedElements = Array.from(clone.querySelectorAll('*'));
        const clones = new Map(originalElements.map((el, i) => [el, clonedElements[i]]));
        const selector = 'pre, code, kbd, samp, textarea, script, style, ' +
            '.cnblogs_code, .cnblogs_Highlighter, .syntaxhighlighter, ' +
            'mjx-container, .MathJax_Display, .MathJax_SVG_Display, ' +
            '.MathJax_CHTML_Display, .MJXc-display, .MathJax, .MathJax_SVG, .MathJax_CHTML';
        const candidates = Array.from(root.querySelectorAll(selector));
        const candidateSet = new Set(candidates);
        const nodes = candidates.filter(el => {
            for (let parent = el.parentElement; parent && parent !== root; parent = parent.parentElement) {
                if (candidateSet.has(parent)) return false;
            }
            return true;
        });
        // 仅操作克隆;如果只有代码示例而没有正文指令,原始 DOM 完全不变。
        const token = 'luogu-preserved-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2);
        nodes.forEach((el, i) => {
            const marker = root.ownerDocument.createElement('span');
            marker.setAttribute('data-luogu-preserved-token', token + '-' + i);
            clones.get(el).replaceWith(marker);
        });
        return {html: clone.innerHTML, nodes, token};
    }

    function bindFoldEvents(container) {
        container.querySelectorAll('.luogu-fold-block').forEach(block => {
            const titleEl = block.querySelector(':scope > .fold-title');
            if (!titleEl) return;
            if (titleEl.dataset.luoguBound === 'true') return;
            titleEl.dataset.luoguBound = 'true';
            titleEl.addEventListener('click', function(e) {
                e.stopPropagation();
                const wasOpen = block.classList.contains('open');
                block.classList.toggle('open');
                const contentEl = block.querySelector(':scope > .fold-content');
                if (contentEl) {
                    contentEl.classList.toggle('mathjax_ignore', wasOpen);
                    contentEl.classList.toggle('tex2jax_ignore', wasOpen);
                    if (!wasOpen) {
                        // 等浏览器应用展开后的布局后再测量,而不是仅重扫新公式。
                        requestAnimationFrame(() => typesetElement(contentEl, true));
                    }
                }
            });
        });
    }

    function findContentElement() {
        const contentSelectors = [
            '#cnblogs_post_body',
            '.postBody',
            '.post',
            '.blogpost-body',
            'article .entry-content',
            '.post-content',
            '#mainContent',
            '.article-content',
        ];

        for (const sel of contentSelectors) {
            const el = document.querySelector(sel);
            if (el) return el;
        }

        const iframes = document.querySelectorAll('iframe');
        for (const iframe of iframes) {
            try {
                const doc = iframe.contentDocument || iframe.contentWindow?.document;
                if (!doc) continue;
                for (const sel of contentSelectors) {
                    const el = doc.querySelector(sel);
                    if (el) return el;
                }
            } catch (e) {}
        }

        return null;
    }

    const processingRoots = new WeakSet();

    async function main() {
        const contentEl = findContentElement();

        if (!contentEl) {
            console.log('[洛谷渲染] 未找到文章内容区,跳过');
            return;
        }

        if (contentEl.dataset.luoguRendered === 'true' || processingRoots.has(contentEl)) {
            return;
        }

        processingRoots.add(contentEl);
        try {
            // 避免在 MathJax 首次排版尚未完成时替换文章 DOM。
            const startup = window.MathJax && window.MathJax.startup;
            if (startup && startup.promise) await startup.promise;
            if (!contentEl.isConnected) return;
            ensureMathJaxDollarSupport();

            const snapshot = snapshotWithProtectedNodes(contentEl);
            let processedHTML = preprocessHTML(snapshot.html);
            const levels = findColonLevels(processedHTML);
            if (!levels.length) return;
            const beforeParsing = processedHTML;
            processedHTML = parseEpigraph(processedHTML, levels);
            processedHTML = parseFoldBlocks(processedHTML, levels);
            processedHTML = parseAlignBlocks(processedHTML, levels);
            if (processedHTML === beforeParsing) return;

            const template = contentEl.ownerDocument.createElement('template');
            template.innerHTML = processedHTML;
            // 移回代码、高亮和公式的原节点,而不是通过 innerHTML 重新创建它们。
            snapshot.nodes.forEach((node, i) => {
                const marker = template.content.querySelector(
                    '[data-luogu-preserved-token="' + snapshot.token + '-' + i + '"]'
                );
                if (marker) marker.replaceWith(node);
            });
            contentEl.replaceChildren(template.content);
            contentEl.dataset.luoguRendered = 'true';
            bindFoldEvents(contentEl);
            // 初次只排版文章一次;不再同时全页排版和逐个展开块排版。
            typesetElement(contentEl, true);
            console.log('[洛谷渲染] 处理完成');
        } catch (err) {
            console.warn('[洛谷渲染] 处理失败:', err);
        } finally {
            processingRoots.delete(contentEl);
        }
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', main);
    } else {
        main();
    }

    const observer = new MutationObserver((mutations) => {
        for (const mutation of mutations) {
            for (const node of mutation.addedNodes) {
                if (node.nodeType === 1) {
                    const selectors = ['#cnblogs_post_body', '.postBody', 'article .entry-content', '.post'];
                    for (const sel of selectors) {
                        if (node.querySelector && (node.querySelector(sel) || (node.matches && node.matches(sel)))) {
                            setTimeout(main, 500);
                            return;
                        }
                    }
                }
            }
        }
    });

    observer.observe(document.body, {
        childList: true,
        subtree: true
    });

})();

::::

功能简述

目前能使用的语法:这里可以测试。

  1. 排版功能。:::align{center} 以及 :::align{right} 支持。
  2. CF 风格 epigraph 功能。:::epigraph[发布者] 功能支持。
  3. 折叠块功能(主要功能)。::::info[标题] 以及 ::::info[标题]{open} 支持,嵌套支持,数学公式显示(::::success[$$\displaystyle\sum_{i = 1}^n \sum_{j = 1}^n \gcd(i, j)$$])支持。

折叠深度限制:未知,理论无限制,但是请注意过多嵌套对性能的影响(测试页面那里是 \(13\) 层,我记得愚人节那天洛谷那个题功能是 \(11\) 层来着)。

问题反馈

如果有问题可以在讨论区内回复,或到博客园内这里的评论区反馈。欢迎私信咨询。


https://www.luogu.com.cn/article/ytc9cv9d

posted @ 2026-10-12 14:31  Ivan422  阅读(4)  评论(0)    收藏  举报