mevebu 2.3.0 → 2.5.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 **225 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 | 31 |
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** | **225** |
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.
@@ -3,8 +3,20 @@
3
3
  @layer theme, base, components, utilities;
4
4
  @layer theme {
5
5
  :root, :host {
6
+ --color-amber-300: oklch(87.9% 0.169 91.605);
7
+ --color-amber-400: oklch(82.8% 0.189 84.429);
8
+ --color-amber-500: oklch(76.9% 0.188 70.08);
9
+ --color-amber-600: oklch(66.6% 0.179 58.318);
10
+ --color-emerald-400: oklch(76.5% 0.177 163.223);
11
+ --color-emerald-500: oklch(69.6% 0.17 162.48);
12
+ --color-emerald-600: oklch(59.6% 0.145 163.225);
13
+ --color-rose-400: oklch(71.2% 0.194 13.428);
14
+ --color-rose-500: oklch(64.5% 0.246 16.439);
15
+ --color-rose-600: oklch(58.6% 0.253 17.585);
6
16
  --color-black: #000;
7
17
  --spacing: 0.25rem;
18
+ --text-xs: 0.75rem;
19
+ --text-xs--line-height: calc(1 / 0.75);
8
20
  --text-sm: 14px;
9
21
  --text-sm--line-height: 1.5;
10
22
  --text-base: 16px;
@@ -21,6 +33,7 @@
21
33
  --font-weight-semibold: 600;
22
34
  --font-weight-bold: 700;
23
35
  --leading-normal: 1.5;
36
+ --radius-xs: 0.125rem;
24
37
  --radius-lg: 0.5rem;
25
38
  --radius-xl: 0.75rem;
26
39
  --default-transition-duration: 150ms;
@@ -608,6 +621,9 @@
608
621
  .w-0 {
609
622
  width: 0px;
610
623
  }
624
+ .w-5 {
625
+ width: calc(var(--spacing) * 5);
626
+ }
611
627
  .w-24 {
612
628
  width: calc(var(--spacing) * 24);
613
629
  }
@@ -964,6 +980,9 @@
964
980
  .rounded-xl {
965
981
  border-radius: var(--radius-xl);
966
982
  }
983
+ .rounded-xs {
984
+ border-radius: var(--radius-xs);
985
+ }
967
986
  .border {
968
987
  border-style: var(--tw-border-style);
969
988
  border-width: 1px;
@@ -996,6 +1015,12 @@
996
1015
  --tw-border-style: dashed;
997
1016
  border-style: dashed;
998
1017
  }
1018
+ .border-amber-500\/40 {
1019
+ border-color: color-mix(in srgb, oklch(76.9% 0.188 70.08) 40%, transparent);
1020
+ @supports (color: color-mix(in lab, red, red)) {
1021
+ border-color: color-mix(in oklab, var(--color-amber-500) 40%, transparent);
1022
+ }
1023
+ }
999
1024
  .border-current\/15 {
1000
1025
  border-color: currentcolor;
1001
1026
  @supports (color: color-mix(in lab, red, red)) {
@@ -1011,6 +1036,12 @@
1011
1036
  border-color: color-mix(in oklab, var(--danger) 40%, transparent);
1012
1037
  }
1013
1038
  }
1039
+ .border-emerald-500\/40 {
1040
+ border-color: color-mix(in srgb, oklch(69.6% 0.17 162.48) 40%, transparent);
1041
+ @supports (color: color-mix(in lab, red, red)) {
1042
+ border-color: color-mix(in oklab, var(--color-emerald-500) 40%, transparent);
1043
+ }
1044
+ }
1014
1045
  .border-jade {
1015
1046
  border-color: var(--jade);
1016
1047
  }
@@ -1020,6 +1051,12 @@
1020
1051
  .border-orbit-hard {
1021
1052
  border-color: var(--orbit-hard);
1022
1053
  }
1054
+ .border-rose-500\/40 {
1055
+ border-color: color-mix(in srgb, oklch(64.5% 0.246 16.439) 40%, transparent);
1056
+ @supports (color: color-mix(in lab, red, red)) {
1057
+ border-color: color-mix(in oklab, var(--color-rose-500) 40%, transparent);
1058
+ }
1059
+ }
1023
1060
  .border-transparent {
1024
1061
  border-color: transparent;
1025
1062
  }
@@ -1035,6 +1072,12 @@
1035
1072
  .bg-\[var\(--scrim\)\] {
1036
1073
  background-color: var(--scrim);
1037
1074
  }
1075
+ .bg-amber-300\/60 {
1076
+ background-color: color-mix(in srgb, oklch(87.9% 0.169 91.605) 60%, transparent);
1077
+ @supports (color: color-mix(in lab, red, red)) {
1078
+ background-color: color-mix(in oklab, var(--color-amber-300) 60%, transparent);
1079
+ }
1080
+ }
1038
1081
  .bg-black {
1039
1082
  background-color: var(--color-black);
1040
1083
  }
@@ -1176,6 +1219,9 @@
1176
1219
  .px-0 {
1177
1220
  padding-inline: 0px;
1178
1221
  }
1222
+ .px-0\.5 {
1223
+ padding-inline: calc(var(--spacing) * 0.5);
1224
+ }
1179
1225
  .px-1 {
1180
1226
  padding-inline: var(--spacing);
1181
1227
  }
@@ -1400,6 +1446,10 @@
1400
1446
  font-size: var(--text-xl);
1401
1447
  line-height: var(--tw-leading, var(--text-xl--line-height));
1402
1448
  }
1449
+ .text-xs {
1450
+ font-size: var(--text-xs);
1451
+ line-height: var(--tw-leading, var(--text-xs--line-height));
1452
+ }
1403
1453
  .text-\[0\.92em\] {
1404
1454
  font-size: 0.92em;
1405
1455
  }
@@ -1513,9 +1563,18 @@
1513
1563
  .whitespace-pre-wrap {
1514
1564
  white-space: pre-wrap;
1515
1565
  }
1566
+ .text-amber-600 {
1567
+ color: var(--color-amber-600);
1568
+ }
1516
1569
  .text-danger {
1517
1570
  color: var(--danger);
1518
1571
  }
1572
+ .text-emerald-600 {
1573
+ color: var(--color-emerald-600);
1574
+ }
1575
+ .text-inherit {
1576
+ color: inherit;
1577
+ }
1519
1578
  .text-ink {
1520
1579
  color: var(--ink);
1521
1580
  }
@@ -1537,6 +1596,9 @@
1537
1596
  .text-orbit-hard {
1538
1597
  color: var(--orbit-hard);
1539
1598
  }
1599
+ .text-rose-600 {
1600
+ color: var(--color-rose-600);
1601
+ }
1540
1602
  .text-void {
1541
1603
  color: var(--void);
1542
1604
  }
@@ -1568,6 +1630,12 @@
1568
1630
  .underline {
1569
1631
  text-decoration-line: underline;
1570
1632
  }
1633
+ .decoration-dotted {
1634
+ text-decoration-style: dotted;
1635
+ }
1636
+ .underline-offset-2 {
1637
+ text-underline-offset: 2px;
1638
+ }
1571
1639
  .underline-offset-\[3px\] {
1572
1640
  text-underline-offset: 3px;
1573
1641
  }
@@ -2055,6 +2123,21 @@
2055
2123
  grid-template-columns: repeat(2, minmax(0, 1fr));
2056
2124
  }
2057
2125
  }
2126
+ .dark\:bg-amber-400\/40:where([data-theme="dark"], [data-theme="dark"] *) {
2127
+ background-color: color-mix(in srgb, oklch(82.8% 0.189 84.429) 40%, transparent);
2128
+ @supports (color: color-mix(in lab, red, red)) {
2129
+ background-color: color-mix(in oklab, var(--color-amber-400) 40%, transparent);
2130
+ }
2131
+ }
2132
+ .dark\:text-amber-400:where([data-theme="dark"], [data-theme="dark"] *) {
2133
+ color: var(--color-amber-400);
2134
+ }
2135
+ .dark\:text-emerald-400:where([data-theme="dark"], [data-theme="dark"] *) {
2136
+ color: var(--color-emerald-400);
2137
+ }
2138
+ .dark\:text-rose-400:where([data-theme="dark"], [data-theme="dark"] *) {
2139
+ color: var(--color-rose-400);
2140
+ }
2058
2141
  .\[\&_a\]\:text-jade a {
2059
2142
  color: var(--jade);
2060
2143
  }
@@ -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.5.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,186 @@ 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
+ },
665
+ {
666
+ id: "json-diff",
667
+ category: "developer",
668
+ name: "JSON Diff",
669
+ icon: "git-compare",
670
+ note: "Compare two JSON documents by path.",
671
+ about: "Lists every added, removed and changed path, with a count per kind.",
672
+ tutorial: {
673
+ intro: "See what changed between two JSON documents.",
674
+ steps: [
675
+ "Paste the old document and the new one.",
676
+ "Read the paths that were added, removed or changed.",
677
+ "Copy the report.",
678
+ ],
679
+ },
680
+ keywords: "json diff compare changes paths added removed",
681
+ load: () => import("./tools/json-diff.js"),
682
+ },
683
+ {
684
+ id: "json-path-tester",
685
+ category: "developer",
686
+ name: "JSONPath Tester",
687
+ icon: "text-search",
688
+ note: "Run a JSONPath expression on JSON.",
689
+ about: "Supports root, child, wildcard, index, slice and filter selectors.",
690
+ tutorial: {
691
+ intro: "Test a JSONPath expression against a document.",
692
+ steps: [
693
+ "Paste the JSON.",
694
+ "Type an expression such as $.items[?(@.price > 10)].name.",
695
+ "Read the matches and their paths.",
696
+ ],
697
+ },
698
+ keywords: "jsonpath json query path filter select expression",
699
+ load: () => import("./tools/json-path-tester.js"),
700
+ },
701
+ {
702
+ id: "cron-next-runs",
703
+ category: "developer",
704
+ name: "Cron Next Runs",
705
+ icon: "calendar-clock",
706
+ note: "See the next ten times a cron job runs.",
707
+ about: "Reads five fields and the @daily shortcuts, in your timezone or UTC.",
708
+ tutorial: {
709
+ intro: "Find out when a cron expression actually runs.",
710
+ steps: [
711
+ "Paste an expression such as */15 9-17 * * 1-5.",
712
+ "Pick your browser timezone or UTC.",
713
+ "The next ten runs appear as dates.",
714
+ ],
715
+ },
716
+ keywords: "cron schedule next runs crontab job time",
717
+ load: () => import("./tools/cron-next-runs.js"),
718
+ },
719
+ {
720
+ id: "html-to-jsx",
721
+ category: "developer",
722
+ name: "HTML to JSX",
723
+ icon: "code-xml",
724
+ note: "Convert an HTML fragment to JSX.",
725
+ about: "Renames attributes, turns inline styles into objects, and closes void tags.",
726
+ tutorial: {
727
+ intro: "Turn copied markup into a JSX fragment.",
728
+ steps: [
729
+ "Paste the HTML.",
730
+ "Read the JSX on the right.",
731
+ "Copy it into your component.",
732
+ ],
733
+ },
734
+ keywords: "html to jsx react convert className htmlFor style object",
735
+ load: () => import("./tools/html-to-jsx.js"),
736
+ },
737
+ {
738
+ id: "regex-cheatsheet",
739
+ category: "developer",
740
+ name: "Regex Cheatsheet",
741
+ icon: "book-open",
742
+ note: "Look up regex tokens with examples.",
743
+ about: "Every row shows the token, what it matches and a highlighted example.",
744
+ tutorial: {
745
+ intro: "Find the regex token you need.",
746
+ steps: [
747
+ "Type to filter the list.",
748
+ "Read the token and its example.",
749
+ "Open Regex Tester to try the full pattern.",
750
+ ],
751
+ },
752
+ keywords: "regex cheatsheet reference tokens syntax javascript",
753
+ load: () => import("./tools/regex-cheatsheet.js"),
754
+ },
575
755
  {
576
756
  id: "uuid-generator",
577
757
  category: "developer",