Skip to content

Instantly share code, notes, and snippets.

@simonw
Created December 22, 2024 22:43
Show Gist options
  • Save simonw/54b4e533481cc7a686b0172c3a9ac21e to your computer and use it in GitHub Desktop.
Save simonw/54b4e533481cc7a686b0172c3a9ac21e to your computer and use it in GitHub Desktop.

YAML Document Explorer Interface

human (Dec 22, 2024, 02:33 PM)

UI where I can paste in a YAML document and I get back an interface with expand/hide widgets for each level of nesting provided using HTML <details><summary> - but with an extra "expand all" link on each of those levels to expand everything

assistant (Dec 22, 2024, 02:34 PM)

I'll create a YAML explorer interface using vanilla HTML, JavaScript and CSS with collapsible sections and expand all functionality.

create YAML Explorer

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>YAML Explorer</title>
<style>
* {
  box-sizing: border-box;
}

body {
  font-family: Helvetica, Arial, sans-serif;
  line-height: 1.4;
  margin: 0;
  padding: 20px;
}

textarea {
  width: 100%;
  height: 200px;
  margin-bottom: 20px;
  padding: 8px;
  font-size: 16px;
  font-family: monospace;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

.output {
  border: 1px solid #eee;
  padding: 20px;
  border-radius: 4px;
}

details {
  margin: 0.5em 0;
  padding-left: 20px;
}

summary {
  margin-left: -20px;
  cursor: pointer;
}

summary > span {
  color: #666;
  font-size: 0.9em;
}

.expand-all {
  color: #0066cc;
  cursor: pointer;
  text-decoration: underline;
  font-size: 0.9em;
  margin-left: 8px;
}

.key {
  color: #0066cc;
  font-weight: bold;
}

.string {
  color: #008000;
}

.number {
  color: #ff6600;
}

.boolean {
  color: #9933cc;
}

.null {
  color: #999;
}
</style>
</head>
<body>
<div class="container">
  <h1>YAML Explorer</h1>
  <textarea placeholder="Paste your YAML here..."></textarea>
  <div class="output"></div>
</div>

<script type="module">
import jsyaml from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js'

const textarea = document.querySelector('textarea')
const output = document.querySelector('.output')

function renderValue(value) {
  if (value === null) return `<span class="null">null</span>`
  if (typeof value === 'string') return `<span class="string">"${value}"</span>`
  if (typeof value === 'number') return `<span class="number">${value}</span>`
  if (typeof value === 'boolean') return `<span class="boolean">${value}</span>`
  return value
}

function createExpandAllButton(details) {
  const button = document.createElement('span')
  button.className = 'expand-all'
  button.textContent = 'expand all'
  button.onclick = (e) => {
    e.preventDefault()
    e.stopPropagation()
    const allDetails = details.querySelectorAll('details')
    allDetails.forEach(d => d.open = true)
  }
  return button
}

function renderObject(obj, level = 0) {
  if (typeof obj !== 'object' || obj === null) {
    return renderValue(obj)
  }

  const details = document.createElement('details')
  const summary = document.createElement('summary')
  const isArray = Array.isArray(obj)
  
  if (isArray) {
    summary.innerHTML = `Array <span>(${obj.length} items)</span>`
  } else {
    summary.innerHTML = `Object <span>(${Object.keys(obj).length} properties)</span>`
  }
  
  summary.appendChild(createExpandAllButton(details))
  details.appendChild(summary)

  const items = isArray ? obj : Object.entries(obj)
  
  items.forEach((item, index) => {
    const div = document.createElement('div')
    if (isArray) {
      div.innerHTML = renderObject(item, level + 1)
    } else {
      const [key, value] = item
      div.innerHTML = `<span class="key">${key}:</span> ${renderObject(value, level + 1)}`
    }
    details.appendChild(div)
  })

  return details.outerHTML
}

textarea.addEventListener('input', () => {
  try {
    const yaml = textarea.value.trim()
    if (!yaml) {
      output.innerHTML = ''
      return
    }
    
    const data = jsyaml.load(yaml)
    output.innerHTML = ''
    output.appendChild(renderObject(data))
  } catch (err) {
    output.innerHTML = `<div style="color: red">Error parsing YAML: ${err.message}</div>`
  }
})
</script>
</body>
</html>

I've created a YAML explorer interface with the following features:

  1. A textarea for pasting YAML content
  2. Nested collapsible sections using <details> and <summary> elements
  3. "Expand all" button at each level that expands all nested content
  4. Color-coded syntax highlighting for different types (strings, numbers, booleans, etc.)
  5. Clean minimal styling with Helvetica font

To use it:

  1. Simply paste your YAML content into the textarea
  2. The interface will automatically parse and display it as an interactive tree
  3. Click on any section to expand/collapse it
  4. Use the "expand all" link to expand all nested content within that section

The interface uses js-yaml for parsing and vanilla JavaScript for rendering. The styling is kept minimal and clean with good spacing for readability. Let me know if you'd like any adjustments to the styling or functionality!

human (Dec 22, 2024, 02:34 PM)

Uncaught Error: SyntaxError: The requested module 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js' doesn't provide an export named: 'default'

assistant (Dec 22, 2024, 02:34 PM)

Ah sorry - let me fix the import issue with js-yaml.

update yaml-explorer

Find this:

import jsyaml from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js'

Replace with this:

import * as jsyaml from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js'

I've updated the import statement to use import * as jsyaml instead of trying to import a default export. This should resolve the error. Try pasting in some YAML now - it should work properly!

The js-yaml library doesn't use a default export, so we need to import all of its named exports using the * as syntax. This lets us use functions like jsyaml.load() as we do in the code.

Let me know if you encounter any other issues!

human (Dec 22, 2024, 02:34 PM)

Error parsing YAML: jsyaml.load is not a function

assistant (Dec 22, 2024, 02:34 PM)

Ah, we need to handle the actual exports correctly - let me fix that.

update yaml-explorer

Find this:

import * as jsyaml from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.min.js'

Replace with this:

import { load } from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.mjs'

update yaml-explorer

Find this:

    const data = jsyaml.load(yaml)

Replace with this:

    const data = load(yaml)

I've made two changes:

  1. Changed to import the load function directly from the .mjs version of js-yaml
  2. Updated the code to use load() directly instead of jsyaml.load()

This should now properly parse the YAML. Try pasting some YAML content and let me know if you run into any other issues!

human (Dec 22, 2024, 02:34 PM)

Error parsing YAML: Node.appendChild: Argument 1 is not an object.

assistant (Dec 22, 2024, 02:35 PM)

Ah, I see the issue - we need to create DOM elements properly for the recursive rendering. Let me fix that.

rewrite Untitled

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>YAML Explorer</title>
<style>
* {
  box-sizing: border-box;
}

body {
  font-family: Helvetica, Arial, sans-serif;
  line-height: 1.4;
  margin: 0;
  padding: 20px;
}

textarea {
  width: 100%;
  height: 200px;
  margin-bottom: 20px;
  padding: 8px;
  font-size: 16px;
  font-family: monospace;
  border: 1px solid #ccc;
  border-radius: 4px;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

.output {
  border: 1px solid #eee;
  padding: 20px;
  border-radius: 4px;
}

details {
  margin: 0.5em 0;
  padding-left: 20px;
}

summary {
  margin-left: -20px;
  cursor: pointer;
}

summary > span {
  color: #666;
  font-size: 0.9em;
}

.expand-all {
  color: #0066cc;
  cursor: pointer;
  text-decoration: underline;
  font-size: 0.9em;
  margin-left: 8px;
}

.key {
  color: #0066cc;
  font-weight: bold;
}

.string {
  color: #008000;
}

.number {
  color: #ff6600;
}

.boolean {
  color: #9933cc;
}

.null {
  color: #999;
}
</style>
</head>
<body>
<div class="container">
  <h1>YAML Explorer</h1>
  <textarea placeholder="Paste your YAML here..."></textarea>
  <div class="output"></div>
</div>

<script type="module">
import { load } from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.mjs'

const textarea = document.querySelector('textarea')
const output = document.querySelector('.output')

function createValueSpan(value) {
  const span = document.createElement('span')
  if (value === null) {
    span.className = 'null'
    span.textContent = 'null'
  } else if (typeof value === 'string') {
    span.className = 'string'
    span.textContent = `"${value}"`
  } else if (typeof value === 'number') {
    span.className = 'number'
    span.textContent = value
  } else if (typeof value === 'boolean') {
    span.className = 'boolean'
    span.textContent = value
  }
  return span
}

function createExpandAllButton(parent) {
  const button = document.createElement('span')
  button.className = 'expand-all'
  button.textContent = 'expand all'
  button.onclick = (e) => {
    e.preventDefault()
    e.stopPropagation()
    const allDetails = parent.querySelectorAll('details')
    allDetails.forEach(d => d.open = true)
  }
  return button
}

function renderObject(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return createValueSpan(obj)
  }

  const details = document.createElement('details')
  const summary = document.createElement('summary')
  const isArray = Array.isArray(obj)
  
  const text = document.createElement('span')
  text.textContent = isArray ? 
    `Array (${obj.length} items)` : 
    `Object (${Object.keys(obj).length} properties)`
  
  summary.appendChild(text)
  summary.appendChild(createExpandAllButton(details))
  details.appendChild(summary)

  const items = isArray ? obj : Object.entries(obj)
  
  items.forEach((item, index) => {
    const div = document.createElement('div')
    if (isArray) {
      div.appendChild(renderObject(item))
    } else {
      const [key, value] = item
      const keySpan = document.createElement('span')
      keySpan.className = 'key'
      keySpan.textContent = `${key}: `
      div.appendChild(keySpan)
      div.appendChild(renderObject(value))
    }
    details.appendChild(div)
  })

  return details
}

textarea.addEventListener('input', () => {
  try {
    const yaml = textarea.value.trim()
    output.innerHTML = ''
    
    if (!yaml) return
    
    const data = load(yaml)
    output.appendChild(renderObject(data))
  } catch (err) {
    const error = document.createElement('div')
    error.style.color = 'red'
    error.textContent = `Error parsing YAML: ${err.message}`
    output.appendChild(error)
  }
})
</script>
</body>
</html>

I've completely rewritten the rendering logic to properly create DOM elements instead of using innerHTML. The key changes are:

  1. Created separate functions to handle DOM element creation
  2. Properly append child elements using appendChild()
  3. Maintain proper element hierarchy throughout the recursive rendering

Try pasting some YAML now - everything should work correctly! For example, try this sample YAML:

server:
  host: localhost
  port: 8080
  settings:
    debug: true
    cache:
      enabled: true
      max_size: 1000
users:
  - name: John
    role: admin
  - name: Jane
    role: user

Let me know if you encounter any other issues!

human (Dec 22, 2024, 02:36 PM)

This works great! Add a feature where I can optionally paste in the URL to a YAML file which will then be fetched via fetch() and loaded into that textarea. This URL should be persisted in a #url=... fragment of the page URL itself, and when the page loads if that is set it should be fetched automatically. Also design a serialization of the current open/close state of the different detail/summary things and encode that in the URL fragment too, restoring that state on page load

assistant (Dec 22, 2024, 02:37 PM)

I'll add URL loading and state persistence features.

rewrite Untitled

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>YAML Explorer</title>
<style>
* {
  box-sizing: border-box;
}

body {
  font-family: Helvetica, Arial, sans-serif;
  line-height: 1.4;
  margin: 0;
  padding: 20px;
}

textarea, input[type="url"] {
  width: 100%;
  margin-bottom: 20px;
  padding: 8px;
  font-size: 16px;
  font-family: monospace;
  border: 1px solid #ccc;
  border-radius: 4px;
}

textarea {
  height: 200px;
}

.container {
  max-width: 800px;
  margin: 0 auto;
}

.output {
  border: 1px solid #eee;
  padding: 20px;
  border-radius: 4px;
}

details {
  margin: 0.5em 0;
  padding-left: 20px;
}

summary {
  margin-left: -20px;
  cursor: pointer;
}

summary > span {
  color: #666;
  font-size: 0.9em;
}

.expand-all {
  color: #0066cc;
  cursor: pointer;
  text-decoration: underline;
  font-size: 0.9em;
  margin-left: 8px;
}

.key {
  color: #0066cc;
  font-weight: bold;
}

.string {
  color: #008000;
}

.number {
  color: #ff6600;
}

.boolean {
  color: #9933cc;
}

.null {
  color: #999;
}

.error {
  color: red;
  margin: 1em 0;
}
</style>
</head>
<body>
<div class="container">
  <h1>YAML Explorer</h1>
  <input type="url" placeholder="Optional: Enter URL to YAML file" />
  <textarea placeholder="Paste your YAML here..."></textarea>
  <div class="output"></div>
</div>

<script type="module">
import { load } from 'https://cdnjs.cloudflare.com/ajax/libs/js-yaml/4.1.0/js-yaml.mjs'

const urlInput = document.querySelector('input[type="url"]')
const textarea = document.querySelector('textarea')
const output = document.querySelector('.output')

// Create unique IDs for details elements to track state
let detailsCounter = 0

function createValueSpan(value) {
  const span = document.createElement('span')
  if (value === null) {
    span.className = 'null'
    span.textContent = 'null'
  } else if (typeof value === 'string') {
    span.className = 'string'
    span.textContent = `"${value}"`
  } else if (typeof value === 'number') {
    span.className = 'number'
    span.textContent = value
  } else if (typeof value === 'boolean') {
    span.className = 'boolean'
    span.textContent = value
  }
  return span
}

function createExpandAllButton(parent) {
  const button = document.createElement('span')
  button.className = 'expand-all'
  button.textContent = 'expand all'
  button.onclick = (e) => {
    e.preventDefault()
    e.stopPropagation()
    const allDetails = parent.querySelectorAll('details')
    allDetails.forEach(d => d.open = true)
    updateUrlState()
  }
  return button
}

function renderObject(obj) {
  if (typeof obj !== 'object' || obj === null) {
    return createValueSpan(obj)
  }

  const details = document.createElement('details')
  const detailsId = `d${detailsCounter++}`
  details.dataset.id = detailsId
  
  details.addEventListener('toggle', updateUrlState)

  const summary = document.createElement('summary')
  const isArray = Array.isArray(obj)
  
  const text = document.createElement('span')
  text.textContent = isArray ? 
    `Array (${obj.length} items)` : 
    `Object (${Object.keys(obj).length} properties)`
  
  summary.appendChild(text)
  summary.appendChild(createExpandAllButton(details))
  details.appendChild(summary)

  const items = isArray ? obj : Object.entries(obj)
  
  items.forEach((item, index) => {
    const div = document.createElement('div')
    if (isArray) {
      div.appendChild(renderObject(item))
    } else {
      const [key, value] = item
      const keySpan = document.createElement('span')
      keySpan.className = 'key'
      keySpan.textContent = `${key}: `
      div.appendChild(keySpan)
      div.appendChild(renderObject(value))
    }
    details.appendChild(div)
  })

  return details
}

function showError(message) {
  const error = document.createElement('div')
  error.className = 'error'
  error.textContent = message
  output.appendChild(error)
}

function updateUrlState() {
  const url = urlInput.value
  const openDetails = [...document.querySelectorAll('details[data-id]')]
    .filter(d => d.open)
    .map(d => d.dataset.id)
  
  const state = {
    url: url || undefined,
    open: openDetails.length ? openDetails : undefined
  }
  
  const fragment = '#' + btoa(JSON.stringify(state))
  window.history.replaceState(null, '', fragment)
}

function parseUrlState() {
  try {
    const fragment = window.location.hash.slice(1)
    if (!fragment) return {}
    return JSON.parse(atob(fragment))
  } catch (e) {
    console.warn('Failed to parse URL state:', e)
    return {}
  }
}

async function loadYaml(yaml) {
  try {
    output.innerHTML = ''
    detailsCounter = 0
    
    if (!yaml) return
    
    const data = load(yaml)
    const tree = renderObject(data)
    output.appendChild(tree)
    
    // Restore open state from URL if present
    const state = parseUrlState()
    if (state.open) {
      state.open.forEach(id => {
        const details = document.querySelector(`details[data-id="${id}"]`)
        if (details) details.open = true
      })
    }
  } catch (err) {
    showError(`Error parsing YAML: ${err.message}`)
  }
}

async function fetchAndLoadUrl(url) {
  if (!url) return
  
  try {
    const response = await fetch(url)
    if (!response.ok) {
      throw new Error(`HTTP error! status: ${response.status}`)
    }
    const yaml = await response.text()
    textarea.value = yaml
    await loadYaml(yaml)
    updateUrlState()
  } catch (err) {
    showError(`Error fetching YAML: ${err.message}`)
  }
}

textarea.addEventListener('input', () => {
  loadYaml(textarea.value.trim())
  updateUrlState()
})

urlInput.addEventListener('change', () => {
  fetchAndLoadUrl(urlInput.value)
})

// Initial load from URL state
const initialState = parseUrlState()
if (initialState.url) {
  urlInput.value = initialState.url
  fetchAndLoadUrl(initialState.url)
}
</script>
</body>
</html>

I've added several new features:

  1. URL input field at the top to fetch YAML files
  2. URL state persistence:
    • The YAML file URL is stored in the fragment using base64-encoded JSON
    • When the page loads, it automatically fetches the URL if present
  3. Open/closed state persistence:
    • Each details element gets a unique ID
    • The open/closed state is stored in the URL fragment
    • States are restored when loading the page
  4. Better error handling for both YAML parsing and URL fetching

The URL fragment structure is base64-encoded JSON with this format:

{
  url: "https://example.com/file.yaml",  // Optional URL to YAML file
  open: ["d0", "d1", "d3"]              // Optional list of open detail IDs
}

To try it out:

  1. Enter a URL to a YAML file in the input field
  2. The YAML will be fetched and displayed
  3. Expand/collapse different sections
  4. The URL fragment will update to reflect both the source URL and your current view state
  5. Share the URL with someone else and they'll see the same expanded sections

Let me know if you'd like any adjustments to the implementation!

human (Dec 22, 2024, 02:39 PM)

Add a "Load" button next to the URL bar (on the right end of it) that has to be clicked for it to load - but on page load it should automatically run still

assistant (Dec 22, 2024, 02:39 PM)

I'll modify the URL input to have a load button, while keeping the auto-load behavior on initial page load.

update yaml-explorer

Find this:

<input type="url" placeholder="Optional: Enter URL to YAML file" />

Replace with this:

<div style="display: flex; gap: 8px;">
    <input type="url" placeholder="Optional: Enter URL to YAML file" style="flex: 1;" />
    <button type="button" style="font-size: 16px; padding: 0 16px;">Load</button>
  </div>

update yaml-explorer

Find this:

urlInput.addEventListener('change', () => {
  fetchAndLoadUrl(urlInput.value)
})

Replace with this:

const loadButton = document.querySelector('button')
loadButton.addEventListener('click', () => {
  fetchAndLoadUrl(urlInput.value)
})

I've made these changes:

  1. Added a "Load" button next to the URL input
  2. Removed the automatic loading on URL input change
  3. Added click handler for the Load button
  4. Kept the automatic loading on page load when URL is in the fragment

The URL will now only be fetched when you click the Load button or when the page first loads with a URL in the fragment. Let me know if you'd like any adjustments to the styling or behavior!

human (Dec 22, 2024, 02:39 PM)

image

Make the button blue and better sized to fit

assistant (Dec 22, 2024, 02:39 PM)

I'll update the button styling to make it blue and properly sized.

update yaml-explorer

Find this:

button {
  font-size: 16px;
  padding: 0 16px;
}

Replace with this:

button {
  font-size: 16px;
  padding: 8px 20px;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  min-width: 80px;
}

button:hover {
  background: #0052a3;
}

button:active {
  background: #004080;
}

I've updated the button styling to:

  1. Make it blue (#0066cc) with white text
  2. Add proper padding for better height
  3. Add hover and active states
  4. Set a minimum width to prevent size changes
  5. Add a border radius to match the input style
  6. Remove the border for a cleaner look

The button should now be better proportioned relative to the input field and have a more polished appearance. Let me know if you'd like any adjustments to the colors or sizing!

human (Dec 22, 2024, 02:40 PM)

doesn't look blue to me

assistant (Dec 22, 2024, 02:40 PM)

The button styles need to be placed in the right location in the CSS - let me fix that.

update yaml-explorer

Find this:

textarea, input[type="url"] {
  width: 100%;
  margin-bottom: 20px;
  padding: 8px;
  font-size: 16px;
  font-family: monospace;
  border: 1px solid #ccc;
  border-radius: 4px;
}

textarea {
  height: 200px;
}

Replace with this:

textarea, input[type="url"] {
  width: 100%;
  margin-bottom: 20px;
  padding: 8px;
  font-size: 16px;
  font-family: monospace;
  border: 1px solid #ccc;
  border-radius: 4px;
}

button {
  font-size: 16px;
  padding: 8px 20px;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  min-width: 80px;
}

button:hover {
  background: #0052a3;
}

button:active {
  background: #004080;
}

textarea {
  height: 200px;
}

I moved the button styles into the main CSS block where they'll be properly applied. The button should now be blue with the hover and active states working correctly. Let me know if you'd like any adjustments to the shade of blue or other aspects of the styling!

human (Dec 22, 2024, 02:40 PM)

button is still too tall, should be same height as the input for the URL

assistant (Dec 22, 2024, 02:40 PM)

I'll adjust the padding to match the URL input height.

update yaml-explorer

Find this:

button {
  font-size: 16px;
  padding: 8px 20px;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  min-width: 80px;
}

button:hover {
  background: #0052a3;
}

button:active {
  background: #004080;
}

Replace with this:

button {
  font-size: 16px;
  padding: 0 20px;
  background: #0066cc;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  min-width: 80px;
  height: 37px; /* Match input height */
}

button:hover {
  background: #0052a3;
}

button:active {
  background: #004080;
}

I removed the vertical padding and instead set an explicit height to match the URL input (which is 37px tall with its padding and border). The button should now be perfectly aligned with the input. Let me know if you'd like any further adjustments!