mevebu 2.4.0 → 2.5.1

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
- **220 free browser tools, one page each. No sign up. No uploads. Everything runs in your tab.**
5
+ **225 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-220-brightgreen)
13
+ ![Tools](https://img.shields.io/badge/tools-225-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 **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.
26
+ mevebu is a collection of **225 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,33 +64,33 @@ 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 **220 tools in 20 categories**. Here is the full breakdown:
68
-
69
- | Category | Tools |
70
- | ------------------ | ----: |
71
- | Data Generator | 10 |
72
- | Passwords and keys | 20 |
73
- | Developer | 26 |
74
- | Convert | 15 |
75
- | Text and data | 14 |
76
- | Daily use | 8 |
77
- | Math | 17 |
78
- | Date and time | 7 |
79
- | Data and lists | 8 |
80
- | Design | 10 |
81
- | Web and SEO | 12 |
82
- | Encoding | 8 |
83
- | Academic | 15 |
84
- | Study | 7 |
85
- | Business | 10 |
86
- | Locale | 7 |
87
- | Content | 8 |
88
- | GitHub | 8 |
89
- | Units | 8 |
90
- | QR codes | 2 |
91
- | **Total** | **220** |
92
-
93
- The counts come straight from the tool registry, so this table and the app never drift apart.
67
+ mevebu has **225 tools in 20 categories**. Here is the full breakdown:
68
+
69
+ | Category | A few tools | Count |
70
+ | --- | --- | ---: |
71
+ | [Data Generator](https://github.com/mevebu/mevebu/blob/main/docs/tools/data-gen.md) | Team Generator, Roster Rotation, Sample CSV | 10 |
72
+ | [Passwords and keys](https://github.com/mevebu/mevebu/blob/main/docs/tools/security.md) | Password Generator, JWT Generator, Hash Generator | 20 |
73
+ | [Developer](https://github.com/mevebu/mevebu/blob/main/docs/tools/developer.md) | JavaScript Formatter, JavaScript Minifier, YAML to JSON | 31 |
74
+ | [Convert](https://github.com/mevebu/mevebu/blob/main/docs/tools/convert.md) | Base64, URL Encoder, Case Converter | 15 |
75
+ | [Text and data](https://github.com/mevebu/mevebu/blob/main/docs/tools/text.md) | Lorem Ipsum, Test Data, Word Counter | 14 |
76
+ | [Daily use](https://github.com/mevebu/mevebu/blob/main/docs/tools/daily.md) | Countdown Timer, Stopwatch, Checklist | 8 |
77
+ | [Math](https://github.com/mevebu/mevebu/blob/main/docs/tools/math.md) | Percentage Calculator, Percentage Change, Discount Calculator | 17 |
78
+ | [Date and time](https://github.com/mevebu/mevebu/blob/main/docs/tools/datetime.md) | Date Difference, Add or Subtract Days, Business Days | 7 |
79
+ | [Data and lists](https://github.com/mevebu/mevebu/blob/main/docs/tools/data.md) | CSV Viewer, CSV to JSON, JSON to CSV | 8 |
80
+ | [Design](https://github.com/mevebu/mevebu/blob/main/docs/tools/design.md) | Color Picker, Color Palette, Gradient Generator | 10 |
81
+ | [Web and SEO](https://github.com/mevebu/mevebu/blob/main/docs/tools/seo.md) | Meta Tags, Open Graph Tags, Robots.txt | 12 |
82
+ | [Academic](https://github.com/mevebu/mevebu/blob/main/docs/tools/academic.md) | APA 7 Reference, MLA Citation, IEEE Reference | 15 |
83
+ | [Study](https://github.com/mevebu/mevebu/blob/main/docs/tools/education.md) | Flashcard Creator, Quiz Formatter, Grade Calculator | 7 |
84
+ | [Business](https://github.com/mevebu/mevebu/blob/main/docs/tools/business.md) | Cost per Unit, Selling Price, Break-Even Point | 10 |
85
+ | [Encoding](https://github.com/mevebu/mevebu/blob/main/docs/tools/encoding.md) | Unicode Inspector, UTF-8 Byte Counter, HTML to Text | 8 |
86
+ | [Locale](https://github.com/mevebu/mevebu/blob/main/docs/tools/locale.md) | Reading Time, Readability, Sentence Length | 7 |
87
+ | [Content](https://github.com/mevebu/mevebu/blob/main/docs/tools/content.md) | Caption Counter, Hashtag Formatter, Line Break Formatter | 8 |
88
+ | [GitHub](https://github.com/mevebu/mevebu/blob/main/docs/tools/git.md) | README Generator, Gitignore Generator, Semantic Version | 8 |
89
+ | [Units](https://github.com/mevebu/mevebu/blob/main/docs/tools/measure.md) | Length Converter, Weight Converter, Temperature Converter | 8 |
90
+ | [QR codes](https://github.com/mevebu/mevebu/blob/main/docs/tools/qr.md) | QR Generator, QR Scanner | 2 |
91
+ | **Total** | **20 categories** | **225** |
92
+
93
+ The counts and the category pages come straight from the tool registry. Run `npm run docs` after adding a tool, so this table, the pages under `docs/tools/`, and the app never drift apart.
94
94
 
95
95
  ### Popular tools
96
96
 
@@ -128,7 +128,7 @@ Do not just take our word for it. Open the Network tab in your browser dev tools
128
128
 
129
129
  ## Credit
130
130
 
131
- mevebu is built and maintained by the [mevebu](https://github.com/mevebu) organization, created by [Ilyas Mukhlisin](https://www.instagram.com/ilyas.mukhlisin). Two repositories live there: **Mevebu**, this toolbox, and **Waitsec**, a set of guardrails for coding agents.
131
+ mevebu is built and maintained by the [mevebu](https://github.com/mevebu) organization, created by [Ilyas Mukhlisin](https://github.com/ilyasmukhlisin). Two repositories live there: **Mevebu**, this toolbox, and **Waitsec**, a set of guardrails for coding agents.
132
132
 
133
133
  The code is MIT licensed. If you learn from it, borrow a helper, or ship something inspired by it, keep the copyright notice and say where it came from. A link back to [github.com/mevebu/mevebu](https://github.com/mevebu/mevebu) is enough.
134
134
 
@@ -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 220 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 225 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.4.0";
753
+ export const VERSION = "2.5.1";
754
754
  export const PACKAGE_NAME = "mevebu";
755
755
  export const REPO_SLUG = "mevebu/mevebu";
756
756
 
@@ -662,6 +662,96 @@ export const TOOLS = [
662
662
  keywords: "xml minify compact whitespace cdata size",
663
663
  load: () => import("./tools/xml-minifier.js"),
664
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
+ },
665
755
  {
666
756
  id: "uuid-generator",
667
757
  category: "developer",
@@ -0,0 +1,179 @@
1
+ // Lists the next ten times a five-field cron expression fires, in the browser
2
+ // timezone or UTC.
3
+ import { el, bench, stack, panel, textInput, notice, chipRow, copyButton, actions, toolcard } from "../core.js";
4
+
5
+ const MONTHS = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"];
6
+ const DAYS = ["SUN", "MON", "TUE", "WED", "THU", "FRI", "SAT"];
7
+ const SPECIAL = {
8
+ "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *",
9
+ "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *",
10
+ "@hourly": "0 * * * *",
11
+ };
12
+
13
+ function valueOf(raw, names, min, max) {
14
+ const text = raw.toUpperCase();
15
+ if (names && names.includes(text)) return names.indexOf(text) + (names === MONTHS ? 1 : 0);
16
+ if (!/^\d+$/.test(text)) throw new Error(`Unknown value ${raw}.`);
17
+ const value = Number(text);
18
+ if (value < min || value > max) {
19
+ throw new Error(`Value ${raw} is out of range.`);
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function parseField(raw, min, max, names = null) {
25
+ if (raw === "*") {
26
+ const set = new Set();
27
+ for (let value = min; value <= max; value++) set.add(value);
28
+ return { set, restricted: false };
29
+ }
30
+ const set = new Set();
31
+ for (const part of raw.split(",")) {
32
+ const [range, stepText] = part.split("/");
33
+ const step = stepText === undefined ? 1 : Number(stepText);
34
+ if (!Number.isInteger(step) || step < 1) throw new Error(`Bad step in ${part}.`);
35
+ let from;
36
+ let to;
37
+ if (range === "*") {
38
+ from = min;
39
+ to = max;
40
+ } else if (range.includes("-")) {
41
+ const [a, b] = range.split("-");
42
+ from = valueOf(a, names, min, max);
43
+ to = valueOf(b, names, min, max);
44
+ } else {
45
+ from = valueOf(range, names, min, max);
46
+ to = stepText === undefined ? from : max;
47
+ }
48
+ if (from > to) throw new Error(`Range ${range} runs backwards.`);
49
+ for (let value = from; value <= to; value += step) {
50
+ set.add(names === DAYS && value === 7 ? 0 : value);
51
+ }
52
+ }
53
+ return { set, restricted: true };
54
+ }
55
+
56
+ function compile(expression) {
57
+ const raw = SPECIAL[expression.trim().toLowerCase()] ?? expression.trim();
58
+ const fields = raw.split(/\s+/);
59
+ if (fields.length !== 5) throw new Error("A cron expression needs exactly five fields.");
60
+ return {
61
+ minute: parseField(fields[0], 0, 59),
62
+ hour: parseField(fields[1], 0, 23),
63
+ day: parseField(fields[2], 1, 31),
64
+ month: parseField(fields[3], 1, 12, MONTHS),
65
+ weekday: parseField(fields[4], 0, 6, DAYS),
66
+ };
67
+ }
68
+
69
+ function nextRuns(expression, timezone, count = 10) {
70
+ const cron = compile(expression);
71
+ const utc = timezone === "utc";
72
+ const get = (date, key) => date[utc ? `getUTC${key}` : `get${key}`]();
73
+ const hours = [...cron.hour.set].sort((a, b) => a - b);
74
+ const minutes = [...cron.minute.set].sort((a, b) => a - b);
75
+ const results = [];
76
+ const start = new Date();
77
+ const cursor = new Date(start.getTime());
78
+ if (utc) cursor.setUTCHours(0, 0, 0, 0);
79
+ else cursor.setHours(0, 0, 0, 0);
80
+
81
+ for (let day = 0; day < 3660 && results.length < count; day++) {
82
+ const month = get(cursor, "Month") + 1;
83
+ const dayOfMonth = get(cursor, "Date");
84
+ const weekday = get(cursor, "Day");
85
+ const dayMatch = cron.day.set.has(dayOfMonth);
86
+ const weekdayMatch = cron.weekday.set.has(weekday);
87
+ const dayOk = cron.day.restricted && cron.weekday.restricted
88
+ ? dayMatch || weekdayMatch
89
+ : dayMatch && weekdayMatch;
90
+ if (dayOk && cron.month.set.has(month)) {
91
+ const year = get(cursor, "FullYear");
92
+ for (const hour of hours) {
93
+ for (const minute of minutes) {
94
+ const candidate = utc
95
+ ? new Date(Date.UTC(year, month - 1, dayOfMonth, hour, minute))
96
+ : new Date(year, month - 1, dayOfMonth, hour, minute);
97
+ if (candidate <= start) continue;
98
+ results.push(candidate);
99
+ if (results.length === count) break;
100
+ }
101
+ if (results.length === count) break;
102
+ }
103
+ }
104
+ if (utc) cursor.setUTCDate(cursor.getUTCDate() + 1);
105
+ else cursor.setDate(cursor.getDate() + 1);
106
+ }
107
+ if (!results.length) throw new Error("No run found in the next ten years.");
108
+ return results;
109
+ }
110
+
111
+ export function mount(slot) {
112
+ const expression = textInput({ value: "*/15 9-17 * * 1-5", "aria-label": "Cron expression", class: "font-mono" });
113
+ const error = notice("error");
114
+ const list = el("div", { class: "grid gap-1.5" });
115
+ let timezone = "local";
116
+
117
+ const chips = chipRow(
118
+ [{ value: "local", label: "Browser" }, { value: "utc", label: "UTC" }],
119
+ { value: "local", label: "Timezone", onChange: (value) => { timezone = value; run(); } },
120
+ );
121
+
122
+ const presets = chipRow(
123
+ [
124
+ { value: "*/15 * * * *", label: "Every 15 min" },
125
+ { value: "0 * * * *", label: "Hourly" },
126
+ { value: "0 0 * * *", label: "Daily" },
127
+ { value: "0 9 * * 1-5", label: "Weekdays 9:00" },
128
+ { value: "0 0 * * 1", label: "Weekly" },
129
+ ],
130
+ { label: "Presets", onChange: (value) => { if (!value) return; expression.value = value; run(); } },
131
+ );
132
+
133
+ const formatter = () => new Intl.DateTimeFormat(undefined, {
134
+ timeZone: timezone === "utc" ? "UTC" : undefined,
135
+ weekday: "short", year: "numeric", month: "short", day: "2-digit",
136
+ hour: "2-digit", minute: "2-digit", hour12: false,
137
+ });
138
+
139
+ let runs = [];
140
+
141
+ function run() {
142
+ error.write("");
143
+ runs = [];
144
+ if (!expression.value.trim()) {
145
+ list.replaceChildren();
146
+ return;
147
+ }
148
+ try {
149
+ runs = nextRuns(expression.value, timezone);
150
+ } catch (caught) {
151
+ error.write(caught.message);
152
+ list.replaceChildren();
153
+ return;
154
+ }
155
+ const format = formatter();
156
+ list.replaceChildren(...runs.map((date, index) =>
157
+ el("div", { class: "flex items-baseline gap-3 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-2.5" },
158
+ el("span", { class: "w-5 flex-none text-2xs font-bold tabular-nums text-ink-3" }, String(index + 1)),
159
+ el("span", { class: "font-mono text-sm" }, format.format(date)))));
160
+ }
161
+
162
+ expression.addEventListener("input", () => { presets.select(""); run(); });
163
+
164
+ const report = () => runs.map((date) => formatter().format(date)).join("\n");
165
+
166
+ const card = toolcard({
167
+ body: bench(
168
+ stack(panel("Expression", expression), panel("Presets", presets), panel("Timezone", chips), error),
169
+ stack(panel("Next runs", list)),
170
+ ),
171
+ foot: actions(copyButton(report, "Copy times", "fill")),
172
+ });
173
+ slot.append(card.node);
174
+ run();
175
+
176
+ return {};
177
+ }
178
+
179
+ export default { mount };
@@ -0,0 +1,144 @@
1
+ // Converts an HTML fragment to JSX: class to className, for to htmlFor, inline
2
+ // styles to objects, and void tags self closed.
3
+ import { bench, stack, panel, textArea, output, notice, copyButton, actions, toolcard } from "../core.js";
4
+
5
+ const EXAMPLE = '<section class="card" style="padding: 12px; background-color: #fff">\n <h2 for="title">Hello</h2>\n <img src="logo.png" alt="">\n <input type="text" disabled>\n</section>';
6
+
7
+ const VOID = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"]);
8
+ const BOOLEAN = new Set(["allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "default", "defer", "disabled", "hidden", "loop", "multiple", "muted", "novalidate", "open", "readonly", "required", "reversed", "selected"]);
9
+ const SPECIAL = {
10
+ class: "className", for: "htmlFor", tabindex: "tabIndex", readonly: "readOnly",
11
+ maxlength: "maxLength", minlength: "minLength", colspan: "colSpan", rowspan: "rowSpan",
12
+ contenteditable: "contentEditable", autocomplete: "autoComplete", autofocus: "autoFocus",
13
+ enctype: "encType", srcset: "srcSet", crossorigin: "crossOrigin", datetime: "dateTime",
14
+ novalidate: "noValidate", spellcheck: "spellCheck", formaction: "formAction",
15
+ allowfullscreen: "allowFullScreen", usemap: "useMap", frameborder: "frameBorder",
16
+ accesskey: "accessKey", charset: "charSet", cellpadding: "cellPadding", cellspacing: "cellSpacing",
17
+ hreflang: "hrefLang", referrerpolicy: "referrerPolicy",
18
+ inputmode: "inputMode", enterkeyhint: "enterKeyHint", autoplay: "autoPlay",
19
+ };
20
+
21
+ const camel = (name) => {
22
+ if (name.startsWith("data-") || name.startsWith("aria-")) return name;
23
+ return SPECIAL[name] ?? name.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
24
+ };
25
+
26
+ const escapeAttribute = (value) => value.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
27
+ const escapeText = (value) => value
28
+ .replace(/&/g, "&amp;")
29
+ .replace(/</g, "&lt;")
30
+ .replace(/>/g, "&gt;")
31
+ .replace(/[{}]/g, (brace) => (brace === "{" ? "{'{'}" : "{'}'}"));
32
+
33
+ function styleObject(text) {
34
+ const parts = text.split(";").map((part) => part.trim()).filter(Boolean);
35
+ const pairs = parts.map((part) => {
36
+ const index = part.indexOf(":");
37
+ if (index === -1) return null;
38
+ const name = part.slice(0, index).trim().replace(/-([a-z])/g, (_, letter) => letter.toUpperCase());
39
+ const value = part.slice(index + 1).trim();
40
+ return `${name}: ${JSON.stringify(value)}`;
41
+ }).filter(Boolean);
42
+ return `{ ${pairs.join(", ")} }`;
43
+ }
44
+
45
+ function attributes(node) {
46
+ const output = [];
47
+ for (const attribute of node.attributes) {
48
+ const name = attribute.name;
49
+ const lower = name.toLowerCase();
50
+ if (BOOLEAN.has(lower) && (attribute.value === "" || attribute.value === lower)) {
51
+ output.push(camel(lower));
52
+ continue;
53
+ }
54
+ if (lower === "style") {
55
+ output.push(`style={${styleObject(attribute.value)}}`);
56
+ continue;
57
+ }
58
+ output.push(`${camel(name)}="${escapeAttribute(attribute.value)}"`);
59
+ }
60
+ return output;
61
+ }
62
+
63
+ function serializeChildren(node, depth) {
64
+ const pad = " ".repeat(depth);
65
+ return Array.from(node.childNodes).map((child) => serializeNode(child, depth, pad)).filter(Boolean);
66
+ }
67
+
68
+ function serializeNode(node, depth, pad = " ".repeat(depth)) {
69
+ if (node.nodeType === Node.TEXT_NODE) {
70
+ const text = node.textContent.replace(/\s+/g, " ").trim();
71
+ return text ? `${pad}${escapeText(text)}` : "";
72
+ }
73
+ if (node.nodeType === Node.COMMENT_NODE) {
74
+ return `${pad}{/* ${node.textContent.trim()} */}`;
75
+ }
76
+ if (node.nodeType !== Node.ELEMENT_NODE) return "";
77
+
78
+ const html = node.namespaceURI === "http://www.w3.org/1999/xhtml";
79
+ const name = html ? node.tagName.toLowerCase() : node.tagName;
80
+ const attrs = attributes(node);
81
+ const open = `<${name}${attrs.length ? ` ${attrs.join(" ")}` : ""}`;
82
+
83
+ if (VOID.has(name.toLowerCase())) return `${pad}${open} />`;
84
+
85
+ const children = Array.from(node.childNodes);
86
+ if (!children.length) return `${pad}${open} />`;
87
+
88
+ const onlyText = children.every((child) => child.nodeType === Node.TEXT_NODE);
89
+ if (onlyText) {
90
+ const text = children.map((child) => child.textContent.replace(/\s+/g, " ").trim()).filter(Boolean).join(" ");
91
+ return text ? `${pad}${open}>${escapeText(text)}</${name}>` : `${pad}${open} />`;
92
+ }
93
+
94
+ const body = serializeChildren(node, depth + 1);
95
+ return `${pad}${open}>\n${body.join("\n")}\n${pad}</${name}>`;
96
+ }
97
+
98
+ function convert(source) {
99
+ const document = new DOMParser().parseFromString(`<body>${source}</body>`, "text/html");
100
+ const roots = Array.from(document.body.childNodes).filter((node) =>
101
+ node.nodeType === Node.ELEMENT_NODE || (node.nodeType === Node.TEXT_NODE && node.textContent.trim()) || node.nodeType === Node.COMMENT_NODE);
102
+ if (!roots.length) return { text: "" };
103
+ if (roots.length === 1 && roots[0].nodeType === Node.ELEMENT_NODE) {
104
+ return { text: serializeNode(roots[0], 0, "") };
105
+ }
106
+ const body = roots.map((node) => serializeNode(node, 1)).join("\n");
107
+ return { text: `<>\n${body}\n</>` };
108
+ }
109
+
110
+ export function mount(slot) {
111
+ const source = textArea({ rows: 14, "aria-label": "HTML", value: EXAMPLE });
112
+ const result = output({ tag: "textarea", rows: 14 });
113
+ const error = notice("error");
114
+
115
+ function run() {
116
+ error.write("");
117
+ if (!source.value.trim()) {
118
+ result.value = "";
119
+ return;
120
+ }
121
+ try {
122
+ result.value = convert(source.value).text;
123
+ } catch (caught) {
124
+ result.value = "";
125
+ error.write(caught.message);
126
+ }
127
+ }
128
+
129
+ source.addEventListener("input", run);
130
+
131
+ const card = toolcard({
132
+ body: bench(
133
+ stack(panel("HTML", source), error),
134
+ stack(panel("JSX", result)),
135
+ ),
136
+ foot: actions(copyButton(() => result.value, "Copy JSX", "fill")),
137
+ });
138
+ slot.append(card.node);
139
+ run();
140
+
141
+ return {};
142
+ }
143
+
144
+ export default { mount };
@@ -0,0 +1,111 @@
1
+ // Compares two JSON documents by path, so a reformatted file does not look
2
+ // like a rewrite.
3
+ import { el, bench, stack, panel, textArea, notice, copyButton, actions, toolcard } from "../core.js";
4
+
5
+ const EXAMPLE_OLD = '{\n "name": "mevebu",\n "tools": 220,\n "tags": ["offline", "browser"]\n}';
6
+ const EXAMPLE_NEW = '{\n "name": "mevebu",\n "tools": 225,\n "tags": ["offline", "browser", "free"],\n "license": "MIT"\n}';
7
+
8
+ const kindLabel = { added: "Added", removed: "Removed", changed: "Changed" };
9
+
10
+ function isObject(value) {
11
+ return value !== null && typeof value === "object" && !Array.isArray(value);
12
+ }
13
+
14
+ function walk(oldValue, newValue, path, output) {
15
+ if (Array.isArray(oldValue) && Array.isArray(newValue)) {
16
+ const length = Math.max(oldValue.length, newValue.length);
17
+ for (let index = 0; index < length; index++) {
18
+ const child = `${path}[${index}]`;
19
+ if (index >= oldValue.length) output.push({ kind: "added", path: child, after: newValue[index] });
20
+ else if (index >= newValue.length) output.push({ kind: "removed", path: child, before: oldValue[index] });
21
+ else walk(oldValue[index], newValue[index], child, output);
22
+ }
23
+ return;
24
+ }
25
+ if (isObject(oldValue) && isObject(newValue)) {
26
+ const keys = new Set([...Object.keys(oldValue), ...Object.keys(newValue)]);
27
+ for (const key of keys) {
28
+ const child = path ? `${path}.${key}` : key;
29
+ if (!(key in newValue)) output.push({ kind: "removed", path: child, before: oldValue[key] });
30
+ else if (!(key in oldValue)) output.push({ kind: "added", path: child, after: newValue[key] });
31
+ else walk(oldValue[key], newValue[key], child, output);
32
+ }
33
+ return;
34
+ }
35
+ if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
36
+ output.push({ kind: "changed", path, before: oldValue, after: newValue });
37
+ }
38
+ }
39
+
40
+ const show = (value) => {
41
+ if (value === undefined) return "";
42
+ const text = JSON.stringify(value);
43
+ return text.length > 80 ? `${text.slice(0, 77)}...` : text;
44
+ };
45
+
46
+ export function mount(slot) {
47
+ const before = textArea({ rows: 10, "aria-label": "Old JSON", value: EXAMPLE_OLD });
48
+ const after = textArea({ rows: 10, "aria-label": "New JSON", value: EXAMPLE_NEW });
49
+ const error = notice("error");
50
+ const list = el("div", { class: "grid gap-2" });
51
+ const summary = el("div", { class: "flex flex-wrap gap-2 text-2xs font-semibold" });
52
+ let rows = [];
53
+
54
+ function run() {
55
+ error.write("");
56
+ rows = [];
57
+ if (!before.value.trim() || !after.value.trim()) {
58
+ list.replaceChildren(el("p", { class: "text-sm text-ink-3" }, "Paste both documents."));
59
+ summary.replaceChildren();
60
+ return;
61
+ }
62
+ let oldValue;
63
+ let newValue;
64
+ try {
65
+ oldValue = JSON.parse(before.value);
66
+ newValue = JSON.parse(after.value);
67
+ } catch (caught) {
68
+ list.replaceChildren();
69
+ summary.replaceChildren();
70
+ error.write(caught.message);
71
+ return;
72
+ }
73
+ walk(oldValue, newValue, "$", rows);
74
+ const counts = rows.reduce((total, row) => ({ ...total, [row.kind]: (total[row.kind] ?? 0) + 1 }), {});
75
+ summary.replaceChildren(...["added", "removed", "changed"].map((kind) =>
76
+ el("span", { class: `rounded-full border px-2.5 py-1 ${kind === "added" ? "border-emerald-500/40 text-emerald-600 dark:text-emerald-400" : kind === "removed" ? "border-rose-500/40 text-rose-600 dark:text-rose-400" : "border-amber-500/40 text-amber-600 dark:text-amber-400"}` },
77
+ `${counts[kind] ?? 0} ${kind}`)));
78
+ if (!rows.length) {
79
+ list.replaceChildren(el("p", { class: "text-sm text-ink-2" }, "No differences."));
80
+ return;
81
+ }
82
+ list.replaceChildren(...rows.map((row) =>
83
+ el("div", { class: "grid gap-1 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" },
84
+ el("div", { class: "flex flex-wrap items-baseline gap-2" },
85
+ el("span", { class: "rounded-full border border-orbit px-2 py-0.5 text-2xs font-semibold text-ink-2" }, kindLabel[row.kind]),
86
+ el("code", { class: "wrap-anywhere text-sm font-semibold" }, row.path)),
87
+ el("p", { class: "wrap-anywhere font-mono text-xs text-ink-2" },
88
+ row.kind === "added" ? show(row.after)
89
+ : row.kind === "removed" ? show(row.before)
90
+ : `${show(row.before)} -> ${show(row.after)}`))));
91
+ }
92
+
93
+ before.addEventListener("input", run);
94
+ after.addEventListener("input", run);
95
+
96
+ const report = () => rows.map((row) => `${kindLabel[row.kind]}: ${row.path}`).join("\n");
97
+
98
+ const card = toolcard({
99
+ body: bench(
100
+ stack(panel("Old", before), panel("New", after), error),
101
+ stack(panel("Changes", el("div", { class: "grid gap-2" }, summary, list))),
102
+ ),
103
+ foot: actions(copyButton(report, "Copy report", "fill")),
104
+ });
105
+ slot.append(card.node);
106
+ run();
107
+
108
+ return {};
109
+ }
110
+
111
+ export default { mount };
@@ -0,0 +1,215 @@
1
+ // Runs a documented JSONPath subset against a document. No library, no
2
+ // network: the parser and the evaluator live here.
3
+ import { el, bench, stack, panel, textArea, textInput, notice, copyButton, actions, toolcard } from "../core.js";
4
+
5
+ const EXAMPLE = '{\n "items": [\n { "name": "pen", "price": 2 },\n { "name": "lamp", "price": 18 },\n { "name": "desk", "price": 120 }\n ],\n "meta": { "currency": "USD" }\n}';
6
+
7
+ function tokenizePath(expr) {
8
+ if (!expr.startsWith("$")) throw new Error("Start with $.");
9
+ const steps = [];
10
+ let i = 1;
11
+ while (i < expr.length) {
12
+ const ch = expr[i];
13
+ if (ch === ".") {
14
+ if (expr[i + 1] === ".") {
15
+ let end = i + 2;
16
+ while (end < expr.length && /[\w$-]/.test(expr[end])) end++;
17
+ const name = expr.slice(i + 2, end);
18
+ if (!name) throw new Error("Expected a name after ..");
19
+ steps.push({ kind: "descend", name });
20
+ i = end;
21
+ } else {
22
+ let end = i + 1;
23
+ if (expr[end] === "*") {
24
+ steps.push({ kind: "wildcard" });
25
+ i = end + 1;
26
+ continue;
27
+ }
28
+ while (end < expr.length && /[\w$-]/.test(expr[end])) end++;
29
+ const name = expr.slice(i + 1, end);
30
+ if (!name) throw new Error("Expected a name after .");
31
+ steps.push({ kind: "child", name });
32
+ i = end;
33
+ }
34
+ continue;
35
+ }
36
+ if (ch === "[") {
37
+ let end = i + 1;
38
+ let quote = null;
39
+ while (end < expr.length) {
40
+ const c = expr[end];
41
+ if (quote) { if (c === quote) quote = null; }
42
+ else if (c === "'" || c === '"') quote = c;
43
+ else if (c === "]") break;
44
+ end++;
45
+ }
46
+ if (end >= expr.length) throw new Error("Expected ].");
47
+ const body = expr.slice(i + 1, end).trim();
48
+ if (body === "*") steps.push({ kind: "wildcard" });
49
+ else if (/^['"]/.test(body)) steps.push({ kind: "child", name: body.slice(1, -1) });
50
+ else if (body.startsWith("?(") && body.endsWith(")")) steps.push({ kind: "filter", test: body.slice(2, -1).trim() });
51
+ else if (/^-?\d+$/.test(body)) steps.push({ kind: "index", index: Number(body) });
52
+ else if (/^-?\d*:-?\d*$/.test(body)) {
53
+ const [from, to] = body.split(":");
54
+ steps.push({ kind: "slice", from: from === "" ? null : Number(from), to: to === "" ? null : Number(to) });
55
+ } else throw new Error(`Unknown selector [${body}].`);
56
+ i = end + 1;
57
+ continue;
58
+ }
59
+ throw new Error(`Unexpected ${ch}.`);
60
+ }
61
+ return steps;
62
+ }
63
+
64
+ function readField(value, path) {
65
+ const parts = path.split(".").filter(Boolean);
66
+ let current = value;
67
+ for (const part of parts) {
68
+ if (current === null || typeof current !== "object") return undefined;
69
+ current = current[part];
70
+ }
71
+ return current;
72
+ }
73
+
74
+ function makeTest(source) {
75
+ const match = source.match(/^@((?:\.[\w$-]+)+)\s*(?:(>=|<=|==|!=|>|<)\s*(.+))?$/);
76
+ if (!match) throw new Error("Filter must look like @.price > 10.");
77
+ const [, path, operator, raw] = match;
78
+ let expected;
79
+ if (operator) {
80
+ const text = raw.trim();
81
+ if (/^-?\d+(?:\.\d+)?$/.test(text)) expected = Number(text);
82
+ else if (text === "true" || text === "false") expected = text === "true";
83
+ else if (text === "null") expected = null;
84
+ else expected = text.replace(/^['"]|['"]$/g, "");
85
+ }
86
+ return (value) => {
87
+ const actual = readField(value, path.slice(1));
88
+ if (!operator) return Boolean(actual);
89
+ if (actual === undefined) return false;
90
+ if (operator === "==") return actual === expected;
91
+ if (operator === "!=") return actual !== expected;
92
+ if (operator === ">") return actual > expected;
93
+ if (operator === "<") return actual < expected;
94
+ if (operator === ">=") return actual >= expected;
95
+ return actual <= expected;
96
+ };
97
+ }
98
+
99
+ function select(step, entries) {
100
+ const output = [];
101
+ for (const entry of entries) {
102
+ const value = entry.value;
103
+ const path = entry.path;
104
+ if (step.kind === "child") {
105
+ if (value !== null && typeof value === "object" && step.name in value) {
106
+ output.push({ path: `${path}.${step.name}`, value: value[step.name] });
107
+ }
108
+ } else if (step.kind === "index") {
109
+ if (Array.isArray(value)) {
110
+ const index = step.index < 0 ? value.length + step.index : step.index;
111
+ if (index >= 0 && index < value.length) output.push({ path: `${path}[${index}]`, value: value[index] });
112
+ }
113
+ } else if (step.kind === "slice") {
114
+ if (Array.isArray(value)) {
115
+ const from = step.from ?? 0;
116
+ const to = step.to ?? value.length;
117
+ const start = from < 0 ? Math.max(value.length + from, 0) : from;
118
+ value.slice(from, to).forEach((item, offset) =>
119
+ output.push({ path: `${path}[${start + offset}]`, value: item }));
120
+ }
121
+ } else if (step.kind === "wildcard") {
122
+ if (Array.isArray(value)) value.forEach((item, index) => output.push({ path: `${path}[${index}]`, value: item }));
123
+ else if (value !== null && typeof value === "object") {
124
+ Object.entries(value).forEach(([key, item]) => output.push({ path: `${path}.${key}`, value: item }));
125
+ }
126
+ } else if (step.kind === "filter") {
127
+ const test = makeTest(step.test);
128
+ if (Array.isArray(value)) value.forEach((item, index) => {
129
+ if (item !== null && typeof item === "object" && test(item)) output.push({ path: `${path}[${index}]`, value: item });
130
+ });
131
+ } else if (step.kind === "descend") {
132
+ const visit = (node, at) => {
133
+ if (node === null || typeof node !== "object") return;
134
+ for (const [key, item] of Object.entries(node)) {
135
+ const childPath = Array.isArray(node) ? `${at}[${key}]` : `${at}.${key}`;
136
+ if (step.name === "*" || key === step.name) output.push({ path: childPath, value: item });
137
+ visit(item, childPath);
138
+ }
139
+ };
140
+ visit(value, path);
141
+ }
142
+ }
143
+ return output;
144
+ }
145
+
146
+ const show = (value) => {
147
+ const text = JSON.stringify(value) ?? String(value);
148
+ return text.length > 90 ? `${text.slice(0, 87)}...` : text;
149
+ };
150
+
151
+ export function mount(slot) {
152
+ const source = textArea({ rows: 13, "aria-label": "JSON", value: EXAMPLE });
153
+ const expression = textInput({ value: "$.items[?(@.price > 10)].name", "aria-label": "JSONPath", class: "font-mono" });
154
+ const error = notice("error");
155
+ const list = el("div", { class: "grid gap-2" });
156
+ const count = el("p", { class: "text-sm text-ink-3" });
157
+ let results = [];
158
+
159
+ function run() {
160
+ error.write("");
161
+ results = [];
162
+ if (!source.value.trim() || !expression.value.trim()) {
163
+ list.replaceChildren();
164
+ count.textContent = "";
165
+ return;
166
+ }
167
+ let document;
168
+ try {
169
+ document = JSON.parse(source.value);
170
+ } catch (caught) {
171
+ error.write(caught.message);
172
+ list.replaceChildren();
173
+ count.textContent = "";
174
+ return;
175
+ }
176
+ try {
177
+ let entries = [{ path: "$", value: document }];
178
+ for (const step of tokenizePath(expression.value.trim())) entries = select(step, entries);
179
+ results = entries;
180
+ } catch (caught) {
181
+ error.write(caught.message);
182
+ list.replaceChildren();
183
+ count.textContent = "";
184
+ return;
185
+ }
186
+ count.textContent = `${results.length} ${results.length === 1 ? "match" : "matches"}`;
187
+ if (!results.length) {
188
+ list.replaceChildren(el("p", { class: "text-sm text-ink-2" }, "No match."));
189
+ return;
190
+ }
191
+ list.replaceChildren(...results.map((row) =>
192
+ el("div", { class: "grid gap-1 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" },
193
+ el("code", { class: "wrap-anywhere text-sm font-semibold" }, row.path),
194
+ el("p", { class: "wrap-anywhere font-mono text-xs text-ink-2" }, show(row.value)))));
195
+ }
196
+
197
+ source.addEventListener("input", run);
198
+ expression.addEventListener("input", run);
199
+
200
+ const report = () => results.map((row) => `${row.path}: ${JSON.stringify(row.value)}`).join("\n");
201
+
202
+ const card = toolcard({
203
+ body: bench(
204
+ stack(panel("JSON", source), panel("JSONPath", expression), error),
205
+ stack(panel("Matches", el("div", { class: "grid gap-2" }, count, list))),
206
+ ),
207
+ foot: actions(copyButton(report, "Copy matches", "fill")),
208
+ });
209
+ slot.append(card.node);
210
+ run();
211
+
212
+ return {};
213
+ }
214
+
215
+ export default { mount };
@@ -0,0 +1,100 @@
1
+ // A searchable reference for JavaScript regular expressions. Each row shows
2
+ // the token, what it matches, and an example with the match highlighted.
3
+ import { el, bench, stack, panel, textInput, toolcard } from "../core.js";
4
+
5
+ const ROWS = [
6
+ { token: ".", meaning: "Any character except a newline.", pattern: "a.c", example: "abc adc a\nc" },
7
+ { token: "\\d", meaning: "A digit, 0 to 9.", pattern: "\\d+", example: "Order 42 shipped" },
8
+ { token: "\\D", meaning: "Anything that is not a digit.", pattern: "\\D+", example: "42 and 7" },
9
+ { token: "\\w", meaning: "A letter, digit or underscore.", pattern: "\\w+", example: "hello_world!" },
10
+ { token: "\\W", meaning: "Anything that is not a word character.", pattern: "\\W+", example: "a-b c" },
11
+ { token: "\\s", meaning: "A space, tab or line break.", pattern: "\\s", example: "a b\tc" },
12
+ { token: "\\S", meaning: "Anything that is not whitespace.", pattern: "\\S+", example: " keep this" },
13
+ { token: "^", meaning: "The start of the string, or of a line with m.", pattern: "^abc", example: "abc abc" },
14
+ { token: "$", meaning: "The end of the string, or of a line with m.", pattern: "abc$", example: "abc abc" },
15
+ { token: "*", meaning: "Zero or more of the previous token.", pattern: "ab*", example: "a ab abb" },
16
+ { token: "+", meaning: "One or more of the previous token.", pattern: "ab+", example: "a ab abb" },
17
+ { token: "?", meaning: "Zero or one of the previous token.", pattern: "colou?r", example: "color colour" },
18
+ { token: "{n,m}", meaning: "Between n and m repeats.", pattern: "\\d{2,3}", example: "5 42 1234" },
19
+ { token: "[abc]", meaning: "One character from the set.", pattern: "[aeiou]", example: "mevebu" },
20
+ { token: "[^abc]", meaning: "One character outside the set.", pattern: "[^aeiou]", example: "mevebu" },
21
+ { token: "[a-z]", meaning: "One character from a range.", pattern: "[a-c]+", example: "abcxyz" },
22
+ { token: "(...)", meaning: "A capturing group.", pattern: "(\\d{4})-(\\d{2})", example: "2026-10-20" },
23
+ { token: "(?:...)", meaning: "A group that is not captured.", pattern: "(?:cat|dog)s", example: "cats dogs" },
24
+ { token: "(?<name>...)", meaning: "A named capturing group.", pattern: "(?<year>\\d{4})", example: "in 2026" },
25
+ { token: "\\b", meaning: "A word boundary.", pattern: "\\bcat\\b", example: "cat scatter cat" },
26
+ { token: "|", meaning: "Either the left or the right side.", pattern: "cat|dog", example: "cat dog cow" },
27
+ { token: "\\.", meaning: "A literal dot, escaped.", pattern: "\\d+\\.\\d+", example: "3.14 and 42" },
28
+ { token: "(?=...)", meaning: "Lookahead: what comes next.", pattern: "\\d+(?= USD)", example: "10 USD 20 EUR" },
29
+ { token: "(?<=...)", meaning: "Lookbehind: what came before.", pattern: "(?<=\\$)\\d+", example: "$5 and €7" },
30
+ { token: "*?", meaning: "Lazy: match as little as possible.", pattern: "<.+?>", example: "<a><b>" },
31
+ { token: "i", meaning: "Ignore case. A flag, not a token.", pattern: "[a-z]+", flags: "gi", example: "AbC abc" },
32
+ { token: "g", meaning: "Find every match, not just the first.", pattern: "\\d+", flags: "g", example: "1 22 333" },
33
+ { token: "m", meaning: "Make ^ and $ work per line.", pattern: "^\\w+", flags: "gm", example: "one\ntwo" },
34
+ { token: "s", meaning: "Let . match a newline too.", pattern: "a.b", flags: "s", example: "a\nb" },
35
+ { token: "u", meaning: "Unicode mode, for full code points.", pattern: "\\u{1F600}", flags: "u", example: "hi \u{1F600}" },
36
+ ];
37
+
38
+ function highlight(example, pattern, flags = "") {
39
+ let regex;
40
+ try {
41
+ regex = new RegExp(pattern, flags.includes("g") ? flags : `${flags}g`);
42
+ } catch {
43
+ return [document.createTextNode(example)];
44
+ }
45
+ const nodes = [];
46
+ let last = 0;
47
+ for (const match of example.matchAll(regex)) {
48
+ if (match.index > last) nodes.push(document.createTextNode(example.slice(last, match.index)));
49
+ if (!match[0]) break;
50
+ nodes.push(el("mark", { class: "rounded-xs bg-amber-300/60 px-0.5 text-inherit dark:bg-amber-400/40" }, match[0]));
51
+ last = match.index + match[0].length;
52
+ }
53
+ if (last < example.length) nodes.push(document.createTextNode(example.slice(last)));
54
+ return nodes;
55
+ }
56
+
57
+ export function mount(slot) {
58
+ const search = textInput({ placeholder: "Filter tokens", "aria-label": "Filter" });
59
+ const list = el("div", { class: "grid gap-2" });
60
+
61
+ function render() {
62
+ const query = search.value.trim().toLowerCase();
63
+ const rows = ROWS.filter((row) =>
64
+ !query || row.token.toLowerCase().includes(query) || row.meaning.toLowerCase().includes(query));
65
+ if (!rows.length) {
66
+ list.replaceChildren(el("p", { class: "text-sm text-ink-2" }, "No token matches that."));
67
+ return;
68
+ }
69
+ list.replaceChildren(...rows.map((row) =>
70
+ el("div", { class: "grid gap-1.5 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" },
71
+ el("div", { class: "flex flex-wrap items-baseline gap-2" },
72
+ el("code", { class: "rounded-xs border border-orbit bg-deck px-1.5 py-0.5 font-mono text-sm font-semibold" }, row.token),
73
+ el("span", { class: "text-sm text-ink-2" }, row.meaning)),
74
+ el("p", { class: "font-mono text-sm" },
75
+ ...highlight(row.example, row.pattern, row.flags)),
76
+ el("div", { class: "flex flex-wrap items-center gap-2 text-xs text-ink-3" },
77
+ el("code", { class: "font-mono" }, `/${row.pattern}/${row.flags ?? ""}`),
78
+ el("a", { href: "#/developer/regex-tester", class: "underline decoration-dotted underline-offset-2 hover:text-ink-2" }, "Try it")))));
79
+ }
80
+
81
+ search.addEventListener("input", render);
82
+
83
+ const link = el("p", { class: "text-sm text-ink-3" },
84
+ "Matches are JavaScript flavoured. Open ",
85
+ el("a", { href: "#/developer/regex-tester", class: "underline decoration-dotted underline-offset-2 hover:text-ink-2" }, "Regex Tester"),
86
+ " to run a full pattern.");
87
+
88
+ const card = toolcard({
89
+ body: bench(
90
+ stack(panel("Find a token", search), link),
91
+ stack(panel("Tokens", list)),
92
+ ),
93
+ });
94
+ slot.append(card.node);
95
+ render();
96
+
97
+ return {};
98
+ }
99
+
100
+ export default { mount };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mevebu",
3
- "version": "2.4.0",
3
+ "version": "2.5.1",
4
4
  "description": "Small tools that run in your browser. No accounts, no uploads, no tracking.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -20,7 +20,8 @@
20
20
  "dev": "node bin/mevebu.js",
21
21
  "start": "node bin/mevebu.js",
22
22
  "ship": "node scripts/ship.js",
23
- "social": "node scripts/vendor-social.mjs"
23
+ "social": "node scripts/vendor-social.mjs",
24
+ "docs": "node scripts/tools-docs.mjs"
24
25
  },
25
26
  "keywords": [
26
27
  "tools",