From 9ca901f8d5b23b0a03060b3384af58c60cfbbaa0 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 20 Jun 2026 11:05:44 +0800 Subject: [PATCH] feat(shared): add apiToolPath() and section-prefixed routes; drop MODALITY_URL_SLUG --- packages/shared/src/constants.ts | 22 +++++++++++------- packages/shared/src/modality.ts | 8 ------- tests/e2e/navigation.spec.ts | 4 ++-- tests/unit/shared/api-tool-path.test.ts | 31 +++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 18 deletions(-) create mode 100644 tests/unit/shared/api-tool-path.test.ts diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 01850c30..3d6c1bc0 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -1,10 +1,5 @@ -import { - AUDIO_INPUTS, - IMAGE_INPUTS, - MODALITY_URL_SLUG, - SUBTITLE_INPUTS, - VIDEO_INPUTS, -} from "./modality.js"; +import { AUDIO_INPUTS, IMAGE_INPUTS, SUBTITLE_INPUTS, VIDEO_INPUTS } from "./modality.js"; +import { toolSection } from "./section.js"; import type { CategoryInfo, SocialMediaPreset, Tool } from "./types.js"; export const CATEGORIES: CategoryInfo[] = [ @@ -1790,7 +1785,18 @@ export const TOOLS: Tool[] = [ ]; for (const tool of TOOLS) { - tool.route = `/${MODALITY_URL_SLUG[tool.modality]}${tool.route}`; + const slug = `/${toolSection(tool)}`; + if (!tool.route.startsWith(`${slug}/`)) { + tool.route = `${slug}${tool.route}`; + } +} + +export function apiToolPath( + toolOrId: string | Pick, +): string { + const tool = typeof toolOrId === "string" ? TOOLS.find((t) => t.id === toolOrId) : toolOrId; + if (!tool) throw new Error(`apiToolPath: unknown tool "${String(toolOrId)}"`); + return `/api/v1/tools/${toolSection(tool)}/${tool.id}`; } export const SOCIAL_MEDIA_PRESETS: SocialMediaPreset[] = [ diff --git a/packages/shared/src/modality.ts b/packages/shared/src/modality.ts index f4553d8d..e260ce5f 100644 --- a/packages/shared/src/modality.ts +++ b/packages/shared/src/modality.ts @@ -117,14 +117,6 @@ export const DOCUMENT_INPUTS = [ ]; export const FILE_INPUTS = [".csv", ".json", ".xml", ".yaml", ".yml", ".zip"]; -export const MODALITY_URL_SLUG: Record = { - image: "image", - video: "video", - audio: "audio", - document: "document", - file: "data", -}; - export function detectModalityFromMime(mime: string): Modality { if (mime.startsWith("image/")) return "image"; if (mime.startsWith("video/")) return "video"; diff --git a/tests/e2e/navigation.spec.ts b/tests/e2e/navigation.spec.ts index e6ca875f..00189f9e 100644 --- a/tests/e2e/navigation.spec.ts +++ b/tests/e2e/navigation.spec.ts @@ -62,9 +62,9 @@ test.describe("Navigation", () => { }); test("clicking a tool on home page navigates to tool page", async ({ loggedInPage: page }) => { - // Routes are /:modality/:toolId (App.tsx line 243). + // Routes are /:section/:toolId (App.tsx line 243). // Resize route = /image/resize (constants.ts: route "/resize" + modality "image", - // post-processed at line 1793 with MODALITY_URL_SLUG prefix). + // section prefix added via toolSection() in the route post-processing loop). await page.goto("/"); // Click on Resize tool diff --git a/tests/unit/shared/api-tool-path.test.ts b/tests/unit/shared/api-tool-path.test.ts new file mode 100644 index 00000000..293a56d9 --- /dev/null +++ b/tests/unit/shared/api-tool-path.test.ts @@ -0,0 +1,31 @@ +import { apiToolPath, TOOLS } from "@snapotter/shared"; +import { describe, expect, it } from "vitest"; + +describe("apiToolPath", () => { + it("builds /api/v1/tools/
/", () => { + expect(apiToolPath("crop")).toBe("/api/v1/tools/image/crop"); + expect(apiToolPath("merge-pdf")).toBe("/api/v1/tools/pdf/merge-pdf"); + expect(apiToolPath("word-to-pdf")).toBe("/api/v1/tools/files/word-to-pdf"); + expect(apiToolPath("csv-json")).toBe("/api/v1/tools/files/csv-json"); + }); + + it("throws on unknown tool", () => { + expect(() => apiToolPath("nope")).toThrow(/unknown tool/); + }); +}); + +describe("route values", () => { + it("are section-prefixed and unique", () => { + const routes = TOOLS.map((t) => t.route); + expect(routes).toContain("/pdf/merge-pdf"); + expect(routes).toContain("/files/csv-json"); + expect(routes).toContain("/image/crop"); + expect(new Set(routes).size).toBe(TOOLS.length); // no collisions + // every route is exactly /
/ (two segments) + expect(routes.every((r) => r.split("/").length === 3)).toBe(true); + }); + + it("has globally unique ids", () => { + expect(new Set(TOOLS.map((t) => t.id)).size).toBe(TOOLS.length); + }); +});