mevebu 2.3.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
- **215 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-215-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 **215 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 **215 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
72
  | Passwords and keys | 20 |
73
- | Developer | 21 |
73
+ | Developer | 26 |
74
74
  | Convert | 15 |
75
75
  | Text and data | 14 |
76
76
  | Daily use | 8 |
@@ -88,7 +88,7 @@ mevebu has **215 tools in 20 categories**. Here is the full breakdown:
88
88
  | GitHub | 8 |
89
89
  | Units | 8 |
90
90
  | QR codes | 2 |
91
- | **Total** | **215** |
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 215 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.
@@ -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.3.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
+ }
@@ -572,6 +572,96 @@ export const TOOLS = [
572
572
  load: () => import("./tools/ssh-fingerprint.js"),
573
573
  },
574
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
+ },
575
665
  {
576
666
  id: "uuid-generator",
577
667
  category: "developer",
@@ -0,0 +1,182 @@
1
+ // Formats JavaScript by re-indenting its tokens. Strings, templates, regexes
2
+ // and comments are kept byte for byte.
3
+ import { el, bench, stack, panel, textArea, notice, chipRow, copyButton, actions, toolcard } from "../core.js";
4
+ import { tokenize } from "../js-tokens.js";
5
+
6
+ const CONTROL = new Set(["if", "for", "while", "switch", "catch", "with"]);
7
+ const BINARY = new Set([
8
+ "=", "+", "-", "*", "/", "%", "**", "==", "===", "!=", "!==", "<", ">", "<=", ">=",
9
+ "&&", "||", "??", "&", "|", "^", "<<", ">>", ">>>", "+=", "-=", "*=", "/=", "%=",
10
+ "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>=", "&&=", "||=", "??=", "=>",
11
+ ]);
12
+ const NO_SPACE_BEFORE = new Set([")", "]", ",", ";", ".", "?.", "++", "--"]);
13
+ const VALUE_TYPES = new Set(["word", "number", "string", "template", "regex"]);
14
+
15
+ function bracketError(tokens) {
16
+ const stack = [];
17
+ const pairs = { ")": "(", "]": "[", "}": "{" };
18
+ for (const token of tokens) {
19
+ if (token.type !== "punct") continue;
20
+ if ("([{".includes(token.value)) stack.push(token.value);
21
+ else if (")]}".includes(token.value)) {
22
+ if (stack.pop() !== pairs[token.value]) return `Unexpected ${token.value}.`;
23
+ }
24
+ }
25
+ return stack.length ? `Unclosed ${stack[stack.length - 1]}.` : null;
26
+ }
27
+
28
+ function format(source, size) {
29
+ const { tokens, error } = tokenize(source);
30
+ if (error) return { error };
31
+ const balance = bracketError(tokens);
32
+ if (balance) return { error: balance };
33
+
34
+ const pad = " ".repeat(size);
35
+ const lines = [];
36
+ let line = "";
37
+ let indent = 0;
38
+ let paren = 0;
39
+
40
+ const flush = () => {
41
+ if (line.trim()) lines.push(pad.repeat(indent) + line.trimEnd());
42
+ line = "";
43
+ };
44
+ const add = (text, space = false) => {
45
+ if (space && line && !line.endsWith(" ")) line += " ";
46
+ line += text;
47
+ };
48
+ const nextOf = (index) => tokens[index + 1] ?? null;
49
+
50
+ tokens.forEach((token, index) => {
51
+ const prev = tokens[index - 1] ?? null;
52
+ const next = nextOf(index);
53
+ const value = token.value;
54
+
55
+ if (token.type === "lineComment") {
56
+ add(value, Boolean(line));
57
+ flush();
58
+ return;
59
+ }
60
+ if (token.type === "blockComment") {
61
+ add(value, Boolean(line));
62
+ if (value.includes("\n")) flush();
63
+ return;
64
+ }
65
+
66
+ if (token.type === "punct") {
67
+ if (value === "{") {
68
+ const space = prev && (
69
+ prev.type === "word" || prev.type === "number" || prev.type === "string" ||
70
+ prev.type === "template" || prev.value === ")" || prev.value === "]"
71
+ );
72
+ add("{", space);
73
+ flush();
74
+ indent++;
75
+ return;
76
+ }
77
+ if (value === "}") {
78
+ flush();
79
+ indent = Math.max(0, indent - 1);
80
+ line = "}";
81
+ const keep = next && (
82
+ next.value === ";" || next.value === "," || next.value === ")" ||
83
+ next.value === "]" || next.value === "else" || next.value === "catch" ||
84
+ next.value === "finally" || next.value === "while"
85
+ );
86
+ if (!keep) flush();
87
+ return;
88
+ }
89
+ if (value === ";") {
90
+ add(";");
91
+ if (paren === 0) flush();
92
+ return;
93
+ }
94
+ if (value === "(") {
95
+ const control = prev && prev.type === "word" && CONTROL.has(prev.value);
96
+ add("(", control);
97
+ paren++;
98
+ return;
99
+ }
100
+ if (value === ")") {
101
+ add(")");
102
+ paren = Math.max(0, paren - 1);
103
+ return;
104
+ }
105
+ if (value === "[") { add("["); return; }
106
+ if (value === "]") { add("]"); return; }
107
+ if (value === ",") { add(","); add(" "); return; }
108
+ if (value === ":") {
109
+ add(":");
110
+ add(" ");
111
+ if (line.trimStart().startsWith("case ") || line.trimStart().startsWith("default")) flush();
112
+ return;
113
+ }
114
+ if (value === "?") { add(" ? "); return; }
115
+ if (value === "." || value === "?." || value === "++" || value === "--" || value === "!" || value === "~" || value === "...") {
116
+ add(value);
117
+ return;
118
+ }
119
+ if (BINARY.has(value)) {
120
+ add(` ${value} `);
121
+ return;
122
+ }
123
+ add(value);
124
+ return;
125
+ }
126
+
127
+ const needsSpace = prev && (
128
+ VALUE_TYPES.has(prev.type) || prev.value === ")" || prev.value === "]" || prev.value === "}"
129
+ );
130
+ add(value, needsSpace);
131
+ });
132
+
133
+ flush();
134
+ return { text: lines.join("\n") };
135
+ }
136
+
137
+ export function mount(slot) {
138
+ const source = textArea({
139
+ rows: 14,
140
+ "aria-label": "JavaScript",
141
+ value: "const greet=(name)=>{if(!name)return 'hello';return `hello ${name}`};\n// keep this comment\nconst list=[1,2,3].map(n=>n*2);",
142
+ });
143
+ const result = textArea({ rows: 14, readonly: true, "aria-label": "Formatted JavaScript" });
144
+ const error = notice("error");
145
+ let size = "2";
146
+
147
+ const chips = chipRow(
148
+ [{ value: "2", label: "2 spaces" }, { value: "4", label: "4 spaces" }],
149
+ { value: "2", label: "Indent", onChange: (value) => { size = value; run(); } },
150
+ );
151
+
152
+ function run() {
153
+ error.write("");
154
+ if (!source.value.trim()) {
155
+ result.value = "";
156
+ return;
157
+ }
158
+ const output = format(source.value, Number(size));
159
+ if (output.error) {
160
+ result.value = "";
161
+ error.write(output.error);
162
+ return;
163
+ }
164
+ result.value = output.text;
165
+ }
166
+
167
+ source.addEventListener("input", run);
168
+
169
+ const card = toolcard({
170
+ body: bench(
171
+ stack(panel("Indent", chips), panel("JavaScript", source), error),
172
+ stack(panel("Formatted", result)),
173
+ ),
174
+ foot: actions(copyButton(() => result.value, "Copy JavaScript", "fill")),
175
+ });
176
+ slot.append(card.node);
177
+ run();
178
+
179
+ return {};
180
+ }
181
+
182
+ export default { mount };
@@ -0,0 +1,111 @@
1
+ // Removes comments and redundant whitespace from JavaScript, without renaming
2
+ // anything or changing the meaning of a statement.
3
+ import { el, bench, stack, panel, textArea, output, notice, copyButton, actions, toolcard } from "../core.js";
4
+ import { tokenize, bytes } from "../js-tokens.js";
5
+
6
+ const VALUE_TYPES = new Set(["word", "number", "string", "template", "regex"]);
7
+ const ASI_WORDS = new Set(["return", "throw", "break", "continue", "yield"]);
8
+ const RISKY_START = new Set(["(", "[", "`", "+", "-", "/"]);
9
+
10
+ const tile = (key, value) =>
11
+ el("div", { class: "grid gap-0.5 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" },
12
+ el("span", { class: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, value),
13
+ el("span", { class: "text-2xs font-semibold text-ink-3" }, key));
14
+
15
+ function keepComment(value) {
16
+ return value.startsWith("/*!") || value.startsWith("//!") || /@license|@preserve/.test(value);
17
+ }
18
+
19
+ function minify(source) {
20
+ const { tokens, error } = tokenize(source);
21
+ if (error) return { error };
22
+
23
+ let out = "";
24
+ let previous = null;
25
+
26
+ const emit = (text) => { out += text; };
27
+
28
+ tokens.forEach((token) => {
29
+ if (token.type === "lineComment" || token.type === "blockComment") {
30
+ if (keepComment(token.value)) {
31
+ if (out && !out.endsWith("\n")) emit("\n");
32
+ emit(token.value);
33
+ if (token.type === "lineComment") emit("\n");
34
+ }
35
+ return;
36
+ }
37
+
38
+ if (previous) {
39
+ const newline = token.newline && (
40
+ (previous.type === "word" && ASI_WORDS.has(previous.value)) ||
41
+ (RISKY_START.has(token.value) && (
42
+ VALUE_TYPES.has(previous.type) || [")", "]", "}", "++", "--"].includes(previous.value)
43
+ ))
44
+ );
45
+ if (newline) {
46
+ emit("\n");
47
+ emit(token.value);
48
+ previous = token;
49
+ return;
50
+ }
51
+
52
+ const wordPair = VALUE_TYPES.has(previous.type) && VALUE_TYPES.has(token.type);
53
+ const plusPair = ["+", "++"].includes(previous.value) && ["+", "++"].includes(token.value);
54
+ const minusPair = ["-", "--"].includes(previous.value) && ["-", "--"].includes(token.value);
55
+ const commentStart = previous.value === "/" && ["/", "*"].includes(token.value);
56
+ if (wordPair || plusPair || minusPair || commentStart) emit(" ");
57
+ }
58
+
59
+ emit(token.value);
60
+ previous = token;
61
+ });
62
+
63
+ return { text: out.trim() };
64
+ }
65
+
66
+ export function mount(slot) {
67
+ const source = textArea({
68
+ rows: 14,
69
+ "aria-label": "JavaScript",
70
+ value: "// greeting\nconst greet = (name) => {\n if (!name) return 'hello';\n return `hello ${name}`;\n};\n\nconst list = [1, 2, 3].map((n) => n * 2);",
71
+ });
72
+ const result = output({ tag: "textarea", rows: 12 });
73
+ const stats = el("div", { class: "grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))]" });
74
+ const error = notice("error");
75
+
76
+ function run() {
77
+ error.write("");
78
+ stats.replaceChildren();
79
+ if (!source.value.trim()) {
80
+ result.value = "";
81
+ return;
82
+ }
83
+ const output = minify(source.value);
84
+ if (output.error) {
85
+ result.value = "";
86
+ error.write(output.error);
87
+ return;
88
+ }
89
+ const before = bytes(source.value);
90
+ const after = bytes(output.text);
91
+ const saved = before ? Math.max(0, Math.round((1 - after / before) * 100)) : 0;
92
+ result.value = output.text;
93
+ stats.append(tile("Before", `${before} B`), tile("After", `${after} B`), tile("Saved", `${saved}%`));
94
+ }
95
+
96
+ source.addEventListener("input", run);
97
+
98
+ const card = toolcard({
99
+ body: bench(
100
+ stack(panel("JavaScript", source), error),
101
+ stack(panel("Minified", result), stats),
102
+ ),
103
+ foot: actions(copyButton(() => result.value, "Copy result", "fill")),
104
+ });
105
+ slot.append(card.node);
106
+ run();
107
+
108
+ return {};
109
+ }
110
+
111
+ export default { mount };