feat: 论坛 emoji 表情支持

- 新增 dyEmoji 表情库与解析测试、EmojiPicker 选择器与 EmojiImg 组件
- Markdown 编辑器/渲染支持表情图片,新增 public/emoji 资源与生成脚本
- 聊天室与首页适配
This commit is contained in:
2026-09-28 01:28:41 +08:00
parent 2f96a36275
commit 34aee91a13
3522 changed files with 1560 additions and 251 deletions

View File

@@ -0,0 +1,100 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import {
DY_EMOJI_ALIASES,
DY_EMOJI_GROUPS,
DY_EMOJI_RE,
dyEmojiFileKey,
dyEmojiUrl,
expandDyEmojiShortcodes,
} from "./dyEmoji.ts";
const CAT_HASH = DY_EMOJI_ALIASES["猫猫"];
describe("DY_EMOJI_ALIASES", () => {
it("中文语义名 → hash 文件名,文件名全局唯一", () => {
assert.match(CAT_HASH, /^[A-Za-z0-9_-]+$/);
const files = Object.values(DY_EMOJI_ALIASES);
assert.equal(new Set(files).size, files.length);
});
it("完整覆盖 manifest.tos(集合一致;面板分组顺序独立于 manifest)", () => {
const manifest = JSON.parse(
readFileSync(fileURLToPath(new URL("../public/emoji/dy/manifest.json", import.meta.url)), "utf8")
);
assert.deepEqual(Object.values(DY_EMOJI_ALIASES).sort(), [...manifest.tos].sort());
});
});
describe("DY_EMOJI_GROUPS", () => {
it("分组 keys 无重复", () => {
const all = DY_EMOJI_GROUPS.flatMap((g) => g.keys);
assert.equal(new Set(all).size, all.length);
});
it("分组 keys 恰好覆盖全部中文语义名(不多不少)", () => {
const grouped = DY_EMOJI_GROUPS.flatMap((g) => g.keys).sort();
const allKeys = Object.keys(DY_EMOJI_ALIASES).sort();
assert.deepEqual(grouped, allKeys);
});
});
describe("dyEmojiFileKey / dyEmojiUrl", () => {
it("中文 key 解析为实际文件名,合成表情组合名透传", () => {
assert.equal(dyEmojiFileKey("猫猫"), CAT_HASH);
assert.equal(dyEmojiFileKey("001--002--heart"), "001--002--heart");
});
it("拼接扁平目录 URL", () => {
assert.equal(dyEmojiUrl("猫猫"), `/emoji/dy/${CAT_HASH}.png`);
assert.equal(dyEmojiUrl("001--002--heart"), "/emoji/dy/001--002--heart.png");
});
it("未知 key 原样 URL 编码透传", () => {
assert.equal(dyEmojiUrl("不存在"), `/emoji/dy/${encodeURIComponent("不存在")}.png`);
});
});
describe("expandDyEmojiShortcodes", () => {
it("中文短代码替换为 markdown 图片", () => {
assert.equal(
expandDyEmojiShortcodes("你好[:猫猫:]!"),
`你好![dy:猫猫](/emoji/dy/${CAT_HASH}.png)!`
);
});
it("合成表情组合名短代码", () => {
assert.equal(
expandDyEmojiShortcodes("[:001--002--heart:]"),
"![dy:001--002--heart](/emoji/dy/001--002--heart.png)"
);
});
it("多次替换且共享前缀", () => {
assert.equal(
expandDyEmojiShortcodes("[:猫猫:][:aa:][:猫猫:]"),
`![dy:猫猫](/emoji/dy/${CAT_HASH}.png)![dy:aa](/emoji/dy/aa.png)![dy:猫猫](/emoji/dy/${CAT_HASH}.png)`
);
});
it("无短代码时原样返回", () => {
const s = "普通文本 [a:] :x: [[y]]";
assert.equal(expandDyEmojiShortcodes(s), s);
assert.equal(expandDyEmojiShortcodes(""), "");
});
it("非法 key 不匹配(含 . / 空 / @ / 空白)", () => {
assert.equal(expandDyEmojiShortcodes("[:a.b:][:]"), "[:a.b:][:]");
assert.equal(expandDyEmojiShortcodes("[:空 格:]"), "[:空 格:]");
assert.equal(expandDyEmojiShortcodes("[:@x:]"), "[:@x:]");
});
it("跨行文本", () => {
assert.equal(
expandDyEmojiShortcodes("第一行[:猫猫:]\n第二行"),
`第一行![dy:猫猫](/emoji/dy/${CAT_HASH}.png)\n第二行`
);
});
it("连续使用共享同一正则(lastIndex 复用安全)", () => {
assert.equal(expandDyEmojiShortcodes("[:猫猫:]"), `![dy:猫猫](/emoji/dy/${CAT_HASH}.png)`);
assert.equal(expandDyEmojiShortcodes("[:b:]"), "![dy:b](/emoji/dy/b.png)");
assert.equal(expandDyEmojiShortcodes("纯文本"), "纯文本");
assert.equal(expandDyEmojiShortcodes("[:c:]"), "![dy:c](/emoji/dy/c.png)");
});
it("正则含 g 标志(供分段渲染使用)", () => {
assert.ok(DY_EMOJI_RE.global);
});
});