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 +7 -7
- package/mevebu/app/tailwind.css +83 -0
- package/mevebu/libs/js/core.js +1 -1
- package/mevebu/libs/js/js-tokens.js +195 -0
- package/mevebu/libs/js/registry.js +180 -0
- package/mevebu/libs/js/tools/cron-next-runs.js +179 -0
- package/mevebu/libs/js/tools/html-to-jsx.js +144 -0
- package/mevebu/libs/js/tools/javascript-formatter.js +182 -0
- package/mevebu/libs/js/tools/javascript-minifier.js +111 -0
- package/mevebu/libs/js/tools/json-diff.js +111 -0
- package/mevebu/libs/js/tools/json-path-tester.js +215 -0
- package/mevebu/libs/js/tools/json-to-yaml.js +59 -0
- package/mevebu/libs/js/tools/regex-cheatsheet.js +100 -0
- package/mevebu/libs/js/tools/xml-minifier.js +103 -0
- package/mevebu/libs/js/tools/yaml-to-json.js +46 -0
- package/mevebu/libs/js/vendor/js-yaml.LICENSE +21 -0
- package/mevebu/libs/js/vendor/js-yaml.mjs +3851 -0
- package/package.json +1 -1
|
@@ -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, "&").replace(/"/g, """);
|
|
27
|
+
const escapeText = (value) => value
|
|
28
|
+
.replace(/&/g, "&")
|
|
29
|
+
.replace(/</g, "<")
|
|
30
|
+
.replace(/>/g, ">")
|
|
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,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 };
|