From e14a1eaf26244f21948c6fd9e8af09ba8f486a09 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:38:52 +0530 Subject: [PATCH 01/50] ci: run the built Worker and request every page (#186) --- .github/workflows/ci.yml | 48 +++++++++++++++++++++++++++++++++++++++- CONTRIBUTING.md | 4 +++- 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9183990..bfb2421 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,4 +18,50 @@ jobs: - run: npm ci - run: npm run lint - run: npm run test - - run: npm run build + # Builds the Worker the same way Workers Builds does. This runs `npm run build` + # (next build) first, so there is no separate build step. + - run: npx opennextjs-cloudflare build + # A green next build doesn't mean the Worker runs. v0.7.0 built fine and then + # threw on every request, so start the built Worker in local workerd and + # request every page in the sitemap, the og:image each page links to and a + # missing page. + - name: Request every page from the built Worker + env: + WRANGLER_SEND_METRICS: "false" + run: | + npx wrangler dev --ip 127.0.0.1 --port 8787 > wrangler.log 2>&1 & + base=http://127.0.0.1:8787 + for i in $(seq 90); do + curl -s -o /dev/null "$base/robots.txt" && break + sleep 1 + done + pages=$(curl -s "$base/sitemap.xml" | grep -o '[^<]*' | sed -E 's|https?://[^/]+||') + fail=0 + if [ -z "$pages" ]; then + echo "sitemap.xml has no pages" + fail=1 + fi + check() { + code=$(curl -s -o body.out -w '%{http_code}' "$base$1" || true) + echo "$code $1" + if [ "$code" != "$2" ]; then fail=1; fi + } + for p in $pages; do + check "$p" 200 + img=$(grep -o 'property="og:image" content="[^"]*' body.out | head -1 | sed -E 's|.*content="https?://[^/]+||; s|&|\&|g') + if [ -n "$img" ]; then + check "$img" 200 + else + echo "no og:image on $p" + fail=1 + fi + done + check /sitemap.xml 200 + check /robots.txt 200 + check /no-such-page 404 + if grep -q 'wrangler:error' wrangler.log; then fail=1; fi + if [ "$fail" != 0 ]; then + echo "The built Worker failed. Wrangler output:" + cat wrangler.log + exit 1 + fi diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dbd3ad..c7d0b62 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -80,6 +80,8 @@ Before opening a pull request, make sure this passes: npm run lint && npm run test && npm run build ``` +CI also builds the Cloudflare Worker and requests every page from it in local workerd. If that step fails while `npm run build` passes locally, run `npm run preview` and open the page the CI log names; the error shows up in the terminal. + ## How the code is organized - Pure logic lives in `lib/` (parsing, crypto, PDF, conversions). It has no React and no DOM, so it is easy to test. Put new logic here, not inside components. @@ -114,7 +116,7 @@ toolbelt follows the standard CODERCOPS flow: - `develop` is the default and integration branch; `production` is the deployed branch (tools.codercops.com). Never commit directly to either. - Start every change on a branch off `develop`: `feat/*`, `fix/*`, `chore/*`, or `docs/*`. -- Open a PR into `develop` and **squash and merge** it (one squashed commit per feature). Use conventional commit titles (`feat:`, `fix:`, `chore:`, `docs:`). CI (lint, tests, build) must be green. +- Open a PR into `develop` and **squash and merge** it (one squashed commit per feature). Use conventional commit titles (`feat:`, `fix:`, `chore:`, `docs:`). CI (lint, tests, build, and the page check on the built Worker) must be green. - A release is a PR from `develop` into `production`, merged as a **merge commit** (not squash), so production keeps full history. Bump the `package.json` version in that PR, and add a `release:minor` or `release:major` label if it is not a patch. - Merging the release PR runs the Release workflow: it tags `vX.Y.Z`, publishes a GitHub Release, smoke-tests production, and fast-forwards `develop` back up to `production`. From 689a20198b1749b573219c36f1036486b032dbda Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Sat, 17 Oct 2026 08:39:09 +0530 Subject: [PATCH 02/50] chore(deps): update next and @opennextjs/cloudflare in one Dependabot group (#187) --- .github/dependabot.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d98153c..557e8c2 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,15 @@ updates: interval: weekly open-pull-requests-limit: 5 groups: + # Next and the OpenNext adapter have to move together. next 16.4.0 on its own + # (#169) broke every page until @opennextjs/cloudflare 1.20.12 (v0.7.0). + # Dependabot puts a dependency in the first group it matches, so this one + # has to stay above dev-dependencies to take eslint-config-next. + next: + patterns: + - "next" + - "@opennextjs/cloudflare" + - "eslint-config-next" dev-dependencies: dependency-type: development ignore: From 1ed745ae26b1ec73f98f1dcb50b60871be415a94 Mon Sep 17 00:00:00 2001 From: Nalla Prakash <81852531+prakashNalla@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:19:55 +0530 Subject: [PATCH 03/50] feat(seo): add inLanguage, isAccessibleForFree and site dates to json-ld (#189) --- lib/__tests__/tools.test.ts | 10 ++++++++++ lib/jsonLd.ts | 8 ++++++++ next.config.js | 2 ++ 3 files changed, 20 insertions(+) diff --git a/lib/__tests__/tools.test.ts b/lib/__tests__/tools.test.ts index 795487a..318bc0b 100644 --- a/lib/__tests__/tools.test.ts +++ b/lib/__tests__/tools.test.ts @@ -57,6 +57,10 @@ describe("tool registry", () => { expect(toolJsonLd(tool).url).toBe(`${SITE_URL}${tool.path}`); }); + it("marks tool JSON-LD as English and free", () => { + expect(toolJsonLd(tool)).toMatchObject({ inLanguage: "en", isAccessibleForFree: true }); + }); + it("omits item from the final breadcrumb", () => { expect(breadcrumbJsonLd(tool).itemListElement.at(-1)).not.toHaveProperty("item"); }); @@ -83,4 +87,10 @@ describe("homepage JSON-LD", () => { })), }); }); + + it("dates the WebSite node", () => { + const website = homeJsonLd()["@graph"].find((node) => node["@type"] === "WebSite"); + expect(website).toMatchObject({ datePublished: "2026-07-08" }); + expect(website).toHaveProperty("dateModified", expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)); + }); }); diff --git a/lib/jsonLd.ts b/lib/jsonLd.ts index 1f13683..1595f98 100644 --- a/lib/jsonLd.ts +++ b/lib/jsonLd.ts @@ -8,6 +8,10 @@ import { SITE_URL, TOOLS, type Tool } from "./tools"; const ORG_ID = `${SITE_URL}/#organization`; const WEBSITE_ID = `${SITE_URL}/#website`; +// The initial public release. dateModified is the build date (BUILD_DATE is +// set in next.config.js), not the request date, since pages render per request. +const SITE_PUBLISHED = "2026-07-08"; + // The publisher is CODERCOPS (the agency). Keyed by an @id on this domain so the // WebSite node can reference it. sameAs lists only identities we can honestly // claim — the agency site is `url`; the GitHub org is the verified profile. @@ -31,6 +35,8 @@ export function toolJsonLd(tool: Tool) { operatingSystem: "Any (Browser)", description: tool.jsonLdDescription, url: `${SITE_URL}${tool.path}`, + inLanguage: "en", + isAccessibleForFree: true, offers: { "@type": "Offer", price: "0", priceCurrency: "USD" }, publisher: { "@type": "Organization", name: "CODERCOPS", url: "https://www.codercops.com" }, }; @@ -81,6 +87,8 @@ export function homeJsonLd() { name: "CODERCOPS Tools", description: "Free, fast, privacy-first developer tools that run entirely in your browser.", publisher: { "@id": ORG_ID }, + datePublished: SITE_PUBLISHED, + dateModified: process.env.BUILD_DATE ?? SITE_PUBLISHED, }, { "@type": "ItemList", diff --git a/next.config.js b/next.config.js index fc34422..b310796 100644 --- a/next.config.js +++ b/next.config.js @@ -16,6 +16,8 @@ const securityHeaders = [ const nextConfig = { reactStrictMode: true, + // Inlined at build time for the WebSite JSON-LD dateModified. + env: { BUILD_DATE: new Date().toISOString().slice(0, 10) }, async headers() { return [ { From b3d036d56b24a5a150e1a4cbdad2d3a2a4ba31dc Mon Sep 17 00:00:00 2001 From: Bhushan Agrawal Date: Sat, 17 Oct 2026 11:48:53 +0530 Subject: [PATCH 04/50] feat(color-converter): add color converter & contrast checker (#84) (#188) * feat(color-converter): add color converter & contrast checker (#84) * fix(color-converter): address maintainer review feedback for #84 - switch registry accentVar to --fuchsia and accentHex to #F26DF0 - replace --violet styling in ColorConverterClient with --fuchsia - add peer-focus-visible styling to swatch labels on keyboard focus - remove conflicting btn btn-ghost classes from FG/BG buttons to match info pill - remove NAMED_COLORS, transparent support, and Named label - simplify wcagRating to return only normalText and largeText without overloads - add tests for 3-value space-separated rgb/hsl syntax - fix contrastRatio translucent alpha compositing order over white and add regression test --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + README.md | 1 + .../color-converter/ColorConverterClient.tsx | 698 ++++++++++++++++++ .../color-converter/opengraph-image.tsx | 12 + app/(tools)/color-converter/page.tsx | 17 + lib/__tests__/color-utils.test.ts | 225 ++++++ lib/color-utils.ts | 484 ++++++++++++ lib/tools.ts | 53 ++ 8 files changed, 1491 insertions(+) create mode 100644 app/(tools)/color-converter/ColorConverterClient.tsx create mode 100644 app/(tools)/color-converter/opengraph-image.tsx create mode 100644 app/(tools)/color-converter/page.tsx create mode 100644 lib/__tests__/color-utils.test.ts create mode 100644 lib/color-utils.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c51984c..9398007 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,6 +16,7 @@ body: - Base64 - URL encoder/decoder - Invoice generator + - Color converter & contrast checker - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/README.md b/README.md index b88092d..e60a28e 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien - **[JWT decoder and inspector](https://tools.codercops.com/jwt-decoder).** Decode header, payload, and claims; verify HS/RS/PS/ES signatures via WebCrypto; security audit; HS256/384/512 encoder. - **[Base64 encoder and decoder](https://tools.codercops.com/base64).** Text, images, and files; URL-safe variant; data URIs; magic-byte detection; Base32/58/85 and hashes. - **[Invoice generator](https://tools.codercops.com/invoice-generator).** Live preview, vector PDF export with Unicode currency support, a reusable sender profile, all saved locally. +- **[Color converter & contrast checker](https://tools.codercops.com/color-converter).** Convert between HEX, RGB, and HSL with alpha channel support, live swatch, and WCAG AA/AAA contrast ratios for normal and large text. ## Why it exists diff --git a/app/(tools)/color-converter/ColorConverterClient.tsx b/app/(tools)/color-converter/ColorConverterClient.tsx new file mode 100644 index 0000000..31c1d0e --- /dev/null +++ b/app/(tools)/color-converter/ColorConverterClient.tsx @@ -0,0 +1,698 @@ +"use client"; + +import { useId, useMemo, useState } from "react"; +import { + ArrowUpDown, + AlertCircle, + CheckCircle2, + XCircle, + Palette, + Sliders, + Sparkles, +} from "lucide-react"; +import { + parseColor, + toHex, + toRgbString, + toHslString, + relativeLuminance, + contrastRatio, + wcagRating, + type RgbaColor, +} from "@/lib/color-utils"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { useToast } from "@/components/shared/Toast"; +import { useRegisterCommands } from "@/components/shared/CommandPalette"; +import { ShortcutsLegend } from "@/components/shared/ShortcutsLegend"; +import { cn } from "@/lib/cn"; + +const isMac = + typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform); +const MOD = isMac ? "⌘" : "Ctrl"; + +function formatRatio(ratio: number): string { + return ratio.toFixed(2); +} + +function hex6(color: RgbaColor): string { + const r = Math.round(Math.max(0, Math.min(255, color.r))).toString(16).padStart(2, "0"); + const g = Math.round(Math.max(0, Math.min(255, color.g))).toString(16).padStart(2, "0"); + const b = Math.round(Math.max(0, Math.min(255, color.b))).toString(16).padStart(2, "0"); + return `#${r}${g}${b}`; +} + +export function ColorConverterClient() { + const [colorInput, setColorInput] = useState("#F26DF0"); + const [fgInput, setFgInput] = useState("#FFFFFF"); + const [bgInput, setBgInput] = useState("#0A0B0F"); + const { push } = useToast(); + + const colorInputId = useId(); + const colorPickerId = useId(); + const alphaSliderId = useId(); + const fgInputId = useId(); + const fgPickerId = useId(); + const bgInputId = useId(); + const bgPickerId = useId(); + + // Parse primary color + const parsed = useMemo(() => parseColor(colorInput), [colorInput]); + + // Derived formats + const hexValue = useMemo(() => (parsed.ok ? toHex(parsed.value) : ""), [parsed]); + const rgbValue = useMemo(() => (parsed.ok ? toRgbString(parsed.value) : ""), [parsed]); + const hslValue = useMemo(() => (parsed.ok ? toHslString(parsed.value) : ""), [parsed]); + const pickerHex = useMemo( + () => (parsed.ok ? hex6(parsed.value) : "#F26DF0"), + [parsed] + ); + const alphaVal = parsed.ok ? parsed.value.a : 1; + const luminanceVal = parsed.ok ? relativeLuminance(parsed.value).toFixed(4) : "—"; + + // Contrast calculations + const parsedFg = useMemo(() => parseColor(fgInput), [fgInput]); + const parsedBg = useMemo(() => parseColor(bgInput), [bgInput]); + + const contrastResult = useMemo(() => { + if (!parsedFg.ok || !parsedBg.ok) return null; + const ratio = contrastRatio(parsedFg.value, parsedBg.value); + const rating = wcagRating(ratio); + const fgLum = relativeLuminance(parsedFg.value); + const bgLum = relativeLuminance(parsedBg.value); + return { ratio, rating, fgLum, bgLum }; + }, [parsedFg, parsedBg]); + + // Color picker synchronization + function handleNativeColorChange(hex: string) { + if (parsed.ok && parsed.value.a < 1) { + const nextParsed = parseColor(hex); + if (nextParsed.ok) { + setColorInput(toHex({ ...nextParsed.value, a: parsed.value.a })); + return; + } + } + setColorInput(hex); + } + + function handleAlphaChange(newAlpha: number) { + if (!parsed.ok) return; + const updated = { ...parsed.value, a: newAlpha }; + if (colorInput.trim().startsWith("rgb")) { + setColorInput(toRgbString(updated)); + } else if (colorInput.trim().startsWith("hsl")) { + setColorInput(toHslString(updated)); + } else { + setColorInput(toHex(updated)); + } + } + + function swapContrastColors() { + setFgInput(bgInput); + setBgInput(fgInput); + push("Contrast colors swapped", "success"); + } + + function useAsForeground() { + if (parsed.ok) { + setFgInput(toHex(parsed.value)); + push("Set as contrast foreground", "success"); + } + } + + function useAsBackground() { + if (parsed.ok) { + setBgInput(toHex(parsed.value)); + push("Set as contrast background", "success"); + } + } + + useRegisterCommands( + "color-converter", + [ + { + id: "color:swap", + section: "Color Converter", + title: "Swap contrast foreground & background", + run: swapContrastColors, + }, + { + id: "color:set-fg", + section: "Color Converter", + title: "Use current color as contrast foreground", + run: useAsForeground, + }, + { + id: "color:set-bg", + section: "Color Converter", + title: "Use current color as contrast background", + run: useAsBackground, + }, + { + id: "color:copy-hex", + section: "Color Converter", + title: "Copy current color as HEX", + run: async () => { + if (hexValue) { + await navigator.clipboard.writeText(hexValue); + push("Copied HEX to clipboard", "success"); + } + }, + }, + { + id: "color:copy-rgb", + section: "Color Converter", + title: "Copy current color as RGB", + run: async () => { + if (rgbValue) { + await navigator.clipboard.writeText(rgbValue); + push("Copied RGB to clipboard", "success"); + } + }, + }, + { + id: "color:copy-hsl", + section: "Color Converter", + title: "Copy current color as HSL", + run: async () => { + if (hslValue) { + await navigator.clipboard.writeText(hslValue); + push("Copied HSL to clipboard", "success"); + } + }, + }, + ], + [hexValue, rgbValue, hslValue, fgInput, bgInput, parsed] + ); + + return ( +
+ {/* Top action row */} +
+
+ + Live Color Inspector & WCAG 2.1 Contrast Calculator +
+ +
+ + {/* Main Color Inspector & Conversion */} +
+ {/* Left Column: Input + Swatch */} +
+
+ + + HEX · RGB · HSL + +
+ +
+
+ setColorInput(e.target.value)} + placeholder="e.g. #F26DF0, rgb(242, 109, 240), hsl(301, 84%, 69%)" + className={cn( + "w-full px-3.5 py-2.5 rounded-lg font-mono text-[13.5px] bg-[var(--bg-card)] border text-[var(--fg)] placeholder:text-[var(--fg-dim)] transition-colors focus:outline-none focus:ring-1 focus:ring-[var(--fuchsia)]", + parsed.ok + ? "border-[var(--hairline-strong)]" + : "border-[var(--rose)] ring-1 ring-[var(--rose)]" + )} + aria-invalid={!parsed.ok} + aria-describedby={!parsed.ok ? "color-input-error" : undefined} + /> +
+ + {/* Synchronized native color picker */} +
+ handleNativeColorChange(e.target.value)} + className="sr-only peer" + aria-label="Pick color with native system picker" + /> + +
+
+ + {!parsed.ok && ( + + )} + + {/* Live Swatch with Transparency Checkerboard */} +
+
+ Live swatch + Luminance: {luminanceVal} +
+ +
+ {parsed.ok && ( +
+ )} + + {/* Swatch info pill */} +
+ {hexValue || "—"} + · + Alpha {Math.round(alphaVal * 100)}% +
+ +
+ + +
+
+
+ + {/* Alpha Slider */} +
+
+ + + {Math.round(alphaVal * 100)}% + +
+ handleAlphaChange(parseFloat(e.target.value))} + className="w-full accent-[var(--fuchsia)] cursor-pointer" + aria-label="Adjust opacity alpha channel" + /> +
+
+ + {/* Right Column: Converted formats */} +
+
+
+

+ Color Formats & Copy +

+ CSS Ready +
+ + {/* HEX Output */} +
+
+
+ + HEX + + + {alphaVal < 1 ? "8-digit hex with alpha (#rrggbbaa)" : "6-digit hex (#rrggbb)"} + +
+
+ {hexValue || "—"} +
+
+ +
+ + {/* RGB Output */} +
+
+
+ + RGB + + + {alphaVal < 1 ? "rgba(r, g, b, a)" : "rgb(r, g, b)"} + +
+
+ {rgbValue || "—"} +
+
+ +
+ + {/* HSL Output */} +
+
+
+ + HSL + + + {alphaVal < 1 ? "hsla(h, s%, l%, a)" : "hsl(h, s%, l%)"} + +
+
+ {hslValue || "—"} +
+
+ +
+
+ + {/* Quick Info Box */} +
+ +

+ Accepts HEX (3, 4, 6, 8 digits), standard rgb() /{" "} + rgba(), and hsl() /{" "} + hsla() with both comma-separated and modern CSS space-separated slash syntax. +

+
+
+
+ + {/* WCAG Contrast Checker Section */} +
+
+
+

+ WCAG Contrast Checker +

+

+ Evaluate readability and compliance against WCAG 2.1 Level AA and AAA standards. +

+
+ +
+ + {/* Inputs for Foreground and Background */} +
+ {/* Foreground */} +
+
+ + + {parsedFg.ok ? `L: ${relativeLuminance(parsedFg.value).toFixed(3)}` : "Invalid"} + +
+
+ setFgInput(e.target.value)} + placeholder="#FFFFFF" + className={cn( + "flex-1 px-3.5 py-2.5 rounded-lg font-mono text-[13px] bg-[var(--bg-raise)] border text-[var(--fg)] placeholder:text-[var(--fg-dim)] focus:outline-none focus:ring-1 focus:ring-[var(--fuchsia)]", + parsedFg.ok ? "border-[var(--hairline)]" : "border-[var(--rose)] ring-1 ring-[var(--rose)]" + )} + aria-invalid={!parsedFg.ok} + /> + setFgInput(e.target.value)} + className="sr-only peer" + aria-label="Foreground color picker" + /> + +
+
+ + {/* Background */} +
+
+ + + {parsedBg.ok ? `L: ${relativeLuminance(parsedBg.value).toFixed(3)}` : "Invalid"} + +
+
+ setBgInput(e.target.value)} + placeholder="#0A0B0F" + className={cn( + "flex-1 px-3.5 py-2.5 rounded-lg font-mono text-[13px] bg-[var(--bg-raise)] border text-[var(--fg)] placeholder:text-[var(--fg-dim)] focus:outline-none focus:ring-1 focus:ring-[var(--fuchsia)]", + parsedBg.ok ? "border-[var(--hairline)]" : "border-[var(--rose)] ring-1 ring-[var(--rose)]" + )} + aria-invalid={!parsedBg.ok} + /> + setBgInput(e.target.value)} + className="sr-only peer" + aria-label="Background color picker" + /> + +
+
+
+ + {/* Live Preview Container */} + {contrastResult && ( +
+ {/* Real Text Sample Render */} +
+
+ + Sample Heading (Large Text) + +

+ Design Accessible Experiences +

+
+ +
+ + Body Copy (Normal Text) + +

+ Clear typography and sufficient color contrast ensure that content remains legible for + users with low vision, color blindness, or varying lighting conditions. +

+
+
+ + {/* Compliance Scores */} +
+
+
+ + Contrast Ratio + + + {formatRatio(contrastResult.ratio)} : 1 + +
+ +
+
= 7 + ? "bg-[var(--cyan)]" + : contrastResult.ratio >= 4.5 + ? "bg-[var(--fuchsia)]" + : contrastResult.ratio >= 3 + ? "bg-[var(--amber)]" + : "bg-[var(--rose)]" + )} + style={{ width: `${Math.min(100, (contrastResult.ratio / 21) * 100)}%` }} + /> +
+
+ + {/* Grid of 4 Badges */} +
+ {/* Normal AA */} +
+
+
Normal AA
+
≥ 4.5:1
+
+ {contrastResult.rating.normalText.aa ? ( + + PASS + + ) : ( + + FAIL + + )} +
+ + {/* Normal AAA */} +
+
+
Normal AAA
+
≥ 7.0:1
+
+ {contrastResult.rating.normalText.aaa ? ( + + PASS + + ) : ( + + FAIL + + )} +
+ + {/* Large AA */} +
+
+
Large AA
+
≥ 3.0:1
+
+ {contrastResult.rating.largeText.aa ? ( + + PASS + + ) : ( + + FAIL + + )} +
+ + {/* Large AAA */} +
+
+
Large AAA
+
≥ 4.5:1
+
+ {contrastResult.rating.largeText.aaa ? ( + + PASS + + ) : ( + + FAIL + + )} +
+
+
+
+ )} +
+
+ ); +} diff --git a/app/(tools)/color-converter/opengraph-image.tsx b/app/(tools)/color-converter/opengraph-image.tsx new file mode 100644 index 0000000..13db734 --- /dev/null +++ b/app/(tools)/color-converter/opengraph-image.tsx @@ -0,0 +1,12 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("color-converter")!; + +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/(tools)/color-converter/page.tsx b/app/(tools)/color-converter/page.tsx new file mode 100644 index 0000000..0986fda --- /dev/null +++ b/app/(tools)/color-converter/page.tsx @@ -0,0 +1,17 @@ +import type { Metadata } from "next"; +import { ColorConverterClient } from "./ColorConverterClient"; +import { ToolPageLayout } from "@/components/shared/ToolPageLayout"; +import { generateToolMetadata } from "@/lib/toolMetadata"; +import { getTool } from "@/lib/tools"; + +const tool = getTool("color-converter")!; + +export const metadata: Metadata = generateToolMetadata(tool); + +export default function ColorConverterPage() { + return ( + + + + ); +} diff --git a/lib/__tests__/color-utils.test.ts b/lib/__tests__/color-utils.test.ts new file mode 100644 index 0000000..eb5afb4 --- /dev/null +++ b/lib/__tests__/color-utils.test.ts @@ -0,0 +1,225 @@ +import { describe, it, expect } from "vitest"; +import { + parseColor, + toHex, + toRgbString, + toHslString, + relativeLuminance, + contrastRatio, + wcagRating, + rgbToHsl, + hslToRgb, + type RgbaColor, +} from "../color-utils"; + +describe("color conversions for red", () => { + const red: RgbaColor = { r: 255, g: 0, b: 0, a: 1 }; + + it("converts red consistently between HEX, RGB, and HSL", () => { + const hex = toHex(red); + const rgbStr = toRgbString(red); + const hslStr = toHslString(red); + + expect(hex).toBe("#ff0000"); + expect(rgbStr).toBe("rgb(255, 0, 0)"); + expect(hslStr).toBe("hsl(0, 100%, 50%)"); + + const fromHex = parseColor(hex); + const fromRgb = parseColor(rgbStr); + const fromHsl = parseColor(hslStr); + + expect(fromHex).toEqual({ ok: true, value: red }); + expect(fromRgb).toEqual({ ok: true, value: red }); + expect(fromHsl).toEqual({ ok: true, value: red }); + }); + + it("converts between RGB and HSL structures accurately", () => { + const hsl = rgbToHsl(red); + expect(hsl).toEqual({ h: 0, s: 100, l: 50, a: 1 }); + const backToRgb = hslToRgb(hsl); + expect(backToRgb).toEqual(red); + }); +}); + +describe("HEX parsing", () => { + it("correctly parses #0f08 with normalized alpha", () => { + const r = parseColor("#0f08"); + expect(r.ok).toBe(true); + if (!r.ok) return; + + expect(r.value.r).toBe(0); + expect(r.value.g).toBe(255); + expect(r.value.b).toBe(0); + expect(r.value.a).toBeCloseTo(136 / 255, 4); + + expect(toHex(r.value)).toBe("#00ff0088"); + }); + + it("parses 3, 6, and 8 digit hex formats", () => { + expect(parseColor("#fff")).toEqual({ + ok: true, + value: { r: 255, g: 255, b: 255, a: 1 }, + }); + expect(parseColor("#000000")).toEqual({ + ok: true, + value: { r: 0, g: 0, b: 0, a: 1 }, + }); + expect(parseColor("#ff000080")).toEqual({ + ok: true, + value: { r: 255, g: 0, b: 0, a: 128 / 255 }, + }); + }); +}); + +describe("RGB / RGBA and HSL / HSLA syntax varieties", () => { + it("supports comma and space syntax for rgb/rgba", () => { + const comma = parseColor("rgba(255, 128, 0, 0.5)"); + const space = parseColor("rgb(255 128 0 / 0.5)"); + const percentAlpha = parseColor("rgb(255 128 0 / 50%)"); + + expect(comma).toEqual({ + ok: true, + value: { r: 255, g: 128, b: 0, a: 0.5 }, + }); + expect(space).toEqual({ + ok: true, + value: { r: 255, g: 128, b: 0, a: 0.5 }, + }); + expect(percentAlpha).toEqual({ + ok: true, + value: { r: 255, g: 128, b: 0, a: 0.5 }, + }); + }); + + it("supports comma and space syntax for hsl/hsla with angle units", () => { + const deg = parseColor("hsl(180deg, 100%, 50%)"); + const turn = parseColor("hsl(0.5turn 100% 50% / 0.8)"); + + expect(deg).toEqual({ + ok: true, + value: { r: 0, g: 255, b: 255, a: 1 }, + }); + expect(turn.ok && turn.value.a).toBe(0.8); + expect(turn.ok && turn.value.r).toBe(0); + expect(turn.ok && turn.value.g).toBe(255); + }); + + it("supports plain three-value space-separated syntax without slash", () => { + expect(parseColor("rgb(255 0 0)")).toEqual({ + ok: true, + value: { r: 255, g: 0, b: 0, a: 1 }, + }); + expect(parseColor("hsl(0 100% 50%)")).toEqual({ + ok: true, + value: { r: 255, g: 0, b: 0, a: 1 }, + }); + }); +}); + +describe("invalid input handling", () => { + it.each([ + "", + " ", + "not-a-color", + "red", + "transparent", + "blue", + "#12345", + "#gggggg", + "rgb(255, 0)", + "rgb(255, 0, 0, 0, 0)", + "hsl(100)", + "rgba(invalid)", + ])("returns ok: false for %s", (input) => { + const r = parseColor(input); + expect(r.ok).toBe(false); + if (!r.ok) { + expect(r.error.length).toBeGreaterThan(0); + } + }); +}); + +describe("relativeLuminance & contrastRatio", () => { + const white: RgbaColor = { r: 255, g: 255, b: 255, a: 1 }; + const black: RgbaColor = { r: 0, g: 0, b: 0, a: 1 }; + + it("calculates accurate relative luminance for extremes", () => { + expect(relativeLuminance(white)).toBe(1); + expect(relativeLuminance(black)).toBe(0); + }); + + it("yields 21 for black on white", () => { + expect(contrastRatio(black, white)).toBe(21); + expect(contrastRatio(white, black)).toBe(21); + }); + + it("yields 1 for identical colors", () => { + expect(contrastRatio(white, white)).toBe(1); + expect(contrastRatio(black, black)).toBe(1); + const red: RgbaColor = { r: 255, g: 0, b: 0, a: 1 }; + expect(contrastRatio(red, red)).toBe(1); + }); + + it("yields approximately 4.48 for #777777 on white and fails normal-text AA", () => { + const gray = parseColor("#777777"); + expect(gray.ok).toBe(true); + if (!gray.ok) return; + + const ratio = contrastRatio(gray.value, white); + expect(ratio).toBeCloseTo(4.48, 2); + + const rating = wcagRating(ratio); + expect(rating.normalText.aa).toBe(false); + expect(rating.normalText.aaa).toBe(false); + expect(rating.largeText.aa).toBe(true); + expect(rating.largeText.aaa).toBe(false); + }); + + it("composites translucent background onto white, then translucent foreground onto background", () => { + const fg = parseColor("rgba(255, 255, 255, 0.5)"); + const bg = parseColor("rgba(0, 0, 0, 0.5)"); + expect(fg.ok).toBe(true); + expect(bg.ok).toBe(true); + if (!fg.ok || !bg.ok) return; + + // Background (black 0.5) over white (255) => rgb(127.5, 127.5, 127.5) with luminance ~0.214 + // Foreground (white 0.5) over that background => rgb(191.25, 191.25, 191.25) with luminance ~0.523 + // Correct contrast ratio is ~2.17 : 1 + // If incorrectly calculated as white (luminance 1) against gray (~0.214), ratio would be ~3.98 : 1 + const ratio = contrastRatio(fg.value, bg.value); + expect(ratio).toBeCloseTo(2.17, 2); + expect(ratio).not.toBeCloseTo(3.98, 1); + }); +}); + +describe("wcagRating", () => { + it("correctly distinguishes normal and large text", () => { + // 4.48: fails normal AA (<4.5), passes large AA (>=3) + const r448 = wcagRating(4.48); + expect(r448.normalText.aa).toBe(false); + expect(r448.normalText.aaa).toBe(false); + expect(r448.largeText.aa).toBe(true); + expect(r448.largeText.aaa).toBe(false); + + // 4.6: passes normal AA (>=4.5) and large AAA (>=4.5), fails normal AAA (<7) + const r46 = wcagRating(4.6); + expect(r46.normalText.aa).toBe(true); + expect(r46.normalText.aaa).toBe(false); + expect(r46.largeText.aa).toBe(true); + expect(r46.largeText.aaa).toBe(true); + + // 7.5: passes everything + const r75 = wcagRating(7.5); + expect(r75.normalText.aa).toBe(true); + expect(r75.normalText.aaa).toBe(true); + expect(r75.largeText.aa).toBe(true); + expect(r75.largeText.aaa).toBe(true); + + // 2.5: fails everything + const r25 = wcagRating(2.5); + expect(r25.normalText.aa).toBe(false); + expect(r25.normalText.aaa).toBe(false); + expect(r25.largeText.aa).toBe(false); + expect(r25.largeText.aaa).toBe(false); + }); +}); diff --git a/lib/color-utils.ts b/lib/color-utils.ts new file mode 100644 index 0000000..f7ca3ce --- /dev/null +++ b/lib/color-utils.ts @@ -0,0 +1,484 @@ +export interface RgbaColor { + r: number; // 0 - 255 + g: number; // 0 - 255 + b: number; // 0 - 255 + a: number; // 0 - 1 +} + +export interface HslColor { + h: number; // 0 - 360 + s: number; // 0 - 100 + l: number; // 0 - 100 + a: number; // 0 - 1 +} + +export type ParseColorResult = + | { ok: true; value: RgbaColor } + | { ok: false; error: string }; + +export interface WcagRatingResult { + normalText: { + aa: boolean; + aaa: boolean; + }; + largeText: { + aa: boolean; + aaa: boolean; + }; +} + +/** + * Converts an HSL color to RGBA. + */ +export function hslToRgb(hsl: HslColor): RgbaColor { + const hNorm = ((hsl.h % 360) + 360) % 360; + const sNorm = Math.max(0, Math.min(100, hsl.s)) / 100; + const lNorm = Math.max(0, Math.min(100, hsl.l)) / 100; + const aNorm = Math.max(0, Math.min(1, hsl.a)); + + const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm; + const x = c * (1 - Math.abs(((hNorm / 60) % 2) - 1)); + const m = lNorm - c / 2; + + let rPrime = 0; + let gPrime = 0; + let bPrime = 0; + + if (hNorm < 60) { + rPrime = c; + gPrime = x; + bPrime = 0; + } else if (hNorm < 120) { + rPrime = x; + gPrime = c; + bPrime = 0; + } else if (hNorm < 180) { + rPrime = 0; + gPrime = c; + bPrime = x; + } else if (hNorm < 240) { + rPrime = 0; + gPrime = x; + bPrime = c; + } else if (hNorm < 300) { + rPrime = x; + gPrime = 0; + bPrime = c; + } else { + rPrime = c; + gPrime = 0; + bPrime = x; + } + + return { + r: Math.round((rPrime + m) * 255), + g: Math.round((gPrime + m) * 255), + b: Math.round((bPrime + m) * 255), + a: aNorm, + }; +} + +/** + * Converts an RGBA color to HSL. + */ +export function rgbToHsl(color: RgbaColor): HslColor { + const rNorm = Math.max(0, Math.min(255, color.r)) / 255; + const gNorm = Math.max(0, Math.min(255, color.g)) / 255; + const bNorm = Math.max(0, Math.min(255, color.b)) / 255; + const aNorm = Math.max(0, Math.min(1, color.a)); + + const max = Math.max(rNorm, gNorm, bNorm); + const min = Math.min(rNorm, gNorm, bNorm); + const delta = max - min; + + const l = (max + min) / 2; + let s = 0; + if (delta !== 0) { + s = delta / (1 - Math.abs(2 * l - 1)); + } + + let h = 0; + if (delta !== 0) { + if (max === rNorm) { + h = 60 * (((gNorm - bNorm) / delta) % 6); + } else if (max === gNorm) { + h = 60 * ((bNorm - rNorm) / delta + 2); + } else { + h = 60 * ((rNorm - gNorm) / delta + 4); + } + } + + if (h < 0) { + h += 360; + } + + return { + h: Math.round(h) % 360, + s: Math.round(s * 100), + l: Math.round(l * 100), + a: aNorm, + }; +} + +function parseHex(raw: string): ParseColorResult { + const hex = raw.slice(1); + if (!/^[0-9a-fA-F]+$/.test(hex)) { + return { ok: false, error: "Invalid hexadecimal characters" }; + } + + if (hex.length === 3) { + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + return { ok: true, value: { r, g, b, a: 1 } }; + } + + if (hex.length === 4) { + const r = parseInt(hex[0] + hex[0], 16); + const g = parseInt(hex[1] + hex[1], 16); + const b = parseInt(hex[2] + hex[2], 16); + const a = parseInt(hex[3] + hex[3], 16) / 255; + return { ok: true, value: { r, g, b, a } }; + } + + if (hex.length === 6) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + return { ok: true, value: { r, g, b, a: 1 } }; + } + + if (hex.length === 8) { + const r = parseInt(hex.slice(0, 2), 16); + const g = parseInt(hex.slice(2, 4), 16); + const b = parseInt(hex.slice(4, 6), 16); + const a = parseInt(hex.slice(6, 8), 16) / 255; + return { ok: true, value: { r, g, b, a } }; + } + + return { + ok: false, + error: `Invalid HEX length: #${hex} (expected 3, 4, 6, or 8 characters)`, + }; +} + +function parseAlphaToken(token: string): number | null { + const trimmed = token.trim(); + if (trimmed.endsWith("%")) { + const num = parseFloat(trimmed.slice(0, -1)); + if (isNaN(num)) return null; + return Math.max(0, Math.min(1, num / 100)); + } + const num = parseFloat(trimmed); + if (isNaN(num)) return null; + return Math.max(0, Math.min(1, num)); +} + +function parseRgbComponent(token: string): number | null { + const trimmed = token.trim(); + if (trimmed.endsWith("%")) { + const num = parseFloat(trimmed.slice(0, -1)); + if (isNaN(num)) return null; + return Math.max(0, Math.min(255, (num / 100) * 255)); + } + const num = parseFloat(trimmed); + if (isNaN(num)) return null; + return Math.max(0, Math.min(255, num)); +} + +function parseRgbTokens(content: string): { + rStr: string; + gStr: string; + bStr: string; + aStr?: string; +} | null { + const trimmed = content.trim(); + if (trimmed.includes(",")) { + const rawParts = trimmed.split(","); + const parts = rawParts.map((s) => s.trim()); + if (parts.some((p) => p === "")) return null; + if (parts.length === 3) { + return { rStr: parts[0], gStr: parts[1], bStr: parts[2] }; + } + if (parts.length === 4) { + return { rStr: parts[0], gStr: parts[1], bStr: parts[2], aStr: parts[3] }; + } + return null; + } + + if (trimmed.includes("/")) { + const slashParts = trimmed.split("/"); + if (slashParts.length !== 2) return null; + const colorTokens = slashParts[0].trim().split(/\s+/); + if (colorTokens.length !== 3 || colorTokens.some((p) => p === "")) return null; + const aStr = slashParts[1].trim(); + if (!aStr) return null; + return { + rStr: colorTokens[0], + gStr: colorTokens[1], + bStr: colorTokens[2], + aStr, + }; + } + + const tokens = trimmed.split(/\s+/); + if (tokens.some((p) => p === "")) return null; + if (tokens.length === 3) { + return { rStr: tokens[0], gStr: tokens[1], bStr: tokens[2] }; + } + if (tokens.length === 4) { + return { rStr: tokens[0], gStr: tokens[1], bStr: tokens[2], aStr: tokens[3] }; + } + return null; +} + +function parseRgb(content: string): ParseColorResult { + const tokens = parseRgbTokens(content); + if (!tokens) { + return { ok: false, error: "Invalid rgb() syntax" }; + } + + const r = parseRgbComponent(tokens.rStr); + const g = parseRgbComponent(tokens.gStr); + const b = parseRgbComponent(tokens.bStr); + + if (r === null || g === null || b === null) { + return { ok: false, error: "Invalid RGB color values" }; + } + + let a = 1; + if (tokens.aStr !== undefined) { + const parsedAlpha = parseAlphaToken(tokens.aStr); + if (parsedAlpha === null) { + return { ok: false, error: "Invalid alpha channel value" }; + } + a = parsedAlpha; + } + + return { + ok: true, + value: { + r: Math.round(r), + g: Math.round(g), + b: Math.round(b), + a, + }, + }; +} + +function parseHue(token: string): number | null { + const lower = token.trim().toLowerCase(); + let h: number; + if (lower.endsWith("deg")) { + h = parseFloat(lower.slice(0, -3)); + } else if (lower.endsWith("grad")) { + h = (parseFloat(lower.slice(0, -4)) * 360) / 400; + } else if (lower.endsWith("rad")) { + h = (parseFloat(lower.slice(0, -3)) * 180) / Math.PI; + } else if (lower.endsWith("turn")) { + h = parseFloat(lower.slice(0, -4)) * 360; + } else { + h = parseFloat(lower); + } + + if (isNaN(h)) return null; + return ((h % 360) + 360) % 360; +} + +function parseHsl(content: string): ParseColorResult { + const tokens = parseRgbTokens(content); + if (!tokens) { + return { ok: false, error: "Invalid hsl() syntax" }; + } + + const h = parseHue(tokens.rStr); + if (h === null) { + return { ok: false, error: "Invalid hue value" }; + } + + const sStr = tokens.gStr.trim(); + const sNum = parseFloat(sStr.endsWith("%") ? sStr.slice(0, -1) : sStr); + if (isNaN(sNum)) { + return { ok: false, error: "Invalid saturation value" }; + } + const s = Math.max(0, Math.min(100, sNum)); + + const lStr = tokens.bStr.trim(); + const lNum = parseFloat(lStr.endsWith("%") ? lStr.slice(0, -1) : lStr); + if (isNaN(lNum)) { + return { ok: false, error: "Invalid lightness value" }; + } + const l = Math.max(0, Math.min(100, lNum)); + + let a = 1; + if (tokens.aStr !== undefined) { + const parsedAlpha = parseAlphaToken(tokens.aStr); + if (parsedAlpha === null) { + return { ok: false, error: "Invalid alpha channel value" }; + } + a = parsedAlpha; + } + + const rgb = hslToRgb({ h, s, l, a }); + return { ok: true, value: rgb }; +} + +/** + * Parses a color string in HEX (#rgb, #rgba, #rrggbb, #rrggbbaa), + * RGB / RGBA (comma or space syntax), or HSL / HSLA (comma or space syntax). + * + * Normalizes alpha to [0, 1] and returns { ok: false, error: string } for invalid inputs. + */ +export function parseColor(input: string): ParseColorResult { + if (!input || typeof input !== "string") { + return { ok: false, error: "Input is empty or not a string" }; + } + + const str = input.trim(); + if (str === "") { + return { ok: false, error: "Input is empty" }; + } + + // Hex + if (str.startsWith("#")) { + return parseHex(str); + } + + // RGB / RGBA + const rgbMatch = str.match(/^rgba?\s*\((.*)\)$/i); + if (rgbMatch) { + return parseRgb(rgbMatch[1]); + } + + // HSL / HSLA + const hslMatch = str.match(/^hsla?\s*\((.*)\)$/i); + if (hslMatch) { + return parseHsl(hslMatch[1]); + } + + return { + ok: false, + error: `Unrecognized color format: "${input}". Expected #HEX, rgb(), rgba(), hsl(), or hsla().`, + }; +} + +/** + * Formats an RGBA color as a lowercase HEX string. + * Emits 6 digits (#rrggbb) if alpha is 1, or 8 digits (#rrggbbaa) if alpha < 1. + */ +export function toHex(color: RgbaColor, options?: { forceAlpha?: boolean }): string { + const r = Math.round(Math.max(0, Math.min(255, color.r))) + .toString(16) + .padStart(2, "0"); + const g = Math.round(Math.max(0, Math.min(255, color.g))) + .toString(16) + .padStart(2, "0"); + const b = Math.round(Math.max(0, Math.min(255, color.b))) + .toString(16) + .padStart(2, "0"); + const aNorm = Math.max(0, Math.min(1, color.a)); + + if (options?.forceAlpha || aNorm < 1) { + const a = Math.round(aNorm * 255) + .toString(16) + .padStart(2, "0"); + return `#${r}${g}${b}${a}`; + } + return `#${r}${g}${b}`; +} + +/** + * Formats an RGBA color as an rgb() or rgba() string. + */ +export function toRgbString(color: RgbaColor): string { + const r = Math.round(Math.max(0, Math.min(255, color.r))); + const g = Math.round(Math.max(0, Math.min(255, color.g))); + const b = Math.round(Math.max(0, Math.min(255, color.b))); + const aNorm = Math.max(0, Math.min(1, color.a)); + + if (aNorm < 1) { + const aFormatted = parseFloat(aNorm.toFixed(4)).toString(); + return `rgba(${r}, ${g}, ${b}, ${aFormatted})`; + } + return `rgb(${r}, ${g}, ${b})`; +} + +/** + * Formats an RGBA or HSL color as an hsl() or hsla() string. + */ +export function toHslString(color: RgbaColor | HslColor): string { + const hsl = "h" in color && "s" in color && "l" in color ? color : rgbToHsl(color); + const h = Math.round(((hsl.h % 360) + 360) % 360); + const s = Math.round(Math.max(0, Math.min(100, hsl.s))); + const l = Math.round(Math.max(0, Math.min(100, hsl.l))); + const aNorm = Math.max(0, Math.min(1, hsl.a)); + + if (aNorm < 1) { + const aFormatted = parseFloat(aNorm.toFixed(4)).toString(); + return `hsla(${h}, ${s}%, ${l}%, ${aFormatted})`; + } + return `hsl(${h}, ${s}%, ${l}%)`; +} + +/** + * Calculates the relative luminance of an sRGB color per WCAG 2.1 specs. + */ +export function relativeLuminance(color: RgbaColor): number { + const rNorm = Math.max(0, Math.min(255, color.r)) / 255; + const gNorm = Math.max(0, Math.min(255, color.g)) / 255; + const bNorm = Math.max(0, Math.min(255, color.b)) / 255; + + const R = rNorm <= 0.04045 ? rNorm / 12.92 : Math.pow((rNorm + 0.055) / 1.055, 2.4); + const G = gNorm <= 0.04045 ? gNorm / 12.92 : Math.pow((gNorm + 0.055) / 1.055, 2.4); + const B = bNorm <= 0.04045 ? bNorm / 12.92 : Math.pow((bNorm + 0.055) / 1.055, 2.4); + + return 0.2126 * R + 0.7152 * G + 0.0722 * B; +} + +/** + * Calculates the WCAG 2.1 contrast ratio between two colors (ranging from 1 to 21). + * If the background has alpha < 1, it is first composited onto white (#ffffff). + * If the foreground has alpha < 1, it is then composited onto the resulting background. + * The contrast ratio is calculated between the two final opaque colors. + */ +export function contrastRatio(color1: RgbaColor, color2: RgbaColor): number { + const bgAlpha = Math.max(0, Math.min(1, color2.a)); + const bg: RgbaColor = { + r: color2.r * bgAlpha + 255 * (1 - bgAlpha), + g: color2.g * bgAlpha + 255 * (1 - bgAlpha), + b: color2.b * bgAlpha + 255 * (1 - bgAlpha), + a: 1, + }; + + const fgAlpha = Math.max(0, Math.min(1, color1.a)); + const fg: RgbaColor = { + r: color1.r * fgAlpha + bg.r * (1 - fgAlpha), + g: color1.g * fgAlpha + bg.g * (1 - fgAlpha), + b: color1.b * fgAlpha + bg.b * (1 - fgAlpha), + a: 1, + }; + + const l1 = relativeLuminance(fg); + const l2 = relativeLuminance(bg); + + const lighter = Math.max(l1, l2); + const darker = Math.min(l1, l2); + + return (lighter + 0.05) / (darker + 0.05); +} + +/** + * Computes WCAG 2.1 AA and AAA compliance ratings for normal and large text. + */ +export function wcagRating(ratio: number): WcagRatingResult { + return { + normalText: { + aa: ratio >= 4.5, + aaa: ratio >= 7, + }, + largeText: { + aa: ratio >= 3, + aaa: ratio >= 4.5, + }, + }; +} diff --git a/lib/tools.ts b/lib/tools.ts index 55fe39a..04233b8 100644 --- a/lib/tools.ts +++ b/lib/tools.ts @@ -317,6 +317,59 @@ export const TOOLS: Tool[] = [ }, sitemapPriority: 0.9, }, + { + slug: "color-converter", + path: "/color-converter", + title: "Color Converter & Contrast Checker", + tagline: "Design", + cardDescription: + "Convert between HEX, RGB, and HSL formats with alpha support, live swatch preview, and WCAG AA and AAA accessibility contrast checking.", + features: ["HEX", "RGB", "HSL", "Alpha", "WCAG contrast"], + accentVar: "--fuchsia", + accentHex: "#F26DF0", + metaTitle: "Color Converter & Contrast Checker - Free Tool", + metaDescription: + "Convert colors between HEX, RGB, and HSL formats with alpha support, live swatch preview, and WCAG AA/AAA contrast ratio checking. Fast, free, and private.", + ogTitle: "Color Converter & Contrast Checker - CODERCOPS Tools", + ogDescription: + "Convert between HEX, RGB, and HSL with alpha channel support, live swatch, and WCAG contrast ratios.", + heroBreadcrumb: "Convert · Preview · Contrast", + heroHeading: "Color Converter & Contrast Checker", + heroDim: "&", + heroDescription: + "Convert colors between HEX, RGB, and HSL formats and check WCAG accessibility contrast ratios in real time — 100% in your browser.", + jsonLdName: "Color Converter & Contrast Checker", + jsonLdDescription: + "Free online color converter and contrast checker supporting HEX, RGB, HSL, alpha channel, and WCAG AA/AAA contrast ratios.", + applicationCategory: "DeveloperApplication", + faqs: [ + { + q: "How does the color converter handle alpha transparency?", + a: "Alpha transparency is supported in 8-digit HEX (#rrggbbaa), rgba(), and hsla() notations. Alpha is normalized between 0 and 1 and factored into contrast calculations when blended over backgrounds.", + }, + { + q: "What are WCAG AA and AAA contrast requirements?", + a: "Under WCAG 2.1 guidelines, normal text requires a contrast ratio of at least 4.5:1 for level AA and 7:1 for level AAA. Large text (at least 18pt or 14pt bold) requires at least 3:1 for AA and 4.5:1 for AAA.", + }, + { + q: "Can I enter modern CSS space-separated color syntax?", + a: "Yes. Both legacy comma-separated syntax like rgb(255, 0, 0) and modern CSS Color 4 space-separated syntax like rgb(255 0 0 / 0.5) and hsl(200 100% 50% / 0.8) are supported.", + }, + { + q: "Is any color data sent to a server?", + a: "No. All conversions, swatch rendering, and contrast calculations run entirely in your browser using client-side JavaScript. No data ever leaves your device.", + }, + ], + cta: { + headline: "Building accessible web apps and design systems?", + sub: "CODERCOPS designs and builds accessible, high-performance web applications and UI component systems for startups and enterprises.", + primaryLabel: "View our work", + primaryHref: CTA_PROJECTS, + secondaryLabel: "Get in touch", + secondaryHref: CTA_CONTACT, + }, + sitemapPriority: 0.9, + }, ]; export function getTool(slug: string): Tool | undefined { From 4a719fa9692c12027227043c752abed5ba050a2b Mon Sep 17 00:00:00 2001 From: tnalbant - timtim <182372382+tnalbant@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:42:31 +0300 Subject: [PATCH 05/50] fix(history): give the history panel the tool's useHistory instance (#178) HistoryPanel created its own useHistory(storageKey) copy of the same localStorage state, and useLocalStorage does not sync separate instances. The panel therefore never saw entries the tool had just pushed (they showed up only after a reload), and the tool's stale copy wrote removed entries back on its next push. The tools now own the single instance and pass it to the panel as a prop (HistoryApi), and the panel's own max default of 20 is gone, so the history size stays defined once per tool (15, as both tools already passed). Three files, +14/-6; lib/useLocalStorage.ts is unchanged. --- .../json-formatter/JsonFormatterClient.tsx | 2 +- app/(tools)/jwt-decoder/JwtDecoderClient.tsx | 2 +- components/shared/HistoryPanel.tsx | 16 ++++++++++++---- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/app/(tools)/json-formatter/JsonFormatterClient.tsx b/app/(tools)/json-formatter/JsonFormatterClient.tsx index f8c6b9c..9a8523f 100644 --- a/app/(tools)/json-formatter/JsonFormatterClient.tsx +++ b/app/(tools)/json-formatter/JsonFormatterClient.tsx @@ -459,7 +459,7 @@ export function JsonFormatterClient() {
{ setInput(v); const r = format(v, indent); diff --git a/app/(tools)/jwt-decoder/JwtDecoderClient.tsx b/app/(tools)/jwt-decoder/JwtDecoderClient.tsx index 64ee09c..b5b9e80 100644 --- a/app/(tools)/jwt-decoder/JwtDecoderClient.tsx +++ b/app/(tools)/jwt-decoder/JwtDecoderClient.tsx @@ -339,7 +339,7 @@ export function JwtDecoderClient() {
setToken(v)} formatLabel={(item) => item.label ?? redact(item.value)} diff --git a/components/shared/HistoryPanel.tsx b/components/shared/HistoryPanel.tsx index 4e41c52..7a6cca9 100644 --- a/components/shared/HistoryPanel.tsx +++ b/components/shared/HistoryPanel.tsx @@ -13,7 +13,15 @@ export interface HistoryItem { label?: string; } -export function useHistory(key: string, max = 20) { +export interface HistoryApi { + items: HistoryItem[]; + push: (value: string, label?: string) => void; + pin: (id: string) => void; + remove: (id: string) => void; + clear: () => void; +} + +export function useHistory(key: string, max = 20): HistoryApi { const [items, setItems] = useLocalStorage(`cc:history:${key}`, []); function push(value: string, label?: string) { @@ -55,14 +63,14 @@ export function useHistory(key: string, max = 20) { } interface HistoryPanelProps { - storageKey: string; + history: HistoryApi; onSelect: (value: string) => void; title?: string; formatLabel?: (item: HistoryItem) => string; } -export function HistoryPanel({ storageKey, onSelect, title = "History", formatLabel }: HistoryPanelProps) { - const { items, pin, remove, clear } = useHistory(storageKey); +export function HistoryPanel({ history, onSelect, title = "History", formatLabel }: HistoryPanelProps) { + const { items, pin, remove, clear } = history; const sorted = useMemo(() => { return [...items].sort((a, b) => { From 2060a24a98f9faf289ad544b3abab1d95d11713b Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:15:28 +0530 Subject: [PATCH 06/50] ci: fail on a BOM or text garbled by the wrong encoding (#194) --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfb2421..89d9bd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + # Some editors save files with a UTF-8 BOM, and a tool that reads UTF-8 with + # the wrong encoding turns a dash or an arrow into two or three other + # characters. Both have reached PRs here, so catch them before review. + - name: No BOM or garbled characters + run: | + if git grep -n -I -F -e $'\xEF\xBB\xBF' -e $'\xCE\x93\xC3\x87' -e $'\xCE\x93\xC3\xA5\xC3\x86' \ + -e $'\xE2\x94\xAC\xE2\x95\x96' -e $'\xC3\xA2\xE2\x82\xAC' -e $'\xC3\x83\xC2\xA9'; then + echo "::error::The lines above start with a UTF-8 BOM or have text garbled by the wrong encoding. Save the file as UTF-8 without a BOM, and copy garbled lines back from the base branch." + exit 1 + fi - uses: actions/setup-node@v6 with: node-version: 22 From bba09c5f4ea3919e04d99d75c892d97f8de01988 Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:15:35 +0530 Subject: [PATCH 07/50] ci: skip the smoke test checks when Cloudflare challenges the runner (#196) --- .github/workflows/release.yml | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32cd16a..42b7811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,12 +74,25 @@ jobs: needs: release runs-on: ubuntu-latest env: - # The repo variable points at the workers.dev URL until the - # tools.codercops.com cutover, and at the real host after it. + # Set the SMOKE_BASE_URL repo variable to check another host. BASE_URL: ${{ vars.SMOKE_BASE_URL || 'https://tools.codercops.com' }} EXPECTED_SHA: ${{ github.event.pull_request.merge_commit_sha }} steps: + # Bot Fight Mode on codercops.com answers GitHub's runners with a + # Cloudflare challenge (403 with cf-mitigated: challenge) instead of the + # site, and production has no workers.dev address to check instead. Then + # the checks below can't see the deploy, so they're skipped with a notice + # and the live site gets checked by hand. + - name: Check whether Cloudflare challenges this runner + run: | + read -r STATUS MITIGATED < <(curl -s -o /dev/null -w "%{http_code} %header{cf-mitigated}\n" --max-time 15 "$BASE_URL/version.txt") + if [ "$STATUS" = "403" ] && [ "$MITIGATED" = "challenge" ]; then + echo "::notice::Cloudflare challenged this runner on $BASE_URL, so the deploy wasn't checked from here. Check the live site by hand." + echo "CHALLENGED=1" >> "$GITHUB_ENV" + fi + - name: Wait for the Cloudflare deploy of this merge + if: env.CHALLENGED != '1' run: | # Workers Builds deploys production on its own. version.txt carries # the commit it built, so poll until it matches (up to 15 minutes). @@ -95,6 +108,7 @@ jobs: exit 1 - name: Homepage reachable + if: env.CHALLENGED != '1' run: | STATUS=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 "$BASE_URL/") if [ "$STATUS" != "200" ]; then @@ -104,6 +118,7 @@ jobs: echo "Homepage check passed" - name: Tool page reachable + if: env.CHALLENGED != '1' run: | STATUS=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 "$BASE_URL/json-formatter") if [ "$STATUS" != "200" ]; then @@ -113,6 +128,7 @@ jobs: echo "Tool page check passed" - name: Sitemap valid + if: env.CHALLENGED != '1' run: | STATUS=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 "$BASE_URL/sitemap.xml") if [ "$STATUS" != "200" ]; then From d71777f4df1e1e5190eefff2697a87d24405cb7e Mon Sep 17 00:00:00 2001 From: Anurag Verma <78868769+anurag629@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:15:41 +0530 Subject: [PATCH 08/50] docs: say the PR has to be opened here, not inside a fork (#197) --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c7d0b62..b03c46a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ We're taking part in Hacktoberfest this year, and first-time contributors are ve 2. Comment on it to ask for it. I'll assign it to you, usually within a day. Please don't start on an issue that's assigned to someone else. 3. Take one issue at a time. Once your PR is merged, grab the next one. After 3 merged PRs with us you can hold two issues at once. 4. If you're assigned and there's no PR or update for 5 days, I'll free the issue up for someone else. Just comment if you need more time. -5. Open your PR against `develop` and put `Closes #` in the description. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty. +5. Open your PR against `develop` here in codercops/toolbelt and put `Closes #` in the description. A PR merged inside your own fork never reaches us, so it doesn't count. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty. PRs count for Hacktoberfest once they're merged or labeled `hacktoberfest-accepted`. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. The full rules are under [Ground rules](#ground-rules) below. From 968bd8a7d0fe1c418ff5fff43a06fbde81012912 Mon Sep 17 00:00:00 2001 From: Piyush Date: Mon, 19 Oct 2026 10:19:11 +0530 Subject: [PATCH 09/50] feat: add Unix timestamp converter (#190) * feat: add Unix timestamp converter * fix: address timestamp converter review feedback * fix: restore upstream files after review --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .../TimestampConverterClient.tsx | 130 +++++++++++++++++ .../timestamp-converter/opengraph-image.tsx | 11 ++ app/(tools)/timestamp-converter/page.tsx | 16 +++ lib/__tests__/time-utils.test.ts | 92 ++++++++++++ lib/time-utils.ts | 134 ++++++++++++++++++ lib/tools.ts | 53 +++++++ 7 files changed, 437 insertions(+) create mode 100644 app/(tools)/timestamp-converter/TimestampConverterClient.tsx create mode 100644 app/(tools)/timestamp-converter/opengraph-image.tsx create mode 100644 app/(tools)/timestamp-converter/page.tsx create mode 100644 lib/__tests__/time-utils.test.ts create mode 100644 lib/time-utils.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 9398007..80400ee 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -17,6 +17,7 @@ body: - URL encoder/decoder - Invoice generator - Color converter & contrast checker + - Unix timestamp converter - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/app/(tools)/timestamp-converter/TimestampConverterClient.tsx b/app/(tools)/timestamp-converter/TimestampConverterClient.tsx new file mode 100644 index 0000000..72860fb --- /dev/null +++ b/app/(tools)/timestamp-converter/TimestampConverterClient.tsx @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { formatTimestamp, relativeTime } from "@/lib/jwt-utils"; +import { detectUnit, fromEpoch, toEpoch, type TimestampUnit } from "@/lib/time-utils"; + +const UNIT_OPTIONS: { value: TimestampUnit; label: string }[] = [ + { value: "s", label: "Seconds" }, + { value: "ms", label: "Milliseconds" }, + { value: "us", label: "Microseconds" }, + { value: "ns", label: "Nanoseconds" }, +]; + +function Field({ label, value, copyValue }: { label: string; value: string; copyValue?: string }) { + return ( +
+
+ {label} + {copyValue !== undefined && } +
+

{value || "—"}

+
+ ); +} + +export function TimestampConverterClient() { + const [now, setNow] = useState(null); + const [epochInput, setEpochInput] = useState("1700000000"); + const [unit, setUnit] = useState<"auto" | TimestampUnit>("auto"); + const [dateInput, setDateInput] = useState("2024-02-29T00:00:00Z"); + + useEffect(() => { + const initialTimer = window.setTimeout(() => { + setNow(Date.now()); + }, 0); + const timer = window.setInterval(() => setNow(Date.now()), 1000); + return () => { + window.clearTimeout(initialTimer); + window.clearInterval(timer); + }; + }, []); + + const detectedUnit = useMemo(() => detectUnit(epochInput), [epochInput]); + const epochResult = useMemo( + () => fromEpoch(epochInput, unit === "auto" ? undefined : unit), + [epochInput, unit], + ); + const dateResult = useMemo(() => toEpoch(dateInput), [dateInput]); + + const liveSeconds = now === null ? "—" : Math.floor(now / 1000).toString(); + const liveMilliseconds = now === null ? "—" : now.toString(); + const liveIso = now === null ? "—" : new Date(now).toISOString(); + + return ( +
+
+
+
+

Live Unix time

+

Current timestamp

+
+

Updates every second

+
+
+ + +
+
+ +
+
+ +
+
+
+

Epoch to date

+

Convert a timestamp

+

Paste an epoch value. Unit detection is based on its magnitude.

+
+ + setEpochInput(e.target.value)} inputMode="decimal" spellCheck={false} placeholder="e.g. 1700000000" className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--bg)] px-3 py-3 font-mono text-sm text-[var(--fg)] outline-none focus:border-[var(--green)]" /> +
+
+ + +
+
+ +
+
+ {!epochResult.ok ? ( +

{epochResult.error}

+ ) : ( +
+

Interpreted as {UNIT_OPTIONS.find((option) => option.value === epochResult.unitUsed)?.label}

+ + + + +
+ )} +
+ +
+
+

Date to epoch

+

Convert a date

+

Enter an ISO 8601 timestamp or a date string recognized by your browser.

+
+ + setDateInput(e.target.value)} spellCheck={false} placeholder="2024-02-29T00:00:00Z" className="w-full rounded-xl border border-[var(--hairline)] bg-[var(--bg)] px-3 py-3 font-mono text-sm text-[var(--fg)] outline-none focus:border-[var(--green)]" /> + {!dateResult.ok ? ( +

{dateResult.error}

+ ) : ( +
+ + + +
+ )} +

For reliable results, include a timezone such as Z or an explicit UTC offset.

+
+
+
+ ); +} diff --git a/app/(tools)/timestamp-converter/opengraph-image.tsx b/app/(tools)/timestamp-converter/opengraph-image.tsx new file mode 100644 index 0000000..9dba05d --- /dev/null +++ b/app/(tools)/timestamp-converter/opengraph-image.tsx @@ -0,0 +1,11 @@ +import { getTool } from "@/lib/tools"; +import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og"; + +const tool = getTool("timestamp-converter")!; +export const alt = tool.ogTitle; +export const size = OG_SIZE; +export const contentType = OG_CONTENT_TYPE; + +export default function Image() { + return renderToolOgImage(tool); +} diff --git a/app/(tools)/timestamp-converter/page.tsx b/app/(tools)/timestamp-converter/page.tsx new file mode 100644 index 0000000..9e26de0 --- /dev/null +++ b/app/(tools)/timestamp-converter/page.tsx @@ -0,0 +1,16 @@ +import type { Metadata } from "next"; +import { TimestampConverterClient } from "./TimestampConverterClient"; +import { ToolPageLayout } from "@/components/shared/ToolPageLayout"; +import { generateToolMetadata } from "@/lib/toolMetadata"; +import { getTool } from "@/lib/tools"; + +const tool = getTool("timestamp-converter")!; +export const metadata: Metadata = generateToolMetadata(tool); + +export default function TimestampConverterPage() { + return ( + + + + ); +} diff --git a/lib/__tests__/time-utils.test.ts b/lib/__tests__/time-utils.test.ts new file mode 100644 index 0000000..3bd280f --- /dev/null +++ b/lib/__tests__/time-utils.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; +import { detectUnit, fromEpoch, toEpoch } from "@/lib/time-utils"; + +describe("detectUnit", () => { + it("detects seconds", () => { + expect(detectUnit(1_700_000_000)).toBe("s"); + }); + + it("detects milliseconds", () => { + expect(detectUnit(1_700_000_000_000)).toBe("ms"); + }); + + it("detects microseconds and nanoseconds", () => { + expect(detectUnit(1_700_000_000_000_000)).toBe("us"); + expect(detectUnit(1_700_000_000_000_000_000)).toBe("ns"); + }); + + it("detects negative timestamps by magnitude", () => { + expect(detectUnit(-1_700_000_000)).toBe("s"); + }); +}); + +describe("fromEpoch", () => { + it("converts zero to the Unix epoch", () => { + const result = fromEpoch(0); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.iso).toBe("1970-01-01T00:00:00.000Z"); + expect(result.unitUsed).toBe("s"); + } + }); + + it("converts seconds to ISO 8601", () => { + const result = fromEpoch(1_700_000_000); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.iso).toBe("2023-11-14T22:13:20.000Z"); + } + }); + + it("auto-detects milliseconds", () => { + const result = fromEpoch(1_700_000_000_000); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.iso).toBe("2023-11-14T22:13:20.000Z"); + expect(result.unitUsed).toBe("ms"); + } + }); + + it("supports negative timestamps", () => { + const result = fromEpoch(-1); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.iso).toBe("1969-12-31T23:59:59.000Z"); + } + }); + + it("rejects nonnumeric input", () => { + expect(fromEpoch("abc").ok).toBe(false); + }); + + it("supports a manual unit override", () => { + const result = fromEpoch(1_700_000_000, "ms"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.unitUsed).toBe("ms"); + } + }); + + it("rejects out-of-range timestamps", () => { + expect(fromEpoch("999999999999999999999999").ok).toBe(false); + }); +}); + +describe("toEpoch", () => { + it("converts an ISO 8601 leap-day timestamp", () => { + const result = toEpoch("2024-02-29T00:00:00Z"); + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.seconds).toBe(1_709_164_800); + expect(result.milliseconds).toBe(1_709_164_800_000); + } + }); + + it("rejects invalid dates", () => { + expect(toEpoch("not a date").ok).toBe(false); + }); + + it("rejects empty input", () => { + expect(toEpoch("").ok).toBe(false); + }); +}); diff --git a/lib/time-utils.ts b/lib/time-utils.ts new file mode 100644 index 0000000..aa69717 --- /dev/null +++ b/lib/time-utils.ts @@ -0,0 +1,134 @@ + +export type TimestampUnit = "s" | "ms" | "us" | "ns"; + +export type EpochResult = + | { + ok: true; + iso: string; + utc: string; + unitUsed: TimestampUnit; + milliseconds: number; + } + | { + ok: false; + error: string; + }; + +export type DateEpochResult = + | { ok: true; seconds: number; milliseconds: number } + | { ok: false; error: string }; + +const MAX_DATE_MS = 8.64e15; + +function parseNumericValue(value: string | number): number | null { + if (typeof value === "number") { + return Number.isFinite(value) ? value : null; + } + + const trimmed = value.trim(); + if (!trimmed) return null; + + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +/** + * Detect the likely epoch unit using the magnitude of the value. + * Thresholds follow the issue's examples and also support microseconds + * and nanoseconds. + */ +export function detectUnit(value: string | number): TimestampUnit { + const parsed = parseNumericValue(value); + if (parsed === null) return "s"; + + const magnitude = Math.abs(parsed); + + if (magnitude < 1e11) return "s"; + if (magnitude < 1e14) return "ms"; + if (magnitude < 1e17) return "us"; + return "ns"; +} + +function unitToMilliseconds(value: number, unit: TimestampUnit): number { + switch (unit) { + case "s": + return value * 1000; + case "ms": + return value; + case "us": + return value / 1000; + case "ns": + return value / 1_000_000; + } +} + +function formatUtc(date: Date): string { + return date.toLocaleString("en-GB", { + timeZone: "UTC", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }) + " UTC"; +} + +export function fromEpoch( + value: string | number, + unit?: TimestampUnit +): EpochResult { + const parsed = parseNumericValue(value); + + if (parsed === null) { + return { ok: false, error: "Enter a valid numeric timestamp." }; + } + + const unitUsed = unit ?? detectUnit(parsed); + const milliseconds = unitToMilliseconds(parsed, unitUsed); + + if (!Number.isFinite(milliseconds) || Math.abs(milliseconds) > MAX_DATE_MS) { + return { ok: false, error: "Timestamp is outside the supported date range." }; + } + + const date = new Date(milliseconds); + + if (Number.isNaN(date.getTime())) { + return { ok: false, error: "Timestamp is outside the supported date range." }; + } + + return { + ok: true, + iso: date.toISOString(), + utc: formatUtc(date), + unitUsed, + milliseconds, + }; +} + +export function toEpoch(dateString: string): DateEpochResult { + const trimmed = dateString.trim(); + + if (!trimmed) { + return { ok: false, error: "Enter a date or ISO 8601 timestamp." }; + } + + const milliseconds = Date.parse(trimmed); + + if (!Number.isFinite(milliseconds)) { + return { ok: false, error: "Enter a valid date or ISO 8601 timestamp." }; + } + + const date = new Date(milliseconds); + + if (Number.isNaN(date.getTime())) { + return { ok: false, error: "Enter a valid date or ISO 8601 timestamp." }; + } + + return { + ok: true, + seconds: Math.floor(milliseconds / 1000), + milliseconds, + }; +} diff --git a/lib/tools.ts b/lib/tools.ts index 04233b8..64ca28e 100644 --- a/lib/tools.ts +++ b/lib/tools.ts @@ -317,6 +317,59 @@ export const TOOLS: Tool[] = [ }, sitemapPriority: 0.9, }, + { + slug: "timestamp-converter", + path: "/timestamp-converter", + title: "Unix Timestamp Converter", + tagline: "Time", + cardDescription: + "Convert Unix timestamps and dates with automatic unit detection, UTC, local time, ISO 8601, and relative time.", + features: ["Epoch to date", "Date to epoch", "Seconds or ms", "ISO 8601", "Relative time"], + accentVar: "--green", + accentHex: "#3FDD6B", + metaTitle: "Unix Timestamp Converter - Free Online Tool", + metaDescription: + "Convert Unix timestamps to readable dates and dates to epoch time. Detect seconds or milliseconds, view UTC and local time, and copy ISO 8601 output.", + ogTitle: "Unix Timestamp Converter - CODERCOPS Tools", + ogDescription: + "Convert Unix timestamps and dates. Detect seconds or milliseconds, inspect UTC and local time, and copy ISO 8601 output.", + heroBreadcrumb: "Time", + heroHeading: "Unix Timestamp Converter", + heroDim: "", + heroDescription: + "Convert epoch timestamps to readable dates or dates to Unix time. All conversions run locally in your browser.", + jsonLdName: "Unix Timestamp Converter", + jsonLdDescription: + "Free online Unix timestamp converter for epoch-to-date and date-to-epoch conversions, with automatic unit detection and ISO 8601 output.", + applicationCategory: "DeveloperApplication", + faqs: [ + { + q: "What is a Unix timestamp?", + a: "A Unix timestamp measures elapsed time from 1970-01-01 00:00:00 UTC. It is commonly represented in seconds or milliseconds.", + }, + { + q: "How can I tell seconds from milliseconds?", + a: "This tool estimates the unit from the number's magnitude. You can also select seconds, milliseconds, microseconds, or nanoseconds manually.", + }, + { + q: "Are my timestamps sent to a server?", + a: "No. Conversion happens locally in your browser. The values you enter are not uploaded by this tool.", + }, + { + q: "Which date formats are supported?", + a: "ISO 8601 timestamps and date strings understood by your browser are supported. For unambiguous results, include a timezone such as Z or an explicit UTC offset.", + }, + ], + cta: { + headline: "Building tools that handle dates and APIs?", + sub: "CODERCOPS builds production-grade backends, REST APIs, and full-stack applications for startups and businesses.", + primaryLabel: "View our work", + primaryHref: CTA_PROJECTS, + secondaryLabel: "Get a free quote", + secondaryHref: CTA_CONTACT, + }, + sitemapPriority: 0.9, + }, { slug: "color-converter", path: "/color-converter", From 40d1372e5dc94c7efef39e69785cf9e600eb489d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:33:24 +0530 Subject: [PATCH 10/50] chore(deps-dev): bump the dev-dependencies group across 1 directory with 3 updates (#198) Bumps the dev-dependencies group with 3 updates in the / directory: [postcss](https://github.com/postcss/postcss), [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) and [wrangler](https://github.com/cloudflare/workers-sdk/tree/HEAD/packages/wrangler). Updates `postcss` from 8.5.29 to 8.5.30 - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.29...8.5.30) Updates `vitest` from 4.1.11 to 4.1.12 - [Release notes](https://github.com/vitest-dev/vitest/releases) - [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md) - [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.12/packages/vitest) Updates `wrangler` from 4.149.0 to 4.151.0 - [Release notes](https://github.com/cloudflare/workers-sdk/releases) - [Commits](https://github.com/cloudflare/workers-sdk/commits/wrangler@4.151.0/packages/wrangler) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.30 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: vitest dependency-version: 4.1.12 dependency-type: direct:development update-type: version-update:semver-patch dependency-group: dev-dependencies - dependency-name: wrangler dependency-version: 4.151.0 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: dev-dependencies ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 344 +++++++++++++++++++++++++--------------------- package.json | 4 +- 2 files changed, 189 insertions(+), 159 deletions(-) diff --git a/package-lock.json b/package-lock.json index 72aebbb..aeccb61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,8 +31,8 @@ "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5", - "vitest": "^4.1.11", - "wrangler": "^4.149.0" + "vitest": "^4.1.12", + "wrangler": "^4.151.0" }, "engines": { "node": ">=20" @@ -1465,9 +1465,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-64": { - "version": "1.20261006.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261006.1.tgz", - "integrity": "sha512-lCX6avJO2It9AyDEYitjmOtJRVp/xmaW0TziyoLAnfxlzozyyRbSFfIIMMCiDx/sTP1++E4VLbV+7BHSD33f0Q==", + "version": "1.20261012.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20261012.1.tgz", + "integrity": "sha512-dkTL9aY3V4D40b+jBpGPCistWUPFQ/M61wlWi6i1o8MvHFFgEw7EQkcaYEv6Yd3bkPDD/3TFT4hRsMb2xVZSwQ==", "cpu": [ "x64" ], @@ -1481,9 +1481,9 @@ } }, "node_modules/@cloudflare/workerd-darwin-arm64": { - "version": "1.20261006.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261006.1.tgz", - "integrity": "sha512-PyFGjOaldtppnLuKsdkoYWXfLVpDjuf8u+uoykX+1Xvxo2EPmCVViq0iDKm+U7c7PRno85ehS3/MPVXyk2HwHw==", + "version": "1.20261012.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20261012.1.tgz", + "integrity": "sha512-2/nVGcKXjNNI+DhxKSepTKvFng9H7aN2WXv6Zu67iqgI6JrxypNt5kTPv4ZHosxrcfyKMci/2RfN7GIrN0BitQ==", "cpu": [ "arm64" ], @@ -1497,9 +1497,9 @@ } }, "node_modules/@cloudflare/workerd-linux-64": { - "version": "1.20261006.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261006.1.tgz", - "integrity": "sha512-Ntf9S8eesmVufdjaT2kzWlz6Ng9XGYTc6cHQVRKAg59lFufwsIabGeMT6U4KkrpEeSIxgHq8aPtuLbZXE19tCA==", + "version": "1.20261012.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20261012.1.tgz", + "integrity": "sha512-uMe7ONHnLxStD1Ye0YFHBO36uj4cStv5P53rW+5GVWXFT8BRjqVixlH0IqCAHe1Pmcwyd1+7fVoGde89MZSCJg==", "cpu": [ "x64" ], @@ -1513,9 +1513,9 @@ } }, "node_modules/@cloudflare/workerd-linux-arm64": { - "version": "1.20261006.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261006.1.tgz", - "integrity": "sha512-JyeDBymVWw5tVRebSsVE8ojDYxd6UlZrAseHU8G+NpIyd5wF61tXlF/axLeCUqu0pOhBFHb8I5QKi0jwrCK41Q==", + "version": "1.20261012.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20261012.1.tgz", + "integrity": "sha512-I0VMdcXk7ygG5vYDce0ePTBFdnBXC0i9WiBY5mWiv0iTinbfea6/rHqSQ7B1UMpjgrYVs9E0m4bZ9ufsk5GCFA==", "cpu": [ "arm64" ], @@ -1529,9 +1529,9 @@ } }, "node_modules/@cloudflare/workerd-windows-64": { - "version": "1.20261006.1", - "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261006.1.tgz", - "integrity": "sha512-nWP0URNqOBN9fa/gZ7eh/w051DwjOXN8j4lDZDYVXyQPsux6sV3DfyYZT+CjR/rt9nKBSmJT9QE9/bWT18+84w==", + "version": "1.20261012.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20261012.1.tgz", + "integrity": "sha512-c5hQvNx574UEoknaCFvMa/NdLrrYiALNbe6sx1okW8uOiRYWxrFuA8WpXFnBJ5XJypm0TEAHYaUTGv3rRLpY7Q==", "cpu": [ "x64" ], @@ -3362,9 +3362,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.152.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", - "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", "dev": true, "license": "MIT", "funding": { @@ -3420,9 +3420,9 @@ "license": "MIT" }, "node_modules/@rolldown/binding-android-arm-eabi": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz", - "integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", "cpu": [ "arm" ], @@ -3437,9 +3437,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz", - "integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", "cpu": [ "arm64" ], @@ -3454,9 +3454,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz", - "integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", "cpu": [ "arm64" ], @@ -3471,9 +3471,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz", - "integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", "cpu": [ "x64" ], @@ -3488,9 +3488,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz", - "integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", "cpu": [ "x64" ], @@ -3505,9 +3505,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz", - "integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", "cpu": [ "arm" ], @@ -3522,13 +3522,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz", - "integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3539,13 +3542,16 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz", - "integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", "cpu": [ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3556,13 +3562,16 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz", - "integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", "cpu": [ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3573,13 +3582,16 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz", - "integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", "cpu": [ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3590,13 +3602,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz", - "integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -3607,13 +3622,16 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz", - "integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", "cpu": [ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -3624,9 +3642,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz", - "integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", "cpu": [ "arm64" ], @@ -3641,9 +3659,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz", - "integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", "cpu": [ "arm64" ], @@ -3658,9 +3676,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz", - "integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", "cpu": [ "x64" ], @@ -4951,16 +4969,16 @@ ] }, "node_modules/@vitest/expect": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", - "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -4969,13 +4987,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", - "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.11", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -4996,9 +5014,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", - "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { @@ -5009,13 +5027,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", - "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.11", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -5023,14 +5041,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", - "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -5039,9 +5057,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", - "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -5049,13 +5067,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", - "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.11", + "@vitest/pretty-format": "4.1.12", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -8808,6 +8826,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8829,6 +8850,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8850,6 +8874,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -8871,6 +8898,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MPL-2.0", "optional": true, "os": [ @@ -9101,15 +9131,15 @@ } }, "node_modules/miniflare": { - "version": "5.20261006.1-alpha", - "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261006.1-alpha.tgz", - "integrity": "sha512-6ylv8tamkrspDI2R/cAYfv+YWmfGNdXe76Pi9TauEUWsFVXn3LNF9Yk8oduzf1a+TYsSRYTqlOE7+nGGRsMHdw==", + "version": "5.20261012.0-beta", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261012.0-beta.tgz", + "integrity": "sha512-Cjrs+vHHQ7krLO4HYELYpYBJBuMhcnNZD/Z5z/UcQSnFqWSQY3DhtTCFalXgI1/yO1iK+YrRKK8EYkumrEopxg==", "license": "MIT", "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.5", "undici": "7.29.1", - "workerd": "1.20261006.1", + "workerd": "1.20261012.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, @@ -9201,9 +9231,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.19", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", - "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "funding": [ { "type": "github", @@ -9832,9 +9862,9 @@ } }, "node_modules/postcss": { - "version": "8.5.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", - "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", "dev": true, "funding": [ { @@ -9852,7 +9882,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.19", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" }, @@ -10308,13 +10338,13 @@ } }, "node_modules/rolldown": { - "version": "1.2.12", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", - "integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==", + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.152.0", + "@oxc-project/types": "=0.154.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -10324,21 +10354,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm-eabi": "1.2.12", - "@rolldown/binding-android-arm64": "1.2.12", - "@rolldown/binding-darwin-arm64": "1.2.12", - "@rolldown/binding-darwin-x64": "1.2.12", - "@rolldown/binding-freebsd-x64": "1.2.12", - "@rolldown/binding-linux-arm-gnueabihf": "1.2.12", - "@rolldown/binding-linux-arm64-gnu": "1.2.12", - "@rolldown/binding-linux-arm64-musl": "1.2.12", - "@rolldown/binding-linux-ppc64-gnu": "1.2.12", - "@rolldown/binding-linux-s390x-gnu": "1.2.12", - "@rolldown/binding-linux-x64-gnu": "1.2.12", - "@rolldown/binding-linux-x64-musl": "1.2.12", - "@rolldown/binding-openharmony-arm64": "1.2.12", - "@rolldown/binding-win32-arm64-msvc": "1.2.12", - "@rolldown/binding-win32-x64-msvc": "1.2.12" + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" } }, "node_modules/router": { @@ -11720,16 +11750,16 @@ } }, "node_modules/vite": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz", - "integrity": "sha512-SQr1x6W5vVSbROg7vsyXIaxK9b0G7zsT68acdWWRmnBUsgDieLCRG+Rep9WdZgcposvv/GSnr4GUUBqB3vXq6w==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.28", - "rolldown": "~1.2.11", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "bin": { @@ -11811,19 +11841,19 @@ } }, "node_modules/vitest": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", - "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.11", - "@vitest/mocker": "4.1.11", - "@vitest/pretty-format": "4.1.11", - "@vitest/runner": "4.1.11", - "@vitest/snapshot": "4.1.11", - "@vitest/spy": "4.1.11", - "@vitest/utils": "4.1.11", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -11851,12 +11881,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.11", - "@vitest/browser-preview": "4.1.11", - "@vitest/browser-webdriverio": "4.1.11", - "@vitest/coverage-istanbul": "4.1.11", - "@vitest/coverage-v8": "4.1.11", - "@vitest/ui": "4.1.11", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" @@ -12070,9 +12100,9 @@ } }, "node_modules/workerd": { - "version": "1.20261006.1", - "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261006.1.tgz", - "integrity": "sha512-8MadBuffZ5NdpLwZjUmsqT5QlaKXq4gjgT6wzcKeynZTICNXyePhc+5QVoKiK7+MMhFTLNrRO2KXLlpk8Q6BkA==", + "version": "1.20261012.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20261012.1.tgz", + "integrity": "sha512-tEHoMPsMga0seVjxfsR8Pd3f8IRqYwkq++dTUKI5Gdgj6zuSDluYwisydq+dn90SsIACwBquJUQ1nBpcOqaWTA==", "hasInstallScript": true, "license": "Apache-2.0", "bin": { @@ -12082,27 +12112,27 @@ "node": ">=16" }, "optionalDependencies": { - "@cloudflare/workerd-darwin-64": "1.20261006.1", - "@cloudflare/workerd-darwin-arm64": "1.20261006.1", - "@cloudflare/workerd-linux-64": "1.20261006.1", - "@cloudflare/workerd-linux-arm64": "1.20261006.1", - "@cloudflare/workerd-windows-64": "1.20261006.1" + "@cloudflare/workerd-darwin-64": "1.20261012.1", + "@cloudflare/workerd-darwin-arm64": "1.20261012.1", + "@cloudflare/workerd-linux-64": "1.20261012.1", + "@cloudflare/workerd-linux-arm64": "1.20261012.1", + "@cloudflare/workerd-windows-64": "1.20261012.1" } }, "node_modules/wrangler": { - "version": "4.149.0", - "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.149.0.tgz", - "integrity": "sha512-OzK7xmB5r5iLKb3cIT3783g13fe6T7xyeKu9LEdUyl+DKAM2KP/jkjnE0OUhyw+mB/XJM9ycjgF3MWm8PR9amg==", + "version": "4.151.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.151.0.tgz", + "integrity": "sha512-ra8MpzSR+ClkbGofu9S+AmAKjytS60MqSuayXKpa1CAYIzP+NIc8q6EiF55Gz5PS3lxT4qcvMuPHihCNhTPpDg==", "license": "MIT OR Apache-2.0", "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.2", "blake3-wasm": "2.1.5", "esbuild": "0.28.2", - "miniflare": "5.20261006.1-alpha", + "miniflare": "5.20261012.0-beta", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", - "workerd": "1.20261006.1" + "workerd": "1.20261012.1" }, "bin": { "cf-wrangler": "bin/cf-wrangler.js", diff --git a/package.json b/package.json index 6d22afc..332a4bb 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,8 @@ "postcss": "^8", "tailwindcss": "^3.4.1", "typescript": "^5", - "vitest": "^4.1.11", - "wrangler": "^4.149.0" + "vitest": "^4.1.12", + "wrangler": "^4.151.0" }, "overrides": { "@node-minify/core": { From 3cbcb9123d12711138a678f9b1fbf36966d922ca Mon Sep 17 00:00:00 2001 From: Sameer Akhtar <73059128+sameerakhtar406@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:06:10 +0530 Subject: [PATCH 11/50] Fix: Update parseSyntaxError to use lossless-json for accurate error locations (#181) * Fix: Update parseSyntaxError to use lossless-json for accurate error locations * Refactored: review feedback on fallback logic and test cases * Apply batched suggestions from code review Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> * Apply batched suggestions from code review Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> * style: apply formatting fix for else try catch block --------- Co-authored-by: Anurag Verma <78868769+anurag629@users.noreply.github.com> --- lib/__tests__/json-utils.test.ts | 58 ++++++++++++++++++++++++++++++++ lib/json-utils.ts | 19 ++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/__tests__/json-utils.test.ts b/lib/__tests__/json-utils.test.ts index 0ca5d19..1635d3c 100644 --- a/lib/__tests__/json-utils.test.ts +++ b/lib/__tests__/json-utils.test.ts @@ -11,10 +11,68 @@ import { escapeForCode, parseJson5Compat, diffLines, + validate, } from "../json-utils"; const BIG = '{"id":12345678901234567890,"pi":3.141592653589793238,"n":42}'; +describe("JSON Formatter Error Locations", () => { + it("extracts correct location for unquoted values", () => { + const result = validate('{"a": x}'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.line).toBe(1); + expect(result.error.column).toBe(7); + } + }); + + it("extracts correct location for multi-line JSON", () => { + const input = '{"a": 1,\n"b": tru\n}'; + const result = validate(input); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.line).toBe(2); + expect(result.error.column).toBe(6); + } + }); + + it("keeps original location for duplicate keys with trailing comma", () => { + const result = validate('{"a":1,"a":2,}'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.line).toBe(1); + expect(result.error.column).toBe(14); + } + }); + + it("extracts correct location for unexpected end of input", () => { + const result = validate('{"a": '); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.line).toBe(1); + expect(result.error.column).toBe(7); + } + }); + + it("extracts correct location for undefined in array", () => { + const result = validate('[1, 2, undefined]'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.line).toBe(1); + expect(result.error.column).toBe(8); + } + }); + + it("keeps existing correct locations intact", () => { + const result = validate('{"a":1,}'); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.line).toBe(1); + expect(result.error.column).toBe(8); + } + }); +}); + describe("lossless numbers", () => { it("preserves large integers and long decimals when formatting", () => { const r = format(BIG, 2); diff --git a/lib/json-utils.ts b/lib/json-utils.ts index 5b9db4f..5c8e9fe 100644 --- a/lib/json-utils.ts +++ b/lib/json-utils.ts @@ -35,9 +35,11 @@ function parseSyntaxError(raw: string, err: unknown): JsonError { const msg = err instanceof Error ? err.message : String(err); const posMatch = msg.match(/position (\d+)/i); const lineMatch = msg.match(/line (\d+) column (\d+)/i); + let line = 1; let column = 1; let position = 0; + if (posMatch) { position = parseInt(posMatch[1], 10); const lc = computeLineCol(raw, position); @@ -46,11 +48,26 @@ function parseSyntaxError(raw: string, err: unknown): JsonError { } else if (lineMatch) { line = parseInt(lineMatch[1], 10); column = parseInt(lineMatch[2], 10); + } else { + try { + llParse(raw); + } catch (llError) { + if (llError instanceof Error) { + const llPosMatch = llError.message.match(/position (\d+)/i); + if (llPosMatch) { + position = parseInt(llPosMatch[1], 10); + const lc = computeLineCol(raw, position); + line = lc.line; + column = lc.column; + } + } + } } + const suggestion = suggestFix(raw); return { message: msg.replace(/in JSON.*$/, "").trim() || "Invalid JSON", line, column, position, suggestion }; } - + export function validate(raw: string): Result { if (!raw.trim()) { return { From c5af43f5385183bfc4a6497153f6b7cc96d18008 Mon Sep 17 00:00:00 2001 From: Stalin <161853795+0x5t4l1n@users.noreply.github.com> Date: Mon, 19 Oct 2026 12:06:23 +0530 Subject: [PATCH 12/50] fix: keep command palette selection visible during keyboard navigation (#192) --- components/shared/CommandPalette.tsx | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/components/shared/CommandPalette.tsx b/components/shared/CommandPalette.tsx index 7d27eef..10380b6 100644 --- a/components/shared/CommandPalette.tsx +++ b/components/shared/CommandPalette.tsx @@ -46,6 +46,7 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode const scopedRef = useRef>({}); const [scopedTick, setScopedTick] = useState(0); const inputRef = useRef(null); + const keyboardSelectionRef = useRef(false); const register = useCallback((scope: string, commands: Command[]) => { scopedRef.current[scope] = commands; @@ -144,6 +145,12 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode useEffect(() => setCursor(0), [query]); + useEffect(() => { + if (!isOpen || !keyboardSelectionRef.current) return; + document.getElementById(`cmd-opt-${cursor}`)?.scrollIntoView({ block: "nearest" }); + keyboardSelectionRef.current = false; + }, [cursor, isOpen]); + function onKeyDown(e: React.KeyboardEvent) { if (e.key === "Tab") { // Trap focus: the palette is a combobox driven by arrows, so keep focus @@ -151,9 +158,11 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode e.preventDefault(); } else if (e.key === "ArrowDown") { e.preventDefault(); + keyboardSelectionRef.current = true; setCursor((c) => Math.min(filtered.length - 1, c + 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); + keyboardSelectionRef.current = true; setCursor((c) => Math.max(0, c - 1)); } else if (e.key === "Enter") { e.preventDefault(); @@ -187,7 +196,10 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode setQuery(e.target.value)} + onChange={(e) => { + keyboardSelectionRef.current = false; + setQuery(e.target.value); + }} onKeyDown={onKeyDown} placeholder="Search commands, tools, actions..." className="flex-1 bg-transparent text-[14px] text-[var(--fg)] outline-none font-mono" @@ -207,9 +219,12 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode
)} {filtered.map((cmd, i) => ( -
+
{i === 0 || filtered[i - 1].section !== cmd.section ? ( -
+
{cmd.section ?? "Actions"}
) : null} @@ -221,7 +236,10 @@ export function CommandPaletteProvider({ children }: { children: React.ReactNode setIsOpen(false); Promise.resolve(cmd.run()).catch(() => {}); }} - onMouseEnter={() => setCursor(i)} + onMouseMove={() => { + keyboardSelectionRef.current = false; + setCursor(i); + }} className={cn( "w-full flex items-center gap-3 px-4 py-2 text-left transition-colors", i === cursor ? "bg-white/[0.05]" : "hover:bg-white/[0.03]" From 53df087e357be9f9c156a464ee26185071ee0fd2 Mon Sep 17 00:00:00 2001 From: Harshit Bharti Date: Mon, 19 Oct 2026 14:31:43 +0530 Subject: [PATCH 13/50] feat: add Text Case Converter tool (#199) * feat: add Text Case Converter tool Closes #81 * fix: address review feedback on case converter a11y - drop textarea aria-label (visible label already associated via htmlFor) - drop invalid aria-label from output pre elements - stop uppercasing output labels so casing stays readable --------- Co-authored-by: harshit-exe-dev --- .github/ISSUE_TEMPLATE/bug_report.yml | 1 + .../case-converter/CaseConverterClient.tsx | 69 +++++++++++++++ .../case-converter/opengraph-image.tsx | 12 +++ app/(tools)/case-converter/page.tsx | 17 ++++ lib/__tests__/case-utils.test.ts | 69 +++++++++++++++ lib/case-utils.ts | 83 +++++++++++++++++++ lib/tools.ts | 49 +++++++++++ 7 files changed, 300 insertions(+) create mode 100644 app/(tools)/case-converter/CaseConverterClient.tsx create mode 100644 app/(tools)/case-converter/opengraph-image.tsx create mode 100644 app/(tools)/case-converter/page.tsx create mode 100644 lib/__tests__/case-utils.test.ts create mode 100644 lib/case-utils.ts diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 80400ee..384a923 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -18,6 +18,7 @@ body: - Invoice generator - Color converter & contrast checker - Unix timestamp converter + - Text case converter - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/app/(tools)/case-converter/CaseConverterClient.tsx b/app/(tools)/case-converter/CaseConverterClient.tsx new file mode 100644 index 0000000..7df900c --- /dev/null +++ b/app/(tools)/case-converter/CaseConverterClient.tsx @@ -0,0 +1,69 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { toCase, CASE_STYLES } from "@/lib/case-utils"; +import { CopyButton } from "@/components/shared/CopyButton"; + +export function CaseConverterClient() { + const [input, setInput] = useState(""); + + // Pure conversion — derived from the input, no effect needed. + const outputs = useMemo( + () => + CASE_STYLES.map((style) => ({ + ...style, + value: input ? toCase(input, style.id) : "", + })), + [input] + ); + + return ( +
+
+
+ + + {input.length} chars + +
+