大更新

This commit is contained in:
RiseForever
2026-08-12 01:15:12 +08:00
parent d84b250f7a
commit 07294b9f24
11 changed files with 659 additions and 44 deletions
+52
View File
@@ -0,0 +1,52 @@
const NOTICE_ICON = {
type: 'element',
tagName: 'span',
properties: { className: ['shortcode-notice'], ariaLabel: '警告' },
children: [{
type: 'element',
tagName: 'svg',
properties: {
ariaHidden: 'true',
viewBox: '0 0 24 24',
fill: 'none',
stroke: 'currentColor',
strokeWidth: 2,
strokeLinecap: 'round',
strokeLinejoin: 'round',
},
children: [
{ type: 'element', tagName: 'circle', properties: { cx: 12, cy: 12, r: 10 }, children: [] },
{ type: 'element', tagName: 'path', properties: { d: 'M12 8v4' }, children: [] },
{ type: 'element', tagName: 'path', properties: { d: 'M12 16h.01' }, children: [] },
],
}],
};
function cloneNoticeIcon() {
return structuredClone(NOTICE_ICON);
}
export function applyNoticeShortcodes(tree: any) {
const transform = (node: any) => {
if (!Array.isArray(node.children) || node.tagName === 'code' || node.tagName === 'pre') return;
const output: any[] = [];
for (const child of node.children) {
if (child.type !== 'text' || !child.value.includes('[!/]')) {
transform(child);
output.push(child);
continue;
}
const parts = child.value.split('[!/]');
parts.forEach((part: string, index: number) => {
if (part) output.push({ type: 'text', value: part });
if (index < parts.length - 1) output.push({ type: 'text', value: '\u00a0' }, cloneNoticeIcon(), { type: 'text', value: '\u00a0' });
});
}
node.children = output;
};
transform(tree);
}
export default function noticeShortcode() {
return (tree: any) => applyNoticeShortcodes(tree);
}
+25
View File
@@ -0,0 +1,25 @@
import { SKIP, visit } from 'unist-util-visit';
export function applyResponsiveTables(tree: any) {
visit(tree, 'element', (node: any, index: number | undefined, parent: any) => {
if (node.tagName !== 'table' || index === undefined || !parent) return;
const classes = Array.isArray(node.properties?.className) ? node.properties.className : [];
if (classes.includes('responsive-table')) return;
const section = node.children?.find((child: any) => child.tagName === 'thead' || child.tagName === 'tbody');
const row = section?.children?.find((child: any) => child.tagName === 'tr');
const columns = Math.max(1, row?.children?.filter((child: any) => child.tagName === 'th' || child.tagName === 'td').length ?? 1);
node.properties = { ...node.properties, className: [...classes, 'responsive-table'] };
parent.children[index] = {
type: 'element',
tagName: 'div',
properties: { className: ['table-scroll'], style: `--table-columns:${columns}` },
children: [node],
};
return SKIP;
});
}
export default function responsiveTables() {
return (tree: any) => applyResponsiveTables(tree);
}
+72 -16
View File
@@ -5,10 +5,18 @@ import { toHast } from 'mdast-util-to-hast';
import { gfm } from 'micromark-extension-gfm';
import { visit } from 'unist-util-visit';
import { applyExternalLinks } from './external-links';
import { applyResponsiveTables } from './responsive-tables';
import { applyNoticeShortcodes } from './notice-shortcode';
const URL_SCHEME = /^(https?:|mailto:|tel:)/i;
const TAG_TYPES = new Set(['primary', 'success', 'warning', 'danger', 'info', 'default']);
const HINT_TYPES = new Set(['warn', 'warning', 'error', 'danger', 'success', 'info']);
const HINT_ICONS: Record<string, string> = {
info: '<circle cx="12" cy="12" r="10"></circle><path d="M12 16v-4"></path><path d="M12 8h.01"></path>',
warning: '<circle cx="12" cy="12" r="10"></circle><path d="M12 8v4"></path><path d="M12 16h.01"></path>',
danger: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 8.54 21h6.92a2 2 0 0 0 1.73-3Z"></path><path d="M12 9v4"></path><path d="M12 17h.01"></path>',
success: '<path d="m9 12 2 2 4-4"></path><circle cx="12" cy="12" r="10"></circle>',
};
const BLOCK_NAMES = new Set(['hint', 'tip', 'collapse', 'tabs', 'tools']);
const INLINE_NAMES = new Set(['button', 'btn', 'file', 'tag', 'label']);
type GithubData = { stars?: string; forks?: string; description?: string; commitDate?: string; htmlUrl?: string; defaultBranch?: string };
@@ -61,12 +69,37 @@ function normalizeMarkdownHeadings(source: string): string {
}).join('\n');
}
function markdown(source: string, preserveHtml = false): string {
function markdown(source: string, preserveHtml = false, lineNumbers = false): string {
const normalized = normalizeMarkdownHeadings(source);
const tree = fromMarkdown(normalized, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
transform(tree, normalized);
if (!preserveHtml) visit(tree, 'html', (node: any) => { if (!node.data?.shortcode) node.value = ''; });
const hast = toHast(tree, { allowDangerousHtml: true }) as any;
if (lineNumbers) {
visit(hast, 'element', (node: any) => {
if (node.tagName !== 'pre') return;
const code = node.children?.find((child: any) => child.type === 'element' && child.tagName === 'code');
if (!code || code.children?.some((child: any) => child.type !== 'text')) return;
const value = code.children.map((child: any) => child.value).join('');
const lines = value.endsWith('\n') ? value.slice(0, -1).split('\n') : value.split('\n');
const classes = Array.isArray(node.properties?.className) ? node.properties.className : [];
node.properties = { ...node.properties, className: [...classes, 'shortcode-code'] };
code.children = lines.flatMap((line: string, index: number) => [
{
type: 'element',
tagName: 'span',
properties: { className: ['line'] },
children: [
{ type: 'element', tagName: 'span', properties: { className: ['shortcode-line-number'], ariaHidden: 'true' }, children: [{ type: 'text', value: String(index + 1) }] },
...(line ? [{ type: 'text', value: line }] : []),
],
},
...(index < lines.length - 1 ? [{ type: 'text', value: '\n' }] : []),
]);
});
}
applyNoticeShortcodes(hast);
applyResponsiveTables(hast);
if (siteUrl) applyExternalLinks(hast, siteUrl);
return toHtml(hast, { allowDangerousHtml: true });
}
@@ -77,24 +110,32 @@ export function renderMarkdown(source: string): string {
function renderBlock(name: string, args: Record<string, string>, body = ''): string {
const label = args.title ?? args.name ?? args.text ?? body.trim().split('\n')[0] ?? '';
if (name === 'button' || name === 'btn' || name === 'file') {
if (name === 'button' || name === 'btn') {
const url = safeUrl(args.href ?? args.url ?? args.link);
if (!url) return `<span class="shortcode-invalid">${escape(label || '链接不可用')}</span>`;
return `<a class="shortcode-button shortcode-${name}" href="${escape(url)}" data-no-text-link>${inline(label || url)}</a>`;
}
if (name === 'file') {
const url = safeUrl(args.href ?? args.url ?? args.link);
if (!url) return `<span class="shortcode-invalid">${escape(label || '文件链接不可用')}</span>`;
const icon = '<svg class="content-file-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline><path d="M10 12v-1"></path><path d="M10 18v-2"></path><path d="M10 7v1"></path><path d="M10 15h.01"></path></svg>';
return `<a class="shortcode-file content-file" href="${escape(url)}" target="_blank" rel="noopener noreferrer" data-no-text-link>${icon}<span class="content-file-filename">${inline(label || url)}</span></a>`;
}
if (name === 'tag' || name === 'label') {
const type = TAG_TYPES.has(args.type ?? '') ? args.type : 'default';
const outline = args.outline !== undefined ? ' shortcode-tag-outline' : '';
return `<span class="shortcode-tag shortcode-tag-${type}${outline}">${inline(label)}</span>`;
}
if (name === 'hint' || name === 'tip') {
const type = HINT_TYPES.has(args.type ?? '') ? args.type : 'info';
const title = args.title ? `<strong>${inline(args.title)}</strong>` : '';
return `<aside class="shortcode-hint shortcode-hint-${type}" role="note">${title}${markdown(body)}</aside>`;
const requestedType = args.type ?? [...HINT_TYPES].find((candidate) => args[candidate] !== undefined);
const type = requestedType === 'warn' ? 'warning' : requestedType === 'error' ? 'danger' : HINT_TYPES.has(requestedType ?? '') ? requestedType : 'info';
const icon = `<svg class="content-hint-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${HINT_ICONS[type]}</svg>`;
const heading = args.title ? `<div class="content-hint-heading">${icon}<p class="content-hint-title">${inline(args.title)}</p></div>` : icon;
return `<aside class="shortcode-hint content-hint shortcode-${name} shortcode-hint-${type} hint-${type}${args.title ? ' content-hint-has-title' : ''}" role="note">${heading}${markdown(body)}</aside>`;
}
if (name === 'collapse') {
const open = args.open !== undefined || args.expanded !== undefined ? ' open' : '';
return `<details class="shortcode-collapse"${open}><summary><span class="shortcode-collapse-marker" aria-hidden="true"></span><span>${inline(label || '展开内容')}</span></summary><div class="shortcode-body"><div class="shortcode-body-inner">${markdown(body, true)}</div></div></details>`;
return `<details class="shortcode-collapse"${open}><summary><span class="shortcode-collapse-marker" aria-hidden="true"></span><span>${inline(label || '展开内容')}</span></summary><div class="shortcode-body"><div class="shortcode-body-inner">${markdown(body, true, true)}</div></div></details>`;
}
if (name === 'tabs') {
const parsed = parseBlocks(body);
@@ -151,8 +192,28 @@ function renderBlock(name: string, args: Record<string, string>, body = ''): str
function parseBlocks(source: string): Array<{ name: string; args: Record<string, string>; body: string }> {
const result: Array<{ name: string; args: Record<string, string>; body: string }> = [];
const pattern = /^\[tab([^\]]*)\]\s*\n?([\s\S]*?)^\[\/tab\]\s*$/gm;
for (const match of source.matchAll(pattern)) result.push({ name: 'tab', args: attrs(match[1]), body: match[2] });
let fence: string | null = null;
let opening: { args: Record<string, string>; bodyStart: number } | null = null;
for (const line of source.matchAll(/^[^\n]*(?:\n|$)/gm)) {
const text = line[0].trim();
const start = line.index!;
const fenceMatch = text.match(/^(`{3,}|~{3,})/);
if (fenceMatch) {
if (!fence) fence = fenceMatch[1][0];
else if (fenceMatch[1][0] === fence) fence = null;
continue;
}
if (fence) continue;
const closing = text.match(/^\[\/tab\]\s*$/i);
if (closing && opening) {
result.push({ name: 'tab', args: opening.args, body: source.slice(opening.bodyStart, start) });
opening = null;
continue;
}
const openingMatch = text.match(/^\[tab([^\]]*)\]\s*$/i);
if (openingMatch && !opening) opening = { args: attrs(openingMatch[1]), bodyStart: start + line[0].length };
}
return result;
}
@@ -220,15 +281,9 @@ function findBlocks(source: string): Array<{ name: string; args: Record<string,
}
function replaceInline(source: string): string | null {
const noticePattern = /^\[!(?:\/\s*)?\]\s*(.+)$/gm;
let replacedNotice = false;
source = source.replace(noticePattern, (_match, body) => {
replacedNotice = true;
return `<span class="shortcode-notice" role="note">${inline(body)}</span>`;
});
const shortcode = /\[(button|btn|file|tag|label)([^\]]*)\]([\s\S]*?)\[\/\1\]/gi;
const shortcode = /\[(button|btn|file|tag|label|hint|tip)([^\]]*)\]([\s\S]*?)\[\/\1\]/gi;
const replacedShortcode = shortcode.test(source);
if (!replacedNotice && !replacedShortcode) return null;
if (!replacedShortcode) return null;
return source.replace(shortcode, (_match, name, rawArgs, body) => renderBlock(name.toLowerCase(), attrs(rawArgs), body));
}
@@ -268,6 +323,7 @@ function transform(tree: any, raw = '') {
const output: any[] = [];
for (const node of root.children) {
if (node.type !== 'paragraph' && node.type !== 'html') { output.push(node); continue; }
if (node.type === 'html' && node.data?.shortcode) { output.push(node); continue; }
const source = node.type === 'html' ? node.value : node.position ? raw.slice(node.position.start.offset, node.position.end.offset) : '';
const replaced = replaceInline(source.trim());
output.push(replaced ? { type: 'html', value: replaced, data: { shortcode: true } } : node);