Xml Tree View
xml-tree-view ui 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
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/xml-tree-view.json $ npx shadcn-vue@latest add https://uipkge.dev/r/vue/xml-tree-view.json $ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/xml-tree-view.json $ bunx 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…
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"><</span> <span :class="tagColor" class="select-none">{{ node.name }}</span> <template v-for="attr in node.attributes" :key="attr.name"> <span class="select-none"> </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">></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"></</span> <span :class="tagColor">{{ node.name }}</span> <span :class="punctColor">></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"><</span> <span :class="tagColor" class="select-none">{{ node.name }}</span> <template v-for="attr in node.attributes" :key="attr.name"> <span class="select-none"> </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">></span> <span :class="textColor" class="truncate">{{ textOnlyChild }}</span> <span :class="punctColor" class="shrink-0 select-none"></</span> <span :class="tagColor" class="shrink-0 select-none">{{ node.name }}</span> <span :class="punctColor" class="shrink-0 select-none">></span> </template> <template v-else> <span :class="punctColor" class="select-none"> /></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"><!--{{ 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 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"><![CDATA[</span> <span :class="textColor" class="truncate">{{ node.text }}</span> <span :class="punctColor" class="shrink-0 select-none">]]></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, '&').replace(/"/g, '"').replace(/</g, '<') } function escapeText(s: string): string { return s.replace(/&/g, '&').replace(/</g, '<') } /** 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