npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@spscommerce/sorc-text-viewer

v1.0.4

Published

Shared React component and hooks library for viewing parcel payloads

Readme

text-viewer

A performant, virtualized text viewer React component for displaying large files with line numbers, search, collapsible sections, and interactive features.

Features

  • Virtualized rendering — only visible lines are in the DOM; handles thousands of lines without performance issues
  • Built-in search — debounced search (500ms) with Next/Previous navigation and result counter
  • Collapsible sections — expand/collapse hierarchical content (e.g. XML groups)
  • Customizable lines — per-line CSS classes, gutter icons, and action buttons
  • Line selection — highlight a single line or range via prop ("90" or "90-94"); drag the gutter to select a range interactively
  • XML parser hookuseXmlParser converts raw XML into renderable lines with XPath, attributes, and diff support
  • Keyboard shortcutsCtrl+F focuses search, Enter navigates results, Ctrl+Alt+C triggers a custom hover action

Publishing

This package is published to npm under the SPS Commerce organization automatically by the Azure pipeline (azure-pipelines.yml). You do not publish manually.

How it works:

  • On every PR to main — the pipeline installs dependencies, runs tests, and builds the package. Nothing is published.
  • On merge to main — the version bump is derived from the Conventional Commits in the merge, the package is published to npm, and a matching git tag/release is created.

The version bump follows semver based on commit messages since the last release:

| Commit type | Example | Release | |---|---|---| | fix: | fix: handle empty xml | patch (1.0.01.0.1) | | feat: | feat: add line wrapping | minor (1.0.01.1.0) | | BREAKING CHANGE: footer (or feat!:) | feat!: drop node 22 | major (1.0.02.0.0) |

Commits that don't map to a release (e.g. chore:, docs:, test:) produce no publish. Write commit messages in the Conventional Commits format — the automated versioning depends on it.

Development

Requirements:

  • node 24
  • pnpm 11
pnpm install
pnpm run build   # outputs to lib/
pnpm test        # run tests

Changelog

CHANGELOG.md follows the Keep a Changelog format. When a release is cut, the pipeline automatically stamps the [Unreleased] section with the computed version and today's date, then commits it back to main.

What you need to do: Keep a running [Unreleased] section at the top of CHANGELOG.md as you work. Add entries under ### Added, ### Changed, or ### Fixed for anything worth noting. The pipeline handles the version number — you never fill that in manually.

## [Unreleased]

### Added

- Description of a new feature or export

### Fixed

- Description of a bug fix

If the [Unreleased] section is missing when a release runs, the sed substitution in the pipeline will silently no-op and the new version block will not appear in the changelog. Keep the heading exactly as ## [Unreleased] (including the brackets).

Installation

From npm

# npm
npm install @spscommerce/sorc-text-viewer

# pnpm
pnpm add @spscommerce/sorc-text-viewer

This package requires the following peer dependencies to be installed in the consuming project:

# npm
npm install react @tanstack/react-virtual @spscommerce/ds-react @spscommerce/ds-shared @spscommerce/utils @sps-woodland/buttons @sps-woodland/growler

# pnpm
pnpm add react @tanstack/react-virtual @spscommerce/ds-react @spscommerce/ds-shared @spscommerce/utils @sps-woodland/buttons @sps-woodland/growler

As a local dependency

If you are developing @spscommerce/sorc-text-viewer locally and want to consume it from another project without publishing, reference it by path in the consuming project's package.json:

{
  "dependencies": {
    "@spscommerce/sorc-text-viewer": "file:../text-viewer"
  }
}

Then run npm install or pnpm install in the consuming project to symlink it. Changes to text-viewer/lib/ are picked up immediately. To rebuild after source changes:

# inside text-viewer
pnpm run build

pnpm workspaces: If both projects are in a pnpm workspace, use workspace:* instead of file: and declare both packages in pnpm-workspace.yaml:

# pnpm-workspace.yaml
packages:
  - "packages/*"   # adjust to match your repo layout
{ "dependencies": { "@spscommerce/sorc-text-viewer": "workspace:*" } }

Styles

Import the compiled stylesheet once — at the app root or in the component that uses TextViewer:

import "@spscommerce/sorc-text-viewer/lib/index.css";

Usage

Basic text viewer

import { useState } from "react";
import { TextViewer } from "@spscommerce/sorc-text-viewer";
import type { RenderableLine } from "@spscommerce/sorc-text-viewer";

function SimpleViewer() {
  const [selection, setSelection] = useState<string | undefined>("2");

  const lines: RenderableLine[] = [
    { lineNumber: 1, text: "First line", parentLine: 0 },
    { lineNumber: 2, text: "Second line", parentLine: 0, showActionButton: true },
    { lineNumber: 3, text: "Third line", parentLine: 0 },
  ];

  return (
    <TextViewer
      lines={lines}
      initialLineSelection={selection}
      onLineSelectionChange={setSelection}
      onLineActionClick={(line) => console.log("action:", line)}
      onLineNumberClick={(lineNumber) => console.log("line:", lineNumber)}
      onSearch={(matched) => console.log("matches:", matched)}
    />
  );
}

XML viewer with useXmlParser

import { TextViewer, useXmlParser } from "@spscommerce/sorc-text-viewer";
import type { RenderableXmlLine } from "@spscommerce/sorc-text-viewer";

function XmlViewer({ xmlData }: { xmlData: string }) {
  const { xmlLines, setXmlLines } = useXmlParser(
    xmlData,
    true, // removeAttributes — strips attributes from display text; shows a "details" button instead
  );

  function handleLineActionClick(line: RenderableXmlLine): void {
    console.log("action clicked:", line.xpath);
  }

  function handleSearch(matched: number[]): void {
    const updated = xmlLines.map((line) => ({
      ...line,
      cssClasses: matched.includes(line.lineNumber) ? ["search-match"] : [],
    }));
    setXmlLines(updated);
  }
  
  function getLineValueToCopy(line: RenderableXmlLine): string {
    // get the xpath with a leading slash when the user clicks the copy button on the pop up bar.
    return `/${line.xpath}`
  }

  return (
    <TextViewer
      lines={xmlLines}
      onLineActionClick={handleLineActionClick}
      onLineNumberClick={(lineNumber) => console.log("clicked:", lineNumber)}
      onSearch={handleSearch}
      getLineValueToCopy={getLineValueToCopy}
    />
  );
}

Diff highlighting with customLineCss

Pass a customLineCss function as the third argument to useXmlParser to apply CSS classes based on each line's properties. Lines with the modified-line or deleted-line class names automatically receive +/- gutter icons, and the class propagates to all child lines within the same collapsible group.

import { useXmlParser } from "@spscommerce/sorc-text-viewer";
import type { RenderableXmlLine } from "@spscommerce/sorc-text-viewer";
import { TagType } from "@spscommerce/sorc-text-viewer";

function customLineCss(line: RenderableXmlLine): string[] {
  if (line.tagType === TagType.GROUP) return ["group-tag"];
  if (line.tagType === TagType.FIELD && line.xpath.includes("/Changed/")) {
    return ["modified-line"];
  }
  return [];
}

const { xmlLines, setXmlLines } = useXmlParser(xmlData, true, customLineCss);
.group-tag    { background-color: #f0f8ff; }
.search-match { background-color: #fef08a; }
/* These two class names also trigger built-in gutter icons: */
.modified-line { background-color: #dcfce7; }
.deleted-line  { background-color: #fee2e2; }

API

<TextViewer>

| Prop | Type | Required | Description | |---|---|---|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | lines | T[] | Yes | Array of lines. T must extend RenderableLine. | | onLineActionClick | (line: T, lines: T[]) => void | Yes | Called when the action button on a line is clicked. | | onLineNumberClick | (lineNumber: number, lines: T[]) => void | Yes | Called when a line number in the gutter is pressed. | | onSearch | (lineNumbers: number[]) => void | Yes | Called 500ms after the user stops typing with the matching line numbers. | | onCtrlCForHoveredLine | (line: T) => void \| Promise<void> | No | Called when Ctrl+Alt+C is pressed while a line is hovered. | | initialLineSelection | string | No | Line to scroll to and highlight on mount. Accepts "90" for a single line or "90-94" for a range. Updates to this prop reset the selection and re-scroll. | | onLineSelectionChange | (lineParam: string \| null) => void | No | Called when the user completes a gutter click or drag. Receives "90" for a single line or "90-94" for a range. | | getLineValueToCopy | (line: T) => string | No | Called to get a specific value from the lineObject for the copy bar. Lines can be complex, and you may want to get specific values from the line that will be copied to the clipboard. | | ref | React.Ref<TextViewerHandle> | No | Imperative handle for programmatic scroll and selection control. See TextViewerHandle. |

TextViewerHandle

Attach a ref to <TextViewer> to call these methods without triggering a re-render:

interface TextViewerHandle {
  scrollToIndex(index: number, options?: VirtualScrollOptions): void;
  scrollToOffset(offset: number, options?: VirtualScrollOptions): void;
  scrollToLineNumber(lineNumber: number, options?: VirtualScrollOptions): void;
  setSelectedLineNumber(lineNumber: number | null): void; // null clears the selection
}
const viewerRef = useRef<TextViewerHandle>(null);

// Scroll to line 42 without changing the selection prop
viewerRef.current?.scrollToLineNumber(42, { align: "center" });

// Programmatically select and scroll to a line
viewerRef.current?.setSelectedLineNumber(42);

RenderableLine

interface RenderableLine {
  lineNumber: number;             // set by the parser; must be 1-based and sequential
  text: string;                   // display text for the line
  parentLine: number;             // parent line number (0 for root/top-level lines)
  showActionButton?: boolean;     // show an action button on the right side
  actionLabel?: string;           // custom button label (default: "details")
  selected?: boolean;             // @deprecated — ignored by the renderer; use initialLineSelection instead
  collapseToLineNumber?: number;  // collapse lines from lineNumber+1 through this value
  gutterIcon?: ReactElement;      // element rendered in the left gutter
  cssClasses?: string[];          // extra CSS classes on the line wrapper
}

useXmlParser(xmlData, removeAttributes?, customLineCss?)

| Parameter | Type | Default | Description | |---|---|---|---| | xmlData | string | — | Raw XML string to parse. | | removeAttributes | boolean | true | When true, attributes are stripped from displayed text and a "details" button is shown. | | customLineCss | (line: RenderableXmlLine) => string[] | — | Called per line; return CSS class names to apply. |

Returns { xmlLines: RenderableXmlLine[], setXmlLines }.

RenderableXmlLine

Extends RenderableLine with:

interface RenderableXmlLine extends RenderableLine {
  xpath: string;
  tagName: string;
  tagType: TagType;                  // TagType.GROUP | TagType.FIELD
  attributes?: Map<string, string>;
}

xmlToLines(xmlData, removeAttributes?)

Lower-level utility that parses a raw XML string into RenderableXmlLine[] without React state. Useful for preprocessing outside a component or in unit tests. Throws on invalid XML — use xmlToLinesSafe if you need a non-throwing alternative.

xmlToLinesSafe(xmlData, removeAttributes?)

Same as xmlToLines but returns null instead of throwing for empty or invalid XML. Prefer this over the if (isParseableXml(xml)) xmlToLines(xml) guard pattern — it validates and parses in a single DOMParser pass.

const lines = xmlToLinesSafe(rawXml);
if (lines !== null) {
  // lines is RenderableXmlLine[]
}

isParseableXml(xmlData)

Returns true if the string is non-empty, well-formed XML. Useful when you only need to know whether input is valid without also needing the parsed lines. If you need both, use xmlToLinesSafe instead.

if (isParseableXml(rawXml)) {
  // safe to pass to xmlToLines or useXmlParser
}