diff --git a/app/(tools)/url-encoder/UrlEncoderClient.test.tsx b/app/(tools)/url-encoder/UrlEncoderClient.test.tsx index ddd58c0..b99f878 100644 --- a/app/(tools)/url-encoder/UrlEncoderClient.test.tsx +++ b/app/(tools)/url-encoder/UrlEncoderClient.test.tsx @@ -110,4 +110,45 @@ describe("UrlEncoderClient tabs", () => { expect(screen.queryByRole("button", { name: "Copy encoded output" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Copy decoded output" })).toHaveTextContent("Copy"); }); + + it.each([ + ["?tag=react&tag=typescript", ["react", "typescript"]], + ["?tag=react&tag=react", ["react", "react"]], + ["?tag=&tag=typescript", ["", "typescript"]], + ["?tag=a&%74ag=b", ["a", "b"]], + ])("flags repeated parameters in %s", (query, values) => { + render(); + fireEvent.change(screen.getByRole("textbox", { name: "Text or URL to encode" }), { + target: { value: `https://example.com/${query}` }, + }); + expect(screen.getAllByText("Repeated key")).toHaveLength(2); + expect(screen.getByRole("status")).toHaveClass("border-[var(--amber)]", "text-[var(--amber)]"); + expect(screen.getByRole("status")).toHaveTextContent("1 repeated key: tag."); + expect(screen.getAllByText("Repeated key")[0]).toHaveClass("border-[var(--amber)]", "text-[var(--amber)]"); + expect(screen.getByText(/not necessarily errors/)).toBeInTheDocument(); + const rows = screen.getAllByRole("button", { name: "Copy value of tag" }); + expect(rows.map((button) => button.parentElement?.querySelector("span.text-\\[var\\(--fg\\)\\]")?.textContent)).toEqual(values); + }); + + it("keeps case-sensitive and unique keys unflagged and labels empty names", () => { + render(); + const input = screen.getByRole("textbox", { name: "Text or URL to encode" }); + fireEvent.change(input, { target: { value: "https://example.com/?Tag=a&tag=b&=empty" } }); + expect(screen.queryByText("Repeated key")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Copy value of (empty name)" }).parentElement).toHaveTextContent("(empty name) = empty"); + fireEvent.change(input, { target: { value: "https://example.com/?=first&=second" } }); + expect(screen.getAllByText("Repeated key")).toHaveLength(2); + expect(screen.getByRole("status")).toHaveTextContent("1 repeated key: (empty name)."); + }); + + it("updates repeated-key indicators when the URL changes", () => { + render(); + const input = screen.getByRole("textbox", { name: "Text or URL to encode" }); + fireEvent.change(input, { target: { value: "https://example.com/?tag=a&tag=b" } }); + expect(screen.getAllByText("Repeated key")).toHaveLength(2); + fireEvent.change(input, { target: { value: "https://example.com/?tag=a&sort=b" } }); + expect(screen.queryByText("Repeated key")).not.toBeInTheDocument(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + }); }); diff --git a/app/(tools)/url-encoder/UrlEncoderClient.tsx b/app/(tools)/url-encoder/UrlEncoderClient.tsx index 8150c77..f4f1deb 100644 --- a/app/(tools)/url-encoder/UrlEncoderClient.tsx +++ b/app/(tools)/url-encoder/UrlEncoderClient.tsx @@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from "react"; import { AlertCircle, ArrowLeftRight } from "lucide-react"; -import { encodeUrl, decodeUrl, parseUrl, type UrlEncodeMode } from "@/lib/url-utils"; +import { analyzeQueryParams, encodeUrl, decodeUrl, parseUrl, type UrlEncodeMode } from "@/lib/url-utils"; import { CopyButton } from "@/components/shared/CopyButton"; import { useRegisterCommands } from "@/components/shared/CommandPalette"; @@ -40,6 +40,7 @@ export function UrlEncoderClient() { } return null; }, [input, output]); + const queryParams = useMemo(() => analyzeQueryParams(parsed?.params ?? []), [parsed]); function onTabKeyDown(e: React.KeyboardEvent, index: number) { let next = index; @@ -211,14 +212,20 @@ export function UrlEncoderClient() { {parsed.hash && } {parsed.params.length > 0 && (
- {parsed.params.map(([key, value], i) => ( + {queryParams.repeatedKeys.length > 0 && ( +
+ {queryParams.repeatedKeys.length} repeated {queryParams.repeatedKeys.length === 1 ? "key" : "keys"}: {queryParams.repeatedKeys.map((key) => key || "(empty name)").join(", ")}. Repeated parameters are not necessarily errors; applications may interpret them differently. +
+ )} + {queryParams.occurrences.map(({ key, value, repeated }, i) => (
- {key} = {value} + {key || "(empty name)"} = {value} + {repeated && Repeated key}
diff --git a/lib/__tests__/url-utils.test.ts b/lib/__tests__/url-utils.test.ts index 779634c..81ad619 100644 --- a/lib/__tests__/url-utils.test.ts +++ b/lib/__tests__/url-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { encodeUrl, decodeUrl, parseUrl } from "../url-utils"; +import { analyzeQueryParams, encodeUrl, decodeUrl, parseUrl } from "../url-utils"; describe("encodeUrl / decodeUrl round-trip", () => { it("round-trips unicode text", () => { @@ -81,3 +81,24 @@ describe("parseUrl", () => { expect(r.ok).toBe(false); }); }); + +describe("analyzeQueryParams", () => { + it("marks every same-valued and different-valued repeat and reports distinct repeated names", () => { + const result = analyzeQueryParams([["tag", "react"], ["tag", "typescript"], ["sort", "a"], ["sort", "a"]]); + expect(result.occurrences.map(({ repeated }) => repeated)).toEqual([true, true, true, true]); + expect(result.repeatedKeys).toEqual(["tag", "sort"]); + }); + it("groups decoded names, preserves case sensitivity, empty names, values, and order", () => { + const result = analyzeQueryParams([["tag", "a"], ["tag", "b"], ["Tag", "c"], ["tag", ""], ["", ""], ["", "x"]]); + expect(result.occurrences).toEqual([ + { key: "tag", value: "a", repeated: true }, { key: "tag", value: "b", repeated: true }, + { key: "Tag", value: "c", repeated: false }, { key: "tag", value: "", repeated: true }, + { key: "", value: "", repeated: true }, { key: "", value: "x", repeated: true }, + ]); + expect(result.repeatedKeys).toEqual(["tag", ""]); + }); + it("leaves unique and empty parameter lists unflagged", () => { + expect(analyzeQueryParams([["a", ""], ["b", "2"]]).repeatedKeys).toEqual([]); + expect(analyzeQueryParams([])).toEqual({ occurrences: [], repeatedKeys: [] }); + }); +}); diff --git a/lib/url-utils.ts b/lib/url-utils.ts index 72f7ff1..53c7aba 100644 --- a/lib/url-utils.ts +++ b/lib/url-utils.ts @@ -37,6 +37,26 @@ export interface ParsedUrl { params: [string, string][]; } +export interface QueryParamOccurrence { + key: string; + value: string; + repeated: boolean; +} + +/** Marks every occurrence of each repeated decoded name without changing order or values. */ +export function analyzeQueryParams(params: readonly [string, string][]): { + occurrences: QueryParamOccurrence[]; + repeatedKeys: string[]; +} { + const counts = new Map(); + for (const [key] of params) counts.set(key, (counts.get(key) ?? 0) + 1); + const repeatedKeys = [...counts].filter(([, count]) => count > 1).map(([key]) => key); + return { + occurrences: params.map(([key, value]) => ({ key, value, repeated: counts.get(key)! > 1 })), + repeatedKeys, + }; +} + export function parseUrl(text: string): { ok: true; value: ParsedUrl } | { ok: false; error: string } { try { const url = new URL(text);