diff --git a/package-lock.json b/package-lock.json index 4dde0ea..5b96e37 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", + "json-stringify-pretty-compact": "^4.0.0", "monaco-editor": "^0.54.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/package.json b/package.json index ac1c658..bb335ff 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "dependencies": { "@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-sans": "^5.2.8", + "json-stringify-pretty-compact": "^4.0.0", "monaco-editor": "^0.54.0", "react": "^19.2.7", "react-dom": "^19.2.7", diff --git a/src/app/components/SpecEditor.tsx b/src/app/components/SpecEditor.tsx index 0eb9f43..a3f7675 100644 --- a/src/app/components/SpecEditor.tsx +++ b/src/app/components/SpecEditor.tsx @@ -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 '../infrastructure/monaco-env'; // side-effect: wire workers before create import { configureVegaLiteJson } from '../infrastructure/monaco-schema'; +import { configureJsonFormatter, installFormatOnPaste } from '../infrastructure/monaco-format'; import { openModal } from '../modals/ModalCoordinator'; import { useAppStore } from '../stores/AppStore'; import { confirm } from '../stores/ConfirmStore'; @@ -134,6 +135,8 @@ function EditorSettings() { // Register the bundled Vega-Lite schema once: resolves `$schema` locally (no // network warning) and powers validation, autocomplete, and hover docs. configureVegaLiteJson(); +// Register the compact JSON formatter once (Format Document + format-on-paste, §03A). +configureJsonFormatter(); function EditorToolbar() { 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 → // "bind listeners in exactly one place"), which publishes before the // interactive-context gate so it works while the editor has focus. Monaco @@ -274,6 +282,7 @@ export function SpecEditor() { return () => { sub.dispose(); + pasteSub.dispose(); editor.dispose(); editorRef.current = null; }; diff --git a/src/app/infrastructure/monaco-format.ts b/src/app/infrastructure/monaco-format.ts new file mode 100644 index 0000000..2922874 --- /dev/null +++ b/src/app/infrastructure/monaco-format.ts @@ -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 }]); + }); +} diff --git a/src/core/json-format.test.ts b/src/core/json-format.test.ts new file mode 100644 index 0000000..3fd6128 --- /dev/null +++ b/src/core/json-format.test.ts @@ -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'); + }); +}); diff --git a/src/core/json-format.ts b/src/core/json-format.ts new file mode 100644 index 0000000..9553331 --- /dev/null +++ b/src/core/json-format.ts @@ -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, + }); +}