Skip to content

Commit ee48769

Browse files
committed
1 parent 40aa7bc commit ee48769

File tree

2 files changed

+302
-0
lines changed

2 files changed

+302
-0
lines changed

README.md

+1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ Prompts used are linked to from [the commit messages](https://github.com/simonw/
2626
- [Writing Style Analyzer](https://tools.simonwillison.net/writing-style) - identify weasel words, passive voice, duplicate words - adapted from [these shell scripts](https://matt.might.net/articles/shell-scripts-for-passive-voice-weasel-words-duplicates/) published by Matt Might
2727
- [Navigation for headings](https://tools.simonwillison.net/nav-for-headings) - paste in an HTML document with headings, each heading is assigned a unique ID and the tool then generates a navigation `<ul>`
2828
- [JSON to YAML](https://tools.simonwillison.net/json-to-yaml) - convert JSON to YAML, showing different styles of YAML output
29+
- [YAML Explorer](https://tools.simonwillison.net/yaml-explorer) - nested hierarchy explorer for YAML files, which can be loaded from an external URL and have their expand/collapse state persisted in the URL to the tool
2930

3031
## LLM playgrounds and debuggers
3132

yaml-explorer.html

+301
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<title>YAML Explorer</title>
6+
<style>
7+
* {
8+
box-sizing: border-box;
9+
}
10+
11+
body {
12+
font-family: Helvetica, Arial, sans-serif;
13+
line-height: 1.4;
14+
margin: 0;
15+
padding: 20px;
16+
}
17+
18+
textarea, input[type="url"] {
19+
width: 100%;
20+
margin-bottom: 20px;
21+
padding: 8px;
22+
font-size: 16px;
23+
font-family: monospace;
24+
border: 1px solid #ccc;
25+
border-radius: 4px;
26+
}
27+
28+
button {
29+
font-size: 16px;
30+
padding: 0 20px;
31+
background: #0066cc;
32+
color: white;
33+
border: none;
34+
border-radius: 4px;
35+
cursor: pointer;
36+
min-width: 80px;
37+
height: 37px; /* Match input height */
38+
}
39+
40+
button:hover {
41+
background: #0052a3;
42+
}
43+
44+
button:active {
45+
background: #004080;
46+
}
47+
48+
textarea {
49+
height: 200px;
50+
}
51+
52+
.container {
53+
max-width: 800px;
54+
margin: 0 auto;
55+
}
56+
57+
.output {
58+
border: 1px solid #eee;
59+
padding: 20px;
60+
border-radius: 4px;
61+
}
62+
63+
details {
64+
margin: 0.5em 0;
65+
padding-left: 20px;
66+
}
67+
68+
summary {
69+
margin-left: -20px;
70+
cursor: pointer;
71+
}
72+
73+
summary > span {
74+
color: #666;
75+
font-size: 0.9em;
76+
}
77+
78+
.expand-all {
79+
color: #0066cc;
80+
cursor: pointer;
81+
text-decoration: underline;
82+
font-size: 0.9em;
83+
margin-left: 8px;
84+
}
85+
86+
.key {
87+
color: #0066cc;
88+
font-weight: bold;
89+
}
90+
91+
.string {
92+
color: #008000;
93+
}
94+
95+
.number {
96+
color: #ff6600;
97+
}
98+
99+
.boolean {
100+
color: #9933cc;
101+
}
102+
103+
.null {
104+
color: #999;
105+
}
106+
107+
.error {
108+
color: red;
109+
margin: 1em 0;
110+
}
111+
</style>
112+
</head>
113+
<body>
114+
<div class="container">
115+
<h1>YAML Explorer</h1>
116+
<div style="display: flex; gap: 8px;">
117+
<input type="url" placeholder="Optional: Enter URL to YAML file" style="flex: 1;" />
118+
<button type="button" style="font-size: 16px; padding: 0 16px;">Load</button>
119+
</div>
120+
<textarea placeholder="Paste your YAML here..."></textarea>
121+
<div class="output"></div>
122+
</div>
123+
124+
<script type="module">
125+
import { load } from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.mjs'
126+
127+
const urlInput = document.querySelector('input[type="url"]')
128+
const textarea = document.querySelector('textarea')
129+
const output = document.querySelector('.output')
130+
131+
// Create unique IDs for details elements to track state
132+
let detailsCounter = 0
133+
134+
function createValueSpan(value) {
135+
const span = document.createElement('span')
136+
if (value === null) {
137+
span.className = 'null'
138+
span.textContent = 'null'
139+
} else if (typeof value === 'string') {
140+
span.className = 'string'
141+
span.textContent = `"${value}"`
142+
} else if (typeof value === 'number') {
143+
span.className = 'number'
144+
span.textContent = value
145+
} else if (typeof value === 'boolean') {
146+
span.className = 'boolean'
147+
span.textContent = value
148+
}
149+
return span
150+
}
151+
152+
function createExpandAllButton(parent) {
153+
const button = document.createElement('span')
154+
button.className = 'expand-all'
155+
button.textContent = 'expand all'
156+
button.onclick = (e) => {
157+
e.preventDefault()
158+
e.stopPropagation()
159+
const allDetails = parent.querySelectorAll('details')
160+
allDetails.forEach(d => d.open = true)
161+
updateUrlState()
162+
}
163+
return button
164+
}
165+
166+
function renderObject(obj) {
167+
if (typeof obj !== 'object' || obj === null) {
168+
return createValueSpan(obj)
169+
}
170+
171+
const details = document.createElement('details')
172+
const detailsId = `d${detailsCounter++}`
173+
details.dataset.id = detailsId
174+
175+
details.addEventListener('toggle', updateUrlState)
176+
177+
const summary = document.createElement('summary')
178+
const isArray = Array.isArray(obj)
179+
180+
const text = document.createElement('span')
181+
text.textContent = isArray ?
182+
`Array (${obj.length} items)` :
183+
`Object (${Object.keys(obj).length} properties)`
184+
185+
summary.appendChild(text)
186+
summary.appendChild(createExpandAllButton(details))
187+
details.appendChild(summary)
188+
189+
const items = isArray ? obj : Object.entries(obj)
190+
191+
items.forEach((item, index) => {
192+
const div = document.createElement('div')
193+
if (isArray) {
194+
div.appendChild(renderObject(item))
195+
} else {
196+
const [key, value] = item
197+
const keySpan = document.createElement('span')
198+
keySpan.className = 'key'
199+
keySpan.textContent = `${key}: `
200+
div.appendChild(keySpan)
201+
div.appendChild(renderObject(value))
202+
}
203+
details.appendChild(div)
204+
})
205+
206+
return details
207+
}
208+
209+
function showError(message) {
210+
const error = document.createElement('div')
211+
error.className = 'error'
212+
error.textContent = message
213+
output.appendChild(error)
214+
}
215+
216+
function updateUrlState() {
217+
const url = urlInput.value
218+
const openDetails = [...document.querySelectorAll('details[data-id]')]
219+
.filter(d => d.open)
220+
.map(d => d.dataset.id)
221+
222+
const state = {
223+
url: url || undefined,
224+
open: openDetails.length ? openDetails : undefined
225+
}
226+
227+
const fragment = '#' + btoa(JSON.stringify(state))
228+
window.history.replaceState(null, '', fragment)
229+
}
230+
231+
function parseUrlState() {
232+
try {
233+
const fragment = window.location.hash.slice(1)
234+
if (!fragment) return {}
235+
return JSON.parse(atob(fragment))
236+
} catch (e) {
237+
console.warn('Failed to parse URL state:', e)
238+
return {}
239+
}
240+
}
241+
242+
async function loadYaml(yaml) {
243+
try {
244+
output.innerHTML = ''
245+
detailsCounter = 0
246+
247+
if (!yaml) return
248+
249+
const data = load(yaml)
250+
const tree = renderObject(data)
251+
output.appendChild(tree)
252+
253+
// Restore open state from URL if present
254+
const state = parseUrlState()
255+
if (state.open) {
256+
state.open.forEach(id => {
257+
const details = document.querySelector(`details[data-id="${id}"]`)
258+
if (details) details.open = true
259+
})
260+
}
261+
} catch (err) {
262+
showError(`Error parsing YAML: ${err.message}`)
263+
}
264+
}
265+
266+
async function fetchAndLoadUrl(url) {
267+
if (!url) return
268+
269+
try {
270+
const response = await fetch(url)
271+
if (!response.ok) {
272+
throw new Error(`HTTP error! status: ${response.status}`)
273+
}
274+
const yaml = await response.text()
275+
textarea.value = yaml
276+
await loadYaml(yaml)
277+
updateUrlState()
278+
} catch (err) {
279+
showError(`Error fetching YAML: ${err.message}`)
280+
}
281+
}
282+
283+
textarea.addEventListener('input', () => {
284+
loadYaml(textarea.value.trim())
285+
updateUrlState()
286+
})
287+
288+
const loadButton = document.querySelector('button')
289+
loadButton.addEventListener('click', () => {
290+
fetchAndLoadUrl(urlInput.value)
291+
})
292+
293+
// Initial load from URL state
294+
const initialState = parseUrlState()
295+
if (initialState.url) {
296+
urlInput.value = initialState.url
297+
fetchAndLoadUrl(initialState.url)
298+
}
299+
</script>
300+
</body>
301+
</html>

0 commit comments

Comments
 (0)