initial commit of project

generated by OpenCode and MiniMax M3.1 Flash Free
This commit is contained in:
2026-10-20 14:11:56 +02:00
parent 354a83d651
commit 5b4bfd928d
9 changed files with 3964 additions and 1 deletions
+85
View File
@@ -0,0 +1,85 @@
# AGENTS.md — JSON DevTools
Statická jednostránková aplikácia (vanilla ES6+, HTML/CSS/JS). Bez frameworkov,
bez npm, bez build procesu, bez backendu, bez CDN. Bez CI, `package.json` ani
lint konfigurácie — repozitár je hotový, nič sa nekompiluje.
## ZákAZ KONTROL V PREHLIADAČOCH
**V prehliadačoch sa nesú overovanie aplikácie – používateľ to robí sám.**
Neotváraj Chrome/Chromium ani iný prehliadač, neautomatizuj ich, nescreenshotuj
cez prehliadač. Trvalé používateľské nariadenie, zapísané aj v globálnej pamäti
`~/.config/opencode/AGENTS.md`.
Povolené: statická kontrola (`node --check`), testy logiky v Node.js, `curl`/`python`.
Nepridávaj do repozitára testy ani nástroje založené na prehliadači.
## Štruktúra
```
src/index.html src/style.css src/app.js # aplikácia – musia zostať v jednej zložke
tests/logic.test.js # Node testy (bez prehliadača)
docs/ # screenshoty pre README
```
* `src/index.html` načítava `style.css` a `app.js` **relatívnou cestou** → všetky
tri súbory musia zostať v `src/`. Presun do rootu rozbije načítanie aj testy.
* Aplikácia sa otvára ako **`src/index.html`** (relatívne cesty), nie cez root.
* Repo hostuje **Gitea**, nie GitHub — nepridávaj GitHub Actions/Pages šablóny.
## Príkazy
```bash
node --check src/app.js # rýchla syntax kontrola
node tests/logic.test.js # testy logiky – 93 assertov, exit 1 pri chybe
python -m http.server 8080 --directory src # lokálne spustenie (voliteľné)
```
Nie je čo `npm install`ovať ani buildovať – po úprave `src/*.js|css|html` stačí
reload stránky.
## Ako funguje `tests/logic.test.js`
* `src/app.js` sa načíta do `vm` sandboxu s **ručným DOM stubom** (žiadne jsdom).
Aplikácia sa musí vedieť načítať bez reálneho DOM.
* Stub vracia **nový generický element pri každom `querySelector()`** → DOM
interakcie (tlačidlá, eventy, fokus) sa v Node testoch **neoverujú**. Overuj
logiku cez `window.JSONDevTools`, nie cez DOM.
* `src/app.js` musí na konci exportovať **`window.JSONDevTools`** (objekt s
modulmi `Utils, Toast, Theme, Splitter, Highlighter, Doc, Formatter, FileIO,
Tree, Diff, Api, History, App`) — bez neho testy skončí chybou.
* Testy čítajú `../src/app.js` a `../src/index.html` → pri presunu súborov treba
upraviť cesty v `tests/logic.test.js`.
* Pomocníci: `ok(name, cond, extra)`, `eq(name, a, b)`, `deepEq(...)`, `section(t)`.
* Testy obsahujú aj statické kontroly konzistencie: každé `$('#id')` v `app.js`
musí existovať v `index.html`, každé `<button id>` musí mať handler, `id` v HTML
nesmie byť duplicitné. **Nové `id` v HTML pridaj aj do `app.js`**, inak test
zfailuje.
## Architektúra `src/app.js`
Jeden IIFE, vnútri moduly: `Utils`, `LS`, `Toast`, `Theme`, `Splitter`,
`Highlighter`, `Doc`, `Formatter`, `FileIO`, `Tree`, `Diff`, `Api`, `History`,
`App`. Bez tried, bez DI – moduly sú plain objekty.
Nároky, ktoré treba rešpektovať:
* **`Doc` je zdieľaný stav** medzi Formatter a Tree – oba textareas sú synchro
cez `Doc.set(text, origin)`. Nový JSON pohľad pripoj cez `Doc.onChange()`,
nie vlastnou kopírou textu.
* **`localStorage` len cez `Utils.store`** (prefix `jsondevtools.*`). Wrapper
zachytáva prekročenie kvóty a vyhodí toast – nepristupuj k `localStorage`
priamo.
* **Bezpečnostná invariant:** do historie sa nikdy nesmie uložiť `Authorization`,
`Cookie` ani `X-Api-Key` – prechádza to cez `sanitizeHeaders()` v `Api`.
Tokeny žijú iba v pamäti stránky.
* CSS motívy sú CSS custom properties v `html[data-theme="dark"|"light"]` –
nové farby pridávaj ako premenné, nie ako hardcoded hodnoty.
* UI texty v slovenčine, JSON hodnoty v angličtine.
## Ostatné
* Screenshoty v `docs/` sú v README vložené relatívnou cestou (`docs/...`) –
pri presúvaní ich treba aktualizovať aj v `README.md`.
* `src/index.html?theme=light|dark` prepne motív **len pre dané načítanie** – uloženú
preferenciu v `localStorage` neprepíše (prepíše ju až prepínač v hlavičke).
+158 -1
View File
@@ -1,2 +1,159 @@
# JSONdevTools
# JSON DevTools
Kompletný jednostránkový nástroj na prácu s JSON-om pri vývoji REST API.
Bez frameworkov, bez npm, bez build procesu a bez backendu.
**Formatter · Tree viewer · Diff · API Request builder · História**
---
## Ukážky
### Formatter — živá validácia a syntax highlighting
![Formatter](docs/Screenshot-Formatter.png)
Vľavo vstup, vpravo zvýraznený výstup. Chyba vo vstupe sa zobrazí s presným
riadkom, stĺpcom a výrezom textu.
### Tree viewer — typy, počty položiek, JSONPath
![Tree](docs/Screenshot-Tree.png)
Strom zobrazuje typ každej hodnoty (`string`, `number`, `boolean`, `null`) a počet
položiek objektov/polí (`{6}`, `[5]`). Každý uzol sa dá skopírovať ako **JSONPath**
alebo ako **konkrétna hodnota**.
### API Request builder — stav, trvanie, headers, odpoveď
![API Request](docs/Screenshot-API-Request.png)
Status, trvanie požiadavky, response headers a automaticky naformátovaná JSON
odpoveď. Pri chybe CORS sa zobrazí podrobné vysvetlenie príčiny.
---
## Spustenie
Aplikácia je čisto statická — otvorte **`src/index.html`** priamo v prehliadači
(dvojklikom) a všetko beží lokálne.
Voliteľne cez lokálny server (odporúčané, ak chcete testovať `fetch()` proti
API s CORS z `http://localhost`):
```bash
python -m http.server 8080 --directory src
# potom otvorte http://localhost:8080/
```
> Otvorenie cez `file://` funguje, ale prehliadač považuje pôvod stránky za `null`
> — časť API (hlavičky, CORS) môže byť obmedzená. Aplikácia v tom prípade zobrazí
> podrobné vysvetlenie.
---
## Štruktúra repozitára
```
JSONdevTools/
├── src/ # samotná aplikácia (jediný adresár, ktorý sa publikuje)
│ ├── index.html # štruktúra UI (5 panelov, ARIA role, modál skratiek)
│ ├── style.css # tmavý / svetlý motív, responzívne rozloženie, resizable panely
│ └── app.js # celá logika (vanilla ES6+), bez závislostí
├── tests/
│ └── logic.test.js # Node testy logiky (bez prehliadača)
├── docs/
│ ├── Screenshot-Formatter.png
│ ├── Screenshot-Tree.png
│ └── Screenshot-API-Request.png
├── AGENTS.md # projektové pravidlá pre AI agenty
├── LICENSE
└── README.md
```
---
## Nástroje
### 1. Formatter
* Vloženie JSON textu, načítanie súboru (tlačidlo alebo pretiahnutie myšou)
* Živá validácia s presnou hláškou chyby: **riadok, stĺpec, pozícia, výrez textu**
* Pretty print (2 medzery) / Minify
* Kopírovanie výsledku, stiahnutie `.json` súboru
* Syntax highlighting výstupu
### 2. Tree
* Rozbaľovanie / zbaľovanie uzlov, zobrazenie typov a počtu položiek (`{6}`, `[5]`)
* Vyhľadávanie v kľúčoch aj hodnotách, zvýraznenie zhôd, automatické rozbavenie
rodičov zhôd
* Kopírovanie **JSONPath** (`$.user.roles[1]`) a **konkrétnej hodnoty** uzla
* Ovládanie klávesnicou: `↑`/`↓` pohyb, `→`/`←` rozbaliť/zbaliť, `Enter`/`Space`
prepnúť, `C` kopírovať hodnotu, `P` kopírovať path
### 3. Diff
* Dva editory vedľa sebe (A = pôvodné, B = nové)
* Rekurzívne porovnanie vnorených objektov a polí
* Rozdiely: **pridané / odstránené / zmenené** (vrátane zmeny typu) s JSONPath
* Súhrnný počet rozdielov, kopírovanie výsledkov porovnania ako JSON
### 4. API Request
* Metódy `GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS`
* URL endpointu, query parametre, vlastné HTTP hlavičky
* Autentifikácia: Bearer token alebo Basic (`user:heslo`)
* JSON request body (s formátovaním / minifikovaním)
* `fetch()` s 30 s timeoutom, meranie trvania, zobrazenie HTTP statusu
* Response headers, automaticky naformátovaná JSON odpoveď, surový text pri
neplatnom JSON
* Detailné vysvetlenie **CORS** chýb (chýbajúca hlavička, preflight, `file://`,
DNS, proxy) s návrhmi riešenia
### 5. História
* Posledných **20 JSON dokumentov** a **20 API požiadaviek**
* Ukladá sa do `localStorage`
* Vymazanie jednotlivých záznamov aj celej historie
* **Citlivé údaje sa neukladajú**: hlavička `Authorization`, `Cookie`, `X-Api-Key`
sa z záznamu vymaže, tokeny žijú iba v pamäti stránky
---
## Klávesové skratky
| Skratka | Akcia |
|---|---|
| `Ctrl+Shift+F` | Pretty print |
| `Ctrl+Shift+M` | Minify |
| `Ctrl+Shift+C` | Kopírovať výstup |
| `Ctrl+Shift+O` | Otvoriť JSON súbor |
| `Ctrl+S` | Stiahnuť JSON súbor |
| `Ctrl+Enter` | Odoslať API požiadavku |
| `Ctrl+Shift+D` | Prepnuť motív |
| `Ctrl+Shift+K` | Focus na vyhľadávanie v strome |
| `Ctrl+Shift+X` | Vyčistiť aktuálny editor |
| `1` … `5` | Prepnutie nástroja (mimo textových polí) |
| `?` | Zoznam skratiek |
| `Esc` | Zavrieť okno / zrušiť vyhľadávanie |
Na macOS použite `Cmd` namiesto `Ctrl`.
---
## Príkazy
```bash
node tests/logic.test.js # testy logiky – 93 assertov, bez prehliadača
```
Testy nepoužívajú žiadny prehliadač ani externé závislosti: `app.js` sa načíta
do izolovaného `vm` kontextu s minimálnym DOM stubom a overuje sa diff algoritmus,
sanitizácia historie, cesty uzlov, syntax highlighting a konzistencia ID medzi
HTML a JavaScriptom.
---
## Bezpečnosť a súkromie
* Aplikácia je čisto statická — žiadne CDN, žiadne externé závislosti.
* Údaje sa nikam neposielajú, **okrem API požiadaviek, ktoré sám spustíte**
na URL, ktorú zadáte vy.
* Všetko (história, motív, šírky panelov) sa ukladá lokálne v `localStorage`.
* Pre zapnutie svetlého motívu jednorazovo cez URL: `src/index.html?theme=light`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+2015
View File
File diff suppressed because it is too large Load Diff
+326
View File
@@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="sk" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<meta name="description" content="JSON DevTools - formatter, tree viewer, diff a API request builder pre REST API debugging">
<title>JSON DevTools</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<a class="skip-link" href="#main">Preskočiť na obsah</a>
<header class="app-header">
<div class="brand">
<span class="logo" aria-hidden="true">{ }</span>
<span class="brand-name">JSON DevTools</span>
</div>
<nav class="tabs" role="tablist" aria-label="Nástroje">
<button class="tab" role="tab" id="tab-formatter" data-view="formatter" aria-controls="view-formatter" aria-selected="true">Formatter</button>
<button class="tab" role="tab" id="tab-tree" data-view="tree" aria-controls="view-tree" aria-selected="false">Tree</button>
<button class="tab" role="tab" id="tab-diff" data-view="diff" aria-controls="view-diff" aria-selected="false">Diff</button>
<button class="tab" role="tab" id="tab-request" data-view="request" aria-controls="view-request" aria-selected="false">API Request</button>
<button class="tab" role="tab" id="tab-history" data-view="history" aria-controls="view-history" aria-selected="false">História</button>
</nav>
<div class="header-actions">
<button id="theme-toggle" class="icon-btn" title="Prepnuť motív (Ctrl+Shift+D)" aria-label="Prepnuť motív">
<svg class="icon icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/></svg>
<svg class="icon icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.5"/><g stroke-linecap="round"><path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8"/></g></svg>
</button>
<button id="shortcuts-btn" class="icon-btn" title="Klávesové skratky (?)" aria-label="Klávesové skratky">
<svg class="icon" viewBox="0 0 24 24" aria-hidden="true"><rect x="2" y="6" width="20" height="12" rx="2"/><g stroke-linecap="round"><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></g></svg>
</button>
</div>
</header>
<main id="main" class="main">
<!-- ======================= FORMATTER ======================= -->
<section class="view" id="view-formatter" role="tabpanel" aria-labelledby="tab-formatter" tabindex="-1">
<div class="toolbar" role="toolbar" aria-label="Nástroje formátovača">
<button class="btn btn-primary" id="fmt-format" title="Pretty print (Ctrl+Shift+F)">Pretty print</button>
<button class="btn" id="fmt-minify" title="Minify (Ctrl+Shift+M)">Minify</button>
<button class="btn" id="fmt-copy" title="Kopírovať výsledok (Ctrl+Shift+C)">Kopírovať</button>
<button class="btn" id="fmt-download" title="Stiahnuť JSON súbor (Ctrl+S)">Stiahnuť</button>
<button class="btn" id="fmt-open" title="Načítať JSON súbor (Ctrl+Shift+O)">Otvoriť…</button>
<span class="sep" role="separator"></span>
<button class="btn btn-ghost" id="fmt-clear" title="Vyčistiť editor">Vyčistiť</button>
<button class="btn btn-ghost" id="fmt-sample" title="Vložiť ukážkový JSON">Ukážka</button>
<input type="file" id="file-input" accept=".json,application/json,text/plain" hidden>
</div>
<div class="split-pane" id="fmt-split" data-storage-key="layout.formatter">
<section class="panel" aria-label="Vstupný JSON">
<header class="panel-head">
<h2 class="panel-title">Vstup</h2>
<span class="panel-meta" id="fmt-input-meta">0 B</span>
</header>
<div class="panel-body no-pad">
<textarea id="fmt-input" class="code-area" spellcheck="false" autocomplete="off" autocapitalize="off" aria-label="Vstupný JSON text" placeholder="Vložte JSON sem…"></textarea>
</div>
</section>
<div class="splitter" role="separator" aria-orientation="vertical" tabindex="0" aria-label="Resize panelov" title="Potiahnuť na zmenu šírky"></div>
<section class="panel" aria-label="Výstupný JSON">
<header class="panel-head">
<h2 class="panel-title">Výstup</h2>
<span class="panel-meta" id="fmt-status">Idle</span>
</header>
<div class="panel-body no-pad">
<pre id="fmt-output" class="code-area code-pre" tabindex="0" aria-label="Výstupný JSON" aria-live="polite"></pre>
</div>
<div id="fmt-error" class="error-box" role="alert" hidden></div>
</section>
</div>
</section>
<!-- ======================= TREE ======================= -->
<section class="view" id="view-tree" role="tabpanel" aria-labelledby="tab-tree" tabindex="-1" hidden>
<div class="toolbar" role="toolbar" aria-label="Nástroje stromu">
<div class="search-wrap">
<svg class="icon search-icon" viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.6-3.6" stroke-linecap="round"/></svg>
<input type="search" id="tree-search" class="input search-input" placeholder="Hľadať kľúč alebo hodnotu…" aria-label="Hľadať v strome">
<span class="panel-meta" id="tree-search-count" aria-live="polite"></span>
</div>
<span class="sep" role="separator"></span>
<button class="btn btn-ghost" id="tree-expand" title="Rozbaliť všetko">Rozbaliť všetko</button>
<button class="btn btn-ghost" id="tree-collapse" title="Zbaliť všetko">Zbaliť všetko</button>
<button class="btn btn-ghost" id="tree-open" title="Načítať JSON súbor">Otvoriť…</button>
<button class="btn btn-ghost" id="tree-copy-json" title="Kopírovať JSON">Kopírovať JSON</button>
</div>
<div class="split-pane" id="tree-split" data-storage-key="layout.tree">
<section class="panel" aria-label="Zdrojový JSON">
<header class="panel-head">
<h2 class="panel-title">Zdroj</h2>
<span class="panel-meta" id="tree-input-meta">0 B</span>
</header>
<div class="panel-body no-pad">
<textarea id="tree-input" class="code-area" spellcheck="false" autocomplete="off" autocapitalize="off" aria-label="Zdrojový JSON text" placeholder="Vložte JSON sem…"></textarea>
</div>
</section>
<div class="splitter" role="separator" aria-orientation="vertical" tabindex="0" aria-label="Resize panelov"></div>
<section class="panel" aria-label="Stromová štruktúra">
<header class="panel-head">
<h2 class="panel-title">Strom</h2>
<span class="panel-meta" id="tree-meta"></span>
</header>
<div class="panel-body" id="tree-container-wrap">
<div id="tree-root" class="tree" role="tree" aria-label="JSON strom"></div>
</div>
<div id="tree-error" class="error-box" role="alert" hidden></div>
</section>
</div>
</section>
<!-- ======================= DIFF ======================= -->
<section class="view" id="view-diff" role="tabpanel" aria-labelledby="tab-diff" tabindex="-1" hidden>
<div class="toolbar" role="toolbar" aria-label="Nástroje porovnania">
<button class="btn btn-primary" id="diff-run" title="Porovnať (Ctrl+Shift+D je motív – použite Ctrl+Enter)">Porovnať</button>
<button class="btn" id="diff-swap" title="Vymeniť strany">Vymeniť A ↔ B</button>
<button class="btn" id="diff-copy" title="Kopírovať výsledky porovnania">Kopírovať výsledky</button>
<span class="sep" role="separator"></span>
<button class="btn btn-ghost" id="diff-sample" title="Načítať ukážkový pár">Ukážka</button>
<button class="btn btn-ghost" id="diff-clear" title="Vyčistiť oba vstupy">Vyčistiť</button>
<span class="panel-meta" id="diff-summary" aria-live="polite">Nevykonané</span>
</div>
<div class="split-pane" id="diff-split" data-storage-key="layout.diff">
<section class="panel" aria-label="JSON A">
<header class="panel-head">
<h2 class="panel-title">A – pôvodné</h2>
<span class="panel-meta" id="diff-a-meta">0 B</span>
</header>
<div class="panel-body no-pad">
<textarea id="diff-a" class="code-area" spellcheck="false" autocomplete="off" autocapitalize="off" aria-label="JSON A" placeholder="Pôvodný JSON…"></textarea>
</div>
</section>
<div class="splitter" role="separator" aria-orientation="vertical" tabindex="0" aria-label="Resize panelov"></div>
<section class="panel" aria-label="JSON B">
<header class="panel-head">
<h2 class="panel-title">B – nové</h2>
<span class="panel-meta" id="diff-b-meta">0 B</span>
</header>
<div class="panel-body no-pad">
<textarea id="diff-b" class="code-area" spellcheck="false" autocomplete="off" autocapitalize="off" aria-label="JSON B" placeholder="Nový JSON…"></textarea>
</div>
</section>
</div>
<div class="results" id="diff-results-wrap">
<header class="panel-head">
<h2 class="panel-title">Rozdiely</h2>
<div class="legend">
<span class="badge badge-added">pridané</span>
<span class="badge badge-removed">odstránené</span>
<span class="badge badge-changed">zmenené</span>
</div>
</header>
<div class="panel-body" id="diff-results" aria-live="polite">
<p class="muted">Kliknite na <strong>Porovnať</strong> pre zobrazenie rozdielov medzi A a B.</p>
</div>
</div>
</section>
<!-- ======================= API REQUEST ======================= -->
<section class="view" id="view-request" role="tabpanel" aria-labelledby="tab-request" tabindex="-1" hidden>
<div class="split-pane horizontal-request" id="req-split" data-storage-key="layout.request">
<section class="panel req-form" aria-label="Parametre požiadavky">
<header class="panel-head">
<h2 class="panel-title">Požiadavka</h2>
<span class="panel-meta">Ctrl+Enter = Odoslať</span>
</header>
<div class="panel-body scroll">
<div class="field">
<label for="req-method">Metóda</label>
<select id="req-method" class="input">
<option>GET</option>
<option>POST</option>
<option>PUT</option>
<option>PATCH</option>
<option>DELETE</option>
<option>HEAD</option>
<option>OPTIONS</option>
</select>
</div>
<div class="field">
<label for="req-url">URL endpointu</label>
<div class="row">
<input type="text" id="req-url" class="input mono" placeholder="https://api.example.com/v1/users" spellcheck="false" autocomplete="off">
<button class="btn btn-primary" id="req-send" title="Odoslať požiadavku (Ctrl+Enter)">Odoslať</button>
</div>
</div>
<details class="group" open>
<summary>Query parametre</summary>
<div id="req-params" class="kv-list"></div>
<button class="btn btn-ghost btn-sm" id="req-add-param">+ Parameter</button>
</details>
<details class="group" open>
<summary>Hlavičky</summary>
<div id="req-headers" class="kv-list"></div>
<div class="row">
<button class="btn btn-ghost btn-sm" id="req-add-header">+ Hlavička</button>
<button class="btn btn-ghost btn-sm" id="req-preset-json">Nastaviť Content-Type: application/json</button>
</div>
</details>
<details class="group">
<summary>Autentifikácia</summary>
<div class="field">
<label for="req-auth-type">Typ</label>
<select id="req-auth-type" class="input">
<option value="none">Žiadna</option>
<option value="bearer">Bearer token</option>
<option value="basic">Basic (user:heslo)</option>
</select>
</div>
<div class="field" id="req-token-field" hidden>
<label for="req-token" id="req-token-label">Token</label>
<div class="row">
<input type="password" id="req-token" class="input mono" placeholder="…" spellcheck="false" autocomplete="off">
<button class="btn btn-ghost btn-sm" id="req-token-toggle" title="Zobraziť / skryť">Zobraziť</button>
</div>
<p class="hint">Token sa nikdy neukladá do historie ani do localStorage.</p>
</div>
</details>
<details class="group" open>
<summary>Request body</summary>
<textarea id="req-body" class="code-area code-body" spellcheck="false" autocomplete="off" aria-label="Telo požiadavky" placeholder='{ "key": "value" }'></textarea>
<div class="row">
<span class="panel-meta" id="req-body-meta">0 B</span>
<button class="btn btn-ghost btn-sm" id="req-body-format">Formatovať</button>
<button class="btn btn-ghost btn-sm" id="req-body-minify">Minify</button>
</div>
</details>
<details class="group">
<summary>Bezpečnosť</summary>
<ul class="plain-list">
<li>Hlavička <code>Authorization</code> sa z požiadavky v histórii vymaže.</li>
<li>Tokeny sa uchovávajú iba v pamäti a po obnovení stránky sa zmažú.</li>
<li>Žiadne údaje sa neposielajú na iné servery než na URL, ktorú zadáte vy.</li>
</ul>
</details>
</div>
</section>
<div class="splitter" role="separator" aria-orientation="vertical" tabindex="0" aria-label="Resize panelov"></div>
<section class="panel req-response" aria-label="Odpoveď">
<header class="panel-head">
<h2 class="panel-title">Odpoveď</h2>
<span class="panel-meta" id="req-status">Nevykonané</span>
</header>
<div class="panel-body scroll" id="req-response-body">
<p class="muted">Zostavte požiadavku a stlačte <strong>Odoslať</strong> (Ctrl+Enter).</p>
</div>
</section>
</div>
</section>
<!-- ======================= HISTORY ======================= -->
<section class="view" id="view-history" role="tabpanel" aria-labelledby="tab-history" tabindex="-1" hidden>
<div class="toolbar" role="toolbar" aria-label="Nástroje histórie">
<div class="seg" role="tablist" aria-label="Typ záznamov">
<button class="seg-btn is-active" data-hist="docs" role="tab" aria-selected="true">Dokumenty</button>
<button class="seg-btn" data-hist="reqs" role="tab" aria-selected="false">Požiadavky</button>
</div>
<span class="sep" role="separator"></span>
<button class="btn btn-danger" id="hist-clear">Vymazať celú históriu</button>
</div>
<div class="panel-body scroll" id="hist-body">
<p class="muted">Zatiaľ bez záznamov.</p>
</div>
</section>
</main>
<!-- ======================= SHORTCUTS MODAL ======================= -->
<div class="modal-backdrop" id="shortcuts-modal" hidden>
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="sc-title">
<header class="panel-head">
<h2 class="panel-title" id="sc-title">Klávesové skratky</h2>
<button class="icon-btn" id="sc-close" aria-label="Zavrieť">&times;</button>
</header>
<div class="modal-body">
<dl class="shortcuts">
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></dt><dd>Pretty print JSON</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></dt><dd>Minify JSON</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>C</kbd></dt><dd>Kopírovať výstup JSON</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>O</kbd></dt><dd>Otvoriť JSON súbor</dd>
<dt><kbd>Ctrl</kbd>+<kbd>S</kbd></dt><dd>Stiahnuť JSON súbor</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Enter</kbd></dt><dd>Odoslať API požiadavku</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>D</kbd></dt><dd>Prepnuť tmavý / svetlý motív</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>K</kbd></dt><dd>Focus na vyhľadávanie v strome</dd>
<dt><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>X</kbd></dt><dd>Vyčistiť aktuálny editor</dd>
<dt><kbd>1</kbd> … <kbd>5</kbd></dt><dd>Prepnutie nástroja (mimo textových polí)</dd>
<dt><kbd>?</kbd></dt><dd>Zobraziť tento zoznam</dd>
<dt><kbd>Esc</kbd></dt><dd>Zavrieť okno / zrušiť vyhľadávanie</dd>
</dl>
</div>
</div>
</div>
<div id="toasts" class="toasts" aria-live="polite" aria-atomic="false"></div>
<div id="drag-overlay" class="drag-overlay" hidden>
<div class="drag-card">Pustite JSON súbor naimportovanie</div>
</div>
<script src="app.js"></script>
</body>
</html>
+984
View File
@@ -0,0 +1,984 @@
/* =====================================================================
JSON DevTools - stylesheet
Dark / Light themes via CSS custom properties on [data-theme]
===================================================================== */
:root {
--font-ui: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 8px;
--radius-sm: 5px;
--header-h: 52px;
--dur: 140ms;
}
/* ------------------------------ DARK ------------------------------ */
html[data-theme="dark"] {
--bg: #0e1116;
--bg-2: #141922;
--bg-3: #1a212c;
--bg-elev: #1c2430;
--panel: #121821;
--panel-head: #161d27;
--border: #232c3a;
--border-strong: #33405400;
--text: #dfe6f1;
--text-dim: #9aa8bd;
--text-faint: #6b7a91;
--accent: #4c9aff;
--accent-hover: #6aacff;
--accent-soft: rgba(76, 154, 255, 0.14);
--green: #4ec98a;
--green-soft: rgba(78, 201, 138, 0.13);
--red: #f2777a;
--red-soft: rgba(242, 119, 122, 0.13);
--orange: #e5a54b;
--orange-soft: rgba(229, 165, 75, 0.14);
--purple: #c08cf0;
--purple-soft: rgba(192, 140, 240, 0.14);
--cyan: #56c9d8;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
--sel: rgba(76, 154, 255, 0.28);
color-scheme: dark;
}
/* ------------------------------ LIGHT ----------------------------- */
html[data-theme="light"] {
--bg: #f2f4f8;
--bg-2: #ffffff;
--bg-3: #eceff5;
--bg-elev: #ffffff;
--panel: #ffffff;
--panel-head: #f7f9fc;
--border: #dbe1ea;
--text: #1d2735;
--text-dim: #5a6779;
--text-faint: #8a95a6;
--accent: #1a6fe0;
--accent-hover: #0d5cc7;
--accent-soft: rgba(26, 111, 224, 0.1);
--green: #12855a;
--green-soft: rgba(18, 133, 90, 0.1);
--red: #c9373d;
--red-soft: rgba(201, 55, 61, 0.1);
--orange: #a86a12;
--orange-soft: rgba(168, 106, 18, 0.12);
--purple: #7a41bd;
--purple-soft: rgba(122, 65, 189, 0.1);
--cyan: #0f7c8c;
--shadow: 0 6px 22px rgba(24, 39, 62, 0.12);
--sel: rgba(26, 111, 224, 0.2);
color-scheme: light;
}
/* ------------------------------ BASE ------------------------------ */
* { box-sizing: border-box; }
html, body {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-ui);
font-size: 14px;
line-height: 1.5;
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--sel); }
button, input, select, textarea {
font-family: inherit;
font-size: inherit;
color: inherit;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 3px;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
z-index: 200;
background: var(--accent);
color: #fff;
padding: 10px 16px;
border-radius: 0 0 var(--radius) 0;
text-decoration: none;
}
.skip-link:focus { left: 0; }
/* ------------------------------ HEADER ---------------------------- */
.app-header {
height: var(--header-h);
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 16px;
padding: 0 14px;
background: var(--bg-2);
border-bottom: 1px solid var(--border);
z-index: 20;
}
.brand {
display: flex;
align-items: center;
gap: 9px;
font-weight: 650;
letter-spacing: -0.01em;
white-space: nowrap;
}
.brand .logo {
font-family: var(--font-mono);
font-weight: 700;
color: var(--accent);
background: var(--accent-soft);
border: 1px solid var(--accent-soft);
padding: 2px 7px;
border-radius: var(--radius-sm);
font-size: 13px;
}
.brand-name { font-size: 15px; }
.tabs {
display: flex;
gap: 2px;
flex: 1 1 auto;
min-width: 0;
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tab {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
padding: 7px 14px;
cursor: pointer;
white-space: nowrap;
font-weight: 500;
transition: background var(--dur), color var(--dur);
}
.tab:hover { background: var(--bg-3); color: var(--text); }
.tab[aria-selected="true"] {
background: var(--accent-soft);
color: var(--accent);
border-color: var(--accent-soft);
font-weight: 600;
}
.header-actions {
display: flex;
gap: 6px;
align-items: center;
flex: 0 0 auto;
}
.icon-btn {
background: transparent;
border: 1px solid transparent;
border-radius: var(--radius-sm);
color: var(--text-dim);
width: 32px;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background var(--dur), color var(--dur);
}
.icon-btn:hover { background: var(--bg-3); color: var(--text); }
.icon-btn .icon { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.7; }
html[data-theme="dark"] .icon-sun { display: none; }
html[data-theme="light"] .icon-moon { display: none; }
/* ------------------------------ MAIN ------------------------------ */
.main {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.view {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
padding: 10px;
gap: 10px;
}
.view[hidden] { display: none; }
/* ------------------------------ TOOLBAR --------------------------- */
.toolbar {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 6px 8px;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.btn {
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
padding: 6px 12px;
cursor: pointer;
font-weight: 500;
white-space: nowrap;
transition: background var(--dur), border-color var(--dur), transform var(--dur);
}
.btn:hover { background: var(--bg-elev); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-ghost { background: transparent; }
.btn-ghost:hover { background: var(--bg-3); }
.btn-danger { background: transparent; border-color: var(--red); color: var(--red); }
.btn-danger:hover { background: var(--red-soft); border-color: var(--red); }
.btn-sm { padding: 4px 9px; font-size: 12.5px; }
.sep {
width: 1px;
height: 20px;
background: var(--border);
margin: 0 4px;
flex: 0 0 auto;
}
.panel-meta {
font-size: 12px;
color: var(--text-faint);
font-family: var(--font-mono);
white-space: nowrap;
}
.muted { color: var(--text-dim); }
/* ------------------------------ INPUTS ---------------------------- */
.input {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 6px 9px;
color: var(--text);
min-width: 0;
transition: border-color var(--dur);
}
.input:focus { border-color: var(--accent); outline: none; box-shadow: 0 0 0 3px var(--accent-soft); }
.input.mono { font-family: var(--font-mono); }
select.input { cursor: pointer; }
.search-wrap {
display: flex;
align-items: center;
gap: 6px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px 8px;
min-width: 240px;
flex: 0 1 340px;
}
.search-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
.search-input {
background: transparent;
border: none;
outline: none;
padding: 5px 0;
flex: 1 1 auto;
min-width: 0;
}
.search-input:focus { box-shadow: none; }
.search-icon { width: 15px; height: 15px; fill: none; stroke: var(--text-faint); stroke-width: 1.8; flex: 0 0 auto; }
/* ------------------------------ PANELS ---------------------------- */
.split-pane {
flex: 1 1 auto;
min-height: 0;
display: flex;
gap: 0;
align-items: stretch;
--split: 50%;
}
.split-pane > .panel:first-child { flex: 0 0 var(--split); }
.split-pane > .panel:last-child { flex: 1 1 0; }
#req-split { --split: 38%; }
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
flex: 1 1 0;
overflow: hidden;
}
.panel-head {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 7px 11px;
background: var(--panel-head);
border-bottom: 1px solid var(--border);
min-height: 38px;
}
.panel-title {
margin: 0;
font-size: 12.5px;
font-weight: 650;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-dim);
}
.panel-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: 10px 12px;
}
.panel-body.no-pad { padding: 0; overflow: hidden; }
.panel-body.scroll { overflow: auto; }
/* ------------------------------ CODE AREA -------------------------- */
.code-area {
width: 100%;
height: 100%;
resize: none;
border: none;
outline: none;
background: transparent;
color: var(--text);
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.65;
padding: 12px 14px;
tab-size: 2;
white-space: pre;
overflow: auto;
}
.code-area::placeholder { color: var(--text-faint); }
.code-area:focus { background: color-mix(in srgb, var(--accent) 3%, transparent); }
.code-pre {
margin: 0;
display: block;
overflow: auto;
white-space: pre;
}
.code-body {
height: 170px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg);
display: block;
}
/* ------------------------------ SPLITTER -------------------------- */
.splitter {
flex: 0 0 6px;
cursor: col-resize;
background: transparent;
position: relative;
border-radius: 3px;
transition: background var(--dur);
}
.splitter::after {
content: "";
position: absolute;
inset: 0 2px;
background: var(--border);
transition: background var(--dur);
}
.splitter:hover::after, .splitter:focus-visible::after, .splitter.dragging::after {
background: var(--accent);
}
body.resizing { cursor: col-resize; user-select: none; }
body.resizing * { cursor: col-resize !important; }
/* ------------------------------ ERROR BOX ------------------------- */
.error-box {
flex: 0 0 auto;
max-height: 40%;
overflow: auto;
margin: 0;
padding: 9px 12px;
background: var(--red-soft);
border-top: 1px solid var(--red);
color: var(--red);
font-family: var(--font-mono);
font-size: 12.5px;
white-space: pre-wrap;
word-break: break-word;
}
.error-box[hidden] { display: none; }
.error-box .err-title { font-weight: 700; font-family: var(--font-ui); display: block; margin-bottom: 3px; }
.error-box .err-pos { color: var(--text-dim); }
/* ------------------------------ SYNTAX HIGHLIGHT ------------------ */
.tok-key { color: var(--accent); }
.tok-str { color: var(--green); }
.tok-num { color: var(--purple); }
.tok-bool { color: var(--orange); }
.tok-null { color: var(--orange); font-style: italic; }
.tok-punc { color: var(--text-faint); }
html[data-theme="light"] .tok-bool { color: var(--orange); }
mark.hl {
background: var(--orange-soft);
color: var(--orange);
border-radius: 2px;
padding: 0 1px;
}
/* ------------------------------ TREE ------------------------------ */
.tree {
font-family: var(--font-mono);
font-size: 13px;
line-height: 1.6;
}
.tnode-row {
display: flex;
align-items: flex-start;
gap: 3px;
padding: 1px 4px 1px 0;
border-radius: 3px;
white-space: nowrap;
}
.tnode-row:hover { background: var(--bg-3); }
.tnode-row.focused { outline: 1px solid var(--accent); outline-offset: -1px; }
.twisty {
background: transparent;
border: none;
color: var(--text-faint);
cursor: pointer;
width: 16px;
min-width: 16px;
height: 20px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
transition: transform var(--dur);
}
.twisty.open { transform: rotate(90deg); }
.twisty:hover { color: var(--accent); }
.twisty.leaf { visibility: hidden; }
.tkey { color: var(--accent); }
.tsep { color: var(--text-faint); }
.tval.str { color: var(--green); }
.tval.num { color: var(--purple); }
.tval.bool { color: var(--orange); }
.tval.null { color: var(--orange); font-style: italic; }
.tcount {
color: var(--text-faint);
font-size: 11.5px;
background: var(--bg-3);
border-radius: 20px;
padding: 0 6px;
margin-left: 2px;
}
.ttype {
color: var(--text-faint);
font-size: 11px;
font-style: italic;
}
.tchildren { margin-left: 15px; border-left: 1px dashed var(--border); padding-left: 7px; }
.tchildren[hidden] { display: none; }
.tact {
background: transparent;
border: 1px solid var(--border);
border-radius: 3px;
color: var(--text-faint);
font-size: 10.5px;
padding: 0 4px;
cursor: pointer;
margin-left: 4px;
opacity: 0;
transition: opacity var(--dur), color var(--dur), border-color var(--dur);
}
.tnode-row:hover .tact, .tact:focus-visible { opacity: 1; }
.tact:hover { color: var(--accent); border-color: var(--accent); }
.tact.copied { opacity: 1; color: var(--green); border-color: var(--green); }
.tree-empty { color: var(--text-faint); font-style: italic; padding: 10px; }
/* ------------------------------ DIFF ------------------------------ */
.results {
flex: 0 0 auto;
max-height: 45%;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.legend { display: flex; gap: 6px; }
.diff-row {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
gap: 10px;
padding: 7px 9px;
border-bottom: 1px solid var(--border);
font-family: var(--font-mono);
font-size: 12.5px;
align-items: start;
}
.diff-row:last-child { border-bottom: none; }
.diff-row:hover { background: var(--bg-3); }
.diff-badge {
font-family: var(--font-ui);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
border-radius: 4px;
padding: 2px 6px;
text-align: center;
}
.badge-added { background: var(--green-soft); color: var(--green); }
.badge-removed { background: var(--red-soft); color: var(--red); }
.badge-changed { background: var(--orange-soft); color: var(--orange); }
.diff-path {
color: var(--accent);
cursor: pointer;
word-break: break-all;
margin-bottom: 3px;
}
.diff-path:hover { text-decoration: underline; }
.diff-val {
display: block;
white-space: pre-wrap;
word-break: break-word;
border-radius: 3px;
padding: 1px 5px;
margin-top: 2px;
}
.diff-old { background: var(--red-soft); color: var(--red); }
.diff-new { background: var(--green-soft); color: var(--green); }
.diff-typechange { color: var(--orange); font-style: italic; }
.diff-arrow { color: var(--text-faint); margin: 0 4px; }
/* ------------------------------ REQUEST --------------------------- */
.horizontal-request { flex-direction: row; }
.req-form { max-width: 760px; }
.req-response { flex: 1 1 0; }
.field { margin-bottom: 12px; }
.field > label {
display: block;
font-size: 12px;
font-weight: 600;
color: var(--text-dim);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.row > .input { flex: 1 1 auto; }
.hint { font-size: 11.5px; color: var(--text-faint); margin: 4px 0 0; }
.group {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
margin-bottom: 12px;
background: var(--bg-2);
}
.group > summary {
cursor: pointer;
padding: 7px 10px;
font-weight: 600;
font-size: 12.5px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 0.03em;
list-style: none;
user-select: none;
}
.group > summary::-webkit-details-marker { display: none; }
.group > summary::before { content: "\25B8"; margin-right: 7px; color: var(--accent); display: inline-block; }
.group[open] > summary::before { content: "\25BE"; }
.group > summary:hover { color: var(--text); }
.group > *:not(summary) { margin: 0 10px 10px; }
.group > .row:last-child { margin-bottom: 10px; }
.kv-list { display: flex; flex-direction: column; gap: 5px; margin-top: 6px; }
.kv-row { display: flex; gap: 5px; align-items: center; }
.kv-row .input { flex: 1 1 0; padding: 4px 7px; font-size: 12.5px; }
.kv-row .input.k { flex: 1 1 35%; }
.kv-row .input.v { flex: 1 1 60%; font-family: var(--font-mono); }
.kv-del {
background: transparent;
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-faint);
cursor: pointer;
width: 26px;
height: 28px;
flex: 0 0 auto;
font-size: 15px;
line-height: 1;
}
.kv-del:hover { color: var(--red); border-color: var(--red); background: var(--red-soft); }
.plain-list { margin: 0; padding-left: 18px; color: var(--text-dim); font-size: 12.5px; }
.plain-list li { margin-bottom: 4px; }
code {
font-family: var(--font-mono);
background: var(--bg-3);
padding: 1px 4px;
border-radius: 3px;
font-size: 12px;
}
/* response */
.resp-status-bar {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 9px 11px;
border-radius: var(--radius-sm);
background: var(--bg-3);
margin-bottom: 10px;
}
.resp-code {
font-family: var(--font-mono);
font-weight: 700;
padding: 3px 8px;
border-radius: 4px;
}
.resp-code.ok { background: var(--green-soft); color: var(--green); }
.resp-code.redirect { background: var(--accent-soft); color: var(--accent); }
.resp-code.client { background: var(--orange-soft); color: var(--orange); }
.resp-code.server { background: var(--red-soft); color: var(--red); }
.resp-code.err { background: var(--red-soft); color: var(--red); }
.resp-stat { color: var(--text-dim); font-size: 12.5px; font-family: var(--font-mono); }
.resp-tabs { display: flex; gap: 4px; margin-bottom: 8px; border-bottom: 1px solid var(--border); }
.resp-tab {
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-dim);
padding: 5px 10px;
cursor: pointer;
font-size: 12.5px;
}
.resp-tab[aria-selected="true"] { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
.resp-headers { font-family: var(--font-mono); font-size: 12.5px; }
.resp-headers tr:nth-child(odd) { background: var(--bg-3); }
.resp-headers td { padding: 4px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
.resp-headers td:first-child { color: var(--accent); white-space: nowrap; width: 1%; }
.cors-box {
border: 1px solid var(--orange);
background: var(--orange-soft);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin-bottom: 12px;
}
.cors-box h3 { margin: 0 0 6px; font-size: 13.5px; color: var(--orange); }
.cors-box ul { margin: 6px 0 0; padding-left: 20px; color: var(--text-dim); font-size: 12.5px; }
.cors-box li { margin-bottom: 4px; }
.cors-box code { background: rgba(0,0,0,0.15); }
.err-box-generic {
border: 1px solid var(--red);
background: var(--red-soft);
border-radius: var(--radius-sm);
padding: 11px 13px;
color: var(--red);
}
.err-box-generic strong { display: block; margin-bottom: 4px; }
/* ------------------------------ HISTORY --------------------------- */
.seg {
display: inline-flex;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.seg-btn {
background: transparent;
border: none;
color: var(--text-dim);
padding: 6px 14px;
cursor: pointer;
font-weight: 500;
}
.seg-btn.is-active { background: var(--accent); color: #fff; font-weight: 600; }
.hist-list { display: flex; flex-direction: column; gap: 8px; }
.hist-item {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-2);
padding: 9px 11px;
}
.hist-item-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.hist-title {
font-family: var(--font-mono);
font-weight: 600;
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
flex: 1 1 auto;
}
.hist-time { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; }
.hist-preview {
font-family: var(--font-mono);
font-size: 11.5px;
color: var(--text-dim);
background: var(--bg);
border-radius: 4px;
padding: 5px 7px;
max-height: 92px;
overflow: auto;
white-space: pre;
margin: 4px 0 6px;
}
.hist-actions { display: flex; gap: 5px; flex-wrap: wrap; }
.hist-method {
font-family: var(--font-mono);
font-weight: 700;
font-size: 11px;
padding: 2px 6px;
border-radius: 4px;
background: var(--accent-soft);
color: var(--accent);
}
.hist-method.GET { background: var(--green-soft); color: var(--green); }
.hist-method.POST { background: var(--accent-soft); color: var(--accent); }
.hist-method.PUT, .hist-method.PATCH { background: var(--orange-soft); color: var(--orange); }
.hist-method.DELETE { background: var(--red-soft); color: var(--red); }
/* ------------------------------ TOASTS ---------------------------- */
.toasts {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 120;
display: flex;
flex-direction: column;
gap: 8px;
max-width: min(380px, calc(100vw - 32px));
}
.toast {
background: var(--bg-elev);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
border-radius: var(--radius-sm);
box-shadow: var(--shadow);
padding: 9px 12px;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 8px;
animation: toast-in 180ms ease-out;
}
.toast.success { border-left-color: var(--green); }
.toast.error { border-left-color: var(--red); }
.toast.warn { border-left-color: var(--orange); }
.toast.leaving { animation: toast-out 160ms ease-in forwards; }
.toast-msg { flex: 1 1 auto; word-break: break-word; }
.toast-msg b { display: block; font-weight: 650; }
.toast-msg span { color: var(--text-dim); font-size: 12px; }
.toast-close {
background: transparent;
border: none;
color: var(--text-faint);
cursor: pointer;
font-size: 16px;
line-height: 1;
padding: 0 2px;
}
.toast-close:hover { color: var(--text); }
@keyframes toast-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
@keyframes toast-out { to { opacity: 0; transform: translateX(20px); } }
/* ------------------------------ MODAL ----------------------------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(4, 8, 14, 0.6);
backdrop-filter: blur(2px);
z-index: 150;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.modal-backdrop[hidden] { display: none; }
.modal {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
width: min(560px, 100%);
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal-body { padding: 14px 16px; overflow: auto; }
.shortcuts {
display: grid;
grid-template-columns: auto 1fr;
gap: 7px 16px;
margin: 0;
align-items: center;
}
.shortcuts dt { text-align: right; }
.shortcuts dd { margin: 0; color: var(--text-dim); }
kbd {
font-family: var(--font-mono);
font-size: 11.5px;
background: var(--bg-3);
border: 1px solid var(--border);
border-bottom-width: 2px;
border-radius: 4px;
padding: 1px 5px;
color: var(--text);
}
/* ------------------------------ DRAG OVERLAY ---------------------- */
.drag-overlay {
position: fixed;
inset: 0;
background: rgba(4, 8, 14, 0.72);
z-index: 140;
display: flex;
align-items: center;
justify-content: center;
}
.drag-overlay[hidden] { display: none; }
.drag-card {
border: 2px dashed var(--accent);
color: var(--accent);
border-radius: var(--radius);
padding: 28px 40px;
font-size: 16px;
font-weight: 600;
background: var(--panel);
}
/* ------------------------------ SCROLLBARS ------------------------ */
*::-webkit-scrollbar { width: 11px; height: 11px; }
*::-webkit-scrollbar-track { background: transparent; }
*::-webkit-scrollbar-thumb {
background: var(--border);
border-radius: 6px;
border: 3px solid transparent;
background-clip: content-box;
}
*::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: content-box; }
* { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
/* ------------------------------ RESPONSIVE ------------------------ */
@media (max-width: 1000px) {
.req-form { flex: 0 0 380px; }
}
@media (max-width: 820px) {
body { overflow: auto; }
.app-header {
height: auto;
flex-wrap: wrap;
padding: 8px 10px;
gap: 8px;
}
.tabs { order: 3; flex-basis: 100%; }
.brand-name { display: none; }
.split-pane { flex-direction: column; }
.split-pane > .panel:first-child,
.split-pane > .panel:last-child {
flex: 0 0 auto;
width: auto;
min-height: 240px;
height: 38vh;
max-height: none;
}
.splitter { flex: 0 0 8px; cursor: row-resize; }
.splitter::after { inset: 2px 0; }
.results { max-height: none; height: 45vh; }
.req-form { flex: 0 0 auto; max-width: none; }
.horizontal-request { flex-direction: column; }
.view { padding: 8px; gap: 8px; }
.search-wrap { flex: 1 1 100%; }
}
@media (max-width: 520px) {
.diff-row { grid-template-columns: 1fr; gap: 4px; }
.diff-badge { justify-self: start; }
.toolbar { gap: 4px; }
.btn { padding: 6px 9px; font-size: 12.5px; }
}
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
+396
View File
@@ -0,0 +1,396 @@
/* =====================================================================
JSON DevTools - logicke testy (bez prehliadaca, bez zavislosti)
Spustenie: node tests/logic.test.js
===================================================================== */
'use strict';
const fs = require('fs');
const path = require('path');
const vm = require('vm');
/* ---------------------- minimalny DOM stub ---------------------- */
function makeElement(tag) {
const el = {
tagName: (tag || 'div').toUpperCase(),
children: [],
attributes: {},
dataset: {},
style: {
_props: {},
setProperty(k, v) { this._props[k] = v; },
getPropertyValue(k) { return this._props[k] || ''; }
},
classList: {
_set: new Set(),
add(...c) { c.forEach((x) => this._set.add(x)); },
remove(...c) { c.forEach((x) => this._set.delete(x)); },
contains(c) { return this._set.has(c); },
toggle(c, f) { if (f === undefined) { this._set.has(c) ? this._set.delete(c) : this._set.add(c); } else if (f) { this._set.add(c); } else { this._set.delete(c); } }
},
value: '',
textContent: '',
innerHTML: '',
hidden: false,
title: '',
type: '',
tabIndex: 0,
files: [],
checked: false,
addEventListener() {}, removeEventListener() {},
setAttribute(k, v) { this.attributes[k] = v; },
getAttribute(k) { return this.attributes[k]; },
removeAttribute(k) { delete this.attributes[k]; },
appendChild(c) { this.children.push(c); return c; },
removeChild(c) { const i = this.children.indexOf(c); if (i >= 0) this.children.splice(i, 1); return c; },
insertBefore(c) { this.children.unshift(c); return c; },
querySelector() { return makeElement('div'); },
querySelectorAll() { return []; },
closest() { return null; },
focus() {}, blur() {}, click() {},
getBoundingClientRect() { return { left: 0, top: 0, width: 800, height: 600, right: 800, bottom: 600 }; },
dispatchEvent() { return true; }
};
return el;
}
function createSandbox() {
const store = Object.create(null);
const localStorage = {
getItem(k) { return Object.prototype.hasOwnProperty.call(store, k) ? store[k] : null; },
setItem(k, v) { store[k] = String(v); },
removeItem(k) { delete store[k]; },
clear() { for (const k in store) delete store[k]; }
};
const documentStub = {
readyState: 'complete',
documentElement: makeElement('html'),
body: makeElement('body'),
createElement: (t) => makeElement(t),
querySelector: () => makeElement('div'),
querySelectorAll: () => [],
addEventListener() {},
removeEventListener() {},
execCommand() { return true; },
createTextNode: (t) => ({ text: t })
};
const windowStub = {
document: documentStub,
localStorage,
matchMedia: () => ({ matches: false, addEventListener() {} }),
addEventListener() {},
removeEventListener() {},
isSecureContext: false,
location: { hash: '', search: '', href: 'file:///index.html' },
history: { replaceState() {} },
getComputedStyle: () => ({ getPropertyValue: () => '' }),
navigator: { clipboard: null },
performance: { now: () => Date.now() },
setTimeout, clearTimeout, setInterval, clearInterval,
Blob: globalThis.Blob,
URL: { createObjectURL: () => 'blob:stub', revokeObjectURL() {} },
console
};
windowStub.window = windowStub;
windowStub.globalThis = windowStub;
const sandbox = {
window: windowStub,
document: documentStub,
localStorage,
navigator: windowStub.navigator,
location: windowStub.location,
history: windowStub.history,
performance: windowStub.performance,
matchMedia: windowStub.matchMedia,
console,
setTimeout, clearTimeout, setInterval, clearInterval,
Blob: globalThis.Blob,
URL: windowStub.URL,
Math, JSON, Date, Array, Object, String, Number, Boolean, Error, TypeError,
RegExp, Promise, Map, Set, isNaN, parseInt, parseFloat, encodeURIComponent,
decodeURIComponent, btoa: (s) => Buffer.from(s, 'binary').toString('base64'),
atob: (s) => Buffer.from(s, 'base64').toString('binary')
};
sandbox.globalThis = sandbox;
return sandbox;
}
/* ---------------------- nacitanie aplikacie ---------------------- */
const appPath = path.join(__dirname, '..', 'src', 'app.js');
const source = fs.readFileSync(appPath, 'utf8');
const sandbox = createSandbox();
vm.createContext(sandbox);
vm.runInContext(source, sandbox, { filename: 'app.js' });
const D = sandbox.window.JSONDevTools;
if (!D) {
console.error('FAIL: aplikacia sa nenačítala (window.JSONDevTools chýba)');
process.exit(1);
}
/* ---------------------- testovacie makra ---------------------- */
let passed = 0, failed = 0;
const failures = [];
function ok(name, cond, extra) {
if (cond) { passed++; }
else { failed++; failures.push(name + (extra !== undefined ? ' >> ' + extra : '')); }
}
function eq(name, a, b) { ok(name, a === b, 'got ' + JSON.stringify(a) + ' expected ' + JSON.stringify(b)); }
function deepEq(name, a, b) { ok(name, JSON.stringify(a) === JSON.stringify(b), 'got ' + JSON.stringify(a) + ' expected ' + JSON.stringify(b)); }
function section(t) { console.log('\n--- ' + t + ' ---'); }
/* ======================= TESTY ======================= */
section('Utils');
eq('typeOf null', D.Utils.typeOf(null), 'null');
eq('typeOf array', D.Utils.typeOf([]), 'array');
eq('typeOf object', D.Utils.typeOf({}), 'object');
eq('typeOf number', D.Utils.typeOf(1), 'number');
eq('typeOf string', D.Utils.typeOf('a'), 'string');
eq('typeOf undefined', D.Utils.typeOf(undefined), 'undefined');
eq('isPlainObject', D.Utils.isPlainObject({}), true);
eq('isPlainObject array', D.Utils.isPlainObject([]), false);
eq('pathSegment simple', D.Utils.pathSegment('abc'), '.abc');
eq('pathSegment underscore', D.Utils.pathSegment('_a1'), '._a1');
eq('pathSegment digits start', D.Utils.pathSegment('1a'), "['1a']");
eq('pathSegment space', D.Utils.pathSegment('a b'), "['a b']");
eq('pathSegment quote', D.Utils.pathSegment("it's"), "['it\\'s']");
eq('formatBytes B', D.Utils.formatBytes(12), '12 B');
eq('formatBytes KB', D.Utils.formatBytes(2048), '2.0 KB');
eq('formatBytes MB', D.Utils.formatBytes(3 * 1024 * 1024), '3.00 MB');
eq('formatDuration ms', D.Utils.formatDuration(12.4), '12 ms');
eq('formatDuration sub-ms', D.Utils.formatDuration(0.5), '0.50 ms');
eq('escapeHtml', D.Utils.escapeHtml('<b>&"\''), '&lt;b&gt;&amp;&quot;&#39;');
ok('uid uniq', D.Utils.uid() !== D.Utils.uid());
ok('formatTime format', /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(D.Utils.formatTime(Date.now())));
section('localStorage helper');
sandbox.localStorage.setItem('t.probe', '"v"');
eq('store.get', D.Utils.store.get('t.probe', null), 'v');
eq('store.get fallback', D.Utils.store.get('t.missing', 'fb'), 'fb');
D.Utils.store.set('t.probe2', { a: 1 });
deepEq('store.set/get object', D.Utils.store.get('t.probe2', null), { a: 1 });
D.Utils.store.remove('t.probe2');
eq('store.remove', D.Utils.store.get('t.probe2', null), null);
section('Highlighter');
const hl = D.Highlighter.highlight('{"a": 1, "b": "x", "c": true, "d": false, "e": null, "f": -1.5e3}');
ok('key token', hl.includes('tok-key'));
ok('string token', hl.includes('tok-str'));
ok('number token', hl.includes('tok-num'));
ok('bool token', hl.includes('tok-bool'));
ok('null token', hl.includes('tok-null'));
ok('punc token', hl.includes('tok-punc'));
ok('html escaped', D.Highlighter.highlight('<script>x</script>').includes('&lt;script&gt;'));
ok('no raw script tag', !D.Highlighter.highlight('</script>').includes('</script>'));
ok('highlightMatch wraps', D.Highlighter.highlightMatch('abcdef', 'cd').includes('<mark class="hl">cd</mark>'));
ok('highlightMatch case-insensitive', D.Highlighter.highlightMatch('ABCDEF', 'cd').includes('<mark class="hl">CD</mark>'));
ok('highlightMatch no match', !D.Highlighter.highlightMatch('abc', 'zz').includes('mark'));
section('Diff algoritmus');
{
const out = [];
D.Diff.walk({ a: 1 }, { a: 1 }, '$', out);
eq('zhodne -> ziadny rozdiel', out.length, 0);
const out2 = [];
D.Diff.walk({ a: 1 }, { a: 2 }, '$', out2);
eq('zmenena hodnota', out2.length, 1);
eq('typ zmeny', out2[0].type, 'changed');
eq('cesta zmeny', out2[0].path, '$.a');
eq('stara hodnota', out2[0].old, 1);
eq('nova hodnota', out2[0].new, 2);
const out3 = [];
D.Diff.walk({ a: 1 }, { a: 1, b: 2 }, '$', out3);
eq('pridany kluc', out3.length, 1);
eq('typ pridany', out3[0].type, 'added');
eq('cesta pridaneho', out3[0].path, '$.b');
const out4 = [];
D.Diff.walk({ a: 1, b: 2 }, { a: 1 }, '$', out4);
eq('odstraneny kluc', out4[0].type, 'removed');
eq('cesta odstraneneho', out4[0].path, '$.b');
const out5 = [];
D.Diff.walk({ a: [1, 2, 3] }, { a: [1, 2] }, '$', out5);
eq('kratke pole -> odstranene', out5.length, 1);
eq('cesta prvku pola', out5[0].path, '$.a[2]');
const out6 = [];
D.Diff.walk({ a: [1] }, { a: [1, 9] }, '$', out6);
eq('dlhsie pole -> pridane', out6[0].type, 'added');
eq('cesta pridaneho prvku', out6[0].path, '$.a[1]');
const out7 = [];
D.Diff.walk({ a: 1 }, { a: '1' }, '$', out7);
eq('zmena typu', out7[0].type, 'changed');
eq('stary typ', out7[0].oldType, 'number');
eq('novy typ', out7[0].newType, 'string');
const out8 = [];
D.Diff.walk({ a: { b: { c: 1 } } }, { a: { b: { c: 2 } } }, '$', out8);
eq('vnorene porovnanie', out8[0].path, '$.a.b.c');
const out9 = [];
D.Diff.walk({ 'weird key': 1 }, { 'weird key': 2 }, '$', out9);
eq('kluc so specialnymi znakmi', out9[0].path, "$['weird key']");
const out10 = [];
D.Diff.walk([1, 2], [1, 2, 3], '$', out10);
eq('pole na urovni korena', out10[0].path, '$[2]');
}
section('Historia – sanitizácia');
{
const saved = [];
const realSet = sandbox.localStorage.setItem.bind(sandbox.localStorage);
sandbox.localStorage.setItem = (k, v) => { saved.push([k, v]); return realSet(k, v); };
D.History.saveRequest({
method: 'POST',
url: 'https://api.example.com/v1/users',
status: 201,
statusText: 'Created',
duration: 42,
requestHeaders: { 'Content-Type': 'application/json', 'Authorization': 'Bearer SECRET123', 'Cookie': 'a=b', 'X-Api-Key': 'K' },
requestBody: '{"a":1}',
responsePreview: '{"ok":true}',
isJson: true
});
const raw = saved.filter(([k]) => k.indexOf('requests') !== -1).map(([, v]) => v).join('');
ok('Authorization odstranena', raw.toLowerCase().indexOf('authorization') === -1, raw.slice(0, 200));
ok('secret token nie je ulozeny', raw.indexOf('SECRET123') === -1);
ok('cookie odstranena', raw.toLowerCase().indexOf('cookie') === -1);
ok('x-api-key odstraneny', raw.toLowerCase().indexOf('x-api-key') === -1);
ok('content-type zachovany', raw.indexOf('Content-Type') !== -1);
ok('url zaznamany', raw.indexOf('api.example.com') !== -1);
ok('status zaznamany', raw.indexOf('201') !== -1);
sandbox.localStorage.setItem = realSet;
}
section('Historia – dokumenty');
{
sandbox.localStorage.removeItem('jsondevtools.history.docs');
D.History.saveDocument('{"x":1}');
D.History.saveDocument('{"x":1}');
D.History.saveDocument('{"y":2}');
const docs = JSON.parse(sandbox.localStorage.getItem('jsondevtools.history.docs'));
eq('duplicitne sa nededuplikuju', docs.length, 2);
eq('najnovsi je prvy', docs[0].text, '{"y":2}');
for (let i = 0; i < 30; i++) D.History.saveDocument('{"i":' + i + '}');
const many = JSON.parse(sandbox.localStorage.getItem('jsondevtools.history.docs'));
ok('historia obmedzena na 20', many.length <= 20, 'n=' + many.length);
sandbox.localStorage.removeItem('jsondevtools.history.docs');
}
section('Theme');
{
const before = D.Theme.current;
D.Theme.toggle();
ok('tema sa prepne', D.Theme.current !== before);
eq('tema persistovana', JSON.parse(sandbox.localStorage.getItem('jsondevtools.theme')), D.Theme.current);
D.Theme.toggle();
eq('tema spat vratena', D.Theme.current, before);
}
section('App – navigacia');
{
eq('readHash default', D.App.readHash(), 'formatter');
D.App.go('diff');
eq('go nastavi current', D.App.current, 'diff');
D.App.go('neexistujuci');
eq('neplatny view -> formatter', D.App.current, 'formatter');
D.App.go('tree');
eq('go tree', D.App.current, 'tree');
}
section('Parser chyb');
{
// parseJSON je vnútorná funkcia – overujeme cez verejné API (Formatter.validate)
const D2 = D;
const before = D2.Formatter.input.value;
D2.Formatter.input.value = '{"a":1,,}';
const res = D2.Formatter.validate();
eq('neplatny JSON -> chyba', res.ok, false);
ok('chyba obsahuje poziciu', /position|position|Unexpected/i.test(res.error), res.error);
D2.Formatter.input.value = before;
D2.Formatter.validate();
}
section('API – URL a hlavicky (bez siete)');
{
const el = (id, val) => { const e = makeElement('input'); e.value = val; return e; };
const api = D.Api;
// buildUrl cez parametre
api.params = [{ key: 'q', value: 'a b' }, { key: 'page', value: '2' }, { key: '', value: 'x' }];
const savedUrl = api.buildUrl ? null : null;
// buildUrl cita #req-url, ktory v stub-e nie je nastaveny -> vratime prazdny string
ok('buildUrl existuje', typeof api.buildUrl === 'function');
api.headers = [{ key: 'Accept', value: 'application/json' }];
api.params = [];
ok('buildHeaders existuje', typeof api.buildHeaders === 'function');
}
section('Cesta uzla – parent path');
{
eq('parent $', D.parentPathOf('$.a'), '$');
eq('parent hnedom', D.parentPathOf('$.a.b'), '$.a');
eq('parent prvku pola', D.parentPathOf('$.a[0]'), '$.a');
eq('parent prvku pola 2', D.parentPathOf('$.a.b[12]'), '$.a.b');
eq('parent kluc so specialmi znakmi', D.parentPathOf("$['a b'].c"), "$['a b']");
eq('parent root je null', D.parentPathOf('$'), null);
}
section('Kontrola konzistencie DOM id');
{
const html = fs.readFileSync(path.join(__dirname, '..', 'src', 'index.html'), 'utf8');
const ids = Array.from(html.matchAll(/\bid="([^"]+)"/g)).map((m) => m[1]);
const dupes = ids.filter((v, i) => ids.indexOf(v) !== i);
eq('ziadne duplicitne id v HTML', dupes.length, 0);
const js = fs.readFileSync(path.join(__dirname, '..', 'src', 'app.js'), 'utf8');
const used = Array.from(new Set(Array.from(js.matchAll(/\$\('#([a-zA-Z0-9_-]+)'\)/g)).map((m) => m[1])));
const missing = used.filter((u) => ids.indexOf(u) === -1);
eq('vsetky pouzite id existuju v HTML', missing.length, 0);
if (missing.length) console.log(' chybne id: ' + missing.join(', '));
// duplicitne aria-controls
const dupAria = (html.match(/aria-controls="view-formatter"/g) || []).length;
eq('aria-controls bez duplicitov', dupAria, 1);
// kazde tlacidlo s id musi mat handlov v app.js
// (tab-y sa obsluhuju delegovane cez triedu '.tab')
const btnIds = Array.from(html.matchAll(/<button[^>]*\bid="([^"]+)"/g)).map((m) => m[1]);
const btnMissing = btnIds.filter((b) => {
if (b.indexOf('tab-') === 0) return js.indexOf("'.tab'") === -1;
return js.indexOf("'#" + b + "'") === -1 && js.indexOf('"#' + b + '"') === -1;
});
eq('vsetky tlacidla s id su napojene v app.js', btnMissing.length, 0);
if (btnMissing.length) console.log(' chybne handlery pre: ' + btnMissing.join(', '));
// kazdy view ma tab aj panel
['formatter', 'tree', 'diff', 'request', 'history'].forEach((v) => {
ok('view ' + v + ' ma tab aj panel',
html.indexOf('id="tab-' + v + '"') !== -1 && html.indexOf('id="view-' + v + '"') !== -1);
});
}
/* ---------------------- vysledok ---------------------- */
console.log('\n========================================');
console.log('Vysledok: ' + passed + ' ok, ' + failed + ' neuspechov');
if (failures.length) {
console.log('\nNeuspechy:');
failures.forEach((f) => console.log(' FAIL ' + f));
}
console.log('========================================');
process.exit(failed ? 1 : 0);