Biblioteka TreeStructInfo JavaScript
Wprowadzenie
TreeStructInfoJS
to lekka, bezzewnętrzna biblioteka JavaScript umożliwiająca przetwarzanie danych zapisanych w formacie TreeStructInfo 2.0 – czytelnej, drzewiastej strukturze tekstowej opartej na atrybutach i węzłach.
Zawiera dwie główne klasy:
TreeStructInfoParserJS
– parser tekstu.tsinfo
do strukturyjson
({ version, name, data }
)TreeStructInfoExporterJS
– eksporter odwrotny: ze struktury do formatu.tsinfo
Przeznaczona do pracy w przeglądarce, biblioteka może być wykorzystywana np. do importu/eksportu konfiguracji, danych hierarchicznych, zasobów tekstowych, itp.
Struktura danych TreeStructInfo
Przykład reprezentacji:
treestructinfo "2.0" name "Example"
attr title "Hello"
attr count "123"
node meta
attr enabled "true"
end node
ref attr secret
end tree
ref attr secret "token-xyz"
- w pliku konfiguracyjnym musi być zachowana odpowiednia kolejność definiowania danych
- atrybuty i węzły referencjonowane muszą być zdefiniowane po całym drzewie z danymi czyli po
end tree
- atrybuty referencjonowane muszą być zdefiniowane przed węzłami referencjonowanymi
- węzły referencjonowane występujące w innych węzłach referencjonowanych muszą być zdefiniowane przed nimi
- przy włączonym automatycznym rzutowaniu typów separatorem miejsc dziesiętnych musi być kropka
Parser zamienia powyższy zapis na:
{
"version": "2.0",
"name": "Example",
"data": {
"title": "Hello",
"count": 123,
"meta": {
"enabled": true
},
"secret": "token-xyz"
}
}
TreeStructInfoParserJS
Parser wczytuje dane z formatu .tsinfo
i konwertuje je do struktury obiektowej. Obsługuje:
- automatyczne rzutowanie typów (int, float, bool, base64, hex)
- węzły
node ... end node
- atrybuty
attr
, również wielolinijkowe - atrybuty referencyjne
ref attr ...
(lokalne lub zdefiniowane wcześniej) - komentarze
::
Kod parsera:
/**
* TreeStructInfoParserJS
*
* A parser for the TreeStructInfo textual format.
* It parses the versioned header, hierarchical nodes, attributes,
* reference attributes, and supports multi-line values and auto-casting.
* Copyright (c) 2025 Dariusz Rorat
* Licensed under the BSD 3-Clause License.
*/
class TreeStructInfoParserJS {
constructor() {
/**
* @private
* @type {string[]}
*/
this.lines = [];
/**
* @private
* @type {string[]}
*/
this.refLines = [];
/**
* @private
* @type {number}
*/
this.currentLine = 0;
/**
* @private
* @type {number}
*/
this.currentRefLine = 0;
/**
* Stores reference attributes encountered during parsing.
* @type {Object.<string, string>}
*/
this.references = {};
/**
* Stores node reference attributes encountered during parsing.
* @type {Object.<string, string>}
*/
this.nodeReferences = {};
/**
* Whether to auto-cast values (e.g. booleans, numbers).
* @type {boolean}
*/
this.autoCasting = false;
/**
* Character used to join multi-line values.
* @type {string}
*/
this.multilineJoinChar = "\n";
/**
* List of string values recognized as boolean `true`.
* @type {string[]}
*/
this.trueValues = ['true'];
/**
* List of string values recognized as boolean `false`.
* @type {string[]}
*/
this.falseValues = ['false'];
}
/**
* Enables or disables automatic casting of values.
* @param {boolean} value
*/
setAutoCasting(value) {
this.autoCasting = value;
}
/**
* Sets the character used to join multi-line string values.
* @param {string} char
*/
setMultilineJoinChar(char) {
this.multilineJoinChar = char;
}
/**
* Sets the values that should be recognized as booleans.
* @param {string[]} trueValues
* @param {string[]} falseValues
*/
setBoolValues(trueValues, falseValues) {
if (!trueValues.length || !falseValues.length) {
throw new Error("Empty boolean value list");
}
this.trueValues = trueValues;
this.falseValues = falseValues;
}
/**
* Parses the TreeStructInfo string into a JavaScript object.
* @param {string} input
* @returns {{version: string, name: string|null, data: Object}}
*/
parse(input) {
const allLines = input
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
const treeLines = [];
let i = 0;
let lastRefAttrName = null;
let endTree = false;
this.refLines = [];
this.currentRefLine = 0;
while (i < allLines.length) {
const line = allLines[i];
// Match: ref attr Name "Value"
const refAttrMatch = line.match(/^ref attr (.+?)\s+"([^"]+)"$/);
if (refAttrMatch) {
const [ , name, value ] = refAttrMatch;
this.references[name] = value;
lastRefAttrName = name;
i++;
continue;
}
// Multiline continuation of last ref attr
if (lastRefAttrName !== null && line.startsWith('"')) {
this.references[lastRefAttrName] += this.multilineJoinChar + line.replace(/^"+|"+$/g, '');
i++;
continue;
}
if (line.startsWith('end tree')) {
endTree = true;
}
// Match: ref node SomeName
const refNodeMatch = line.match(/^ref node (.+?)$/);
if (endTree && refNodeMatch) {
const refName = refNodeMatch[1];
const nodeName = `node ${refName}`;
this.refLines.push(nodeName);
i++; // Skip the "ref node ..." line
// Collect ref node lines until "end ref node"
while (i < allLines.length && allLines[i].trim() !== 'end ref node') {
this.refLines.push(allLines[i]);
i++;
}
this.refLines.push('end node');
if (!allLines[i] || allLines[i].trim() !== 'end ref node') {
throw new Error(`Missing 'end ref node' for ref node ${refName}`);
}
i++; // Skip 'end ref node'
const refNode = this.parseAnonymousNode();
this.nodeReferences[refName] = refNode[refName];
continue;
}
// Regular line goes into main tree
treeLines.push(line);
i++;
}
this.lines = treeLines;
this.currentLine = 0;
return this.parseTree();
}
/**
* Parses a reference node structure (with optional children).
*
*/
parseAnonymousNode() {
const line = this.nextRefLine();
const match = line.match(/^node\s+(.+?)$/);
if (!match) {
throw new Error(`Invalid node start: ${line}`);
}
const name = match[1];
const node = {};
node[name] = {};
let lastname = null;
while ((this.peekRefLine() !== null) && this.peekRefLine() !== 'end node') {
const currentLine = this.peekRefLine();
if (currentLine.startsWith('::')) {
this.nextRefLine(); // skip comment
} else if (currentLine.startsWith('attr ')) {
const attr = this.parseAttr(this.nextRefLine());
node[name][attr.name] = attr.value;
lastname = attr.name;
} else if (currentLine.startsWith('"')) {
const attrLine = this.nextRefLine();
if (lastname) {
node[name][lastname] += this.multilineJoinChar + attrLine.replace(/^"+|"+$/g, '');
}
} else if (currentLine.startsWith('ref attr ')) {
const attr = this.resolveRefAttr(this.nextRefLine());
node[name][attr.name] = attr.value;
lastname = attr.name;
} else if (currentLine.startsWith('ref node ')) {
const refNode = this.resolveRefNode(this.nextRefLine());
const childName = refNode.name;
const childValue = refNode.value;
node[name][childName] = childValue;
} else if (currentLine.startsWith('node ')) {
const childNode = this.parseAnonymousNode();
if (Array.isArray(node[name])) {
node[name].push(childNode);
} else if (typeof node[name] === 'object') {
node[name] = {
...node[name],
...childNode
};
} else {
node[name] = childNode;
}
} else {
throw new Error(`Unexpected line in node: ${currentLine}`);
}
}
this.expectRefLine('end node');
return node;
}
/**
* @private
* Parses the main tree structure.
*/
parseTree() {
let header = this.nextLine();
while (header.startsWith('::')) {
header = this.nextLine(); // skip comments
}
const match = header.match(/^treestructinfo\s+"(\d+\.\d+)"(?:\s+name\s+"([^"]+)")?$/);
if (!match) {
throw new Error("Invalid header format");
}
const result = {
version: match[1],
name: match[2] || null
};
const tree = {};
let lastname = null;
while (this.peekLine() !== null && this.peekLine() !== 'end tree') {
const line = this.peekLine();
if (line.startsWith('::')) {
this.nextLine(); // skip comment
} else if (line.startsWith('attr ')) {
const attr = this.parseAttr(this.nextLine());
tree[attr.name] = attr.value;
lastname = attr.name;
} else if (line.startsWith('"')) {
const continuation = this.nextLine();
if (lastname) {
tree[lastname] += this.multilineJoinChar + continuation.replace(/^"+|"+$/g, '');
}
} else if (line.startsWith('ref attr ')) {
const attr = this.resolveRefAttr(this.nextLine());
tree[attr.name] = attr.value;
lastname = attr.name;
} else if (line.startsWith('ref node ')) {
const refNode = this.resolveRefNode(this.nextLine());
tree[refNode.name] = refNode.value;
} else if (line.startsWith('node ')) {
const node = this.parseNode();
Object.assign(tree, node); // equivalent of array_merge in PHP
} else {
throw new Error(`Unexpected line in tree: ${line}`);
}
}
this.expectLine('end tree');
result.data = tree;
return result;
}
/**
* @private
* Parses a single node.
* @returns {Object}
*/
parseNode() {
const line = this.nextLine();
const match = line.match(/^node\s+(.+?)$/);
if (!match) {
throw new Error(`Invalid node start: ${line}`);
}
const name = match[1];
const node = {};
node[name] = {};
let lastname = null;
while (this.peekLine() !== null && this.peekLine() !== 'end node') {
const currentLine = this.peekLine();
if (currentLine.startsWith('::')) {
this.nextLine(); // skip comment
} else if (currentLine.startsWith('attr ')) {
const attr = this.parseAttr(this.nextLine());
node[name][attr.name] = attr.value;
lastname = attr.name;
} else if (currentLine.startsWith('"')) {
const continuation = this.nextLine();
if (lastname) {
node[name][lastname] += this.multilineJoinChar + continuation.replace(/^"+|"+$/g, '');
}
} else if (currentLine.startsWith('ref attr ')) {
const attr = this.resolveRefAttr(this.nextLine());
node[name][attr.name] = attr.value;
lastname = attr.name;
} else if (currentLine.startsWith('ref node ')) {
const refNode = this.resolveRefNode(this.nextLine());
node[name][refNode.name] = refNode.value;
} else if (currentLine.startsWith('node ')) {
const childNode = this.parseNode();
if (typeof node[name] === 'object' && !Array.isArray(node[name])) {
Object.assign(node[name], childNode);
} else {
node[name] = childNode;
}
} else {
throw new Error(`Unexpected line in node: ${currentLine}`);
}
}
this.expectLine('end node');
return node;
}
/**
* @private
* Parses an attribute line.
* @param {string} line
* @returns {{name: string, value: any}}
*/
parseAttr(line) {
const match = line.match(/^attr\s+(.+?)\s+"([^"]*)"?$/);
if (!match) {
throw new Error("Invalid attribute: " + line);
}
const name = match[1];
const rawValue = match[2] || '';
const value = this.autoCasting ? this.autoCast(rawValue) : rawValue;
return { name, value };
}
/**
* @private
* Resolves a reference attribute line.
* @param {string} line
* @returns {{name: string, value: any}}
*/
resolveRefAttr(line) {
const match = line.match(/^ref attr (.+)$/);
if (!match) {
throw new Error("Invalid ref attr: " + line);
}
const name = match[1];
if (!(name in this.references)) {
throw new Error("Undefined reference: " + name);
}
const value = this.references[name];
return {
name,
value: this.autoCasting ? this.autoCast(value) : value
};
}
/**
* @private
* Resolves a reference node.
* @param {string} line
* @returns {{name: string, value: any}}
*/
resolveRefNode(line) {
const match = line.match(/^ref node (.+)$/);
if (!match) {
throw new Error("Invalid node usage: " + line);
}
const name = match[1];
if (!(name in this.nodeReferences)) {
throw new Error("Undefined reference: " + name);
}
const value = this.nodeReferences[name];
return {
name,
value: value
};
}
/**
* @private
* Peeks at the current line without advancing the cursor.
* @returns {string|null}
*/
peekLine() {
return this.lines[this.currentLine] || null;
}
/**
* @private
* Peeks at the current ref line without advancing the cursor.
* @returns {string|null}
*/
peekRefLine() {
return this.refLines[this.currentRefLine] || null;
}
/**
* @private
* Gets the current line and advances the cursor.
* @returns {string|null}
*/
nextLine() {
return this.lines[this.currentLine++] || null;
}
/**
* @private
* Gets the current ref line and advances the cursor.
* @returns {string|null}
*/
nextRefLine() {
return this.refLines[this.currentRefLine++] || null;
}
/**
* @private
* Ensures the next line matches the expected value.
* @param {string} expected
*/
expectLine(expected) {
const line = this.nextLine();
if (line !== expected) {
throw new Error(`Expected "${expected}" but found "${line}"`);
}
}
/**
* @private
* Ensures the next ref line matches the expected value.
* @param {string} expected
*/
expectRefLine(expected) {
const line = this.nextRefLine();
if (line !== expected) {
throw new Error(`Expected "${expected}" but found "${line}"`);
}
}
/**
* @private
* Attempts to convert a string value to boolean, number, base64, etc.
* @param {string} value
* @returns {any}
*/
autoCast(value) {
const trimmed = value.trim();
// "X,Y" Coords
if (/^\+?(0[xob])?[0-9a-fA-F]+,\+?(0[xob])?[0-9a-fA-F]+$/i.test(trimmed)) {
return trimmed.split(',').map(v => this.autoCast(v));
}
const lower = trimmed.toLowerCase();
if (this.trueValues.includes(lower)) return true;
if (this.falseValues.includes(lower)) return false;
// Hex (np. 0xA3)
if (/^0x[0-9a-f]+$/i.test(trimmed)) return parseInt(trimmed, 16);
// Binary (np. 0b1010)
if (/^0b[01]+$/i.test(trimmed)) return parseInt(trimmed.slice(2), 2);
// Octal (np. 0o755)
if (/^0o[0-7]+$/i.test(trimmed)) return parseInt(trimmed.slice(2), 8);
// Integer
if (/^[+-]?\d+$/.test(trimmed)) return parseInt(trimmed, 10);
// Float
if (/^[+-]?\d+[.,]\d+([eE][+-]?\d+)?$/.test(trimmed)) {
return parseFloat(trimmed.replace(',', '.'));
}
// Base64
try {
if (btoa(atob(trimmed)) === trimmed) {
return atob(trimmed);
}
} catch {
// Not valid base64
}
return trimmed;
}
}
Przykład użycia parsera:
const parser = new TreeStructInfoParserJS();
parser.setAutoCasting(true);
const result = parser.parse(tsinfoText);
console.log(result.version); // "2.0"
console.log(result.data.title); // np. "Hello"
Automatyczne rzutowanie może powodować nieprawidłową interpretację danych. Jeśli wartości float będą miały przecinek jako miejsce dziesiętne, zostanie to zinterpretowane jako współrzędne. Niektóre atrybuty mogą także zwracać null.
TreeStructInfoExporterJS
Eksporter działa odwrotnie: przyjmuje obiekt { version, name, data }
i generuje tekst .tsinfo
. Obsługuje:
- struktury zagnieżdżone
- różne typy danych
- ręczne definiowanie referencji (
ref attr
), (ref node
) - opcjonalne formatowanie wcięć
Kod eksportera
/**
* TreeStructInfoExporterJS
*
* Serializes a structured JavaScript object into the TreeStructInfo format.
* Supports indentation, quoting, and reference attribute declarations.
* Copyright (c) 2025 Dariusz Rorat
* Licensed under the BSD 3-Clause License.
*/
class TreeStructInfoExporterJS {
constructor() {
/**
* String used for indentation (default: two spaces).
* @type {string}
*/
this.indent = " ";
/**
* Whether to automatically quote string values (default: true).
* Currently unused internally but can be wired to control behavior.
* @type {boolean}
*/
this.autoQuoting = true;
/**
* Reference attributes to be declared at the end of the export.
* @type {Object.<string, string>}
*/
this.refAttributes = {};
}
/**
* Sets the indentation string used for nested nodes.
* @param {string} indentString
*/
setIndent(indentString) {
this.indent = indentString;
}
/**
* Sets the reference attributes to be emitted at the end of the file.
* @param {Object.<string, string>} refs
*/
setRefAttributes(refs) {
this.refAttributes = refs || {};
}
/**
* Exports a TreeStructInfo-compliant object to its textual representation.
* @param {{version: string, name?: string, data: Object}} obj
* @returns {string} The serialized TreeStructInfo string.
* @throws {Error} If the object is not valid.
*/
export(obj) {
if (!obj || typeof obj !== "object" || !obj.version) {
throw new Error("Invalid TreeStructInfo object");
}
let output = [];
// Header
let header = `treestructinfo "${obj.version}"`;
if (obj.name) header += ` name "${obj.name}"`;
output.push(header);
// Tree
output.push(...this.exportTree(obj.data, 1));
output.push("end tree");
// Reference attributes
for (const [key, val] of Object.entries(this.refAttributes)) {
output.push(`ref attr ${key} "${this.escape(val)}"`);
}
return output.join("\n");
}
/**
* @private
* Recursively exports nested objects as tree nodes or attributes.
* @param {Object} data
* @param {number} level
* @returns {string[]} Lines of the TreeStructInfo body.
*/
exportTree(data, level) {
const lines = [];
const indent = this.indent.repeat(level);
for (const [key, value] of Object.entries(data)) {
if (this.isPlainObject(value)) {
lines.push(`${indent}node ${key}`);
lines.push(...this.exportTree(value, level + 1));
lines.push(`${indent}end node`);
} else {
lines.push(`${indent}attr ${key} "${this.stringify(value)}"`);
}
}
return lines;
}
/**
* @private
* Converts a value to a TreeStructInfo-compatible string.
* Handles types like boolean, number, string, Buffer, Uint8Array.
* @param {any} value
* @returns {string}
*/
stringify(value) {
if (typeof value === "boolean") {
return value ? "true" : "false";
} else if (typeof value === "number") {
return value.toString();
} else if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(value)));
} else if (typeof value === "string") {
return this.escape(value);
} else if (value instanceof Buffer) {
return value.toString("base64");
} else {
return this.escape(String(value));
}
}
/**
* @private
* Escapes special characters in strings (e.g. quotes).
* @param {string} str
* @returns {string}
*/
escape(str) {
return str.replace(/"/g, '\\"');
}
/**
* @private
* Checks if the value is a plain object (not array or null).
* @param {any} obj
* @returns {boolean}
*/
isPlainObject(obj) {
return typeof obj === "object" && obj !== null && !Array.isArray(obj);
}
}
Przykład użycia eksportera:
const exporter = new TreeStructInfoExporterJS();
exporter.setRefAttributes({ secret: "abc123" });
const output = exporter.export({
version: "2.0",
name: "TestDoc",
data: {
title: "Hello",
flag: true,
section: {
count: 42
}
}
});
console.log(output);
Wynik:
treestructinfo "2.0" name "TestDoc"
attr title "Hello"
attr flag "true"
node section
attr count "42"
end node
end tree
ref attr secret "abc123"
W tym przypadku widać że brakuje w strukturze drzewa atrybutu referencjonowanego secret
i trzeba go później dopisać ręcznie.
Cechy wspólne
Funkcja | Parser | Eksporter |
---|---|---|
Obsługa wersji | ||
Wczytywanie nazw | ||
Auto-casting typów | ||
Węzły i atrybuty | ||
Atrybuty referencyjne | ||
Komentarze :: |
(ignoruje) | (pomija) |
Przykładowy test
const parser = new TreeStructInfoParserJS();
parser.setAutoCasting(true);
const exporter = new TreeStructInfoExporterJS();
const text = `
treestructinfo "2.0" name "Sample"
attr name "Tree"
attr value "123"
node info
attr valid "true"
end node
end tree
`;
const parsed = parser.parse(text);
const reExported = exporter.export(parsed);
console.log(reExported);
Dokumentacja
http://www.dariuszrorat.ugu.pl/dokumenty/16-dokumentacja-treestructinfoparserjs
http://www.dariuszrorat.ugu.pl/dokumenty/17-dokumentacja-treestructinfoexporterjs
BSD-3-Clause License Agreement
BSD-3-Clause
Сopyright (c) 2025 Dariusz Rorat
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
-
Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer. -
Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution. - Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Podsumowanie
TreeStructInfoJS to kompletna implementacja parsera i eksportera tekstowego formatu TreeStructInfo 2.0
w JavaScript. Dzięki automatycznemu rzutowaniu typów, obsłudze atrybutów referencyjnych i czytelnej strukturze kodu, biblioteka sprawdza się zarówno do edytowalnych danych konfiguracyjnych, jak i strukturalnych zapisów danych.
Możliwość pracy offline i łatwe przystosowanie do YAML/JSON/HTML sprawiają, że TreeStructInfoJS
doskonale wpisuje się w potrzeby aplikacji frontendowych i backendowych.
Strona formatu TreeStructInfo:
https://tsinfo.4programmers.net/pl/index.htm
Moja aplikacja online do testowania formatu:
http://www.dariuszrorat.ugu.pl/aplikacje/treestructinfo-tester
Kody źródłowe do pobrania:
http://www.dariuszrorat.ugu.pl/uploads/media/2025/07/tsinfo.zip