UIPackage
Menu

Framework

Change language

Boilerplate repo

Xml Tree View

xml-tree-view ui
Boilerplate repo

Collapsible XML tree viewer with color-coded tags and attributes, click-to-copy, live search/filter, and expand/collapse-all controls. Parses XML strings and renders elements, text, comments, and CDATA with contained scrolling.

Also available for React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/xml-tree-view.json
Named registry: npx shadcn-vue@latest add @uipkge/xml-tree-view Installs to: app/components/ui/xml-tree-view/

Examples

Loading interactive previews…

Props

Name Type / Values Default Required
data

Raw XML string to parse and display.

string required
expandDepth number 1 optional
maxDepth number 100 optional
showSearch boolean true optional
showToolbar boolean true optional
rootLabel

Override path root label; defaults to the document element name.

string optional
class HTMLAttributes['class'] optional

Schema

Type aliases from this item's source — use them to shape the data you pass in.

XmlAttr
interface XmlAttr {
  name: string
  value: string
}
XmlNode
interface XmlNode {
  type: XmlNodeType
  /** Tag name for elements; empty for text / comment / cdata. */
  name: string
  attributes: XmlAttr[]
  /** Character data for text / comment / cdata nodes. */
  text: string
  children: XmlNode[]
}
ParseXmlResult
interface ParseXmlResult {
  root: XmlNode | null
  error: string | null
}

npm dependencies

Files installed (4)

  • app/components/ui/xml-tree-view/XmlTreeView.vue 8.6 kB
    <script setup lang="ts">
    import type { HTMLAttributes } from 'vue'
    import { computed, ref, watch } from 'vue'
    import { Search, CodeXml, FoldVertical, UnfoldVertical, AlertCircle } from 'lucide-vue-next'
    import { cn } from '@/lib/utils'
    import XmlTreeNode from './XmlTreeNode.vue'
    import { parseXml, countElements, isExpandable, type XmlNode } from './types'
    
    export type { XmlNode } from './types'
    
    interface Props {
      /** Raw XML string to parse and display. */
      data: string
      expandDepth?: number
      maxDepth?: number
      showSearch?: boolean
      showToolbar?: boolean
      /** Override path root label; defaults to the document element name. */
      rootLabel?: string
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      expandDepth: 1,
      maxDepth: 100,
      showSearch: true,
      showToolbar: true,
    })
    
    const emit = defineEmits<{
      copy: [value: string, path: string]
    }>()
    
    const expanded = ref<Set<string>>(new Set())
    const search = ref('')
    const copiedPath = ref<string | null>(null)
    
    const parsed = computed(() => parseXml(props.data))
    const root = computed(() => parsed.value.root)
    const parseError = computed(() => parsed.value.error)
    
    function pathKey(path: string[]): string {
      return path.length ? '/' + path.join('/') : '/'
    }
    
    function walkExpandable(
      node: XmlNode,
      path: string[],
      depth: number,
      max: number,
      visit: (path: string[], node: XmlNode) => void,
    ) {
      if (depth >= max) return
      if (isExpandable(node)) {
        visit(path, node)
        const counts = new Map<string, number>()
        const totals = new Map<string, number>()
        for (const c of node.children) {
          if (c.type === 'element') totals.set(c.name, (totals.get(c.name) ?? 0) + 1)
        }
        node.children.forEach((child, i) => {
          let segment: string
          if (child.type === 'element') {
            const n = (counts.get(child.name) ?? 0) + 1
            counts.set(child.name, n)
            const total = totals.get(child.name) ?? 1
            segment = total > 1 ? `${child.name}[${n}]` : child.name
          } else if (child.type === 'comment') {
            segment = `comment()[${i}]`
          } else {
            segment = `text()[${i}]`
          }
          walkExpandable(child, [...path, segment], depth + 1, max, visit)
        })
      }
    }
    
    function defaultExpanded(): Set<string> {
      const next = new Set<string>()
      const r = root.value
      if (!r) return next
      // Root is always at path [] with key "/"
      walkExpandable(r, [], 0, props.expandDepth, (path) => {
        next.add(pathKey(path))
      })
      return next
    }
    
    watch(
      () => [props.data, props.expandDepth],
      () => {
        expanded.value = defaultExpanded()
      },
      { immediate: true },
    )
    
    function toggle(path: string[]) {
      const key = pathKey(path)
      const next = new Set(expanded.value)
      if (next.has(key)) next.delete(key)
      else next.add(key)
      expanded.value = next
    }
    
    function isExpanded(path: string[]): boolean {
      return expanded.value.has(pathKey(path))
    }
    
    function expandAll() {
      const next = new Set<string>()
      const r = root.value
      if (!r) {
        expanded.value = next
        return
      }
      walkExpandable(r, [], 0, props.maxDepth, (path) => {
        next.add(pathKey(path))
      })
      expanded.value = next
    }
    
    function collapseAll() {
      expanded.value = new Set()
    }
    
    function matchesSearch(node: XmlNode): boolean {
      if (!search.value) return true
      const term = search.value.toLowerCase()
      const walk = (n: XmlNode): boolean => {
        if (n.type === 'element') {
          if (n.name.toLowerCase().includes(term)) return true
          if (n.attributes.some((a) => a.name.toLowerCase().includes(term) || a.value.toLowerCase().includes(term)))
            return true
          return n.children.some(walk)
        }
        return n.text.toLowerCase().includes(term)
      }
      return walk(node)
    }
    
    // Auto-expand nodes that contain search matches
    watch(search, (q) => {
      if (!q) {
        expanded.value = defaultExpanded()
        return
      }
      const next = new Set<string>()
      const r = root.value
      if (!r) {
        expanded.value = next
        return
      }
      walkExpandable(r, [], 0, props.maxDepth, (path, node) => {
        if (matchesSearch(node)) next.add(pathKey(path))
      })
      expanded.value = next
    })
    
    const tagColor = 'text-violet-600 dark:text-violet-400'
    const attrNameColor = 'text-blue-600 dark:text-blue-400'
    const attrValueColor = 'text-emerald-600 dark:text-emerald-400'
    const textColor = 'text-emerald-600 dark:text-emerald-400'
    const commentColor = 'text-muted-foreground'
    const punctColor = 'text-muted-foreground'
    
    async function copyValue(value: string, path: string[]) {
      const p = pathKey(path)
      try {
        await navigator.clipboard.writeText(value)
        copiedPath.value = p
        emit('copy', value, p)
        setTimeout(() => {
          if (copiedPath.value === p) copiedPath.value = null
        }, 1200)
      } catch {
        // clipboard unavailable
      }
    }
    
    const effectiveRootLabel = computed(() => {
      if (props.rootLabel) return props.rootLabel
      return root.value?.name ?? 'xml'
    })
    
    const summary = computed(() => {
      if (parseError.value) return 'Parse error'
      if (!root.value) return 'Empty'
      const n = countElements(root.value)
      return `${root.value.name} · ${n} element${n === 1 ? '' : 's'}`
    })
    
    const searchMatchCount = computed(() => {
      if (!search.value || !root.value) return 0
      let count = 0
      const term = search.value.toLowerCase()
      const walk = (n: XmlNode) => {
        if (n.type === 'element') {
          if (n.name.toLowerCase().includes(term)) count++
          for (const a of n.attributes) {
            if (a.name.toLowerCase().includes(term) || a.value.toLowerCase().includes(term)) count++
          }
          n.children.forEach(walk)
          return
        }
        if (n.text.toLowerCase().includes(term)) count++
      }
      walk(root.value)
      return count
    })
    </script>
    
    <template>
      <div
        data-uipkge
        data-slot="xml-tree-view"
        :class="cn('bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm', props.class)"
      >
        <!-- Toolbar -->
        <div v-if="showToolbar || showSearch" class="border-border flex items-center gap-2 border-b px-3 py-2">
          <div class="flex items-center gap-1.5">
            <CodeXml class="text-muted-foreground size-4" />
            <span class="text-muted-foreground text-xs">{{ summary }}</span>
          </div>
          <div class="ml-auto flex items-center gap-1">
            <div v-if="showSearch && !parseError" class="relative">
              <Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
              <input
                v-model="search"
                type="text"
                placeholder="Filter..."
                aria-label="Filter XML tree"
                class="border-input bg-muted/40 focus:border-ring focus:ring-ring/30 h-7 w-32 rounded-md pr-2 pl-7 text-xs transition-[width] outline-none focus:w-44 focus:ring-2"
              />
            </div>
            <span v-if="search && !parseError" class="text-muted-foreground text-xs">
              {{ searchMatchCount }} match{{ searchMatchCount === 1 ? '' : 'es' }}
            </span>
            <button
              v-if="!parseError"
              type="button"
              class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors"
              title="Expand all"
              aria-label="Expand all"
              @click="expandAll"
            >
              <UnfoldVertical class="size-4" />
            </button>
            <button
              v-if="!parseError"
              type="button"
              class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-7 items-center justify-center rounded-md transition-colors"
              title="Collapse all"
              aria-label="Collapse all"
              @click="collapseAll"
            >
              <FoldVertical class="size-4" />
            </button>
          </div>
        </div>
    
        <!-- Parse error -->
        <div v-if="parseError" class="text-destructive flex items-start gap-2 p-4 text-sm">
          <AlertCircle class="mt-0.5 size-4 shrink-0" />
          <div class="min-w-0">
            <p class="font-sans font-medium">Invalid XML</p>
            <p class="text-muted-foreground mt-1 font-mono text-xs break-words">{{ parseError }}</p>
          </div>
        </div>
    
        <!-- Tree -->
        <div v-else-if="root" class="min-h-0 flex-1 overflow-auto p-2" role="tree" :aria-label="effectiveRootLabel">
          <XmlTreeNode
            :node="root"
            :path="[]"
            :is-root="true"
            :search="search"
            :max-depth="maxDepth"
            :matches-search="matchesSearch"
            :is-expanded="isExpanded"
            :toggle="toggle"
            :tag-color="tagColor"
            :attr-name-color="attrNameColor"
            :attr-value-color="attrValueColor"
            :text-color="textColor"
            :comment-color="commentColor"
            :punct-color="punctColor"
            :copied-path="copiedPath"
            @copy="copyValue"
          />
        </div>
      </div>
    </template>
  • app/components/ui/xml-tree-view/XmlTreeNode.vue 14.5 kB
    <script setup lang="ts">
    import { computed, defineAsyncComponent } from 'vue'
    import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-vue-next'
    import type { XmlNode } from './types'
    import { isExpandable, serializeXml } from './types'
    
    // Self-reference for recursive rendering — use defineAsyncComponent to avoid circular import
    const XmlTreeNode = defineAsyncComponent(() => import('./XmlTreeNode.vue'))
    
    interface Props {
      node: XmlNode
      path: string[]
      isRoot?: boolean
      search?: string
      maxDepth?: number
      matchesSearch: (node: XmlNode) => boolean
      isExpanded: (path: string[]) => boolean
      toggle: (path: string[]) => void
      tagColor: string
      attrNameColor: string
      attrValueColor: string
      textColor: string
      commentColor: string
      punctColor: string
      copiedPath?: string | null
    }
    
    const props = withDefaults(defineProps<Props>(), {
      isRoot: false,
      search: '',
      maxDepth: 100,
      copiedPath: null,
    })
    
    const emit = defineEmits<{
      copy: [value: string, path: string[]]
    }>()
    
    function pathKey(path: string[]): string {
      return path.length ? '/' + path.join('/') : '/'
    }
    
    const key = computed(() => pathKey(props.path))
    const open = computed(() => props.isExpanded(props.path))
    const expandable = computed(() => isExpandable(props.node))
    const dimmed = computed(() => !!props.search && !props.matchesSearch(props.node))
    const indent = computed(() => (props.isRoot ? 0 : 20))
    
    const parentKey = computed(() => {
      if (!props.path.length) return null
      return pathKey(props.path.slice(0, -1))
    })
    
    /** Child path segments with sibling indices for duplicate tag names. */
    const childEntries = computed(() => {
      const counts = new Map<string, number>()
      const totals = new Map<string, number>()
      for (const c of props.node.children) {
        if (c.type === 'element') {
          totals.set(c.name, (totals.get(c.name) ?? 0) + 1)
        }
      }
      return props.node.children.map((child, i) => {
        let segment: string
        if (child.type === 'element') {
          const n = (counts.get(child.name) ?? 0) + 1
          counts.set(child.name, n)
          const total = totals.get(child.name) ?? 1
          segment = total > 1 ? `${child.name}[${n}]` : child.name
        } else if (child.type === 'comment') {
          segment = `comment()[${i}]`
        } else if (child.type === 'cdata') {
          segment = `text()[${i}]`
        } else {
          segment = `text()[${i}]`
        }
        return { child, segment, path: [...props.path, segment] as string[] }
      })
    })
    
    const childCount = computed(() => props.node.children.filter((c) => c.type === 'element').length)
    
    const collapsedPreview = computed(() => {
      if (open.value || !expandable.value) return ''
      const tags = props.node.children.filter((c) => c.type === 'element').slice(0, 3)
      const parts = tags.map((c) => `<${c.name}${c.attributes.length ? '' : ''}>`)
      const suffix = childCount.value > 3 ? '' : ''
      return parts.join(' ') + suffix
    })
    
    const textOnlyChild = computed(() => {
      if (props.node.type !== 'element') return null
      if (props.node.children.length === 1 && props.node.children[0]!.type === 'text') {
        return props.node.children[0]!.text
      }
      return null
    })
    
    function onCopy() {
      const value = props.node.type === 'element' ? serializeXml(props.node) : props.node.text
      emit('copy', value, props.path)
    }
    
    function getTreeRows(from: HTMLElement): HTMLElement[] {
      const tree = from.closest('[role="tree"]')
      if (!tree) return []
      return Array.from(tree.querySelectorAll<HTMLElement>('[data-tree-row]'))
    }
    
    function focusRow(row: HTMLElement | null | undefined) {
      row?.focus()
    }
    
    function handleRowKeydown(e: KeyboardEvent) {
      const target = e.currentTarget as HTMLElement
    
      if (e.key === 'Enter' || e.key === ' ') {
        e.preventDefault()
        if (expandable.value) props.toggle(props.path)
        else onCopy()
        return
      }
    
      if (e.key === 'ArrowRight') {
        e.preventDefault()
        if (expandable.value && !open.value) {
          props.toggle(props.path)
        } else if (expandable.value && open.value) {
          const rows = getTreeRows(target)
          const idx = rows.indexOf(target)
          if (idx >= 0 && idx < rows.length - 1) focusRow(rows[idx + 1])
        }
        return
      }
    
      if (e.key === 'ArrowLeft') {
        e.preventDefault()
        if (expandable.value && open.value) {
          props.toggle(props.path)
        } else if (parentKey.value) {
          const tree = target.closest('[role="tree"]')
          const parent = tree?.querySelector<HTMLElement>(`[data-tree-row][data-tree-id="${CSS.escape(parentKey.value)}"]`)
          focusRow(parent)
        }
        return
      }
    
      if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
        e.preventDefault()
        const rows = getTreeRows(target)
        const idx = rows.indexOf(target)
        if (idx < 0) return
        focusRow(e.key === 'ArrowDown' ? rows[idx + 1] : rows[idx - 1])
        return
      }
    
      if (e.key === 'Home') {
        e.preventDefault()
        focusRow(getTreeRows(target)[0])
        return
      }
    
      if (e.key === 'End') {
        e.preventDefault()
        const rows = getTreeRows(target)
        focusRow(rows[rows.length - 1])
      }
    }
    </script>
    
    <template>
      <div
        :data-dimmed="dimmed ? '' : undefined"
        :class="dimmed ? 'opacity-30' : ''"
        role="treeitem"
        :aria-expanded="expandable ? open : undefined"
      >
        <!-- Element node -->
        <template v-if="node.type === 'element'">
          <!-- Expandable element header -->
          <div
            v-if="expandable"
            data-tree-row
            :data-tree-id="key"
            :data-tree-parent="parentKey ?? undefined"
            tabindex="0"
            class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
            :style="{ paddingLeft: `${indent}px` }"
            @click="toggle(path)"
            @keydown="handleRowKeydown"
          >
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground hover:bg-accent inline-flex size-4 shrink-0 items-center justify-center rounded"
              :aria-expanded="open"
              :aria-label="open ? 'Collapse' : 'Expand'"
              tabindex="-1"
              @click.stop="toggle(path)"
            >
              <ChevronDown v-if="open" class="size-3.5" />
              <ChevronRight v-else class="size-3.5" />
            </button>
            <span :class="punctColor" class="select-none">&lt;</span>
            <span :class="tagColor" class="select-none">{{ node.name }}</span>
            <template v-for="attr in node.attributes" :key="attr.name">
              <span class="select-none">&nbsp;</span>
              <span :class="attrNameColor" class="select-none">{{ attr.name }}</span>
              <span :class="punctColor" class="select-none">=</span>
              <span :class="attrValueColor" class="select-none">"{{ attr.value }}"</span>
            </template>
            <span :class="punctColor" class="select-none">&gt;</span>
            <span v-if="open" class="text-muted-foreground ml-0.5 text-xs">
              {{ childCount }} {{ childCount === 1 ? 'child' : 'children' }}
            </span>
            <span v-else class="text-muted-foreground ml-1 truncate text-xs select-none">{{ collapsedPreview }}</span>
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 shrink-0 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1"
              title="Copy subtree"
              aria-label="Copy subtree"
              tabindex="-1"
              @click.stop="onCopy"
            >
              <Check v-if="copiedPath === key" class="size-3 text-emerald-500" />
              <Copy v-else class="size-3" />
            </button>
          </div>
    
          <!-- Expandable children -->
          <div v-if="expandable && open" role="group">
            <XmlTreeNode
              v-for="entry in childEntries"
              :key="entry.segment"
              :node="entry.child"
              :path="entry.path"
              :is-root="false"
              :search="search"
              :max-depth="maxDepth"
              :matches-search="matchesSearch"
              :is-expanded="isExpanded"
              :toggle="toggle"
              :tag-color="tagColor"
              :attr-name-color="attrNameColor"
              :attr-value-color="attrValueColor"
              :text-color="textColor"
              :comment-color="commentColor"
              :punct-color="punctColor"
              :copied-path="copiedPath"
              @copy="(val, p) => emit('copy', val, p)"
            />
            <div class="flex items-center gap-0.5 py-0.5 select-none" :style="{ paddingLeft: `${indent}px` }">
              <span class="inline-flex size-4 shrink-0" />
              <span :class="punctColor">&lt;/</span>
              <span :class="tagColor">{{ node.name }}</span>
              <span :class="punctColor">&gt;</span>
            </div>
          </div>
    
          <!-- Inline element: text-only or empty / self-closing -->
          <div
            v-if="!expandable"
            data-tree-row
            :data-tree-id="key"
            :data-tree-parent="parentKey ?? undefined"
            tabindex="0"
            class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
            :style="{ paddingLeft: `${indent}px` }"
            @click="onCopy"
            @keydown="handleRowKeydown"
          >
            <span class="inline-flex size-4 shrink-0" />
            <span :class="punctColor" class="select-none">&lt;</span>
            <span :class="tagColor" class="select-none">{{ node.name }}</span>
            <template v-for="attr in node.attributes" :key="attr.name">
              <span class="select-none">&nbsp;</span>
              <span :class="attrNameColor" class="select-none">{{ attr.name }}</span>
              <span :class="punctColor" class="select-none">=</span>
              <span :class="attrValueColor" class="select-none">"{{ attr.value }}"</span>
            </template>
            <template v-if="textOnlyChild !== null">
              <span :class="punctColor" class="select-none">&gt;</span>
              <span :class="textColor" class="truncate">{{ textOnlyChild }}</span>
              <span :class="punctColor" class="shrink-0 select-none">&lt;/</span>
              <span :class="tagColor" class="shrink-0 select-none">{{ node.name }}</span>
              <span :class="punctColor" class="shrink-0 select-none">&gt;</span>
            </template>
            <template v-else>
              <span :class="punctColor" class="select-none"> /&gt;</span>
            </template>
            <button
              type="button"
              class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 shrink-0 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1"
              title="Copy value"
              aria-label="Copy value"
              tabindex="-1"
              @click.stop="onCopy"
            >
              <Check v-if="copiedPath === key" class="size-3 text-emerald-500" />
              <Copy v-else class="size-3" />
            </button>
          </div>
        </template>
    
        <!-- Comment -->
        <div
          v-else-if="node.type === 'comment'"
          data-tree-row
          :data-tree-id="key"
          :data-tree-parent="parentKey ?? undefined"
          tabindex="0"
          class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
          :style="{ paddingLeft: `${indent}px` }"
          @click="onCopy"
          @keydown="handleRowKeydown"
        >
          <span class="inline-flex size-4 shrink-0" />
          <span :class="commentColor" class="truncate italic select-none">&lt;!--{{ node.text }}--&gt;</span>
          <button
            type="button"
            class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 shrink-0 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1"
            title="Copy comment"
            aria-label="Copy comment"
            tabindex="-1"
            @click.stop="onCopy"
          >
            <Check v-if="copiedPath === key" class="size-3 text-emerald-500" />
            <Copy v-else class="size-3" />
          </button>
        </div>
    
        <!-- CDATA -->
        <div
          v-else-if="node.type === 'cdata'"
          data-tree-row
          :data-tree-id="key"
          :data-tree-parent="parentKey ?? undefined"
          tabindex="0"
          class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
          :style="{ paddingLeft: `${indent}px` }"
          @click="onCopy"
          @keydown="handleRowKeydown"
        >
          <span class="inline-flex size-4 shrink-0" />
          <span :class="punctColor" class="select-none">&lt;![CDATA[</span>
          <span :class="textColor" class="truncate">{{ node.text }}</span>
          <span :class="punctColor" class="shrink-0 select-none">]]&gt;</span>
          <button
            type="button"
            class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 shrink-0 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1"
            title="Copy CDATA"
            aria-label="Copy CDATA"
            tabindex="-1"
            @click.stop="onCopy"
          >
            <Check v-if="copiedPath === key" class="size-3 text-emerald-500" />
            <Copy v-else class="size-3" />
          </button>
        </div>
    
        <!-- Bare text (rare when not folded into parent) -->
        <div
          v-else
          data-tree-row
          :data-tree-id="key"
          :data-tree-parent="parentKey ?? undefined"
          tabindex="0"
          class="group hover:bg-accent/40 focus-visible:ring-ring/50 -mx-1 flex items-center gap-0.5 rounded px-1 py-0.5 transition-colors focus-visible:ring-2 focus-visible:outline-none"
          :style="{ paddingLeft: `${indent}px` }"
          @click="onCopy"
          @keydown="handleRowKeydown"
        >
          <span class="inline-flex size-4 shrink-0" />
          <span :class="textColor" class="truncate">{{ node.text }}</span>
          <button
            type="button"
            class="text-muted-foreground hover:text-foreground focus-visible:ring-ring ml-auto inline-flex size-5 shrink-0 items-center justify-center rounded opacity-0 transition-opacity group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100 focus-visible:ring-1"
            title="Copy text"
            aria-label="Copy text"
            tabindex="-1"
            @click.stop="onCopy"
          >
            <Check v-if="copiedPath === key" class="size-3 text-emerald-500" />
            <Copy v-else class="size-3" />
          </button>
        </div>
      </div>
    </template>
  • app/components/ui/xml-tree-view/types.ts 4.4 kB
    export type XmlNodeType = 'element' | 'text' | 'comment' | 'cdata'
    
    export interface XmlAttr {
      name: string
      value: string
    }
    
    export interface XmlNode {
      type: XmlNodeType
      /** Tag name for elements; empty for text / comment / cdata. */
      name: string
      attributes: XmlAttr[]
      /** Character data for text / comment / cdata nodes. */
      text: string
      children: XmlNode[]
    }
    
    export interface ParseXmlResult {
      root: XmlNode | null
      error: string | null
    }
    
    /**
     * Convert a DOM node into a lightweight tree for rendering.
     * Whitespace-only text nodes are dropped so the tree stays readable.
     */
    function domToNode(node: Node): XmlNode | null {
      if (node.nodeType === Node.ELEMENT_NODE) {
        const el = node as Element
        const attributes: XmlAttr[] = Array.from(el.attributes).map((a) => ({
          name: a.name,
          value: a.value,
        }))
        const children: XmlNode[] = []
        for (const child of Array.from(el.childNodes)) {
          const n = domToNode(child)
          if (n) children.push(n)
        }
        return {
          type: 'element',
          name: el.tagName,
          attributes,
          text: '',
          children,
        }
      }
    
      if (node.nodeType === Node.TEXT_NODE) {
        const text = node.textContent ?? ''
        if (!text.trim()) return null
        return { type: 'text', name: '', attributes: [], text, children: [] }
      }
    
      if (node.nodeType === Node.COMMENT_NODE) {
        return {
          type: 'comment',
          name: '',
          attributes: [],
          text: node.textContent ?? '',
          children: [],
        }
      }
    
      if (node.nodeType === Node.CDATA_SECTION_NODE) {
        return {
          type: 'cdata',
          name: '',
          attributes: [],
          text: node.textContent ?? '',
          children: [],
        }
      }
    
      return null
    }
    
    /**
     * Parse an XML string with the browser DOMParser.
     * Returns a structured tree or a human-readable error.
     */
    export function parseXml(source: string): ParseXmlResult {
      const trimmed = source?.trim() ?? ''
      if (!trimmed) return { root: null, error: 'Empty XML' }
    
      if (typeof DOMParser === 'undefined') {
        return { root: null, error: 'DOMParser is not available in this environment' }
      }
    
      try {
        const doc = new DOMParser().parseFromString(trimmed, 'application/xml')
        const parseError = doc.querySelector('parsererror')
        if (parseError) {
          const msg = parseError.textContent?.replace(/\s+/g, ' ').trim() || 'Invalid XML'
          return { root: null, error: msg }
        }
        const el = doc.documentElement
        if (!el) return { root: null, error: 'Empty document' }
        const root = domToNode(el)
        if (!root) return { root: null, error: 'Could not read document element' }
        return { root, error: null }
      } catch (e) {
        return { root: null, error: e instanceof Error ? e.message : 'Failed to parse XML' }
      }
    }
    
    /** Serialize a tree node (and descendants) back to XML. */
    export function serializeXml(node: XmlNode, indent = 0): string {
      const pad = '  '.repeat(indent)
    
      if (node.type === 'text') return node.text
      if (node.type === 'comment') return `${pad}<!--${node.text}-->`
      if (node.type === 'cdata') return `${pad}<![CDATA[${node.text}]]>`
    
      const attrs =
        node.attributes.length > 0
          ? ' ' + node.attributes.map((a) => `${a.name}="${escapeAttr(a.value)}"`).join(' ')
          : ''
    
      if (node.children.length === 0) {
        return `${pad}<${node.name}${attrs} />`
      }
    
      // Single text child → keep on one line
      if (node.children.length === 1 && node.children[0]!.type === 'text') {
        return `${pad}<${node.name}${attrs}>${escapeText(node.children[0]!.text)}</${node.name}>`
      }
    
      const inner = node.children.map((c) => serializeXml(c, indent + 1)).join('\n')
      return `${pad}<${node.name}${attrs}>\n${inner}\n${pad}</${node.name}>`
    }
    
    function escapeAttr(s: string): string {
      return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
    }
    
    function escapeText(s: string): string {
      return s.replace(/&/g, '&amp;').replace(/</g, '&lt;')
    }
    
    /** True when an element expands (has non-trivial children). */
    export function isExpandable(node: XmlNode): boolean {
      if (node.type !== 'element') return false
      if (node.children.length === 0) return false
      // Single text child stays inline — no expand
      if (node.children.length === 1 && node.children[0]!.type === 'text') return false
      return true
    }
    
    /** Count element descendants (for summary). */
    export function countElements(node: XmlNode): number {
      let n = node.type === 'element' ? 1 : 0
      for (const c of node.children) n += countElements(c)
      return n
    }
  • app/components/ui/xml-tree-view/index.ts 0.3 kB
    export { default as XmlTreeView } from './XmlTreeView.vue'
    export { default as XmlTreeNode } from './XmlTreeNode.vue'
    export type { XmlNode, XmlAttr, XmlNodeType, ParseXmlResult } from './types'
    export { parseXml, serializeXml, isExpandable, countElements } from './types'

Raw manifest: https://uipkge.dev/r/vue/xml-tree-view.json