292 lines
14 KiB
TypeScript
292 lines
14 KiB
TypeScript
import zhCn from '../data/runtime/language/zh_cn.json';
|
|
import type {font_style, log} from '../types/utils/logger';
|
|
|
|
type TextComponent = MinecraftTextComponent | NbtTag | string | number | boolean | TextComponent[];
|
|
interface NbtTag {
|
|
type: string;
|
|
value: unknown;
|
|
}
|
|
type Format = 'bold' | 'italic' | 'underlined' | 'strikethrough' | 'obfuscated';
|
|
const formats: Format[] = ['bold', 'italic', 'underlined', 'strikethrough', 'obfuscated'];
|
|
const maxDepth = 64;
|
|
|
|
interface MinecraftTextComponent {
|
|
text?: string;
|
|
translate?: string;
|
|
fallback?: string;
|
|
with?: TextComponent[];
|
|
color?: string;
|
|
bold?: boolean | number;
|
|
italic?: boolean | number;
|
|
underlined?: boolean | number;
|
|
strikethrough?: boolean | number;
|
|
obfuscated?: boolean | number;
|
|
extra?: TextComponent[];
|
|
hoverEvent?: HoverEvent;
|
|
hover_event?: HoverEvent;
|
|
clickEvent?: ClickEvent;
|
|
click_event?: ClickEvent;
|
|
insertion?: string;
|
|
font?: string;
|
|
json?: TextComponent;
|
|
warn?: Function;
|
|
}
|
|
|
|
interface HoverEvent {
|
|
action: 'show_text' | 'show_item' | 'show_entity';
|
|
contents?: TextComponent | Record<string, unknown>;
|
|
value?: TextComponent | Record<string, unknown>;
|
|
}
|
|
|
|
interface ClickEvent {
|
|
action: 'open_url' | 'open_file' | 'run_command' | 'suggest_command' | 'change_page' | 'copy_to_clipboard' | 'show_dialog' | 'custom';
|
|
value?: string;
|
|
command?: string;
|
|
url?: string;
|
|
page?: string | number;
|
|
path?: string;
|
|
dialog?: string;
|
|
id?: string;
|
|
payload?: string;
|
|
text?: string;
|
|
}
|
|
|
|
interface ParsedMessage {
|
|
username: string;
|
|
message: string;
|
|
log: log[];
|
|
normalized: MinecraftTextComponent | null;
|
|
plainText: string;
|
|
}
|
|
|
|
const colorMap: Record<string, string> = {
|
|
black: '#000000', dark_blue: '#0000AA', dark_green: '#00AA00', dark_aqua: '#00AAAA',
|
|
dark_red: '#AA0000', dark_purple: '#AA00AA', gold: '#FFAA00', gray: '#AAAAAA',
|
|
dark_gray: '#555555', blue: '#5555FF', green: '#55FF55', aqua: '#55FFFF',
|
|
red: '#FF5555', light_purple: '#FF55FF', yellow: '#FFFF55', white: '#FFFFFF'
|
|
};
|
|
|
|
/** Minecraft / prismarine-chat 文本组件解析器。 */
|
|
export class MinecraftJsonParser {
|
|
static parse(jsonData: TextComponent | null | undefined): ParsedMessage {
|
|
const normalized = this.normalizeJson(jsonData);
|
|
const entries = this.toLog(normalized);
|
|
const plainText = entries.map(entry => entry.message).join('');
|
|
return {
|
|
...this.parseUsernameAndMessage(plainText),
|
|
plainText,
|
|
log: entries,
|
|
normalized
|
|
};
|
|
}
|
|
|
|
static normalizeJson(node: TextComponent | null | undefined, depth = 0): MinecraftTextComponent | null {
|
|
if (node == null || depth > maxDepth) return null;
|
|
if (typeof node !== 'object') return {text: String(node)};
|
|
if (!Array.isArray(node) && 'type' in node && 'value' in node) {
|
|
const unwrap = (tag: unknown, level: number): unknown => {
|
|
if (level > maxDepth || tag == null) return null;
|
|
if (Array.isArray(tag)) return tag.map(value => unwrap(value, level + 1));
|
|
if (typeof tag !== 'object') return tag;
|
|
const value = tag as NbtTag;
|
|
if (value.type === 'compound' && value.value && typeof value.value === 'object') {
|
|
return Object.fromEntries(Object.entries(value.value).map(([key, child]) => [key, unwrap(child, level + 1)]));
|
|
}
|
|
if (value.type === 'list' && value.value && typeof value.value === 'object') {
|
|
const list = value.value as {type: string; value: unknown[]};
|
|
return Array.isArray(list.value) ? list.value.map(child => unwrap({type: list.type, value: child}, level + 1)) : [];
|
|
}
|
|
if ('value' in value) return value.value;
|
|
return tag;
|
|
};
|
|
return this.normalizeJson(unwrap(node, depth) as TextComponent, depth + 1);
|
|
}
|
|
if (Array.isArray(node)) {
|
|
// Minecraft 数组中的后续组件继承第一个组件的样式。
|
|
const [first, ...rest] = node;
|
|
const root = this.normalizeJson(first, depth + 1);
|
|
if (!root) return null;
|
|
const extra = rest.map(child => this.normalizeJson(child, depth + 1))
|
|
.filter((child): child is MinecraftTextComponent => child !== null);
|
|
if (extra.length) root.extra = [...(root.extra ?? []), ...extra];
|
|
return root;
|
|
}
|
|
|
|
// ChatMessage 的原始协议组件保留了 font、事件和完整的 Unicode 文本。
|
|
if (node.json != null && typeof node.warn === 'function') {
|
|
return this.normalizeJson(node.json, depth + 1);
|
|
}
|
|
|
|
const result: MinecraftTextComponent = {};
|
|
if (node.text !== undefined) result.text = String(node.text ?? '');
|
|
if (node.translate !== undefined) result.translate = node.translate;
|
|
if (node.fallback !== undefined) result.fallback = node.fallback;
|
|
if (node.color) result.color = node.color;
|
|
if (node.font) result.font = node.font;
|
|
for (const format of formats) {
|
|
// false / 0 必须保留,用于关闭父组件继承下来的样式。
|
|
if (node[format] !== undefined) result[format] = !!node[format];
|
|
}
|
|
const hoverEvent = this.extractHoverEvent(node);
|
|
if (hoverEvent) result.hoverEvent = hoverEvent;
|
|
const clickEvent = this.extractClickEvent(node);
|
|
if (clickEvent) result.clickEvent = clickEvent;
|
|
const insertion = node.insertion ?? (node.json as MinecraftTextComponent)?.insertion;
|
|
if (insertion !== undefined) result.insertion = insertion;
|
|
|
|
if (Array.isArray(node.with)) {
|
|
result.with = node.with.map(child => this.normalizeJson(child, depth + 1) ?? {text: ''});
|
|
}
|
|
if (Array.isArray(node.extra)) {
|
|
result.extra = node.extra.map(child => this.normalizeJson(child, depth + 1))
|
|
.filter((child): child is MinecraftTextComponent => child !== null);
|
|
}
|
|
return Object.keys(result).length ? result : null;
|
|
}
|
|
|
|
static extractHoverEvent(node: MinecraftTextComponent): HoverEvent | undefined {
|
|
const raw = node.json as MinecraftTextComponent | undefined;
|
|
return node.hoverEvent ?? node.hover_event ?? raw?.hoverEvent ?? raw?.hover_event;
|
|
}
|
|
|
|
static extractClickEvent(node: MinecraftTextComponent): ClickEvent | undefined {
|
|
const raw = node.json as MinecraftTextComponent | undefined;
|
|
const event = node.clickEvent ?? node.click_event ?? raw?.clickEvent ?? raw?.click_event;
|
|
if (!event || typeof event.action !== 'string') return undefined;
|
|
const value = event.value ?? (() => {
|
|
switch (event.action) {
|
|
case 'open_url': return event.url;
|
|
case 'run_command':
|
|
case 'suggest_command': return event.command;
|
|
case 'change_page': return event.page;
|
|
case 'open_file': return event.path;
|
|
case 'show_dialog': return event.dialog;
|
|
case 'custom': return event.payload ?? event.id;
|
|
case 'copy_to_clipboard': return event.text;
|
|
default: return undefined;
|
|
}
|
|
})();
|
|
if (typeof value === 'string') return {action: event.action, value};
|
|
if (typeof value === 'number' && Number.isFinite(value)) return {action: event.action, value: String(value)};
|
|
return undefined;
|
|
}
|
|
|
|
static expandTranslate(node: MinecraftTextComponent): MinecraftTextComponent | null {
|
|
if (node.translate === undefined) return null;
|
|
const template = (zhCn as Record<string, string>)[node.translate] ?? node.fallback ?? node.translate;
|
|
const params = node.with ?? [];
|
|
const extra: TextComponent[] = [];
|
|
let lastIndex = 0;
|
|
let seqIndex = 0;
|
|
for (const match of template.matchAll(/%%|%(?:(\d+)\$)?s/g)) {
|
|
if (match.index > lastIndex) extra.push({text: template.slice(lastIndex, match.index)});
|
|
if (match[0] === '%%') {
|
|
extra.push({text: '%'});
|
|
} else {
|
|
const index = match[1] ? Number(match[1]) - 1 : seqIndex++;
|
|
extra.push(params[index] ?? {text: match[0]});
|
|
}
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
if (lastIndex < template.length) extra.push({text: template.slice(lastIndex)});
|
|
// 在同一父组件下展开,保留事件、样式继承以及翻译文本后的 extra。
|
|
const {translate, fallback, with: withParams, ...base} = node;
|
|
return {...base, text: '', extra: [...extra, ...(node.extra ?? [])]};
|
|
}
|
|
|
|
static extractPlainText(node: TextComponent | null | undefined): string {
|
|
const visit = (component: MinecraftTextComponent | null, depth: number): string => {
|
|
if (!component || depth > maxDepth) return '';
|
|
const current = component.translate !== undefined ? this.expandTranslate(component)! : component;
|
|
return (current.text ?? '') + (current.extra ?? [])
|
|
.map(child => visit(child as MinecraftTextComponent, depth + 1)).join('');
|
|
};
|
|
return visit(this.normalizeJson(node), 0);
|
|
}
|
|
|
|
static parseUsernameAndMessage(plainText: string): {username: string; message: string} {
|
|
const text = plainText.trim();
|
|
const vanilla = text.match(/^<([^>]+)>\s*([\s\S]*)$/);
|
|
if (vanilla) return {username: vanilla[1], message: vanilla[2]};
|
|
const arrowIndex = text.indexOf('»');
|
|
if (arrowIndex === -1) return {username: '', message: text};
|
|
const beforeArrow = text.slice(0, arrowIndex).trim();
|
|
const username = beforeArrow.match(/(\S+)\s*$/)?.[1] ?? '';
|
|
return {username, message: text.slice(arrowIndex + 1).trim()};
|
|
}
|
|
|
|
/** 返回可直接传给 Logger.log() 的日志片段。 */
|
|
static toLog(node: TextComponent | null | undefined, includeEvents = true): log[] {
|
|
const result: log[] = [];
|
|
const visit = (component: MinecraftTextComponent | null, inherited: MinecraftTextComponent, depth: number): void => {
|
|
if (!component || depth > maxDepth) return;
|
|
const current = component.translate !== undefined ? this.expandTranslate(component)! : component;
|
|
const style: MinecraftTextComponent = {
|
|
color: current.color ?? inherited.color,
|
|
clickEvent: current.clickEvent ?? inherited.clickEvent,
|
|
hoverEvent: current.hoverEvent ?? inherited.hoverEvent,
|
|
insertion: current.insertion ?? inherited.insertion
|
|
};
|
|
for (const format of formats) style[format] = current[format] ?? inherited[format];
|
|
if (current.text) {
|
|
const font_style: font_style[] = [];
|
|
if (style.bold) font_style.push('bold');
|
|
if (style.italic) font_style.push('italic');
|
|
if (style.underlined) font_style.push('underline');
|
|
if (style.clickEvent?.action === 'open_url') font_style.push('url');
|
|
if (!font_style.length) font_style.push('normal');
|
|
const font_color = this.normalizeColor(style.color);
|
|
const entry: log = {message: current.text, font_color, font_style};
|
|
if (includeEvents && style.clickEvent) entry.clickEvent = {...style.clickEvent} as log['clickEvent'];
|
|
if (includeEvents && style.insertion !== undefined) entry.insertion = style.insertion;
|
|
if (includeEvents && style.hoverEvent) {
|
|
const {action} = style.hoverEvent;
|
|
const content = style.hoverEvent.contents ?? style.hoverEvent.value;
|
|
if (content !== undefined && ['show_text', 'show_item', 'show_entity'].includes(action)) {
|
|
let value: string | undefined;
|
|
if (action === 'show_text') value = this.extractPlainText(content as TextComponent);
|
|
else {
|
|
try { value = typeof content === 'string' ? content : JSON.stringify(content); }
|
|
catch { /* Ignore malformed hover payloads without losing message text. */ }
|
|
}
|
|
if (value !== undefined) {
|
|
entry.hoverEvent = {action, value};
|
|
if (action === 'show_text') {
|
|
let remaining = 4096;
|
|
entry.hoverEvent.segments = this.toLog(content as TextComponent, false).slice(0, 64)
|
|
.map(({message, font_color, font_style}) => {
|
|
const text = String(message).slice(0, remaining);
|
|
remaining -= text.length;
|
|
return {message: text, font_color, font_style};
|
|
}).filter(segment => segment.message.length > 0);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
const previous = result[result.length - 1];
|
|
if (previous && previous.font_color === font_color
|
|
&& previous.font_style.join(',') === font_style.join(',')
|
|
&& JSON.stringify(previous.hoverEvent) === JSON.stringify(entry.hoverEvent)
|
|
&& JSON.stringify(previous.clickEvent) === JSON.stringify(entry.clickEvent)
|
|
&& previous.insertion === entry.insertion) {
|
|
previous.message += current.text;
|
|
} else {
|
|
result.push(entry);
|
|
}
|
|
}
|
|
for (const child of current.extra ?? []) visit(child as MinecraftTextComponent, style, depth + 1);
|
|
};
|
|
visit(this.normalizeJson(node), {}, 0);
|
|
return result;
|
|
}
|
|
|
|
static normalizeColor(color: string | undefined): string {
|
|
if (color && /^#[\da-f]{6}$/i.test(color)) return color;
|
|
return color && Object.hasOwn(colorMap, color) ? colorMap[color] : '#FFFFFF';
|
|
}
|
|
|
|
static debug(jsonData: TextComponent | null | undefined): MinecraftTextComponent | null {
|
|
return this.normalizeJson(jsonData);
|
|
}
|
|
}
|