The TypeScript library
@stxt-lang/core is the STXT
parser for TypeScript and JavaScript. It implements the five specifications and
reports the same error codes as the Java and Python
libraries.
It is the library behind the command line, the VS Code extension and the playground.
Installation
npm install @stxt-lang/core
It ships as CommonJS with type declarations, and works from TypeScript, from
require and from ES modules. Everything is imported from the package root. It has
no runtime dependencies and accesses neither the file system nor the environment
(resolution receives them injected), so it runs the same in Node and in the browser.
Parsing
| Entry point | Behaviour |
|---|---|
parseResult(text) |
Collects every error and also returns the nodes it managed to build |
parse(text) |
Throws a ParseException on the first error; with no errors, returns Node[] |
parseStream(lines) |
Retains no nodes or errors; see Streaming and parser limits |
import { Parser, ParseResult, ParseException } from '@stxt-lang/core';
const parser = new Parser();
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 roots = result.getNodes(); // the root nodes, in order; there may be several
// The "throw on first error" form
try {
const nodes = parser.parse(text);
} catch (e) {
if (e instanceof ParseException) console.error(e.line, e.code, e.message);
}
Every error is a ParseException with line (from 1), code and message. Grammar
errors are ValidationException, a subclass: a single loop walks both kinds and
instanceof tells them apart. After a syntax error the parser recovers and carries
on, and the result may include nodes.
The tree
Node is an abstract class with two forms:
| Class | Syntax | Own methods |
|---|---|---|
InlineNode |
Name: value |
getValue()/setValue(), getChildren(), getChild(name), getChildrenByName(name), addChild(), removeChild(), addInlineNode(), addTextNode() |
TextNode |
Name >> |
getTextLines(), setText(), setTextLines(), addTextLine(), clearText() |
Common methods, in Node:
| Method | Description |
|---|---|
getName() |
The name as written |
getCanonicalName() |
The canonical name (STXT-SPEC §4.3) |
getDeclaredNamespace() |
The namespace written between parentheses, or "" |
getNamespace() |
The effective namespace, inherited from the parents |
getLine() |
The line of the document |
getLevel() |
The level, derived from the depth |
getParent() |
The parent InlineNode, or null at the root |
getText() |
The value of an inline node, or the joined lines of a block |
detach() |
Detaches the node from its parent |
The form of a node is told apart with instanceof. With the document of the
tutorial:
Book (com.acme.book):
Title: Arquitectura de software moderna
Authors:
Author: María Pérez
Author: Juan García
ISBN: 978-84-123456-7-8
Published: 2025-10-01
Chapter: Introducción
Content >>
Conceptos básicos y objetivos del libro.import { Parser, InlineNode, TextNode, Node } from '@stxt-lang/core';
const book = new Parser().parseResult(text).getNodes()[0];
book.getName(); // "Book"
book.getCanonicalName(); // "book"
book.getNamespace(); // "com.acme.book"
book.getLine(); // 1
if (book instanceof InlineNode) {
book.getChild('Title')?.getText(); // "Arquitectura de software moderna"
book.getChild('title')?.getName(); // "Title": lookups go by canonical name
book.getChild('Publisher'); // null: not there
const authors = book.getChild('Authors') as InlineNode;
authors.getChildrenByName('Author').map(a => a.getText()); // ["María Pérez", "Juan García"]
authors.getDeclaredNamespace(); // "": it declares none...
authors.getNamespace(); // "com.acme.book": ...it inherits it
const chapter = book.getChild('Chapter') as InlineNode;
const content = chapter.getChild('Content');
if (content instanceof TextNode) {
content.getTextLines(); // ["Conceptos básicos y objetivos del libro."]
content.getLevel(); // 2
content.getParent() === chapter; // true
}
}
// A generic walk
function walk(node: Node, depth = 0): void {
console.log(' '.repeat(depth) + node.getName());
if (node instanceof InlineNode) {
for (const child of node.getChildren()) walk(child, depth + 1);
}
}
Building and editing
Trees are mutable:
addChildrefuses a node that already has a parent (NODE_ALREADY_ATTACHED) or that is an ancestor (NODE_CYCLE);removeChildanddetach()detach it.setValueandaddTextLinerefuse a line break (LINE_BREAK_NOT_ALLOWED).- The level is derived from the chain of parents, and the line is only set by the parser.
- In the factories with two strings, the second one is the content (value or text); the namespace only appears in the three-argument form.
import { InlineNode, TextNode } from '@stxt-lang/core';
const email = new InlineNode('Email', 'com.example.mail', 'Weekly report');
email.addInlineNode('From', 'Ana García <[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.mail", inherited
// Reorder: "To" first
to.detach();
email.addChild(to, 0);
// Edit
email.setNamespace('com.example.docs'); // the whole inheriting subtree follows
body.setText('Hi Bob,\n\nSee the new attachment.');
Validating against a schema or a template
UnifiedSchemaProvider loads schemas and templates with addFile(text): it parses
the definition, validates it against its meta-schema and registers the schema by
namespace. SchemaValidator is a Validator that is registered on the Parser and
validates each node with a namespace as it is closed.
Template (@stxt.template): com.acme.book
Structure >>
Book:
Title: (1)
Authors: (1)
Author: (+)
ISBN: (1)
Publisher: (?)
Published: (?) DATE
Summary: (?) TEXT
Chapter: (+)
Content: (?) TEXT
Description >>
Book: Template for publisher book recordsimport {
Parser, UnifiedSchemaProvider, SchemaValidator,
ValidationException,
} from '@stxt-lang/core';
const provider = new UnifiedSchemaProvider();
provider.addFile(templateText); // throws if the template does not validate against its meta-schema
const parser = new Parser();
parser.registerValidator(new SchemaValidator(provider));
const result = parser.parseResult(documentText);
for (const error of result.getErrors()) {
const kind = error instanceof ValidationException ? 'schema' : 'syntax';
console.log(`${kind} line ${error.line} [${error.code}]: ${error.message}`);
}
With a book without ISBN and with a date that is not YYYY-MM-DD, the loop prints:
schema line 6 [INVALID_VALUE]: Published: Invalid date (1 de octubre de 2025)
schema line 1 [TOO_FEW_CHILDREN]: 0 nodes of 'com.acme.book:isbn' and min is 1
- A cardinality error is reported on the line of the parent.
- A namespace the provider does not know produces a
SCHEMA_NOT_FOUNDper node; the provider does not throw (getSchema()returnsundefined). - Nodes without a namespace are not validated (STXT-SCHEMA-SPEC §5). A definition is always validated against its meta-schema.
addFileaccepts files with several definitions;getAllSchemas()lists them andclear()empties the provider.- All the types of STXT-SCHEMA-SPEC §9 are implemented.
The compiled schema can be inspected: provider.getSchema(ns) returns a Schema
with getNamespace() and getNodeDefinition(name); each NodeDefinition has
getType(), getChildren() (a map of ChildDefinition with getMin() /
getMax()), getValues() for an ENUM and getDescription().
Grammar resolution
DiscoveryResolver implements STXT-DISCOVERY-SPEC: given the
directory of a document, it determines which definitions apply to it (the .stxt/
directories of the document and of its ancestors, then ~/.stxt and /etc/stxt,
with per-namespace precedence; STXT_PATH replaces the chain). It is the resolver
of the CLI and of the extension.
It accesses neither the file system nor the environment: it receives a
DiscoveryFileSystem and a DiscoveryEnvironment, which may be Node's fs, an
editor's virtual file system or an in-memory tree in a test. Adapters for Node:
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { DiscoveryEntry, DiscoveryEnvironment, DiscoveryFileSystem } from '@stxt-lang/core';
class NodeFileSystem implements DiscoveryFileSystem {
async isDirectory(p: string): Promise<boolean> {
try { return (await fs.stat(p)).isDirectory(); } catch { return false; }
}
async listDirectory(p: string): Promise<DiscoveryEntry[]> {
const entries = await fs.readdir(p, { withFileTypes: true });
return entries.map(e => ({ path: path.join(p, e.name), name: e.name, isDirectory: e.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'; }
}
resolve takes the directory of the document, or null for standard input or an
unsaved buffer; in that case the chain starts at the user level. DiscoveryResult
implements SchemaProvider and is passed directly to the validator:
import { Parser, SchemaValidator, DiscoveryResolver } from '@stxt-lang/core';
const resolver = new DiscoveryResolver(new NodeFileSystem(), new NodeEnvironment());
const discovery = await resolver.resolve('/home/ana/libros/docs');
discovery.getChain(); // ["/home/ana/libros/.stxt"] (every ancestor, nearest first)
// Resolution errors are collected, not thrown
for (const error of discovery.getErrors()) {
console.error(`[${error.code}] ${error.file}: ${error.message}`);
}
const parser = new Parser();
parser.registerValidator(new SchemaValidator(discovery));
const result = parser.parseResult(documentText);
DiscoveryResult also records the origin of each definition:
const definition = discovery.getDefinition('com.acme.book');
definition?.file; // "/home/ana/libros/.stxt/@stxt.template/com.acme.book.stxt"
definition?.levelDir; // "/home/ana/libros/.stxt" (the level that won)
definition?.schema; // the compiled Schema
discovery.getActiveDefinitions(); // one per namespace, precedence applied
discovery.getAllSchemas(); // just the schemas of the above
Levels are cached by directory; resolver.clearCache() invalidates them when the
definition files may have changed. Errors are DiscoveryError (code, file,
message, namespace), with the codes DISCOVERY_DUPLICATE_NAMESPACE,
DISCOVERY_NOT_A_DEFINITION, DISCOVERY_NOT_PARSEABLE and
DISCOVERY_INVALID_DEFINITION.
The canonical tree as JSON
toCanonicalTree(nodes) returns the JSON value of STXT-TREE-SPEC,
the same one stxt describe emits, and toCanonicalJson(nodes) serialises it with
two-space indentation. They emit only the normative fields, with no positions or
comments. The types CanonicalDocument, CanonicalNode, CanonicalInlineNode and
CanonicalBlockNode describe the result.
import { Parser, toCanonicalTree, toCanonicalJson, CanonicalDocument } from '@stxt-lang/core';
const nodes = new Parser().parseResult(text).getNodes();
const tree: CanonicalDocument = toCanonicalTree(nodes);
tree[0].name; // "Book"
tree[0].form; // "inline"
process.stdout.write(toCanonicalJson(nodes));
Writing STXT
NodeWriter serialises a node, or a list of root nodes, in the canonical form of
STXT-TREE-SPEC §11, with IndentStyle.TABS (default) or
IndentStyle.SPACES_4. It writes the namespace only where it changes from the
parent's. Since it starts from the tree, the output has no comments or blank lines
outside blocks; it is what stxt format --clean does.
import { NodeWriter, IndentStyle } from '@stxt-lang/core';
const one = NodeWriter.toSTXT(email); // one node, with tabs
const all = NodeWriter.toSTXTDocs(result.getNodes(), IndentStyle.SPACES_4); // a whole document
The email built above, written with toSTXT:
Email (com.example.docs): Weekly report
To:
Address: [email protected]
From: Ana García <[email protected]>
Body >>
Hi Bob,
See the new attachment.Formatter (STXT-TREE-SPEC §12) reformats while keeping
comments and blank lines: it rewrites the original text line by line and returns,
along with the text, the syntax errors it found. It is the formatter of
stxt format, the extension and the playground.
import { Formatter, IndentStyle } from '@stxt-lang/core';
const { text, errors } = Formatter.format(source, IndentStyle.TABS);
Observing the parse
A registered Observer receives calls during the parse: onCreate(node, line) when
a node is opened (already with its parent, effective namespace and level),
onFinish(node) when it is closed, onComment(lineNumber, line) for each comment
and onTextLine(node, lineNumber, lineString, line) for each line of a block.
import { Parser, Observer, Node, TextNode, 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: TextNode, lineNumber: number, lineString: string, line: Line): void { /* ... */ }
}
const parser = new Parser();
parser.registerObserver(new LoggingObserver());
parser.parseResult(text);
Streaming and parser limits
parseStream(lines) takes an iterable of lines, each without its line break, and
retains no nodes or errors. The results arrive through a StreamObserver:
onRootNode(node) with each complete, already validated root node, which the parser
releases after the call, and onError(error) with each error, syntax or validation.
The memory in use is that of one root tree, which allows processing files that do not
fit in memory. A registered StreamObserver receives the same calls with parse and
parseResult.
import { Parser, StreamObserver, Node, ParseException } from '@stxt-lang/core';
class Counter implements StreamObserver {
roots = 0;
onRootNode(node: Node): void { this.roots++; }
onError(error: ParseException): void { console.error(error.line, error.code, error.message); }
}
const parser = new Parser();
const counter = new Counter();
parser.registerStreamObserver(counter);
parser.parseStream(lines); // Iterable<string>, one line per item
counter.roots;
The parser applies the limits of STXT-SPEC §11.2, configurable
in the constructor with ParserOptions: maxNesting (100 levels), maxLineLength
(10,000 characters) and maxInputSize (10,000,000 characters); -1 disables one.
Exceeding a limit produces a LimitException (a subclass of ParseException, with the
codes LIMIT_NESTING_EXCEEDED, LIMIT_LINE_LENGTH_EXCEEDED and
LIMIT_INPUT_SIZE_EXCEEDED) and aborts the parse.
const parser = new Parser({ maxNesting: 50, maxInputSize: -1 });
The API surface
The package exports; the rest is not public API:
| Group | Exports |
|---|---|
| Parsing | Parser, ParserOptions, ParseResult, Node, InlineNode, TextNode, Line, parseLine, Constants, SPEC_VERSION, StringUtils |
| Errors | ParseException, ValidationException, LimitException, RuntimeException |
| Extension points | Observer, StreamObserver, Validator |
| Schemas | Schema, NodeDefinition, ChildDefinition, SchemaProvider, SchemaProviderMemory, SchemaProviderMeta, SchemaValidator, Type, TypeRegistry, transformNodeToSchema |
| Templates | transformTemplateNodeToSchema, TemplateSchemaProviderMemory, MetaTemplateSchemaProvider, TEMPLATE_NAMESPACE |
| Runtime | UnifiedSchemaProvider, NodeWriter, IndentStyle, Formatter, FormatResult, toCanonicalTree, toCanonicalJson and the Canonical* types |
| Resolution | DiscoveryResolver, DiscoveryOptions, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment |
The package follows semantic versioning: the API does not change incompatibly within the 1.x line. The status of each specification is in Stability and versions. The changes of each version are in the repository, which is also where parsing and validation errors seen through the CLI, the extension or the playground are reported.