diff --git a/README.md b/README.md index 9c2ad72..4328426 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/package-lock.json b/package-lock.json index 8f519b9..6ad4ee6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/src/all_fonts.ts b/src/all_fonts.ts new file mode 100644 index 0000000..1e199d9 --- /dev/null +++ b/src/all_fonts.ts @@ -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; +} diff --git a/src/all_fonts_interaction_test.ts b/src/all_fonts_interaction_test.ts new file mode 100644 index 0000000..d699929 --- /dev/null +++ b/src/all_fonts_interaction_test.ts @@ -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); +}); diff --git a/src/all_fonts_test.ts b/src/all_fonts_test.ts new file mode 100644 index 0000000..c31994f --- /dev/null +++ b/src/all_fonts_test.ts @@ -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"); +}); diff --git a/src/context_commands.ts b/src/context_commands.ts index c832c45..e3020f3 100644 --- a/src/context_commands.ts +++ b/src/context_commands.ts @@ -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)), ]; diff --git a/src/listener/interaction_create.ts b/src/listener/interaction_create.ts index 253dda9..4bbbe66 100644 --- a/src/listener/interaction_create.ts +++ b/src/listener/interaction_create.ts @@ -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, ): Promise { 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