Xml Tree View
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.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/xml-tree-view.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/xml-tree-view.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/xml-tree-view.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/xml-tree-view.jsonManifest:
https://uipkge.dev/r/svelte/xml-tree-view.jsonInstalls to:src/lib/components/ui/xml-tree-view/Examples
Loading interactive previews…
Loading interactive previews…
Props
| 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 |
oncopy | (value: string, path: string) => void | — | optional |
Schema
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
}npm dependencies
Files installed (4)
src/lib/components/ui/xml-tree-view/XmlTreeView.svelte9.1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' // `oncopy` here is the copy-notification callback (value, path) — it shadows the // native clipboard-event prop, which consumers don't need on this root. export interface XmlTreeViewProps extends Omit<HTMLAttributes<HTMLDivElement>, 'oncopy'> { /** 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 oncopy?: (value: string, path: string) => void } </script> <script lang="ts"> import { AlertCircle, CodeXml, FoldVertical, Search, UnfoldVertical } from '@lucide/svelte' import { cn } from '$lib/utils' import XmlTreeNode from './XmlTreeNode.svelte' import { countElements, isExpandable, parseXml, type XmlNode } from './types' let { data, expandDepth = 1, maxDepth = 100, showSearch = true, showToolbar = true, rootLabel, oncopy, class: className, ...restProps }: XmlTreeViewProps = $props() let expanded = $state<Set<string>>(new Set()) let search = $state('') let copiedPath = $state<string | null>(null) const parsed = $derived(parseXml(data)) const root = $derived(parsed.root) const parseError = $derived(parsed.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 if (!r) return next // Root is always at path [] with key "/" walkExpandable(r, [], 0, expandDepth, (path) => { next.add(pathKey(path)) }) return next } // Reset expansion when the document or depth changes (mirrors Vue's immediate watcher). $effect(() => { const _d = data const _e = expandDepth expanded = defaultExpanded() }) function toggle(path: string[]) { const key = pathKey(path) const next = new Set(expanded) if (next.has(key)) next.delete(key) else next.add(key) expanded = next } function isExpanded(path: string[]): boolean { return expanded.has(pathKey(path)) } function expandAll() { const next = new Set<string>() const r = root if (!r) { expanded = next return } walkExpandable(r, [], 0, maxDepth, (path) => { next.add(pathKey(path)) }) expanded = next } function collapseAll() { expanded = new Set() } function matchesSearch(node: XmlNode): boolean { if (!search) return true const term = search.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. Guarded to first-run so the // mount pass keeps the default expansion (mirrors Vue's non-immediate watcher). let searchPrimed = false $effect(() => { const q = search if (!searchPrimed) { searchPrimed = true return } if (!q) { expanded = defaultExpanded() return } const next = new Set<string>() const r = root if (!r) { expanded = next return } walkExpandable(r, [], 0, maxDepth, (path, node) => { if (matchesSearch(node)) next.add(pathKey(path)) }) expanded = 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 = p oncopy?.(value, p) setTimeout(() => { if (copiedPath === p) copiedPath = null }, 1200) } catch { // clipboard unavailable } } const effectiveRootLabel = $derived(rootLabel ?? root?.name ?? 'xml') const summary = $derived.by(() => { if (parseError) return 'Parse error' if (!root) return 'Empty' const n = countElements(root) return `${root.name} · ${n} element${n === 1 ? '' : 's'}` }) const searchMatchCount = $derived.by(() => { if (!search || !root) return 0 let count = 0 const term = search.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) return count }) </script> <div data-uipkge data-slot="xml-tree-view" class={cn('bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm', className)} {...restProps} > <!-- Toolbar --> {#if showToolbar || showSearch} <div 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"> {#if showSearch && !parseError} <div class="relative"> <Search class="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" /> <input bind:value={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> {/if} {#if search && !parseError} <span class="text-muted-foreground text-xs"> {searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'} </span> {/if} {#if !parseError} <button 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" onclick={expandAll} > <UnfoldVertical class="size-4" /> </button> <button 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" onclick={collapseAll} > <FoldVertical class="size-4" /> </button> {/if} </div> </div> {/if} <!-- Parse error --> {#if parseError} <div 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> {:else if root} <!-- Tree --> <div class="min-h-0 flex-1 overflow-auto p-2" role="tree" aria-label={effectiveRootLabel}> <XmlTreeNode node={root} path={[]} isRoot={true} {search} {maxDepth} {matchesSearch} {isExpanded} {toggle} {tagColor} {attrNameColor} {attrValueColor} {textColor} {commentColor} {punctColor} {copiedPath} oncopy={copyValue} /> </div> {/if} </div>src/lib/components/ui/xml-tree-view/XmlTreeNode.svelte13.4 kB
<script lang="ts" module> import type { XmlNode } from './types' export interface XmlTreeNodeProps { 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 oncopy: (value: string, path: string[]) => void } </script> <script lang="ts"> import { Check, ChevronDown, ChevronRight, Copy } from '@lucide/svelte' import { isExpandable, serializeXml } from './types' import XmlTreeNodeSelf from './XmlTreeNode.svelte' let { node, path, isRoot = false, search = '', maxDepth = 100, matchesSearch, isExpanded, toggle, tagColor, attrNameColor, attrValueColor, textColor, commentColor, punctColor, copiedPath = null, oncopy, }: XmlTreeNodeProps = $props() function pathKey(p: string[]): string { return p.length ? '/' + p.join('/') : '/' } const key = $derived(pathKey(path)) const open = $derived(isExpanded(path)) const expandable = $derived(isExpandable(node)) const dimmed = $derived(!!search && !matchesSearch(node)) const indent = $derived(isRoot ? 0 : 20) const parentKey = $derived(path.length ? pathKey(path.slice(0, -1)) : null) /** Child path segments with sibling indices for duplicate tag names. */ const childEntries = $derived.by(() => { 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) } } return 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: [...path, segment] as string[] } }) }) const childCount = $derived(node.children.filter((c) => c.type === 'element').length) const collapsedPreview = $derived.by(() => { if (open || !expandable) return '' const tags = node.children.filter((c) => c.type === 'element').slice(0, 3) const parts = tags.map((c) => `<${c.name}${c.attributes.length ? ' …' : ''}>`) const suffix = childCount > 3 ? ' …' : '' return parts.join(' ') + suffix }) const textOnlyChild = $derived.by(() => { if (node.type !== 'element') return null if (node.children.length === 1 && node.children[0]!.type === 'text') { return node.children[0]!.text } return null }) function onCopy() { const value = node.type === 'element' ? serializeXml(node) : node.text oncopy(value, 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) toggle(path) else onCopy() return } if (e.key === 'ArrowRight') { e.preventDefault() if (expandable && !open) { toggle(path) } else if (expandable && open) { 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 && open) { toggle(path) } else if (parentKey) { const tree = target.closest('[role="tree"]') const parent = tree?.querySelector<HTMLElement>(`[data-tree-row][data-tree-id="${CSS.escape(parentKey)}"]`) 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]) } } const rowClass = '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' const copyBtnClass = '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' </script> <div data-dimmed={dimmed ? '' : undefined} class={dimmed ? 'opacity-30' : ''} role="treeitem" aria-expanded={expandable ? open : undefined} aria-selected={false} > {#if node.type === 'element'} <!-- Expandable element header --> {#if expandable} <!-- svelte-ignore a11y_no_static_element_interactions --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class={rowClass} style:padding-left={`${indent}px`} onclick={() => toggle(path)} onkeydown={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" onclick={(e) => { e.stopPropagation() toggle(path) }} > {#if open} <ChevronDown class="size-3.5" /> {:else} <ChevronRight class="size-3.5" /> {/if} </button> <span class={punctColor + ' select-none'}><</span> <span class={tagColor + ' select-none'}>{node.name}</span> {#each node.attributes as attr (attr.name)} <span class="select-none"> </span> <span class={attrNameColor + ' select-none'}>{attr.name}</span> <span class={punctColor + ' select-none'}>=</span> <span class={attrValueColor + ' select-none'}>"{attr.value}"</span> {/each} <span class={punctColor + ' select-none'}>></span> {#if open} <span class="text-muted-foreground ml-0.5 text-xs"> {childCount} {childCount === 1 ? 'child' : 'children'} </span> {:else} <span class="text-muted-foreground ml-1 truncate text-xs select-none">{collapsedPreview}</span> {/if} <button type="button" class={copyBtnClass} title="Copy subtree" aria-label="Copy subtree" tabindex="-1" onclick={(e) => { e.stopPropagation() onCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> <!-- Expandable children --> {#if open} <div role="group"> {#each childEntries as entry (entry.segment)} <XmlTreeNodeSelf node={entry.child} path={entry.path} isRoot={false} {search} maxDepth={maxDepth} {matchesSearch} {isExpanded} {toggle} {tagColor} {attrNameColor} {attrValueColor} {textColor} {commentColor} {punctColor} {copiedPath} {oncopy} /> {/each} <div class="flex items-center gap-0.5 py-0.5 select-none" style:padding-left={`${indent}px`}> <span class="inline-flex size-4 shrink-0"></span> <span class={punctColor}></</span> <span class={tagColor}>{node.name}</span> <span class={punctColor}>></span> </div> </div> {/if} {:else} <!-- Inline element: text-only or empty / self-closing --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class={rowClass} style:padding-left={`${indent}px`} onclick={onCopy} onkeydown={handleRowKeydown} > <span class="inline-flex size-4 shrink-0"></span> <span class={punctColor + ' select-none'}><</span> <span class={tagColor + ' select-none'}>{node.name}</span> {#each node.attributes as attr (attr.name)} <span class="select-none"> </span> <span class={attrNameColor + ' select-none'}>{attr.name}</span> <span class={punctColor + ' select-none'}>=</span> <span class={attrValueColor + ' select-none'}>"{attr.value}"</span> {/each} {#if textOnlyChild !== null} <span class={punctColor + ' select-none'}>></span> <span class={textColor + ' truncate'}>{textOnlyChild}</span> <span class={punctColor + ' shrink-0 select-none'}></</span> <span class={tagColor + ' shrink-0 select-none'}>{node.name}</span> <span class={punctColor + ' shrink-0 select-none'}>></span> {:else} <span class={punctColor + ' select-none'}> /></span> {/if} <button type="button" class={copyBtnClass} title="Copy value" aria-label="Copy value" tabindex="-1" onclick={(e) => { e.stopPropagation() onCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> {/if} {:else if node.type === 'comment'} <!-- svelte-ignore a11y_no_static_element_interactions --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class={rowClass} style:padding-left={`${indent}px`} onclick={onCopy} onkeydown={handleRowKeydown} > <span class="inline-flex size-4 shrink-0"></span> <span class={commentColor + ' truncate italic select-none'}><!--{node.text}--></span> <button type="button" class={copyBtnClass} title="Copy comment" aria-label="Copy comment" tabindex="-1" onclick={(e) => { e.stopPropagation() onCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> {:else if node.type === 'cdata'} <!-- svelte-ignore a11y_no_static_element_interactions --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class={rowClass} style:padding-left={`${indent}px`} onclick={onCopy} onkeydown={handleRowKeydown} > <span class="inline-flex size-4 shrink-0"></span> <span class={punctColor + ' select-none'}><![CDATA[</span> <span class={textColor + ' truncate'}>{node.text}</span> <span class={punctColor + ' shrink-0 select-none'}>]]></span> <button type="button" class={copyBtnClass} title="Copy CDATA" aria-label="Copy CDATA" tabindex="-1" onclick={(e) => { e.stopPropagation() onCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> {:else} <!-- Bare text (rare when not folded into parent) --> <!-- svelte-ignore a11y_no_static_element_interactions --> <div data-tree-row data-tree-id={key} data-tree-parent={parentKey ?? undefined} tabindex="0" class={rowClass} style:padding-left={`${indent}px`} onclick={onCopy} onkeydown={handleRowKeydown} > <span class="inline-flex size-4 shrink-0"></span> <span class={textColor + ' truncate'}>{node.text}</span> <button type="button" class={copyBtnClass} title="Copy text" aria-label="Copy text" tabindex="-1" onclick={(e) => { e.stopPropagation() onCopy() }} > {#if copiedPath === key} <Check class="size-3 text-emerald-500" /> {:else} <Copy class="size-3" /> {/if} </button> </div> {/if} </div>src/lib/components/ui/xml-tree-view/types.ts4.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 }src/lib/components/ui/xml-tree-view/index.ts0.3 kB
export { default as XmlTreeView, type XmlTreeViewProps } from './XmlTreeView.svelte' export { default as XmlTreeNode, type XmlTreeNodeProps } from './XmlTreeNode.svelte' export type { XmlNode, XmlAttr, XmlNodeType, ParseXmlResult } from './types' export { parseXml, serializeXml, isExpandable, countElements } from './types'
Raw manifest:https://uipkge.dev/r/svelte/xml-tree-view.json