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);