mevebu 2.2.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  # mevebu
4
4
 
5
- **210 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
5
+ **220 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
6
6
 
7
7
  A private toolbox for the small jobs that do not deserve a whole app: format JSON, decode a JWT, make a password, build a cron line, pick a color, scan a QR code, and a lot more.
8
8
 
@@ -10,7 +10,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
10
10
  [![npm downloads](https://img.shields.io/npm/dm/mevebu?color=2ea44f)](https://www.npmjs.com/package/mevebu)
11
11
  [![License: MIT](https://img.shields.io/github/license/mevebu/mevebu?color=blue)](./LICENSE)
12
12
  [![GitHub stars](https://img.shields.io/github/stars/mevebu/mevebu?style=flat&color=yellow)](https://github.com/mevebu/mevebu/stargazers)
13
- ![Tools](https://img.shields.io/badge/tools-210-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-220-brightgreen)
14
14
  ![Privacy](https://img.shields.io/badge/privacy-100%25%20in--browser-blue)
15
15
 
16
16
  [Quick start](#quick-start) · [What's inside](#whats-inside) · [Privacy](#privacy-by-design) · [Credit](#credit) · [Development](#development) · [FAQ](#faq)
@@ -23,7 +23,7 @@ A private toolbox for the small jobs that do not deserve a whole app: format JSO
23
23
 
24
24
  ## What is mevebu?
25
25
 
26
- mevebu is a collection of **210 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
26
+ mevebu is a collection of **220 small online tools** that all run **inside your browser**. Each tool lives on its own page, does one job, and does it fast. Most of them update as you type, so there is no submit button and no waiting.
27
27
 
28
28
  Think of it as a clean, private alternative to the usual "free online tools" websites. There are no accounts, no ads in your way, no uploads, and no tracking. Your text, passwords, tokens, and files stay on your device.
29
29
 
@@ -64,13 +64,13 @@ mevebu tries port `5555` first, then `5678`, then `5432`, and finally any free p
64
64
 
65
65
  ## What's inside
66
66
 
67
- mevebu has **210 tools in 20 categories**. Here is the full breakdown:
67
+ mevebu has **220 tools in 20 categories**. Here is the full breakdown:
68
68
 
69
69
  | Category | Tools |
70
70
  | ------------------ | ----: |
71
71
  | Data Generator | 10 |
72
- | Passwords and keys | 15 |
73
- | Developer | 21 |
72
+ | Passwords and keys | 20 |
73
+ | Developer | 26 |
74
74
  | Convert | 15 |
75
75
  | Text and data | 14 |
76
76
  | Daily use | 8 |
@@ -88,7 +88,7 @@ mevebu has **210 tools in 20 categories**. Here is the full breakdown:
88
88
  | GitHub | 8 |
89
89
  | Units | 8 |
90
90
  | QR codes | 2 |
91
- | **Total** | **210** |
91
+ | **Total** | **220** |
92
92
 
93
93
  The counts come straight from the tool registry, so this table and the app never drift apart.
94
94
 
@@ -176,7 +176,7 @@ That is all. The catalog, search, breadcrumbs, and category pages pick it up aut
176
176
  ## FAQ
177
177
 
178
178
  **What is mevebu?**
179
- mevebu is a free, open source collection of 210 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
179
+ mevebu is a free, open source collection of 220 browser based tools for developers, students, creators, and everyday use. Every tool runs locally in your browser.
180
180
 
181
181
  **Is mevebu really free?**
182
182
  Yes. It is MIT licensed, with no paid tier, no account, and no usage limits.
@@ -578,6 +578,12 @@
578
578
  .min-h-24 {
579
579
  min-height: calc(var(--spacing) * 24);
580
580
  }
581
+ .min-h-28 {
582
+ min-height: calc(var(--spacing) * 28);
583
+ }
584
+ .min-h-32 {
585
+ min-height: calc(var(--spacing) * 32);
586
+ }
581
587
  .min-h-40 {
582
588
  min-height: calc(var(--spacing) * 40);
583
589
  }
@@ -1041,6 +1047,12 @@
1041
1047
  background-color: color-mix(in oklab, var(--danger) 10%, transparent);
1042
1048
  }
1043
1049
  }
1050
+ .bg-danger\/15 {
1051
+ background-color: var(--danger);
1052
+ @supports (color: color-mix(in lab, red, red)) {
1053
+ background-color: color-mix(in oklab, var(--danger) 15%, transparent);
1054
+ }
1055
+ }
1044
1056
  .bg-deck {
1045
1057
  background-color: var(--deck);
1046
1058
  }
@@ -750,7 +750,7 @@ export function blank(iconName, text) {
750
750
  // ---------- version ----------
751
751
 
752
752
  // Kept in one place. The CLI reads the same value from package.json.
753
- export const VERSION = "2.2.0";
753
+ export const VERSION = "2.4.0";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -0,0 +1,195 @@
1
+ // A small JavaScript scanner shared by the formatter and the minifier. It does
2
+ // not build an AST: it separates code from strings, templates, regexes and
3
+ // comments so whitespace can be changed without touching what a token means.
4
+
5
+ const KEYWORDS_BEFORE_REGEX = new Set([
6
+ "return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "do",
7
+ "else", "case", "yield", "await", "throw",
8
+ ]);
9
+
10
+ const PUNCT = [
11
+ ">>>=", "===", "!==", "**=", "<<=", ">>=", ">>>", "...", "&&=", "||=", "??=",
12
+ "=>", "==", "!=", "<=", ">=", "&&", "||", "??", "?.", "++", "--", "+=", "-=",
13
+ "*=", "/=", "%=", "&=", "|=", "^=", "<<", ">>", "**",
14
+ ];
15
+
16
+ const isWordStart = (ch) => /[A-Za-z_$\u0080-\uffff]/.test(ch);
17
+ const isWord = (ch) => /[A-Za-z0-9_$\u0080-\uffff]/.test(ch);
18
+ const isDigit = (ch) => /[0-9]/.test(ch);
19
+
20
+ function regexAllowed(previous) {
21
+ if (!previous) return true;
22
+ if (previous.type === "word") return KEYWORDS_BEFORE_REGEX.has(previous.value);
23
+ if (previous.type === "number" || previous.type === "string" || previous.type === "template" || previous.type === "regex") return false;
24
+ if (previous.type === "punct") return ![")", "]", "}"].includes(previous.value);
25
+ return true;
26
+ }
27
+
28
+ function scanTemplate(source, start) {
29
+ let i = start + 1;
30
+ while (i < source.length) {
31
+ const ch = source[i];
32
+ if (ch === "\\") { i += 2; continue; }
33
+ if (ch === "`") return { end: i + 1 };
34
+ if (ch === "$" && source[i + 1] === "{") {
35
+ const inner = scanBraces(source, i + 2);
36
+ if (inner.error) return inner;
37
+ i = inner.end;
38
+ continue;
39
+ }
40
+ i++;
41
+ }
42
+ return { error: "Unclosed template literal." };
43
+ }
44
+
45
+ function scanBraces(source, start) {
46
+ let depth = 1;
47
+ let i = start;
48
+ while (i < source.length) {
49
+ const ch = source[i];
50
+ if (ch === "'" || ch === '"') {
51
+ const end = scanString(source, i);
52
+ if (end.error) return end;
53
+ i = end.end;
54
+ continue;
55
+ }
56
+ if (ch === "`") {
57
+ const end = scanTemplate(source, i);
58
+ if (end.error) return end;
59
+ i = end.end;
60
+ continue;
61
+ }
62
+ if (ch === "/" && source[i + 1] === "/") {
63
+ const end = source.indexOf("\n", i);
64
+ i = end === -1 ? source.length : end;
65
+ continue;
66
+ }
67
+ if (ch === "/" && source[i + 1] === "*") {
68
+ const end = source.indexOf("*/", i + 2);
69
+ if (end === -1) return { error: "Unclosed block comment." };
70
+ i = end + 2;
71
+ continue;
72
+ }
73
+ if (ch === "{") depth++;
74
+ if (ch === "}") {
75
+ depth--;
76
+ if (depth === 0) return { end: i + 1 };
77
+ }
78
+ i++;
79
+ }
80
+ return { error: "Unclosed ${ in a template literal." };
81
+ }
82
+
83
+ function scanString(source, start) {
84
+ const quote = source[start];
85
+ let i = start + 1;
86
+ while (i < source.length) {
87
+ const ch = source[i];
88
+ if (ch === "\\") { i += 2; continue; }
89
+ if (ch === quote) return { end: i + 1 };
90
+ if (ch === "\n") return { error: "Unclosed string." };
91
+ i++;
92
+ }
93
+ return { error: "Unclosed string." };
94
+ }
95
+
96
+ function scanRegex(source, start) {
97
+ let i = start + 1;
98
+ let inClass = false;
99
+ while (i < source.length) {
100
+ const ch = source[i];
101
+ if (ch === "\\") { i += 2; continue; }
102
+ if (ch === "[") inClass = true;
103
+ else if (ch === "]") inClass = false;
104
+ else if (ch === "/" && !inClass) {
105
+ i++;
106
+ while (i < source.length && /[a-z]/i.test(source[i])) i++;
107
+ return { end: i };
108
+ } else if (ch === "\n") return { error: "Unclosed regular expression." };
109
+ i++;
110
+ }
111
+ return { error: "Unclosed regular expression." };
112
+ }
113
+
114
+ /**
115
+ * Split JavaScript into significant tokens. Whitespace is dropped, but each
116
+ * token remembers whether a newline came before it.
117
+ * @param {string} source
118
+ * @returns {{tokens: object[], error: string|null}}
119
+ */
120
+ export function tokenize(source) {
121
+ const tokens = [];
122
+ let i = 0;
123
+ let newline = false;
124
+ const push = (token) => {
125
+ token.newline = newline;
126
+ newline = false;
127
+ tokens.push(token);
128
+ };
129
+
130
+ while (i < source.length) {
131
+ const ch = source[i];
132
+ if (ch === "\n") { newline = true; i++; continue; }
133
+ if (/\s/.test(ch)) { i++; continue; }
134
+
135
+ if (ch === "/" && source[i + 1] === "/") {
136
+ const end = source.indexOf("\n", i);
137
+ const stop = end === -1 ? source.length : end;
138
+ push({ type: "lineComment", value: source.slice(i, stop) });
139
+ i = stop;
140
+ continue;
141
+ }
142
+ if (ch === "/" && source[i + 1] === "*") {
143
+ const end = source.indexOf("*/", i + 2);
144
+ if (end === -1) return { tokens, error: "Unclosed block comment." };
145
+ push({ type: "blockComment", value: source.slice(i, end + 2) });
146
+ i = end + 2;
147
+ continue;
148
+ }
149
+ if (ch === "'" || ch === '"') {
150
+ const result = scanString(source, i);
151
+ if (result.error) return { tokens, error: result.error };
152
+ push({ type: "string", value: source.slice(i, result.end) });
153
+ i = result.end;
154
+ continue;
155
+ }
156
+ if (ch === "`") {
157
+ const result = scanTemplate(source, i);
158
+ if (result.error) return { tokens, error: result.error };
159
+ push({ type: "template", value: source.slice(i, result.end) });
160
+ i = result.end;
161
+ continue;
162
+ }
163
+ if (ch === "/" && regexAllowed(tokens[tokens.length - 1])) {
164
+ const result = scanRegex(source, i);
165
+ if (result.error) return { tokens, error: result.error };
166
+ push({ type: "regex", value: source.slice(i, result.end) });
167
+ i = result.end;
168
+ continue;
169
+ }
170
+ if (isDigit(ch) || (ch === "." && isDigit(source[i + 1]))) {
171
+ const match = source.slice(i).match(/^(?:0[xX][0-9a-fA-F_]+|0[bB][01_]+|0[oO][0-7_]+|(?:\d[\d_]*)?\.?\d[\d_]*(?:[eE][+-]?\d[\d_]*)?n?)/);
172
+ const value = match ? match[0] : ch;
173
+ push({ type: "number", value });
174
+ i += value.length;
175
+ continue;
176
+ }
177
+ if (isWordStart(ch)) {
178
+ let end = i + 1;
179
+ while (end < source.length && isWord(source[end])) end++;
180
+ push({ type: "word", value: source.slice(i, end) });
181
+ i = end;
182
+ continue;
183
+ }
184
+ const multi = PUNCT.find((entry) => source.startsWith(entry, i));
185
+ const value = multi || ch;
186
+ push({ type: "punct", value });
187
+ i += value.length;
188
+ }
189
+
190
+ return { tokens, error: null };
191
+ }
192
+
193
+ export function bytes(text) {
194
+ return new TextEncoder().encode(text).length;
195
+ }
@@ -481,7 +481,187 @@ export const TOOLS = [
481
481
  keywords: "aes encrypt decrypt password gcm pbkdf2 secret message",
482
482
  load: () => import("./tools/aes-text.js"),
483
483
  },
484
+ {
485
+ id: "rsa-key-pair",
486
+ category: "security",
487
+ name: "RSA Key Pair",
488
+ icon: "key-round",
489
+ note: "Make an RSA key pair in PEM form.",
490
+ about: "Generated in this tab with Web Crypto. Public as SPKI, private as PKCS#8.",
491
+ tutorial: {
492
+ intro: "Generate an RSA key pair and copy or download the PEM files.",
493
+ steps: [
494
+ "Pick a key size. 2048 is the common floor.",
495
+ "Press Generate pair and wait a moment for larger keys.",
496
+ "Copy or download the public and private PEM. Closing the page discards them.",
497
+ ],
498
+ },
499
+ keywords: "rsa key pair pem public private ssh jwt tls generate spki pkcs8",
500
+ load: () => import("./tools/rsa-key-pair.js"),
501
+ },
502
+ {
503
+ id: "bcrypt-hash",
504
+ category: "security",
505
+ name: "Bcrypt Hash",
506
+ icon: "hash",
507
+ note: "Hash a password with bcrypt, and check one.",
508
+ about: "Bcrypt with a cost factor you set, run in this tab with a vendored engine.",
509
+ tutorial: {
510
+ intro: "Make a bcrypt hash for a test login, or check a password against one you already have.",
511
+ steps: [
512
+ "Type a password and pick a cost factor. 10 is a common default.",
513
+ "Press Hash password. The timing shows how long this device takes.",
514
+ "To check, paste a stored hash and the password. The verdict updates as you type.",
515
+ ],
516
+ },
517
+ keywords: "bcrypt hash password login test salt cost compare verify",
518
+ load: () => import("./tools/bcrypt-hash.js"),
519
+ },
520
+ {
521
+ id: "password-policy",
522
+ category: "security",
523
+ name: "Password Policy Builder",
524
+ icon: "list-checks",
525
+ note: "Turn a password rule into text, a regex, and a checklist.",
526
+ about: "The wording, the pattern, and a live check all come from the same settings.",
527
+ tutorial: {
528
+ intro: "Write a password policy once, in every form you need.",
529
+ steps: [
530
+ "Set the length and the character classes.",
531
+ "Read the rule in plain words and copy the regex.",
532
+ "Type a sample password to see which part it fails.",
533
+ ],
534
+ },
535
+ keywords: "password policy regex rule requirements checklist enforce",
536
+ load: () => import("./tools/password-policy.js"),
537
+ },
538
+ {
539
+ id: "secret-scanner",
540
+ category: "security",
541
+ name: "Secret Scanner",
542
+ icon: "shield-alert",
543
+ note: "Find API keys and tokens before you paste them.",
544
+ about: "Known shapes for AWS, GitHub, Slack, Stripe, Google, JWTs and private keys.",
545
+ tutorial: {
546
+ intro: "Catch a leaked key before it goes into a chat.",
547
+ steps: [
548
+ "Paste the text, or drop a file.",
549
+ "Read the matches with their line numbers. Values are masked by default.",
550
+ "Rotate anything listed. A clean result is a hint, not a guarantee.",
551
+ ],
552
+ },
553
+ keywords: "secret scanner leak api key token private key aws github slack stripe jwt detect",
554
+ load: () => import("./tools/secret-scanner.js"),
555
+ },
556
+ {
557
+ id: "ssh-fingerprint",
558
+ category: "security",
559
+ name: "SSH Fingerprint",
560
+ icon: "fingerprint",
561
+ note: "Read the fingerprint of an SSH public key.",
562
+ about: "The SHA-256 form a server prints, plus the older MD5 form for comparison.",
563
+ tutorial: {
564
+ intro: "Compare a host fingerprint before you trust it.",
565
+ steps: [
566
+ "Paste the public key line, for example ssh-ed25519 AAAAC3...",
567
+ "Read the fingerprint and compare it with the one the host shows.",
568
+ "Use the MD5 form only for an old server that still prints that.",
569
+ ],
570
+ },
571
+ keywords: "ssh fingerprint public key sha256 md5 host verify known_hosts",
572
+ load: () => import("./tools/ssh-fingerprint.js"),
573
+ },
484
574
 
575
+ {
576
+ id: "javascript-formatter",
577
+ category: "developer",
578
+ name: "JavaScript Formatter",
579
+ icon: "file-code",
580
+ note: "Indent a JavaScript file so it reads like code again.",
581
+ about: "Re-indents tokens and keeps strings, templates, regexes and comments as they were.",
582
+ tutorial: {
583
+ intro: "Paste a script and get the same code with readable indentation.",
584
+ steps: [
585
+ "Paste the script. Pick two or four spaces.",
586
+ "Read the first bracket or string problem if the script cannot be laid out.",
587
+ "Copy the result. Only whitespace changed, not the code.",
588
+ ],
589
+ },
590
+ keywords: "javascript js format beautify indent pretty print code",
591
+ load: () => import("./tools/javascript-formatter.js"),
592
+ },
593
+ {
594
+ id: "javascript-minifier",
595
+ category: "developer",
596
+ name: "JavaScript Minifier",
597
+ icon: "minimize-2",
598
+ note: "Strip comments and extra whitespace from JavaScript.",
599
+ about: "Shows the bytes before and after. Variables are never renamed.",
600
+ tutorial: {
601
+ intro: "Make a script smaller without changing what it does.",
602
+ steps: [
603
+ "Paste the script.",
604
+ "Read the size before and after, and the percentage saved.",
605
+ "Copy the result. Keep license comments with /*! or @license.",
606
+ ],
607
+ },
608
+ keywords: "javascript js minify compress whitespace comments size",
609
+ load: () => import("./tools/javascript-minifier.js"),
610
+ },
611
+ {
612
+ id: "yaml-to-json",
613
+ category: "developer",
614
+ name: "YAML to JSON",
615
+ icon: "braces",
616
+ note: "Turn a YAML document into JSON.",
617
+ about: "Anchors are resolved, comments are dropped, and the key order stays.",
618
+ tutorial: {
619
+ intro: "Convert a config file for an API or a script that expects JSON.",
620
+ steps: [
621
+ "Paste the YAML.",
622
+ "Read the JSON on the right. An invalid document reports its line.",
623
+ "Copy it or download data.json.",
624
+ ],
625
+ },
626
+ keywords: "yaml yml to json convert config kubernetes ci",
627
+ load: () => import("./tools/yaml-to-json.js"),
628
+ },
629
+ {
630
+ id: "json-to-yaml",
631
+ category: "developer",
632
+ name: "JSON to YAML",
633
+ icon: "file-text",
634
+ note: "Write JSON as block or flow YAML.",
635
+ about: "Two-space block style by default, with a flow toggle for compact output.",
636
+ tutorial: {
637
+ intro: "Turn an API response into a config file.",
638
+ steps: [
639
+ "Paste the JSON.",
640
+ "Pick Block or Flow. Block reads better, flow is shorter.",
641
+ "Copy the YAML or download data.yaml.",
642
+ ],
643
+ },
644
+ keywords: "json to yaml yml convert config block flow",
645
+ load: () => import("./tools/json-to-yaml.js"),
646
+ },
647
+ {
648
+ id: "xml-minifier",
649
+ category: "developer",
650
+ name: "XML Minifier",
651
+ icon: "code",
652
+ note: "Remove indentation between XML tags.",
653
+ about: "CDATA, comments and attribute values are copied unchanged.",
654
+ tutorial: {
655
+ intro: "Compact an XML file without touching its content.",
656
+ steps: [
657
+ "Paste the XML.",
658
+ "Read the size saved. Text that is not only whitespace stays.",
659
+ "Copy the result.",
660
+ ],
661
+ },
662
+ keywords: "xml minify compact whitespace cdata size",
663
+ load: () => import("./tools/xml-minifier.js"),
664
+ },
485
665
  {
486
666
  id: "uuid-generator",
487
667
  category: "developer",
@@ -0,0 +1,161 @@
1
+ // Bcrypt, for testing a login. The hash and the check both run in this tab with a
2
+ // vendored pure JavaScript build, so no password leaves the page. The cost factor
3
+ // is the point: each step doubles the work, and the timing shows it.
4
+ import {
5
+ el, toast, bench, stack, panel, textInput, numberInput, button, actions,
6
+ toolcard, notice, copyButton, output, chipRow,
7
+ } from "../core.js";
8
+
9
+ const COST_MIN = 4;
10
+ const COST_MAX = 14;
11
+
12
+ function bcryptLib() {
13
+ const lib = globalThis.dcodeIO?.bcrypt ?? globalThis.bcrypt;
14
+ if (!lib) throw new Error("The bcrypt engine did not load.");
15
+ return lib;
16
+ }
17
+
18
+ function loadEngine() {
19
+ if (globalThis.dcodeIO?.bcrypt || globalThis.bcrypt) return Promise.resolve(bcryptLib());
20
+ return new Promise((resolve, reject) => {
21
+ const script = document.createElement("script");
22
+ script.src = new URL("../vendor/bcrypt.min.js", import.meta.url).href;
23
+ script.onload = () => {
24
+ try {
25
+ resolve(bcryptLib());
26
+ } catch (caught) {
27
+ reject(caught);
28
+ }
29
+ };
30
+ script.onerror = () => reject(new Error("The bcrypt engine could not be loaded."));
31
+ document.head.append(script);
32
+ });
33
+ }
34
+
35
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
36
+
37
+ export function mount(slot) {
38
+ let hashed = "";
39
+ let busy = false;
40
+ let token = 0;
41
+
42
+ const password = textInput({
43
+ type: "password",
44
+ placeholder: "The password to hash",
45
+ "aria-label": "Password",
46
+ autocomplete: "new-password",
47
+ });
48
+ const show = el("input", { type: "checkbox", class: "m-0 size-4 accent-[var(--jade)]" });
49
+ const cost = numberInput({ min: String(COST_MIN), max: String(COST_MAX), step: "1", value: "10", "aria-label": "Cost factor" });
50
+ const costLabel = el("span", { class: "text-sm font-semibold text-ink-2 tabular-nums" }, "");
51
+
52
+ const existing = textInput({
53
+ placeholder: "$2b$10$...",
54
+ "aria-label": "Hash to check",
55
+ class: "font-mono text-sm",
56
+ autocomplete: "off",
57
+ spellcheck: "false",
58
+ });
59
+
60
+ const error = notice("warn");
61
+ const summary = el("p", { class: "text-sm text-ink-2" });
62
+ const hashOut = output({ rows: 3 });
63
+ const verdict = el("p", { class: "m-0 text-sm font-semibold" });
64
+
65
+ function syncCost() {
66
+ const value = Math.max(COST_MIN, Math.min(COST_MAX, Number(cost.value) || 10));
67
+ costLabel.textContent = `${value} · about ${2 ** value} rounds`;
68
+ }
69
+
70
+ async function hash() {
71
+ if (busy) return;
72
+ if (!password.value) {
73
+ error.write("Type a password first.");
74
+ return;
75
+ }
76
+ busy = true;
77
+ const current = ++token;
78
+ error.write("");
79
+ summary.textContent = "Hashing";
80
+ try {
81
+ const lib = await loadEngine();
82
+ const rounds = Math.max(COST_MIN, Math.min(COST_MAX, Number(cost.value) || 10));
83
+ await tick();
84
+ const started = performance.now();
85
+ const salt = lib.genSaltSync(rounds);
86
+ const result = lib.hashSync(password.value, salt);
87
+ const took = performance.now() - started;
88
+ if (current !== token) return;
89
+ hashed = result;
90
+ hashOut.write(result);
91
+ summary.textContent = `cost ${rounds} · ${Math.round(took)} ms on this device`;
92
+ toast("Hash ready");
93
+ } catch (caught) {
94
+ if (current !== token) return;
95
+ error.write(caught?.message || "The hash could not be made.");
96
+ } finally {
97
+ busy = false;
98
+ }
99
+ }
100
+
101
+ async function check() {
102
+ const stored = existing.value.trim();
103
+ verdict.textContent = "";
104
+ if (!stored) return;
105
+ if (!/^\$2[abxy]\$\d{2}\$.{53}$/.test(stored)) {
106
+ verdict.textContent = "That is not a bcrypt hash. It should start with $2b$ and be 60 characters.";
107
+ verdict.className = "m-0 text-sm font-semibold text-danger";
108
+ return;
109
+ }
110
+ if (!password.value) {
111
+ verdict.textContent = "Type the password to check against that hash.";
112
+ verdict.className = "m-0 text-sm font-semibold text-ink-2";
113
+ return;
114
+ }
115
+ try {
116
+ const lib = await loadEngine();
117
+ const ok = lib.compareSync(password.value, stored);
118
+ verdict.textContent = ok ? "Match: this password produces that hash." : "No match for that password.";
119
+ verdict.className = ok ? "m-0 text-sm font-semibold text-jade" : "m-0 text-sm font-semibold text-danger";
120
+ } catch (caught) {
121
+ verdict.textContent = caught?.message || "The check could not run.";
122
+ verdict.className = "m-0 text-sm font-semibold text-danger";
123
+ }
124
+ }
125
+
126
+ show.addEventListener("change", () => {
127
+ password.type = show.checked ? "text" : "password";
128
+ });
129
+ password.addEventListener("input", check);
130
+ existing.addEventListener("input", check);
131
+ cost.addEventListener("input", syncCost);
132
+
133
+ const copy = copyButton(() => hashed, "Copy hash");
134
+ const hashButton = button("Hash password", { icon: "hash", kind: "fill", onclick: hash });
135
+
136
+ syncCost();
137
+
138
+ const shell = toolcard({
139
+ body: bench(
140
+ stack(
141
+ panel("Password", password),
142
+ el("label", { class: "inline-flex min-h-9 cursor-pointer items-center gap-2 text-sm text-ink-2" }, show, "Show password"),
143
+ panel("Cost factor", cost, "Each step doubles the work. 10 is a common default; 12 is slower and safer."),
144
+ costLabel,
145
+ panel("Hash to check", existing, "Paste a stored hash to see whether the password matches it."),
146
+ verdict,
147
+ error
148
+ ),
149
+ stack(summary, hashOut,
150
+ el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
151
+ "Bcrypt is a password hash, not encryption. The salt is in the hash, so the same password gives a different result each time. Use the timing to pick a cost your server can afford."))
152
+ ),
153
+ foot: actions(copy, hashButton),
154
+ });
155
+
156
+ slot.append(shell.node);
157
+
158
+ return {};
159
+ }
160
+
161
+ export default { mount };