@spscommerce/sorc-text-viewer
v1.0.4
Published
Shared React component and hooks library for viewing parcel payloads
Keywords
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 hook —
useXmlParserconverts raw XML into renderable lines with XPath, attributes, and diff support - Keyboard shortcuts —
Ctrl+Ffocuses search,Enternavigates results,Ctrl+Alt+Ctriggers 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.0 → 1.0.1) |
| feat: | feat: add line wrapping | minor (1.0.0 → 1.1.0) |
| BREAKING CHANGE: footer (or feat!:) | feat!: drop node 22 | major (1.0.0 → 2.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 testsChangelog
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 fixIf 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-viewerThis 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/growlerAs 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 buildpnpm workspaces: If both projects are in a pnpm workspace, use
workspace:*instead offile:and declare both packages inpnpm-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
}