Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions app/(tools)/url-encoder/UrlEncoderClient.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<UrlEncoderClient />);
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(<UrlEncoderClient />);
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(<UrlEncoderClient />);
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();
});
});
15 changes: 11 additions & 4 deletions app/(tools)/url-encoder/UrlEncoderClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -211,14 +212,20 @@ export function UrlEncoderClient() {
{parsed.hash && <Row label="Hash" value={parsed.hash} />}
{parsed.params.length > 0 && (
<div className="pt-2 mt-1 border-t border-[var(--hairline)] space-y-1.5">
{parsed.params.map(([key, value], i) => (
{queryParams.repeatedKeys.length > 0 && (
<div role="status" className="rounded-lg border border-[var(--amber)] p-2 text-[var(--amber)]">
{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.
</div>
)}
{queryParams.occurrences.map(({ key, value, repeated }, i) => (
<div key={`${key}-${i}`} className="flex items-center justify-between gap-2">
<span className="text-[var(--fg-muted)] break-all">
{key} = <span className="text-[var(--fg)]">{value}</span>
{key || "(empty name)"} = <span className="text-[var(--fg)]">{value}</span>
{repeated && <span className="ml-2 inline-block rounded border border-[var(--amber)] px-1.5 py-0.5 text-[10px] font-sans font-semibold text-[var(--amber)]">Repeated key</span>}
</span>
<CopyButton
value={value}
ariaLabel={`Copy value of ${key}`}
ariaLabel={`Copy value of ${key || "(empty name)"}`}
className="py-0.5 shrink-0"
/>
</div>
Expand Down
23 changes: 22 additions & 1 deletion lib/__tests__/url-utils.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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: [] });
});
});
20 changes: 20 additions & 0 deletions lib/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>();
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);
Expand Down
Loading