From c17a4a422582594c6e2127429a7373c2e0ade11c Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 20 Jun 2026 11:12:38 +0800 Subject: [PATCH] feat(scripts): add segment-anchored tool-path rewrite transform --- scripts/lib/rewrite-tool-paths.ts | 33 ++++++++ tests/unit/scripts/rewrite-tool-paths.test.ts | 79 +++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 scripts/lib/rewrite-tool-paths.ts create mode 100644 tests/unit/scripts/rewrite-tool-paths.test.ts diff --git a/scripts/lib/rewrite-tool-paths.ts b/scripts/lib/rewrite-tool-paths.ts new file mode 100644 index 00000000..9a970b81 --- /dev/null +++ b/scripts/lib/rewrite-tool-paths.ts @@ -0,0 +1,33 @@ +export type IdToSection = Record; + +// A real toolId must be a full path segment: the char after it is end-of-string +// or a boundary char (so "convert" never matches inside "convert-video", whose +// next char is "-"). ":" lets YAML path keys match (".../resize:"); the backtick +// lets backtick string literals match. ":toolId"/"{toolId}" placeholders never +// appear as real ids in the map, so parametric routes are left untouched. +const BOUNDARY = "(?=$|[/\"'`\\s?#:])"; + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function rewriteToolPaths(text: string, idToSection: IdToSection): string { + const ids = Object.keys(idToSection).sort((a, b) => b.length - a.length); // longest first + + // Guard: the transform is idempotent only because no section slug (a value) + // is also a toolId (a key). Fail loudly if that invariant is ever violated. + const sections = new Set(Object.values(idToSection)); + for (const id of Object.keys(idToSection)) { + if (sections.has(id)) { + throw new Error( + `rewriteToolPaths: id "${id}" is also a section slug, which would break idempotency`, + ); + } + } + let out = text; + for (const id of ids) { + const re = new RegExp(`/api/v1/tools/${escapeRegExp(id)}${BOUNDARY}`, "g"); + out = out.replace(re, `/api/v1/tools/${idToSection[id]}/${id}`); + } + return out; +} diff --git a/tests/unit/scripts/rewrite-tool-paths.test.ts b/tests/unit/scripts/rewrite-tool-paths.test.ts new file mode 100644 index 00000000..85fa778d --- /dev/null +++ b/tests/unit/scripts/rewrite-tool-paths.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "vitest"; +import { rewriteToolPaths } from "../../../scripts/lib/rewrite-tool-paths.js"; + +const MAP: Record = { + crop: "image", + "crop-video": "video", + convert: "image", + "convert-video": "video", + ocr: "image", + "ocr-pdf": "pdf", + resize: "image", + "passport-photo": "image", + "strip-metadata": "image", +}; + +describe("rewriteToolPaths", () => { + it("inserts the section as a full segment", () => { + expect(rewriteToolPaths(`fetch("/api/v1/tools/crop")`, MAP)).toBe( + `fetch("/api/v1/tools/image/crop")`, + ); + }); + + it("does NOT corrupt prefix-colliding ids", () => { + expect(rewriteToolPaths("/api/v1/tools/convert-video", MAP)).toBe( + "/api/v1/tools/video/convert-video", + ); + expect(rewriteToolPaths("/api/v1/tools/ocr-pdf", MAP)).toBe("/api/v1/tools/pdf/ocr-pdf"); + }); + + it("handles sub-routes and YAML path keys", () => { + expect(rewriteToolPaths("/api/v1/tools/passport-photo/analyze", MAP)).toBe( + "/api/v1/tools/image/passport-photo/analyze", + ); + expect(rewriteToolPaths(" /api/v1/tools/resize:", MAP)).toBe(" /api/v1/tools/image/resize:"); + expect(rewriteToolPaths("/api/v1/tools/strip-metadata/inspect", MAP)).toBe( + "/api/v1/tools/image/strip-metadata/inspect", + ); + }); + + it("matches inside backtick template literals", () => { + expect(rewriteToolPaths("`/api/v1/tools/resize`", MAP)).toBe("`/api/v1/tools/image/resize`"); + }); + + it("leaves parametric placeholders and wildcards untouched", () => { + expect(rewriteToolPaths("/api/v1/tools/:toolId/batch", MAP)).toBe( + "/api/v1/tools/:toolId/batch", + ); + expect(rewriteToolPaths("/api/v1/tools/{toolId}/batch", MAP)).toBe( + "/api/v1/tools/{toolId}/batch", + ); + expect(rewriteToolPaths("**/api/v1/tools/**", MAP)).toBe("**/api/v1/tools/**"); + }); + + it("rewrites the id inside a glob", () => { + expect(rewriteToolPaths("**/api/v1/tools/resize", MAP)).toBe("**/api/v1/tools/image/resize"); + }); + + it("is idempotent", () => { + const once = rewriteToolPaths("/api/v1/tools/convert", MAP); + expect(rewriteToolPaths(once, MAP)).toBe(once); + expect(once).toBe("/api/v1/tools/image/convert"); + }); + + it("rewrites multiple distinct ids on one line", () => { + expect(rewriteToolPaths(`"/api/v1/tools/crop" then "/api/v1/tools/resize"`, MAP)).toBe( + `"/api/v1/tools/image/crop" then "/api/v1/tools/image/resize"`, + ); + }); + + it("leaves unknown ids untouched", () => { + expect(rewriteToolPaths("/api/v1/tools/nonexistent-tool", MAP)).toBe( + "/api/v1/tools/nonexistent-tool", + ); + }); + + it("throws if an id is also a section slug (idempotency invariant)", () => { + expect(() => rewriteToolPaths("x", { image: "image" })).toThrow(/idempotency/); + }); +});