ploon-cli
Version:
CLI tool for PLOON (Path-Level Object Oriented Notation) - Convert JSON/XML/YAML to the most token-efficient format
215 lines (210 loc) • 8.03 kB
JavaScript
import { Command } from "commander";
import { consola } from "consola";
import { readFile, writeFile } from "node:fs/promises";
import { stdin, stdout } from "node:process";
import { fromJSON, fromXML, fromYAML, isValid, minify, parse, prettify, stringify, toJSON, toXML, toYAML } from "ploon";
import { encoding_for_model } from "tiktoken";
//#region src/utils.ts
/**
* Read input from file or stdin
*/
async function readInput(filePath) {
if (!filePath || filePath === "-") return readStdin();
try {
return await readFile(filePath, "utf-8");
} catch (error) {
throw new Error(`Failed to read file: ${filePath}`);
}
}
/**
* Write output to file or stdout
*/
async function writeOutput(content, filePath) {
if (!filePath) {
stdout.write(content + "\n");
return;
}
try {
await writeFile(filePath, content, "utf-8");
} catch (error) {
throw new Error(`Failed to write file: ${filePath}`);
}
}
/**
* Read from stdin
*/
function readStdin() {
return new Promise((resolve, reject) => {
let data = "";
stdin.setEncoding("utf-8");
stdin.on("data", (chunk) => {
data += chunk;
});
stdin.on("end", () => {
resolve(data);
});
stdin.on("error", (error) => {
reject(error);
});
});
}
/**
* Detect input format from file extension or content
*/
function detectFormat(filePath, content) {
if (filePath && filePath !== "-") {
const ext = filePath.split(".").pop()?.toLowerCase();
if (ext === "json") return "json";
if (ext === "xml") return "xml";
if (ext === "yaml" || ext === "yml") return "yaml";
if (ext === "ploon") return "ploon";
}
const trimmed = content.trim();
if (trimmed.match(/^\[[\w]+#\d+\]/)) return "ploon";
if (trimmed.startsWith("{") || trimmed.startsWith("[")) return "json";
if (trimmed.startsWith("<")) return "xml";
if (trimmed.match(/^[\w-]+:/) || trimmed.startsWith("-")) return "yaml";
return "json";
}
//#endregion
//#region src/convert.ts
/**
* Main conversion function
*/
async function convert(input, options) {
const { inputFormat = "auto", outputFormat, minify: shouldMinify, prettify: shouldPrettify, validate: shouldValidate } = options;
if (shouldValidate) {
if (!isValid(input)) throw new Error("Invalid PLOON format");
return input;
}
if (outputFormat) return convertFromPloon(input, outputFormat, options);
return convertToPloon(input, inputFormat, shouldMinify, shouldPrettify, options);
}
/**
* Convert to PLOON from various formats
*/
function convertToPloon(input, format, shouldMinify, shouldPrettify, options) {
let data;
if (format === "json" || format === "auto") try {
data = fromJSON(input);
} catch (error) {
throw new Error(`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
}
else if (format === "xml") try {
data = fromXML(input);
} catch (error) {
throw new Error(`Invalid XML: ${error instanceof Error ? error.message : String(error)}`);
}
else if (format === "yaml") try {
data = fromYAML(input);
} catch (error) {
throw new Error(`Invalid YAML: ${error instanceof Error ? error.message : String(error)}`);
}
else if (format === "ploon") {
if (shouldMinify) return minify(input);
if (shouldPrettify) return prettify(input);
return input;
} else throw new Error(`Unsupported input format: ${format}`);
const ploonFormat = shouldMinify ? "compact" : "standard";
const ploon = stringify(data, {
format: ploonFormat,
config: options?.config
});
return ploon;
}
/**
* Convert from PLOON to various formats
*/
function convertFromPloon(input, format, options) {
let data;
try {
data = parse(input, { config: options?.config });
} catch (error) {
throw new Error(`Failed to parse PLOON: ${error instanceof Error ? error.message : String(error)}`);
}
if (format === "json") return toJSON(data, true);
else if (format === "xml") return toXML(data, true);
else if (format === "yaml") return toYAML(data, true);
else throw new Error(`Unsupported output format: ${format}`);
}
//#endregion
//#region src/stats.ts
/**
* Count tokens using tiktoken (accurate for GPT models)
*/
function countTokens(text) {
try {
const encoding = encoding_for_model("gpt-4");
const tokens = encoding.encode(text);
const count = tokens.length;
encoding.free();
return count;
} catch (error) {
consola.warn("Tiktoken failed, using estimation");
return Math.ceil(text.length / 4);
}
}
/**
* Show statistics comparing formats
*/
function showStats(input, output, options) {
const inputTokens = countTokens(input);
const outputTokens = countTokens(output);
const inputChars = input.length;
const outputChars = output.length;
const tokenDiff = inputTokens - outputTokens;
const tokenPercent = inputTokens > 0 ? (tokenDiff / inputTokens * 100).toFixed(1) : "0.0";
const charDiff = inputChars - outputChars;
const charPercent = inputChars > 0 ? (charDiff / inputChars * 100).toFixed(1) : "0.0";
console.log("");
consola.box({
title: "📊 Token Statistics (tiktoken/GPT-4)",
message: [
`Input: ${inputTokens} tokens (${inputChars} chars)`,
`Output: ${outputTokens} tokens (${outputChars} chars)`,
"",
tokenDiff >= 0 ? `✅ Saved ${tokenDiff} tokens (-${tokenPercent}%)` : `⚠️ Added ${Math.abs(tokenDiff)} tokens (+${Math.abs(parseFloat(tokenPercent))}%)`,
charDiff >= 0 ? `✅ Saved ${charDiff} chars (-${charPercent}%)` : `⚠️ Added ${Math.abs(charDiff)} chars (+${Math.abs(parseFloat(charPercent))}%)`
].join("\n"),
style: {
padding: 1,
borderColor: tokenDiff >= 0 ? "green" : "yellow",
borderStyle: "round"
}
});
}
//#endregion
//#region src/index.ts
const program = new Command();
program.name("ploon").description("PLOON - Path-Level Object Oriented Notation CLI").version("1.0.3");
program.argument("[input]", "Input file (or stdin if not provided)").option("-o, --output <file>", "Output file (default: stdout)").option("--from <format>", "Input format: json|xml|yaml (default: auto-detect)").option("--to <format>", "Output format: json|xml|yaml (converts from PLOON)").option("--minify", "Output compact format (semicolon-separated)").option("--prettify", "Output standard format (newline-separated)").option("--validate", "Validate PLOON format only").option("--stats", "Show token count comparison").option("-c, --config <file>", "Custom configuration file").option("--field-delimiter <char>", "Field delimiter (default: |)").option("--path-separator <char>", "Path separator (default: :)").option("--array-marker <char>", "Array size marker (default: #)").option("--escape-char <char>", "Escape character (default: \\)").option("--no-preserve-empty-fields", "Remove null/empty values from arrays (cleaner output)").action(async (input, options) => {
try {
const inputData = await readInput(input);
const config = {};
if (options.fieldDelimiter !== void 0) config.fieldDelimiter = options.fieldDelimiter;
if (options.pathSeparator !== void 0) config.pathSeparator = options.pathSeparator;
if (options.arrayMarker !== void 0) config.arraySizeMarker = options.arrayMarker;
if (options.escapeChar !== void 0) config.escapeChar = options.escapeChar;
if (options.preserveEmptyFields !== void 0) config.preserveEmptyFields = options.preserveEmptyFields;
const convertOptions = {
inputFormat: options.from,
outputFormat: options.to,
minify: options.minify,
prettify: options.prettify,
validate: options.validate,
showStats: options.stats,
config: Object.keys(config).length > 0 ? config : void 0
};
if (!convertOptions.inputFormat && !convertOptions.outputFormat) convertOptions.inputFormat = detectFormat(input, inputData);
const result = await convert(inputData, convertOptions);
if (options.stats) showStats(inputData, result, convertOptions);
if (!options.validate) await writeOutput(result, options.output);
if (options.validate) consola.success("PLOON format is valid");
} catch (error) {
consola.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
});
program.parse();
//#endregion