代码块优化,增加aardio、R语言代码高亮等

This commit is contained in:
2026-08-05 11:58:35 +08:00
parent 860dffd547
commit e50047e1c8
14 changed files with 1270 additions and 50 deletions

View 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),
},
});
}

View 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(' ');
}

View File

@@ -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, '&quot;');
}
/** 美化并高亮文档中的代码块(加语言标签与复制按钮 */
/** 读取作者配置的展示选项(写在 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');
});
}

View 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);
}

View File

@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
/** 禁用缩进代码块;围栏支持 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 标签 */

View File

@@ -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',