mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Add compact JSON formatter with format-on-paste (§03A)
This commit is contained in:
Generated
+1
@@ -10,6 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||||
|
"json-stringify-pretty-compact": "^4.0.0",
|
||||||
"monaco-editor": "^0.54.0",
|
"monaco-editor": "^0.54.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7",
|
"react-dom": "^19.2.7",
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
|||||||
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
import 'monaco-editor/esm/vs/language/json/monaco.contribution';
|
||||||
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
|
import '../infrastructure/monaco-env'; // side-effect: wire workers before create
|
||||||
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
import { configureVegaLiteJson } from '../infrastructure/monaco-schema';
|
||||||
|
import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format';
|
||||||
import { openModal } from '../modals/ModalCoordinator';
|
import { openModal } from '../modals/ModalCoordinator';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { confirm } from '../stores/ConfirmStore';
|
import { confirm } from '../stores/ConfirmStore';
|
||||||
@@ -134,6 +135,8 @@ function EditorSettings() {
|
|||||||
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
|
// Register the bundled Vega-Lite schema once: resolves `$schema` locally (no
|
||||||
// network warning) and powers validation, autocomplete, and hover docs.
|
// network warning) and powers validation, autocomplete, and hover docs.
|
||||||
configureVegaLiteJson();
|
configureVegaLiteJson();
|
||||||
|
// Register the compact JSON formatter once (Format Document + format-on-paste, §03A).
|
||||||
|
configureJsonFormatter();
|
||||||
|
|
||||||
function EditorToolbar() {
|
function EditorToolbar() {
|
||||||
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
const activeId = useSnippetStore((s) => s.activeSnippetId);
|
||||||
@@ -267,6 +270,11 @@ export function SpecEditor() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Pasting reindents the whole spec to keep it consistently formatted (§03A).
|
||||||
|
// The reformat fires onDidChangeModelContent above, so the draft buffer picks
|
||||||
|
// up the formatted text. No-op on the read-only published view / invalid JSON.
|
||||||
|
const pasteSub = installFormatOnPaste(editor);
|
||||||
|
|
||||||
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
// Cmd/Ctrl+S is owned globally by the EventRouter (docs/architecture/04 →
|
||||||
// "bind listeners in exactly one place"), which publishes before the
|
// "bind listeners in exactly one place"), which publishes before the
|
||||||
// interactive-context gate so it works while the editor has focus. Monaco
|
// interactive-context gate so it works while the editor has focus. Monaco
|
||||||
@@ -274,6 +282,7 @@ export function SpecEditor() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
sub.dispose();
|
sub.dispose();
|
||||||
|
pasteSub.dispose();
|
||||||
editor.dispose();
|
editor.dispose();
|
||||||
editorRef.current = null;
|
editorRef.current = null;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Monaco JSON formatting (spec §03A; docs/architecture/08 → compact formatter).
|
||||||
|
*
|
||||||
|
* Two pieces, both backed by the portable core pretty-printer (`core/json-format`):
|
||||||
|
* - `configureJsonFormatter()` registers a document-formatting provider so
|
||||||
|
* "Format Document" (Shift+Alt+F) produces Vega's compact style.
|
||||||
|
* - `installFormatOnPaste(editor)` reindents the whole document after a paste,
|
||||||
|
* satisfying the spec's "pasting … triggers automatic reformatting".
|
||||||
|
*
|
||||||
|
* Both no-op when the document is not valid JSON, so a partial/in-progress paste
|
||||||
|
* is never mangled (mirrors auto-save's "skip when unparseable" rule). Paste
|
||||||
|
* handling is wired directly via `onDidPaste` rather than Monaco's `formatOnPaste`
|
||||||
|
* option so it reformats the entire spec (not just the pasted range) and does not
|
||||||
|
* depend on which registered formatter Monaco happens to pick.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as monaco from 'monaco-editor/esm/vs/editor/edcore.main';
|
||||||
|
import { formatJson } from '@core/json-format';
|
||||||
|
|
||||||
|
let registered = false;
|
||||||
|
|
||||||
|
/** Register the compact JSON document formatter once. Idempotent. */
|
||||||
|
export function configureJsonFormatter(): void {
|
||||||
|
if (registered) return;
|
||||||
|
registered = true;
|
||||||
|
monaco.languages.registerDocumentFormattingEditProvider('json', {
|
||||||
|
provideDocumentFormattingEdits: (model, options) => {
|
||||||
|
const current = model.getValue();
|
||||||
|
const formatted = formatJson(current, { indent: options.tabSize });
|
||||||
|
if (formatted === null || formatted === current) return [];
|
||||||
|
return [{ range: model.getFullModelRange(), text: formatted }];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reformat the whole document after each paste. Returns a disposable; callers
|
||||||
|
* dispose it when the editor unmounts.
|
||||||
|
*/
|
||||||
|
export function installFormatOnPaste(
|
||||||
|
editor: monaco.editor.IStandaloneCodeEditor,
|
||||||
|
): monaco.IDisposable {
|
||||||
|
return editor.onDidPaste(() => {
|
||||||
|
const model = editor.getModel();
|
||||||
|
if (!model) return;
|
||||||
|
const current = model.getValue();
|
||||||
|
const formatted = formatJson(current, { indent: model.getOptions().tabSize });
|
||||||
|
if (formatted === null || formatted === current) return;
|
||||||
|
editor.executeEdits('format-on-paste', [{ range: model.getFullModelRange(), text: formatted }]);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user