The TypeScript library

@stxt-lang/core is the STXT parser for TypeScript and JavaScript, and the reference port of the ecosystem: the command line, the VS Code extension and the playground run on it and carry no parser of their own. It implements the five specifications —syntax, canonical tree, schemas, templates and resolution— and reports the same error codes as the Java and Python libraries.

Everything shown here comes from a single entry point, with no internal sub-paths.

Installation

npm install @stxt-lang/core

The package ships as CommonJS with type declarations, so it works the same from TypeScript, from require and from ES modules (import { Parser } from '@stxt-lang/core'). It has no runtime dependencies and touches neither the file system nor the environment —those are injected, as shown under Resolution—, so it runs equally in Node and in the browser: the playground is this very library bundled for the client. Its version, and that of the CLI that carries it, show up in stxt --version; the JSDoc travels in the .d.ts, so the editor shows the documentation of each method on hover.

Parsing

Parser has two entry points. parseResult(text) collects every error and also returns the nodes it managed to build; parse(text) throws a ParseException on the first one and returns Node[] when there is none. The former suits an editor or a validator; the latter, a program in which an invalid document is an exception.

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 three fields: line (the line of the document, starting at 1), code (stable, upper-case: INVALID_LINE, INDENTATION_MIXED, INDENTATION_LEVEL_NOT_VALID…) and message. Grammar errors are ValidationException, a subclass with the same fields, so a single loop walks both and instanceof tells the severity apart. A document with a syntax error may still return nodes: the parser recovers and carries on, so that an editor can underline everything that is wrong at once.

The tree

Node is an abstract class with exactly two forms, and each one exposes only what is its own:

Class Syntax What it owns
InlineNode Name: value getValue()/setValue(), getChildren(), getChild(name), getChildrenByName(name), addChild(), removeChild(), addInlineNode(), addTextNode()
TextNode Name >> getTextLines(), setText(), setTextLines(), addTextLine(), clearText()

What they share lives in Node: getName() and getCanonicalName() (the canonical name of STXT-SPEC: lower-case, NFC, unified separators), getDeclaredNamespace() (what the node writes between parentheses, or "") and getNamespace() (the effective one, inherited through the chain of parents), getLine(), getLevel() (derived from the depth), getParent() (always an InlineNode, or null at the root), detach() and getText() —the value of an inline node or the joined lines of a block—. Walking a tree means asking for the form with instanceof, which in TypeScript also narrows the type, the same way the canonical tree of STXT-TREE-SPEC has children only for inline nodes and lines only for blocks.

With the document of the tutorial, a book record:

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 and keep their own integrity: every node knows its parent, addChild links both ends and refuses a node that already has one (NODE_ALREADY_ATTACHED) or that is an ancestor (NODE_CYCLE), and setValue and addTextLine refuse a line break (LINE_BREAK_NOT_ALLOWED); removeChild and detach() undo it. Levels are 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 always 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

Grammars are STXT documents in the reserved namespaces @stxt.schema and @stxt.template; the template is the short authoring form and compiles to a schema when loaded. UnifiedSchemaProvider loads either kind with addFile(text): it parses, validates against the corresponding meta-schema and registers the schema by namespace. Validation is a Validator registered on the Parser that runs on each node as it is closed; it only validates nodes with a namespace, which is the rule of the language (STXT-SCHEMA-SPEC §5): a document without a namespace is not invalid, but it cannot be validated.

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 records
import {
    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 that lacks its ISBN and whose date is not YYYY-MM-DD, the loop prints exactly what stxt validate would print:

schema line 5 [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

Behaviour rules:

  • A cardinality error is reported on the line of the parent, which is the one with zero ISBN.
  • If the document uses a namespace the provider does not know, each node produces a SCHEMA_NOT_FOUND; the provider never throws for a missing namespace (getSchema() returns null).
  • Nodes without a namespace are not validated (a rule of SchemaValidator itself); a document that is itself a definition is always validated against its meta-schema.
  • addFile accepts files with several definitions, and getAllSchemas() lists them; clear() empties the provider.
  • The available value types are those of STXT-SCHEMA-SPEC: INLINE, BLOCK, TEXT, MARKDOWN, BOOLEAN, INTEGER, NATURAL, NUMBER, DATE, TIME, TIMESTAMP, UUID, EMAIL, URL, HEXADECIMAL, BINARY, BASE64, GROUP and ENUM.

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(). It is what the extension uses for completion and hover.

Resolution: which grammar applies to a document

UnifiedSchemaProvider receives the grammar text. Resolution answers the previous question: given a document, which definitions apply to it. DiscoveryResolver is the reference implementation of STXT-DISCOVERY-SPEC —the .stxt/ directories of the document and of all its ancestors, then ~/.stxt and /etc/stxt, per-namespace precedence, STXT_PATH replacing the chain—, and it is the same one the CLI and the extension use, so all three agree by construction.

The resolver accesses neither the file system nor the environment by itself: a DiscoveryFileSystem and a DiscoveryEnvironment are injected. That is what lets the same logic run over Node's fs, over an editor's virtual file system or over 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'; }
}

With those in place, resolving a document and validating it are two steps, because DiscoveryResult implements SchemaProvider and is passed directly to the validator. The chain is per document: pass the directory it lives in (null for standard input or an unsaved buffer, which starts the chain at the user level).

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, never thrown: report them and carry on
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 where each grammar came from, which is what an editor needs for "go to definition" or a diagnostic that names the origin of the applied 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: 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—. The resolution error codes are DISCOVERY_DUPLICATE_NAMESPACE, DISCOVERY_NOT_A_DEFINITION, DISCOVERY_NOT_PARSEABLE and DISCOVERY_INVALID_DEFINITION; DiscoveryError is a data class (code, file, message, namespace), not an exception, because the spec wants a bad definition reported without stopping the load of the rest.

The canonical tree as JSON

toCanonicalTree(nodes) turns the root nodes into the JSON value of STXT-TREE-SPEC —the same one stxt describe emits— and toCanonicalJson(nodes) serialises it with two-space indentation. It is an explicit function, not JSON.stringify over the node: it emits only the normative fields, children for inline nodes and lines for blocks, 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 performs the inverse operation: it serialises a node, or a list of documents, to STXT text 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, wherever the source declared it. Since it starts from the logical tree, a text that goes through parse and NodeWriter comes out without comments or blank lines outside blocks —that 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 reformatting that keeps comments and blank lines is Formatter (since 0.11.1; STXT-TREE-SPEC §12): it rewrites the original text line by line —the lines that open a node in canonical form, those of a block at the level of the block, the rest as they are, with their indentation units converted to the requested style— and returns, along with the text, the syntax errors it met, so that the caller decides what to do with a document that does not parse. It is the formatter behind stxt format, the extension and the playground.

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

const { text, errors } = Formatter.format(source, IndentStyle.TABS);

The email built above, written with toSTXT:

Email (com.example.mail): Weekly report
	To:
		Address: [email protected]
	From: Ana García <[email protected]>
	Body >>
		Hi Bob,

		See attached.

Observing the parse

Observer receives streaming callbacks while the document is parsed: 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) and onTextLine(node, lineNumber, lineString, line) for each line of a block. It is the basis of the extension's semantic colouring and of the line→node map of the CLI formatter.

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);

The API surface

The package exports; nothing else is a contract:

Group Exports
Parsing Parser, ParseResult, Node, InlineNode, TextNode, Line, parseLine, Constants, StringUtils
Errors ParseException, ValidationException
Extension points Observer
Schemas Schema, NodeDefinition, ChildDefinition, SchemaProvider, SchemaValidator, transformNodeToSchema, transformTemplateNodeToSchema
Runtime UnifiedSchemaProvider, NodeWriter, IndentStyle, Formatter, FormatResult, toCanonicalTree, toCanonicalJson and the Canonical* types
Resolution DiscoveryResolver, DiscoveryOptions, DiscoveryResult, DiscoveryDefinition, DiscoveryLevel, DiscoveryError, DiscoveryFileSystem, DiscoveryEntry, DiscoveryEnvironment

From 1.0 on this API is frozen within the package's 1.x line, and the language and the canonical tree are frozen for good: STXT-SPEC and STXT-TREE-SPEC are in Zenith status, and a document that parses today parses the same tomorrow. What exactly is frozen, what is not, and why the package version is not the specification date, in Stability and versions. The release notes of each version are in the repository, which is also where parser and validation errors are reported —including those seen through the CLI, the extension or the playground—.