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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/assets/og-base64.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/og-home.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/og-invoice-generator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/og-json-formatter.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added .github/assets/og-jwt-decoder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
79 changes: 67 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,67 @@
# toolbelt

[![License: MIT](https://img.shields.io/badge/License-MIT-00E5C7.svg)](./LICENSE)
[![Live](https://img.shields.io/badge/live-tools.codercops.com-00E5C7?labelColor=0A0B0F&style=flat)](https://tools.codercops.com)
[![License: MIT](https://img.shields.io/badge/license-MIT-00E5C7.svg?labelColor=0A0B0F)](./LICENSE)
[![CI](https://github.com/codercops/toolbelt/actions/workflows/ci.yml/badge.svg)](https://github.com/codercops/toolbelt/actions/workflows/ci.yml)
[![Made by CODERCOPS](https://img.shields.io/badge/by-CODERCOPS-9E7BFF.svg)](https://www.codercops.com)
[![GitHub stars](https://img.shields.io/github/stars/codercops/toolbelt?labelColor=0A0B0F&color=9E7BFF&logo=github)](https://github.com/codercops/toolbelt/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/codercops/toolbelt?labelColor=0A0B0F&color=8A93A8)](https://github.com/codercops/toolbelt/commits)
[![Made by CODERCOPS](https://img.shields.io/badge/by-CODERCOPS-9E7BFF.svg?labelColor=0A0B0F)](https://www.codercops.com)

A developer's toolbelt: fast, privacy-first browser tools. Everything runs client-side, so there is no login, no upload, and no data leaves your tab. Live at **[tools.codercops.com](https://tools.codercops.com)**.

<p align="center">
<a href="https://tools.codercops.com">
<img src="./.github/assets/og-home.png" alt="CODERCOPS Tools, sharp and focused developer tools that run entirely in your browser" width="820">
</a>
</p>

<p align="center">
<a href="https://tools.codercops.com"><b>Open the tools</b></a>
&nbsp;·&nbsp;
<a href="https://github.com/codercops/toolbelt/stargazers"><b>Star the repo</b></a>
&nbsp;·&nbsp;
<a href="./CONTRIBUTING.md"><b>Contribute</b></a>
</p>

## Contents

- [Tools](#tools)
- [Why it exists](#why-it-exists)
- [Stack](#stack)
- [Run locally](#run-locally)
- [Deploy your own](#deploy-your-own)
- [Add a tool](#add-a-tool)
- [Project layout](#project-layout)
- [Branching and releases](#branching-and-releases)
- [Contributing](#contributing)
- [Security](#security)
- [License](#license)

## Tools

- **JSON formatter and validator** — format, minify, validate, collapsible tree view, and JSON to CSV / TypeScript / YAML / XML / query string. Large numbers are preserved losslessly.
- **JWT decoder and inspector** — decode header, payload, and claims; verify HS/RS/PS/ES signatures via WebCrypto; security audit; HS256/384/512 encoder.
- **Base64 encoder and decoder** — text, images, and files; URL-safe variant; data URIs; magic-byte detection; Base32/58/85 and hashes.
- **Invoice generator** — live preview, vector PDF export with Unicode currency support, a reusable sender profile, all saved locally.
<table>
<tr>
<td width="50%">
<a href="https://tools.codercops.com/json-formatter"><img src="./.github/assets/og-json-formatter.png" alt="JSON Formatter and Validator"></a>
</td>
<td width="50%">
<a href="https://tools.codercops.com/jwt-decoder"><img src="./.github/assets/og-jwt-decoder.png" alt="JWT Decoder and Inspector"></a>
</td>
</tr>
<tr>
<td width="50%">
<a href="https://tools.codercops.com/base64"><img src="./.github/assets/og-base64.png" alt="Base64 Encoder and Decoder"></a>
</td>
<td width="50%">
<a href="https://tools.codercops.com/invoice-generator"><img src="./.github/assets/og-invoice-generator.png" alt="Invoice Generator"></a>
</td>
</tr>
</table>

- **[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.
- **[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.

## Why it exists

Expand All @@ -24,6 +74,7 @@ CODERCOPS builds production backends and full-stack apps. These are the small ut
- WebCrypto for JWT signing and verification
- jsPDF (dynamically imported) for invoice PDFs
- `lossless-json` so JSON number precision is never dropped
- Per-page Open Graph images and schema.org structured data, generated from the tool registry
- Installable PWA via a web manifest, with a nonce-based Content-Security-Policy

## Run locally
Expand All @@ -45,14 +96,14 @@ npm run test # vitest unit tests (lib/)

## Deploy your own

The app deploys to any Node host. On Vercel, import the repo and it builds with zero config. There are no required environment variables. If you fork it, update the domain in `lib/tools.ts` (`SITE_URL`) and `app/robots.ts` so the sitemap and canonical URLs point at your host.
The app deploys to any Node host. On Vercel, import the repo and it builds with zero config. There are no required environment variables. If you fork it, update the domain in `lib/tools.ts` (`SITE_URL`) and `app/robots.ts` so the sitemap and canonical URLs point at your host, and update `GITHUB_REPO` in `lib/tools.ts` so the star-count link points at your fork.

## Add a tool

The tool set is a single typed registry in [`lib/tools.ts`](lib/tools.ts). The home grid, per-tool metadata, JSON-LD, hero, FAQ, CTA, sitemap, web manifest, and command palette all derive from it. To add a tool:
The tool set is a single typed registry in [`lib/tools.ts`](lib/tools.ts). The home grid, per-tool metadata, JSON-LD (SoftwareApplication, breadcrumb, FAQ), social share images, hero, FAQ, CTA, sitemap, web manifest, and command palette all derive from it. To add a tool:

1. Add an entry to the `TOOLS` array in `lib/tools.ts`.
2. Create `app/(tools)/<slug>/page.tsx` (metadata plus your client component) and its `<Name>Client.tsx`.
2. Create `app/(tools)/<slug>/page.tsx` (metadata plus your client component), its `<Name>Client.tsx`, and an `opengraph-image.tsx` (a few lines that pass the registry entry to the shared renderer).
3. Put pure logic in `lib/` and cover it with a test in `lib/__tests__/`.

## Project layout
Expand All @@ -61,9 +112,11 @@ The tool set is a single typed registry in [`lib/tools.ts`](lib/tools.ts). The h
app/ shell (layout, error/not-found boundaries, metadata routes)
app/(tools)/ one route per tool, sharing a layout and ToolPageLayout
components/shared/ header, theme, toasts, command palette, reusable UI
lib/ pure per-tool logic (parsing, crypto, PDF, conversions) unit-tested
lib/ pure per-tool logic (parsing, crypto, PDF, conversions), unit-tested
lib/tools.ts the tool registry (single source of truth)
public/fonts/ subset Noto Sans used by the invoice PDF (OFL, see OFL.txt)
lib/jsonLd.ts schema.org builders derived from the registry
lib/og.tsx the shared Open Graph card renderer
public/fonts/ subset Noto Sans used by the invoice PDF and OG cards (OFL, see OFL.txt)
middleware.ts per-request CSP nonce
```

Expand All @@ -77,7 +130,7 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the full flow.

## Contributing

Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md). In short: keep logic in `lib/`, add a test, and make sure `npm run lint && npm run test && npm run build` passes.
Contributions are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) and the [Code of Conduct](./CODE_OF_CONDUCT.md). In short: keep logic in `lib/`, add a test, and make sure `npm run lint && npm run test && npm run build` passes. If a tool proves useful, a star helps other developers find it.

## Security

Expand All @@ -86,3 +139,5 @@ Found a vulnerability? Please report it privately per [SECURITY.md](./SECURITY.m
## 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)).

The tool social cards in [`.github/assets/`](.github/assets) are generated from [`lib/og.tsx`](lib/og.tsx); regenerate them if a tool's name or copy changes.
12 changes: 12 additions & 0 deletions app/(tools)/base64/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getTool } from "@/lib/tools";
import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

const tool = getTool("base64")!;

export const alt = tool.ogTitle;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function Image() {
return renderToolOgImage(tool);
}
12 changes: 12 additions & 0 deletions app/(tools)/invoice-generator/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getTool } from "@/lib/tools";
import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

const tool = getTool("invoice-generator")!;

export const alt = tool.ogTitle;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function Image() {
return renderToolOgImage(tool);
}
12 changes: 12 additions & 0 deletions app/(tools)/json-formatter/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getTool } from "@/lib/tools";
import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

const tool = getTool("json-formatter")!;

export const alt = tool.ogTitle;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function Image() {
return renderToolOgImage(tool);
}
12 changes: 12 additions & 0 deletions app/(tools)/jwt-decoder/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { getTool } from "@/lib/tools";
import { renderToolOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

const tool = getTool("jwt-decoder")!;

export const alt = tool.ogTitle;
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function Image() {
return renderToolOgImage(tool);
}
5 changes: 5 additions & 0 deletions app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,14 @@ export const metadata: Metadata = {
type: "website",
siteName: "CODERCOPS Tools",
url: "https://tools.codercops.com",
locale: "en_US",
title: "CODERCOPS Tools — Developer Utilities",
description:
"Free, fast, privacy-first developer tools: JSON formatter, JWT decoder, Base64 encoder, and more. Runs entirely in your browser.",
},
twitter: {
card: "summary_large_image",
site: "@codercops",
creator: "@codercops",
},
};
Expand Down
60 changes: 4 additions & 56 deletions app/opengraph-image.tsx
Original file line number Diff line number Diff line change
@@ -1,61 +1,9 @@
import { ImageResponse } from "next/og";
import { renderHomeOgImage, OG_SIZE, OG_CONTENT_TYPE } from "@/lib/og";

export const alt = "CODERCOPS Tools — fast, privacy-first developer utilities";
export const size = { width: 1200, height: 630 };
export const contentType = "image/png";
export const size = OG_SIZE;
export const contentType = OG_CONTENT_TYPE;

export default function OpengraphImage() {
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
padding: 72,
background: "linear-gradient(135deg, #0E1420 0%, #0A0B0F 55%)",
color: "#E8E6E1",
fontFamily: "monospace",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 20 }}>
<div
style={{
width: 64,
height: 64,
borderRadius: 14,
border: "1px solid rgba(255,255,255,0.14)",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "#00E5C7",
fontSize: 34,
fontWeight: 700,
}}
>
{"{}"}
</div>
<div style={{ fontSize: 26, letterSpacing: 2, color: "#8289A0" }}>CODERCOPS · TOOLS</div>
</div>

<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<div style={{ fontSize: 62, fontWeight: 700, lineHeight: 1.05, maxWidth: 900 }}>
Sharp, focused tools for shipping software
</div>
<div style={{ fontSize: 28, color: "#8289A0", maxWidth: 880 }}>
JSON formatter · JWT decoder · Base64 · Invoice generator. Runs entirely in your browser.
</div>
</div>

<div style={{ display: "flex", gap: 12 }}>
{["#5AB5FF", "#9E7BFF", "#FFB547", "#A6F36B"].map((c) => (
<div key={c} style={{ width: 44, height: 6, borderRadius: 3, background: c }} />
))}
</div>
</div>
),
{ ...size }
);
return renderHomeOgImage();
}
13 changes: 13 additions & 0 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
import type { Metadata } from "next";
import { ToolCard } from "@/components/shared/ToolCard";
import { CtaBanner } from "@/components/shared/CtaBanner";
import { Footer } from "@/components/shared/Footer";
import { TOOLS } from "@/lib/tools";
import { homeJsonLd } from "@/lib/jsonLd";

// Only the canonical is set here; title, description, and OpenGraph are inherited
// from the root layout (Next merges metadata shallowly, so re-declaring openGraph
// would drop the root's siteName/locale/type from the homepage).
export const metadata: Metadata = {
alternates: { canonical: "/" },
};

export default function HomePage() {
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(homeJsonLd()) }}
/>
<section className="relative">
<div className="absolute inset-0 grid-bg opacity-50" />
<div className="relative mx-auto max-w-7xl px-4 sm:px-6 pt-20 pb-14">
Expand Down
19 changes: 12 additions & 7 deletions components/shared/Footer.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import { GitHubStars } from "./GitHubStars";

export function Footer() {
return (
<footer className="border-t border-[var(--hairline)]">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-6 flex items-center justify-between">
<div className="mx-auto max-w-7xl px-4 sm:px-6 py-6 flex flex-wrap items-center justify-between gap-3">
<span className="font-mono text-[11px] text-[var(--fg-dim)]">
© {new Date().getFullYear()} CODERCOPS
</span>
<a
href="https://www.codercops.com"
className="font-mono text-[11px] tracking-wider uppercase text-[var(--fg-dim)] hover:text-[var(--fg)] transition-colors"
>
codercops.com
</a>
<div className="flex items-center gap-3">
<GitHubStars showLabel />
<a
href="https://www.codercops.com"
className="font-mono text-[11px] tracking-wider uppercase text-[var(--fg-dim)] hover:text-[var(--fg)] transition-colors"
>
codercops.com
</a>
</div>
</div>
</footer>
);
Expand Down
77 changes: 77 additions & 0 deletions components/shared/GitHubStars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Star } from "lucide-react";
import { cn } from "@/lib/cn";
import { GITHUB_REPO, GITHUB_REPO_URL } from "@/lib/tools";

// Server-side star-count fetch. Cached for an hour, so GitHub's API is hit at
// most once per hour (far under the 60/hr unauthenticated limit) no matter how
// much traffic we get, and no request ever reaches the browser. A bounded
// timeout plus a null fallback means a slow or failing GitHub never breaks a
// page — we just render the button without a count.
async function getStarCount(): Promise<number | null> {
try {
const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}`, {
headers: { Accept: "application/vnd.github+json", "User-Agent": "toolbelt-site" },
next: { revalidate: 3600 },
signal: AbortSignal.timeout(3000),
});
if (!res.ok) return null;
const data = (await res.json()) as { stargazers_count?: number };
return typeof data.stargazers_count === "number" ? data.stargazers_count : null;
} catch {
return null;
}
}

function formatStars(n: number): string {
if (n < 1000) return String(n);
const k = n / 1000;
// Decide the branch on the rounded value so 9.95k–9.999k renders "10k",
// not "10.0k" (which matches neither the sub-10k nor the 10k+ format).
return k >= 9.95 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
}

// The official GitHub mark, inlined so we don't depend on lucide's deprecated
// brand icons.
function GitHubMark({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" className={className}>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23A11.509 11.509 0 0 1 12 5.803c1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222 0 1.606-.014 2.898-.014 3.293 0 .322.216.694.825.576C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}

/**
* A "Star on GitHub" link with a live star count. Renders in both the header
* (compact, no label) and the footer (with label). If the count can't be
* fetched it degrades to just the link, so people can still star.
*/
export async function GitHubStars({
showLabel = false,
className,
}: {
showLabel?: boolean;
className?: string;
}) {
const count = await getStarCount();
return (
<a
href={GITHUB_REPO_URL}
target="_blank"
rel="noopener noreferrer"
aria-label={`Star ${GITHUB_REPO} on GitHub${count != null ? `, ${formatStars(count)} star${count === 1 ? "" : "s"}` : ""}`}
className={cn(
"group inline-flex items-center gap-1.5 rounded-md border border-[var(--hairline-strong)] bg-[var(--bg-raise)] px-2 py-1 font-mono text-[11px] text-[var(--fg-muted)] transition-colors hover:border-[var(--cyan)] hover:text-[var(--fg)]",
className
)}
>
<GitHubMark className="w-3.5 h-3.5" />
{showLabel && <span className="tracking-wider uppercase">Star on GitHub</span>}
{count != null && (
<span className="inline-flex items-center gap-1 tabular-nums">
<Star className="w-3 h-3 fill-[var(--amber)] text-[var(--amber)]" aria-hidden="true" />
{formatStars(count)}
</span>
)}
</a>
);
}
Loading
Loading