@zjy4fun/json-open 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +3 -2
- package/README.md +67 -64
- package/bin/json.js +357 -7
- package/package.json +6 -2
|
@@ -18,9 +18,8 @@ jobs:
|
|
|
18
18
|
|
|
19
19
|
- uses: actions/setup-node@v4
|
|
20
20
|
with:
|
|
21
|
-
node-version:
|
|
21
|
+
node-version: 22
|
|
22
22
|
registry-url: 'https://registry.npmjs.org'
|
|
23
|
-
cache: 'npm'
|
|
24
23
|
|
|
25
24
|
- run: npm ci
|
|
26
25
|
- run: npm test --if-present
|
|
@@ -33,3 +32,5 @@ jobs:
|
|
|
33
32
|
[ "$TAG_VERSION" = "$PKG_VERSION" ] || (echo "Tag $TAG_VERSION != package.json $PKG_VERSION" && exit 1)
|
|
34
33
|
|
|
35
34
|
- run: npm publish --access public --provenance
|
|
35
|
+
env:
|
|
36
|
+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
package/README.md
CHANGED
|
@@ -1,56 +1,59 @@
|
|
|
1
1
|
# json-open
|
|
2
2
|
|
|
3
|
-
Open JSON in your browser
|
|
3
|
+
Open JSON in your browser as a collapsible tree view.
|
|
4
4
|
|
|
5
|
-
> A tiny CLI for
|
|
6
|
-
|
|
7
|
-
## Demo
|
|
5
|
+
> A tiny CLI for quickly inspecting JSON from APIs, logs, or inline text.
|
|
8
6
|
|
|
9
7
|

|
|
10
8
|
|
|
11
9
|
---
|
|
12
10
|
|
|
13
|
-
## Why
|
|
11
|
+
## Why json-open?
|
|
14
12
|
|
|
15
|
-
Reading JSON in
|
|
13
|
+
Reading JSON in terminal is often painful:
|
|
16
14
|
|
|
17
15
|
- Long output is hard to scan
|
|
18
16
|
- Deep nesting is hard to understand quickly
|
|
19
|
-
-
|
|
17
|
+
- Full-featured tools can feel heavy for quick checks
|
|
20
18
|
|
|
21
|
-
`json-open` keeps
|
|
19
|
+
`json-open` keeps it simple: **pipe JSON in, inspect it visually in seconds**.
|
|
22
20
|
|
|
23
21
|
---
|
|
24
22
|
|
|
25
23
|
## Features
|
|
26
24
|
|
|
27
|
-
- ✅
|
|
28
|
-
- ✅
|
|
29
|
-
- ✅
|
|
30
|
-
- ✅
|
|
31
|
-
- ✅
|
|
32
|
-
- ✅
|
|
25
|
+
- ✅ Read from stdin (pipe)
|
|
26
|
+
- ✅ Read inline JSON text
|
|
27
|
+
- ✅ Open browser automatically (macOS / Linux / Windows)
|
|
28
|
+
- ✅ Collapsible tree view
|
|
29
|
+
- ✅ Expand all / Collapse all
|
|
30
|
+
- ✅ Local temp file rendering (no remote upload)
|
|
31
|
+
- ✅ **Auto-parse serialized JSON strings** (escaped/double-encoded)
|
|
32
|
+
- ✅ **Deep nested JSON string expansion** (auto-unwrap JSON strings inside objects)
|
|
33
33
|
|
|
34
34
|
---
|
|
35
35
|
|
|
36
36
|
## Installation
|
|
37
37
|
|
|
38
|
-
###
|
|
38
|
+
### npm (recommended)
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
```bash
|
|
41
|
+
npm i -g @zjy4fun/json-open
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
After install, use the global command:
|
|
41
45
|
|
|
42
46
|
```bash
|
|
43
|
-
|
|
44
|
-
echo "//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN" >> ~/.npmrc
|
|
47
|
+
json --version
|
|
45
48
|
```
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
### Run once with npx (no global install)
|
|
48
51
|
|
|
49
52
|
```bash
|
|
50
|
-
|
|
53
|
+
npx @zjy4fun/json-open '{"hello":"world"}'
|
|
51
54
|
```
|
|
52
55
|
|
|
53
|
-
###
|
|
56
|
+
### Local development
|
|
54
57
|
|
|
55
58
|
```bash
|
|
56
59
|
git clone https://github.com/zjy4fun/json-open.git
|
|
@@ -59,11 +62,9 @@ npm install
|
|
|
59
62
|
npm link
|
|
60
63
|
```
|
|
61
64
|
|
|
62
|
-
After that, the `json` command is available globally.
|
|
63
|
-
|
|
64
65
|
---
|
|
65
66
|
|
|
66
|
-
## Quick
|
|
67
|
+
## Quick Start
|
|
67
68
|
|
|
68
69
|
```bash
|
|
69
70
|
# 1) API response
|
|
@@ -76,41 +77,25 @@ json '{"hello":"world","list":[1,2,3]}'
|
|
|
76
77
|
cat response.json | json
|
|
77
78
|
```
|
|
78
79
|
|
|
79
|
-
The command opens your browser and shows a JSON tree
|
|
80
|
+
The command opens your default browser and shows a structured JSON tree.
|
|
80
81
|
|
|
81
82
|
---
|
|
82
83
|
|
|
83
|
-
##
|
|
84
|
-
|
|
85
|
-
1. **API debugging**
|
|
86
|
-
Inspect response shape quickly, especially nested data.
|
|
87
|
-
|
|
88
|
-
2. **Backend/frontend integration checks**
|
|
89
|
-
Verify missing fields or type mismatches after API changes.
|
|
90
|
-
|
|
91
|
-
3. **Ad-hoc JSON inspection**
|
|
92
|
-
Visualize JSON copied from logs, queues, or snapshots.
|
|
93
|
-
|
|
94
|
-
4. **Team discussion/demo**
|
|
95
|
-
Share a clearer structure view when discussing payloads.
|
|
96
|
-
|
|
97
|
-
---
|
|
98
|
-
|
|
99
|
-
## Command behavior
|
|
84
|
+
## CLI Usage
|
|
100
85
|
|
|
101
86
|
```bash
|
|
102
|
-
json
|
|
87
|
+
json [inline-json]
|
|
103
88
|
```
|
|
104
89
|
|
|
105
|
-
Input
|
|
90
|
+
Input sources:
|
|
106
91
|
|
|
107
|
-
- stdin (pipe)
|
|
108
|
-
-
|
|
92
|
+
- `stdin` (pipe)
|
|
93
|
+
- Inline JSON argument
|
|
109
94
|
|
|
110
95
|
Options:
|
|
111
96
|
|
|
112
|
-
- `-h, --help`
|
|
113
|
-
- `-v, --version`
|
|
97
|
+
- `-h, --help` Show help
|
|
98
|
+
- `-v, --version` Show version
|
|
114
99
|
|
|
115
100
|
Examples:
|
|
116
101
|
|
|
@@ -120,19 +105,43 @@ json --version
|
|
|
120
105
|
json '{"ok":true}'
|
|
121
106
|
```
|
|
122
107
|
|
|
123
|
-
If no input is provided,
|
|
108
|
+
If no input is provided, usage help is printed.
|
|
124
109
|
|
|
125
110
|
---
|
|
126
111
|
|
|
127
|
-
##
|
|
112
|
+
## Serialized JSON String Support
|
|
128
113
|
|
|
129
|
-
|
|
114
|
+
`json-open` now automatically handles serialized/escaped JSON strings — a common pain point when working with logs, databases, and APIs.
|
|
130
115
|
|
|
131
|
-
|
|
132
|
-
-
|
|
133
|
-
|
|
116
|
+
```bash
|
|
117
|
+
# Double-encoded JSON string (e.g. from database or API response body)
|
|
118
|
+
json '"{\"name\":\"test\",\"age\":25}"'
|
|
119
|
+
# → auto-detects and parses as { "name": "test", "age": 25 }
|
|
134
120
|
|
|
135
|
-
|
|
121
|
+
# Nested JSON strings inside objects
|
|
122
|
+
json '{"status":"ok","data":"{\"users\":[{\"id\":1}]}"}'
|
|
123
|
+
# → auto-expands "data" field into a real JSON tree
|
|
124
|
+
|
|
125
|
+
# Multi-level serialization
|
|
126
|
+
json '"\"[1,2,3]\""'
|
|
127
|
+
# → recursively unwraps to [1, 2, 3]
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
This works for:
|
|
131
|
+
- Escaped JSON from `JSON.stringify()` output
|
|
132
|
+
- Log files with embedded JSON payloads
|
|
133
|
+
- API responses where a field contains a JSON string
|
|
134
|
+
- Database columns storing serialized JSON
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## Common Use Cases
|
|
139
|
+
|
|
140
|
+
- API debugging (inspect response shape quickly)
|
|
141
|
+
- Backend/frontend contract checks
|
|
142
|
+
- Ad-hoc JSON visualization from logs
|
|
143
|
+
- Payload discussion/demo with teammates
|
|
144
|
+
- **Inspecting serialized JSON from databases or message queues**
|
|
136
145
|
|
|
137
146
|
---
|
|
138
147
|
|
|
@@ -142,24 +151,18 @@ Issues and PRs are welcome.
|
|
|
142
151
|
|
|
143
152
|
### Good contribution ideas
|
|
144
153
|
|
|
145
|
-
- Better
|
|
154
|
+
- Better JSON parse error location hints
|
|
146
155
|
- Theme switch (light/dark)
|
|
147
156
|
- Direct file path support (e.g. `json ./data.json`)
|
|
148
|
-
-
|
|
157
|
+
- Search / highlight / copy JSON path
|
|
149
158
|
|
|
150
|
-
### Local
|
|
159
|
+
### Local dev
|
|
151
160
|
|
|
152
161
|
```bash
|
|
153
162
|
npm install
|
|
154
163
|
npm test
|
|
155
164
|
```
|
|
156
165
|
|
|
157
|
-
Before submitting:
|
|
158
|
-
|
|
159
|
-
- Ensure code runs correctly
|
|
160
|
-
- Ensure README examples still work
|
|
161
|
-
- Keep changes focused and clear
|
|
162
|
-
|
|
163
166
|
---
|
|
164
167
|
|
|
165
168
|
## License
|
package/bin/json.js
CHANGED
|
@@ -100,8 +100,11 @@ function valueToHtml(value, key = null) {
|
|
|
100
100
|
return `<div class=\"line\">${keyHtml}<span>${escapeHtml(String(value))}</span></div>`
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
function toHtml(jsonObj) {
|
|
104
|
-
const
|
|
103
|
+
function toHtml(jsonObj, deepParsedObj) {
|
|
104
|
+
const rawBody = valueToHtml(jsonObj)
|
|
105
|
+
const parsedBody = valueToHtml(deepParsedObj)
|
|
106
|
+
// 检测是否有差异(有嵌套 JSON 字符串可以展开)
|
|
107
|
+
const hasDiff = JSON.stringify(jsonObj) !== JSON.stringify(deepParsedObj)
|
|
105
108
|
return `<!doctype html>
|
|
106
109
|
<html lang=\"en\">
|
|
107
110
|
<head>
|
|
@@ -130,6 +133,8 @@ function toHtml(jsonObj) {
|
|
|
130
133
|
margin-bottom: 16px;
|
|
131
134
|
display: flex;
|
|
132
135
|
gap: 8px;
|
|
136
|
+
align-items: center;
|
|
137
|
+
flex-wrap: wrap;
|
|
133
138
|
}
|
|
134
139
|
button {
|
|
135
140
|
border: 1px solid #475569;
|
|
@@ -142,6 +147,51 @@ function toHtml(jsonObj) {
|
|
|
142
147
|
button:hover {
|
|
143
148
|
background: #334155;
|
|
144
149
|
}
|
|
150
|
+
/* 开关样式 */
|
|
151
|
+
.toggle-wrap {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
gap: 8px;
|
|
155
|
+
margin-left: auto;
|
|
156
|
+
font-size: 13px;
|
|
157
|
+
color: #94a3b8;
|
|
158
|
+
}
|
|
159
|
+
.toggle-wrap.hidden { display: none; }
|
|
160
|
+
.toggle {
|
|
161
|
+
position: relative;
|
|
162
|
+
width: 40px;
|
|
163
|
+
height: 22px;
|
|
164
|
+
cursor: pointer;
|
|
165
|
+
}
|
|
166
|
+
.toggle input {
|
|
167
|
+
opacity: 0;
|
|
168
|
+
width: 0;
|
|
169
|
+
height: 0;
|
|
170
|
+
}
|
|
171
|
+
.toggle .slider {
|
|
172
|
+
position: absolute;
|
|
173
|
+
inset: 0;
|
|
174
|
+
background: #334155;
|
|
175
|
+
border-radius: 22px;
|
|
176
|
+
transition: background 0.2s;
|
|
177
|
+
}
|
|
178
|
+
.toggle .slider::before {
|
|
179
|
+
content: '';
|
|
180
|
+
position: absolute;
|
|
181
|
+
width: 16px;
|
|
182
|
+
height: 16px;
|
|
183
|
+
left: 3px;
|
|
184
|
+
bottom: 3px;
|
|
185
|
+
background: #e2e8f0;
|
|
186
|
+
border-radius: 50%;
|
|
187
|
+
transition: transform 0.2s;
|
|
188
|
+
}
|
|
189
|
+
.toggle input:checked + .slider {
|
|
190
|
+
background: #58a6ff;
|
|
191
|
+
}
|
|
192
|
+
.toggle input:checked + .slider::before {
|
|
193
|
+
transform: translateX(18px);
|
|
194
|
+
}
|
|
145
195
|
details {
|
|
146
196
|
margin-left: 16px;
|
|
147
197
|
}
|
|
@@ -174,23 +224,280 @@ function toHtml(jsonObj) {
|
|
|
174
224
|
.null { color: #cbd5e1; }
|
|
175
225
|
.symbol { color: #c4b5fd; }
|
|
176
226
|
.meta { color: #64748b; }
|
|
227
|
+
/* 搜索框 */
|
|
228
|
+
.search-wrap {
|
|
229
|
+
display: flex;
|
|
230
|
+
align-items: center;
|
|
231
|
+
gap: 6px;
|
|
232
|
+
}
|
|
233
|
+
.search-wrap input {
|
|
234
|
+
border: 1px solid #475569;
|
|
235
|
+
background: #1e293b;
|
|
236
|
+
color: #e2e8f0;
|
|
237
|
+
border-radius: 8px;
|
|
238
|
+
padding: 7px 12px;
|
|
239
|
+
font-size: 13px;
|
|
240
|
+
font-family: inherit;
|
|
241
|
+
width: 200px;
|
|
242
|
+
outline: none;
|
|
243
|
+
transition: border-color 0.2s;
|
|
244
|
+
}
|
|
245
|
+
.search-wrap input:focus {
|
|
246
|
+
border-color: #58a6ff;
|
|
247
|
+
}
|
|
248
|
+
.search-wrap input::placeholder {
|
|
249
|
+
color: #64748b;
|
|
250
|
+
}
|
|
251
|
+
.search-count {
|
|
252
|
+
font-size: 12px;
|
|
253
|
+
color: #64748b;
|
|
254
|
+
min-width: 60px;
|
|
255
|
+
}
|
|
256
|
+
.search-nav button {
|
|
257
|
+
padding: 4px 8px;
|
|
258
|
+
font-size: 12px;
|
|
259
|
+
border-radius: 6px;
|
|
260
|
+
}
|
|
261
|
+
/* 搜索高亮 */
|
|
262
|
+
mark.highlight {
|
|
263
|
+
background: #f0883e;
|
|
264
|
+
color: #0d1117;
|
|
265
|
+
border-radius: 2px;
|
|
266
|
+
padding: 0 1px;
|
|
267
|
+
}
|
|
268
|
+
mark.highlight.current {
|
|
269
|
+
background: #58a6ff;
|
|
270
|
+
color: #fff;
|
|
271
|
+
box-shadow: 0 0 0 2px rgba(88,166,255,0.4);
|
|
272
|
+
}
|
|
273
|
+
/* 搜索时隐藏不匹配的行 */
|
|
274
|
+
.search-active .line.hidden-by-search,
|
|
275
|
+
.search-active li.hidden-by-search {
|
|
276
|
+
display: none;
|
|
277
|
+
}
|
|
177
278
|
</style>
|
|
178
279
|
</head>
|
|
179
280
|
<body>
|
|
180
281
|
<div class=\"toolbar\">
|
|
181
282
|
<button id=\"expand-all\">Expand all</button>
|
|
182
283
|
<button id=\"collapse-all\">Collapse all</button>
|
|
284
|
+
<div class=\"search-wrap\">
|
|
285
|
+
<input type=\"text\" id=\"search-input\" placeholder=\"Search...\" autocomplete=\"off\" />
|
|
286
|
+
<span class=\"search-count\" id=\"search-count\"></span>
|
|
287
|
+
<span class=\"search-nav\">
|
|
288
|
+
<button id=\"search-prev\" title=\"Previous (Shift+Enter)\">▲</button>
|
|
289
|
+
<button id=\"search-next\" title=\"Next (Enter)\">▼</button>
|
|
290
|
+
</span>
|
|
291
|
+
</div>
|
|
292
|
+
<div class=\"toggle-wrap${hasDiff ? '' : ' hidden'}\" title=\"Parse embedded JSON strings inside values\">
|
|
293
|
+
<span>Parse JSON strings</span>
|
|
294
|
+
<label class=\"toggle\">
|
|
295
|
+
<input type=\"checkbox\" id=\"deep-parse-toggle\" />
|
|
296
|
+
<span class=\"slider\"></span>
|
|
297
|
+
</label>
|
|
298
|
+
</div>
|
|
183
299
|
</div>
|
|
184
|
-
<main>${
|
|
300
|
+
<main id=\"raw-view\">${rawBody}</main>
|
|
301
|
+
<main id=\"parsed-view\" style=\"display:none\">${parsedBody}</main>
|
|
185
302
|
<script>
|
|
186
|
-
const details = () => Array.from(document.querySelectorAll('details'))
|
|
303
|
+
const details = () => Array.from(document.querySelectorAll('main:not([style*=\"display:none\"]) details'))
|
|
187
304
|
document.getElementById('expand-all').addEventListener('click', () => details().forEach((d) => d.open = true))
|
|
188
305
|
document.getElementById('collapse-all').addEventListener('click', () => details().forEach((d) => d.open = false))
|
|
306
|
+
|
|
307
|
+
const toggle = document.getElementById('deep-parse-toggle')
|
|
308
|
+
const rawView = document.getElementById('raw-view')
|
|
309
|
+
const parsedView = document.getElementById('parsed-view')
|
|
310
|
+
if (toggle) {
|
|
311
|
+
toggle.addEventListener('change', () => {
|
|
312
|
+
if (toggle.checked) {
|
|
313
|
+
rawView.style.display = 'none'
|
|
314
|
+
parsedView.style.display = ''
|
|
315
|
+
} else {
|
|
316
|
+
rawView.style.display = ''
|
|
317
|
+
parsedView.style.display = 'none'
|
|
318
|
+
}
|
|
319
|
+
// 切换视图后重新搜索
|
|
320
|
+
if (searchInput.value.trim()) doSearch()
|
|
321
|
+
})
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ===== 搜索功能 =====
|
|
325
|
+
const searchInput = document.getElementById('search-input')
|
|
326
|
+
const searchCount = document.getElementById('search-count')
|
|
327
|
+
const searchPrev = document.getElementById('search-prev')
|
|
328
|
+
const searchNext = document.getElementById('search-next')
|
|
329
|
+
let highlights = []
|
|
330
|
+
let currentIdx = -1
|
|
331
|
+
|
|
332
|
+
function getActiveView() {
|
|
333
|
+
return parsedView.style.display === 'none' ? rawView : parsedView
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function clearSearch() {
|
|
337
|
+
// 移除所有高亮
|
|
338
|
+
document.querySelectorAll('mark.highlight').forEach(mark => {
|
|
339
|
+
const parent = mark.parentNode
|
|
340
|
+
parent.replaceChild(document.createTextNode(mark.textContent), mark)
|
|
341
|
+
parent.normalize()
|
|
342
|
+
})
|
|
343
|
+
highlights = []
|
|
344
|
+
currentIdx = -1
|
|
345
|
+
searchCount.textContent = ''
|
|
346
|
+
document.body.classList.remove('search-active')
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function doSearch() {
|
|
350
|
+
clearSearch()
|
|
351
|
+
const query = searchInput.value.trim()
|
|
352
|
+
if (!query) return
|
|
353
|
+
|
|
354
|
+
const view = getActiveView()
|
|
355
|
+
const regex = new RegExp(query.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&'), 'gi')
|
|
356
|
+
|
|
357
|
+
// 遍历所有文本节点进行高亮
|
|
358
|
+
const walker = document.createTreeWalker(view, NodeFilter.SHOW_TEXT, null)
|
|
359
|
+
const textNodes = []
|
|
360
|
+
while (walker.nextNode()) textNodes.push(walker.currentNode)
|
|
361
|
+
|
|
362
|
+
textNodes.forEach(node => {
|
|
363
|
+
const text = node.textContent
|
|
364
|
+
if (!regex.test(text)) return
|
|
365
|
+
regex.lastIndex = 0
|
|
366
|
+
|
|
367
|
+
const frag = document.createDocumentFragment()
|
|
368
|
+
let lastIdx = 0
|
|
369
|
+
let match
|
|
370
|
+
while ((match = regex.exec(text)) !== null) {
|
|
371
|
+
if (match.index > lastIdx) {
|
|
372
|
+
frag.appendChild(document.createTextNode(text.slice(lastIdx, match.index)))
|
|
373
|
+
}
|
|
374
|
+
const mark = document.createElement('mark')
|
|
375
|
+
mark.className = 'highlight'
|
|
376
|
+
mark.textContent = match[0]
|
|
377
|
+
frag.appendChild(mark)
|
|
378
|
+
lastIdx = regex.lastIndex
|
|
379
|
+
}
|
|
380
|
+
if (lastIdx < text.length) {
|
|
381
|
+
frag.appendChild(document.createTextNode(text.slice(lastIdx)))
|
|
382
|
+
}
|
|
383
|
+
node.parentNode.replaceChild(frag, node)
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
highlights = Array.from(view.querySelectorAll('mark.highlight'))
|
|
387
|
+
if (highlights.length > 0) {
|
|
388
|
+
// 展开所有包含匹配的 <details>
|
|
389
|
+
highlights.forEach(h => {
|
|
390
|
+
let el = h.parentElement
|
|
391
|
+
while (el && el !== view) {
|
|
392
|
+
if (el.tagName === 'DETAILS') el.open = true
|
|
393
|
+
el = el.parentElement
|
|
394
|
+
}
|
|
395
|
+
})
|
|
396
|
+
currentIdx = 0
|
|
397
|
+
scrollToCurrent()
|
|
398
|
+
}
|
|
399
|
+
updateCount()
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function updateCount() {
|
|
403
|
+
if (highlights.length === 0 && searchInput.value.trim()) {
|
|
404
|
+
searchCount.textContent = 'No match'
|
|
405
|
+
} else if (highlights.length > 0) {
|
|
406
|
+
searchCount.textContent = (currentIdx + 1) + ' / ' + highlights.length
|
|
407
|
+
} else {
|
|
408
|
+
searchCount.textContent = ''
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function scrollToCurrent() {
|
|
413
|
+
highlights.forEach((h, i) => {
|
|
414
|
+
h.classList.toggle('current', i === currentIdx)
|
|
415
|
+
})
|
|
416
|
+
if (highlights[currentIdx]) {
|
|
417
|
+
highlights[currentIdx].scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
418
|
+
}
|
|
419
|
+
updateCount()
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function goNext() {
|
|
423
|
+
if (highlights.length === 0) return
|
|
424
|
+
currentIdx = (currentIdx + 1) % highlights.length
|
|
425
|
+
scrollToCurrent()
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function goPrev() {
|
|
429
|
+
if (highlights.length === 0) return
|
|
430
|
+
currentIdx = (currentIdx - 1 + highlights.length) % highlights.length
|
|
431
|
+
scrollToCurrent()
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// 输入时实时搜索(防抖 200ms)
|
|
435
|
+
let debounceTimer
|
|
436
|
+
searchInput.addEventListener('input', () => {
|
|
437
|
+
clearTimeout(debounceTimer)
|
|
438
|
+
debounceTimer = setTimeout(doSearch, 200)
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
// Enter = 下一个,Shift+Enter = 上一个
|
|
442
|
+
searchInput.addEventListener('keydown', (e) => {
|
|
443
|
+
if (e.key === 'Enter') {
|
|
444
|
+
e.preventDefault()
|
|
445
|
+
e.shiftKey ? goPrev() : goNext()
|
|
446
|
+
}
|
|
447
|
+
if (e.key === 'Escape') {
|
|
448
|
+
searchInput.value = ''
|
|
449
|
+
clearSearch()
|
|
450
|
+
searchInput.blur()
|
|
451
|
+
}
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
searchNext.addEventListener('click', goNext)
|
|
455
|
+
searchPrev.addEventListener('click', goPrev)
|
|
456
|
+
|
|
457
|
+
// Ctrl+F / Cmd+F 聚焦搜索框
|
|
458
|
+
document.addEventListener('keydown', (e) => {
|
|
459
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
460
|
+
e.preventDefault()
|
|
461
|
+
searchInput.focus()
|
|
462
|
+
searchInput.select()
|
|
463
|
+
}
|
|
464
|
+
})
|
|
189
465
|
</script>
|
|
190
466
|
</body>
|
|
191
467
|
</html>`
|
|
192
468
|
}
|
|
193
469
|
|
|
470
|
+
/**
|
|
471
|
+
* 递归遍历 JSON 对象,尝试将值为 JSON 字符串的字段自动解析为对象
|
|
472
|
+
* 比如 { "data": "{\"name\":\"test\"}" } → { "data": { "name": "test" } }
|
|
473
|
+
* 这在 API 响应和日志中非常常见
|
|
474
|
+
*/
|
|
475
|
+
function deepParseJsonStrings(obj) {
|
|
476
|
+
if (obj === null || obj === undefined) return obj
|
|
477
|
+
if (Array.isArray(obj)) return obj.map(deepParseJsonStrings)
|
|
478
|
+
if (typeof obj === 'object') {
|
|
479
|
+
const result = {}
|
|
480
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
481
|
+
result[key] = deepParseJsonStrings(value)
|
|
482
|
+
}
|
|
483
|
+
return result
|
|
484
|
+
}
|
|
485
|
+
if (typeof obj === 'string') {
|
|
486
|
+
const trimmed = obj.trim()
|
|
487
|
+
// 只尝试解析看起来像 JSON 对象或数组的字符串
|
|
488
|
+
if ((trimmed.startsWith('{') && trimmed.endsWith('}')) ||
|
|
489
|
+
(trimmed.startsWith('[') && trimmed.endsWith(']'))) {
|
|
490
|
+
try {
|
|
491
|
+
const parsed = JSON.parse(trimmed)
|
|
492
|
+
return deepParseJsonStrings(parsed)
|
|
493
|
+
} catch {
|
|
494
|
+
return obj
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
return obj
|
|
499
|
+
}
|
|
500
|
+
|
|
194
501
|
function openInBrowser(filePath) {
|
|
195
502
|
const platform = process.platform
|
|
196
503
|
const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open'
|
|
@@ -234,11 +541,54 @@ async function main() {
|
|
|
234
541
|
try {
|
|
235
542
|
parsed = JSON.parse(input)
|
|
236
543
|
} catch {
|
|
237
|
-
|
|
238
|
-
|
|
544
|
+
// 尝试处理 JSON 序列化后的字符串(双重转义)
|
|
545
|
+
// 比如:"{\"name\":\"test\"}" 或 '"{\\\"name\\\":\\\"test\\\"}"'
|
|
546
|
+
// 这种情况常见于:日志输出、API 响应中嵌套的 JSON 字符串、数据库存储的 JSON
|
|
547
|
+
try {
|
|
548
|
+
// 第一步:去掉首尾引号(如果有的话)
|
|
549
|
+
let cleaned = input.trim()
|
|
550
|
+
if ((cleaned.startsWith('"') && cleaned.endsWith('"')) ||
|
|
551
|
+
(cleaned.startsWith("'") && cleaned.endsWith("'"))) {
|
|
552
|
+
cleaned = cleaned.slice(1, -1)
|
|
553
|
+
}
|
|
554
|
+
// 第二步:处理转义字符
|
|
555
|
+
// 替换 \" → ",\\ → \,\n → 换行,\t → tab
|
|
556
|
+
cleaned = cleaned
|
|
557
|
+
.replace(/\\"/g, '"')
|
|
558
|
+
.replace(/\\\\/g, '\\')
|
|
559
|
+
.replace(/\\n/g, '\n')
|
|
560
|
+
.replace(/\\t/g, '\t')
|
|
561
|
+
.replace(/\\r/g, '\r')
|
|
562
|
+
parsed = JSON.parse(cleaned)
|
|
563
|
+
console.log('ℹ️ Detected serialized JSON string, auto-unescaped.')
|
|
564
|
+
} catch {
|
|
565
|
+
// 第三步:尝试递归解析(多层序列化的情况)
|
|
566
|
+
try {
|
|
567
|
+
let result = input.trim()
|
|
568
|
+
let depth = 0
|
|
569
|
+
const maxDepth = 5
|
|
570
|
+
while (typeof result === 'string' && depth < maxDepth) {
|
|
571
|
+
result = JSON.parse(result)
|
|
572
|
+
depth++
|
|
573
|
+
}
|
|
574
|
+
if (typeof result === 'object' && result !== null) {
|
|
575
|
+
parsed = result
|
|
576
|
+
console.log(`ℹ️ Detected ${depth}-level serialized JSON string, auto-parsed.`)
|
|
577
|
+
} else {
|
|
578
|
+
console.error('Input is not valid JSON.')
|
|
579
|
+
process.exit(1)
|
|
580
|
+
}
|
|
581
|
+
} catch {
|
|
582
|
+
console.error('Input is not valid JSON.')
|
|
583
|
+
process.exit(1)
|
|
584
|
+
}
|
|
585
|
+
}
|
|
239
586
|
}
|
|
240
587
|
|
|
241
|
-
|
|
588
|
+
// 生成深度解析版本(展开嵌套 JSON 字符串),但默认不启用
|
|
589
|
+
const deepParsed = deepParseJsonStrings(parsed)
|
|
590
|
+
|
|
591
|
+
const html = toHtml(parsed, deepParsed)
|
|
242
592
|
const filePath = path.join(os.tmpdir(), `json-viewer-${Date.now()}.html`)
|
|
243
593
|
await fs.writeFile(filePath, html, 'utf8')
|
|
244
594
|
openInBrowser(filePath)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zjy4fun/json-open",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Open JSON (stdin or inline text) in a browser with collapsible tree view",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -17,8 +17,12 @@
|
|
|
17
17
|
"browser",
|
|
18
18
|
"formatter"
|
|
19
19
|
],
|
|
20
|
-
"author": "",
|
|
20
|
+
"author": "zjy4fun",
|
|
21
21
|
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "https://github.com/zjy4fun/json-open"
|
|
25
|
+
},
|
|
22
26
|
"publishConfig": {
|
|
23
27
|
"access": "public",
|
|
24
28
|
"registry": "https://registry.npmjs.org"
|