代码块优化,增加aardio、R语言代码高亮等
This commit is contained in:
71
frontend/src/utils/codeBlockHighlightPlugin.ts
Normal file
71
frontend/src/utils/codeBlockHighlightPlugin.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state';
|
||||
import { Decoration, DecorationSet } from '@tiptap/pm/view';
|
||||
import { registerAardioLanguage } from './hljsAardio';
|
||||
|
||||
registerAardioLanguage(hljs);
|
||||
|
||||
const key = new PluginKey('articleCodeBlockHighlight');
|
||||
|
||||
/** 将 hljs HTML 映射为 ProseMirror inline Decoration */
|
||||
function decorationsForCodeBlock(node: ProseMirrorNode, pos: number): Decoration[] {
|
||||
const language = ((node.attrs.language as string) || '').trim();
|
||||
const text = node.textContent;
|
||||
if (!text || !language || !hljs.getLanguage(language)) return [];
|
||||
|
||||
let html: string;
|
||||
try {
|
||||
html = hljs.highlight(text, { language, ignoreIllegals: true }).value;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const root = document.createElement('div');
|
||||
root.innerHTML = html;
|
||||
const out: Decoration[] = [];
|
||||
let offset = 0;
|
||||
|
||||
const walk = (dom: Node, className: string | null) => {
|
||||
if (dom.nodeType === Node.TEXT_NODE) {
|
||||
const len = dom.textContent?.length ?? 0;
|
||||
if (len > 0 && className) {
|
||||
const from = pos + 1 + offset;
|
||||
out.push(Decoration.inline(from, from + len, { class: className }));
|
||||
}
|
||||
offset += len;
|
||||
return;
|
||||
}
|
||||
if (dom.nodeType !== Node.ELEMENT_NODE) return;
|
||||
const el = dom as Element;
|
||||
const nextClass = el.className || className;
|
||||
el.childNodes.forEach(child => walk(child, nextClass));
|
||||
};
|
||||
|
||||
root.childNodes.forEach(child => walk(child, null));
|
||||
return out;
|
||||
}
|
||||
|
||||
function buildDecorations(doc: ProseMirrorNode): DecorationSet {
|
||||
const decos: Decoration[] = [];
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name !== 'codeBlock') return true;
|
||||
decos.push(...decorationsForCodeBlock(node, pos));
|
||||
return false;
|
||||
});
|
||||
return DecorationSet.create(doc, decos);
|
||||
}
|
||||
|
||||
/** 富文本代码块:用 Decoration 叠 hljs 着色,保持可编辑纯文本 */
|
||||
export function createCodeBlockHighlightPlugin(): Plugin {
|
||||
return new Plugin({
|
||||
key,
|
||||
state: {
|
||||
init: (_, state) => buildDecorations(state.doc),
|
||||
apply: (tr, old) => (tr.docChanged ? buildDecorations(tr.doc) : old),
|
||||
},
|
||||
props: {
|
||||
decorations: state => key.getState(state),
|
||||
},
|
||||
});
|
||||
}
|
||||
125
frontend/src/utils/codeBlockOptions.ts
Normal file
125
frontend/src/utils/codeBlockOptions.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
/** 插入 / 编辑代码块时的选项(外观随站点亮/暗主题自动切换,不可手选) */
|
||||
export interface CodeBlockInsertOptions {
|
||||
/** 语言标识,空字符串表示纯文本 */
|
||||
language: string;
|
||||
lineNumbers: boolean;
|
||||
/** 阅读态默认折叠(仅 ≥5 行时显示折叠按钮) */
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
/** 常用语言(与 highlight.js/lib/common 对齐,并保留若干手写标签) */
|
||||
export const CODE_BLOCK_LANGUAGES: { id: string; label: string }[] = [
|
||||
{ id: '', label: '纯文本' },
|
||||
{ id: 'aardio', label: 'aardio' },
|
||||
{ id: 'javascript', label: 'JavaScript' },
|
||||
{ id: 'typescript', label: 'TypeScript' },
|
||||
{ id: 'python', label: 'Python' },
|
||||
{ id: 'r', label: 'R' },
|
||||
{ id: 'go', label: 'Go' },
|
||||
{ id: 'rust', label: 'Rust' },
|
||||
{ id: 'java', label: 'Java' },
|
||||
{ id: 'c', label: 'C' },
|
||||
{ id: 'cpp', label: 'C++' },
|
||||
{ id: 'csharp', label: 'C#' },
|
||||
{ id: 'php', label: 'PHP' },
|
||||
{ id: 'ruby', label: 'Ruby' },
|
||||
{ id: 'swift', label: 'Swift' },
|
||||
{ id: 'kotlin', label: 'Kotlin' },
|
||||
{ id: 'sql', label: 'SQL' },
|
||||
{ id: 'bash', label: 'Bash / Shell' },
|
||||
{ id: 'powershell', label: 'PowerShell' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'yaml', label: 'YAML' },
|
||||
{ id: 'xml', label: 'XML / HTML' },
|
||||
{ id: 'css', label: 'CSS' },
|
||||
{ id: 'scss', label: 'SCSS' },
|
||||
{ id: 'markdown', label: 'Markdown' },
|
||||
{ id: 'dockerfile', label: 'Dockerfile' },
|
||||
{ id: 'nginx', label: 'Nginx' },
|
||||
];
|
||||
|
||||
export const DEFAULT_CODE_BLOCK_OPTIONS: CodeBlockInsertOptions = {
|
||||
language: '',
|
||||
lineNumbers: false,
|
||||
collapsed: false,
|
||||
};
|
||||
|
||||
const PREFS_KEY = 'j13-codeblock-prefs';
|
||||
|
||||
/** 读取上次插入偏好(不含语言,避免误带到无关帖子) */
|
||||
export function loadCodeBlockPrefs(): Pick<CodeBlockInsertOptions, 'lineNumbers' | 'collapsed'> {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PREFS_KEY);
|
||||
if (!raw) return { lineNumbers: false, collapsed: false };
|
||||
const parsed = JSON.parse(raw) as Partial<CodeBlockInsertOptions>;
|
||||
return {
|
||||
lineNumbers: Boolean(parsed.lineNumbers),
|
||||
collapsed: Boolean(parsed.collapsed),
|
||||
};
|
||||
} catch {
|
||||
return { lineNumbers: false, collapsed: false };
|
||||
}
|
||||
}
|
||||
|
||||
/** 记住展示相关偏好 */
|
||||
export function saveCodeBlockPrefs(opts: CodeBlockInsertOptions): void {
|
||||
try {
|
||||
sessionStorage.setItem(
|
||||
PREFS_KEY,
|
||||
JSON.stringify({
|
||||
lineNumbers: opts.lineNumbers,
|
||||
collapsed: opts.collapsed,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* 隐私模式等忽略 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否为非默认展示选项 */
|
||||
export function hasNonDefaultCodeBlockDisplay(opts: {
|
||||
lineNumbers?: boolean;
|
||||
collapsed?: boolean;
|
||||
}): boolean {
|
||||
return Boolean(opts.lineNumbers) || Boolean(opts.collapsed);
|
||||
}
|
||||
|
||||
/** 解析围栏信息串:`js lines collapsed`(兼容旧的 style=*,解析时忽略) */
|
||||
export function parseFenceInfo(info: string): CodeBlockInsertOptions {
|
||||
const parts = info.trim().split(/\s+/).filter(Boolean);
|
||||
let language = '';
|
||||
let lineNumbers = false;
|
||||
let collapsed = false;
|
||||
|
||||
for (const part of parts) {
|
||||
const lower = part.toLowerCase();
|
||||
if (lower === 'lines' || lower === 'line-numbers') {
|
||||
lineNumbers = true;
|
||||
continue;
|
||||
}
|
||||
if (lower === 'collapsed' || lower === 'fold') {
|
||||
collapsed = true;
|
||||
continue;
|
||||
}
|
||||
// 旧版 style=light|soft|dark:忽略,外观由站点主题决定
|
||||
if (lower.startsWith('style=')) {
|
||||
continue;
|
||||
}
|
||||
// 首个非 flag 片段视为语言
|
||||
if (!language && !lower.includes('=')) {
|
||||
language = lower;
|
||||
}
|
||||
}
|
||||
|
||||
return { language, lineNumbers, collapsed };
|
||||
}
|
||||
|
||||
/** 生成围栏信息串(便于手写) */
|
||||
export function formatFenceInfo(opts: Pick<CodeBlockInsertOptions, 'language' | 'lineNumbers' | 'collapsed'>): string {
|
||||
const parts: string[] = [];
|
||||
const lang = (opts.language || '').trim().toLowerCase();
|
||||
if (lang) parts.push(lang);
|
||||
if (opts.lineNumbers) parts.push('lines');
|
||||
if (opts.collapsed) parts.push('collapsed');
|
||||
return parts.join(' ');
|
||||
}
|
||||
@@ -1,4 +1,16 @@
|
||||
import hljs from 'highlight.js/lib/common';
|
||||
import { registerAardioLanguage } from './hljsAardio';
|
||||
|
||||
registerAardioLanguage(hljs);
|
||||
|
||||
/** 至少这么多行才显示折叠按钮(与默认折叠裁切高度一致) */
|
||||
const CODE_FOLD_MIN_LINES = 5;
|
||||
|
||||
/** 预览头栏语言标签:aardio 保持全小写,其余沿用检测结果 */
|
||||
function formatLangLabel(lang: string): string {
|
||||
if (lang === 'aardio') return 'aardio';
|
||||
return lang;
|
||||
}
|
||||
|
||||
/** 从 class / data-lang 中解析作者标注的语言标识 */
|
||||
function detectLang(...els: Element[]): string {
|
||||
@@ -20,55 +32,110 @@ function escapeHtml(s: string): string {
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(加语言标签与复制按钮) */
|
||||
/** 读取作者配置的展示选项(写在 pre 上;外观由站点主题决定) */
|
||||
function readDisplayOptions(pre: Element) {
|
||||
return {
|
||||
lineNumbers: pre.getAttribute('data-line-numbers') === 'true',
|
||||
collapsed: pre.getAttribute('data-collapsed') === 'true',
|
||||
};
|
||||
}
|
||||
|
||||
/** 为高亮后的 HTML 按行包一层,便于行号与折叠计数 */
|
||||
function wrapCodeLines(highlightedHtml: string, withLineNumbers: boolean): string {
|
||||
const lines = highlightedHtml.split('\n');
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
const num = i + 1;
|
||||
const body = line.length > 0 ? line : '';
|
||||
if (withLineNumbers) {
|
||||
return `<span class="md-code-line"><span class="md-code-line__num" aria-hidden="true">${num}</span><span class="md-code-line__body">${body}</span></span>`;
|
||||
}
|
||||
return `<span class="md-code-line"><span class="md-code-line__body">${body}</span></span>`;
|
||||
})
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** 美化并高亮文档中的代码块(语言标签、复制、行号、折叠;外观跟主题) */
|
||||
export function enhanceCodeBlocks(root: ParentNode): void {
|
||||
root.querySelectorAll('pre').forEach(pre => {
|
||||
if (pre.closest('.md-codeblock')) return;
|
||||
const code = pre.querySelector('code') || pre;
|
||||
const raw = code.textContent || '';
|
||||
const display = readDisplayOptions(pre);
|
||||
|
||||
// 作者写了语言标签则以标注为准,绝不被自动识别覆盖
|
||||
const declaredLang = detectLang(code, pre);
|
||||
let label = declaredLang || 'code';
|
||||
let label = formatLangLabel(declaredLang || 'code');
|
||||
|
||||
try {
|
||||
if (declaredLang && hljs.getLanguage(declaredLang)) {
|
||||
const result = hljs.highlight(raw, { language: declaredLang, ignoreIllegals: true });
|
||||
code.innerHTML = result.value;
|
||||
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (declaredLang) {
|
||||
// 未收录语言(如 aardio):保留原文与标签,不做自动猜测
|
||||
// 未收录语言:保留原文与标签,不做自动猜测
|
||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||
code.classList.add('hljs', `language-${declaredLang}`);
|
||||
} else if (raw.length >= 24) {
|
||||
const result = hljs.highlightAuto(raw);
|
||||
code.innerHTML = result.value;
|
||||
code.innerHTML = wrapCodeLines(result.value, display.lineNumbers);
|
||||
code.classList.add('hljs');
|
||||
if (result.language) {
|
||||
label = result.language;
|
||||
label = formatLangLabel(result.language);
|
||||
code.classList.add(`language-${result.language}`);
|
||||
}
|
||||
} else {
|
||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||
code.classList.add('hljs');
|
||||
}
|
||||
} catch {
|
||||
code.textContent = raw;
|
||||
code.innerHTML = wrapCodeLines(escapeHtml(raw), display.lineNumbers);
|
||||
code.classList.add('hljs');
|
||||
if (declaredLang) code.classList.add(`language-${declaredLang}`);
|
||||
}
|
||||
|
||||
if (display.lineNumbers) {
|
||||
code.classList.add('md-code--lines');
|
||||
}
|
||||
|
||||
const lineCount = raw === '' ? 1 : raw.split('\n').length;
|
||||
const canFold = lineCount >= CODE_FOLD_MIN_LINES;
|
||||
const initiallyCollapsed = display.collapsed && canFold;
|
||||
const wrap = pre.ownerDocument.createElement('div');
|
||||
wrap.className = 'md-codeblock';
|
||||
wrap.className = [
|
||||
'md-codeblock',
|
||||
display.lineNumbers ? 'md-codeblock--lines' : '',
|
||||
initiallyCollapsed ? 'md-codeblock--collapsed' : '',
|
||||
initiallyCollapsed && lineCount <= CODE_FOLD_MIN_LINES ? 'md-codeblock--short' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
wrap.setAttribute('data-lang', label);
|
||||
if (display.lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
|
||||
if (display.collapsed) wrap.setAttribute('data-collapsed', 'true');
|
||||
wrap.setAttribute('data-line-count', String(lineCount));
|
||||
// 行号列宽按最大位数自适应(用 data-*,避免 style 被消毒剥掉)
|
||||
if (display.lineNumbers) {
|
||||
wrap.setAttribute('data-lineno-digits', String(String(lineCount).length));
|
||||
}
|
||||
|
||||
const head = pre.ownerDocument.createElement('div');
|
||||
head.className = 'md-codeblock__head';
|
||||
|
||||
const actions: string[] = [];
|
||||
if (canFold) {
|
||||
const foldLabel = initiallyCollapsed ? '展开' : '收起';
|
||||
actions.push(`<button type="button" class="md-codeblock__fold" data-code-fold>${foldLabel}</button>`);
|
||||
}
|
||||
actions.push('<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>');
|
||||
|
||||
head.innerHTML = `
|
||||
<span class="md-codeblock__lang">${escapeHtml(label)}</span>
|
||||
<button type="button" class="md-codeblock__copy" data-code-copy>复制</button>
|
||||
<span class="md-codeblock__actions">${actions.join('')}</span>
|
||||
`;
|
||||
|
||||
pre.parentNode?.insertBefore(wrap, pre);
|
||||
wrap.appendChild(head);
|
||||
wrap.appendChild(pre);
|
||||
pre.classList.add('md-codeblock__pre');
|
||||
if (display.lineNumbers) pre.classList.add('md-codeblock__pre--lines');
|
||||
});
|
||||
}
|
||||
|
||||
110
frontend/src/utils/hljsAardio.ts
Normal file
110
frontend/src/utils/hljsAardio.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { HLJSApi, Language } from 'highlight.js';
|
||||
|
||||
/**
|
||||
* aardio 语法高亮(对齐官方文档 Prism 定义):
|
||||
* https://www.aardio.com/zh-cn/docs/js/prism.js
|
||||
*/
|
||||
export function aardioLanguage(hljs: HLJSApi): Language {
|
||||
const KEYWORD_RE = (
|
||||
'begin|end|if|lambda|λ|else|elseif|class|function|return|while|do|namespace|'
|
||||
+ 'select|case|catch|try|for|in|this|global|self|owner|var|def|null|and|not|or|'
|
||||
+ 'break|continue|import|with|ctor|eval|type|assert|assert2|assertf|error|rget|'
|
||||
+ 'callex|errput|loadcode|dumpcode|collectgarbage|call|invoke|tostring|topointer|'
|
||||
+ 'tonumber|sleep|execute|setlocale|setprivilege|loadcodex|reduce|switch|'
|
||||
+ 'true|false'
|
||||
);
|
||||
|
||||
const KEYWORDS = {
|
||||
keyword:
|
||||
'begin end if lambda λ else elseif class function return while do namespace '
|
||||
+ 'select case catch try for in this global self owner var def and not or '
|
||||
+ 'break continue import with ctor eval type assert assert2 assertf error '
|
||||
+ 'rget callex errput loadcode dumpcode collectgarbage call invoke tostring '
|
||||
+ 'topointer tonumber sleep execute setlocale setprivilege loadcodex reduce switch',
|
||||
literal: 'true false null',
|
||||
};
|
||||
|
||||
return {
|
||||
name: 'aardio',
|
||||
aliases: ['aardio'],
|
||||
keywords: KEYWORDS,
|
||||
illegal: /<\//,
|
||||
contains: [
|
||||
// /*...*/ 与 /**...**/ 等成对星号注释(宽松匹配)
|
||||
{
|
||||
className: 'comment',
|
||||
begin: /\/\*+/,
|
||||
end: /\*+\//,
|
||||
contains: ['self'],
|
||||
},
|
||||
hljs.C_LINE_COMMENT_MODE,
|
||||
// "..."("" 转义)
|
||||
{
|
||||
className: 'string',
|
||||
begin: '"',
|
||||
end: '"',
|
||||
contains: [{ begin: /""/ }],
|
||||
},
|
||||
// `...`(`` 转义)
|
||||
{
|
||||
className: 'string',
|
||||
begin: '`',
|
||||
end: '`',
|
||||
contains: [{ begin: /``/ }],
|
||||
},
|
||||
// '...'(反斜杠转义)
|
||||
{
|
||||
className: 'string',
|
||||
begin: "'",
|
||||
end: "'",
|
||||
contains: [hljs.BACKSLASH_ESCAPE],
|
||||
},
|
||||
// _CONST 常量
|
||||
{
|
||||
className: 'literal',
|
||||
begin: /\b_[A-Za-z]\w*\b/,
|
||||
},
|
||||
// class Name
|
||||
{
|
||||
beginKeywords: 'class',
|
||||
end: /(?=[{;])/,
|
||||
contains: [
|
||||
{
|
||||
className: 'title',
|
||||
begin: /[\w.\\]+/,
|
||||
relevance: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
// 函数调用名(排除关键字,避免 if( 被当成函数)
|
||||
{
|
||||
className: 'title function_',
|
||||
begin: new RegExp(
|
||||
String.raw`\b(?!(?:${KEYWORD_RE})\b)[_$a-zA-Z\u00A0-\uFFFF][$\w\u00A0-\uFFFF]*(?=\s*\()`,
|
||||
),
|
||||
relevance: 0,
|
||||
},
|
||||
// 数字(十六进制 / 进制字面量 / 小数 / 科学计数)
|
||||
{
|
||||
className: 'number',
|
||||
relevance: 0,
|
||||
variants: [
|
||||
{ begin: /\b0x[a-fA-F_\d]+(?:\.[a-fA-F_\d]*)?(?:p[+-]?\d[_\d]*)?\b/i },
|
||||
{ begin: /\b\d+#[\d_]+\b/ },
|
||||
{ begin: /\b\d[_\d]*(?:\.\B|(?:\.[_\d]*)?(?:e[+-]?\d[_\d]*)?\b)/i },
|
||||
{ begin: /\B\.\d[_\d]*(?:e[+-]?\d[_\d]*)?\b/i },
|
||||
],
|
||||
},
|
||||
{
|
||||
className: 'operator',
|
||||
begin: /\.\.|--|\+\+|\*\*=?|&&=?|\|\|=?|[!=]==|<<=?|>>>?=?|[-+*/%&|^!=<>]=?|[~:?]/,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** 向 highlight.js 注册 aardio(可重复调用) */
|
||||
export function registerAardioLanguage(hljs: HLJSApi): void {
|
||||
if (hljs.getLanguage('aardio')) return;
|
||||
hljs.registerLanguage('aardio', aardioLanguage);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import TurndownService from 'turndown';
|
||||
import { marked } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { POST_CONTENT_PURIFY_CONFIG } from './postContent';
|
||||
import { parseFenceInfo, formatFenceInfo } from './codeBlockOptions';
|
||||
|
||||
const MEMBERS_ONLY_BLOCK_RE = /<members-only>([\s\S]*?)<\/members-only>/gi;
|
||||
|
||||
@@ -50,6 +51,39 @@ function splitParagraphBreaks(html: string): string {
|
||||
|
||||
/** 为 Turndown 注册通用正文规则(不含 members-only) */
|
||||
function addTurndownContentRules(service: TurndownService): void {
|
||||
// TipTap 列表项内是 <li><p>…</p></li>:默认 paragraph 规则会塞多余空行,
|
||||
// 导致列表结构异常;列表内段落只输出内容本身。
|
||||
service.addRule('paragraphInListItem', {
|
||||
filter: (node) =>
|
||||
node.nodeName === 'P' && Boolean(node.parentNode && node.parentNode.nodeName === 'LI'),
|
||||
replacement: (content) => content.trim(),
|
||||
});
|
||||
|
||||
// 代码块统一输出围栏,展示选项写入 info 串:```js lines collapsed
|
||||
service.addRule('fencedCodeBlock', {
|
||||
filter: (node) => node.nodeName === 'PRE',
|
||||
replacement: (_content, node) => {
|
||||
const pre = node as HTMLElement;
|
||||
const codeEl = pre.querySelector('code') || pre;
|
||||
// 行号装饰 span 时仍取纯文本
|
||||
const text = codeEl.textContent || '';
|
||||
const langMatch = (codeEl.getAttribute('class') || '').match(/(?:language|lang)-([a-z0-9_+-]+)/i);
|
||||
const wrap = pre.closest('.md-codeblock') as HTMLElement | null;
|
||||
const language = (
|
||||
pre.getAttribute('data-lang')
|
||||
|| wrap?.getAttribute('data-lang')
|
||||
|| langMatch?.[1]
|
||||
|| ''
|
||||
).trim().toLowerCase();
|
||||
const lineNumbers = pre.getAttribute('data-line-numbers') === 'true'
|
||||
|| wrap?.getAttribute('data-line-numbers') === 'true';
|
||||
const collapsed = pre.getAttribute('data-collapsed') === 'true'
|
||||
|| wrap?.getAttribute('data-collapsed') === 'true';
|
||||
const info = formatFenceInfo({ language, lineNumbers, collapsed });
|
||||
return `\n\n\`\`\`${info}\n${text}\n\`\`\`\n\n`;
|
||||
},
|
||||
});
|
||||
|
||||
service.addRule('imageGroup', {
|
||||
filter: (node) =>
|
||||
node.nodeName === 'DIV' && (node as HTMLElement).hasAttribute('data-image-group'),
|
||||
@@ -127,9 +161,20 @@ function addTurndownContentRules(service: TurndownService): void {
|
||||
/** 子节点转 Markdown 专用,避免 members-only 规则递归 */
|
||||
const contentTurndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(contentTurndown);
|
||||
patchTurndownEscape(contentTurndown);
|
||||
|
||||
const turndown = new TurndownService(TURNDOWN_OPTIONS);
|
||||
addTurndownContentRules(turndown);
|
||||
patchTurndownEscape(turndown);
|
||||
|
||||
/**
|
||||
* Turndown 默认会把「行首 1. 」转义成 1\. ,避免被当成列表。
|
||||
* 富文本有序列表 / 用户手写序号在源码里都应保持 1. ,故去掉该转义。
|
||||
*/
|
||||
function patchTurndownEscape(service: TurndownService): void {
|
||||
const original = service.escape.bind(service);
|
||||
service.escape = (str: string) => original(str).replace(/(\d+)\\(\.)/g, '$1$2');
|
||||
}
|
||||
|
||||
/** 登录可见区块转为 Markdown:逐子节点转换,保留首行缩进 */
|
||||
turndown.addRule('membersOnly', {
|
||||
@@ -159,13 +204,34 @@ marked.setOptions({
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
/** 禁用缩进代码块,Tab/空格缩进仅作正文排版;围栏 ``` 代码块不受影响 */
|
||||
/** HTML 转义代码正文 */
|
||||
function escapeCodeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** 禁用缩进代码块;围栏支持 lines/collapsed 简写(旧 style=* 解析时忽略) */
|
||||
marked.use({
|
||||
tokenizer: {
|
||||
code() {
|
||||
return undefined;
|
||||
},
|
||||
},
|
||||
renderer: {
|
||||
code({ text, lang, escaped }) {
|
||||
const opts = parseFenceInfo(lang || '');
|
||||
const attrs: string[] = [];
|
||||
if (opts.lineNumbers) attrs.push('data-line-numbers="true"');
|
||||
if (opts.collapsed) attrs.push('data-collapsed="true"');
|
||||
const preOpen = attrs.length ? `<pre ${attrs.join(' ')}>` : '<pre>';
|
||||
const classAttr = opts.language ? ` class="language-${opts.language}"` : '';
|
||||
const body = escaped ? text : escapeCodeHtml(text);
|
||||
return `${preOpen}<code${classAttr}>${body}</code></pre>\n`;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** 净化并保留 members-only 标签 */
|
||||
|
||||
@@ -12,7 +12,8 @@ export const POST_CONTENT_PURIFY_CONFIG: Config = {
|
||||
ADD_TAGS: ['members-only'],
|
||||
ADD_ATTR: [
|
||||
'data-locked', 'data-length', 'target', 'rel',
|
||||
'data-code-copy', 'data-lang', 'data-full',
|
||||
'data-code-copy', 'data-code-fold', 'data-lang', 'data-full',
|
||||
'data-code-style', 'data-line-numbers', 'data-collapsed', 'data-line-count', 'data-lineno-digits',
|
||||
'data-image-group', 'data-layout', 'data-display',
|
||||
'data-clear-float',
|
||||
'class',
|
||||
|
||||
Reference in New Issue
Block a user