
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
xml-tree-viewuiCollapsible 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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/xml-tree-viewInstalls to:app/components/ui/xml-tree-view/| Name | Type / Values | Default | Required |
|---|---|---|---|
dataRaw XML string to parse and display. | string | — | required |
expandDepth | number | 1 | optional |
maxDepth | number | 100 | optional |
showSearch | boolean | true | optional |
showToolbar | boolean | true | optional |
rootLabelOverride path root label; defaults to the document element name. | string | — | optional |
class | HTMLAttributes['class'] | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
XmlAttrinterface XmlAttr {
name: string
value: string
}XmlNodeinterface 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[]
}ParseXmlResultinterface ParseXmlResult {
root: XmlNode | null
error: string | null
}<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>
<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>
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
}
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