feat: add all-fonts slash command

This commit is contained in:
2026-10-12 00:57:46 +09:00
parent 44dada3e23
commit a93c07d8ea
7 changed files with 147 additions and 4 deletions
+6
View File
@@ -19,6 +19,12 @@ npm start
The global `Fontify` message command is refreshed at startup. In Discord, right-click a message,
open Apps, select Fontify, and choose a font. Only the user who opened the menu can choose from it.
Use `/fontify-all text:Hello world` to post all 14 available styles together, with
font names and copyable code blocks. The text option accepts up to 200 characters.
Long results are automatically split into messages without truncation; mentions
are disabled. Characters without a font mapping (including Japanese) stay unchanged.
Both commands are registered globally at startup.
## Develop
```sh
+3 -3
View File
@@ -1155,9 +1155,9 @@
}
},
"node_modules/undici": {
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"version": "6.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.29.0.tgz",
"integrity": "sha512-R+RODBqp6i2pPflGdq+xIOUkl+RNfGgHwoinecKu/JCuf2uO06cOKoDbI2P7Dn6KcswdKwrczbU6IYJ6K8X+wg==",
"license": "MIT",
"engines": {
"node": ">=18.17"
+25
View File
@@ -0,0 +1,25 @@
import list from "./fontlist.ts";
import { translateText } from "./translation.ts";
export const ALL_FONTS_MAX_INPUT = 200;
/** Keep each labelled font together; never split Unicode or truncate output. */
export function formatAllFonts(text: string): string[] {
if (!text.trim() || text.length > ALL_FONTS_MAX_INPUT) {
throw new RangeError(`Text must contain 1–${ALL_FONTS_MAX_INPUT} characters.`);
}
const messages: string[] = [];
let current = "";
for (const { label, font } of list) {
const translated = translateText(text, font).replaceAll("```", "``\u200b`");
const block = `**${label}**\n\`\`\`\n${translated}\n\`\`\``;
if (block.length > 2_000) throw new RangeError("Font output exceeds Discord's limit.");
if (current && current.length + 2 + block.length > 2_000) {
messages.push(current);
current = "";
}
current += `${current ? "\n\n" : ""}${block}`;
}
if (current) messages.push(current);
return messages;
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { Interaction } from "discord.js";
import interactionCreate from "./listener/interaction_create.ts";
import { formatAllFonts } from "./all_fonts.ts";
test("slash command acknowledges first and posts every chunk without mentions", async () => {
const calls: { method: string; payload?: any }[] = [];
const text = "A".repeat(200);
const interaction = {
isChatInputCommand: () => true,
commandName: "fontify-all",
options: { getString: () => text },
deferReply: async () => { calls.push({ method: "defer" }); },
editReply: async (payload: any) => { calls.push({ method: "edit", payload }); },
followUp: async (payload: any) => { calls.push({ method: "follow", payload }); },
} as unknown as Interaction;
await interactionCreate(interaction);
assert.equal(calls[0].method, "defer");
assert.equal(calls[1].method, "edit");
assert.deepEqual(calls.slice(1).map((c) => c.payload.content), formatAllFonts(text));
assert.ok(calls.slice(2).every((c) => c.method === "follow"));
for (const call of calls.slice(1)) assert.deepEqual(call.payload.allowedMentions, { parse: [] });
});
test("blank input produces only a private validation reply", async () => {
const calls: any[] = [];
const interaction = {
isChatInputCommand: () => true,
commandName: "fontify-all",
options: { getString: () => " " },
reply: async (payload: any) => { calls.push(payload); },
} as unknown as Interaction;
await interactionCreate(interaction);
assert.equal(calls.length, 1);
assert.equal(calls[0].ephemeral, true);
});
+47
View File
@@ -0,0 +1,47 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { formatAllFonts } from "./all_fonts.ts";
import list from "./fontlist.ts";
import { translateText } from "./translation.ts";
import commands from "./context_commands.ts";
test("all fonts appear exactly once, in catalogue order, with converted text", () => {
const text = "Hello 123 日本語 😀";
const output = formatAllFonts(text).join("\n\n");
assert.equal(output, list.map(({ label, font }) =>
`**${label}**\n\`\`\`\n${translateText(text, font)}\n\`\`\``
).join("\n\n"));
});
test("long input is split into bounded messages without dropping any font", () => {
const messages = formatAllFonts("A".repeat(200));
assert.ok(messages.length > 1);
assert.ok(messages.every((message) => message.length <= 2_000));
for (const { label, font } of list) {
assert.equal(messages.filter((m) => m.includes(`**${label}**\n`)).length, 1);
assert.ok(messages.some((m) => m.includes(translateText("A".repeat(200), font))));
}
});
test("code fences are escaped and emoji stay intact", () => {
const messages = formatAllFonts("```😀".repeat(40));
for (const message of messages) {
assert.ok(message.length <= 2_000);
assert.ok(message.includes("``\u200b`😀"));
assert.ok(!message.includes("\uFFFD"));
}
});
test("reject invalid input", () => {
for (const text of ["", " \n\t", "a".repeat(201)]) {
assert.throws(() => formatAllFonts(text), RangeError);
}
});
test("registration preserves Fontify and adds the bounded slash command", () => {
const definitions = commands.map((command) => command.toJSON());
assert.deepEqual(definitions.map((c) => c.name), ["Fontify", "fontify-all"]);
const slash = definitions[1];
assert.equal(slash.type, 1);
assert.equal(slash.options?.[0].name, "text");
});
+10 -1
View File
@@ -1,7 +1,16 @@
import { ApplicationCommandType, ContextMenuCommandBuilder } from "discord.js";
import { ApplicationCommandType, ContextMenuCommandBuilder, SlashCommandBuilder } from "discord.js";
export default [
new ContextMenuCommandBuilder()
.setName("Fontify")
.setType(ApplicationCommandType.Message),
new SlashCommandBuilder()
.setName("fontify-all")
.setDescription("Show your text in every available font / 全書体をまとめて表示")
.addStringOption((option) => option
.setName("text")
.setDescription("Text to convert / 変換する文字列(200文字まで)")
.setRequired(true)
.setMinLength(1)
.setMaxLength(200)),
];
+19
View File
@@ -5,6 +5,7 @@ import fontSelectionComponent, {
} from "../selection_component.ts";
import list from "../fontlist.ts";
import { formatTranslation, translateText } from "../translation.ts";
import { ALL_FONTS_MAX_INPUT, formatAllFonts } from "../all_fonts.ts";
const FONTIFY_COMMAND = "Fontify";
@@ -12,6 +13,24 @@ export default async function interactionCreate(
interaction: Interaction<CacheType>,
): Promise<void> {
try {
if (interaction.isChatInputCommand() && interaction.commandName === "fontify-all") {
const text = interaction.options.getString("text", true);
if (!text.trim() || text.length > ALL_FONTS_MAX_INPUT) {
await interaction.reply({
content: `空白以外の文字を含む${ALL_FONTS_MAX_INPUT}文字以内のテキストを指定してください。`,
ephemeral: true,
});
return;
}
await interaction.deferReply();
const messages = formatAllFonts(text);
await interaction.editReply({ content: messages[0], allowedMentions: { parse: [] } });
for (const content of messages.slice(1)) {
await interaction.followUp({ content, allowedMentions: { parse: [] } });
}
return;
}
if (
interaction.isMessageContextMenuCommand() &&
interaction.commandName === FONTIFY_COMMAND