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

@stxt-lang/core

v0.14.0

Published

Parser and schema validator for STXT, an indentation-based structured-text format.

Readme

@stxt-lang/core

Parser and schema validator for STXT, an indentation-based structured-text format.

STXT is a plain-text format for writing structured, semantic documents: no braces, no closing tags, just indentation. It is designed to be equally readable by humans and by machines, and it comes with an optional schema layer so documents can be validated.

What STXT looks like

# A line starting with '#' is a comment

Article (blog.post):
    Title: Getting started with STXT
    Author: Joan
    Published: 2026-07-28
    Tags:
        Tag: parser
        Tag: text-format
    Body >>
        Everything indented under a '>>' node is kept verbatim
        as a block of text lines.
  • Name: value declares an inline node.
  • Name >> opens a text block; every deeper-indented line belongs to it.
  • Indentation is one level per tab or per 4 spaces.
  • Name (a.b.c): attaches a namespace to a node; children inherit it unless they declare their own.

Install

npm install @stxt-lang/core

The package ships CommonJS plus type declarations, so it works from both TypeScript and plain Node.

Parsing

import { Parser, ParseResult, Node, InlineNode } from '@stxt-lang/core';

const text = [
    'Article (blog.post):',
    '\tTitle: Getting started with STXT',
    '\tAuthor: Joan',
].join('\n');

const parser = new Parser();

// parseResult() collects every error instead of stopping at the first one
const result: ParseResult = parser.parseResult(text);

if (result.hasErrors()) {
    for (const error of result.getErrors()) {
        console.error(`line ${error.line} [${error.code}]: ${error.message}`);
    }
}

const article: Node = result.getNodes()[0];

console.log(article.getName());       // "Article"
console.log(article.getNamespace());  // "blog.post"
if (article instanceof InlineNode) {
    console.log(article.getChild('Title')?.getText()); // "Getting started with STXT"
}

Use parser.parse(text) instead if you prefer an exception (ParseException) on the first error.

Working with the tree

Node is an abstract class with exactly two forms, and each one owns only what is really its own: InlineNode (Name: value) has the optional value, the children and the child lookups (getChildren(), getChild(name), getChildrenByName(name)); TextNode (Name >>) has the literal text lines and nothing else. What they share lives in Node: name and canonical name, declared and effective namespace, source line, parent (always an InlineNode) and getText() — the value of an inline node or the joined lines of a text node. Walking a tree therefore asks for the form (node instanceof InlineNode), the same way the canonical tree of STXT-TREE-SPEC has children only for inline nodes.

Trees are mutable and keep their own integrity: every node knows its parent, addChild links both ends and refuses a node that already has one, and removeChild / detach() undo it. Levels are derived from the chain of parents; the source line is only set by the parser.

import { InlineNode, TextNode, Node } from '@stxt-lang/core';

const email = new InlineNode('Email', 'com.example.docs', 'Weekly report');
email.addInlineNode('From', '[email protected]');
const to = email.addInlineNode('To');
to.addInlineNode('Address', '[email protected]');
const body = email.addTextNode('Body', 'Hi Bob,\n\nSee attached.');

body.getParent() === email;   // true
body.getLevel();              // 1
to.getNamespace();            // "com.example.docs", inherited
to.getDeclaredNamespace();    // "" — it declares none

// Reorganise: move "To" to the front
to.detach();
email.addChild(to, 0);

// Edit in place
email.setNamespace('com.example.mail');   // the whole inheriting subtree follows
body.setText('Hi Bob,\n\nSee the new attachment.');

for (const child of email.getChildren()) {
    if (child instanceof InlineNode) { console.log(child.getValue(), child.getChildren().length); }
    if (child instanceof TextNode)   { console.log(child.getTextLines()); }
}

Overloads with two strings always take the second one as the content (value or text); the namespace only appears in the three-argument forms. Adding a node that already has a parent throws NODE_ALREADY_ATTACHED; adding an ancestor throws NODE_CYCLE.

Validating against a schema

Schemas are themselves STXT documents, written in the reserved @stxt.schema namespace (or in the friendlier @stxt.template form, which compiles to a schema). UnifiedSchemaProvider loads either kind, validates it against the corresponding meta-schema, and registers it by namespace.

import {
    Parser,
    UnifiedSchemaProvider,
    SchemaValidator,
    ValidationException,
} from '@stxt-lang/core';

const schemaText = `
Schema (@stxt.schema): blog.post
\tNode: Article
\t\tChildren:
\t\t\tChild: Title
\t\t\t\tMin: 1
\t\t\t\tMax: 1
\t\t\tChild: Author
\t\t\t\tMin: 1
\tNode: Title
\tNode: Author
`;

const provider = new UnifiedSchemaProvider();
provider.addFile(schemaText);

const parser = new Parser();
// Only nodes that carry a namespace are validated; free nodes pass through
parser.registerValidator(new SchemaValidator(provider));

const result = parser.parseResult(documentText);

for (const error of result.getErrors()) {
    // Schema problems are ValidationException; syntax problems are plain ParseException
    const severity = error instanceof ValidationException ? 'warning' : 'error';
    console.log(`${severity} at line ${error.line} [${error.code}]: ${error.message}`);
}

Available value types: INLINE, BLOCK, TEXT, MARKDOWN, BOOLEAN, INTEGER, NATURAL, NUMBER, DATE, TIME, TIMESTAMP, UUID, EMAIL, URL, HEXADECIMAL, BINARY, BASE64, GROUP, ENUM.

Finding the schemas: discovery

UnifiedSchemaProvider expects you to hand it the schema text. Discovery answers the previous question: given this document, which schema definitions apply to it? DiscoveryResolver is the reference implementation of the STXT discovery specification, so a command line, an editor and a build step all agree on the answer by construction.

Definitions live in .stxt/ directories. For a given document the resolution chain is, highest precedence first:

  1. every ancestor .stxt/ directory, nearest first — the ascent does not stop at the first one, so in a monorepo both the subproject's and the repo root's participate;
  2. the user level, $HOME/.stxt (%USERPROFILE%\.stxt on Windows);
  3. the system level, /etc/stxt (%ProgramData%\stxt on Windows).

Precedence is per namespace: the nearest level that defines a namespace wins, and the rest of the chain still contributes the namespaces that level does not define. Defining one namespace twice at the same level is a resolution error, and leaves that namespace without an active definition. When STXT_PATH is defined it replaces the whole chain — useful in CI and tests.

The resolver never touches the file system or the environment itself: you inject a DiscoveryFileSystem and a DiscoveryEnvironment. That is what lets the same logic run over Node's fs, over an editor's virtual file system (vscode.workspace.fs) or over an in-memory tree in a test. Here are the Node adapters:

import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import {
    DiscoveryEntry,
    DiscoveryEnvironment,
    DiscoveryFileSystem,
    DiscoveryResolver,
} from '@stxt-lang/core';

class NodeFileSystem implements DiscoveryFileSystem {
    async isDirectory(p: string): Promise<boolean> {
        try {
            return (await fs.stat(p)).isDirectory();
        } catch {
            return false; // not existing is the normal case, not an error
        }
    }
    async listDirectory(p: string): Promise<DiscoveryEntry[]> {
        const entries = await fs.readdir(p, { withFileTypes: true });
        return entries.map(entry => ({
            path: path.join(p, entry.name),
            name: entry.name,
            isDirectory: entry.isDirectory(),
        }));
    }
    readFile(p: string): Promise<string> {
        return fs.readFile(p, 'utf-8');
    }
    parentOf(p: string): string | null {
        const parent = path.dirname(p);
        return parent === p ? null : parent; // null at the file-system root
    }
    join(p: string, name: string): string {
        return path.join(p, name);
    }
}

class NodeEnvironment implements DiscoveryEnvironment {
    getStxtPath(): string[] | null {
        const value = process.env.STXT_PATH;
        // null (not defined) and [] (defined but empty) mean different things
        return value === undefined ? null : value.split(path.delimiter).filter(e => e !== '');
    }
    getUserLevelDir(): string | null {
        return path.join(os.homedir(), '.stxt');
    }
    getSystemLevelDir(): string | null {
        return '/etc/stxt';
    }
}

With those in place, resolving a document and validating it is two steps — and note that DiscoveryResult implements SchemaProvider, so it goes straight into the validator:

import { Parser, SchemaValidator } from '@stxt-lang/core';

const resolver = new DiscoveryResolver(new NodeFileSystem(), new NodeEnvironment());

// The chain is per document: pass the directory the document lives in
// (null for stdin or an unsaved buffer, which starts the chain at the user level).
const result = await resolver.resolve('/repo/site/posts');

console.log(result.getChain());
// [ '/repo/site/.stxt', '/repo/.stxt' ]   ← both ancestors, nearest first

// Resolution errors are collected, never thrown: report them and carry on
for (const error of result.getErrors()) {
    console.error(`[${error.code}] ${error.message}`);
}

const parser = new Parser();
parser.registerValidator(new SchemaValidator(result));

const parsed = parser.parseResult(documentText);

DiscoveryResult also tells you where a schema came from, which is what an editor needs for "go to definition" or a diagnostic that explains itself:

const definition = result.getDefinition('blog.post');

console.log(definition?.file);      // '/repo/site/.stxt/blog.stxt'
console.log(definition?.levelDir);  // '/repo/site/.stxt'  ← the level that won

result.getActiveDefinitions();      // one entry per namespace, precedence applied
result.getAllSchemas();             // just the schemas of the above

Levels are cached by directory, so resolving many documents that share ancestors reads each .stxt/ once. Call resolver.clearCache() when the definition files may have changed — from a file watcher, for instance.

Observing the parse

Observer receives streaming callbacks while the document is parsed — useful for syntax highlighting, indexes or any per-line bookkeeping.

import { Parser, Observer, Node, Line } from '@stxt-lang/core';

class LoggingObserver implements Observer {
    onCreate(node: Node, line: string): void {
        console.log('open', node.getQualifiedName());
    }
    onFinish(node: Node): void {
        console.log('close', node.getQualifiedName());
    }
    onComment(lineNumber: number, line: string): void { /* ... */ }
    onTextLine(node: Node, lineNumber: number, lineString: string, line: Line): void { /* ... */ }
}

const parser = new Parser();
parser.registerObserver(new LoggingObserver());
parser.parseResult(text);

StreamObserver watches the results instead of the process: each completed root node and each error, in every mode. With parseStream the parser retains nothing — no nodes, no errors — so a file larger than memory can be processed one root tree at a time:

import { Parser, StreamObserver, Node, ParseException } from '@stxt-lang/core';

const parser = new Parser();
parser.registerStreamObserver({
    onRootNode(node: Node): void { handle(node); },       // one complete root at a time
    onError(error: ParseException): void { report(error); },
} satisfies StreamObserver);
parser.parseStream(readLinesLazily(file));  // any Iterable<string> of lines

Parser limits

The parser rejects hostile or runaway inputs by default (STXT-SPEC §11.2): documents nesting more than 100 levels, lines longer than 10 000 characters, or inputs over 10 000 000 characters. A limit error is a LimitException (LIMIT_NESTING_EXCEEDED, LIMIT_LINE_LENGTH_EXCEEDED, LIMIT_INPUT_SIZE_EXCEEDED) and aborts the parse: it is always the last error reported. Each limit is configurable per parser; -1 disables it:

const parser = new Parser({ maxNesting: 500, maxInputSize: -1 });

Writing STXT back out

import { NodeWriter, IndentStyle } from '@stxt-lang/core';

// A single node, or a whole document list
const text = NodeWriter.toSTXT(node, IndentStyle.TABS);
const doc = NodeWriter.toSTXTDocs(result.getNodes(), IndentStyle.SPACES_4);

NodeWriter re-serializes the tree, so comments and blank lines are gone. To reformat a document keeping everything the author wrote, use Formatter: it rewrites the original text line by line — node lines in canonical form, block lines re-indented to their block, comments and blank lines kept with their indentation units converted — and reports the syntax errors it met, so the caller decides what to do with a document that does not parse. It is the formatter behind stxt format, the VS Code extension and the playground.

import { Formatter, IndentStyle } from '@stxt-lang/core';

const { text, errors } = Formatter.format(source, IndentStyle.TABS);
if (errors.length === 0) {
  fs.writeFileSync(file, text);
}

API surface

Everything importable from the package:

  • ParsingNode, InlineNode, TextNode, Parser, ParserOptions, ParseResult, Line, Constants, parseLine, StringUtils
  • ExceptionsParseException, ValidationException, LimitException, RuntimeException
  • Extension pointsObserver, StreamObserver, Validator
  • SchemasSchema, SchemaValidator, SchemaProvider, SchemaProviderMemory, SchemaProviderMeta, NodeDefinition, ChildDefinition, TypeRegistry, Type, transformNodeToSchema
  • TemplatestransformTemplateNodeToSchema, TEMPLATE_NAMESPACE, TemplateSchemaProviderMemory, MetaTemplateSchemaProvider
  • RuntimeUnifiedSchemaProvider, NodeWriter, IndentStyle, Formatter, FormatResult, toCanonicalTree, toCanonicalJson
  • DiscoveryDiscoveryResolver, DiscoveryOptions, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment

Conformance

@stxt-lang/core implements the five STXT specifications at SPEC_VERSION (exposed by the package; the package version is independent) and passes every case of the official conformance kit, stxt-lang/conformance, across all its profiles: core, schema, template, discovery and text. The kit is the same one any other implementation can run, which is what makes the three ports interchangeable. What the 1.0 line freezes, and what it does not, is stated at https://stxt.dev/lang-stability.

License

MIT — see LICENSE.