Add compact JSON formatter with format-on-paste (§03A)

This commit is contained in:
2026-06-07 18:45:17 +03:00
parent a62e2c6128
commit 9f7bf27b7a
6 changed files with 148 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { formatJson } from './json-format';
describe('formatJson', () => {
it('pretty-prints a valid object with the default indent', () => {
expect(formatJson('{"a":1,"b":2}')).toBe('{"a": 1, "b": 2}');
});
it('keeps short arrays compact (Vega house style)', () => {
expect(formatJson('{"values":[1,2,3]}')).toBe('{"values": [1, 2, 3]}');
});
it('wraps content that exceeds the line-length budget', () => {
expect(formatJson('{"a":1,"b":2,"c":3}', { maxLength: 5 })).toBe(
'{\n "a": 1,\n "b": 2,\n "c": 3\n}',
);
});
it('respects a custom indent width', () => {
const out = formatJson('{"a":1,"b":2}', { indent: 4, maxLength: 5 });
expect(out).toBe('{\n "a": 1,\n "b": 2\n}');
});
it('returns null for invalid JSON so callers can skip', () => {
expect(formatJson('{"a":')).toBeNull();
expect(formatJson('not json')).toBeNull();
});
it('returns null for empty or whitespace-only input', () => {
expect(formatJson('')).toBeNull();
expect(formatJson(' \n ')).toBeNull();
});
it('is idempotent — formatting formatted text changes nothing', () => {
const once = formatJson('{"a":1,"b":[1,2,3],"c":{"d":4}}');
expect(once).not.toBeNull();
expect(formatJson(once!)).toBe(once);
});
it('handles top-level arrays and primitives', () => {
expect(formatJson('[1,2,3]')).toBe('[1, 2, 3]');
expect(formatJson('42')).toBe('42');
expect(formatJson('true')).toBe('true');
});
});
+41
View File
@@ -0,0 +1,41 @@
/**
* JSON pretty-printer for Vega-Lite specs (spec §03A; docs/architecture/08 →
* "compact formatter").
*
* Uses `json-stringify-pretty-compact` for Vega's house style — arrays/objects
* stay on one line until they exceed a width budget, then wrap — which reads far
* better for VL specs than `JSON.stringify(…, null, 2)`. Pure and portable: no
* Monaco, no browser. The Monaco formatting adapter
* (`infrastructure/monaco-format`) is a thin wrapper over this.
*/
import stringify from 'json-stringify-pretty-compact';
/** Width budget before an array/object wraps onto multiple lines. */
export const DEFAULT_MAX_LINE = 80;
/** Default indent (spaces) — matches the editor's default tab size (spec §07). */
const DEFAULT_INDENT = 2;
/**
* Reformat `text` as consistently-indented JSON, or return `null` when it is not
* valid JSON. Returning `null` (rather than throwing) lets callers skip a no-op
* rather than mangle an in-progress edit, mirroring auto-save's "skip when
* unparseable" rule (spec §03B). The result is idempotent: formatting
* already-formatted text returns it unchanged.
*/
export function formatJson(
text: string,
opts: { indent?: number; maxLength?: number } = {},
): string | null {
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return null;
}
return stringify(parsed, {
indent: opts.indent ?? DEFAULT_INDENT,
maxLength: opts.maxLength ?? DEFAULT_MAX_LINE,
});
}