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 Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/xml-tree-view.json
Named registry: npx shadcn@latest add @uipkge-react/xml-tree-view Installs to: 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 optional
maxDepth number optional
showSearch boolean optional
showToolbar boolean optional
rootLabel

Override 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.

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)

  • components/ui/xml-tree-view/XmlTreeView.tsx 8.6 kB
    import * as React from 'react'
    import { Search, CodeXml, FoldVertical, UnfoldVertical, AlertCircle } from 'lucide-react'
    import { cn } from '@/lib/utils'
    import { XmlTreeNode } from './XmlTreeNode'
    import { parseXml, countElements, pathKey, walkExpandable, type XmlNode } from './types'
    
    export type { XmlNode } from './types'
    
    export interface XmlTreeViewProps extends Omit<React.HTMLAttributes<HTMLDivElement>, 'data' | '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
    }
    
    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'
    
    const XmlTreeView = React.forwardRef<HTMLDivElement, XmlTreeViewProps>(function XmlTreeView(props, ref) {
      const {
        data,
        expandDepth = 1,
        maxDepth = 100,
        showSearch = true,
        showToolbar = true,
        rootLabel,
        onCopy,
        className,
        ...rest
      } = props
    
      const [expanded, setExpanded] = React.useState<Set<string>>(() => new Set())
      const [search, setSearch] = React.useState('')
      const [copiedPath, setCopiedPath] = React.useState<string | null>(null)
    
      const parsed = React.useMemo(() => parseXml(data), [data])
      const root = parsed.root
      const parseError = parsed.error
    
      const expandDepthRef = React.useRef(expandDepth)
      expandDepthRef.current = expandDepth
      const maxDepthRef = React.useRef(maxDepth)
      maxDepthRef.current = maxDepth
      const rootRef = React.useRef(root)
      rootRef.current = root
    
      function defaultExpanded(): Set<string> {
        const next = new Set<string>()
        const r = rootRef.current
        if (!r) return next
        walkExpandable(r, [], 0, expandDepthRef.current, (path) => {
          next.add(pathKey(path))
        })
        return next
      }
    
      React.useEffect(() => {
        setExpanded(defaultExpanded())
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [data, expandDepth])
    
      function toggle(path: string[]) {
        const k = pathKey(path)
        setExpanded((prev) => {
          const next = new Set(prev)
          if (next.has(k)) next.delete(k)
          else next.add(k)
          return next
        })
      }
    
      function isExpanded(path: string[]): boolean {
        return expanded.has(pathKey(path))
      }
    
      function expandAll() {
        const next = new Set<string>()
        const r = rootRef.current
        if (r) {
          walkExpandable(r, [], 0, maxDepthRef.current, (path) => {
            next.add(pathKey(path))
          })
        }
        setExpanded(next)
      }
    
      function collapseAll() {
        setExpanded(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)
      }
    
      React.useEffect(() => {
        if (!search) {
          setExpanded(defaultExpanded())
          return
        }
        const next = new Set<string>()
        const r = rootRef.current
        if (r) {
          walkExpandable(r, [], 0, maxDepthRef.current, (path, node) => {
            if (matchesSearch(node)) next.add(pathKey(path))
          })
        }
        setExpanded(next)
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, [search])
    
      async function copyValue(value: string, path: string[]) {
        const p = pathKey(path)
        try {
          await navigator.clipboard.writeText(value)
          setCopiedPath(p)
          onCopy?.(value, p)
          setTimeout(() => {
            setCopiedPath((cur) => (cur === p ? null : cur))
          }, 1200)
        } catch {
          // clipboard unavailable
        }
      }
    
      const effectiveRootLabel = rootLabel ?? root?.name ?? 'xml'
    
      const summary = React.useMemo(() => {
        if (parseError) return 'Parse error'
        if (!root) return 'Empty'
        const n = countElements(root)
        return `${root.name} · ${n} element${n === 1 ? '' : 's'}`
      }, [parseError, root])
    
      const searchMatchCount = React.useMemo(() => {
        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
      }, [search, root])
    
      return (
        <div
          ref={ref}
          data-uipkge=""
          data-slot="xml-tree-view"
          className={cn('bg-background flex flex-col overflow-hidden rounded-lg border font-mono text-sm', className)}
          {...rest}
        >
          {(showToolbar || showSearch) && (
            <div className="border-border flex items-center gap-2 border-b px-3 py-2">
              <div className="flex items-center gap-1.5">
                <CodeXml className="text-muted-foreground size-4" />
                <span className="text-muted-foreground text-xs">{summary}</span>
              </div>
              <div className="ml-auto flex items-center gap-1">
                {showSearch && !parseError && (
                  <div className="relative">
                    <Search className="text-muted-foreground absolute top-1/2 left-2 size-3.5 -translate-y-1/2" />
                    <input
                      type="text"
                      value={search}
                      onChange={(e) => setSearch(e.target.value)}
                      placeholder="Filter..."
                      aria-label="Filter XML tree"
                      className="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>
                )}
                {search && !parseError && (
                  <span className="text-muted-foreground text-xs">
                    {searchMatchCount} match{searchMatchCount === 1 ? '' : 'es'}
                  </span>
                )}
                {!parseError && (
                  <>
                    <button
                      type="button"
                      className="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 className="size-4" />
                    </button>
                    <button
                      type="button"
                      className="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 className="size-4" />
                    </button>
                  </>
                )}
              </div>
            </div>
          )}
    
          {parseError ? (
            <div className="text-destructive flex items-start gap-2 p-4 text-sm">
              <AlertCircle className="mt-0.5 size-4 shrink-0" />
              <div className="min-w-0">
                <p className="font-sans font-medium">Invalid XML</p>
                <p className="text-muted-foreground mt-1 font-mono text-xs break-words">{parseError}</p>
              </div>
            </div>
          ) : root ? (
            <div className="min-h-0 flex-1 overflow-auto p-2" role="tree" aria-label={effectiveRootLabel}>
              <XmlTreeNode
                node={root}
                path={[]}
                isRoot
                search={search}
                maxDepth={maxDepth}
                matchesSearch={matchesSearch}
                isExpanded={isExpanded}
                toggle={toggle}
                tagColor={tagColor}
                attrNameColor={attrNameColor}
                attrValueColor={attrValueColor}
                textColor={textColor}
                commentColor={commentColor}
                punctColor={punctColor}
                copiedPath={copiedPath}
                onCopy={copyValue}
              />
            </div>
          ) : null}
        </div>
      )
    })
    
    XmlTreeView.displayName = 'XmlTreeView'
    
    export { XmlTreeView }
  • components/ui/xml-tree-view/XmlTreeNode.tsx 11.4 kB
    import * as React from 'react'
    import { ChevronDown, ChevronRight, Copy, Check } from 'lucide-react'
    import type { XmlNode } from './types'
    import { isExpandable, serializeXml, pathKey, childEntries } 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
    }
    
    function XmlTreeNode(props: XmlTreeNodeProps): React.ReactElement {
      const {
        node,
        path,
        isRoot = false,
        search = '',
        maxDepth = 100,
        matchesSearch,
        isExpanded,
        toggle,
        tagColor,
        attrNameColor,
        attrValueColor,
        textColor,
        commentColor,
        punctColor,
        copiedPath = null,
        onCopy,
      } = props
    
      const key = pathKey(path)
      const open = isExpanded(path)
      const expandable = isExpandable(node)
      const dimmed = !!search && !matchesSearch(node)
      const indent = isRoot ? 0 : 20
      const parentKey = path.length ? pathKey(path.slice(0, -1)) : null
    
      const entries = React.useMemo(() => childEntries(node, path), [node, path])
      const childCount = node.children.filter((c) => c.type === 'element').length
    
      const collapsedPreview = React.useMemo(() => {
        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
      }, [open, expandable, node.children, childCount])
    
      const textOnlyChild =
        node.type === 'element' && node.children.length === 1 && node.children[0]!.type === 'text'
          ? node.children[0]!.text
          : null
    
      function handleCopy() {
        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: React.KeyboardEvent) {
        const target = e.currentTarget as HTMLElement
    
        if (e.key === 'Enter' || e.key === ' ') {
          e.preventDefault()
          if (expandable) toggle(path)
          else handleCopy()
          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 copyBtn = (
        <button
          type="button"
          className="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"
          aria-label="Copy"
          tabIndex={-1}
          onClick={(e) => {
            e.stopPropagation()
            handleCopy()
          }}
        >
          {copiedPath === key ? <Check className="size-3 text-emerald-500" /> : <Copy className="size-3" />}
        </button>
      )
    
      const attrs = node.attributes.map((attr) => (
        <React.Fragment key={attr.name}>
          <span className="select-none">&nbsp;</span>
          <span className={`${attrNameColor} select-none`}>{attr.name}</span>
          <span className={`${punctColor} select-none`}>=</span>
          <span className={`${attrValueColor} select-none`}>"{attr.value}"</span>
        </React.Fragment>
      ))
    
      return (
        <div
          data-dimmed={dimmed ? '' : undefined}
          className={dimmed ? 'opacity-30' : ''}
          role="treeitem"
          aria-expanded={expandable ? open : undefined}
        >
          {node.type === 'element' && expandable && (
            <>
              <div
                data-tree-row
                data-tree-id={key}
                data-tree-parent={parentKey ?? undefined}
                tabIndex={0}
                className="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` }}
                onClick={() => toggle(path)}
                onKeyDown={handleRowKeydown}
              >
                <button
                  type="button"
                  className="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)
                  }}
                >
                  {open ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
                </button>
                <span className={`${punctColor} select-none`}>&lt;</span>
                <span className={`${tagColor} select-none`}>{node.name}</span>
                {attrs}
                <span className={`${punctColor} select-none`}>&gt;</span>
                {open ? (
                  <span className="text-muted-foreground ml-0.5 text-xs">
                    {childCount} {childCount === 1 ? 'child' : 'children'}
                  </span>
                ) : (
                  <span className="text-muted-foreground ml-1 truncate text-xs select-none">{collapsedPreview}</span>
                )}
                {copyBtn}
              </div>
    
              {open && (
                <div role="group">
                  {entries.map((entry) => (
                    <XmlTreeNode
                      key={entry.segment}
                      node={entry.child}
                      path={entry.path}
                      isRoot={false}
                      search={search}
                      maxDepth={maxDepth}
                      matchesSearch={matchesSearch}
                      isExpanded={isExpanded}
                      toggle={toggle}
                      tagColor={tagColor}
                      attrNameColor={attrNameColor}
                      attrValueColor={attrValueColor}
                      textColor={textColor}
                      commentColor={commentColor}
                      punctColor={punctColor}
                      copiedPath={copiedPath}
                      onCopy={onCopy}
                    />
                  ))}
                  <div className="flex items-center gap-0.5 py-0.5 select-none" style={{ paddingLeft: `${indent}px` }}>
                    <span className="inline-flex size-4 shrink-0" />
                    <span className={punctColor}>&lt;/</span>
                    <span className={tagColor}>{node.name}</span>
                    <span className={punctColor}>&gt;</span>
                  </div>
                </div>
              )}
            </>
          )}
    
          {node.type === 'element' && !expandable && (
            <div
              data-tree-row
              data-tree-id={key}
              data-tree-parent={parentKey ?? undefined}
              tabIndex={0}
              className="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` }}
              onClick={handleCopy}
              onKeyDown={handleRowKeydown}
            >
              <span className="inline-flex size-4 shrink-0" />
              <span className={`${punctColor} select-none`}>&lt;</span>
              <span className={`${tagColor} select-none`}>{node.name}</span>
              {attrs}
              {textOnlyChild !== null ? (
                <>
                  <span className={`${punctColor} select-none`}>&gt;</span>
                  <span className={`${textColor} truncate`}>{textOnlyChild}</span>
                  <span className={`${punctColor} shrink-0 select-none`}>&lt;/</span>
                  <span className={`${tagColor} shrink-0 select-none`}>{node.name}</span>
                  <span className={`${punctColor} shrink-0 select-none`}>&gt;</span>
                </>
              ) : (
                <span className={`${punctColor} select-none`}> /&gt;</span>
              )}
              {copyBtn}
            </div>
          )}
    
          {node.type === 'comment' && (
            <div
              data-tree-row
              data-tree-id={key}
              data-tree-parent={parentKey ?? undefined}
              tabIndex={0}
              className="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` }}
              onClick={handleCopy}
              onKeyDown={handleRowKeydown}
            >
              <span className="inline-flex size-4 shrink-0" />
              <span className={`${commentColor} truncate italic select-none`}>&lt;!--{node.text}--&gt;</span>
              {copyBtn}
            </div>
          )}
    
          {node.type === 'cdata' && (
            <div
              data-tree-row
              data-tree-id={key}
              data-tree-parent={parentKey ?? undefined}
              tabIndex={0}
              className="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` }}
              onClick={handleCopy}
              onKeyDown={handleRowKeydown}
            >
              <span className="inline-flex size-4 shrink-0" />
              <span className={`${punctColor} select-none`}>&lt;![CDATA[</span>
              <span className={`${textColor} truncate`}>{node.text}</span>
              <span className={`${punctColor} shrink-0 select-none`}>]]&gt;</span>
              {copyBtn}
            </div>
          )}
    
          {node.type === 'text' && (
            <div
              data-tree-row
              data-tree-id={key}
              data-tree-parent={parentKey ?? undefined}
              tabIndex={0}
              className="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` }}
              onClick={handleCopy}
              onKeyDown={handleRowKeydown}
            >
              <span className="inline-flex size-4 shrink-0" />
              <span className={`${textColor} truncate`}>{node.text}</span>
              {copyBtn}
            </div>
          )}
        </div>
      )
    }
    
    export { XmlTreeNode }
  • components/ui/xml-tree-view/types.ts 6.3 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
    }
    
    /** Path key for expand/collapse set. */
    export function pathKey(path: string[]): string {
      return path.length ? '/' + path.join('/') : '/'
    }
    
    /** Walk expandable nodes with consistent sibling-indexed path segments. */
    export 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)
        })
      }
    }
    
    export function childEntries(node: XmlNode, path: string[]) {
      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 {
          segment = `text()[${i}]`
        }
        return { child, segment, path: [...path, segment] as string[] }
      })
    }
  • components/ui/xml-tree-view/index.ts 0.3 kB
    export { XmlTreeView, type XmlTreeViewProps } from './XmlTreeView'
    export { XmlTreeNode, type XmlTreeNodeProps } from './XmlTreeNode'
    export type { XmlNode, XmlAttr, XmlNodeType, ParseXmlResult } from './types'
    export { parseXml, serializeXml, isExpandable, countElements } from './types'

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