Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

[![image](https://github.com/user-attachments/assets/6be1cecf-e0d9-4597-ab04-7124e37e332d)](https://json.ophir.dev)

A React component library for building and editing JSON Schema with a visual UI, a JSON source editor, schema inference, and JSON validation.
A React component library for building and editing JSON Schema with a visual UI, a YAML/JSON source editor, schema inference, and JSON validation.

**Try online**: https://json.ophir.dev

Expand Down Expand Up @@ -30,7 +30,7 @@ import "jsonjoy-builder/styles.css";

## Basic Usage

Use `SchemaBuilder` when you want the full editor: visual editing on one side and editable JSON source on the other.
Use `SchemaBuilder` when you want the full editor: visual editing on one side and editable YAML or JSON source on the other.

```tsx
import "jsonjoy-builder/styles.css";
Expand All @@ -57,7 +57,7 @@ The editor is controlled: pass the current `value`, then persist updates from `o

## Choosing a Component

`SchemaBuilder` is the best default for application screens. It includes the visual editor, JSON source editor, fullscreen mode, and a draggable split view on desktop.
`SchemaBuilder` is the best default for application screens. It includes the visual editor, YAML/JSON source editor, fullscreen mode, and a draggable split view on desktop.

```tsx
<SchemaBuilder value={schema} onChange={setSchema} readOnly={false} />
Expand All @@ -75,7 +75,7 @@ import { SchemaFieldsEditor } from "jsonjoy-builder";
/>
```

`SchemaJsonEditor` renders only the Monaco JSON editor. It can be read-only or editable depending on whether you pass `readOnly`.
`SchemaJsonEditor` renders only the Monaco schema source editor. It can be read-only or editable depending on whether you pass `readOnly`.

```tsx
import { SchemaJsonEditor } from "jsonjoy-builder";
Expand Down Expand Up @@ -277,7 +277,7 @@ The visual editor covers the common schema authoring flow:
- `anyOf`, `oneOf`, and `allOf` composition
- `additionalProperties` controls

You can still edit unsupported JSON Schema keywords directly in the JSON source editor.
You can still edit unsupported JSON Schema keywords directly in the source editor.

## Development

Expand Down
16 changes: 16 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"clsx": "^2.1.1",
"lucide-react": "^1.8.0",
"tailwind-merge": "^3.3.1",
"yaml": "^2.9.0",

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just the yaml parser is 30% the size of this entire package...

Maybe you could implement this by making the schema parser pluggable instead ? Then just show yaml as an example in the docs. This would be a smaller PR, just letting the user pass schemaToSource, sourceToSchema and the syntax coloring to use in monaco

"zod": "^4.0.17"
},
"peerDependencies": {
Expand Down
118 changes: 90 additions & 28 deletions src/components/SchemaEditor/SchemaJsonEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,27 @@
import Editor, { type BeforeMount, type OnMount } from "@monaco-editor/react";
import { Download, FileJson, Loader2 } from "lucide-react";
import { type FC, useRef } from "react";
import { Download, FileCode, Loader2 } from "lucide-react";
import { type FC, useEffect, useMemo, useRef, useState } from "react";
import { useControllableSchema } from "../../hooks/use-controllable-schema.ts";
import { useMonacoTheme } from "../../hooks/use-monaco-theme.ts";
import { useTranslation } from "../../hooks/use-translation.ts";
import { SchemaBuilderProvider } from "../../i18n/schema-builder-config.tsx";
import type { Translation } from "../../i18n/translation-keys.ts";
import {
getSchemaSourceFileName,
getSchemaSourceMimeType,
type SchemaSourceFormat,
schemaToSource,
sourceToSchema,
} from "../../lib/schema-source.ts";
import { cn } from "../../lib/utils.ts";
import type { JsonSchema } from "../../types/jsonSchema.ts";

const schemaSourceFormats: SchemaSourceFormat[] = ["yaml", "json"];
const schemaSourceFormatLabels: Record<SchemaSourceFormat, string> = {
yaml: "YAML",
json: "JSON",
};

/** @public */
export interface SchemaJsonEditorProps {
value?: JsonSchema;
Expand Down Expand Up @@ -67,18 +80,26 @@ const SchemaJsonEditorContent: FC<SchemaJsonEditorContentProps> = ({
className,
}) => {
const editorRef = useRef<Parameters<OnMount>[0] | null>(null);
const {
currentTheme,
defineMonacoThemes,
configureJsonDefaults,
defaultEditorOptions,
} = useMonacoTheme();
const { currentTheme, defineMonacoThemes, defaultEditorOptions } =
useMonacoTheme();

const t = useTranslation();
const valueJson = useMemo(() => JSON.stringify(value), [value]);
const [sourceFormat, setSourceFormat] = useState<SchemaSourceFormat>("yaml");
const [source, setSource] = useState(() => schemaToSource(value, "yaml"));
const lastEmittedJsonRef = useRef<string | null>(null);

useEffect(() => {
if (lastEmittedJsonRef.current === valueJson) {
lastEmittedJsonRef.current = null;
return;
}

setSource(schemaToSource(value, sourceFormat));
}, [value, valueJson, sourceFormat]);

const handleBeforeMount: BeforeMount = (monaco) => {
defineMonacoThemes(monaco);
configureJsonDefaults(monaco);
};

const handleEditorDidMount: OnMount = (editor) => {
Expand All @@ -89,23 +110,39 @@ const SchemaJsonEditorContent: FC<SchemaJsonEditorContentProps> = ({
};

const handleEditorChange = (nextValue: string | undefined) => {
if (readOnly || !nextValue) return;
if (readOnly || nextValue === undefined) return;

setSource(nextValue);

try {
const parsedJson = JSON.parse(nextValue);
onChange(parsedJson);
const parsedSchema = sourceToSchema(nextValue, sourceFormat);
lastEmittedJsonRef.current = JSON.stringify(parsedSchema);
onChange(parsedSchema);
} catch (_error) {
// Monaco will show the error inline, no need for additional error handling
// Keep invalid source as an editable draft until it parses successfully.
}
};

const handleSourceFormatChange = (nextFormat: SchemaSourceFormat) => {
if (nextFormat === sourceFormat) return;

lastEmittedJsonRef.current = null;
setSourceFormat(nextFormat);
setSource(schemaToSource(value, nextFormat));
};

const handleDownload = () => {
const content = JSON.stringify(value, null, 2);
const blob = new Blob([content], { type: "application/json" });
const content = schemaToSource(value, sourceFormat);
const blob = new Blob([content], {
type: getSchemaSourceMimeType(sourceFormat),
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = t.visualizerDownloadFileName;
a.download = getSchemaSourceFileName(
sourceFormat,
t.visualizerDownloadFileName,
);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
Expand All @@ -122,23 +159,43 @@ const SchemaJsonEditorContent: FC<SchemaJsonEditorContentProps> = ({
>
<div className="flex items-center justify-between bg-secondary/80 backdrop-blur-xs px-4 py-2 border-b shrink-0">
<div className="flex items-center gap-2">
<FileJson size={18} />
<FileCode size={18} />
<span className="font-medium text-sm">{t.visualizerSource}</span>
</div>
<button
type="button"
onClick={handleDownload}
className="p-1.5 hover:bg-secondary rounded-md transition-colors"
title={t.visualizerDownloadTitle}
>
<Download size={16} />
</button>
<div className="flex items-center gap-2">
<div className="grid h-8 grid-cols-2 items-center rounded-md bg-muted p-0.5 text-muted-foreground">
{schemaSourceFormats.map((format) => (
<button
key={format}
type="button"
aria-pressed={sourceFormat === format}
onClick={() => handleSourceFormatChange(format)}
className={cn(
"inline-flex h-7 min-w-12 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
sourceFormat === format
? "bg-background text-foreground shadow-xs"
: "hover:text-foreground",
)}
>
{schemaSourceFormatLabels[format]}
</button>
))}
</div>
<button
type="button"
onClick={handleDownload}
className="p-1.5 hover:bg-secondary rounded-md transition-colors"
title={`${t.visualizerDownloadTitle} (${schemaSourceFormatLabels[sourceFormat]})`}
>
<Download size={16} />
</button>
</div>
</div>
<div className="grow flex min-h-0">
<Editor
height="100%"
defaultLanguage="json"
value={JSON.stringify(value, null, 2)}
language={sourceFormat}
value={source}
onChange={handleEditorChange}
beforeMount={handleBeforeMount}
onMount={handleEditorDidMount}
Expand All @@ -148,7 +205,12 @@ const SchemaJsonEditorContent: FC<SchemaJsonEditorContentProps> = ({
<Loader2 className="h-6 w-6 animate-spin" />
</div>
}
options={{ ...defaultEditorOptions, readOnly }}
options={{
...defaultEditorOptions,
fontFamily:
"'SF Mono', Monaco, Menlo, Consolas, 'Liberation Mono', monospace",
readOnly,
}}
theme={currentTheme}
/>
</div>
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const de: Translation = {
schemaEditorTitle: "JSON-Schema-Editor",
schemaEditorToggleFullscreen: "Vollbild umschalten",
schemaEditorEditModeVisual: "Visuell",
schemaEditorEditModeJson: "JSON",
schemaEditorEditModeJson: "Quelle",
schemaEditorLoading: "Editor wird geladen...",

arrayNoConstraint: "Keine Einschränkung",
Expand Down Expand Up @@ -176,8 +176,8 @@ export const de: Translation = {
validatorErrorLocationLineOnly: "Zeile {line}",

visualizerDownloadTitle: "Schema herunterladen",
visualizerDownloadFileName: "schema.json",
visualizerSource: "JSON-Schema-Quelle",
visualizerDownloadFileName: "schema",
visualizerSource: "Schema-Quelle",

visualEditorNoFieldsHint1: "Noch keine Felder definiert",
visualEditorNoFieldsHint2: "Erstes Feld hinzufügen, um zu starten",
Expand Down
8 changes: 4 additions & 4 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const en: Translation = {
schemaEditorTitle: "JSON Schema Editor",
schemaEditorToggleFullscreen: "Toggle fullscreen",
schemaEditorEditModeVisual: "Visual",
schemaEditorEditModeJson: "JSON",
schemaEditorEditModeJson: "Source",
schemaEditorLoading: "Loading editor...",

arrayNoConstraint: "No constraint",
Expand Down Expand Up @@ -172,9 +172,9 @@ export const en: Translation = {
validatorErrorLocationLineAndColumn: "Line {line}, Col {column}",
validatorErrorLocationLineOnly: "Line {line}",

visualizerDownloadTitle: "Download Schema",
visualizerDownloadFileName: "schema.json",
visualizerSource: "JSON Schema Source",
visualizerDownloadTitle: "Download schema",
visualizerDownloadFileName: "schema",
visualizerSource: "Schema Source",

visualEditorNoFieldsHint1: "No fields defined yet",
visualEditorNoFieldsHint2: "Add your first field to get started",
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const es: Translation = {
schemaEditorTitle: "Editor de JSON Schema",
schemaEditorToggleFullscreen: "Cambiar a pantalla completa",
schemaEditorEditModeVisual: "Visual",
schemaEditorEditModeJson: "JSON",
schemaEditorEditModeJson: "Fuente",
schemaEditorLoading: "Cargando editor...",

arrayNoConstraint: "Sin restricción",
Expand Down Expand Up @@ -177,8 +177,8 @@ export const es: Translation = {
validatorErrorLocationLineOnly: "Línea {line}",

visualizerDownloadTitle: "Descargar esquema",
visualizerDownloadFileName: "schema.json",
visualizerSource: "Fuente del esquema JSON",
visualizerDownloadFileName: "schema",
visualizerSource: "Fuente del esquema",

visualEditorNoFieldsHint1: "Aún no hay campos definidos",
visualEditorNoFieldsHint2: "Agrega tu primer campo para comenzar",
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const fr: Translation = {
schemaEditorTitle: "Éditeur de schéma JSON",
schemaEditorToggleFullscreen: "Basculer en plein écran",
schemaEditorEditModeVisual: "Visuel",
schemaEditorEditModeJson: "JSON",
schemaEditorEditModeJson: "Source",
schemaEditorLoading: "Chargement de l'éditeur...",

arrayNoConstraint: "Pas de constrainte",
Expand Down Expand Up @@ -178,8 +178,8 @@ export const fr: Translation = {
validatorErrorLocationLineOnly: "Ligne {line}",

visualizerDownloadTitle: "Télécharger le schéma",
visualizerDownloadFileName: "schema.json",
visualizerSource: "Source du schéma JSON",
visualizerDownloadFileName: "schema",
visualizerSource: "Source du schéma",

visualEditorNoFieldsHint1: "Aucun champ défini pour le moment",
visualEditorNoFieldsHint2: "Ajoutez votre premier champ pour commencer",
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/locales/pl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export const pl: Translation = {
schemaEditorTitle: "Edytor schematu JSON",
schemaEditorToggleFullscreen: "Przełącz tryb pełnoekranowy",
schemaEditorEditModeVisual: "Wizualny",
schemaEditorEditModeJson: "JSON",
schemaEditorEditModeJson: "Źródło",
schemaEditorLoading: "Ładowanie edytora...",

arrayNoConstraint: "Bez ograniczeń",
Expand Down Expand Up @@ -178,8 +178,8 @@ export const pl: Translation = {
validatorErrorLocationLineOnly: "Linia {line}",

visualizerDownloadTitle: "Pobierz schemat",
visualizerDownloadFileName: "schema.json",
visualizerSource: "Schemat JSON",
visualizerDownloadFileName: "schema",
visualizerSource: "Źródło schematu",

visualEditorNoFieldsHint1: "Nie zdefiniowano jeszcze pól",
visualEditorNoFieldsHint2: "Dodaj swoje pierwsze pole, aby rozpocząć",
Expand Down
6 changes: 3 additions & 3 deletions src/i18n/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const ru: Translation = {
schemaEditorTitle: "Редактор JSON-схем",
schemaEditorToggleFullscreen: "Переключить полноэкранный режим",
schemaEditorEditModeVisual: "Визуальный",
schemaEditorEditModeJson: "JSON",
schemaEditorEditModeJson: "Источник",
schemaEditorLoading: "Загрузка редактора...",

arrayNoConstraint: "Без ограничений",
Expand Down Expand Up @@ -177,8 +177,8 @@ export const ru: Translation = {
validatorErrorLocationLineOnly: "Строка {line}",

visualizerDownloadTitle: "Скачать схему",
visualizerDownloadFileName: "schema.json",
visualizerSource: "Источник схемы JSON",
visualizerDownloadFileName: "schema",
visualizerSource: "Источник схемы",

visualEditorNoFieldsHint1: "Пока не определено ни одного поля",
visualEditorNoFieldsHint2: "Добавьте ваше первое поле, чтобы начать",
Expand Down
Loading