@creationix/jot 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -0
- package/SUMMARY.md +151 -0
- package/TOKEN_COUNTS.md +97 -0
- package/bun.lock +19 -0
- package/jot.test.ts +133 -0
- package/jot.ts +650 -0
- package/package.json +10 -0
- package/samples/chat.jot +1 -0
- package/samples/chat.json +1 -0
- package/samples/chat.pretty.jot +6 -0
- package/samples/chat.pretty.json +16 -0
- package/samples/firewall.jot +1 -0
- package/samples/firewall.json +1 -0
- package/samples/firewall.pretty.jot +235 -0
- package/samples/firewall.pretty.json +344 -0
- package/samples/github-issue.jot +1 -0
- package/samples/github-issue.json +1 -0
- package/samples/github-issue.pretty.jot +15 -0
- package/samples/github-issue.pretty.json +20 -0
- package/samples/hikes.jot +1 -0
- package/samples/hikes.json +1 -0
- package/samples/hikes.pretty.jot +14 -0
- package/samples/hikes.pretty.json +38 -0
- package/samples/irregular.jot +1 -0
- package/samples/irregular.json +1 -0
- package/samples/irregular.pretty.jot +13 -0
- package/samples/irregular.pretty.json +23 -0
- package/samples/json-counts-cache.jot +1 -0
- package/samples/json-counts-cache.json +1 -0
- package/samples/json-counts-cache.pretty.jot +26 -0
- package/samples/json-counts-cache.pretty.json +26 -0
- package/samples/key-folding-basic.jot +1 -0
- package/samples/key-folding-basic.json +1 -0
- package/samples/key-folding-basic.pretty.jot +7 -0
- package/samples/key-folding-basic.pretty.json +25 -0
- package/samples/key-folding-mixed.jot +1 -0
- package/samples/key-folding-mixed.json +1 -0
- package/samples/key-folding-mixed.pretty.jot +16 -0
- package/samples/key-folding-mixed.pretty.json +24 -0
- package/samples/key-folding-with-array.jot +1 -0
- package/samples/key-folding-with-array.json +1 -0
- package/samples/key-folding-with-array.pretty.jot +6 -0
- package/samples/key-folding-with-array.pretty.json +29 -0
- package/samples/large.jot +1 -0
- package/samples/large.json +1 -0
- package/samples/large.pretty.jot +72 -0
- package/samples/large.pretty.json +93 -0
- package/samples/logs.jot +1 -0
- package/samples/logs.json +1 -0
- package/samples/logs.pretty.jot +96 -0
- package/samples/logs.pretty.json +350 -0
- package/samples/medium.jot +1 -0
- package/samples/medium.json +1 -0
- package/samples/medium.pretty.jot +13 -0
- package/samples/medium.pretty.json +30 -0
- package/samples/metrics.jot +1 -0
- package/samples/metrics.json +1 -0
- package/samples/metrics.pretty.jot +11 -0
- package/samples/metrics.pretty.json +25 -0
- package/samples/package.jot +1 -0
- package/samples/package.json +1 -0
- package/samples/package.pretty.jot +18 -0
- package/samples/package.pretty.json +18 -0
- package/samples/products.jot +1 -0
- package/samples/products.json +1 -0
- package/samples/products.pretty.jot +69 -0
- package/samples/products.pretty.json +235 -0
- package/samples/routes.jot +1 -0
- package/samples/routes.json +1 -0
- package/samples/routes.pretty.jot +142 -0
- package/samples/routes.pretty.json +354 -0
- package/samples/small.jot +1 -0
- package/samples/small.json +1 -0
- package/samples/small.pretty.jot +8 -0
- package/samples/small.pretty.json +12 -0
- package/samples/users-50.jot +1 -0
- package/samples/users-50.json +1 -0
- package/samples/users-50.pretty.jot +53 -0
- package/samples/users-50.pretty.json +354 -0
package/jot.ts
ADDED
|
@@ -0,0 +1,650 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jot - JSON Optimized for Tokens
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* 1. Unquoted strings - only quote when necessary
|
|
6
|
+
* 2. Key folding - {a:{b:1}} => {a.b:1}
|
|
7
|
+
* 3. Tables - [{a:1},{a:2}] => {{:a;1;2}} with schema changes via :newschema;
|
|
8
|
+
*
|
|
9
|
+
* Pretty-print rules (compact = braces on content lines):
|
|
10
|
+
*
|
|
11
|
+
* Objects:
|
|
12
|
+
* - Single key: always compact `{ key: value }`
|
|
13
|
+
* - Multi-key, array item: compact `{ a: 1,\n b: 2 }` unless last value is multi-line
|
|
14
|
+
* - Multi-key, array item with multi-line last value: expanded
|
|
15
|
+
* - Multi-key, other: expanded `{\n a: 1,\n b: 2\n}`
|
|
16
|
+
*
|
|
17
|
+
* Arrays:
|
|
18
|
+
* - Empty: `[]`
|
|
19
|
+
* - Single item: compact `[item]`
|
|
20
|
+
* - 2+ simple items: spaced `[ a, b ]`
|
|
21
|
+
* - 2+ complex items: expanded `[\n ...,\n ...\n]`
|
|
22
|
+
*
|
|
23
|
+
* Tables:
|
|
24
|
+
* - Only when 2+ consecutive objects share schema
|
|
25
|
+
* - Otherwise use regular array syntax
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
const RESERVED_WORDS = new Set(["true", "false", "null"])
|
|
29
|
+
const UNSAFE_CHARS = [':', ',', '{', '}', '[', ']', '"', ';', '\\']
|
|
30
|
+
|
|
31
|
+
function needsQuotes(str: string, unsafeChars: string[]): boolean {
|
|
32
|
+
if (str === "" || str.trim() !== str) return true
|
|
33
|
+
if (RESERVED_WORDS.has(str)) return true
|
|
34
|
+
if (!isNaN(Number(str))) return true
|
|
35
|
+
if (unsafeChars.some(c => str.includes(c))) return true
|
|
36
|
+
if ([...str].some(c => c.charCodeAt(0) < 32)) return true
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const quoteString = (s: string) => needsQuotes(s, UNSAFE_CHARS) ? JSON.stringify(s) : s
|
|
41
|
+
const quoteKey = (s: string) => needsQuotes(s, [...UNSAFE_CHARS, '.']) ? JSON.stringify(s) : s
|
|
42
|
+
const needsKeyQuoting = (s: string) => needsQuotes(s, [...UNSAFE_CHARS, '.'])
|
|
43
|
+
|
|
44
|
+
function getObjectKeys(obj: object): string[] {
|
|
45
|
+
return Object.keys(obj)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Check if value is a foldable chain: single-key objects nested
|
|
49
|
+
// Skip folding if any key contains "." (would be ambiguous with fold syntax)
|
|
50
|
+
function getFoldPath(value: unknown): { path: string[], leaf: unknown } | null {
|
|
51
|
+
const path: string[] = []
|
|
52
|
+
let current = value
|
|
53
|
+
|
|
54
|
+
while (
|
|
55
|
+
current !== null &&
|
|
56
|
+
typeof current === "object" &&
|
|
57
|
+
!Array.isArray(current)
|
|
58
|
+
) {
|
|
59
|
+
const keys = getObjectKeys(current)
|
|
60
|
+
if (keys.length !== 1) break
|
|
61
|
+
const key = keys[0]
|
|
62
|
+
// Don't fold keys containing "." - they need quoting
|
|
63
|
+
if (key.includes(".")) break
|
|
64
|
+
path.push(key)
|
|
65
|
+
current = (current as Record<string, unknown>)[key]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (path.length < 1) return null
|
|
69
|
+
return { path, leaf: current }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if array is all objects (candidate for table)
|
|
73
|
+
function isAllObjects(arr: unknown[]): boolean {
|
|
74
|
+
return arr.length >= 2 && arr.every(item =>
|
|
75
|
+
item !== null && typeof item === "object" && !Array.isArray(item)
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Check if table format provides benefit (at least one schema reused)
|
|
80
|
+
function hasSchemaReuse(arr: Record<string, unknown>[]): boolean {
|
|
81
|
+
const groups = groupBySchema(arr)
|
|
82
|
+
return groups.some(g => g.objects.length >= 2)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Options for stringify
|
|
86
|
+
export interface StringifyOptions {
|
|
87
|
+
pretty?: boolean
|
|
88
|
+
indent?: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let currentOptions: StringifyOptions = { pretty: false, indent: " " }
|
|
92
|
+
let depth = 0
|
|
93
|
+
|
|
94
|
+
function ind(): string {
|
|
95
|
+
return currentOptions.pretty ? currentOptions.indent!.repeat(depth) : ""
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// atLineStart: true when value will be at start of a line (array items), false when after key:
|
|
99
|
+
function stringifyValue(value: unknown, atLineStart = false): string {
|
|
100
|
+
if (value === null) return "null"
|
|
101
|
+
if (value === true) return "true"
|
|
102
|
+
if (value === false) return "false"
|
|
103
|
+
if (typeof value === "number") return String(value)
|
|
104
|
+
if (typeof value === "string") return quoteString(value)
|
|
105
|
+
|
|
106
|
+
if (Array.isArray(value)) {
|
|
107
|
+
return stringifyArray(value)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (typeof value === "object") {
|
|
111
|
+
return stringifyObject(value as Record<string, unknown>, atLineStart)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return String(value)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function hasComplexItems(arr: unknown[]): boolean {
|
|
118
|
+
return arr.some(item => item !== null && typeof item === "object")
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Group consecutive objects by their schema (sorted key list)
|
|
122
|
+
function groupBySchema(arr: Record<string, unknown>[]): { keys: string[], objects: Record<string, unknown>[] }[] {
|
|
123
|
+
const groups: { keys: string[], objects: Record<string, unknown>[] }[] = []
|
|
124
|
+
|
|
125
|
+
for (const obj of arr) {
|
|
126
|
+
const keys = getObjectKeys(obj)
|
|
127
|
+
const keyStr = keys.join(",")
|
|
128
|
+
|
|
129
|
+
// Check if matches current group
|
|
130
|
+
if (groups.length > 0) {
|
|
131
|
+
const lastGroup = groups[groups.length - 1]
|
|
132
|
+
if (lastGroup.keys.join(",") === keyStr) {
|
|
133
|
+
lastGroup.objects.push(obj)
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Start new group
|
|
139
|
+
groups.push({ keys, objects: [obj] })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return groups
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function stringifyArray(arr: unknown[]): string {
|
|
146
|
+
// Check for object array with schema reuse - use table format
|
|
147
|
+
if (isAllObjects(arr) && hasSchemaReuse(arr as Record<string, unknown>[])) {
|
|
148
|
+
return stringifyTable(arr as Record<string, unknown>[])
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Single-item arrays: compact
|
|
152
|
+
if (arr.length === 1) {
|
|
153
|
+
return `[${stringifyValue(arr[0])}]`
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Regular array formatting
|
|
157
|
+
if (currentOptions.pretty && arr.length > 0 && hasComplexItems(arr)) {
|
|
158
|
+
depth++
|
|
159
|
+
const items = arr.map(item => `${ind()}${stringifyValue(item, true)}`)
|
|
160
|
+
depth--
|
|
161
|
+
// Expanded format for 2+ items: ] on own line
|
|
162
|
+
return `[\n${items.join(",\n")}\n${ind()}]`
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const sep = currentOptions.pretty ? ", " : ","
|
|
166
|
+
const items = arr.map(stringifyValue).join(sep)
|
|
167
|
+
return currentOptions.pretty ? `[ ${items} ]` : `[${items}]`
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function stringifyTable(arr: Record<string, unknown>[]): string {
|
|
171
|
+
const groups = groupBySchema(arr)
|
|
172
|
+
const sep = currentOptions.pretty ? ", " : ","
|
|
173
|
+
|
|
174
|
+
if (currentOptions.pretty) {
|
|
175
|
+
depth++
|
|
176
|
+
const schemaInd = ind() // 1 level for schema rows
|
|
177
|
+
depth++
|
|
178
|
+
const dataInd = ind() // 2 levels for data rows
|
|
179
|
+
const rows: string[] = []
|
|
180
|
+
|
|
181
|
+
for (const group of groups) {
|
|
182
|
+
// Schema row
|
|
183
|
+
rows.push(schemaInd + `:${group.keys.map(k => quoteKey(k)).join(sep)}`)
|
|
184
|
+
// Data rows - stringify with depth at 2 levels
|
|
185
|
+
for (const obj of group.objects) {
|
|
186
|
+
rows.push(dataInd + group.keys.map(k => stringifyValue(obj[k])).join(sep))
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
depth -= 2
|
|
191
|
+
return `{{\n${rows.join("\n")}\n${ind()}}}`
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Non-pretty mode
|
|
195
|
+
const parts: string[] = []
|
|
196
|
+
for (const group of groups) {
|
|
197
|
+
parts.push(`:${group.keys.map(k => quoteKey(k)).join(sep)}`)
|
|
198
|
+
for (const obj of group.objects) {
|
|
199
|
+
parts.push(group.keys.map(k => stringifyValue(obj[k])).join(sep))
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return `{{${parts.join(";")}}}`
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function stringifyObject(obj: Record<string, unknown>, atLineStart = false): string {
|
|
206
|
+
const keys = getObjectKeys(obj)
|
|
207
|
+
|
|
208
|
+
const stringifyPair = (k: string, forPretty: boolean): string => {
|
|
209
|
+
const val = obj[k]
|
|
210
|
+
const quotedKey = quoteKey(k)
|
|
211
|
+
// Try to fold (only if key doesn't need quoting - has no special chars)
|
|
212
|
+
if (!needsKeyQuoting(k) && val !== null && typeof val === "object" && !Array.isArray(val)) {
|
|
213
|
+
const fold = getFoldPath(val)
|
|
214
|
+
if (fold) {
|
|
215
|
+
const foldedKey = `${k}.${fold.path.join(".")}`
|
|
216
|
+
if (forPretty) {
|
|
217
|
+
// Value after key: is not at line start
|
|
218
|
+
return `${foldedKey}: ${stringifyValue(fold.leaf, false)}`
|
|
219
|
+
}
|
|
220
|
+
return `${foldedKey}:${stringifyValue(fold.leaf)}`
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (forPretty) {
|
|
224
|
+
// Value after key: is not at line start
|
|
225
|
+
return `${quotedKey}: ${stringifyValue(val, false)}`
|
|
226
|
+
}
|
|
227
|
+
return `${quotedKey}:${stringifyValue(val)}`
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (currentOptions.pretty && keys.length > 1) {
|
|
231
|
+
depth++
|
|
232
|
+
// First, stringify all pairs to check if last is multi-line
|
|
233
|
+
const rawPairs = keys.map(k => stringifyPair(k, true))
|
|
234
|
+
const lastIsMultiLine = rawPairs[rawPairs.length - 1].endsWith('}') ||
|
|
235
|
+
rawPairs[rawPairs.length - 1].endsWith(']')
|
|
236
|
+
|
|
237
|
+
// Array items with simple last value: compact format
|
|
238
|
+
const useCompact = atLineStart && !lastIsMultiLine
|
|
239
|
+
|
|
240
|
+
const pairs: string[] = []
|
|
241
|
+
for (let i = 0; i < keys.length; i++) {
|
|
242
|
+
if (i === 0 && useCompact) {
|
|
243
|
+
// First pair on same line as { - no indent
|
|
244
|
+
pairs.push(rawPairs[i])
|
|
245
|
+
} else {
|
|
246
|
+
pairs.push(`${ind()}${rawPairs[i]}`)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
depth--
|
|
250
|
+
|
|
251
|
+
if (useCompact) {
|
|
252
|
+
return `{ ${pairs.join(",\n")} }`
|
|
253
|
+
}
|
|
254
|
+
// Expanded format: newlines for open and close
|
|
255
|
+
return `{\n${pairs.join(",\n")}\n${ind()}}`
|
|
256
|
+
}
|
|
257
|
+
if (currentOptions.pretty && keys.length === 1) {
|
|
258
|
+
return `{ ${stringifyPair(keys[0], true)} }`
|
|
259
|
+
}
|
|
260
|
+
const pairs = keys.map(k => stringifyPair(k, false))
|
|
261
|
+
return `{${pairs.join(",")}}`
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function stringify(data: unknown, options: StringifyOptions = {}): string {
|
|
265
|
+
currentOptions = { pretty: false, indent: " ", ...options }
|
|
266
|
+
depth = 0
|
|
267
|
+
return stringifyValue(data)
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ============ PARSER ============
|
|
271
|
+
|
|
272
|
+
class JotParser {
|
|
273
|
+
private pos = 0
|
|
274
|
+
|
|
275
|
+
constructor(private input: string) {}
|
|
276
|
+
|
|
277
|
+
parse(): unknown {
|
|
278
|
+
this.skipWhitespace()
|
|
279
|
+
const result = this.parseValue("")
|
|
280
|
+
this.skipWhitespace()
|
|
281
|
+
if (this.pos < this.input.length) {
|
|
282
|
+
throw new Error(`Unexpected character at position ${this.pos}: '${this.input[this.pos]}'`)
|
|
283
|
+
}
|
|
284
|
+
return result
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private skipWhitespace(): void {
|
|
288
|
+
while (this.pos < this.input.length && /\s/.test(this.input[this.pos])) {
|
|
289
|
+
this.pos++
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private peek(): string {
|
|
294
|
+
return this.input[this.pos] || ""
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
private parseValue(terminators = ""): unknown {
|
|
298
|
+
this.skipWhitespace()
|
|
299
|
+
const ch = this.peek()
|
|
300
|
+
|
|
301
|
+
if (ch === "{") {
|
|
302
|
+
if (this.input[this.pos + 1] === "{") return this.parseTable()
|
|
303
|
+
return this.parseObject()
|
|
304
|
+
}
|
|
305
|
+
if (ch === "[") return this.parseArray()
|
|
306
|
+
if (ch === '"') return this.parseQuotedString()
|
|
307
|
+
|
|
308
|
+
return this.parseAtom(terminators)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
private parseQuotedString(): string {
|
|
312
|
+
if (this.peek() !== '"') {
|
|
313
|
+
throw new Error(`Expected '"' at position ${this.pos}`)
|
|
314
|
+
}
|
|
315
|
+
this.pos++
|
|
316
|
+
|
|
317
|
+
let result = ""
|
|
318
|
+
while (this.pos < this.input.length) {
|
|
319
|
+
const ch = this.input[this.pos]
|
|
320
|
+
if (ch === '"') {
|
|
321
|
+
this.pos++
|
|
322
|
+
return result
|
|
323
|
+
}
|
|
324
|
+
if (ch === "\\") {
|
|
325
|
+
this.pos++
|
|
326
|
+
if (this.pos >= this.input.length) {
|
|
327
|
+
throw new Error("Unexpected end of input in string escape")
|
|
328
|
+
}
|
|
329
|
+
const escaped = this.input[this.pos]
|
|
330
|
+
switch (escaped) {
|
|
331
|
+
case '"': result += '"'; break
|
|
332
|
+
case "\\": result += "\\"; break
|
|
333
|
+
case "/": result += "/"; break
|
|
334
|
+
case "b": result += "\b"; break
|
|
335
|
+
case "f": result += "\f"; break
|
|
336
|
+
case "n": result += "\n"; break
|
|
337
|
+
case "r": result += "\r"; break
|
|
338
|
+
case "t": result += "\t"; break
|
|
339
|
+
case "u": {
|
|
340
|
+
if (this.pos + 4 >= this.input.length) {
|
|
341
|
+
throw new Error("Invalid unicode escape")
|
|
342
|
+
}
|
|
343
|
+
const hex = this.input.slice(this.pos + 1, this.pos + 5)
|
|
344
|
+
result += String.fromCharCode(parseInt(hex, 16))
|
|
345
|
+
this.pos += 4
|
|
346
|
+
break
|
|
347
|
+
}
|
|
348
|
+
default:
|
|
349
|
+
throw new Error(`Invalid escape sequence '\\${escaped}'`)
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
result += ch
|
|
353
|
+
}
|
|
354
|
+
this.pos++
|
|
355
|
+
}
|
|
356
|
+
throw new Error("Unterminated string")
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
private parseAtom(terminators: string): unknown {
|
|
360
|
+
const start = this.pos
|
|
361
|
+
|
|
362
|
+
if (terminators === "") {
|
|
363
|
+
const token = this.input.slice(start).trim()
|
|
364
|
+
this.pos = this.input.length
|
|
365
|
+
|
|
366
|
+
if (token === "") {
|
|
367
|
+
throw new Error(`Unexpected end of input at position ${start}`)
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (token === "null") return null
|
|
371
|
+
if (token === "true") return true
|
|
372
|
+
if (token === "false") return false
|
|
373
|
+
|
|
374
|
+
const num = Number(token)
|
|
375
|
+
if (!isNaN(num)) return num
|
|
376
|
+
|
|
377
|
+
return token
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
while (this.pos < this.input.length) {
|
|
381
|
+
const ch = this.input[this.pos]
|
|
382
|
+
if (terminators.includes(ch)) break
|
|
383
|
+
this.pos++
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const token = this.input.slice(start, this.pos).trim()
|
|
387
|
+
if (token === "") {
|
|
388
|
+
throw new Error(`Unexpected character at position ${this.pos}: '${this.peek()}'`)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (token === "null") return null
|
|
392
|
+
if (token === "true") return true
|
|
393
|
+
if (token === "false") return false
|
|
394
|
+
|
|
395
|
+
const num = Number(token)
|
|
396
|
+
if (!isNaN(num) && token !== "") return num
|
|
397
|
+
|
|
398
|
+
return token
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
private parseArray(): unknown[] {
|
|
402
|
+
if (this.peek() !== "[") {
|
|
403
|
+
throw new Error(`Expected '[' at position ${this.pos}`)
|
|
404
|
+
}
|
|
405
|
+
this.pos++
|
|
406
|
+
|
|
407
|
+
const result: unknown[] = []
|
|
408
|
+
this.skipWhitespace()
|
|
409
|
+
|
|
410
|
+
while (this.peek() !== "]") {
|
|
411
|
+
if (this.pos >= this.input.length) {
|
|
412
|
+
throw new Error("Unterminated array")
|
|
413
|
+
}
|
|
414
|
+
const item = this.parseValue(",]")
|
|
415
|
+
result.push(item)
|
|
416
|
+
this.skipWhitespace()
|
|
417
|
+
if (this.peek() === ",") {
|
|
418
|
+
this.pos++
|
|
419
|
+
this.skipWhitespace()
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
this.pos++
|
|
424
|
+
return result
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Parse table: {{:schema;row;row;:newschema;row}}
|
|
428
|
+
private parseTable(): unknown[] {
|
|
429
|
+
if (this.input.slice(this.pos, this.pos + 2) !== "{{") {
|
|
430
|
+
throw new Error(`Expected '{{' at position ${this.pos}`)
|
|
431
|
+
}
|
|
432
|
+
this.pos += 2
|
|
433
|
+
|
|
434
|
+
const result: Record<string, unknown>[] = []
|
|
435
|
+
let currentSchema: string[] = []
|
|
436
|
+
this.skipWhitespace()
|
|
437
|
+
|
|
438
|
+
while (this.input.slice(this.pos, this.pos + 2) !== "}}") {
|
|
439
|
+
if (this.pos >= this.input.length) {
|
|
440
|
+
throw new Error("Unterminated table")
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
this.skipWhitespace()
|
|
444
|
+
|
|
445
|
+
// Check for schema row (starts with :)
|
|
446
|
+
if (this.peek() === ":") {
|
|
447
|
+
this.pos++ // skip :
|
|
448
|
+
currentSchema = this.parseSchemaRow()
|
|
449
|
+
} else {
|
|
450
|
+
// Data row
|
|
451
|
+
if (currentSchema.length === 0) {
|
|
452
|
+
throw new Error(`Data row without schema at position ${this.pos}`)
|
|
453
|
+
}
|
|
454
|
+
const values = this.parseDataRow(currentSchema.length)
|
|
455
|
+
const obj: Record<string, unknown> = {}
|
|
456
|
+
for (let i = 0; i < currentSchema.length; i++) {
|
|
457
|
+
obj[currentSchema[i]] = values[i]
|
|
458
|
+
}
|
|
459
|
+
result.push(obj)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
this.skipWhitespace()
|
|
463
|
+
if (this.peek() === ";") {
|
|
464
|
+
this.pos++
|
|
465
|
+
this.skipWhitespace()
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
this.pos += 2 // skip }}
|
|
470
|
+
return result
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private parseSchemaRow(): string[] {
|
|
474
|
+
const cols: string[] = []
|
|
475
|
+
let col = ""
|
|
476
|
+
|
|
477
|
+
while (this.pos < this.input.length) {
|
|
478
|
+
const ch = this.input[this.pos]
|
|
479
|
+
if (ch === "}" && this.input[this.pos + 1] === "}") {
|
|
480
|
+
if (col.trim()) cols.push(col.trim())
|
|
481
|
+
break
|
|
482
|
+
}
|
|
483
|
+
if (ch === ";" || ch === "\n") {
|
|
484
|
+
if (col.trim()) cols.push(col.trim())
|
|
485
|
+
break
|
|
486
|
+
}
|
|
487
|
+
if (ch === ",") {
|
|
488
|
+
if (col.trim()) cols.push(col.trim())
|
|
489
|
+
col = ""
|
|
490
|
+
this.pos++
|
|
491
|
+
continue
|
|
492
|
+
}
|
|
493
|
+
col += ch
|
|
494
|
+
this.pos++
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return cols
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
private parseDataRow(colCount: number): unknown[] {
|
|
501
|
+
const values: unknown[] = []
|
|
502
|
+
|
|
503
|
+
for (let i = 0; i < colCount; i++) {
|
|
504
|
+
this.skipWhitespace()
|
|
505
|
+
const terminators = i < colCount - 1 ? ",;}\n" : ";}\n"
|
|
506
|
+
const value = this.parseTableValue(terminators)
|
|
507
|
+
values.push(value)
|
|
508
|
+
this.skipWhitespace()
|
|
509
|
+
if (this.peek() === ",") {
|
|
510
|
+
this.pos++
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
return values
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
private parseTableValue(terminators: string): unknown {
|
|
518
|
+
this.skipWhitespace()
|
|
519
|
+
const ch = this.peek()
|
|
520
|
+
|
|
521
|
+
if (ch === '"') return this.parseQuotedString()
|
|
522
|
+
if (ch === "{") {
|
|
523
|
+
if (this.input[this.pos + 1] === "{") return this.parseTable()
|
|
524
|
+
return this.parseObject()
|
|
525
|
+
}
|
|
526
|
+
if (ch === "[") return this.parseArray()
|
|
527
|
+
|
|
528
|
+
const start = this.pos
|
|
529
|
+
while (this.pos < this.input.length) {
|
|
530
|
+
const c = this.input[this.pos]
|
|
531
|
+
if (c === "}" && this.input[this.pos + 1] === "}") break
|
|
532
|
+
if (terminators.includes(c)) break
|
|
533
|
+
this.pos++
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
const token = this.input.slice(start, this.pos).trim()
|
|
537
|
+
if (token === "") return null
|
|
538
|
+
|
|
539
|
+
if (token === "null") return null
|
|
540
|
+
if (token === "true") return true
|
|
541
|
+
if (token === "false") return false
|
|
542
|
+
|
|
543
|
+
const num = Number(token)
|
|
544
|
+
if (!isNaN(num)) return num
|
|
545
|
+
|
|
546
|
+
return token
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
private parseObject(): Record<string, unknown> {
|
|
550
|
+
if (this.peek() !== "{") {
|
|
551
|
+
throw new Error(`Expected '{' at position ${this.pos}`)
|
|
552
|
+
}
|
|
553
|
+
this.pos++
|
|
554
|
+
|
|
555
|
+
const result: Record<string, unknown> = {}
|
|
556
|
+
this.skipWhitespace()
|
|
557
|
+
|
|
558
|
+
while (this.peek() !== "}") {
|
|
559
|
+
if (this.pos >= this.input.length) {
|
|
560
|
+
throw new Error("Unterminated object")
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const { key: keyPath, quoted } = this.parseKey()
|
|
564
|
+
this.skipWhitespace()
|
|
565
|
+
|
|
566
|
+
if (this.peek() !== ":") {
|
|
567
|
+
throw new Error(`Expected ':' after key '${keyPath}' at position ${this.pos}`)
|
|
568
|
+
}
|
|
569
|
+
this.pos++
|
|
570
|
+
|
|
571
|
+
const value = this.parseValue(",}")
|
|
572
|
+
|
|
573
|
+
if (quoted) {
|
|
574
|
+
result[keyPath] = value
|
|
575
|
+
} else {
|
|
576
|
+
const unfolded = this.unfoldKey(keyPath, value)
|
|
577
|
+
this.mergeObjects(result, unfolded)
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
this.skipWhitespace()
|
|
581
|
+
if (this.peek() === ",") {
|
|
582
|
+
this.pos++
|
|
583
|
+
this.skipWhitespace()
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
this.pos++
|
|
588
|
+
return result
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
private parseKey(): { key: string; quoted: boolean } {
|
|
592
|
+
this.skipWhitespace()
|
|
593
|
+
|
|
594
|
+
if (this.peek() === '"') {
|
|
595
|
+
return { key: this.parseQuotedString(), quoted: true }
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const start = this.pos
|
|
599
|
+
while (this.pos < this.input.length) {
|
|
600
|
+
const ch = this.input[this.pos]
|
|
601
|
+
if (/[:\,{}\[\];]/.test(ch) || /\s/.test(ch)) break
|
|
602
|
+
this.pos++
|
|
603
|
+
}
|
|
604
|
+
const key = this.input.slice(start, this.pos)
|
|
605
|
+
if (key === "") {
|
|
606
|
+
throw new Error(`Expected key at position ${this.pos}`)
|
|
607
|
+
}
|
|
608
|
+
return { key, quoted: false }
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private unfoldKey(keyPath: string, value: unknown): Record<string, unknown> {
|
|
612
|
+
const parts = keyPath.split(".")
|
|
613
|
+
let result: Record<string, unknown> = {}
|
|
614
|
+
let current = result
|
|
615
|
+
|
|
616
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
617
|
+
const nested: Record<string, unknown> = {}
|
|
618
|
+
current[parts[i]] = nested
|
|
619
|
+
current = nested
|
|
620
|
+
}
|
|
621
|
+
current[parts[parts.length - 1]] = value
|
|
622
|
+
|
|
623
|
+
return result
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private mergeObjects(target: Record<string, unknown>, src: Record<string, unknown>): void {
|
|
627
|
+
for (const key of Object.keys(src)) {
|
|
628
|
+
if (
|
|
629
|
+
key in target &&
|
|
630
|
+
typeof target[key] === "object" &&
|
|
631
|
+
target[key] !== null &&
|
|
632
|
+
!Array.isArray(target[key]) &&
|
|
633
|
+
typeof src[key] === "object" &&
|
|
634
|
+
src[key] !== null &&
|
|
635
|
+
!Array.isArray(src[key])
|
|
636
|
+
) {
|
|
637
|
+
this.mergeObjects(
|
|
638
|
+
target[key] as Record<string, unknown>,
|
|
639
|
+
src[key] as Record<string, unknown>
|
|
640
|
+
)
|
|
641
|
+
} else {
|
|
642
|
+
target[key] = src[key]
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export function parse(input: string): unknown {
|
|
649
|
+
return new JotParser(input).parse()
|
|
650
|
+
}
|
package/package.json
ADDED
package/samples/chat.jot
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{messages:{{:role,content;user,How do I configure TypeScript for a monorepo?;assistant,"For a monorepo setup, create a base tsconfig.json at the root with shared settings, then extend it in each package.";user,What about path aliases?}}}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"messages":[{"role":"user","content":"How do I configure TypeScript for a monorepo?"},{"role":"assistant","content":"For a monorepo setup, create a base tsconfig.json at the root with shared settings, then extend it in each package."},{"role":"user","content":"What about path aliases?"}]}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"messages": [
|
|
3
|
+
{
|
|
4
|
+
"role": "user",
|
|
5
|
+
"content": "How do I configure TypeScript for a monorepo?"
|
|
6
|
+
},
|
|
7
|
+
{
|
|
8
|
+
"role": "assistant",
|
|
9
|
+
"content": "For a monorepo setup, create a base tsconfig.json at the root with shared settings, then extend it in each package."
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"role": "user",
|
|
13
|
+
"content": "What about path aliases?"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{active:true,ruleGroups:{php_attacks:{active:true,action:deny,rules:{php_suffix:{has:[{type:path,key:null,value.suf:.php}],missing:[{type:header,key:x-secret,value.eq:bypass}],phase:finalize,system:true},jsp_suffix:{has:[{type:path,key:null,value.suf:.jsp},{type:status,value.eq:404}],missing:[{type:header,key:x-secret,value.eq:bypass}],phase:init,system:true},asp_suffix:{has:[{type:path,key:null,value.suf:.asp}],missing:[{type:header,key:x-secret,value.eq:bypass}],phase:finalize,system:true},cgi_suffix:{has:[{type:path,key:null,value.suf:.cgi}],missing:[{type:header,key:x-secret,value.eq:bypass}],phase:finalize,system:true}}},sql_injection:{active:true,action:deny,rules:{union_select:{has:[{type:query,key:null,value.contains:UNION SELECT}],phase:init,system:true},drop_table:{has:[{type:query,key:null,value.contains:DROP TABLE}],phase:init,system:true},or_1_equals_1:{has:[{type:query,key:null,value.regex:"OR\\s+1\\s*=\\s*1"}],phase:init,system:true}}},rate_limiting:{active:true,action:ratelimit,rules:{api_endpoints:{has:[{type:path,key:null,value.pre:/api/}],rateLimit:{requests:100,window:60,key:ip},phase:init,system:false},auth_endpoints:{has:[{type:path,key:null,value.pre:/auth/}],rateLimit:{requests:10,window:60,key:ip},phase:init,system:false},search_endpoint:{has:[{type:path,key:null,value.eq:/search}],rateLimit:{requests:30,window:60,key:ip},phase:init,system:false}}},geo_blocking:{active:false,action:deny,rules.blocked_countries:{has:[{type:geo,key:country,value.in:[XX,YY,ZZ]}],missing:[{type:header,key:x-bypass-geo,value.eq:allowed}],phase:init,system:false}},bot_protection:{active:true,action:challenge,rules:{missing_ua:{missing:[{type:header,key:user-agent,value.exists:true}],phase:init,system:true},known_bad_bots:{has:[{type:header,key:user-agent,value.regex:(BadBot|Scraper|Harvester)}],phase:init,system:true},suspicious_rate:{has:{{:type,key,value;rate,ip,{gt:50};header,user-agent,{regex:^Mozilla}}},phase:finalize,system:false}}},custom_rules:{active:true,action:log,rules:{admin_access:{has:[{type:path,key:null,value.pre:/admin}],missing:[{type:header,key:x-admin-token,value.exists:true}],phase:init,system:false},api_key_required:{has:[{type:path,key:null,value.pre:/api/v2/}],missing:[{type:header,key:x-api-key,value.exists:true}],phase:init,system:false}}}}}
|