diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 077b492..d7866cd 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -25,6 +25,7 @@ body: - CSV/TSV Formatter & Validator - Regex Tester - Markdown Live Preview + - Unicode & Invisible Character Inspector - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/.github/assets/og-unicode-inspector.png b/.github/assets/og-unicode-inspector.png new file mode 100644 index 0000000..8a9e82a Binary files /dev/null and b/.github/assets/og-unicode-inspector.png differ diff --git a/.github/workflows/accepted-label.yml b/.github/workflows/accepted-label.yml index cb46df7..4e87f54 100644 --- a/.github/workflows/accepted-label.yml +++ b/.github/workflows/accepted-label.yml @@ -1,7 +1,8 @@ name: Accepted label -# A merged PR counts on the leaderboard only with the hacktoberfest-accepted -# label, and only for an issue that was assigned to its author (CONTRIBUTING.md). +# A merged PR counts on the leaderboard (oss.codercops.com) only with the +# codercops-accepted label, and only for an issue that was assigned to its +# author (CONTRIBUTING.md). PRs merged in October also get hacktoberfest-accepted. # Maintainers add the label when they merge. This adds it when that was missed, # by the same rule as the claim check: every issue the PR closes is assigned to # the author. It runs once, at the merge, so removing the label later sticks. @@ -20,12 +21,13 @@ permissions: jobs: label: name: Label a merged contributor PR - # Maintainers and bots don't claim issues, and spam never counts. + # Maintainers and bots don't claim issues, and spam never counts. Outside + # collaborators (contributors with Triage) are labeled like everyone else. if: >- github.event.pull_request.merged == true && github.event.pull_request.user.type != 'Bot' && - !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && - !contains(github.event.pull_request.labels.*.name, 'hacktoberfest-accepted') && + !contains(fromJSON('["OWNER", "MEMBER"]'), github.event.pull_request.author_association) && + !contains(github.event.pull_request.labels.*.name, 'codercops-accepted') && !contains(github.event.pull_request.labels.*.name, 'spam') runs-on: ubuntu-latest steps: @@ -34,6 +36,7 @@ jobs: GH_TOKEN: ${{ github.token }} REPO: ${{ github.repository }} PR: ${{ github.event.pull_request.number }} + MERGED_AT: ${{ github.event.pull_request.merged_at }} run: | set -euo pipefail @@ -70,7 +73,7 @@ jobs: fi if [ "$(jq length <<<"$linked")" -eq 0 ]; then - echo "::notice title=Not labeled::This PR isn't linked to an issue, so it doesn't get hacktoberfest-accepted." + echo "::notice title=Not labeled::This PR isn't linked to an issue, so it doesn't get codercops-accepted." exit 0 fi @@ -81,9 +84,12 @@ jobs: | join(", ")' <<<"$linked") if [ -n "$missing" ]; then - echo "::notice title=Not labeled::$missing isn't assigned to @$author, so this PR doesn't get hacktoberfest-accepted." + echo "::notice title=Not labeled::$missing isn't assigned to @$author, so this PR doesn't get codercops-accepted." exit 0 fi - gh api -X POST "repos/$REPO/issues/$PR/labels" -f "labels[]=hacktoberfest-accepted" --silent - echo "Added hacktoberfest-accepted: $(jq -r '[.[].ref] | join(", ")' <<<"$linked") assigned to @$author" + labels=(-f "labels[]=codercops-accepted") + # Hacktoberfest only counts PRs merged in October (UTC). + if [ "${MERGED_AT:5:2}" = "10" ]; then labels+=(-f "labels[]=hacktoberfest-accepted"); fi + gh api -X POST "repos/$REPO/issues/$PR/labels" "${labels[@]}" --silent + echo "Labeled: $(jq -r '[.[].ref] | join(", ")' <<<"$linked") assigned to @$author" diff --git a/.github/workflows/claim-check.yml b/.github/workflows/claim-check.yml index aaf337e..fe7813b 100644 --- a/.github/workflows/claim-check.yml +++ b/.github/workflows/claim-check.yml @@ -26,7 +26,7 @@ jobs: # Maintainers and bots don't claim issues. if: >- github.event.pull_request.user.type != 'Bot' && - !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) + !contains(fromJSON('["OWNER", "MEMBER"]'), github.event.pull_request.author_association) runs-on: ubuntu-latest steps: - name: Check the linked issue diff --git a/.well-known/funding-manifest-urls b/.well-known/funding-manifest-urls new file mode 100644 index 0000000..42647b8 --- /dev/null +++ b/.well-known/funding-manifest-urls @@ -0,0 +1 @@ +https://oss.codercops.com/funding.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd9ea8d..881669a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,17 +2,17 @@ Thanks for taking the time to help. This project is a small, careful collection of browser-based developer tools, and the bar is simple: everything runs client-side, nothing leaks, and the code stays readable. -## Hacktoberfest 2026 +## How to contribute -We're taking part in Hacktoberfest this year, and first-time contributors are very welcome. +First-time contributors are very welcome. -1. Pick an open issue labeled [`hacktoberfest`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest+no%3Aassignee) or [`good first issue`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+no%3Aassignee) that nobody is assigned to. Want to build a whole new tool? Look at the [`new tool`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+tool%22) issues. +1. Pick an open issue labeled [`good first issue`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22+no%3Aassignee) that nobody is assigned to. Want to build a whole new tool? Look at the [`new tool`](https://github.com/codercops/toolbelt/issues?q=is%3Aissue+is%3Aopen+label%3A%22new+tool%22) issues. 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` 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. +A merged PR counts on the monthly leaderboard once it's labeled `codercops-accepted`. A workflow adds that label when the PR is merged, as long as every issue it closes is assigned to you. 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. Stuck? Ask on the issue, or in `#oss-help` on our [Discord](https://discord.gg/mpGdJyjaRQ). Questions are welcome. Claims still happen on the issue, so the assignment stays visible to everyone. @@ -38,7 +38,7 @@ These get closed and labeled `spam`: - claiming a pile of issues at once, or bulk and automated submissions - untested generated code -Two spam PRs and you won't be assigned more issues this month, and you're out of the CODERCOPS goodies. +Two spam PRs and you won't be assigned more issues this month, and your PRs stop counting on the leaderboard. ### Reviews @@ -48,15 +48,15 @@ For a bug fix, check that your new test fails without the fix: undo the change i I review most PRs within a few days. Please don't ping repeatedly or DM maintainers about a review. If three days pass with no reply, one follow-up comment on the PR is fine. -### Goodies +### Monthly leaderboard -The top 5 contributors this October get a CODERCOPS T-shirt and stickers. You need at least 5 merged PRs labeled `hacktoberfest-accepted` across chatcops, ogcops, toolbelt and discord-bot, merged between Oct 1 and Oct 31 (UTC), and ties go to whoever got there first. To keep it fair: +Every month has its own leaderboard at [oss.codercops.com/leaderboard](https://oss.codercops.com/leaderboard). It counts PRs labeled `codercops-accepted` across chatcops, ogcops, toolbelt and discord-bot that were merged in that month (UTC). You're ranked from 3 merged PRs, and the top 5 get a shout-out on our X and LinkedIn pages. To keep it fair: - only PRs for issues that were assigned to you count - a fix split into several PRs counts once - what counts is the maintainers' call, and that call is final -These goodies come from CODERCOPS through our own open source program. They're separate from Hacktoberfest, so anything Hacktoberfest itself gives out is a different thing with its own rules. +People who keep contributing can be invited as maintainers with Triage access. [GOVERNANCE.md](https://github.com/codercops/.github/blob/main/GOVERNANCE.md) explains how that works and who maintains what. ## Getting set up diff --git a/README.md b/README.md index 0b5d71d..d872a93 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,12 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien Markdown Live Preview + + + Unicode & Invisible Character Inspector + + + - **[JSON formatter and validator](https://tools.codercops.com/json-formatter).** Format, minify, validate, collapsible tree view, and JSON to CSV / TypeScript / YAML / XML / query string. Large numbers are preserved losslessly. @@ -113,6 +119,7 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien - **[CSV/TSV formatter & validator](https://tools.codercops.com/csv-formatter).** Parse, validate, and pretty-print CSV/TSV. Auto-detect delimiters, convert to JSON, TypeScript, YAML, SQL, and fix common formatting issues. - **[Regex tester](https://tools.codercops.com/regex-tester).** Test a JavaScript regular expression against sample text with highlighted matches, capture groups, named groups and a replace preview. - **[Markdown live preview](https://tools.codercops.com/markdown-preview).** Write GitHub-flavored Markdown with a sanitized live HTML preview, syntax-highlighted code, stats, and export tools. +- **[Unicode & invisible character inspector](https://tools.codercops.com/unicode-inspector).** Inspect text code points, expose invisible and control characters, view UTF-8 and UTF-16 encodings, and preview NFC/NFD normalization. ## Why it exists @@ -204,6 +211,10 @@ Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Cod Found a vulnerability? Please report it privately per [SECURITY.md](./SECURITY.md) rather than opening a public issue. +## Maintainers and funding + +toolbelt is maintained by CODERCOPS together with contributors who have Triage access. [GOVERNANCE.md](https://github.com/codercops/.github/blob/main/GOVERNANCE.md) lists who they are and how to become one. The project is free and MIT licensed, and CODERCOPS doesn't sell it or a hosted version of it. If it's useful to you, you can [sponsor the maintainers](https://github.com/sponsors/anurag629). The money pays for review time and hosting. + ## License MIT, see [LICENSE](./LICENSE). The bundled Noto Sans font subsets are under the SIL Open Font License 1.1 ([public/fonts/OFL.txt](public/fonts/OFL.txt)). diff --git a/app/(tools)/color-converter/ColorConverterClient.test.tsx b/app/(tools)/color-converter/ColorConverterClient.test.tsx index ac45828..83376f4 100644 --- a/app/(tools)/color-converter/ColorConverterClient.test.tsx +++ b/app/(tools)/color-converter/ColorConverterClient.test.tsx @@ -100,3 +100,15 @@ describe("accessible foreground suggestions", () => { expect(screen.getByRole("status")).toHaveTextContent(value === "invalid" ? "Enter valid" : "require opaque"); }); }); + +describe("contrast color pickers", () => { + it("keep the alpha of the current colors", () => { + const { foreground, background } = failingPair(); + fireEvent.change(foreground, { target: { value: "#00000080" } }); + fireEvent.change(background, { target: { value: "#ffffff80" } }); + fireEvent.change(screen.getByLabelText("Foreground color picker"), { target: { value: "#ff0000" } }); + fireEvent.change(screen.getByLabelText("Background color picker"), { target: { value: "#0000ff" } }); + expect(foreground).toHaveValue("#ff000080"); + expect(background).toHaveValue("#0000ff80"); + }); +}); diff --git a/app/(tools)/color-converter/ColorConverterClient.tsx b/app/(tools)/color-converter/ColorConverterClient.tsx index d2cca7d..6d561b9 100644 --- a/app/(tools)/color-converter/ColorConverterClient.tsx +++ b/app/(tools)/color-converter/ColorConverterClient.tsx @@ -21,6 +21,7 @@ import { contrastRatio, formatContrastRatio, wcagRating, + type ParseColorResult, type RgbaColor, } from "@/lib/color-utils"; import { CopyButton } from "@/components/shared/CopyButton"; @@ -41,6 +42,12 @@ function hex6(color: RgbaColor): string { return `#${r}${g}${b}`; } +// The native picker has no alpha, so keep the current one +function keepAlpha(hex: string, current: ParseColorResult): string { + const next = parseColor(hex); + return current.ok && current.value.a < 1 && next.ok ? toHex({ ...next.value, a: current.value.a }) : hex; +} + export function ColorConverterClient() { const [colorInput, setColorInput] = useState("#F26DF0"); const [fgInput, setFgInput] = useState("#FFFFFF"); @@ -89,14 +96,7 @@ export function ColorConverterClient() { // 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); + setColorInput(keepAlpha(hex, parsed)); } function handleAlphaChange(newAlpha: number) { @@ -519,7 +519,7 @@ export function ColorConverterClient() { id={fgPickerId} type="color" value={parsedFg.ok ? hex6(parsedFg.value) : "#ffffff"} - onChange={(e) => setFgInput(e.target.value)} + onChange={(e) => setFgInput(keepAlpha(e.target.value, parsedFg))} className="sr-only peer" aria-label="Foreground color picker" /> @@ -564,7 +564,7 @@ export function ColorConverterClient() { id={bgPickerId} type="color" value={parsedBg.ok ? hex6(parsedBg.value) : "#000000"} - onChange={(e) => setBgInput(e.target.value)} + onChange={(e) => setBgInput(keepAlpha(e.target.value, parsedBg))} className="sr-only peer" aria-label="Background color picker" /> diff --git a/app/(tools)/json-formatter/JsonFormatterClient.test.tsx b/app/(tools)/json-formatter/JsonFormatterClient.test.tsx index 1ad883d..8a29723 100644 --- a/app/(tools)/json-formatter/JsonFormatterClient.test.tsx +++ b/app/(tools)/json-formatter/JsonFormatterClient.test.tsx @@ -104,6 +104,36 @@ describe("JsonFormatterClient", () => { }); }); + describe("JSON5 mode", () => { + it("minifies and sorts JSON5 input", async () => { + renderClient(); + + toggleJson5(); + typeInput("{b: 2, a: 1,}"); + fireEvent.click(screen.getByRole("button", { name: "Minify" })); + + await waitFor(() => expect(document.querySelector(".whitespace-pre")).toHaveTextContent('{"b":2,"a":1}')); + expect(screen.queryByText("Invalid JSON")).not.toBeInTheDocument(); + + typeInput("{b: 2, a: 1,}"); + fireEvent.click(screen.getByRole("button", { name: "Sort keys" })); + + await waitFor(() => expect(inputEl().value).toBe('{\n "a": 1,\n "b": 2\n}')); + expect(screen.queryByText("Invalid JSON")).not.toBeInTheDocument(); + }); + + it("validates JSON5 input", async () => { + renderClient(); + + toggleJson5(); + typeInput("{b: 2, a: 1,}"); + fireEvent.click(screen.getByRole("button", { name: "Validate" })); + + expect(await screen.findByText("Valid JSON ✓")).toBeInTheDocument(); + expect(screen.queryByText("Invalid JSON")).not.toBeInTheDocument(); + }); + }); + describe("Fetch from URL", () => { beforeEach(() => { vi.stubGlobal("fetch", vi.fn()); diff --git a/app/(tools)/json-formatter/JsonFormatterClient.tsx b/app/(tools)/json-formatter/JsonFormatterClient.tsx index d516569..117c84b 100644 --- a/app/(tools)/json-formatter/JsonFormatterClient.tsx +++ b/app/(tools)/json-formatter/JsonFormatterClient.tsx @@ -98,8 +98,15 @@ export function JsonFormatterClient() { [input, indent, json5, push, history] ); + // In JSON5 mode, read the input the way Format does before working on it. + const strictInput = useCallback(() => { + if (!json5) return { ok: true as const, value: input }; + return parseJson5Compat(input, indent); + }, [input, indent, json5]); + const doMinify = useCallback(() => { - const r = minify(input); + const strict = strictInput(); + const r = strict.ok ? minify(strict.value) : strict; if (r.ok) { setOutput(r.value); setError(null); @@ -110,10 +117,11 @@ export function JsonFormatterClient() { setErrorShake((n) => n + 1); push("Invalid JSON", "error"); } - }, [input, push, history]); + }, [input, strictInput, push, history]); const doValidate = useCallback(() => { - const r = validate(input); + const strict = strictInput(); + const r = strict.ok ? validate(strict.value) : strict; if (r.ok) { setError(null); push("Valid JSON ✓", "success"); @@ -122,10 +130,11 @@ export function JsonFormatterClient() { setErrorShake((n) => n + 1); push("Invalid JSON", "error"); } - }, [input, push]); + }, [strictInput, push]); const doSort = useCallback(() => { - const r = sortKeys(input, indent); + const strict = strictInput(); + const r = strict.ok ? sortKeys(strict.value, indent) : strict; if (r.ok) { setOutput(r.value); setInput(r.value); @@ -136,7 +145,7 @@ export function JsonFormatterClient() { setErrorShake((n) => n + 1); push("Invalid JSON", "error"); } - }, [input, indent, push]); + }, [indent, strictInput, push]); const handleClear = useCallback(() => { setInput(""); diff --git a/app/(tools)/unicode-inspector/UnicodeInspectorClient.tsx b/app/(tools)/unicode-inspector/UnicodeInspectorClient.tsx new file mode 100644 index 0000000..ef65af2 --- /dev/null +++ b/app/(tools)/unicode-inspector/UnicodeInspectorClient.tsx @@ -0,0 +1,309 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + inspectUnicode, + getNormalization, + EXAMPLES, + INSPECTION_LIMIT, + type CodePointRow, +} from "@/lib/unicode-inspector"; +import { CopyButton } from "@/components/shared/CopyButton"; +import { cn } from "@/lib/cn"; + +const MAX_INPUT_LENGTH = 500_000; +const DEBOUNCE_MS = 120; + +const labelClass = "mb-1.5 block text-sm font-medium text-[var(--fg)]"; +const headingClass = + "font-mono text-[10.5px] tracking-[0.22em] uppercase text-[var(--fg-dim)]"; + +function CharCell({ row }: { row: CodePointRow }) { + if (row.unpairedSurrogate) { + return ( + + ⚠ surrogate + + ); + } + if (row.invisibleLabel) { + return ( + + {row.invisibleLabel} + + ); + } + return ( + + {row.char} + + ); +} + +function NormalizationPanel({ input }: { input: string }) { + const norm = useMemo(() => getNormalization(input), [input]); + + return ( +
+
+

Normalization

+
+
+
+
+ + NFC + {norm.nfcChanged ? ( + + changes input + + ) : ( + + no change + + )} + + +
+
+            {norm.nfc || empty}
+          
+
+ +
+
+ + NFD + {norm.nfdChanged ? ( + + changes input + + ) : ( + + no change + + )} + + +
+
+            {norm.nfd || empty}
+          
+
+
+
+ ); +} + +export function UnicodeInspectorClient() { + const [raw, setRaw] = useState(""); + const [debouncedInput, setDebouncedInput] = useState(""); + + useEffect(() => { + const id = window.setTimeout(() => setDebouncedInput(raw), DEBOUNCE_MS); + return () => window.clearTimeout(id); + }, [raw]); + + const tooLong = raw.length > MAX_INPUT_LENGTH; + const input = tooLong ? "" : debouncedInput; + + const result = useMemo( + () => (input ? inspectUnicode(input) : null), + [input] + ); + + const hasUnpaired = result?.rows.some((r) => r.unpairedSurrogate) ?? false; + + return ( +
+
+
+ +