chore: initial commit

This commit is contained in:
Johann Schopplich
2025-10-22 20:16:02 +02:00
commit f105551c3e
24 changed files with 6983 additions and 0 deletions

41
src/constants.ts Normal file
View File

@@ -0,0 +1,41 @@
// #region List markers
export const LIST_ITEM_MARKER = '-'
export const LIST_ITEM_PREFIX = '- '
// #endregion
// #region Structural characters
export const COMMA = ','
export const COLON = ':'
export const SPACE = ' '
// #endregion
// #region Brackets and braces
export const OPEN_BRACKET = '['
export const CLOSE_BRACKET = ']'
export const OPEN_BRACE = '{'
export const CLOSE_BRACE = '}'
// #endregion
// #region Literals
export const NULL_LITERAL = 'null'
export const TRUE_LITERAL = 'true'
export const FALSE_LITERAL = 'false'
// #endregion
// #region Escape characters
export const BACKSLASH = '\\'
export const DOUBLE_QUOTE = '"'
export const NEWLINE = '\n'
export const CARRIAGE_RETURN = '\r'
export const TAB = '\t'
// #endregion

360
src/encoders.ts Normal file
View File

@@ -0,0 +1,360 @@
import type {
Depth,
JsonArray,
JsonObject,
JsonPrimitive,
JsonValue,
ResolvedEncodeOptions,
} from './types'
import { LIST_ITEM_MARKER, LIST_ITEM_PREFIX } from './constants'
import {
isArrayOfArrays,
isArrayOfObjects,
isArrayOfPrimitives,
isJsonArray,
isJsonObject,
isJsonPrimitive,
} from './normalize'
import {
encodeKey,
encodePrimitive,
formatArrayHeader,
formatKeyedArrayHeader,
formatKeyedTableHeader,
formatTabularHeader,
joinEncodedValues,
} from './primitives'
import { LineWriter } from './writer'
// #region Encode normalized JsonValue
export function encodeValue(value: JsonValue, options: ResolvedEncodeOptions): string {
if (isJsonPrimitive(value)) {
return encodePrimitive(value)
}
const writer = new LineWriter(options.indent)
if (isJsonArray(value)) {
encodeRootArray(value, writer)
}
else if (isJsonObject(value)) {
encodeObject(value, writer, 0)
}
return writer.toString()
}
// #endregion
// #region Object encoding
export function encodeObject(value: JsonObject, writer: LineWriter, depth: Depth): void {
const keys = Object.keys(value)
for (const key of keys) {
encodeKeyValuePair(key, value[key]!, writer, depth)
}
}
export function encodeKeyValuePair(key: string, value: JsonValue, writer: LineWriter, depth: Depth): void {
const encodedKey = encodeKey(key)
if (isJsonPrimitive(value)) {
writer.push(depth, `${encodedKey}: ${encodePrimitive(value)}`)
}
else if (isJsonArray(value)) {
encodeArrayProperty(key, value, writer, depth)
}
else if (isJsonObject(value)) {
const nestedKeys = Object.keys(value)
if (nestedKeys.length === 0) {
// Empty object
writer.push(depth, `${encodedKey}:`)
}
else {
writer.push(depth, `${encodedKey}:`)
encodeObject(value, writer, depth + 1)
}
}
}
// #endregion
// #region Array encoding
export function encodeRootArray(value: JsonArray, writer: LineWriter): void {
if (value.length === 0) {
writer.push(0, '[0]:')
return
}
// Primitive array
if (isArrayOfPrimitives(value)) {
encodeInlinePrimitiveArray(undefined, value, writer, 0)
return
}
// Array of arrays (all primitives)
if (isArrayOfArrays(value)) {
const allPrimitiveArrays = value.every(arr => isArrayOfPrimitives(arr))
if (allPrimitiveArrays) {
encodeArrayOfArraysAsListItems(undefined, value, writer, 0)
return
}
}
// Array of objects
if (isArrayOfObjects(value)) {
const header = detectTabularHeader(value)
if (header) {
encodeArrayOfObjectsAsTabular(undefined, value, header, writer, 0)
}
else {
encodeArrayOfObjectsAsListItems(undefined, value, writer, 0)
}
return
}
// Mixed array: fallback to expanded format (not in spec, but safe default)
encodeMixedArrayAsListItems(undefined, value, writer, 0)
}
export function encodeArrayProperty(key: string, value: JsonArray, writer: LineWriter, depth: Depth): void {
if (value.length === 0) {
const encodedKey = encodeKey(key)
writer.push(depth, `${encodedKey}[0]:`)
return
}
// Primitive array
if (isArrayOfPrimitives(value)) {
encodeInlinePrimitiveArray(key, value, writer, depth)
return
}
// Array of arrays (all primitives)
if (isArrayOfArrays(value)) {
const allPrimitiveArrays = value.every(arr => isArrayOfPrimitives(arr))
if (allPrimitiveArrays) {
encodeArrayOfArraysAsListItems(key, value, writer, depth)
return
}
}
// Array of objects
if (isArrayOfObjects(value)) {
const header = detectTabularHeader(value)
if (header) {
encodeArrayOfObjectsAsTabular(key, value, header, writer, depth)
}
else {
encodeArrayOfObjectsAsListItems(key, value, writer, depth)
}
return
}
// Mixed array: fallback to expanded format
encodeMixedArrayAsListItems(key, value, writer, depth)
}
// #endregion
// #region Primitive array encoding (inline)
export function encodeInlinePrimitiveArray(
prefix: string | undefined,
values: readonly JsonPrimitive[],
writer: LineWriter,
depth: Depth,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, values.length) : formatArrayHeader(values.length)
const joinedValue = joinEncodedValues(values)
// Only add space if there are values
if (values.length === 0) {
writer.push(depth, header)
}
else {
writer.push(depth, `${header} ${joinedValue}`)
}
}
// #endregion
// #region Array of arrays (expanded format)
export function encodeArrayOfArraysAsListItems(
prefix: string | undefined,
values: readonly JsonArray[],
writer: LineWriter,
depth: Depth,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, values.length) : formatArrayHeader(values.length)
writer.push(depth, header)
for (const arr of values) {
if (isArrayOfPrimitives(arr)) {
const inline = formatInlineArray(arr)
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${inline}`)
}
}
}
export function formatInlineArray(values: readonly JsonPrimitive[]): string {
const header = formatArrayHeader(values.length)
const joinedValue = joinEncodedValues(values)
// Only add space if there are values
if (values.length === 0) {
return header
}
return `${header} ${joinedValue}`
}
// #endregion
// #region Array of objects (tabular format)
export function encodeArrayOfObjectsAsTabular(
prefix: string | undefined,
rows: readonly JsonObject[],
header: readonly string[],
writer: LineWriter,
depth: Depth,
): void {
const headerStr = prefix
? formatKeyedTableHeader(prefix, rows.length, header)
: formatTabularHeader(rows.length, header)
writer.push(depth, `${headerStr}`)
for (const row of rows) {
const values = header.map(key => row[key])
const joinedValue = joinEncodedValues(values as JsonPrimitive[])
writer.push(depth + 1, joinedValue)
}
}
export function detectTabularHeader(rows: readonly JsonObject[]): string[] | undefined {
if (rows.length === 0)
return
const firstRow = rows[0]!
const firstKeys = Object.keys(firstRow)
if (firstKeys.length === 0)
return
if (isTabularArray(rows, firstKeys)) {
return firstKeys
}
}
export function isTabularArray(
rows: readonly JsonObject[],
header: readonly string[],
): boolean {
for (const row of rows) {
const keys = Object.keys(row)
// All objects must have the same keys (but order can differ)
if (keys.length !== header.length) {
return false
}
// Check that all header keys exist in the row and all values are primitives
for (const key of header) {
if (!(key in row)) {
return false
}
if (!isJsonPrimitive(row[key])) {
return false
}
}
}
return true
}
// #endregion
// #region Array of objects (expanded format)
export function encodeMixedArrayAsListItems(
prefix: string | undefined,
items: readonly JsonValue[],
writer: LineWriter,
depth: Depth,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, items.length) : formatArrayHeader(items.length)
writer.push(depth, header)
for (const item of items) {
if (isJsonPrimitive(item)) {
// Direct primitive as list item
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${encodePrimitive(item)}`)
}
else if (isJsonArray(item)) {
// Direct array as list item
if (isArrayOfPrimitives(item)) {
const inline = formatInlineArray(item)
writer.push(depth + 1, `${LIST_ITEM_PREFIX}${inline}`)
}
}
else if (isJsonObject(item)) {
// Object as list item
encodeObjectAsListItem(item, writer, depth + 1)
}
}
}
export function encodeArrayOfObjectsAsListItems(
prefix: string | undefined,
rows: readonly JsonObject[],
writer: LineWriter,
depth: Depth,
): void {
const header = prefix ? formatKeyedArrayHeader(prefix, rows.length) : formatArrayHeader(rows.length)
writer.push(depth, `${header}`)
for (const obj of rows) {
encodeObjectAsListItem(obj, writer, depth + 1)
}
}
export function encodeObjectAsListItem(obj: JsonObject, writer: LineWriter, depth: Depth): void {
const keys = Object.keys(obj)
if (keys.length === 0) {
writer.push(depth, LIST_ITEM_MARKER)
return
}
// First key-value on the same line as "- "
const firstKey = keys[0]!
const encodedKey = encodeKey(firstKey)
const firstValue = obj[firstKey]!
if (isJsonPrimitive(firstValue)) {
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}: ${encodePrimitive(firstValue)}`)
}
else if (isJsonArray(firstValue)) {
// For arrays, we need to put them on separate lines
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}[${firstValue.length}]:`)
// ... handle array encoding (simplified for now)
}
else if (isJsonObject(firstValue)) {
const nestedKeys = Object.keys(firstValue)
if (nestedKeys.length === 0) {
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}:`)
}
else {
writer.push(depth, `${LIST_ITEM_PREFIX}${encodedKey}:`)
encodeObject(firstValue, writer, depth + 2)
}
}
// Remaining keys on indented lines
for (let i = 1; i < keys.length; i++) {
const key = keys[i]!
encodeKeyValuePair(key, obj[key]!, writer, depth + 1)
}
}
// #endregion

27
src/index.ts Normal file
View File

@@ -0,0 +1,27 @@
import type {
EncodeOptions,
ResolvedEncodeOptions,
} from './types'
import { encodeValue } from './encoders'
import { normalizeValue } from './normalize'
export type {
EncodeOptions,
JsonArray,
JsonObject,
JsonPrimitive,
JsonValue,
ResolvedEncodeOptions,
} from './types'
export function encode(input: unknown, options?: EncodeOptions): string {
const normalizedValue = normalizeValue(input)
const resolvedOptions = resolveOptions(options)
return encodeValue(normalizedValue, resolvedOptions)
}
function resolveOptions(options?: EncodeOptions): ResolvedEncodeOptions {
return {
indent: options?.indent ?? 2,
}
}

143
src/normalize.ts Normal file
View File

@@ -0,0 +1,143 @@
import type {
JsonArray,
JsonObject,
JsonPrimitive,
JsonValue,
} from './types'
// #region Normalization (unknown → JsonValue)
export function normalizeValue(value: unknown): JsonValue {
// null
if (value === null) {
return null
}
// Primitives
if (typeof value === 'string' || typeof value === 'boolean') {
return value
}
// Numbers: canonicalize -0 to 0, handle NaN and Infinity
if (typeof value === 'number') {
if (Object.is(value, -0)) {
return 0
}
if (!Number.isFinite(value)) {
return null
}
return value
}
// BigInt → number (if safe) or string
if (typeof value === 'bigint') {
// Try to convert to number if within safe integer range
if (value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) {
return Number(value)
}
// Otherwise convert to string (will be unquoted as it looks numeric)
return value.toString()
}
// Date → ISO string
if (value instanceof Date) {
return value.toISOString()
}
// Array
if (Array.isArray(value)) {
return normalizeArray(value)
}
// Set → array
if (value instanceof Set) {
return normalizeArray(Array.from(value))
}
// Map → object
if (value instanceof Map) {
return Object.fromEntries(
Array.from(value, ([k, v]) => [String(k), normalizeValue(v)]),
)
}
// Plain object
if (isPlainObject(value)) {
return normalizeObject(value)
}
// Fallback: function, symbol, undefined, or other → null
return null
}
export function normalizeArray(value: unknown): JsonArray {
if (!Array.isArray(value)) {
return []
}
return value.map(item => normalizeValue(item))
}
export function normalizeObject(value: unknown): JsonObject {
if (!isPlainObject(value)) {
return {}
}
const result: Record<string, JsonValue> = {}
for (const key in value) {
if (Object.prototype.hasOwnProperty.call(value, key)) {
result[key] = normalizeValue(value[key])
}
}
return result
}
// #endregion
// #region Type guards
export function isJsonPrimitive(value: unknown): value is JsonPrimitive {
return (
value === null
|| typeof value === 'string'
|| typeof value === 'number'
|| typeof value === 'boolean'
)
}
export function isJsonArray(value: unknown): value is JsonArray {
return Array.isArray(value)
}
export function isJsonObject(value: unknown): value is JsonObject {
return value !== null && typeof value === 'object' && !Array.isArray(value)
}
export function isPlainObject(value: unknown): value is Record<string, unknown> {
if (value === null || typeof value !== 'object') {
return false
}
const prototype = Object.getPrototypeOf(value)
return prototype === null || prototype === Object.prototype
}
// #endregion
// #region Array type detection
export function isArrayOfPrimitives(value: JsonArray): value is readonly JsonPrimitive[] {
return value.every(item => isJsonPrimitive(item))
}
export function isArrayOfArrays(value: JsonArray): value is readonly JsonArray[] {
return value.every(item => isJsonArray(item))
}
export function isArrayOfObjects(value: JsonArray): value is readonly JsonObject[] {
return value.every(item => isJsonObject(item))
}
// #endregion

129
src/primitives.ts Normal file
View File

@@ -0,0 +1,129 @@
import type { JsonPrimitive } from './types'
import {
BACKSLASH,
COMMA,
DOUBLE_QUOTE,
FALSE_LITERAL,
LIST_ITEM_MARKER,
NULL_LITERAL,
TRUE_LITERAL,
} from './constants'
// #region Primitive encoding
export function encodePrimitive(value: JsonPrimitive): string {
if (value === null) {
return NULL_LITERAL
}
if (typeof value === 'boolean') {
return String(value)
}
if (typeof value === 'number') {
return String(value)
}
return encodeStringLiteral(value)
}
export function encodeStringLiteral(value: string): string {
if (isSafeUnquoted(value)) {
return value
}
return `${DOUBLE_QUOTE}${escapeString(value)}${DOUBLE_QUOTE}`
}
export function escapeString(value: string): string {
return value
.replace(/\\/g, `${BACKSLASH}${BACKSLASH}`)
.replace(/"/g, `${BACKSLASH}${DOUBLE_QUOTE}`)
.replace(/\n/g, `${BACKSLASH}n`)
.replace(/\r/g, `${BACKSLASH}r`)
.replace(/\t/g, `${BACKSLASH}t`)
}
export function isSafeUnquoted(value: string): boolean {
if (!value) {
return false
}
if (isPaddedWithWhitespace(value)) {
return false
}
if (value === TRUE_LITERAL || value === FALSE_LITERAL || value === NULL_LITERAL) {
return false
}
if (isNumericLike(value)) {
return false
}
// Check for structural characters: comma, colon, brackets, braces, hyphen at start, newline, carriage return, tab, double-quote
if (/[,:\n\r\t"[\]{}]/.test(value) || value.startsWith(LIST_ITEM_MARKER)) {
return false
}
return true
}
export function isNumericLike(value: string): boolean {
// Match numbers like: 42, -3.14, 1e-6, 05, etc.
return /^-?\d+(?:\.\d+)?(?:e[+-]?\d+)?$/i.test(value) || /^0\d+$/.test(value)
}
export function isPaddedWithWhitespace(value: string): boolean {
return value !== value.trim()
}
// #endregion
// #region Key encoding
export function encodeKey(key: string): string {
if (isValidUnquotedKey(key)) {
return key
}
return `${DOUBLE_QUOTE}${escapeString(key)}${DOUBLE_QUOTE}`
}
function isValidUnquotedKey(key: string): boolean {
return /^[A-Z_][\w.]*$/i.test(key)
}
// #endregion
// #region Value joining
export function joinEncodedValues(values: readonly JsonPrimitive[]): string {
return values.map(v => encodePrimitive(v)).join(COMMA)
}
// #endregion
// #region Header formatters
export function formatArrayHeader(length: number): string {
return `[${length}]:`
}
export function formatTabularHeader(length: number, fields: readonly string[]): string {
const quotedFields = fields.map(f => encodeKey(f))
return `[${length}]{${quotedFields.join(',')}}:`
}
export function formatKeyedArrayHeader(key: string, length: number): string {
const encodedKey = encodeKey(key)
return `${encodedKey}[${length}]:`
}
export function formatKeyedTableHeader(key: string, length: number, fields: readonly string[]): string {
const encodedKey = encodeKey(key)
const quotedFields = fields.map(f => encodeKey(f))
return `${encodedKey}[${length}]{${quotedFields.join(',')}}:`
}
// #endregion

20
src/types.ts Normal file
View File

@@ -0,0 +1,20 @@
// #region JSON types
export type JsonPrimitive = string | number | boolean | null
export type JsonObject = { [Key in string]: JsonValue } & { [Key in string]?: JsonValue | undefined }
export type JsonArray = JsonValue[] | readonly JsonValue[]
export type JsonValue = JsonPrimitive | JsonObject | JsonArray
// #endregion
// #region Encoder options
export interface EncodeOptions {
indent?: number
}
export type ResolvedEncodeOptions = Readonly<Required<EncodeOptions>>
// #endregion
export type Depth = number

19
src/writer.ts Normal file
View File

@@ -0,0 +1,19 @@
import type { Depth } from './types'
export class LineWriter {
private readonly lines: string[] = []
private readonly indentationString: string
constructor(indentSize: number) {
this.indentationString = ' '.repeat(indentSize)
}
push(depth: Depth, content: string): void {
const indent = this.indentationString.repeat(depth)
this.lines.push(indent + content)
}
toString(): string {
return this.lines.join('\n')
}
}