mevebu 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -8
- package/mevebu/app/tailwind.css +12 -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/bcrypt-hash.js +161 -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-to-yaml.js +59 -0
- package/mevebu/libs/js/tools/password-policy.js +139 -0
- package/mevebu/libs/js/tools/rsa-key-pair.js +137 -0
- package/mevebu/libs/js/tools/secret-scanner.js +168 -0
- package/mevebu/libs/js/tools/ssh-fingerprint.js +210 -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/bcrypt.min.js +1220 -0
- package/mevebu/libs/js/vendor/bcryptjs.LICENSE +27 -0
- package/mevebu/libs/js/vendor/js-yaml.LICENSE +21 -0
- package/mevebu/libs/js/vendor/js-yaml.mjs +3851 -0
- package/package.json +2 -1
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Finds API keys, tokens and private keys in a block of text before it is
|
|
2
|
+
// pasted somewhere. It matches known shapes, so a clean result is a hint, not a
|
|
3
|
+
// guarantee. Every value is masked unless you ask for it.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, bench, stack, panel, checkbox, button, actions, toolcard, blank,
|
|
6
|
+
notice, output, copyButton,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
import { dropzone, formatBytes } from "../files.js";
|
|
9
|
+
|
|
10
|
+
const RULES = [
|
|
11
|
+
{ id: "aws", label: "AWS access key", re: /\b((?:AKIA|ASIA)[0-9A-Z]{16})\b/g, group: 1 },
|
|
12
|
+
{ id: "github", label: "GitHub token", re: /\b((?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{22,}))\b/g, group: 1 },
|
|
13
|
+
{ id: "slack", label: "Slack token", re: /\b(xox[baprs]-[A-Za-z0-9-]{10,})\b/g, group: 1 },
|
|
14
|
+
{ id: "stripe", label: "Stripe key", re: /\b((?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{16,})\b/g, group: 1 },
|
|
15
|
+
{ id: "google", label: "Google API key", re: /\b(AIza[0-9A-Za-z_\-]{35})\b/g, group: 1 },
|
|
16
|
+
{ id: "jwt", label: "JSON Web Token", re: /\b(eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,})\b/g, group: 1 },
|
|
17
|
+
{ id: "pem", label: "Private key block", re: /(-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----)/g, group: 1 },
|
|
18
|
+
{ id: "basic", label: "Basic auth in a URL", re: /\b([a-z][a-z0-9+.-]*:\/\/[^\s/@:]+:[^\s/@]+@)/gi, group: 1 },
|
|
19
|
+
{ id: "sql", label: "Database password in a string", re: /\b(?:password|passwd|pwd)\s*=\s*['"]([^'"]{6,})['"]/gi, group: 1 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function mask(value) {
|
|
23
|
+
if (value.length <= 8) return `${value.slice(0, 2)}${"*".repeat(Math.max(2, value.length - 2))}`;
|
|
24
|
+
return `${value.slice(0, 4)}${"*".repeat(Math.min(12, value.length - 8))}${value.slice(-4)}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lineAt(text, index) {
|
|
28
|
+
let line = 1;
|
|
29
|
+
for (let i = 0; i < index; i++) if (text[i] === "\n") line++;
|
|
30
|
+
return line;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function mount(slot) {
|
|
34
|
+
let findings = [];
|
|
35
|
+
|
|
36
|
+
const reveal = checkbox("Show the matched values", { checked: false });
|
|
37
|
+
const error = notice("warn");
|
|
38
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
39
|
+
const list = el("div", { class: "grid gap-2" });
|
|
40
|
+
const empty = blank("shield-alert", "Paste text or drop a file to scan it.");
|
|
41
|
+
const scanned = output({ rows: 8 });
|
|
42
|
+
|
|
43
|
+
const text = el("textarea", {
|
|
44
|
+
class: "w-full min-w-0 min-h-32 resize-y rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm leading-normal focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
45
|
+
placeholder: "Paste a config, an env list, or a diff",
|
|
46
|
+
rows: "8",
|
|
47
|
+
spellcheck: "false",
|
|
48
|
+
autocomplete: "off",
|
|
49
|
+
"aria-label": "Text to scan",
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
function render() {
|
|
53
|
+
const showValues = reveal.input.checked;
|
|
54
|
+
list.replaceChildren(
|
|
55
|
+
...findings.map((finding, index) =>
|
|
56
|
+
el("div", { class: "grid gap-1 rounded-r1 border border-orbit bg-deck-2 p-2.5" },
|
|
57
|
+
el("div", { class: "flex flex-wrap items-center gap-2" },
|
|
58
|
+
el("span", { class: "rounded-full bg-danger/15 px-2 py-0.5 text-2xs font-bold text-danger" }, finding.label),
|
|
59
|
+
el("span", { class: "text-2xs text-ink-3" }, `line ${finding.line}`),
|
|
60
|
+
el("span", { class: "ms-auto font-mono text-2xs text-ink-2" }, showValues ? finding.value : mask(finding.value)),
|
|
61
|
+
copyButton(() => finding.value, "Copy")),
|
|
62
|
+
el("p", { class: "m-0 wrap-anywhere font-mono text-2xs text-ink-3" },
|
|
63
|
+
`${truncate(finding.value, showValues ? 120 : 48)}${showValues ? "" : " masked"}`)))
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function truncate(value, max) {
|
|
68
|
+
return value.length <= max ? value : `${value.slice(0, max)}...`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scan() {
|
|
72
|
+
const source = text.value;
|
|
73
|
+
findings = [];
|
|
74
|
+
if (!source.trim()) {
|
|
75
|
+
summary.textContent = "";
|
|
76
|
+
list.replaceChildren();
|
|
77
|
+
empty.hidden = false;
|
|
78
|
+
scanned.hidden = true;
|
|
79
|
+
error.write("");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const rule of RULES) {
|
|
83
|
+
rule.re.lastIndex = 0;
|
|
84
|
+
let match;
|
|
85
|
+
while ((match = rule.re.exec(source)) !== null) {
|
|
86
|
+
findings.push({ id: rule.id, label: rule.label, value: match[rule.group], line: lineAt(source, match.index) });
|
|
87
|
+
if (rule.re.lastIndex === match.index) rule.re.lastIndex++;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
findings.sort((a, b) => a.line - b.line);
|
|
91
|
+
summary.textContent = findings.length
|
|
92
|
+
? `${findings.length} possible secret${findings.length === 1 ? "" : "s"} in ${source.split("\n").length} lines`
|
|
93
|
+
: `No known secret shape in ${source.split("\n").length} lines`;
|
|
94
|
+
if (!findings.length) error.write("No known secret shape found. This is a hint, not a guarantee: an unfamiliar secret will pass.");
|
|
95
|
+
else error.write("Treat anything below as compromised and rotate it. The values stay in this page.");
|
|
96
|
+
list.hidden = findings.length === 0;
|
|
97
|
+
empty.hidden = findings.length > 0;
|
|
98
|
+
render();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
text.addEventListener("input", scan);
|
|
102
|
+
reveal.input.addEventListener("change", render);
|
|
103
|
+
|
|
104
|
+
const drop = dropzone({ label: "Drop a file to scan it", accept: "", iconName: "shield-alert" });
|
|
105
|
+
drop.onFiles(async (files) => {
|
|
106
|
+
const picked = Array.from(files)[0];
|
|
107
|
+
if (!picked) return;
|
|
108
|
+
try {
|
|
109
|
+
text.value = await picked.text();
|
|
110
|
+
summary.textContent = `Read ${picked.name} · ${formatBytes(picked.size)}`;
|
|
111
|
+
scan();
|
|
112
|
+
} catch (caught) {
|
|
113
|
+
error.write(caught?.message || "That file could not be read.");
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const copyReport = button("Copy report", {
|
|
118
|
+
icon: "copy",
|
|
119
|
+
onclick: async () => {
|
|
120
|
+
const text_ = findings.length
|
|
121
|
+
? findings.map((f) => `line ${f.line}\t${f.label}\t${f.value}`).join("\n")
|
|
122
|
+
: "No known secret shape found.";
|
|
123
|
+
try {
|
|
124
|
+
await navigator.clipboard.writeText(text_);
|
|
125
|
+
toast("Report copied");
|
|
126
|
+
} catch {
|
|
127
|
+
error.write("The clipboard is blocked here.");
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const clear = button("Clear", {
|
|
133
|
+
icon: "trash",
|
|
134
|
+
kind: "quiet",
|
|
135
|
+
onclick: () => {
|
|
136
|
+
text.value = "";
|
|
137
|
+
findings = [];
|
|
138
|
+
summary.textContent = "";
|
|
139
|
+
error.write("");
|
|
140
|
+
list.replaceChildren();
|
|
141
|
+
empty.hidden = false;
|
|
142
|
+
scanned.hidden = true;
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
scan();
|
|
147
|
+
|
|
148
|
+
const shell = toolcard({
|
|
149
|
+
body: bench(
|
|
150
|
+
stack(
|
|
151
|
+
text,
|
|
152
|
+
drop,
|
|
153
|
+
reveal,
|
|
154
|
+
error,
|
|
155
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
156
|
+
"Matches known shapes for AWS, GitHub, Slack, Stripe, Google, JWTs, private keys, and credentials in URLs. Nothing is sent anywhere.")
|
|
157
|
+
),
|
|
158
|
+
stack(summary, empty, list, scanned)
|
|
159
|
+
),
|
|
160
|
+
foot: actions(clear, copyReport),
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
slot.append(shell.node);
|
|
164
|
+
|
|
165
|
+
return {};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export default { mount };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The fingerprint of an SSH public key, in the SHA256 form that a server prints.
|
|
2
|
+
// Comparing it with the host you are about to trust is the step people skip
|
|
3
|
+
// because the command is awkward.
|
|
4
|
+
import {
|
|
5
|
+
el, toast, bench, stack, panel, textArea, button, actions, toolcard, blank,
|
|
6
|
+
notice, output, copyButton, chipRow,
|
|
7
|
+
} from "../core.js";
|
|
8
|
+
|
|
9
|
+
const BASE64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
10
|
+
|
|
11
|
+
function decodeBase64(text) {
|
|
12
|
+
const clean = text.replace(/\s+/g, "");
|
|
13
|
+
if (!clean) throw new Error("The key is empty.");
|
|
14
|
+
const bits = [];
|
|
15
|
+
for (const char of clean) {
|
|
16
|
+
if (char === "=") break;
|
|
17
|
+
const index = BASE64.indexOf(char);
|
|
18
|
+
if (index < 0) throw new Error(`"${char}" is not a Base64 character.`);
|
|
19
|
+
for (let bit = 5; bit >= 0; bit--) bits.push((index >> bit) & 1);
|
|
20
|
+
}
|
|
21
|
+
const bytes = [];
|
|
22
|
+
for (let i = 0; i + 8 <= bits.length; i += 8) {
|
|
23
|
+
let byte = 0;
|
|
24
|
+
for (let j = 0; j < 8; j++) byte = (byte << 1) | bits[i + j];
|
|
25
|
+
bytes.push(byte);
|
|
26
|
+
}
|
|
27
|
+
return new Uint8Array(bytes);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The SSH wire format: a length prefixed string for each part. */
|
|
31
|
+
function sshString(bytes) {
|
|
32
|
+
const out = new Uint8Array(4 + bytes.length);
|
|
33
|
+
new DataView(out.buffer).setUint32(0, bytes.length);
|
|
34
|
+
out.set(bytes, 4);
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function sha256(bytes) {
|
|
39
|
+
return new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function md5(bytes) {
|
|
43
|
+
// A small MD5, only used for the legacy colon form.
|
|
44
|
+
const s = [7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
|
|
45
|
+
5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
|
|
46
|
+
4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
|
|
47
|
+
6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21];
|
|
48
|
+
const K = Array.from({ length: 64 }, (_, i) => Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32));
|
|
49
|
+
const a0 = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
|
|
50
|
+
let [A, B, C, D] = a0;
|
|
51
|
+
const padded = new Uint8Array((((bytes.length + 8) >> 6) + 1) * 64);
|
|
52
|
+
padded.set(bytes);
|
|
53
|
+
padded[bytes.length] = 0x80;
|
|
54
|
+
const view = new DataView(padded.buffer);
|
|
55
|
+
view.setUint32(padded.length - 8, (bytes.length << 3) >>> 0, true);
|
|
56
|
+
view.setUint32(padded.length - 4, Math.floor(bytes.length / 2 ** 32), true);
|
|
57
|
+
for (let chunk = 0; chunk < padded.length; chunk += 64) {
|
|
58
|
+
const X = [];
|
|
59
|
+
for (let i = 0; i < 16; i++) X[i] = view.getUint32(chunk + i * 4, true);
|
|
60
|
+
let [a, b, c, d] = [A, B, C, D];
|
|
61
|
+
for (let i = 0; i < 64; i++) {
|
|
62
|
+
let f;
|
|
63
|
+
let g;
|
|
64
|
+
if (i < 16) { f = (b & c) | (~b & d); g = i; }
|
|
65
|
+
else if (i < 32) { f = (d & b) | (~d & c); g = (5 * i + 1) % 16; }
|
|
66
|
+
else if (i < 48) { f = b ^ c ^ d; g = (3 * i + 5) % 16; }
|
|
67
|
+
else { f = c ^ (b | ~d); g = (7 * i) % 16; }
|
|
68
|
+
const tmp = d;
|
|
69
|
+
d = c;
|
|
70
|
+
c = b;
|
|
71
|
+
const sum = (a + f + K[i] + X[g]) | 0;
|
|
72
|
+
b = (b + ((sum << s[i]) | (sum >>> (32 - s[i])))) | 0;
|
|
73
|
+
a = tmp;
|
|
74
|
+
}
|
|
75
|
+
A = (A + a) | 0;
|
|
76
|
+
B = (B + b) | 0;
|
|
77
|
+
C = (C + c) | 0;
|
|
78
|
+
D = (D + d) | 0;
|
|
79
|
+
}
|
|
80
|
+
const out = new Uint8Array(16);
|
|
81
|
+
const outView = new DataView(out.buffer);
|
|
82
|
+
outView.setUint32(0, A, true);
|
|
83
|
+
outView.setUint32(4, B, true);
|
|
84
|
+
outView.setUint32(8, C, true);
|
|
85
|
+
outView.setUint32(12, D, true);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function toBase64(bytes) {
|
|
90
|
+
let out = "";
|
|
91
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
92
|
+
const chunk = (bytes[i] << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0);
|
|
93
|
+
out += BASE64[(chunk >> 18) & 63] + BASE64[(chunk >> 12) & 63];
|
|
94
|
+
out += i + 1 < bytes.length ? BASE64[(chunk >> 6) & 63] : "=";
|
|
95
|
+
out += i + 2 < bytes.length ? BASE64[chunk & 63] : "=";
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function mount(slot) {
|
|
101
|
+
let format = "sha256";
|
|
102
|
+
|
|
103
|
+
const key = textArea({
|
|
104
|
+
rows: 5,
|
|
105
|
+
placeholder: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... user@host",
|
|
106
|
+
"aria-label": "Public key",
|
|
107
|
+
class: "w-full min-w-0 min-h-28 resize-y rounded-r1 border border-orbit bg-deck px-3 py-2.5 font-mono text-sm leading-normal focus:border-jade focus:outline-none focus:ring-3 focus:ring-jade-soft",
|
|
108
|
+
spellcheck: "false",
|
|
109
|
+
autocomplete: "off",
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const error = notice("warn");
|
|
113
|
+
const summary = el("p", { class: "text-sm text-ink-2" });
|
|
114
|
+
const result = output({ rows: 3 });
|
|
115
|
+
const empty = blank("fingerprint", "Paste a public key to see its fingerprint.");
|
|
116
|
+
|
|
117
|
+
const mode = chipRow(
|
|
118
|
+
[
|
|
119
|
+
{ value: "sha256", label: "SHA-256" },
|
|
120
|
+
{ value: "md5", label: "MD5" },
|
|
121
|
+
],
|
|
122
|
+
{
|
|
123
|
+
value: "sha256",
|
|
124
|
+
label: "Hash",
|
|
125
|
+
onChange: (value) => {
|
|
126
|
+
format = value;
|
|
127
|
+
run();
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
async function run() {
|
|
133
|
+
const source = key.value.trim();
|
|
134
|
+
if (!source) {
|
|
135
|
+
summary.textContent = "";
|
|
136
|
+
result.write("");
|
|
137
|
+
empty.hidden = false;
|
|
138
|
+
result.hidden = true;
|
|
139
|
+
error.write("");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const parts = source.split(/\s+/);
|
|
143
|
+
if (parts.length < 2) {
|
|
144
|
+
error.write("A public key line needs a type and a Base64 body.");
|
|
145
|
+
summary.textContent = "";
|
|
146
|
+
result.write("");
|
|
147
|
+
empty.hidden = false;
|
|
148
|
+
result.hidden = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
const [type, body] = parts;
|
|
152
|
+
if (!/^(ssh-|ecdsa-|sk-ssh-|sk-ecdsa-)/.test(type)) {
|
|
153
|
+
error.write(`"${type}" is not a known public key type.`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const blob = decodeBase64(body);
|
|
158
|
+
const hash = format === "md5" ? md5(blob) : await sha256(blob);
|
|
159
|
+
const shown = format === "md5"
|
|
160
|
+
? [...hash].map((byte) => byte.toString(16).padStart(2, "0")).join(":")
|
|
161
|
+
: toBase64(hash).replace(/=+$/, "");
|
|
162
|
+
result.write(format === "md5" ? `MD5:${shown}` : `SHA256:${shown}`);
|
|
163
|
+
result.hidden = false;
|
|
164
|
+
empty.hidden = true;
|
|
165
|
+
summary.textContent = `${type} · ${parts.slice(2).join(" ") || "no comment"} · ${blob.length} bytes`;
|
|
166
|
+
error.write("");
|
|
167
|
+
} catch (caught) {
|
|
168
|
+
error.write(caught?.message || "That key could not be read.");
|
|
169
|
+
result.write("");
|
|
170
|
+
result.hidden = true;
|
|
171
|
+
empty.hidden = false;
|
|
172
|
+
summary.textContent = "";
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
key.addEventListener("input", run);
|
|
177
|
+
|
|
178
|
+
const copy = copyButton(() => result.textContent, "Copy fingerprint");
|
|
179
|
+
const clear = button("Clear", {
|
|
180
|
+
icon: "trash",
|
|
181
|
+
kind: "quiet",
|
|
182
|
+
onclick: () => {
|
|
183
|
+
key.value = "";
|
|
184
|
+
run();
|
|
185
|
+
key.focus();
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
run();
|
|
190
|
+
|
|
191
|
+
const shell = toolcard({
|
|
192
|
+
body: bench(
|
|
193
|
+
stack(
|
|
194
|
+
panel("Public key", key, "The one line that starts with ssh-ed25519 or ecdsa-sha2."),
|
|
195
|
+
el("div", { class: "grid gap-1.5" }, el("span", { class: "text-sm font-semibold text-ink-2" }, "Hash"), mode),
|
|
196
|
+
error,
|
|
197
|
+
el("p", { class: "m-0 text-2xs leading-[1.6] text-ink-3" },
|
|
198
|
+
"This reads a public key only. Paste a private key and it will be treated as an unreadable line: never paste one here.")
|
|
199
|
+
),
|
|
200
|
+
stack(summary, empty, result)
|
|
201
|
+
),
|
|
202
|
+
foot: actions(clear, copy),
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
slot.append(shell.node);
|
|
206
|
+
|
|
207
|
+
return {};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export default { mount };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Compacts XML by dropping indentation between tags. CDATA, comments,
|
|
2
|
+
// processing instructions and attribute values are copied unchanged.
|
|
3
|
+
import { el, bench, stack, panel, textArea, output, notice, copyButton, actions, toolcard } from "../core.js";
|
|
4
|
+
import { bytes } from "../js-tokens.js";
|
|
5
|
+
|
|
6
|
+
const EXAMPLE = '<?xml version="1.0"?>\n<config>\n <server host="localhost" port="8080">\n <name>api</name>\n </server>\n <note><![CDATA[keep this spacing]]></note>\n</config>';
|
|
7
|
+
|
|
8
|
+
const tile = (key, value) =>
|
|
9
|
+
el("div", { class: "grid gap-0.5 rounded-r1 border border-orbit bg-deck-2 px-3.5 py-3" },
|
|
10
|
+
el("span", { class: "wrap-anywhere text-xl font-bold tracking-[-0.02em] tabular-nums" }, value),
|
|
11
|
+
el("span", { class: "text-2xs font-semibold text-ink-3" }, key));
|
|
12
|
+
|
|
13
|
+
function minify(source) {
|
|
14
|
+
let out = "";
|
|
15
|
+
let i = 0;
|
|
16
|
+
|
|
17
|
+
while (i < source.length) {
|
|
18
|
+
if (source.startsWith("<![CDATA[", i)) {
|
|
19
|
+
const end = source.indexOf("]]>", i + 9);
|
|
20
|
+
if (end === -1) return { error: "Unclosed CDATA section." };
|
|
21
|
+
out += source.slice(i, end + 3);
|
|
22
|
+
i = end + 3;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (source.startsWith("<!--", i)) {
|
|
26
|
+
const end = source.indexOf("-->", i + 4);
|
|
27
|
+
if (end === -1) return { error: "Unclosed comment." };
|
|
28
|
+
out += source.slice(i, end + 3);
|
|
29
|
+
i = end + 3;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (source[i] === "<") {
|
|
33
|
+
let j = i + 1;
|
|
34
|
+
let quote = null;
|
|
35
|
+
while (j < source.length) {
|
|
36
|
+
const ch = source[j];
|
|
37
|
+
if (quote) {
|
|
38
|
+
if (ch === quote) quote = null;
|
|
39
|
+
} else if (ch === '"' || ch === "'") {
|
|
40
|
+
quote = ch;
|
|
41
|
+
} else if (ch === ">") {
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
j++;
|
|
45
|
+
}
|
|
46
|
+
if (j >= source.length) return { error: "Unclosed tag." };
|
|
47
|
+
out += source.slice(i, j + 1);
|
|
48
|
+
i = j + 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let j = source.indexOf("<", i);
|
|
53
|
+
if (j === -1) j = source.length;
|
|
54
|
+
const text = source.slice(i, j);
|
|
55
|
+
if (!/^\s+$/.test(text) || !text.includes("\n")) out += text;
|
|
56
|
+
i = j;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { text: out };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function mount(slot) {
|
|
63
|
+
const source = textArea({ rows: 14, "aria-label": "XML", value: EXAMPLE });
|
|
64
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
65
|
+
const stats = el("div", { class: "grid gap-2.5 [grid-template-columns:repeat(auto-fit,minmax(120px,1fr))]" });
|
|
66
|
+
const error = notice("error");
|
|
67
|
+
|
|
68
|
+
function run() {
|
|
69
|
+
error.write("");
|
|
70
|
+
stats.replaceChildren();
|
|
71
|
+
if (!source.value.trim()) {
|
|
72
|
+
result.value = "";
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const output = minify(source.value);
|
|
76
|
+
if (output.error) {
|
|
77
|
+
result.value = "";
|
|
78
|
+
error.write(output.error);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const before = bytes(source.value);
|
|
82
|
+
const after = bytes(output.text);
|
|
83
|
+
const saved = before ? Math.max(0, Math.round((1 - after / before) * 100)) : 0;
|
|
84
|
+
result.value = output.text;
|
|
85
|
+
stats.append(tile("Before", `${before} B`), tile("After", `${after} B`), tile("Saved", `${saved}%`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
source.addEventListener("input", run);
|
|
89
|
+
|
|
90
|
+
const card = toolcard({
|
|
91
|
+
body: bench(
|
|
92
|
+
stack(panel("XML", source), error),
|
|
93
|
+
stack(panel("Minified", result), stats),
|
|
94
|
+
),
|
|
95
|
+
foot: actions(copyButton(() => result.value, "Copy XML", "fill")),
|
|
96
|
+
});
|
|
97
|
+
slot.append(card.node);
|
|
98
|
+
run();
|
|
99
|
+
|
|
100
|
+
return {};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export default { mount };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Converts YAML to JSON with a vendored parser. Anchors are resolved and the
|
|
2
|
+
// key order is kept.
|
|
3
|
+
import { bench, stack, panel, textArea, output, notice, downloadButton, copyButton, actions, toolcard } from "../core.js";
|
|
4
|
+
import { load } from "../vendor/js-yaml.mjs";
|
|
5
|
+
|
|
6
|
+
const EXAMPLE = "service: api\nreplicas: 2\nports:\n - 8080\n - 8443\nlabels:\n tier: backend";
|
|
7
|
+
|
|
8
|
+
export function mount(slot) {
|
|
9
|
+
const source = textArea({ rows: 14, "aria-label": "YAML", value: EXAMPLE });
|
|
10
|
+
const result = output({ tag: "textarea", rows: 12 });
|
|
11
|
+
const error = notice("error");
|
|
12
|
+
function run() {
|
|
13
|
+
error.write("");
|
|
14
|
+
if (!source.value.trim()) {
|
|
15
|
+
result.value = "";
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
result.value = JSON.stringify(load(source.value), null, 2);
|
|
20
|
+
} catch (caught) {
|
|
21
|
+
result.value = "";
|
|
22
|
+
const line = caught.mark?.line;
|
|
23
|
+
const where = typeof line === "number" ? `Line ${line + 1}: ` : "";
|
|
24
|
+
error.write(`${where}${caught.reason ?? caught.message}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
source.addEventListener("input", run);
|
|
29
|
+
|
|
30
|
+
const card = toolcard({
|
|
31
|
+
body: bench(
|
|
32
|
+
stack(panel("YAML", source), error),
|
|
33
|
+
stack(panel("JSON", result)),
|
|
34
|
+
),
|
|
35
|
+
foot: actions(
|
|
36
|
+
downloadButton(() => result.value, "data.json", "Download", "application/json"),
|
|
37
|
+
copyButton(() => result.value, "Copy JSON", "fill"),
|
|
38
|
+
),
|
|
39
|
+
});
|
|
40
|
+
slot.append(card.node);
|
|
41
|
+
run();
|
|
42
|
+
|
|
43
|
+
return {};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default { mount };
|