init commit

This commit is contained in:
pawka 2026-03-15 16:34:31 -03:00
commit b7b1fd306a
32 changed files with 4527 additions and 0 deletions

23
.gitignore vendored Normal file
View file

@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

1
.npmrc Normal file
View file

@ -0,0 +1 @@
engine-strict=true

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
v25

3
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}

104
CLAUDE.md Normal file
View file

@ -0,0 +1,104 @@
# CLAUDE.md — toolkit
## Project
SvelteKit 2 + Svelte 5 developer toolkit. All tools run client-side — no server-side logic.
## Language
**JavaScript only** (`lang="js"` in Svelte files). No TypeScript. Use JSDoc for type annotations.
```svelte
<script lang="js">
/** @type {string} */
let value = $state('');
```
## Svelte 5 runes
Use `$state`, `$derived`, `$derived.by`, `$effect`, `$props`. No Svelte 4 syntax (`$:`, `export let`, `<slot>`).
## Style conventions
- Arrow functions for single-expression bodies
- Regular functions for multi-line logic
- Imports at top of `<script>`
- No `function` declarations after state/derived blocks
## Shared libraries
- `$lib/clipboard.js``copy(text)` — wraps `navigator.clipboard.writeText`
- `$lib/md5.js``md5(input: string | Uint8Array): string` — no-dep MD5
## Routing
File-based under `src/routes/`. Each tool is a directory with `+page.svelte`.
| Route | Tool |
| ------------ | ---------------------------------------------------------------------------------------- |
| `/` | About — project description + tools index |
| `/object-id` | MongoDB ObjectId generator & parser |
| `/uuid` | UUID v1/v3/v4/v5/v6/v7 generator & parser |
| `/hash` | MD5, SHA-1, SHA-256, SHA-512 |
| `/cron` | Crontab expression builder |
| `/api-key` | High-entropy API key generator |
| `/json` | JSON prettify / compact / validate |
| `/base` | Base64 / Base64url / Base32 / Base58 / Base16 encode & decode |
| `/string` | String transforms: lower, UPPER, snake_case, camelCase, PascalCase, kebab, reverse, trim |
## Navigation
Adding a new tool requires updating three files:
1. `src/routes/+layout.svelte` — add entry to `links` array
2. `src/routes/+page.svelte` — add entry to `tools` array
3. `CLAUDE.md` — add route to the routing table
## Theming
Colors are defined as CSS custom properties in `src/routes/layout.css`.
- Default theme: **Gruvbox Light Hard**
- Dark theme: **Gruvbox Dark Hard** — activated via `[data-theme="dark"]` on `<html>`
- Theme toggle button lives in `src/routes/Header.svelte`; persists to `localStorage`
- Theme is applied before first render via an inline `<script>` in `src/app.html` (no FOUC)
Key CSS variables: `--bg`, `--bg-panel`, `--bg-raised`, `--bg-raised-hover`, `--fg`, `--fg-dim`, `--fg-faint`, `--border`, `--border-faint`, `--active-bg`, `--active-fg`, `--link`, `--link-vis`, `--link-hover`, `--err`, `--err-bg`, `--err-border`, `--green`, `--blue`, `--red`, `--bar-bg`, `--bar-fg`, `--bar-dim`.
**Never hardcode colors.** Always use CSS variables.
## Panel UI pattern
Panels (textarea/output areas) use a consistent structure and class names:
```svelte
<div class="panel">
<div class="panel-header">
<span>Label</span>
<div class="panel-actions">
<button>clear</button>
<button>copy</button>
</div>
</div>
<textarea ...></textarea>
</div>
```
CSS for `.panel-header`: `background: var(--bg-raised)`, `border: 1px solid var(--border)`, `border-bottom: none`, `padding: 2px 6px`, `font-size: 0.85em`, `font-weight: bold`.
## Active/selected state
Buttons and nav tiles use: `background: var(--active-bg); color: var(--active-fg); border-color: var(--active-bg)`.
## CSS
- Global styles: `src/routes/layout.css` — variables, body, a, button, input, select, textarea, table, h1, h2, hr, code
- Component styles: scoped `<style>` blocks inside each `.svelte` file
- Aesthetic: suckless/early-2000s — Arial, 1px solid borders, no gradients/shadows/border-radius
## Crypto
All randomness via `crypto.getRandomValues()`. Hash via `crypto.subtle.digest()`. No external crypto libs except the custom MD5 (needed for UUID v3 and hash page).
## No external runtime dependencies
`package.json` has only build-time devDependencies. Keep it that way.

16
Dockerfile Normal file
View file

@ -0,0 +1,16 @@
# Stage 1: build
FROM node:25-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: runtime
FROM node:25-alpine
WORKDIR /app
COPY --from=builder /app/build ./build
EXPOSE 3000
ENV PORT=3000
ENV HOST=0.0.0.0
CMD ["node", "build"]

86
README.md Normal file
View file

@ -0,0 +1,86 @@
# toolkit
A collection of browser-based tools for developers.
No server, no tracking, no external dependencies — everything runs locally in your browser.
## Tools
| Route | Description |
|--------------|------------------------------------------------------------------|
| `/` | About |
| `/object-id` | MongoDB ObjectId generator & parser |
| `/uuid` | UUID v1/v4/v6/v7 generator & parser |
| `/hash` | MD5, SHA-1, SHA-256, SHA-512 |
| `/cron` | Interactive crontab expression builder with presets |
| `/api-key` | High-entropy API key generator (hex, base62, base64url, …) |
| `/json` | Prettify, compact, tab-indent, validate JSON |
| `/base` | Encode & decode Base64, Base64url, Base32, Base58, Base16 |
| `/string` | String transforms: lower, UPPER, snake_case, camelCase, … |
## Stack
- [SvelteKit 2](https://kit.svelte.dev) + [Svelte 5](https://svelte.dev)
- JavaScript with JSDoc — no TypeScript
- No runtime dependencies
---
## Development
```sh
npm install
npm run dev
```
App runs at `http://localhost:5173`.
## Production build
```sh
npm run build
node build # listens on PORT (default 3000)
```
## Type check
```sh
npm run check
```
---
## Deploy with Docker
### Build and run
```sh
docker build -t toolkit .
docker run -p 3000:3000 -e ORIGIN=http://localhost:3000 toolkit
```
### docker-compose
```sh
docker compose up -d
```
App runs at `http://localhost:3000`.
### Behind a reverse proxy
Set `ORIGIN` to your public URL in `docker-compose.yml`:
```yaml
environment:
- ORIGIN=https://your-domain.com
```
Then proxy traffic to port `3000`.
### Environment variables
| Variable | Default | Description |
|----------|------------|---------------------------------------|
| `PORT` | `3000` | Port the server listens on |
| `HOST` | `0.0.0.0` | Interface to bind |
| `ORIGIN` | — | Public URL (required in production) |

10
docker-compose.yml Normal file
View file

@ -0,0 +1,10 @@
services:
toolkit:
build: .
ports:
- "3000:3000"
environment:
- PORT=3000
- HOST=0.0.0.0
- ORIGIN=http://localhost:3000
restart: unless-stopped

19
jsconfig.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"moduleResolution": "bundler"
}
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
//
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
// from the referenced tsconfig.json - TypeScript does not merge them in
}

1854
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "toolkit",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./jsconfig.json --watch"
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.5.4",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"svelte": "^5.51.0",
"svelte-check": "^4.4.2",
"typescript": "^5.9.3",
"vite": "^7.3.1"
}
}

13
src/app.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};

16
src/app.html Normal file
View file

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<script>
const t = localStorage.getItem('theme');
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

6
src/lib/clipboard.js Normal file
View file

@ -0,0 +1,6 @@
/**
* @param {string} text
* @returns {void}
*/
export const copy = (text) => void navigator.clipboard.writeText(text);

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.1566,22.8189c-10.4-14.8851-30.94-19.2971-45.7914-9.8348L22.2825,29.6078A29.9234,29.9234,0,0,0,8.7639,49.6506a31.5136,31.5136,0,0,0,3.1076,20.2318A30.0061,30.0061,0,0,0,7.3953,81.0653a31.8886,31.8886,0,0,0,5.4473,24.1157c10.4022,14.8865,30.9423,19.2966,45.7914,9.8348L84.7167,98.3921A29.9177,29.9177,0,0,0,98.2353,78.3493,31.5263,31.5263,0,0,0,95.13,58.117a30,30,0,0,0,4.4743-11.1824,31.88,31.88,0,0,0-5.4473-24.1157" style="fill:#ff3e00"/><path d="M45.8171,106.5815A20.7182,20.7182,0,0,1,23.58,98.3389a19.1739,19.1739,0,0,1-3.2766-14.5025,18.1886,18.1886,0,0,1,.6233-2.4357l.4912-1.4978,1.3363.9815a33.6443,33.6443,0,0,0,10.203,5.0978l.9694.2941-.0893.9675a5.8474,5.8474,0,0,0,1.052,3.8781,6.2389,6.2389,0,0,0,6.6952,2.485,5.7449,5.7449,0,0,0,1.6021-.7041L69.27,76.281a5.4306,5.4306,0,0,0,2.4506-3.631,5.7948,5.7948,0,0,0-.9875-4.3712,6.2436,6.2436,0,0,0-6.6978-2.4864,5.7427,5.7427,0,0,0-1.6.7036l-9.9532,6.3449a19.0329,19.0329,0,0,1-5.2965,2.3259,20.7181,20.7181,0,0,1-22.2368-8.2427,19.1725,19.1725,0,0,1-3.2766-14.5024,17.9885,17.9885,0,0,1,8.13-12.0513L55.8833,23.7472a19.0038,19.0038,0,0,1,5.3-2.3287A20.7182,20.7182,0,0,1,83.42,29.6611a19.1739,19.1739,0,0,1,3.2766,14.5025,18.4,18.4,0,0,1-.6233,2.4357l-.4912,1.4978-1.3356-.98a33.6175,33.6175,0,0,0-10.2037-5.1l-.9694-.2942.0893-.9675a5.8588,5.8588,0,0,0-1.052-3.878,6.2389,6.2389,0,0,0-6.6952-2.485,5.7449,5.7449,0,0,0-1.6021.7041L37.73,51.719a5.4218,5.4218,0,0,0-2.4487,3.63,5.7862,5.7862,0,0,0,.9856,4.3717,6.2437,6.2437,0,0,0,6.6978,2.4864,5.7652,5.7652,0,0,0,1.602-.7041l9.9519-6.3425a18.978,18.978,0,0,1,5.2959-2.3278,20.7181,20.7181,0,0,1,22.2368,8.2427,19.1725,19.1725,0,0,1,3.2766,14.5024,17.9977,17.9977,0,0,1-8.13,12.0532L51.1167,104.2528a19.0038,19.0038,0,0,1-5.3,2.3287" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

66
src/lib/md5.js Normal file
View file

@ -0,0 +1,66 @@
/**
* Compact MD5 (no dependencies). Accepts a UTF-8 string or raw bytes.
* @param {string | Uint8Array} input
* @returns {string} hex digest
*/
/** @param {number} x @param {number} y @returns {number} */
function add(x, y) {
const lsw = (x & 0xffff) + (y & 0xffff);
return (((x >> 16) + (y >> 16) + (lsw >> 16)) << 16) | (lsw & 0xffff);
}
/** @param {number} n @param {number} s @returns {number} */
const rol = (n, s) => (n << s) | (n >>> (32 - s));
// Per-round shift amounts (4 rounds × 4 positions)
const S = [7, 12, 17, 22, 5, 9, 14, 20, 4, 11, 16, 23, 6, 10, 15, 21];
// Round constants: floor(abs(sin(i+1)) * 2^32)
const K = new Int32Array([
-680876936, -389564586, 606105819, -1044525330, -176418897, 1200080426, -1473231341, -45705983,
1770035416,-1958414417, -42063, -1990404162, 1804603682, -40341101, -1502002290, 1236535329,
-165796510,-1069501632, 643717713, -373897302, -701558691, 38016083, -660478335, -405537848,
568446438,-1019803690, -187363961, 1163531501,-1444681467, -51403784, 1735328473, -1926607734,
-378558,-2022574463, 1839030562, -35309556,-1530992060, 1272893353, -155497632, -1094730640,
681279174, -358537222, -722521979, 76029189, -640364487, -421815835, 530742520, -995338651,
-198630844, 1126891415,-1416354905, -57434055, 1700485571,-1894986606, -1051523, -2054922799,
1873313359, -30611744,-1560198380, 1309151649, -145523070,-1120210379, 718787259, -343485551,
]);
/** @param {string | Uint8Array} input @returns {string} */
export function md5(input) {
const bytes = input instanceof Uint8Array ? input : new TextEncoder().encode(input);
const len = bytes.length;
// Pad to 512-bit blocks: append 0x80, zeros, then 64-bit LE bit-length
const padded = new Uint8Array((((len + 8) >> 6) + 1) << 6);
padded.set(bytes);
padded[len] = 0x80;
new DataView(padded.buffer).setUint32(padded.length - 8, len * 8, true);
let a = 0x67452301, b = 0xefcdab89, c = 0x98badcfe, d = 0x10325476;
for (let off = 0; off < padded.length; off += 64) {
const M = new Int32Array(padded.buffer, off, 16);
const oa = a, ob = b, oc = c, od = d;
for (let j = 0; j < 64; j++) {
let f, g;
if (j < 16) { f = (b & c) | (~b & d); g = j; }
else if (j < 32) { f = (b & d) | (c & ~d); g = (5 * j + 1) & 15; }
else if (j < 48) { f = b ^ c ^ d; g = (3 * j + 5) & 15; }
else { f = c ^ (b | ~d); g = (7 * j) & 15; }
const tmp = add(add(a, f), add(M[g], K[j]));
a = d; d = c; c = b;
b = add(b, rol(tmp, S[j >> 4 << 2 | j & 3]));
}
a = add(a, oa); b = add(b, ob); c = add(c, oc); d = add(d, od);
}
return [a, b, c, d]
.map(n => Array.from({ length: 4 }, (_, j) => ((n >>> (j * 8)) & 0xff).toString(16).padStart(2, '0')).join(''))
.join('');
}

134
src/routes/+layout.svelte Normal file
View file

@ -0,0 +1,134 @@
<script>
import './layout.css';
import { resolve } from '$app/paths';
import { page } from '$app/state';
/** @type {{children: import('svelte').Snippet}} */
let { children } = $props();
const links = [
{ href: '/object-id', label: 'object id' },
{ href: '/uuid', label: 'uuid' },
{ href: '/hash', label: 'hash' },
{ href: '/cron', label: 'cron' },
{ href: '/api-key', label: 'api key' },
{ href: '/json', label: 'json' },
{ href: '/base', label: 'base' },
{ href: '/string', label: 'string' },
];
let theme = $state('light');
$effect(() => { theme = localStorage.getItem('theme') || 'light'; });
function toggleTheme() {
theme = theme === 'light' ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme === 'dark' ? 'dark' : '');
localStorage.setItem('theme', theme);
}
</script>
<div class="layout">
<nav>
<div class="nav-header">
<a class="site-title" href={resolve('/')}>Toolkit</a>
<button class="theme-btn" onclick={toggleTheme}>{theme === 'light' ? 'dark' : 'light'}</button>
</div>
<ul>
{#each links as link}
<li>
<a
href={resolve(link.href)}
class:active={page.url.pathname === link.href || (link.href !== '/' && page.url.pathname.startsWith(link.href))}
>{link.label}</a>
</li>
{/each}
</ul>
</nav>
<main>
{@render children()}
</main>
</div>
<style>
.layout {
display: flex;
}
nav {
width: 150px;
flex-shrink: 0;
padding: 0.5em;
border-right: 1px solid var(--border);
}
.nav-header {
display: flex;
font-size: 20px;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.site-title {
font-weight: bold;
font-size: 1em;
color: var(--fg);
text-decoration: none;
}
.site-title:visited {
color: var(--fg);
}
.site-title:hover {
color: var(--fg-dim);
}
.theme-btn {
font-size: 0.75em;
}
ul {
display: flex;
flex-wrap: wrap;
list-style: none;
margin: 0;
padding: 0;
gap: 2px;
}
li {
flex: 1 1 auto;
}
li a {
display: block;
border: 1px solid var(--border);
padding: 2px 4px;
font-size: 0.9em;
text-align: center;
text-decoration: none;
color: var(--link);
background: var(--bg-raised);
white-space: nowrap;
}
li a:visited {
color: var(--link-vis);
}
li a:hover {
background: var(--bg-raised-hover);
}
li a.active {
background: var(--active-bg);
color: var(--active-fg);
border-color: var(--active-bg);
}
main {
flex: 1;
padding: 10px 16px;
}
</style>

3
src/routes/+page.js Normal file
View file

@ -0,0 +1,3 @@
// since there's no dynamic data here, we can prerender
// it so that it gets served as a static asset in production
export const prerender = true;

71
src/routes/+page.svelte Normal file
View file

@ -0,0 +1,71 @@
<svelte:head>
<title>Toolkit</title>
<meta name="description" content="Collection of useful browser-based tools for developers" />
</svelte:head>
<script>
import { resolve } from '$app/paths';
const tools = [
{ href: '/object-id', label: 'object id', desc: 'Generate & parse MongoDB ObjectIds (timestamp, random, counter)' },
{ href: '/uuid', label: 'uuid', desc: 'Generate UUIDs v1/v4/v6/v7, parse & inspect' },
{ href: '/hash', label: 'hash', desc: 'MD5, SHA-1, SHA-256, SHA-512' },
{ href: '/cron', label: 'cron', desc: 'Interactive crontab expression builder with presets' },
{ href: '/api-key', label: 'api key', desc: 'High-entropy API key generator (hex, base62, base64url, base58, custom)' },
{ href: '/json', label: 'json', desc: 'Prettify, compact, tab-indent, validate JSON' },
{ href: '/base', label: 'base', desc: 'Encode & decode Base64, Base64url, Base32, Base58, Base16' },
{ href: '/string', label: 'string', desc: 'String transforms: lower, UPPER, snake_case, camelCase, PascalCase, kebab-case, reverse, trim' },
];
</script>
<h1>About</h1>
<p class="about">
A collection of browser-based tools for developers.<br>
No server, no tracking, no external dependencies - everything runs locally in your browser.
</p>
<h2>Tools</h2>
<table class="tools">
<tbody>
{#each tools as tool}
<tr>
<td class="name"><a href={resolve(tool.href)}>{tool.label}</a></td>
<td class="desc">{tool.desc}</td>
</tr>
{/each}
</tbody>
</table>
<h2>Stack</h2>
<p class="about">
<a href="https://kit.svelte.dev">SvelteKit 2</a> +
<a href="https://svelte.dev">Svelte 5</a> ·
JavaScript with JSDoc ·
No runtime dependencies
</p>
<style>
.about {
color: var(--fg-dim);
margin: 0 0 0.5em 0;
line-height: 1.6;
}
.tools {
width: 100%;
margin-bottom: 0.5em;
}
.name {
white-space: nowrap;
font-family: monospace;
width: 1px;
}
.desc {
color: var(--fg-dim);
font-size: 0.9em;
}
</style>

View file

@ -0,0 +1,286 @@
<svelte:head>
<title>API Key Generator</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
const ALPHABETS = {
hex: '0123456789abcdef',
base62: '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
base58: '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz',
alphanum: '0123456789abcdefghijklmnopqrstuvwxyz',
alpha: 'abcdefghijklmnopqrstuvwxyz',
};
/**
* Generate cryptographically secure random string from alphabet.
* Uses rejection sampling to avoid modulo bias.
* @param {string} alphabet
* @param {number} length
* @returns {string}
*/
function randomString(alphabet, length) {
const chars = [];
// Find largest multiple of alphabet.length that fits in a byte (rejection sampling)
const max = 256 - (256 % alphabet.length);
while (chars.length < length) {
const bytes = crypto.getRandomValues(new Uint8Array(length * 2));
for (const b of bytes) {
if (b < max) {
chars.push(alphabet[b % alphabet.length]);
if (chars.length === length) break;
}
}
}
return chars.join('');
}
/**
* Generate raw bytes as base64url (no padding).
* @param {number} byteCount
* @returns {string}
*/
function randomBase64url(byteCount) {
const bytes = crypto.getRandomValues(new Uint8Array(byteCount));
const base64 = btoa(String.fromCharCode(...bytes));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
/**
* @param {number} bits
* @returns {string}
*/
function randomHex(bits) {
const bytes = crypto.getRandomValues(new Uint8Array(bits / 8));
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
}
/**
* @typedef {{ id: string, label: string, desc: string }} Format
*/
/** @type {Format[]} */
const formats = [
{ id: 'hex128', label: 'hex-128', desc: '128-bit hex (32 chars)' },
{ id: 'hex256', label: 'hex-256', desc: '256-bit hex (64 chars)' },
{ id: 'base64url', label: 'base64url', desc: '256-bit base64url (43 chars)' },
{ id: 'base62_32', label: 'base62-32', desc: '32 chars base62 (~190 bits)' },
{ id: 'base62_43', label: 'base62-43', desc: '43 chars base62 (~256 bits)' },
{ id: 'base58_32', label: 'base58-32', desc: '32 chars base58 (~187 bits)' },
{ id: 'alphanum_32',label: 'alnum-32', desc: '32 lowercase alphanumeric (~165 bits)' },
{ id: 'custom', label: 'custom', desc: 'custom alphabet & length' },
];
/** @param {string} id @param {string} alphabet @param {number} customLen @returns {string} */
function generate(id, alphabet, customLen) {
switch (id) {
case 'hex128': return randomHex(128);
case 'hex256': return randomHex(256);
case 'base64url': return randomBase64url(32);
case 'base62_32': return randomString(ALPHABETS.base62, 32);
case 'base62_43': return randomString(ALPHABETS.base62, 43);
case 'base58_32': return randomString(ALPHABETS.base58, 32);
case 'alphanum_32': return randomString(ALPHABETS.alphanum, 32);
case 'custom': return randomString(alphabet || ALPHABETS.base62, customLen);
default: return '';
}
}
/** @param {string} alphabet @param {number} length @returns {number} */
const entropy = (alphabet, length) => Math.floor(Math.log2(alphabet.length) * length);
/** @param {string} id @param {string} customAlphabet @param {number} customLen @returns {number} */
function entropyFor(id, customAlphabet, customLen) {
switch (id) {
case 'hex128': return 128;
case 'hex256': return 256;
case 'base64url': return 256;
case 'base62_32': return entropy(ALPHABETS.base62, 32);
case 'base62_43': return entropy(ALPHABETS.base62, 43);
case 'base58_32': return entropy(ALPHABETS.base58, 32);
case 'alphanum_32': return entropy(ALPHABETS.alphanum, 32);
case 'custom': return entropy(customAlphabet || ALPHABETS.base62, customLen);
default: return 0;
}
}
// --- state ---
let selectedFormat = $state('hex256');
let prefix = $state('');
let customAlphabet = $state(ALPHABETS.base62);
let customLength = $state(32);
let batchCount = $state(5);
/** @type {{ key: string }[]} */
let keys = $state([]);
function regenerate() {
keys = Array.from({ length: batchCount }, () => ({
key: (prefix ? prefix + '_' : '') + generate(selectedFormat, customAlphabet, customLength),
}));
}
// generate on load
regenerate();
const currentEntropy = $derived(entropyFor(selectedFormat, customAlphabet, customLength));
</script>
<h1>API Key Generator</h1>
<h2>Format</h2>
<table class="formats">
<tbody>
{#each formats as f}
<tr>
<td>
<label>
<input type="radio" bind:group={selectedFormat} value={f.id} onchange={regenerate} />
{f.label}
</label>
</td>
<td class="desc">{f.desc}</td>
</tr>
{/each}
</tbody>
</table>
{#if selectedFormat === 'custom'}
<div class="custom-opts">
<label>
Alphabet:
<input type="text" bind:value={customAlphabet} size="50" spellcheck="false" oninput={regenerate} />
<span class="dim">({customAlphabet.length} chars)</span>
</label>
<br>
<label>
Length:
<input type="number" bind:value={customLength} min="8" max="256" size="5" oninput={regenerate} />
</label>
<br>
<div class="quick-alpha">
{#each Object.entries(ALPHABETS) as [name, abc]}
<button onclick={() => { customAlphabet = abc; regenerate(); }}>{name}</button>
{/each}
</div>
</div>
{/if}
<h2>Options</h2>
<p>
<label>
Prefix (optional):
<input type="text" bind:value={prefix} size="16" placeholder="sk, pk, token…" spellcheck="false" oninput={regenerate} />
</label>
&nbsp;
<label>
Count:
<input type="number" bind:value={batchCount} min="1" max="50" size="4" oninput={regenerate} />
</label>
</p>
<h2>Keys <span class="entropy">~{currentEntropy} bits of entropy</span></h2>
<div class="actions">
<button onclick={regenerate}>Regenerate all</button>
<button onclick={() => copy(keys.map(k => k.key).join('\n'))}>Copy all</button>
</div>
<table class="keys">
<tbody>
{#each keys as k, i}
<tr>
<td class="idx">{i + 1}</td>
<td><code>{k.key}</code></td>
<td>
<button onclick={() => {
k.key = (prefix ? prefix + '_' : '') + generate(selectedFormat, customAlphabet, customLength);
}}>new</button>
</td>
<td><button onclick={() => copy(k.key)}>copy</button></td>
</tr>
{/each}
</tbody>
</table>
<style>
.formats {
border-collapse: collapse;
margin-bottom: 8px;
}
.formats td {
padding: 2px 10px 2px 0;
}
.desc {
color: var(--fg-dim);
font-size: 0.9em;
}
.custom-opts {
border: 1px solid var(--border);
padding: 8px 12px;
margin-bottom: 8px;
background: var(--bg-panel);
}
.custom-opts label {
display: block;
margin-bottom: 4px;
}
.quick-alpha {
display: flex;
gap: 4px;
flex-wrap: wrap;
margin-top: 6px;
}
.dim {
color: var(--fg-faint);
font-size: 0.85em;
}
.entropy {
font-size: 0.75em;
font-weight: normal;
color: var(--green);
margin-left: 8px;
}
.actions {
display: flex;
gap: 6px;
margin-bottom: 6px;
}
.keys {
border-collapse: collapse;
width: 100%;
}
.keys td {
padding: 2px 6px;
border: 1px solid var(--border-faint);
}
.keys code {
font-size: 0.95em;
word-break: break-all;
}
.idx {
color: var(--fg-faint);
font-size: 0.85em;
width: 1px;
white-space: nowrap;
text-align: right;
}
.keys td:nth-child(3),
.keys td:nth-child(4) {
width: 1px;
white-space: nowrap;
}
</style>

View file

@ -0,0 +1,317 @@
<svelte:head>
<title>Base Encoder / Decoder</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
// --- Base64 ---
/** @param {string} s @returns {string} */
const encodeBase64 = (s) => btoa(unescape(encodeURIComponent(s)));
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
function decodeBase64(s) {
try {
return { ok: true, value: decodeURIComponent(escape(atob(s.trim()))) };
} catch {
return { ok: false, error: 'Invalid Base64' };
}
}
/** @param {string} s @returns {string} */
const encodeBase64url = (s) => encodeBase64(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
function decodeBase64url(s) {
const padded = s.trim().replace(/-/g, '+').replace(/_/g, '/');
const pad = (4 - padded.length % 4) % 4;
return decodeBase64(padded + '='.repeat(pad));
}
// --- Base32 (RFC 4648) ---
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
/** @param {string} s @returns {string} */
function encodeBase32(s) {
const bytes = new TextEncoder().encode(s);
let bits = 0, buf = 0, out = '';
for (const byte of bytes) {
buf = (buf << 8) | byte;
bits += 8;
while (bits >= 5) {
bits -= 5;
out += B32_ALPHABET[(buf >> bits) & 0x1f];
}
}
if (bits > 0) out += B32_ALPHABET[(buf << (5 - bits)) & 0x1f];
while (out.length % 8 !== 0) out += '=';
return out;
}
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
function decodeBase32(s) {
const clean = s.trim().toUpperCase().replace(/=+$/, '');
let bits = 0, buf = 0;
const bytes = [];
for (const ch of clean) {
const idx = B32_ALPHABET.indexOf(ch);
if (idx === -1) return { ok: false, error: `Invalid Base32 character: '${ch}'` };
buf = (buf << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((buf >> bits) & 0xff);
}
}
try {
return { ok: true, value: new TextDecoder().decode(new Uint8Array(bytes)) };
} catch {
return { ok: false, error: 'Could not decode as UTF-8' };
}
}
// --- Base58 (Bitcoin alphabet) ---
const B58_ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz';
/** @param {string} s @returns {string} */
function encodeBase58(s) {
const bytes = new TextEncoder().encode(s);
let num = 0n;
for (const b of bytes) num = (num << 8n) | BigInt(b);
let out = '';
while (num > 0n) {
out = B58_ALPHABET[Number(num % 58n)] + out;
num /= 58n;
}
// leading zero bytes → leading '1's
for (const b of bytes) {
if (b !== 0) break;
out = '1' + out;
}
return out;
}
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
function decodeBase58(s) {
const clean = s.trim();
let num = 0n;
for (const ch of clean) {
const idx = B58_ALPHABET.indexOf(ch);
if (idx === -1) return { ok: false, error: `Invalid Base58 character: '${ch}'` };
num = num * 58n + BigInt(idx);
}
const bytes = [];
while (num > 0n) {
bytes.unshift(Number(num & 0xffn));
num >>= 8n;
}
for (const ch of clean) {
if (ch !== '1') break;
bytes.unshift(0);
}
try {
return { ok: true, value: new TextDecoder().decode(new Uint8Array(bytes)) };
} catch {
return { ok: false, error: 'Could not decode as UTF-8' };
}
}
// --- Base16 (hex) ---
/** @param {string} s @returns {string} */
const encodeBase16 = (s) => Array.from(new TextEncoder().encode(s), b => b.toString(16).padStart(2, '0')).join('');
/** @param {string} s @returns {{ ok: true, value: string } | { ok: false, error: string }} */
function decodeBase16(s) {
const clean = s.trim().replace(/\s/g, '');
if (!/^[0-9a-fA-F]*$/.test(clean)) return { ok: false, error: 'Invalid hex character' };
if (clean.length % 2 !== 0) return { ok: false, error: 'Odd number of hex characters' };
const bytes = new Uint8Array(clean.length / 2);
for (let i = 0; i < bytes.length; i++) bytes[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
try {
return { ok: true, value: new TextDecoder().decode(bytes) };
} catch {
return { ok: false, error: 'Could not decode as UTF-8' };
}
}
// --- state ---
/** @type {'base64' | 'base64url' | 'base32' | 'base58' | 'base16'} */
let encoding = $state('base64');
/** @type {'encode' | 'decode'} */
let tab = $state('encode');
let plainInput = $state('');
let encodedInput = $state('');
const encodeResult = $derived.by(() => {
if (!plainInput) return { ok: true, value: '' };
if (encoding === 'base64') return { ok: true, value: encodeBase64(plainInput) };
if (encoding === 'base64url') return { ok: true, value: encodeBase64url(plainInput) };
if (encoding === 'base58') return { ok: true, value: encodeBase58(plainInput) };
if (encoding === 'base16') return { ok: true, value: encodeBase16(plainInput) };
return { ok: true, value: encodeBase32(plainInput) };
});
const decodeResult = $derived.by(() => {
if (!encodedInput) return { ok: true, value: '' };
if (encoding === 'base64') return decodeBase64(encodedInput);
if (encoding === 'base64url') return decodeBase64url(encodedInput);
if (encoding === 'base58') return decodeBase58(encodedInput);
if (encoding === 'base16') return decodeBase16(encodedInput);
return decodeBase32(encodedInput);
});
</script>
<h1>Base Encoding</h1>
<div class="toolbar">
<div class="enc-switch">
{#each ['base64', 'base64url', 'base32', 'base58', 'base16'] as enc}
<button
class:active={encoding === enc}
onclick={() => { encoding = enc; }}
>{enc}</button>
{/each}
</div>
<div class="tab-switch">
<button class:active={tab === 'encode'} onclick={() => { tab = 'encode'; }}>encode</button>
<button class:active={tab === 'decode'} onclick={() => { tab = 'decode'; }}>decode</button>
</div>
</div>
{#if tab === 'encode'}
<div class="panel">
<div class="panel-header">
<span>Plain text</span>
<div class="panel-actions">
<button onclick={() => { plainInput = ''; }}>clear</button>
<button disabled={!encodeResult.ok || !encodeResult.value} onclick={() => copy(/** @type {any} */ (encodeResult).value)}>copy</button>
</div>
</div>
<textarea
bind:value={plainInput}
placeholder="Type or paste text to encode…"
spellcheck="false"
></textarea>
{#if plainInput}
{#if encodeResult.ok && encodeResult.value}
<div class="result-row">
<code class="result">{encodeResult.value}</code>
</div>
{:else if !encodeResult.ok}
<div class="error">{encodeResult.error}</div>
{/if}
{/if}
</div>
{:else}
<div class="panel">
<div class="panel-header">
<span>Encoded</span>
<div class="panel-actions">
<button onclick={() => { encodedInput = ''; }}>clear</button>
<button disabled={!decodeResult.ok || !decodeResult.value} onclick={() => copy(/** @type {any} */ (decodeResult).value)}>copy</button>
</div>
</div>
<textarea
bind:value={encodedInput}
placeholder="Paste encoded string to decode…"
spellcheck="false"
></textarea>
{#if encodedInput}
{#if decodeResult.ok && decodeResult.value}
<div class="result-row">
<code class="result">{decodeResult.value}</code>
</div>
{:else if !decodeResult.ok}
<div class="error">{decodeResult.error}</div>
{/if}
{/if}
</div>
{/if}
<style>
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.enc-switch,
.tab-switch {
display: flex;
gap: 4px;
}
.enc-switch button.active,
.tab-switch button.active {
background: var(--active-bg);
color: var(--active-fg);
border-color: var(--active-bg);
}
.panel {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-bottom: none;
padding: 2px 6px;
font-size: 0.85em;
font-weight: bold;
}
.panel-actions {
display: flex;
gap: 4px;
}
textarea {
width: 100%;
box-sizing: border-box;
height: 40vh;
font-family: monospace;
font-size: 0.9em;
border: 1px solid var(--border);
padding: 6px;
resize: vertical;
outline: none;
background: var(--bg);
color: var(--fg);
}
.result-row {
border: 1px solid var(--border);
border-top: none;
background: var(--bg-panel);
padding: 4px 6px;
overflow-x: auto;
}
.result {
font-size: 0.85em;
word-break: break-all;
}
.error {
border: 1px solid var(--err-border);
border-top: none;
background: var(--err-bg);
color: var(--err);
padding: 4px 6px;
font-size: 0.85em;
font-family: monospace;
}
</style>

View file

@ -0,0 +1,370 @@
<svelte:head>
<title>Crontab Generator</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
/**
* @typedef {{ label: string, value: string }[]} Presets
*/
/** @type {{ label: string, name: string, placeholder: string, presets: Presets }[]} */
const fields = [
{
label: 'Minute',
name: 'minute',
placeholder: '0-59',
presets: [
{ label: '*', value: '*' },
{ label: '*/5', value: '*/5' },
{ label: '*/10', value: '*/10' },
{ label: '*/15', value: '*/15' },
{ label: '*/30', value: '*/30' },
{ label: '0', value: '0' },
],
},
{
label: 'Hour',
name: 'hour',
placeholder: '0-23',
presets: [
{ label: '*', value: '*' },
{ label: '*/2', value: '*/2' },
{ label: '*/4', value: '*/4' },
{ label: '*/6', value: '*/6' },
{ label: '*/12', value: '*/12' },
{ label: '0', value: '0' },
{ label: '6', value: '6' },
{ label: '12', value: '12' },
{ label: '18', value: '18' },
],
},
{
label: 'Day (month)',
name: 'dom',
placeholder: '1-31',
presets: [
{ label: '*', value: '*' },
{ label: '1', value: '1' },
{ label: '15', value: '15' },
{ label: 'L', value: 'L' },
],
},
{
label: 'Month',
name: 'month',
placeholder: '1-12',
presets: [
{ label: '*', value: '*' },
{ label: '1', value: '1' },
{ label: '3', value: '3' },
{ label: '6', value: '6' },
{ label: '9', value: '9' },
{ label: '12', value: '12' },
{ label: '1-6', value: '1-6' },
{ label: '7-12', value: '7-12' },
],
},
{
label: 'Day (week)',
name: 'dow',
placeholder: '0-7',
presets: [
{ label: '*', value: '*' },
{ label: 'MonFri', value: '1-5' },
{ label: 'SatSun', value: '6,0' },
{ label: 'Mon', value: '1' },
{ label: 'Wed', value: '3' },
{ label: 'Fri', value: '5' },
{ label: 'Sat', value: '6' },
{ label: 'Sun', value: '0' },
],
},
];
/** @type {Record<string, string>} */
let values = $state({
minute: '*',
hour: '*',
dom: '*',
month: '*',
dow: '*',
});
const expression = $derived(
`${values.minute} ${values.hour} ${values.dom} ${values.month} ${values.dow}`
);
/** @param {string} field @param {string} val */
const set = (field, val) => { values[field] = val; };
const DOW_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const MON_NAMES = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
/** @param {string} v @returns {string} */
function describeMinute(v) {
if (v === '*') return 'every minute';
if (v === '0') return 'at minute 0';
if (v.startsWith('*/')) return `every ${v.slice(2)} minutes`;
return `at minute ${v}`;
}
/** @param {string} v @returns {string} */
function describeHour(v) {
if (v === '*') return 'every hour';
if (v.startsWith('*/')) return `every ${v.slice(2)} hours`;
if (/^\d+$/.test(v)) return `at ${v.padStart(2, '0')}:xx`;
return `at hour ${v}`;
}
/** @param {string} v @returns {string} */
function describeDom(v) {
if (v === '*') return '';
if (v === 'L') return 'on the last day of the month';
if (/^\d+$/.test(v)) return `on day ${v} of the month`;
return `on day-of-month ${v}`;
}
/** @param {string} v @returns {string} */
function describeMonth(v) {
if (v === '*') return '';
if (/^\d+$/.test(v)) return `in ${MON_NAMES[+v] ?? v}`;
if (v.includes('-')) {
const [a, b] = v.split('-');
return `from ${MON_NAMES[+a] ?? a} to ${MON_NAMES[+b] ?? b}`;
}
return `in month ${v}`;
}
/** @param {string} v @returns {string} */
function describeDow(v) {
if (v === '*') return '';
if (v === '1-5') return 'on weekdays (MonFri)';
if (v === '6,0' || v === '0,6') return 'on weekends';
if (/^\d+$/.test(v)) return `on ${DOW_NAMES[+v] ?? v}`;
if (v.includes('-')) {
const [a, b] = v.split('-');
return `from ${DOW_NAMES[+a] ?? a} to ${DOW_NAMES[+b] ?? b}`;
}
return `on day-of-week ${v}`;
}
const description = $derived((() => {
const min = describeMinute(values.minute);
const hr = describeHour(values.hour);
const dom = describeDom(values.dom);
const mon = describeMonth(values.month);
const dow = describeDow(values.dow);
let parts = [];
if (values.minute === '*' && values.hour === '*') {
parts.push('every minute');
} else if (values.minute === '0' && values.hour !== '*') {
parts.push(`at ${values.hour.padStart(2,'0')}:00`);
} else if (values.hour === '*') {
parts.push(min);
} else {
parts.push(`${min}, ${hr}`);
}
if (dom) parts.push(dom);
if (mon) parts.push(mon);
if (dow) parts.push(dow);
return parts.join(', ');
})());
/** Common presets */
const quickPresets = [
{ label: 'Every minute', expr: '* * * * *' },
{ label: 'Every 5 minutes', expr: '*/5 * * * *' },
{ label: 'Every 15 minutes', expr: '*/15 * * * *' },
{ label: 'Every 30 minutes', expr: '*/30 * * * *' },
{ label: 'Every hour', expr: '0 * * * *' },
{ label: 'Every 6 hours', expr: '0 */6 * * *' },
{ label: 'Every day at midnight', expr: '0 0 * * *' },
{ label: 'Every day at noon', expr: '0 12 * * *' },
{ label: 'Every Mon at 09:00',expr: '0 9 * * 1' },
{ label: 'Weekdays at 08:00', expr: '0 8 * * 1-5' },
{ label: 'Every Sunday at 03:00', expr: '0 3 * * 0' },
{ label: '1st of each month', expr: '0 0 1 * *' },
{ label: 'Every quarter', expr: '0 0 1 */3 *' },
{ label: 'Once a year (Jan 1)',expr: '0 0 1 1 *' },
];
/** @param {string} expr */
function applyPreset(expr) {
const parts = expr.split(' ');
values.minute = parts[0];
values.hour = parts[1];
values.dom = parts[2];
values.month = parts[3];
values.dow = parts[4];
}
/** @param {string} expr */
function parseExpression(expr) {
const parts = expr.trim().split(/\s+/);
if (parts.length === 5) applyPreset(parts.join(' '));
}
let manualInput = $state('');
$effect(() => {
manualInput = expression;
});
</script>
<h1>Cron</h1>
<h2>Quick presets</h2>
<div class="presets">
{#each quickPresets as p}
<button onclick={() => applyPreset(p.expr)}>{p.label}</button>
{/each}
</div>
<h2>Builder</h2>
<table class="builder">
<thead>
<tr>
{#each fields as f}
<th>{f.label}</th>
{/each}
</tr>
</thead>
<tbody>
<tr>
{#each fields as f}
<td>
<input
type="text"
value={values[f.name]}
oninput={(e) => set(f.name, /** @type {HTMLInputElement} */ (e.target).value)}
size="6"
spellcheck="false"
/>
</td>
{/each}
</tr>
<tr>
{#each fields as f}
<td class="chips">
{#each f.presets as p}
<button
class:selected={values[f.name] === p.value}
onclick={() => set(f.name, p.value)}
>{p.label}</button>
{/each}
</td>
{/each}
</tr>
</tbody>
</table>
<h2>Expression</h2>
<p>
<code class="expr">{expression}</code>
<button onclick={() => copy(expression)}>copy</button>
</p>
<p class="desc">{description}</p>
<h2>Paste &amp; parse</h2>
<p>
<input
type="text"
bind:value={manualInput}
size="30"
placeholder="* * * * *"
spellcheck="false"
oninput={() => parseExpression(manualInput)}
/>
<button onclick={() => parseExpression(manualInput)}>parse</button>
</p>
<h2>Reference</h2>
<table class="ref">
<thead>
<tr><th>Field</th><th>Range</th><th>Special</th></tr>
</thead>
<tbody>
<tr><td>Minute</td><td>059</td><td rowspan="5"><code>*</code> any &nbsp; <code>,</code> list &nbsp; <code>-</code> range &nbsp; <code>/</code> step</td></tr>
<tr><td>Hour</td><td>023</td></tr>
<tr><td>Day (month)</td><td>131</td></tr>
<tr><td>Month</td><td>112</td></tr>
<tr><td>Day (week)</td><td>07 (0,7=Sun)</td></tr>
</tbody>
</table>
<style>
.presets {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.builder {
border-collapse: collapse;
margin-bottom: 8px;
}
.builder th,
.builder td {
border: 1px solid var(--border);
padding: 4px 8px;
vertical-align: top;
}
.builder th {
background: var(--bg-raised);
font-size: 0.85em;
}
.builder input {
width: 60px;
font-family: monospace;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 2px;
min-width: 80px;
}
.chips button.selected {
background: var(--active-bg);
color: var(--active-fg);
border-color: var(--active-bg);
}
.expr {
font-size: 1.3em;
margin-right: 8px;
letter-spacing: 0.05em;
}
.desc {
color: var(--fg-dim);
font-style: italic;
margin-top: 0;
}
.ref {
border-collapse: collapse;
font-size: 0.9em;
}
.ref th,
.ref td {
border: 1px solid var(--border);
padding: 3px 8px;
}
.ref th {
background: var(--bg-raised);
}
</style>

View file

@ -0,0 +1,136 @@
<svelte:head>
<title>Hash Generator</title>
</svelte:head>
<script lang="js">
import { md5 } from '$lib/md5.js';
import { copy } from '$lib/clipboard.js';
/** @param {AlgorithmIdentifier} algorithm @param {string} input @returns {Promise<string>} */
const sha = async (algorithm, input) => {
const buf = await crypto.subtle.digest(algorithm, new TextEncoder().encode(input));
return Array.from(new Uint8Array(buf), b => b.toString(16).padStart(2, '0')).join('');
};
let input = $state('');
/** @type {HTMLTextAreaElement} */
let textareaEl = $state();
$effect(() => {
input;
if (textareaEl) {
const scrollY = window.scrollY;
textareaEl.style.height = 'auto';
textareaEl.style.height = textareaEl.scrollHeight + 'px';
window.scrollTo(0, scrollY);
}
});
/** @type {{ label: string, value: string }[]} */
let hashes = $state([
{ label: 'MD5', value: '' },
{ label: 'SHA1', value: '' },
{ label: 'SHA256', value: '' },
{ label: 'SHA512', value: '' },
]);
$effect(() => {
const text = input;
hashes[0].value = md5(text);
sha('SHA-1', text).then(v => hashes[1].value = v);
sha('SHA-256', text).then(v => hashes[2].value = v);
sha('SHA-512', text).then(v => hashes[3].value = v);
});
</script>
<h1>Hash Generator</h1>
<div class="layout">
<div class="panel">
<div class="panel-header">
<span>Plain text</span>
<div class="panel-actions">
<button onclick={() => { input = ''; }}>clear</button>
</div>
</div>
<textarea
bind:this={textareaEl}
bind:value={input}
placeholder="Enter text..."
spellcheck="false"
></textarea>
</div>
<table>
<tbody>
{#each hashes as hash}
<tr>
<td class="algo">{hash.label}</td>
<td class="value"><code>{hash.value}</code></td>
<td><button onclick={() => copy(hash.value)}>copy</button></td>
</tr>
{/each}
</tbody>
</table>
</div>
<style>
.layout {
display: flex;
gap: 12px;
}
.panel {
width: 60%;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-bottom: none;
padding: 2px 6px;
font-size: 0.85em;
font-weight: bold;
}
.panel-actions {
display: flex;
gap: 4px;
}
textarea {
width: 100%;
box-sizing: border-box;
min-height: 2em;
resize: none;
overflow: hidden;
font-family: monospace;
font-size: 13px;
padding: 4px;
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
outline: none;
}
table {
flex: 1;
height: fit-content;
}
.algo {
font-weight: bold;
white-space: nowrap;
width: 60px;
}
.value code {
word-break: break-all;
font-size: 12px;
}
</style>

View file

@ -0,0 +1,253 @@
<svelte:head>
<title>JSON formatter</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
let input = $state('');
let indent = $state(2);
/** @type {'pretty' | 'compact' | 'tabs'} */
let mode = $state('pretty');
/**
* @typedef {{ ok: true, value: unknown } | { ok: false, error: string }} ParseResult
*/
/** @type {ParseResult} */
const parsed = $derived.by(() => {
const s = input.trim();
if (!s) return { ok: false, error: '' };
try {
return { ok: true, value: JSON.parse(s) };
} catch (e) {
return { ok: false, error: /** @type {Error} */ (e).message };
}
});
const pretty = $derived(
parsed.ok ? JSON.stringify(parsed.value, null, indent) : ''
);
const compact = $derived(
parsed.ok ? JSON.stringify(parsed.value) : ''
);
const tabs = $derived(
parsed.ok ? JSON.stringify(parsed.value, null, '\t') : ''
);
const output = $derived(
mode === 'compact' ? compact : mode === 'tabs' ? tabs : pretty
);
/** @param {unknown} v @returns {string} */
function typeLabel(v) {
if (v === null) return 'null';
if (Array.isArray(v)) return `array[${v.length}]`;
if (typeof v === 'object') return `object{${Object.keys(/** @type {object} */ (v)).length}}`;
return typeof v;
}
const stats = $derived.by(() => {
if (!parsed.ok) return null;
const v = parsed.value;
return {
type: typeLabel(v),
size: new TextEncoder().encode(compact).length,
prettySize: new TextEncoder().encode(pretty).length,
};
});
const SAMPLE = `{
"name": "toolkit",
"version": "1.0.0",
"tags": ["dev", "tools"],
"meta": {
"author": "otter.su",
"active": true,
"count": 42,
"score": 3.14,
"nothing": null
}
}`;
</script>
<h1>JSON</h1>
<div class="layout">
<div class="panel input-pane">
<div class="panel-header">
<span>Input</span>
<div class="panel-actions">
<button onclick={() => { input = SAMPLE; }}>sample</button>
<button onclick={() => { input = ''; }}>clear</button>
</div>
</div>
<textarea
bind:value={input}
placeholder="Paste JSON here…"
spellcheck="false"
></textarea>
</div>
<div class="middle">
<div class="ops">
<button
disabled={!parsed.ok}
class:active={mode === 'pretty'}
onclick={() => { mode = 'pretty'; }}
>pretty</button>
<button
disabled={!parsed.ok}
class:active={mode === 'compact'}
onclick={() => { mode = 'compact'; }}
>compact</button>
<button
disabled={!parsed.ok}
class:active={mode === 'tabs'}
onclick={() => { mode = 'tabs'; }}
>tabs</button>
{#if mode === 'pretty'}
<label>indent
<input type="number" bind:value={indent} min="1" max="8" size="2" />
</label>
{/if}
</div>
<div class="right">
{#if input.trim() && !parsed.ok && parsed.error}
<span class="error">{parsed.error}</span>
{:else if parsed.ok && stats}
<span class="stats">
<span>type: <b>{stats.type}</b></span>
<span>compact: <b>{stats.size} B</b></span>
<span>pretty: <b>{stats.prettySize} B</b></span>
</span>
{/if}
</div>
</div>
<div class="panel output-pane">
<div class="panel-header">
<span>Output</span>
<div class="panel-actions">
<button disabled={!parsed.ok} onclick={() => copy(output)}>copy</button>
</div>
</div>
<pre class="output" class:error-bg={!parsed.ok && !!parsed.error}>{parsed.ok ? output : (parsed.error ? '— invalid JSON —' : '')}</pre>
</div>
</div>
<style>
.layout {
display: flex;
flex-direction: column;
gap: 6px;
height: calc(100vh - 120px);
}
.panel {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-bottom: none;
padding: 2px 6px;
font-size: 0.85em;
font-weight: bold;
}
.panel-actions {
display: flex;
gap: 4px;
}
textarea {
flex: 1;
width: 100%;
box-sizing: border-box;
font-family: monospace;
font-size: 0.9em;
border: 1px solid var(--border);
padding: 6px;
resize: none;
outline: none;
background: var(--bg);
color: var(--fg);
}
.output {
flex: 1;
margin: 0;
border: 1px solid var(--border);
padding: 6px;
overflow: auto;
font-family: monospace;
font-size: 0.9em;
background: var(--bg-panel);
color: var(--fg);
white-space: pre-wrap;
word-break: break-all;
}
.error-bg {
color: var(--err);
background: var(--err-bg);
}
.middle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.right {
display: flex;
align-items: center;
gap: 12px;
font-size: 0.85em;
}
.ops {
display: flex;
align-items: center;
gap: 6px;
}
.ops button.active {
background: var(--active-bg);
color: var(--active-fg);
border-color: var(--active-bg);
}
.ops label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.9em;
}
.ops input {
width: 36px;
}
.stats {
display: flex;
gap: 12px;
color: var(--fg-dim);
}
.error {
color: var(--err);
font-family: monospace;
}
</style>

132
src/routes/layout.css Normal file
View file

@ -0,0 +1,132 @@
:root {
/* Gruvbox Light Hard */
--bg: #f9f5d7;
--bg-panel: #fbf1c7;
--bg-raised: #ebdbb2;
--bg-raised-hover: #d5c4a1;
--fg: #3c3836;
--fg-dim: #665c54;
--fg-faint: #928374;
--border: #a89984;
--border-faint: #bdae93;
--active-bg: #3c3836;
--active-fg: #f9f5d7;
--link: #458588;
--link-vis: #b16286;
--link-hover: #9d0006;
--err: #9d0006;
--err-bg: #f9e5e5;
--err-border: #cc241d;
--green: #98971a;
--blue: #458588;
--red: #cc241d;
--bar-bg: #3c3836;
--bar-fg: #f9f5d7;
--bar-dim: #928374;
}
[data-theme="dark"] {
/* Gruvbox Dark Hard */
--bg: #1d2021;
--bg-panel: #282828;
--bg-raised: #3c3836;
--bg-raised-hover: #504945;
--fg: #ebdbb2;
--fg-dim: #bdae93;
--fg-faint: #928374;
--border: #665c54;
--border-faint: #504945;
--active-bg: #d5c4a1;
--active-fg: #1d2021;
--link: #83a598;
--link-vis: #d3869b;
--link-hover: #fb4934;
--err: #fb4934;
--err-bg: #3b2020;
--err-border: #cc241d;
--green: #b8bb26;
--blue: #83a598;
--red: #fb4934;
--bar-bg: #282828;
--bar-fg: #ebdbb2;
--bar-dim: #928374;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font-family: Arial, Helvetica, sans-serif;
}
a {
color: var(--link);
text-decoration: underline;
}
a:visited {
color: var(--link-vis);
}
a:hover {
color: var(--link-hover);
}
h1 {
margin: 0 0 0.5em 0;
border-bottom: 1px solid var(--border);
padding-bottom: 2px;
}
h2 {
margin: 0.8em 0 0.3em 0;
}
input, button, textarea, select {
font-family: Arial, Helvetica, sans-serif;
}
button {
background: var(--bg-raised);
border: 1px solid var(--border);
padding: 2px 8px;
cursor: pointer;
color: var(--fg);
}
button:hover {
background: var(--bg-raised-hover);
}
input[type="text"],
input[type="number"] {
border: 1px solid var(--border);
padding: 2px 4px;
background: var(--bg);
color: var(--fg);
}
select {
border: 1px solid var(--border);
background: var(--bg);
color: var(--fg);
}
code {
font-family: monospace;
}
table {
border-collapse: collapse;
}
td, th {
border: 1px solid var(--border);
padding: 3px 6px;
}
hr {
border: none;
border-top: 1px solid var(--border);
margin: 1em 0;
}

View file

@ -0,0 +1,130 @@
<svelte:head>
<title>ObjectId Tools</title>
</svelte:head>
<script lang="js">
/**
* @typedef {{
* timestamp:number,
* date:string,
* dateLocal:string,
* randomPart:string,
* counterPart:string}} ParsedObjectId
*/
// ObjectId: 4 bytes timestamp | 5 bytes random | 3 bytes counter
function generateObjectId() {
const timestamp = Math.floor(Date.now() / 1000);
const tsHex = timestamp.toString(16).padStart(8, '0');
const random = Array.from({ length: 10 }, () =>
Math.floor(Math.random() * 16).toString(16)
).join('');
const counter = Math.floor(Math.random() * 0xffffff);
const counterHex = counter.toString(16).padStart(6, '0');
return tsHex + random + counterHex;
}
/**
* @param {string} id
* @returns {ParsedObjectId | { error: string }}
*/
function parseObjectId(id) {
if (!/^[0-9a-fA-F]{24}$/.test(id)) {
return { error: 'Invalid ObjectId: must be 24 hex characters' };
}
const tsHex = id.slice(0, 8);
const randomPart = id.slice(8, 18);
const counterPart = id.slice(18, 24);
const timestampSec = parseInt(tsHex, 16);
const date = new Date(timestampSec * 1000);
return {
timestamp: timestampSec,
date: date.toISOString(),
dateLocal: date.toLocaleString(),
randomPart,
counterPart
};
}
let generated = $state(generateObjectId());
let parseInput = $state('');
const parseResult = $derived(parseInput.trim().length === 24 ? parseObjectId(parseInput.trim()) : null);
const parsed = $derived(parseResult && !('error' in parseResult) ? parseResult : null);
const parseError = $derived(parseResult && 'error' in parseResult ? parseResult.error : '');
function onGenerate() {
generated = generateObjectId();
}
import { copy as copyToClipboard } from '$lib/clipboard.js';
</script>
<h1>ObjectId</h1>
<h2>Generate</h2>
<p>
<code>
<span class="ts" title="timestamp">{generated.slice(0, 8)}</span><span
class="rnd" title="random">{generated.slice(8, 18)}</span><span
class="cnt" title="counter">{generated.slice(18, 24)}</span>
</code>
&nbsp;
<button onclick={onGenerate}>New</button>
<button onclick={() => copyToClipboard(generated)}>Copy</button>
</p>
<p class="legend">
<span class="ts">■ timestamp</span>
<span class="rnd">■ random</span>
<span class="cnt">■ counter</span>
&nbsp;&nbsp;
Generated: {new Date(parseInt(generated.slice(0, 8), 16) * 1000).toLocaleString()}
</p>
<hr>
<h2>Parse</h2>
<p>
<input
type="text"
size="26"
placeholder="507f1f77bcf86cd799439011"
bind:value={parseInput}
spellcheck="false"
/>
</p>
{#if parseError}
<p class="error">{parseError}</p>
{/if}
{#if parsed}
<table>
<tbody>
<tr><td>Timestamp (unix)</td><td><code>{parsed.timestamp}</code></td><td><button onclick={() => copyToClipboard(String(parsed.timestamp))}>copy</button></td></tr>
<tr><td>Date (UTC)</td><td><code>{parsed.date}</code></td><td><button onclick={() => copyToClipboard(parsed.date)}>copy</button></td></tr>
<tr><td>Date (local)</td><td><code>{parsed.dateLocal}</code></td><td><button onclick={() => copyToClipboard(parsed.dateLocal)}>copy</button></td></tr>
<tr><td>Random part</td><td><code class="rnd">{parsed.randomPart}</code></td><td><button onclick={() => copyToClipboard(parsed.randomPart)}>copy</button></td></tr>
<tr><td>Counter</td><td><code class="cnt">{parsed.counterPart}</code> ({parseInt(parsed.counterPart, 16)})</td><td><button onclick={() => copyToClipboard(parsed.counterPart)}>copy</button></td></tr>
</tbody>
</table>
{/if}
<style>
.ts { color: var(--red); }
.rnd { color: var(--blue); }
.cnt { color: var(--green); }
.legend {
font-size: 12px;
color: var(--fg-dim);
}
.error {
color: var(--err);
}
</style>

View file

@ -0,0 +1,217 @@
<svelte:head>
<title>String Transform</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
/** @param {string} s @returns {string[]} */
function splitWords(s) {
return s
.replace(/([a-z])([A-Z])/g, '$1 $2')
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/[\s_\-]+/g, ' ')
.trim()
.split(/\s+/)
.filter(Boolean);
}
/** @param {string} s @returns {string} */
const capitalize = (s) => s.charAt(0).toUpperCase() + s.slice(1).toLowerCase();
/** @type {Record<string, (s: string) => string>} */
const ops = {
lower: (s) => s.toLowerCase(),
upper: (s) => s.toUpperCase(),
snake: (s) => splitWords(s).map(w => w.toLowerCase()).join('_'),
screaming: (s) => splitWords(s).map(w => w.toUpperCase()).join('_'),
kebab: (s) => splitWords(s).map(w => w.toLowerCase()).join('-'),
camel: (s) => splitWords(s).map((w, i) => i === 0 ? w.toLowerCase() : capitalize(w)).join(''),
pascal: (s) => splitWords(s).map(w => capitalize(w)).join(''),
title: (s) => splitWords(s).map(w => capitalize(w)).join(' '),
reverse: (s) => s.split('').reverse().join(''),
trim: (s) => s.trim(),
};
/** @type {{ id: string, label: string }[]} */
const buttons = [
{ id: 'lower', label: 'lower' },
{ id: 'upper', label: 'UPPER' },
{ id: 'snake', label: 'snake_case' },
{ id: 'screaming', label: 'SCREAMING_SNAKE' },
{ id: 'kebab', label: 'kebab-case' },
{ id: 'camel', label: 'camelCase' },
{ id: 'pascal', label: 'PascalCase' },
{ id: 'title', label: 'Title Case' },
{ id: 'reverse', label: 'reverse' },
{ id: 'trim', label: 'trim' },
];
let input = $state('');
let multi = $state(false);
/** @type {string | null} */
let op = $state(null);
/** @param {string} s @param {string} opId @returns {string} */
function applyOp(s, opId) {
const fn = ops[opId];
if (!fn) return s;
if (multi) return s.split('\n').map(line => line ? fn(line) : '').join('\n');
return fn(s);
}
const output = $derived(op && input ? applyOp(input, op) : '');
const stats = $derived.by(() => {
if (!input) return null;
const chars = input.length;
const words = input.trim() ? input.trim().split(/\s+/).length : 0;
const lines = input.split('\n').length;
return { chars, words, lines };
});
</script>
<h1>String</h1>
<div class="layout">
<div class="panel">
<div class="panel-header">
<span>Input</span>
<div class="panel-actions">
<button onclick={() => { input = ''; op = null; }}>clear</button>
<button disabled={!input} onclick={() => copy(input)}>copy</button>
</div>
</div>
<textarea
bind:value={input}
placeholder="Paste or type text…"
spellcheck="false"
></textarea>
</div>
<div class="toolbar">
<div class="ops">
{#each buttons as b}
<button
class:active={op === b.id}
onclick={() => { op = op === b.id ? null : b.id; }}
>{b.label}</button>
{/each}
</div>
<label class="multi-label">
<input type="checkbox" bind:checked={multi} />
Multiple input (each line separately)
</label>
{#if stats}
<span class="stats">
<span>{stats.chars} chars</span>
<span>{stats.words} words</span>
<span>{stats.lines} lines</span>
</span>
{/if}
</div>
{#if output}
<div class="panel">
<div class="panel-header">
<span>Result{op ? ` - ${buttons.find(b => b.id === op)?.label}` : ''}</span>
<div class="panel-actions">
<button onclick={() => copy(output)}>copy</button>
</div>
</div>
<pre class="output">{output}</pre>
</div>
{/if}
</div>
<style>
.layout {
display: flex;
flex-direction: column;
gap: 8px;
}
.panel {
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--bg-raised);
border: 1px solid var(--border);
border-bottom: none;
padding: 2px 6px;
font-size: 0.85em;
font-weight: bold;
}
.panel-actions {
display: flex;
gap: 4px;
}
textarea {
width: 100%;
box-sizing: border-box;
height: 35vh;
font-family: monospace;
font-size: 0.9em;
border: 1px solid var(--border);
padding: 6px;
resize: vertical;
outline: none;
background: var(--bg);
color: var(--fg);
}
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.ops {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.ops button.active {
background: var(--active-bg);
color: var(--active-fg);
border-color: var(--active-bg);
}
.multi-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.9em;
cursor: pointer;
}
.stats {
display: flex;
gap: 10px;
font-size: 0.85em;
color: var(--fg-faint);
margin-left: auto;
}
.output {
margin: 0;
border: 1px solid var(--border);
padding: 6px;
font-family: monospace;
font-size: 0.9em;
background: var(--bg-panel);
color: var(--fg);
white-space: pre-wrap;
word-break: break-all;
min-height: 3em;
}
</style>

View file

@ -0,0 +1,213 @@
<svelte:head>
<title>UUID Tools</title>
</svelte:head>
<script lang="js">
import { copy } from '$lib/clipboard.js';
// 100-ns intervals from 1582-10-15 to 1970-01-01
const GREGORIAN_OFFSET = 122192928000000000n;
/** @param {Uint8Array} bytes @returns {string} */
function bytesToUUID(bytes) {
const hex = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0,8)}-${hex.slice(8,12)}-${hex.slice(12,16)}-${hex.slice(16,20)}-${hex.slice(20)}`;
}
function uuidv1() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
const t = BigInt(Date.now()) * 10000n + GREGORIAN_OFFSET;
const tLow = Number(t & 0xffffffffn);
const tMid = Number((t >> 32n) & 0xffffn);
const tHi = Number((t >> 48n) & 0x0fffn);
bytes[0] = (tLow >> 24) & 0xff;
bytes[1] = (tLow >> 16) & 0xff;
bytes[2] = (tLow >> 8) & 0xff;
bytes[3] = tLow & 0xff;
bytes[4] = (tMid >> 8) & 0xff;
bytes[5] = tMid & 0xff;
bytes[6] = ((tHi >> 8) & 0x0f) | 0x10;
bytes[7] = tHi & 0xff;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
bytes[10] |= 0x01; // multicast bit → random node
return bytesToUUID(bytes);
}
function uuidv4() {
return crypto.randomUUID();
}
function uuidv6() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
const t = BigInt(Date.now()) * 10000n + GREGORIAN_OFFSET;
const tTop = t >> 12n;
bytes[0] = Number((tTop >> 40n) & 0xffn);
bytes[1] = Number((tTop >> 32n) & 0xffn);
bytes[2] = Number((tTop >> 24n) & 0xffn);
bytes[3] = Number((tTop >> 16n) & 0xffn);
bytes[4] = Number((tTop >> 8n) & 0xffn);
bytes[5] = Number( tTop & 0xffn);
bytes[6] = 0x60 | Number((t >> 8n) & 0x0fn);
bytes[7] = Number(t & 0xffn);
bytes[8] = (bytes[8] & 0x3f) | 0x80;
bytes[10] |= 0x01;
return bytesToUUID(bytes);
}
function uuidv7() {
const bytes = crypto.getRandomValues(new Uint8Array(16));
const ms = Date.now();
bytes[0] = (ms / 2**40) & 0xff;
bytes[1] = (ms / 2**32) & 0xff;
bytes[2] = (ms / 2**24) & 0xff;
bytes[3] = (ms / 2**16) & 0xff;
bytes[4] = (ms / 2**8) & 0xff;
bytes[5] = ms & 0xff;
bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
return bytesToUUID(bytes);
}
/**
* @typedef {{
* version: number,
* variant: string,
* timestamp?: number,
* date?: string,
* dateLocal?: string,
* randA?: string,
* randB?: string,
* clockSeq?: string,
* node?: string
* }} ParsedUUID
*/
/** @param {string} input @returns {ParsedUUID | { error: string }} */
function parseUUID(input) {
const s = input.trim().toLowerCase();
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(s)) {
return { error: 'Invalid UUID format (expected xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)' };
}
const hex = s.replace(/-/g, '');
const version = parseInt(hex[12], 16);
const variantNibble = parseInt(hex[16], 16);
let variant;
if ((variantNibble & 0x8) === 0) variant = 'NCS (0xxx)';
else if ((variantNibble & 0xc) === 0x8) variant = 'RFC 4122 (10xx)';
else if ((variantNibble & 0xe) === 0xc) variant = 'Microsoft (110x)';
else variant = 'Reserved (111x)';
/** @type {ParsedUUID} */
const result = { version, variant };
if (version === 7) {
const tsMs = parseInt(hex.slice(0, 12), 16);
const date = new Date(tsMs);
result.timestamp = tsMs;
result.date = date.toISOString();
result.dateLocal = date.toLocaleString();
result.randA = hex.slice(13, 16);
result.randB = hex.slice(16);
} else if (version === 1) {
const tHi = hex.slice(13, 16);
const tMid = hex.slice(8, 12);
const tLow = hex.slice(0, 8);
const t100ns = BigInt('0x' + tHi + tMid + tLow);
const unixMs = (t100ns - GREGORIAN_OFFSET) / 10000n;
const date = new Date(Number(unixMs));
result.timestamp = Number(unixMs);
result.date = date.toISOString();
result.dateLocal = date.toLocaleString();
result.clockSeq = hex.slice(16, 20);
result.node = hex.slice(20);
} else if (version === 6) {
const tHigh48 = hex.slice(0, 12);
const tLow12 = hex.slice(13, 16);
const t100ns = (BigInt('0x' + tHigh48) << 12n) | BigInt('0x' + tLow12);
const unixMs = (t100ns - GREGORIAN_OFFSET) / 10000n;
const date = new Date(Number(unixMs));
result.timestamp = Number(unixMs);
result.date = date.toISOString();
result.dateLocal = date.toLocaleString();
result.clockSeq = hex.slice(16, 20);
result.node = hex.slice(20);
}
return result;
}
// --- State ---
/** @type {{ label: string, uuid: string, gen: () => string }[]} */
let generators = $state([
{ label: 'v1', uuid: uuidv1(), gen: uuidv1 },
{ label: 'v4', uuid: uuidv4(), gen: uuidv4 },
{ label: 'v6', uuid: uuidv6(), gen: uuidv6 },
{ label: 'v7', uuid: uuidv7(), gen: uuidv7 },
]);
let parseInput = $state('');
const parseResult = $derived(parseInput.trim().length === 36 ? parseUUID(parseInput) : null);
const parsed = $derived(parseResult && !('error' in parseResult) ? parseResult : null);
const parseError = $derived(parseResult && 'error' in parseResult ? parseResult.error : '');
</script>
<h1>UUID</h1>
<h2>Generate</h2>
<table>
<tbody>
{#each generators as g}
<tr>
<td>{g.label}</td>
<td><code>{g.uuid}</code></td>
<td><button onclick={() => { g.uuid = g.gen(); }}>new</button></td>
<td><button onclick={() => copy(g.uuid)}>copy</button></td>
</tr>
{/each}
</tbody>
</table>
<hr>
<h2>Parse / Inspect</h2>
<p>
<input
type="text"
bind:value={parseInput}
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
size="38"
spellcheck="false"
>
</p>
{#if parseError}
<p class="error">{parseError}</p>
{/if}
{#if parsed}
<table>
<tbody>
<tr><td>Version</td><td><b>{parsed.version}</b></td><td></td></tr>
<tr><td>Variant</td><td>{parsed.variant}</td><td></td></tr>
{#if parsed.date}
<tr><td>Timestamp (ms)</td><td><code>{parsed.timestamp}</code></td><td><button onclick={() => copy(String(parsed.timestamp))}>copy</button></td></tr>
<tr><td>Date (UTC)</td><td><code>{parsed.date}</code></td><td><button onclick={() => copy(parsed.date ?? '')}>copy</button></td></tr>
<tr><td>Date (local)</td><td><code>{parsed.dateLocal}</code></td><td><button onclick={() => copy(parsed.dateLocal ?? '')}>copy</button></td></tr>
{/if}
{#if parsed.randA}
<tr><td>rand_a (12 bits)</td><td><code>{parsed.randA}</code></td><td></td></tr>
<tr><td>rand_b (62 bits)</td><td><code>{parsed.randB}</code></td><td></td></tr>
{/if}
{#if parsed.clockSeq}
<tr><td>Clock seq</td><td><code>{parsed.clockSeq}</code></td><td></td></tr>
<tr><td>Node</td><td><code>{parsed.node}</code></td><td><button onclick={() => copy(parsed.node ?? '')}>copy</button></td></tr>
{/if}
</tbody>
</table>
{/if}
<style>
.error { color: var(--err); }
</style>

1
static/favicon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

3
static/robots.txt Normal file
View file

@ -0,0 +1,3 @@
# allow crawling everything by default
User-agent: *
Disallow:

13
svelte.config.js Normal file
View file

@ -0,0 +1,13 @@
import adapter from '@sveltejs/adapter-node';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
adapter: adapter()
}
};
export default config;

6
vite.config.js Normal file
View file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});