pyhttp-app 2.0.1__py3-none-any.whl
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.
- pyhttp/__init__.py +9 -0
- pyhttp/__main__.py +222 -0
- pyhttp/app.py +1333 -0
- pyhttp/assertions.py +289 -0
- pyhttp/cli.py +364 -0
- pyhttp/console.py +82 -0
- pyhttp/curl.py +151 -0
- pyhttp/db.py +1483 -0
- pyhttp/jsengine.py +224 -0
- pyhttp/jsonc.py +54 -0
- pyhttp/mcp.py +316 -0
- pyhttp/mock.py +204 -0
- pyhttp/monitors.py +116 -0
- pyhttp/oauth2.py +153 -0
- pyhttp/openapi.py +365 -0
- pyhttp/pm.js +496 -0
- pyhttp/postman.py +412 -0
- pyhttp/runner.py +1089 -0
- pyhttp/scripts.py +352 -0
- pyhttp/static/app.js +2739 -0
- pyhttp/static/codegen.js +134 -0
- pyhttp/static/editor.js +421 -0
- pyhttp/static/features.css +630 -0
- pyhttp/static/features.js +2396 -0
- pyhttp/static/index.html +372 -0
- pyhttp/static/logo.svg +13 -0
- pyhttp/static/style.css +585 -0
- pyhttp/static/vendor/codemirror.js +28 -0
- pyhttp/static/vendor/fonts/Inter-OFL.txt +93 -0
- pyhttp/static/vendor/fonts/inter-latin-ext-wght-normal.woff2 +0 -0
- pyhttp/static/vendor/fonts/inter-latin-wght-normal.woff2 +0 -0
- pyhttp/static/viewer.js +434 -0
- pyhttp/templating.py +155 -0
- pyhttp/updater.py +387 -0
- pyhttp_app-2.0.1.dist-info/METADATA +654 -0
- pyhttp_app-2.0.1.dist-info/RECORD +40 -0
- pyhttp_app-2.0.1.dist-info/WHEEL +5 -0
- pyhttp_app-2.0.1.dist-info/entry_points.txt +2 -0
- pyhttp_app-2.0.1.dist-info/licenses/LICENSE.md +121 -0
- pyhttp_app-2.0.1.dist-info/top_level.txt +1 -0
pyhttp/static/codegen.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Code snippets from a resolved request ({method, url, params, headers, body, body_kind, files, form}).
|
|
3
|
+
|
|
4
|
+
const SNIPPET_LANGS = [
|
|
5
|
+
['curl', 'cURL'], ['python', 'Python (requests)'], ['httpx', 'Python (httpx)'], ['fetch', 'JavaScript (fetch)'],
|
|
6
|
+
['node', 'Node.js (axios)'], ['powershell', 'PowerShell'], ['http', 'HTTP (raw)'], ['go', 'Go'],
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
function fullUrl(req) {
|
|
10
|
+
const params = req.params || [];
|
|
11
|
+
if (!params.length) return req.url;
|
|
12
|
+
const qs = params.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join('&');
|
|
13
|
+
return req.url + (req.url.includes('?') ? '&' : '?') + qs;
|
|
14
|
+
}
|
|
15
|
+
const shQuote = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
16
|
+
const psQuote = (s) => `'${String(s).replace(/'/g, "''")}'`;
|
|
17
|
+
const pyStr = (s) => JSON.stringify(String(s));
|
|
18
|
+
const headersOf = (req) => (req.headers || []).filter(([k]) => !['content-length', 'host'].includes(k.toLowerCase()));
|
|
19
|
+
const bodyOf = (req) => (req.body_kind === 'text' || req.body_kind === 'form') ? (req.body || '') : '';
|
|
20
|
+
const isJsonBody = (req) => req.body_kind === 'text' && headersOf(req).some(([k, v]) => k.toLowerCase() === 'content-type' && /json/i.test(v));
|
|
21
|
+
|
|
22
|
+
function snippet(lang, req) {
|
|
23
|
+
const url = fullUrl(req);
|
|
24
|
+
const method = req.method || 'GET';
|
|
25
|
+
const headers = headersOf(req);
|
|
26
|
+
const body = bodyOf(req);
|
|
27
|
+
const multipart = req.body_kind === 'multipart';
|
|
28
|
+
const hdrs = multipart ? headers.filter(([k]) => k.toLowerCase() !== 'content-type') : headers;
|
|
29
|
+
switch (lang) {
|
|
30
|
+
case 'curl': {
|
|
31
|
+
const lines = [`curl -X ${method} ${shQuote(url)}`];
|
|
32
|
+
hdrs.forEach(([k, v]) => lines.push(` -H ${shQuote(`${k}: ${v}`)}`));
|
|
33
|
+
if (multipart) {
|
|
34
|
+
(req.form || []).forEach(([k, v]) => lines.push(` -F ${shQuote(`${k}=${v}`)}`));
|
|
35
|
+
(req.files || []).forEach((f) => lines.push(` -F ${shQuote(`${f.field}=@${f.filename}`)}`));
|
|
36
|
+
} else if (req.body_kind === 'binary') lines.push(` --data-binary @file`);
|
|
37
|
+
else if (body) lines.push(` -d ${shQuote(body)}`);
|
|
38
|
+
return lines.join(' \\\n');
|
|
39
|
+
}
|
|
40
|
+
case 'python': {
|
|
41
|
+
const out = ['import requests', '', `url = ${pyStr(url)}`];
|
|
42
|
+
out.push(`headers = {${hdrs.map(([k, v]) => `\n ${pyStr(k)}: ${pyStr(v)},`).join('')}${hdrs.length ? '\n' : ''}}`);
|
|
43
|
+
let call = `requests.request(${pyStr(method)}, url, headers=headers`;
|
|
44
|
+
if (multipart) {
|
|
45
|
+
out.push(`data = {${(req.form || []).map(([k, v]) => `${pyStr(k)}: ${pyStr(v)}`).join(', ')}}`);
|
|
46
|
+
out.push(`files = {${(req.files || []).map((f) => `${pyStr(f.field)}: open(${pyStr(f.filename)}, "rb")`).join(', ')}}`);
|
|
47
|
+
call += ', data=data, files=files';
|
|
48
|
+
} else if (isJsonBody(req)) {
|
|
49
|
+
let parsed = null;
|
|
50
|
+
try { parsed = JSON.parse(body); } catch { parsed = null; }
|
|
51
|
+
if (parsed !== null) { out.push(`payload = ${pyLiteral(parsed, 0)}`); call += ', json=payload'; }
|
|
52
|
+
else { out.push(`payload = ${pyStr(body)}`); call += ', data=payload'; }
|
|
53
|
+
} else if (body) { out.push(`payload = ${pyStr(body)}`); call += ', data=payload'; }
|
|
54
|
+
out.push('', `response = ${call})`, 'print(response.status_code)', 'print(response.text)');
|
|
55
|
+
return out.join('\n');
|
|
56
|
+
}
|
|
57
|
+
case 'httpx': {
|
|
58
|
+
const out = ['import httpx', '', `url = ${pyStr(url)}`];
|
|
59
|
+
out.push(`headers = {${hdrs.map(([k, v]) => `\n ${pyStr(k)}: ${pyStr(v)},`).join('')}${hdrs.length ? '\n' : ''}}`);
|
|
60
|
+
let call = `httpx.request(${pyStr(method)}, url, headers=headers`;
|
|
61
|
+
if (multipart) {
|
|
62
|
+
out.push(`files = {${(req.files || []).map((f) => `${pyStr(f.field)}: open(${pyStr(f.filename)}, "rb")`).join(', ')}}`);
|
|
63
|
+
out.push(`data = {${(req.form || []).map(([k, v]) => `${pyStr(k)}: ${pyStr(v)}`).join(', ')}}`);
|
|
64
|
+
call += ', data=data, files=files';
|
|
65
|
+
} else if (body) { out.push(`content = ${pyStr(body)}`); call += ', content=content'; }
|
|
66
|
+
out.push('', `response = ${call}, timeout=30)`, 'print(response.status_code, response.text)');
|
|
67
|
+
return out.join('\n');
|
|
68
|
+
}
|
|
69
|
+
case 'fetch': {
|
|
70
|
+
const out = [];
|
|
71
|
+
const opts = [` method: ${JSON.stringify(method)}`];
|
|
72
|
+
if (hdrs.length) opts.push(` headers: {${hdrs.map(([k, v]) => `\n ${JSON.stringify(k)}: ${JSON.stringify(v)},`).join('')}\n }`);
|
|
73
|
+
if (multipart) {
|
|
74
|
+
out.push('const form = new FormData();');
|
|
75
|
+
(req.form || []).forEach(([k, v]) => out.push(`form.append(${JSON.stringify(k)}, ${JSON.stringify(v)});`));
|
|
76
|
+
(req.files || []).forEach((f) => out.push(`form.append(${JSON.stringify(f.field)}, fileInput.files[0]); // ${f.filename}`));
|
|
77
|
+
opts.push(' body: form');
|
|
78
|
+
} else if (body) opts.push(` body: ${JSON.stringify(body)}`);
|
|
79
|
+
out.push(`const response = await fetch(${JSON.stringify(url)}, {\n${opts.join(',\n')}\n});`, 'console.log(response.status);', 'console.log(await response.text());');
|
|
80
|
+
return out.join('\n');
|
|
81
|
+
}
|
|
82
|
+
case 'node': {
|
|
83
|
+
const out = ["const axios = require('axios');", ''];
|
|
84
|
+
const cfg = [` method: ${JSON.stringify(method.toLowerCase())}`, ` url: ${JSON.stringify(url)}`];
|
|
85
|
+
if (hdrs.length) cfg.push(` headers: {${hdrs.map(([k, v]) => `\n ${JSON.stringify(k)}: ${JSON.stringify(v)},`).join('')}\n }`);
|
|
86
|
+
if (body) cfg.push(` data: ${JSON.stringify(body)}`);
|
|
87
|
+
out.push(`axios({\n${cfg.join(',\n')}\n}).then((r) => console.log(r.status, r.data)).catch((e) => console.error(e.message));`);
|
|
88
|
+
return out.join('\n');
|
|
89
|
+
}
|
|
90
|
+
case 'powershell': {
|
|
91
|
+
const out = [];
|
|
92
|
+
out.push(`$headers = @{${hdrs.map(([k, v]) => `\n ${psQuote(k)} = ${psQuote(v)}`).join('')}${hdrs.length ? '\n' : ''}}`);
|
|
93
|
+
let call = `Invoke-RestMethod -Method ${method} -Uri ${psQuote(url)} -Headers $headers`;
|
|
94
|
+
if (body) { out.push(`$body = @'\n${body}\n'@`); call += ' -Body $body'; }
|
|
95
|
+
out.push(`$response = ${call}`, '$response | ConvertTo-Json -Depth 10');
|
|
96
|
+
return out.join('\n');
|
|
97
|
+
}
|
|
98
|
+
case 'http': {
|
|
99
|
+
let host = '';
|
|
100
|
+
try { host = new URL(url).host; } catch { host = ''; }
|
|
101
|
+
let pathq = url;
|
|
102
|
+
try { const u = new URL(url); pathq = u.pathname + u.search; } catch { /* keep */ }
|
|
103
|
+
const lines = [`${method} ${pathq} HTTP/1.1`, `Host: ${host}`];
|
|
104
|
+
headers.forEach(([k, v]) => { if (k.toLowerCase() !== 'host') lines.push(`${k}: ${v}`); });
|
|
105
|
+
lines.push('');
|
|
106
|
+
if (body) lines.push(body);
|
|
107
|
+
return lines.join('\n');
|
|
108
|
+
}
|
|
109
|
+
case 'go': {
|
|
110
|
+
const out = ['package main', '', 'import (', '\t"fmt"', '\t"io"', '\t"net/http"', '\t"strings"', ')', '', 'func main() {'];
|
|
111
|
+
out.push(`\turl := ${JSON.stringify(url)}`);
|
|
112
|
+
if (body) out.push(`\tpayload := strings.NewReader(${JSON.stringify(body)})`, `\treq, _ := http.NewRequest(${JSON.stringify(method)}, url, payload)`);
|
|
113
|
+
else out.push(`\treq, _ := http.NewRequest(${JSON.stringify(method)}, url, nil)`);
|
|
114
|
+
hdrs.forEach(([k, v]) => out.push(`\treq.Header.Set(${JSON.stringify(k)}, ${JSON.stringify(v)})`));
|
|
115
|
+
out.push('\tres, err := http.DefaultClient.Do(req)', '\tif err != nil {', '\t\tpanic(err)', '\t}', '\tdefer res.Body.Close()',
|
|
116
|
+
'\tbody, _ := io.ReadAll(res.Body)', '\tfmt.Println(res.StatusCode)', '\tfmt.Println(string(body))', '}');
|
|
117
|
+
return out.join('\n');
|
|
118
|
+
}
|
|
119
|
+
default:
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function pyLiteral(v, depth) {
|
|
125
|
+
const pad = ' '.repeat(depth + 1), end = ' '.repeat(depth);
|
|
126
|
+
if (v === null) return 'None';
|
|
127
|
+
if (v === true) return 'True';
|
|
128
|
+
if (v === false) return 'False';
|
|
129
|
+
if (typeof v === 'number') return String(v);
|
|
130
|
+
if (typeof v === 'string') return pyStr(v);
|
|
131
|
+
if (Array.isArray(v)) return v.length ? `[\n${v.map((x) => pad + pyLiteral(x, depth + 1)).join(',\n')}\n${end}]` : '[]';
|
|
132
|
+
const keys = Object.keys(v);
|
|
133
|
+
return keys.length ? `{\n${keys.map((k) => `${pad}${pyStr(k)}: ${pyLiteral(v[k], depth + 1)}`).join(',\n')}\n${end}}` : '{}';
|
|
134
|
+
}
|
pyhttp/static/editor.js
ADDED
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Code-editor behaviours for plain <textarea>s: Ctrl+/ comments, Tab indentation,
|
|
3
|
+
// auto-indent, a line-number gutter, a JSON linter that understands comments,
|
|
4
|
+
// and a formatter that keeps comments. No dependencies.
|
|
5
|
+
|
|
6
|
+
const INDENT = ' ';
|
|
7
|
+
|
|
8
|
+
// ------------------------------------------------------------ JSON tokenizer
|
|
9
|
+
// Tokens: {t: 'punct'|'string'|'number'|'literal'|'comment'|'error', v, line, col, nl (newline before)}
|
|
10
|
+
function jsonTokens(text) {
|
|
11
|
+
const toks = [];
|
|
12
|
+
let i = 0, line = 1, col = 1, sawNl = false;
|
|
13
|
+
const n = text.length;
|
|
14
|
+
const push = (t, v, l, c) => { toks.push({ t, v, line: l, col: c, nl: sawNl }); sawNl = false; };
|
|
15
|
+
while (i < n) {
|
|
16
|
+
const ch = text[i];
|
|
17
|
+
if (ch === '\n') { i++; line++; col = 1; sawNl = true; continue; }
|
|
18
|
+
if (ch === ' ' || ch === '\t' || ch === '\r') { i++; col++; continue; }
|
|
19
|
+
const l = line, c = col;
|
|
20
|
+
if (ch === '/' && text[i + 1] === '/') {
|
|
21
|
+
let j = text.indexOf('\n', i);
|
|
22
|
+
if (j === -1) j = n;
|
|
23
|
+
push('comment', text.slice(i, j).replace(/\s+$/, ''), l, c);
|
|
24
|
+
col += j - i; i = j; continue;
|
|
25
|
+
}
|
|
26
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
27
|
+
let j = text.indexOf('*/', i + 2);
|
|
28
|
+
j = j === -1 ? n : j + 2;
|
|
29
|
+
const v = text.slice(i, j);
|
|
30
|
+
push('comment', v, l, c);
|
|
31
|
+
const nls = v.split('\n');
|
|
32
|
+
if (nls.length > 1) { line += nls.length - 1; col = nls[nls.length - 1].length + 1; } else col += v.length;
|
|
33
|
+
i = j; continue;
|
|
34
|
+
}
|
|
35
|
+
if ('{}[]:,'.includes(ch)) { push('punct', ch, l, c); i++; col++; continue; }
|
|
36
|
+
if (ch === '"') {
|
|
37
|
+
let j = i + 1, ok = false;
|
|
38
|
+
while (j < n) {
|
|
39
|
+
if (text[j] === '\\') { j += 2; continue; }
|
|
40
|
+
if (text[j] === '"') { ok = true; break; }
|
|
41
|
+
if (text[j] === '\n') break;
|
|
42
|
+
j++;
|
|
43
|
+
}
|
|
44
|
+
if (!ok) { push('error', 'Unterminated string', l, c); return toks; }
|
|
45
|
+
const v = text.slice(i, j + 1);
|
|
46
|
+
if (/[\u0000-\u001f]/.test(v)) { push('error', 'Control character in string', l, c); return toks; }
|
|
47
|
+
if (/\\(?!["\\/bfnrtu])/.test(v) || /\\u(?![0-9a-fA-F]{4})/.test(v)) { push('error', 'Bad escape in string', l, c); return toks; }
|
|
48
|
+
push('string', v, l, c); col += v.length; i = j + 1; continue;
|
|
49
|
+
}
|
|
50
|
+
const m = /^-?(0|[1-9]\d*)(\.\d+)?([eE][+-]?\d+)?/.exec(text.slice(i, i + 40));
|
|
51
|
+
if (m && !/[A-Za-z0-9_.]/.test(text[i + m[0].length] || '')) { push('number', m[0], l, c); i += m[0].length; col += m[0].length; continue; }
|
|
52
|
+
const w = /^[A-Za-z_][A-Za-z0-9_]*/.exec(text.slice(i, i + 40));
|
|
53
|
+
if (w) {
|
|
54
|
+
if (['true', 'false', 'null'].includes(w[0])) push('literal', w[0], l, c);
|
|
55
|
+
else push('error', `Unexpected word "${w[0]}" (strings need double quotes)`, l, c);
|
|
56
|
+
if (toks[toks.length - 1].t === 'error') return toks;
|
|
57
|
+
i += w[0].length; col += w[0].length; continue;
|
|
58
|
+
}
|
|
59
|
+
if (ch === "'") { push('error', "Single quotes are not valid JSON; use double quotes", l, c); return toks; }
|
|
60
|
+
push('error', `Unexpected character "${ch}"`, l, c);
|
|
61
|
+
return toks;
|
|
62
|
+
}
|
|
63
|
+
return toks;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Validates JSON (comments allowed). Returns {ok, message, line, col, comments}
|
|
67
|
+
function jsonLint(text) {
|
|
68
|
+
if (!text.trim()) return { ok: true, empty: true, message: 'Empty', comments: false };
|
|
69
|
+
const toks = jsonTokens(text);
|
|
70
|
+
const comments = toks.some((t) => t.t === 'comment');
|
|
71
|
+
const err = toks.find((t) => t.t === 'error');
|
|
72
|
+
if (err) return { ok: false, message: err.v, line: err.line, col: err.col, comments };
|
|
73
|
+
const ts = toks.filter((t) => t.t !== 'comment');
|
|
74
|
+
let p = 0;
|
|
75
|
+
const last = ts[ts.length - 1];
|
|
76
|
+
const at = (tok) => tok ? { line: tok.line, col: tok.col } : { line: last ? last.line : 1, col: last ? last.col + String(last.v).length : 1 };
|
|
77
|
+
const fail = (msg, tok) => { throw Object.assign(new Error(msg), at(tok)); };
|
|
78
|
+
const peek = () => ts[p];
|
|
79
|
+
function value() {
|
|
80
|
+
const t = peek();
|
|
81
|
+
if (!t) fail('Unexpected end of input');
|
|
82
|
+
if (t.t === 'string' || t.t === 'number' || t.t === 'literal') { p++; return; }
|
|
83
|
+
if (t.t === 'punct' && t.v === '{') { p++; object(); return; }
|
|
84
|
+
if (t.t === 'punct' && t.v === '[') { p++; array(); return; }
|
|
85
|
+
fail(`Unexpected "${t.v}"`, t);
|
|
86
|
+
}
|
|
87
|
+
function object() {
|
|
88
|
+
if (peek() && peek().v === '}') { p++; return; }
|
|
89
|
+
for (;;) {
|
|
90
|
+
const k = peek();
|
|
91
|
+
if (!k) fail('Unexpected end of input: object not closed');
|
|
92
|
+
if (k.v === '}') fail('Trailing comma before }', k);
|
|
93
|
+
if (k.t !== 'string') fail(k.v === ',' ? 'Unexpected comma' : 'Property name must be a double-quoted string', k);
|
|
94
|
+
p++;
|
|
95
|
+
const colon = peek();
|
|
96
|
+
if (!colon || colon.v !== ':') fail('Expected ":" after property name', colon || k);
|
|
97
|
+
p++;
|
|
98
|
+
value();
|
|
99
|
+
const sep = peek();
|
|
100
|
+
if (!sep) fail('Unexpected end of input: object not closed');
|
|
101
|
+
if (sep.v === ',') { p++; continue; }
|
|
102
|
+
if (sep.v === '}') { p++; return; }
|
|
103
|
+
fail(`Expected "," or "}" but found "${sep.v}"`, sep);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function array() {
|
|
107
|
+
if (peek() && peek().v === ']') { p++; return; }
|
|
108
|
+
for (;;) {
|
|
109
|
+
const t = peek();
|
|
110
|
+
if (t && t.v === ']') fail('Trailing comma before ]', t);
|
|
111
|
+
value();
|
|
112
|
+
const sep = peek();
|
|
113
|
+
if (!sep) fail('Unexpected end of input: array not closed');
|
|
114
|
+
if (sep.v === ',') { p++; continue; }
|
|
115
|
+
if (sep.v === ']') { p++; return; }
|
|
116
|
+
fail(`Expected "," or "]" but found "${sep.v}"`, sep);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
try {
|
|
120
|
+
value();
|
|
121
|
+
if (p < ts.length) fail(`Unexpected "${ts[p].v}" after the end of the document`, ts[p]);
|
|
122
|
+
return { ok: true, message: comments ? 'Valid JSON (comments are removed when sent)' : 'Valid JSON', comments };
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { ok: false, message: e.message, line: e.line, col: e.col, comments };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Strip comments in JS (mirror of jsonc.py) for the UI.
|
|
129
|
+
function stripJsonComments(text) {
|
|
130
|
+
if (!text || (!text.includes('//') && !text.includes('/*'))) return text || '';
|
|
131
|
+
let out = '', i = 0, inStr = false;
|
|
132
|
+
const n = text.length;
|
|
133
|
+
while (i < n) {
|
|
134
|
+
const c = text[i];
|
|
135
|
+
if (inStr) {
|
|
136
|
+
out += c;
|
|
137
|
+
if (c === '\\' && i + 1 < n) { out += text[i + 1]; i += 2; continue; }
|
|
138
|
+
if (c === '"') inStr = false;
|
|
139
|
+
i++; continue;
|
|
140
|
+
}
|
|
141
|
+
if (c === '"') { inStr = true; out += c; i++; continue; }
|
|
142
|
+
if (c === '/' && text[i + 1] === '/') { let j = text.indexOf('\n', i); i = j === -1 ? n : j; continue; }
|
|
143
|
+
if (c === '/' && text[i + 1] === '*') {
|
|
144
|
+
let j = text.indexOf('*/', i + 2); const end = j === -1 ? n : j + 2;
|
|
145
|
+
out += text.slice(i, end).replace(/[^\n]/g, ''); i = end; continue;
|
|
146
|
+
}
|
|
147
|
+
out += c; i++;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Pretty-print JSON while keeping comments in place. Falls back to JSON.stringify when the
|
|
153
|
+
// document is valid and has no comments. Returns null when the text is not valid JSON(C).
|
|
154
|
+
function formatJsonKeepComments(text, indent = INDENT) {
|
|
155
|
+
const lint = jsonLint(text);
|
|
156
|
+
if (!lint.ok || lint.empty) return null;
|
|
157
|
+
if (!lint.comments) return JSON.stringify(JSON.parse(text), null, indent);
|
|
158
|
+
const toks = jsonTokens(text);
|
|
159
|
+
let out = '', depth = 0, lineOpen = false; // lineOpen: something is written on the current line
|
|
160
|
+
const pad = () => indent.repeat(depth);
|
|
161
|
+
const newline = () => { out = out.replace(/[ \t]+$/, ''); out += '\n'; lineOpen = false; };
|
|
162
|
+
const write = (s) => { if (!lineOpen) { out += pad(); lineOpen = true; } out += s; };
|
|
163
|
+
for (let i = 0; i < toks.length; i++) {
|
|
164
|
+
const t = toks[i];
|
|
165
|
+
const next = toks[i + 1];
|
|
166
|
+
if (t.t === 'comment') {
|
|
167
|
+
if (t.v.startsWith('//')) {
|
|
168
|
+
if (t.nl || !lineOpen) { if (lineOpen) newline(); write(t.v); newline(); }
|
|
169
|
+
else { out += ' ' + t.v; newline(); }
|
|
170
|
+
} else { // block comment
|
|
171
|
+
if (t.nl && lineOpen) newline();
|
|
172
|
+
if (lineOpen) out += ' ';
|
|
173
|
+
write(t.v);
|
|
174
|
+
if (t.v.includes('\n') || (next && next.nl)) newline(); else out += ' ';
|
|
175
|
+
}
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (t.v === '{' || t.v === '[') {
|
|
179
|
+
const close = t.v === '{' ? '}' : ']';
|
|
180
|
+
// empty container
|
|
181
|
+
let j = i + 1;
|
|
182
|
+
while (toks[j] && toks[j].t === 'comment') j++;
|
|
183
|
+
if (toks[j] && toks[j].v === close && j === i + 1) { write(t.v + close); i = j; afterValue(); continue; }
|
|
184
|
+
write(t.v); depth++; newline(); continue;
|
|
185
|
+
}
|
|
186
|
+
if (t.v === '}' || t.v === ']') {
|
|
187
|
+
depth--; if (lineOpen) newline(); write(t.v); afterValue(); continue;
|
|
188
|
+
}
|
|
189
|
+
if (t.v === ':') { out += ': '; continue; }
|
|
190
|
+
if (t.v === ',') { out += ','; const nc = next && next.t === 'comment' && next.v.startsWith('//') && !next.nl; if (!nc) newline(); continue; }
|
|
191
|
+
write(t.v);
|
|
192
|
+
afterValue();
|
|
193
|
+
}
|
|
194
|
+
function afterValue() { /* separators handle line breaks */ }
|
|
195
|
+
return out.replace(/[ \t]+$/gm, '').replace(/\n{3,}/g, '\n\n').trim() + '\n';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ------------------------------------------------------------------ enhance
|
|
199
|
+
// Replaces a <textarea> with a CodeMirror 6 editor (line numbers, folding, bracket
|
|
200
|
+
// matching, Ctrl+/ comments, search, syntax colours) while keeping the textarea in
|
|
201
|
+
// sync, so the rest of the app keeps reading/writing `ta.value` and listening to
|
|
202
|
+
// 'input'. opts: { lang: () => 'json'|'javascript'|'python'|'xml'|'html'|'text' }
|
|
203
|
+
const CM_JSON_COMMENTS = { line: '//', block: { open: '/*', close: '*/' } };
|
|
204
|
+
function cmLanguage(name) {
|
|
205
|
+
const C = window.CM;
|
|
206
|
+
switch (name) {
|
|
207
|
+
case 'json':
|
|
208
|
+
case 'graphql': return [C.json(), C.jsonLanguage.data.of({ commentTokens: CM_JSON_COMMENTS })];
|
|
209
|
+
case 'javascript': return [C.javascript()];
|
|
210
|
+
case 'python': return [C.python()];
|
|
211
|
+
case 'xml': return [C.xml()];
|
|
212
|
+
case 'html': return [C.html()];
|
|
213
|
+
default: return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
let cmThemeCache = null;
|
|
217
|
+
function cmTheme() {
|
|
218
|
+
const C = window.CM;
|
|
219
|
+
if (cmThemeCache) return cmThemeCache;
|
|
220
|
+
const t = C.tags;
|
|
221
|
+
const highlight = C.HighlightStyle.define([
|
|
222
|
+
{ tag: [t.propertyName, t.attributeName], color: 'var(--cm-key)' },
|
|
223
|
+
{ tag: [t.string, t.special(t.string), t.attributeValue], color: 'var(--cm-string)' },
|
|
224
|
+
{ tag: [t.number, t.bool, t.null, t.atom], color: 'var(--cm-number)' },
|
|
225
|
+
{ tag: [t.keyword, t.operatorKeyword, t.modifier, t.controlKeyword, t.definitionKeyword], color: 'var(--cm-keyword)' },
|
|
226
|
+
{ tag: [t.comment, t.lineComment, t.blockComment], color: 'var(--muted)', fontStyle: 'italic' },
|
|
227
|
+
{ tag: [t.tagName, t.angleBracket], color: 'var(--cm-key)' },
|
|
228
|
+
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: 'var(--cm-func)' },
|
|
229
|
+
{ tag: [t.variableName, t.definition(t.variableName)], color: 'var(--text)' },
|
|
230
|
+
{ tag: [t.punctuation, t.separator, t.bracket, t.brace, t.squareBracket, t.paren], color: 'var(--text)' },
|
|
231
|
+
{ tag: t.invalid, color: 'var(--danger)' },
|
|
232
|
+
]);
|
|
233
|
+
const theme = C.EditorView.theme({
|
|
234
|
+
'&': { height: '100%', backgroundColor: 'var(--bg-3)', color: 'var(--text)', fontSize: 'var(--editor-font)' },
|
|
235
|
+
'.cm-scroller': { fontFamily: 'var(--mono)', lineHeight: '1.5', overflow: 'auto' },
|
|
236
|
+
'.cm-content': { caretColor: 'var(--text)', padding: '6px 0' },
|
|
237
|
+
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--text)' },
|
|
238
|
+
'&.cm-focused': { outline: 'none' },
|
|
239
|
+
'.cm-gutters': { backgroundColor: 'var(--bg-2)', color: 'var(--muted)', borderRight: '1px solid var(--border)' },
|
|
240
|
+
'.cm-activeLineGutter': { backgroundColor: 'rgba(59,130,246,.12)' },
|
|
241
|
+
'.cm-activeLine': { backgroundColor: 'rgba(59,130,246,.06)' },
|
|
242
|
+
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(59,130,246,.3) !important' },
|
|
243
|
+
'.cm-selectionMatch': { backgroundColor: 'rgba(245,158,11,.25)' },
|
|
244
|
+
'.cm-matchingBracket, &.cm-focused .cm-matchingBracket': { backgroundColor: 'rgba(16,185,129,.25)', outline: '1px solid rgba(16,185,129,.5)' },
|
|
245
|
+
'.cm-foldGutter .cm-gutterElement': { cursor: 'pointer', color: 'var(--muted)' },
|
|
246
|
+
'.cm-foldPlaceholder': { backgroundColor: 'var(--bg-4)', border: '1px solid var(--border)', color: 'var(--muted)', padding: '0 6px', margin: '0 3px', borderRadius: '4px' },
|
|
247
|
+
'.cm-lintRange-error': { backgroundImage: 'none', borderBottom: '2px solid var(--danger)' },
|
|
248
|
+
'.cm-tooltip': { backgroundColor: 'var(--bg-2)', border: '1px solid var(--border)', color: 'var(--text)' },
|
|
249
|
+
'.cm-panels': { backgroundColor: 'var(--bg-2)', color: 'var(--text)', borderColor: 'var(--border)' },
|
|
250
|
+
'.cm-panel.cm-search input, .cm-panel.cm-search button, .cm-panel.cm-search label': { fontSize: '12px' },
|
|
251
|
+
'.cm-placeholder': { color: 'var(--muted)', fontStyle: 'italic' },
|
|
252
|
+
});
|
|
253
|
+
cmThemeCache = [theme, C.syntaxHighlighting(highlight)];
|
|
254
|
+
return cmThemeCache;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function enhanceTextarea(ta, opts = {}) {
|
|
258
|
+
if (ta._enhanced) return ta._enhanced;
|
|
259
|
+
const C = window.CM;
|
|
260
|
+
const wrap = document.createElement('div');
|
|
261
|
+
wrap.className = 'code-editor';
|
|
262
|
+
ta.parentNode.insertBefore(wrap, ta);
|
|
263
|
+
ta.hidden = true;
|
|
264
|
+
ta.classList.add('code-input');
|
|
265
|
+
const status = document.createElement('div');
|
|
266
|
+
status.className = 'code-status';
|
|
267
|
+
wrap.after(status);
|
|
268
|
+
const lang = () => (typeof opts.lang === 'function' ? opts.lang() : (opts.lang || 'text'));
|
|
269
|
+
const langComp = new C.Compartment();
|
|
270
|
+
let currentLang = lang();
|
|
271
|
+
let syncing = false;
|
|
272
|
+
let lintTimer = null;
|
|
273
|
+
const api = { wrap, status, lang, refresh, lint, format, setValue, setCursor, focus: () => view.focus(), view: null };
|
|
274
|
+
ta._enhanced = api;
|
|
275
|
+
|
|
276
|
+
const keys = [
|
|
277
|
+
{ key: 'Mod-Enter', run: () => { if (typeof sendCurrent === 'function' && typeof state !== 'undefined' && state.current) sendCurrent(); return true; } },
|
|
278
|
+
{ key: 'Mod-b', run: () => { format(); return true; } },
|
|
279
|
+
{ key: 'Mod-/', run: C.toggleComment },
|
|
280
|
+
{ key: 'Shift-Alt-a', run: C.toggleBlockComment },
|
|
281
|
+
C.indentWithTab,
|
|
282
|
+
];
|
|
283
|
+
// macOS: Mod- means Cmd, but the UI hints say Ctrl — accept both (elsewhere the extra binding is a no-op duplicate)
|
|
284
|
+
keys.push(...keys.filter((k) => k.key && k.key.startsWith('Mod-')).map((k) => ({ ...k, key: k.key.replace('Mod-', 'Ctrl-') })));
|
|
285
|
+
const view = new C.EditorView({
|
|
286
|
+
state: C.EditorState.create({
|
|
287
|
+
doc: ta.value,
|
|
288
|
+
extensions: [
|
|
289
|
+
C.lineNumbers(), C.foldGutter({ openText: '▾', closedText: '▸' }), C.lintGutter(),
|
|
290
|
+
C.highlightActiveLineGutter(), C.highlightActiveLine(), C.highlightSpecialChars(),
|
|
291
|
+
C.history(), C.drawSelection(), C.dropCursor(), C.indentOnInput(), C.bracketMatching(),
|
|
292
|
+
C.closeBrackets(), C.highlightSelectionMatches(), C.search({ top: true }),
|
|
293
|
+
C.indentUnit.of(' '),
|
|
294
|
+
C.placeholder(ta.placeholder || ''),
|
|
295
|
+
C.keymap.of([...keys, ...C.closeBracketsKeymap, ...C.defaultKeymap, ...C.searchKeymap, ...C.historyKeymap, ...C.foldKeymap, ...C.lintKeymap]),
|
|
296
|
+
langComp.of(cmLanguage(currentLang)),
|
|
297
|
+
...cmTheme(),
|
|
298
|
+
C.EditorView.updateListener.of((u) => {
|
|
299
|
+
if (!u.docChanged || syncing) return;
|
|
300
|
+
syncing = true;
|
|
301
|
+
ta.value = u.state.doc.toString();
|
|
302
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
303
|
+
syncing = false;
|
|
304
|
+
clearTimeout(lintTimer);
|
|
305
|
+
lintTimer = setTimeout(lint, 250);
|
|
306
|
+
}),
|
|
307
|
+
],
|
|
308
|
+
}),
|
|
309
|
+
parent: wrap,
|
|
310
|
+
});
|
|
311
|
+
api.view = view;
|
|
312
|
+
// the placeholder attribute may change later (language switch): keep CodeMirror's in sync
|
|
313
|
+
new MutationObserver(() => { /* placeholder is read at creation; re-create cheaply */ }).observe(ta, { attributes: true, attributeFilter: ['placeholder'] });
|
|
314
|
+
|
|
315
|
+
function docText() { return view.state.doc.toString(); }
|
|
316
|
+
function setValue(text, keepCaret = false) {
|
|
317
|
+
const cur = docText();
|
|
318
|
+
if (cur === text) return;
|
|
319
|
+
syncing = true;
|
|
320
|
+
const sel = keepCaret ? Math.min(view.state.selection.main.head, text.length) : 0;
|
|
321
|
+
view.dispatch({ changes: { from: 0, to: cur.length, insert: text }, selection: { anchor: sel } });
|
|
322
|
+
syncing = false;
|
|
323
|
+
ta.value = text;
|
|
324
|
+
clearTimeout(lintTimer);
|
|
325
|
+
lintTimer = setTimeout(lint, 50);
|
|
326
|
+
}
|
|
327
|
+
function setCursor(anchor, head) {
|
|
328
|
+
view.dispatch({ selection: { anchor, head: head == null ? anchor : head }, scrollIntoView: true });
|
|
329
|
+
view.focus();
|
|
330
|
+
}
|
|
331
|
+
function syncLang() {
|
|
332
|
+
const l = lang();
|
|
333
|
+
if (l === currentLang) return false;
|
|
334
|
+
currentLang = l;
|
|
335
|
+
view.dispatch({ effects: langComp.reconfigure(cmLanguage(l)) });
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
function refresh() {
|
|
339
|
+
syncLang();
|
|
340
|
+
if (ta.value !== docText()) setValue(ta.value);
|
|
341
|
+
else lint();
|
|
342
|
+
view.requestMeasure();
|
|
343
|
+
}
|
|
344
|
+
function posOf(line, col) {
|
|
345
|
+
const ln = view.state.doc.line(Math.min(Math.max(1, line), view.state.doc.lines));
|
|
346
|
+
return Math.min(ln.from + Math.max(0, (col || 1) - 1), ln.to);
|
|
347
|
+
}
|
|
348
|
+
function lint() {
|
|
349
|
+
syncLang();
|
|
350
|
+
const l = lang();
|
|
351
|
+
status.className = 'code-status';
|
|
352
|
+
status.onclick = null;
|
|
353
|
+
const text = docText();
|
|
354
|
+
if (l !== 'json' && l !== 'graphql') { status.textContent = ''; status.hidden = true; view.dispatch(C.setDiagnostics(view.state, [])); return null; }
|
|
355
|
+
const res = jsonLint(text);
|
|
356
|
+
status.hidden = false;
|
|
357
|
+
if (res.empty) { status.textContent = ''; status.hidden = true; view.dispatch(C.setDiagnostics(view.state, [])); return res; }
|
|
358
|
+
if (res.ok) {
|
|
359
|
+
status.classList.add('ok'); status.textContent = '✓ ' + res.message;
|
|
360
|
+
view.dispatch(C.setDiagnostics(view.state, []));
|
|
361
|
+
} else {
|
|
362
|
+
status.classList.add('err');
|
|
363
|
+
status.textContent = `✕ Line ${res.line}, col ${res.col}: ${res.message}`;
|
|
364
|
+
status.title = 'Click to jump to the error';
|
|
365
|
+
status.onclick = () => jumpTo(res.line, res.col);
|
|
366
|
+
const from = posOf(res.line, res.col);
|
|
367
|
+
const to = Math.min(view.state.doc.lineAt(from).to, from + 1);
|
|
368
|
+
view.dispatch(C.setDiagnostics(view.state, [{ from, to: Math.max(to, from), severity: 'error', message: res.message }]));
|
|
369
|
+
}
|
|
370
|
+
return res;
|
|
371
|
+
}
|
|
372
|
+
function jumpTo(line, col) { setCursor(posOf(line, col)); }
|
|
373
|
+
function format() {
|
|
374
|
+
const l = lang();
|
|
375
|
+
const v = docText();
|
|
376
|
+
if (l === 'json' || l === 'graphql') {
|
|
377
|
+
const out = formatJsonKeepComments(v);
|
|
378
|
+
if (out === null) { const res = jsonLint(v); toast(res.empty ? 'Nothing to format' : `Body is not valid JSON: line ${res.line}: ${res.message}`, 'error'); if (!res.empty) jumpTo(res.line, res.col); return false; }
|
|
379
|
+
if (out !== v) view.dispatch({ changes: { from: 0, to: v.length, insert: out } });
|
|
380
|
+
return true;
|
|
381
|
+
}
|
|
382
|
+
if (l === 'xml' || l === 'html') {
|
|
383
|
+
const out = formatXml(v);
|
|
384
|
+
if (out !== v) view.dispatch({ changes: { from: 0, to: v.length, insert: out } });
|
|
385
|
+
return true;
|
|
386
|
+
}
|
|
387
|
+
toast('Formatting is available for JSON and XML bodies');
|
|
388
|
+
return false;
|
|
389
|
+
}
|
|
390
|
+
lint();
|
|
391
|
+
return api;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Minimal XML pretty printer (tags only; text nodes are trimmed).
|
|
395
|
+
function formatXml(xml) {
|
|
396
|
+
const text = xml.replace(/>\s+</g, '><').trim();
|
|
397
|
+
if (!text) return xml;
|
|
398
|
+
let out = '', depth = 0;
|
|
399
|
+
const parts = text.split(/(<[^>]+>)/g).filter(Boolean);
|
|
400
|
+
for (let i = 0; i < parts.length; i++) {
|
|
401
|
+
const p = parts[i];
|
|
402
|
+
if (/^<\?|^<!/.test(p)) { out += INDENT.repeat(depth) + p + '\n'; continue; }
|
|
403
|
+
if (/^<\//.test(p)) { depth = Math.max(0, depth - 1); out += INDENT.repeat(depth) + p + '\n'; continue; }
|
|
404
|
+
if (/^<[^>]*\/>$/.test(p)) { out += INDENT.repeat(depth) + p + '\n'; continue; }
|
|
405
|
+
if (/^</.test(p)) {
|
|
406
|
+
const nxt = parts[i + 1], close = parts[i + 2];
|
|
407
|
+
// <tag>text</tag> stays on one line
|
|
408
|
+
if (nxt && !nxt.startsWith('<') && close && /^<\//.test(close) && nxt.trim().length < 80) {
|
|
409
|
+
out += INDENT.repeat(depth) + p + nxt.trim() + close + '\n';
|
|
410
|
+
i += 2;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
out += INDENT.repeat(depth) + p + '\n';
|
|
414
|
+
depth++;
|
|
415
|
+
continue;
|
|
416
|
+
}
|
|
417
|
+
const t = p.trim();
|
|
418
|
+
if (t) out += INDENT.repeat(depth) + t + '\n';
|
|
419
|
+
}
|
|
420
|
+
return out.trimEnd() + '\n';
|
|
421
|
+
}
|