代码块优化,增加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

@@ -17,7 +17,7 @@ import {
import { POST_CONTENT_PURIFY_CONFIG } from '../utils/postContent';
import { htmlToMarkdown, markdownToHtml } from '../utils/markdownContent';
import PostContent from './PostContent';
import { handleMarkdownTabKey, insertAtCursor } from '../utils/markdownIndent';
import { handleMarkdownTabKey, insertAtCursor, applyTextareaChange } from '../utils/markdownIndent';
import {
wrapMarkdownSelection,
prefixMarkdownLines,
@@ -34,6 +34,12 @@ import { ArticleImage, type ImageDisplay } from './editor/ArticleImageExtension'
import { ImageGroup, suggestImageGroupLayout } from './editor/ImageGroupExtension';
import { ClearFloatParagraph, ClearFloatSync } from './editor/ClearFloatParagraph';
import { ArticleLinkDialog } from './editor/ArticleLinkDialog';
import { ArticleCodeBlockDialog } from './editor/ArticleCodeBlockDialog';
import { ArticleCodeBlock } from './editor/ArticleCodeBlockExtension';
import {
formatFenceInfo,
type CodeBlockInsertOptions,
} from '../utils/codeBlockOptions';
import { Tooltip } from './ui/Tooltip';
export interface ArticleEditorHandle {
@@ -50,6 +56,7 @@ interface Props {
type EditorMode = 'rich' | 'markdown';
type LinkTarget = 'rich' | 'markdown';
type CodeBlockTarget = 'rich' | 'markdown';
interface ToolBtn {
icon: ReactNode;
@@ -63,6 +70,13 @@ interface ToolBtn {
const MEMBERS_ONLY_PLACEHOLDER = '在此输入仅登录用户可见的内容…';
/** 按选项生成 Markdown 侧插入片段(围栏 meta便于手写 */
function buildMarkdownCodeBlockSnippet(opts: CodeBlockInsertOptions, body = '代码'): string {
const info = formatFenceInfo(opts);
const fence = info ? `\`\`\`${info}` : '```';
return `\n${fence}\n${body}\n\`\`\`\n`;
}
/** 净化编辑器 HTML保留 members-only 自定义标签 */
function sanitizeHtml(html: string): string {
return DOMPurify.sanitize(html, POST_CONTENT_PURIFY_CONFIG);
@@ -179,6 +193,10 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
const [linkDialogOpen, setLinkDialogOpen] = useState(false);
const [linkDialogUrl, setLinkDialogUrl] = useState('');
const [linkTarget, setLinkTarget] = useState<LinkTarget>('rich');
const [codeBlockDialogOpen, setCodeBlockDialogOpen] = useState(false);
const [codeBlockTarget, setCodeBlockTarget] = useState<CodeBlockTarget>('rich');
const [codeBlockEditing, setCodeBlockEditing] = useState(false);
const [codeBlockInitial, setCodeBlockInitial] = useState<Partial<CodeBlockInsertOptions> | null>(null);
const markdownRef = useRef<HTMLTextAreaElement>(null);
const editor = useEditor({
@@ -186,7 +204,12 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
StarterKit.configure({
heading: { levels: [2, 3, 4, 5, 6] },
paragraph: false,
codeBlock: false,
// StarterKit v3 已内置;下面单独配置,需先关掉避免重复
link: false,
underline: false,
}),
ArticleCodeBlock,
ClearFloatParagraph,
ClearFloatSync,
Underline,
@@ -328,6 +351,53 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
setLinkDialogOpen(true);
}, [editor]);
const openCodeBlockDialog = useCallback((target: CodeBlockTarget) => {
setCodeBlockTarget(target);
if (target === 'rich' && editor?.isActive('codeBlock')) {
const attrs = editor.getAttributes('codeBlock');
setCodeBlockEditing(true);
setCodeBlockInitial({
language: (attrs.language as string) || '',
lineNumbers: Boolean(attrs.lineNumbers),
collapsed: Boolean(attrs.collapsed),
});
} else {
setCodeBlockEditing(false);
setCodeBlockInitial(null);
}
setCodeBlockDialogOpen(true);
}, [editor]);
const applyCodeBlock = useCallback((opts: CodeBlockInsertOptions) => {
if (codeBlockTarget === 'markdown') {
const textarea = markdownRef.current;
if (!textarea) return;
const { selectionStart, selectionEnd } = textarea;
const selected = markdownSource.slice(selectionStart, selectionEnd);
const body = selected || '代码';
const snippet = buildMarkdownCodeBlockSnippet(opts, body);
const next = markdownSource.slice(0, selectionStart) + snippet + markdownSource.slice(selectionEnd);
const bodyOffset = snippet.indexOf(body);
const newStart = bodyOffset >= 0 ? selectionStart + bodyOffset : selectionStart + snippet.length;
const newEnd = bodyOffset >= 0 ? newStart + body.length : newStart;
applyTextareaChange(textarea, next, newStart, newEnd, handleMarkdownChange);
return;
}
if (!editor) return;
editor.chain().focus().setArticleCodeBlock({
language: opts.language || null,
lineNumbers: opts.lineNumbers,
collapsed: opts.collapsed,
}).run();
}, [codeBlockTarget, editor, markdownSource, handleMarkdownChange]);
const removeCodeBlock = useCallback(() => {
if (!editor) return;
if (editor.isActive('codeBlock')) {
editor.chain().focus().toggleCodeBlock().run();
}
}, [editor]);
const applyLink = useCallback((url: string) => {
if (linkTarget === 'markdown') {
const textarea = markdownRef.current;
@@ -453,7 +523,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <Quote size={15} />, title: '引用', active: editor.isActive('blockquote'), action: () => editor.chain().focus().toggleBlockquote().run() },
{ icon: <List size={15} />, title: '无序列表', active: editor.isActive('bulletList'), action: () => editor.chain().focus().toggleBulletList().run() },
{ icon: <ListOrdered size={15} />, title: '有序列表', active: editor.isActive('orderedList'), action: () => editor.chain().focus().toggleOrderedList().run() },
{ icon: <Code size={15} />, title: '代码块', active: editor.isActive('codeBlock'), action: () => editor.chain().focus().toggleCodeBlock().run() },
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', active: editor.isActive('codeBlock'), action: () => openCodeBlockDialog('rich') },
{ icon: <LinkIcon size={15} />, title: '链接', active: editor.isActive('link'), action: () => openLinkDialog('rich') },
{
icon: <ImageIcon size={15} />,
@@ -505,7 +575,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
});
return tools;
}, [editor, openLinkDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
}, [editor, openLinkDialog, openCodeBlockDialog, setImage, wrapMembersOnly, wrapSelectedAsGroup, setImageDisplay]);
const buildMarkdownTools = useCallback((): ToolBtn[] => [
{ icon: <strong>H</strong>, title: '标题', hint: 'H2 → H6 循环', action: withMarkdown(cycleMarkdownHeading) },
@@ -517,7 +587,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
{ icon: <Quote size={15} />, title: '引用', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '> ', ch)) },
{ icon: <List size={15} />, title: '无序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '- ', ch)) },
{ icon: <ListOrdered size={15} />, title: '有序列表', action: withMarkdown((ta, v, ch) => prefixMarkdownLines(ta, v, '1. ', ch)) },
{ icon: <Code size={15} />, title: '代码块', action: withMarkdown((ta, v, ch) => wrapMarkdownSelection(ta, v, '```\n', '\n```', '代码', ch)) },
{ icon: <Code size={15} />, title: '代码块', hint: '语言、行号与折叠', action: () => openCodeBlockDialog('markdown') },
{ icon: <LinkIcon size={15} />, title: '链接', action: () => openLinkDialog('markdown') },
{ icon: <ImageIcon size={15} />, title: '上传图片', action: insertMarkdownImage },
{
@@ -527,7 +597,7 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
className: 'article-tool-btn--members',
action: withMarkdown(insertMarkdownMembersOnly),
},
], [withMarkdown, openLinkDialog, insertMarkdownImage]);
], [withMarkdown, openLinkDialog, openCodeBlockDialog, insertMarkdownImage]);
const tools = mode === 'rich' ? buildRichTools() : buildMarkdownTools();
const words = mode === 'markdown'
@@ -629,6 +699,14 @@ const ArticleEditor = forwardRef<ArticleEditorHandle, Props>(function ArticleEdi
onConfirm={applyLink}
onRemove={linkTarget === 'rich' && linkDialogUrl ? removeLink : undefined}
/>
<ArticleCodeBlockDialog
open={codeBlockDialogOpen}
onOpenChange={setCodeBlockDialogOpen}
initial={codeBlockInitial}
editing={codeBlockEditing}
onConfirm={applyCodeBlock}
onRemove={codeBlockEditing ? removeCodeBlock : undefined}
/>
</div>
);
});

View File

@@ -80,11 +80,29 @@ export default function PostContent({
}
return;
}
const foldBtn = target.closest<HTMLElement>('[data-code-fold]');
if (foldBtn) {
e.preventDefault();
const block = foldBtn.closest('.md-codeblock');
if (!block) return;
const collapsed = block.classList.toggle('md-codeblock--collapsed');
const lineCount = parseInt(block.getAttribute('data-line-count') || '0', 10)
|| block.querySelectorAll('.md-code-line').length
|| 1;
if (collapsed && lineCount <= 5) block.classList.add('md-codeblock--short');
else block.classList.remove('md-codeblock--short');
foldBtn.textContent = collapsed ? '展开' : '收起';
return;
}
const copyBtn = target.closest<HTMLElement>('[data-code-copy]');
if (copyBtn) {
e.preventDefault();
const block = copyBtn.closest('.md-codeblock');
const text = block?.querySelector('pre')?.textContent ?? '';
// 行号列不参与复制:取各行正文拼接
const bodies = block?.querySelectorAll('.md-code-line__body');
const text = bodies && bodies.length
? [...bodies].map(el => el.textContent ?? '').join('\n')
: (block?.querySelector('pre')?.textContent ?? '');
try {
await navigator.clipboard.writeText(text);
const prev = copyBtn.textContent;

View File

@@ -0,0 +1,183 @@
import { useEffect, useMemo, useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
CODE_BLOCK_LANGUAGES,
DEFAULT_CODE_BLOCK_OPTIONS,
loadCodeBlockPrefs,
saveCodeBlockPrefs,
type CodeBlockInsertOptions,
} from '../../utils/codeBlockOptions';
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
/** 编辑已有代码块时的初始值;新建时用偏好 */
initial?: Partial<CodeBlockInsertOptions> | null;
/** 是否处于已有代码块(显示「移除」) */
editing?: boolean;
onConfirm: (opts: CodeBlockInsertOptions) => void;
onRemove?: () => void;
}
/** 文章编辑器:插入 / 配置代码块 */
export function ArticleCodeBlockDialog({
open,
onOpenChange,
initial,
editing = false,
onConfirm,
onRemove,
}: Props) {
const [language, setLanguage] = useState(DEFAULT_CODE_BLOCK_OPTIONS.language);
const [lineNumbers, setLineNumbers] = useState(DEFAULT_CODE_BLOCK_OPTIONS.lineNumbers);
const [collapsed, setCollapsed] = useState(DEFAULT_CODE_BLOCK_OPTIONS.collapsed);
const [langQuery, setLangQuery] = useState('');
useEffect(() => {
if (!open) return;
const prefs = loadCodeBlockPrefs();
setLanguage(initial?.language ?? '');
setLineNumbers(initial?.lineNumbers ?? prefs.lineNumbers);
setCollapsed(initial?.collapsed ?? prefs.collapsed);
setLangQuery('');
}, [open, initial]);
const filteredLangs = useMemo(() => {
const q = langQuery.trim().toLowerCase();
if (!q) return CODE_BLOCK_LANGUAGES;
return CODE_BLOCK_LANGUAGES.filter(
l => l.id.includes(q) || l.label.toLowerCase().includes(q),
);
}, [langQuery]);
const handleConfirm = () => {
const opts: CodeBlockInsertOptions = {
language: language.trim().toLowerCase(),
lineNumbers,
collapsed,
};
saveCodeBlockPrefs(opts);
onConfirm(opts);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="article-codeblock-dialog sm:max-w-[560px]">
<DialogHeader>
<DialogTitle>{editing ? '代码块设置' : '插入代码块'}</DialogTitle>
<DialogDescription>
/{' '}
<code className="article-codeblock-dialog__codehint">{'```js lines collapsed'}</code>
{' '}
</DialogDescription>
</DialogHeader>
<div className="article-codeblock-dialog__body">
<section className="article-codeblock-dialog__section">
<Label htmlFor="codeblock-lang-search"></Label>
<Input
id="codeblock-lang-search"
value={langQuery}
placeholder="搜索语言…"
onChange={e => setLangQuery(e.target.value)}
autoFocus
/>
<div className="article-codeblock-dialog__langs" role="listbox" aria-label="编程语言">
{filteredLangs.map(lang => {
const active = language === lang.id;
return (
<button
key={lang.id || 'plain'}
type="button"
role="option"
aria-selected={active}
className={`article-codeblock-dialog__lang${active ? ' is-active' : ''}`}
onClick={() => setLanguage(lang.id)}
>
{lang.label}
{lang.id ? <span className="article-codeblock-dialog__lang-id">{lang.id}</span> : null}
</button>
);
})}
{filteredLangs.length === 0 ? (
<p className="article-codeblock-dialog__empty">
使
</p>
) : null}
</div>
<Input
value={language}
placeholder="自定义语言标识(如 aardio"
onChange={e => setLanguage(e.target.value.trim().toLowerCase())}
onKeyDown={e => {
if (e.key === 'Enter') {
e.preventDefault();
handleConfirm();
}
}}
/>
</section>
<section className="article-codeblock-dialog__toggles">
<div className="article-codeblock-dialog__toggle">
<div className="article-codeblock-dialog__toggle-text">
<Label htmlFor="codeblock-lines"></Label>
<p></p>
</div>
<Switch
id="codeblock-lines"
checked={lineNumbers}
onCheckedChange={setLineNumbers}
/>
</div>
<div className="article-codeblock-dialog__toggle">
<div className="article-codeblock-dialog__toggle-text">
<Label htmlFor="codeblock-fold"></Label>
<p> 5 </p>
</div>
<Switch
id="codeblock-fold"
checked={collapsed}
onCheckedChange={setCollapsed}
/>
</div>
</section>
</div>
<DialogFooter className="article-codeblock-dialog__footer">
{editing && onRemove ? (
<Button
type="button"
variant="outline"
className="article-codeblock-dialog__remove"
onClick={() => {
onRemove();
onOpenChange(false);
}}
>
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button type="button" onClick={handleConfirm}>
{editing ? '应用' : '插入'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,208 @@
import CodeBlock from '@tiptap/extension-code-block';
import type { Node as ProseMirrorNode } from '@tiptap/pm/model';
import { Plugin, PluginKey } from '@tiptap/pm/state';
import { createCodeBlockHighlightPlugin } from '../../utils/codeBlockHighlightPlugin';
import { notify } from '@/lib/notify';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
articleCodeBlock: {
/** 按选项插入或更新代码块 */
setArticleCodeBlock: (attrs: {
language?: string | null;
lineNumbers?: boolean;
collapsed?: boolean;
}) => ReturnType;
};
}
}
function formatLangLabel(language: string): string {
if (language === 'aardio') return 'aardio';
return language || 'code';
}
/** 同步编辑态外壳:主题壳 + 语言 + 选项角标(外观随站点主题,不手选风格) */
function applyEditorChrome(ctx: {
wrap: HTMLElement;
langEl: HTMLElement;
metaEl: HTMLElement;
pre: HTMLElement;
code: HTMLElement;
node: ProseMirrorNode;
}) {
const { wrap, langEl, metaEl, pre, code, node } = ctx;
const lineNumbers = Boolean(node.attrs.lineNumbers);
const collapsed = Boolean(node.attrs.collapsed);
const language = ((node.attrs.language as string) || '').trim();
wrap.className = 'md-codeblock md-codeblock--editor';
wrap.removeAttribute('data-code-style');
if (lineNumbers) wrap.setAttribute('data-line-numbers', 'true');
else wrap.removeAttribute('data-line-numbers');
if (collapsed) wrap.setAttribute('data-collapsed', 'true');
else wrap.removeAttribute('data-collapsed');
wrap.setAttribute('data-lang', formatLangLabel(language));
langEl.textContent = formatLangLabel(language);
const badges: string[] = [];
if (lineNumbers) badges.push('行号');
if (collapsed) badges.push('默认折叠');
metaEl.textContent = badges.join(' · ');
metaEl.hidden = badges.length === 0;
pre.className = 'md-codeblock__pre';
code.className = [
'hljs',
language ? `language-${language}` : '',
].filter(Boolean).join(' ');
}
/**
* 文章代码块:行号 / 折叠属性 + 编辑壳 + hljs 着色。
* 外观跟站点亮/暗主题;行号列与折叠裁切仅在阅读态渲染。
*/
export const ArticleCodeBlock = CodeBlock.extend({
name: 'codeBlock',
addAttributes() {
return {
...this.parent?.(),
lineNumbers: {
default: false,
parseHTML: (el) => el.getAttribute('data-line-numbers') === 'true',
renderHTML: (attrs) => {
if (!attrs.lineNumbers) return {};
return { 'data-line-numbers': 'true' };
},
},
collapsed: {
default: false,
parseHTML: (el) => el.getAttribute('data-collapsed') === 'true',
renderHTML: (attrs) => {
if (!attrs.collapsed) return {};
return { 'data-collapsed': 'true' };
},
},
};
},
addNodeView() {
return ({ node: initialNode, editor, getPos }) => {
let node = initialNode;
const wrap = document.createElement('div');
const head = document.createElement('div');
head.className = 'md-codeblock__head';
head.contentEditable = 'false';
const langEl = document.createElement('span');
langEl.className = 'md-codeblock__lang';
const metaEl = document.createElement('span');
metaEl.className = 'md-codeblock__editor-meta';
const actions = document.createElement('span');
actions.className = 'md-codeblock__actions';
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'md-codeblock__copy';
copyBtn.textContent = '复制';
actions.append(metaEl, copyBtn);
head.append(langEl, actions);
const pre = document.createElement('pre');
const code = document.createElement('code');
pre.appendChild(code);
wrap.append(head, pre);
const sync = (n: ProseMirrorNode) => {
applyEditorChrome({ wrap, langEl, metaEl, pre, code, node: n });
};
copyBtn.addEventListener('mousedown', e => e.preventDefault());
copyBtn.addEventListener('click', async e => {
e.preventDefault();
e.stopPropagation();
const pos = typeof getPos === 'function' ? getPos() : null;
const current = typeof pos === 'number' ? editor.state.doc.nodeAt(pos) : null;
const text = current?.textContent ?? code.textContent ?? '';
try {
await navigator.clipboard.writeText(text);
const prev = copyBtn.textContent;
copyBtn.textContent = '已复制';
copyBtn.classList.add('is-copied');
window.setTimeout(() => {
copyBtn.textContent = prev || '复制';
copyBtn.classList.remove('is-copied');
}, 1600);
} catch {
notify.error('复制失败');
}
});
sync(node);
return {
dom: wrap,
contentDOM: code,
update: (updated) => {
if (updated.type.name !== 'codeBlock') return false;
node = updated;
sync(updated);
return true;
},
};
};
},
addProseMirrorPlugins() {
const parentPlugins = this.parent?.() || [];
return [
...parentPlugins,
createCodeBlockHighlightPlugin(),
// 代码块内粘贴:强制纯文本,保留换行(避免 HTML 分块把换行吃掉)
new Plugin({
key: new PluginKey('articleCodeBlockPaste'),
props: {
handlePaste: (view, event) => {
const { state } = view;
if (state.selection.$from.parent.type.name !== this.name) {
return false;
}
const text = event.clipboardData?.getData('text/plain');
if (text == null) return false;
event.preventDefault();
const normalized = text.replace(/\r\n?/g, '\n');
const tr = state.tr;
if (!state.selection.empty) {
tr.deleteSelection();
}
tr.insertText(normalized);
view.dispatch(tr.scrollIntoView());
return true;
},
},
}),
];
},
addCommands() {
return {
...this.parent?.(),
setArticleCodeBlock: (attrs) => ({ commands, editor }) => {
const next = {
language: attrs.language || null,
lineNumbers: Boolean(attrs.lineNumbers),
collapsed: Boolean(attrs.collapsed),
};
if (editor.isActive(this.name)) {
return commands.updateAttributes(this.name, next);
}
return commands.setNode(this.name, next);
},
};
},
});

View File

@@ -4292,12 +4292,12 @@ a.post-title:visited {
border: 1px solid color-mix(in srgb, var(--j13-green) 12%, var(--j13-border-light));
}
/* 代码块外壳 */
/* 代码块外壳(默认跟亮色主题;暗色见下方 .dark */
.post-detail-content .md-codeblock {
margin: 1.15em 0;
border: 1px solid var(--j13-border-light);
border: 1px solid #d8dee4;
border-radius: 12px;
background: #0f1419;
background: #f6f8fa;
overflow: hidden;
box-shadow: var(--j13-shadow-soft);
}
@@ -4307,34 +4307,78 @@ a.post-title:visited {
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
background: #161b22;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: #eef1f4;
border-bottom: 1px solid #d8dee4;
}
.post-detail-content .md-codeblock__lang {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.45);
color: #656d76;
}
.post-detail-content .md-codeblock__copy {
/* aardio 品牌名保持全小写,不做 uppercase */
.post-detail-content .md-codeblock[data-lang="aardio"] .md-codeblock__lang {
text-transform: none;
letter-spacing: 0.02em;
}
.post-detail-content .md-codeblock__actions {
display: inline-flex;
align-items: center;
gap: 6px;
}
.post-detail-content .md-codeblock__editor-meta {
font-size: 11px;
font-weight: 500;
letter-spacing: 0.02em;
color: #656d76;
}
/* 编辑态代码块:只保留主题壳 / 头栏角标 / 可编辑正文,行号与折叠交给阅读态 */
.article-prosemirror .md-codeblock--editor {
margin: 1em 0;
}
.article-prosemirror .md-codeblock--editor .md-codeblock__head {
cursor: default;
user-select: none;
}
.article-prosemirror .md-codeblock--editor .md-codeblock__pre {
min-height: 1.65em;
/* 编辑态永不按折叠裁切,即使误带 collapsed class */
max-height: none !important;
overflow: visible !important;
}
.article-prosemirror .md-codeblock--editor .md-codeblock__pre::after {
display: none !important;
}
.article-prosemirror .md-codeblock--editor .md-codeblock__pre code,
.article-prosemirror .md-codeblock--editor.md-codeblock--lines .md-codeblock__pre code {
display: block;
outline: none;
/* 覆盖阅读态行号布局的 white-space: normal避免粘贴多行被挤成一行 */
white-space: pre-wrap;
overflow-wrap: normal;
word-break: normal;
}
.post-detail-content .md-codeblock__copy,
.post-detail-content .md-codeblock__fold {
padding: 3px 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border: 1px solid #d0d7de;
border-radius: 6px;
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.72);
background: #fff;
color: #57606a;
font-size: 12px;
line-height: 1.4;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.post-detail-content .md-codeblock__copy:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
.post-detail-content .md-codeblock__copy:hover,
.post-detail-content .md-codeblock__fold:hover {
background: #f3f4f6;
color: #24292f;
}
.post-detail-content .md-codeblock__copy.is-copied {
border-color: color-mix(in srgb, var(--j13-green) 50%, transparent);
color: #7ee2a8;
color: color-mix(in srgb, var(--j13-green) 55%, #24292f);
}
.post-detail-content .md-codeblock__pre,
.post-detail-content pre {
@@ -4343,10 +4387,11 @@ a.post-title:visited {
background: transparent;
border-radius: 0;
overflow-x: auto;
color: #e6edf3;
color: #24292f;
font-size: 13px;
line-height: 1.65;
tab-size: 2;
position: relative;
}
.post-detail-content .md-codeblock__pre code,
.post-detail-content pre code {
@@ -4357,56 +4402,182 @@ a.post-title:visited {
color: inherit;
font-size: inherit;
white-space: pre;
display: block;
}
/* 行号布局:外层改为 normal正文行内保留 pre */
.post-detail-content .md-codeblock--lines .md-codeblock__pre code {
white-space: normal;
}
/* 行号:列宽随 data-lineno-digits 自适应nowrap 避免父级 white-space:normal 把多位数折行 */
.post-detail-content .md-codeblock--lines {
--md-lineno-col: 1ch;
}
.post-detail-content .md-codeblock--lines[data-lineno-digits="2"] {
--md-lineno-col: 2ch;
}
.post-detail-content .md-codeblock--lines[data-lineno-digits="3"] {
--md-lineno-col: 3ch;
}
.post-detail-content .md-codeblock--lines[data-lineno-digits="4"] {
--md-lineno-col: 4ch;
}
.post-detail-content .md-code-line {
display: block;
min-height: 1.65em;
}
.post-detail-content .md-codeblock--lines .md-code-line {
display: grid;
grid-template-columns: var(--md-lineno-col) minmax(0, 1fr);
column-gap: 10px;
}
.post-detail-content .md-code-line__num {
user-select: none;
text-align: right;
color: #8c959f;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.post-detail-content .md-code-line__body {
min-width: 0;
overflow-wrap: normal;
white-space: pre;
}
/* 默认折叠:约 5 行;刚好 5 行时再压到约 2 行,保证能看出效果 */
.post-detail-content .md-codeblock--collapsed .md-codeblock__pre {
max-height: calc(5 * 1.65em + 28px);
overflow: hidden;
}
.post-detail-content .md-codeblock--collapsed.md-codeblock--short .md-codeblock__pre {
max-height: calc(2 * 1.65em + 20px);
}
.post-detail-content .md-codeblock--collapsed .md-codeblock__pre::after {
content: '';
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 56px;
pointer-events: none;
background: linear-gradient(to bottom, transparent, #f6f8fa 85%);
}
/* 未包壳的遗留 pre兜底 */
.post-detail-content > pre {
margin: 1.15em 0;
padding: 14px 16px;
background: #0f1419;
border: 1px solid var(--j13-border-light);
background: #f6f8fa;
border: 1px solid #d8dee4;
border-radius: 12px;
color: #e6edf3;
color: #24292f;
overflow-x: auto;
}
/* highlight.js token深色代码块 */
/* highlight.js token亮色 */
.post-detail-content .hljs-comment,
.post-detail-content .hljs-quote { color: #8b949e; font-style: italic; }
.post-detail-content .hljs-quote { color: #6e7781; font-style: italic; }
.post-detail-content .hljs-keyword,
.post-detail-content .hljs-selector-tag,
.post-detail-content .hljs-addition { color: #ff7b72; }
.post-detail-content .hljs-addition { color: #cf222e; }
.post-detail-content .hljs-string,
.post-detail-content .hljs-meta .hljs-string,
.post-detail-content .hljs-attribute { color: #a5d6ff; }
.post-detail-content .hljs-attribute { color: #0a3069; }
.post-detail-content .hljs-number,
.post-detail-content .hljs-literal,
.post-detail-content .hljs-variable,
.post-detail-content .hljs-template-variable { color: #79c0ff; }
.post-detail-content .hljs-template-variable { color: #0550ae; }
.post-detail-content .hljs-title,
.post-detail-content .hljs-section,
.post-detail-content .hljs-name { color: #d2a8ff; }
.post-detail-content .hljs-name { color: #8250df; }
.post-detail-content .hljs-type,
.post-detail-content .hljs-class .hljs-title { color: #ffa657; }
.post-detail-content .hljs-class .hljs-title { color: #953800; }
.post-detail-content .hljs-built_in,
.post-detail-content .hljs-params { color: #ffa657; }
.post-detail-content .hljs-params { color: #953800; }
.post-detail-content .hljs-attr,
.post-detail-content .hljs-symbol,
.post-detail-content .hljs-bullet { color: #7ee787; }
.post-detail-content .hljs-bullet { color: #116329; }
.post-detail-content .hljs-meta,
.post-detail-content .hljs-deletion { color: #ffa198; }
.post-detail-content .hljs-deletion { color: #82071e; }
.post-detail-content .hljs-regexp,
.post-detail-content .hljs-link { color: #a5d6ff; }
.post-detail-content .hljs-link { color: #0a3069; }
.post-detail-content .hljs-operator { color: #cf222e; }
.post-detail-content .hljs-emphasis { font-style: italic; }
.post-detail-content .hljs-strong { font-weight: 700; }
/* 暗色主题:代码块跟随站点主题自动切换 */
.dark .post-detail-content .md-codeblock {
background: #0b0d10;
background: #0f1419;
border-color: #2a2f36;
}
.dark .post-detail-content .md-codeblock__head {
background: #12151a;
background: #161b22;
border-bottom-color: rgba(255, 255, 255, 0.06);
}
.dark .post-detail-content .md-codeblock__lang {
color: rgba(255, 255, 255, 0.45);
}
.dark .post-detail-content .md-codeblock__editor-meta {
color: rgba(255, 255, 255, 0.4);
}
.dark .post-detail-content .md-codeblock__copy,
.dark .post-detail-content .md-codeblock__fold {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.72);
}
.dark .post-detail-content .md-codeblock__copy:hover,
.dark .post-detail-content .md-codeblock__fold:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.dark .post-detail-content .md-codeblock__copy.is-copied {
border-color: color-mix(in srgb, var(--j13-green) 50%, transparent);
color: #7ee2a8;
}
.dark .post-detail-content .md-codeblock__pre,
.dark .post-detail-content pre {
color: #e6edf3;
}
.dark .post-detail-content .md-code-line__num {
color: rgba(255, 255, 255, 0.28);
}
.dark .post-detail-content .md-codeblock--collapsed .md-codeblock__pre::after {
background: linear-gradient(to bottom, transparent, #0f1419 85%);
}
.dark .post-detail-content > pre {
background: #0f1419;
border-color: #2a2f36;
color: #e6edf3;
}
.dark .post-detail-content .hljs-comment,
.dark .post-detail-content .hljs-quote { color: #8b949e; }
.dark .post-detail-content .hljs-keyword,
.dark .post-detail-content .hljs-selector-tag,
.dark .post-detail-content .hljs-addition { color: #ff7b72; }
.dark .post-detail-content .hljs-string,
.dark .post-detail-content .hljs-meta .hljs-string,
.dark .post-detail-content .hljs-attribute { color: #a5d6ff; }
.dark .post-detail-content .hljs-number,
.dark .post-detail-content .hljs-literal,
.dark .post-detail-content .hljs-variable,
.dark .post-detail-content .hljs-template-variable { color: #79c0ff; }
.dark .post-detail-content .hljs-title,
.dark .post-detail-content .hljs-section,
.dark .post-detail-content .hljs-name { color: #d2a8ff; }
.dark .post-detail-content .hljs-type,
.dark .post-detail-content .hljs-class .hljs-title { color: #ffa657; }
.dark .post-detail-content .hljs-built_in,
.dark .post-detail-content .hljs-params { color: #ffa657; }
.dark .post-detail-content .hljs-attr,
.dark .post-detail-content .hljs-symbol,
.dark .post-detail-content .hljs-bullet { color: #7ee787; }
.dark .post-detail-content .hljs-meta,
.dark .post-detail-content .hljs-deletion { color: #ffa198; }
.dark .post-detail-content .hljs-regexp,
.dark .post-detail-content .hljs-link { color: #a5d6ff; }
.dark .post-detail-content .hljs-operator { color: #ff7b72; }
/* 评论区 — 独立卡片板块 */
.comment-section {
@@ -7273,6 +7444,119 @@ button.profile-stat:hover strong {
gap: 8px;
}
/* 代码块插入弹窗 */
.article-codeblock-dialog__body {
display: flex;
flex-direction: column;
gap: 20px;
margin-top: 4px;
}
.article-codeblock-dialog__section {
display: flex;
flex-direction: column;
gap: 10px;
}
.article-codeblock-dialog__langs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
max-height: 220px;
overflow: auto;
padding: 10px;
border: 1px solid var(--j13-border-light);
border-radius: 12px;
background: var(--j13-bg-block-muted);
}
.article-codeblock-dialog__lang {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 9px 12px;
border: 1px solid transparent;
border-radius: 9px;
background: var(--j13-bg-surface);
color: var(--color-text-1, inherit);
font-size: 13px;
text-align: left;
cursor: pointer;
transition: background 0.12s, border-color 0.12s, box-shadow 0.12s;
}
.article-codeblock-dialog__lang:hover {
border-color: color-mix(in srgb, var(--j13-green) 25%, var(--j13-border));
}
.article-codeblock-dialog__lang.is-active {
border-color: color-mix(in srgb, var(--j13-green) 45%, transparent);
background: var(--j13-green-bg);
color: var(--j13-green-hover);
font-weight: 600;
box-shadow: 0 0 0 1px color-mix(in srgb, var(--j13-green) 20%, transparent);
}
.article-codeblock-dialog__lang-id {
font-size: 11px;
opacity: 0.55;
font-family: ui-monospace, Consolas, monospace;
font-weight: 400;
}
.article-codeblock-dialog__empty {
grid-column: 1 / -1;
margin: 0;
padding: 16px;
font-size: 13px;
color: var(--color-text-3, #8b949e);
text-align: center;
}
.article-codeblock-dialog__codehint {
display: inline;
padding: 1px 5px;
border-radius: 4px;
background: var(--j13-bg-block-muted);
font-family: ui-monospace, Consolas, monospace;
font-size: 11px;
color: var(--color-text-2, inherit);
}
.article-codeblock-dialog__toggles {
display: flex;
flex-direction: column;
gap: 12px;
padding-top: 2px;
}
.article-codeblock-dialog__toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
border: 1px solid var(--j13-border-light);
border-radius: 12px;
background: var(--j13-bg-block-muted);
}
.article-codeblock-dialog__toggle-text {
display: flex;
flex-direction: column;
gap: 5px;
min-width: 0;
}
.article-codeblock-dialog__toggle-text p {
margin: 0;
font-size: 12px;
color: var(--color-text-3, #8b949e);
line-height: 1.45;
}
.article-codeblock-dialog__footer {
gap: 8px;
margin-top: 4px;
}
.article-codeblock-dialog__remove {
margin-right: auto;
}
@media (max-width: 560px) {
.article-codeblock-dialog__langs {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.article-editor-markdown {
flex: 1;
min-height: 0;

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