Files
schema-tools/src/components/Workbench.tsx
T
2026-09-01 14:38:44 +02:00

647 lines
22 KiB
TypeScript

import { useMemo, useState } from "react";
import { triggerBlobDownload } from "@add-ideas/toolbox-helpers";
import {
SCHEMA_LIMITS,
compareSchemas,
generateSample,
inspectWorkspace,
validateJsonInstance,
type Diagnostic,
type SampleResult,
type SchemaChange,
type SchemaLanguage,
type SchemaWorkspace,
type WorkspaceInput,
} from "../schema/model";
const mainSchema = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Person",
"type": "object",
"required": ["name", "address"],
"properties": {
"name": { "type": "string", "minLength": 1 },
"age": { "type": "integer", "minimum": 0 },
"address": { "$ref": "address.schema.json#/$defs/address" }
},
"additionalProperties": false
}`;
const addressSchema = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$defs": {
"address": {
"type": "object",
"required": ["city"],
"properties": {
"city": { "type": "string" },
"postalCode": { "type": "string" }
}
}
}
}`;
const sampleDocuments: WorkspaceInput[] = [
{ name: "person.schema.json", source: mainSchema, language: "json-schema" },
{
name: "address.schema.json",
source: addressSchema,
language: "json-schema",
},
];
const sampleInstance = `{
"name": "Ada Lovelace",
"age": 36,
"address": { "city": "London", "postalCode": "SW1A" }
}`;
const beforeSchema = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "id": { "type": "string" }, "label": { "type": "string" } },
"required": ["id"]
}`;
const afterSchema = `{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "id": { "type": "string" }, "label": { "type": "string" }, "kind": { "type": "string" } },
"required": ["id", "kind"],
"additionalProperties": false
}`;
type Tab = "workspace" | "validate" | "sample" | "references" | "compare";
const languageNames: Record<SchemaLanguage, string> = {
"json-schema": "JSON Schema",
openapi: "OpenAPI",
xsd: "XSD",
"relax-ng": "Relax NG",
schematron: "Schematron",
};
const tabs: Array<{ id: Tab; label: string }> = [
{ id: "workspace", label: "Workspace" },
{ id: "validate", label: "Validate" },
{ id: "sample", label: "Samples" },
{ id: "references", label: "References" },
{ id: "compare", label: "Compare" },
];
function message(reason: unknown, fallback: string): string {
return reason instanceof Error ? reason.message : fallback;
}
function download(value: string, filename: string, type: string) {
triggerBlobDownload(new Blob([value], { type }), filename);
}
function DiagnosticList({
diagnostics,
}: {
diagnostics: readonly Diagnostic[];
}) {
if (!diagnostics.length) return <p className="empty">No diagnostics.</p>;
return (
<ul className="diagnostics" aria-label="Diagnostics">
{diagnostics.slice(0, 1_000).map((item, index) => (
<li
className={`diagnostic ${item.level}`}
key={`${item.document}-${item.path}-${index}`}
>
<span className="severity">{item.level}</span>
<div>
<strong>{item.document}</strong> <code>{item.path}</code>
<p>{item.message}</p>
</div>
</li>
))}
</ul>
);
}
export function Workbench() {
const initial = useMemo(
() => inspectWorkspace(sampleDocuments, "person.schema.json"),
[],
);
const [tab, setTab] = useState<Tab>("workspace");
const [documents, setDocuments] = useState<WorkspaceInput[]>(sampleDocuments);
const [selected, setSelected] = useState(0);
const [report, setReport] = useState<SchemaWorkspace>(initial);
const [error, setError] = useState("");
const [instance, setInstance] = useState(sampleInstance);
const [validation, setValidation] = useState<Diagnostic[]>([]);
const [sample, setSample] = useState<SampleResult>();
const [before, setBefore] = useState(beforeSchema);
const [after, setAfter] = useState(afterSchema);
const [comparison, setComparison] = useState<SchemaChange[]>([]);
const current = documents[selected];
const reportEntry = report.documents.get(report.entry);
const updateCurrent = (patch: Partial<WorkspaceInput>) => {
setDocuments((items) =>
items.map((item, index) =>
index === selected ? { ...item, ...patch } : item,
),
);
};
const inspect = () => {
try {
const next = inspectWorkspace(documents, current?.name);
setReport(next);
setValidation([]);
setSample(undefined);
setError("");
} catch (reason) {
setError(message(reason, "Could not inspect this workspace."));
}
};
const openFiles = async (files: FileList | null) => {
if (!files?.length) return;
try {
if (files.length > SCHEMA_LIMITS.documents)
throw new RangeError(
`Select at most ${SCHEMA_LIMITS.documents} files.`,
);
const selectedFiles = Array.from(files);
if (
selectedFiles.some(
(file) => file.size > SCHEMA_LIMITS.documentChars * 4,
)
)
throw new RangeError(
"A schema file exceeds the conservative 8 MiB UTF-8 byte gate.",
);
if (
selectedFiles.reduce((sum, file) => sum + file.size, 0) >
SCHEMA_LIMITS.workspaceChars * 4
)
throw new RangeError(
"Selected files exceed the conservative 32 MiB byte gate.",
);
const next = await Promise.all(
selectedFiles.map(async (file) => ({
name: file.webkitRelativePath || file.name,
source: await file.text(),
language: "auto" as const,
})),
);
const inspected = inspectWorkspace(next, next[0]?.name);
setDocuments(next);
setSelected(0);
setReport(inspected);
setValidation([]);
setSample(undefined);
setError("");
} catch (reason) {
setError(message(reason, "Could not open the selected schema files."));
}
};
const addDocument = () => {
if (documents.length >= SCHEMA_LIMITS.documents) {
setError(`Workspace already has ${SCHEMA_LIMITS.documents} documents.`);
return;
}
const next = [
...documents,
{
name: `schema-${documents.length + 1}.json`,
source: "{}\n",
language: "auto" as const,
},
];
setDocuments(next);
setSelected(next.length - 1);
};
const removeDocument = () => {
if (documents.length === 1) {
setError("A workspace needs at least one document.");
return;
}
const next = documents.filter((_, index) => index !== selected);
setDocuments(next);
setSelected(Math.min(selected, next.length - 1));
};
const validate = () => {
try {
const result = validateJsonInstance(report, instance);
setValidation(result.diagnostics);
setError("");
} catch (reason) {
setError(message(reason, "Could not validate the instance."));
}
};
const createSample = () => {
try {
setSample(generateSample(report));
setError("");
} catch (reason) {
setError(message(reason, "Could not generate a bounded sample."));
}
};
const compare = () => {
try {
setComparison(
compareSchemas(
{ name: "before.schema.json", source: before, language: "auto" },
{ name: "after.schema.json", source: after, language: "auto" },
),
);
setError("");
} catch (reason) {
setError(message(reason, "Could not compare these schemas."));
}
};
return (
<main className="workbench">
<section className="hero">
<div>
<p className="eyebrow">Schema workbench</p>
<h1>Schema Tools</h1>
<p>
Inspect local schema workspaces, trace references, validate bounded
JSON instances, derive samples, and review conservative
compatibility signals.
</p>
</div>
<span className="privacy-pill">Browser-local</span>
</section>
<section className="panel" aria-labelledby="capabilities-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Honest capability tiers</p>
<h2 id="capabilities-title">
Five languages, deliberately different guarantees
</h2>
</div>
</div>
<div className="capability-grid">
<article>
<strong>JSON Schema</strong>
<span className="capability focused">focused validation</span>
<p>
CSP-safe Draft 6/7, 2019-09, and 2020-12 assertion subset, with
bounded local references. Regex-bearing keywords are not executed.
</p>
</article>
<article>
<strong>OpenAPI 3</strong>
<span className="capability focused">focused checks</span>
<p>
JSON/YAML parsing, operations, references, examples, and
conservative breaking-change signalsnot full conformance.
</p>
</article>
<article>
<strong>XSD</strong>
<span className="capability inspect">structural only</span>
<p>
Well-formed XML, declarations/imports, and heuristic instance
samples. No XSD instance validation.
</p>
</article>
<article>
<strong>Relax NG</strong>
<span className="capability inspect">structural only</span>
<p>
XML-syntax grammar inventory, local includes, and bounded
first-branch samples. Compact syntax is not supported.
</p>
</article>
<article>
<strong>Schematron</strong>
<span className="capability inspect">inert inspection</span>
<p>
Patterns, rules, assertions, query binding, and includes are
inventoried. XPath and extensions are never executed.
</p>
</article>
</div>
</section>
<section className="panel workspace">
<div
className="workspace-tabs"
role="tablist"
aria-label="Schema workbench views"
>
{tabs.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-selected={tab === item.id}
onClick={() => setTab(item.id)}
>
{item.label}
</button>
))}
</div>
{error && (
<p className="alert" role="alert">
{error} The last successful report remains available.
</p>
)}
{tab === "workspace" && (
<div className="stack" role="tabpanel">
<div className="actions">
<label className="button file-button">
Open local schemas
<input
type="file"
multiple
accept=".json,.yaml,.yml,.xsd,.rng,.sch,.xml,application/json,application/xml,text/yaml"
onChange={(event) => void openFiles(event.target.files)}
/>
</label>
<button type="button" onClick={addDocument}>
Add document
</button>
<button type="button" onClick={removeDocument}>
Remove selected
</button>
<button className="primary" type="button" onClick={inspect}>
Inspect workspace
</button>
</div>
<div
className="document-tabs"
role="tablist"
aria-label="Workspace documents"
>
{documents.map((item, index) => (
<button
key={`${item.name}-${index}`}
type="button"
role="tab"
aria-selected={index === selected}
onClick={() => setSelected(index)}
>
{item.name || `Document ${index + 1}`}
</button>
))}
</div>
{current && (
<div className="editor-grid">
<div className="stack">
<div className="grid compact-grid">
<label className="field">
<span>Local filename</span>
<input
value={current.name}
onChange={(event) =>
updateCurrent({ name: event.target.value })
}
/>
</label>
<label className="field">
<span>Language</span>
<select
value={current.language ?? "auto"}
onChange={(event) =>
updateCurrent({
language: event.target.value as
SchemaLanguage | "auto",
})
}
>
<option value="auto">Auto-detect</option>
{Object.entries(languageNames).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</label>
</div>
<label className="field">
<span>Schema source</span>
<textarea
aria-label="Schema source"
spellCheck={false}
value={current.source}
onChange={(event) =>
updateCurrent({ source: event.target.value })
}
/>
</label>
</div>
<div className="stack report-column">
<div className="summary-card">
<p className="eyebrow">Last successful entry</p>
<h3>{report.entry}</h3>
<p>
{report.documents.size} document
{report.documents.size === 1 ? "" : "s"} ·{" "}
{report.references.length} reference
{report.references.length === 1 ? "" : "s"}
</p>
{reportEntry && (
<dl className="summary-list">
{reportEntry.summary.map((item) => (
<div key={item.label}>
<dt>{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
)}
</div>
<DiagnosticList diagnostics={report.diagnostics} />
</div>
</div>
)}
</div>
)}
{tab === "validate" && (
<div className="editor-grid" role="tabpanel">
<div className="stack">
<div>
<p className="eyebrow">Bounded instance check</p>
<h2>Validate JSON locally</h2>
<p className="muted">
Available only when the entry is JSON Schema. Formats are
annotations; schemas containing pattern or patternProperties
are refused rather than risking unbounded regex execution.
</p>
</div>
<label className="field">
<span>JSON instance</span>
<textarea
aria-label="JSON instance"
value={instance}
onChange={(event) => setInstance(event.target.value)}
spellCheck={false}
/>
</label>
<div className="actions">
<button className="primary" type="button" onClick={validate}>
Validate instance
</button>
</div>
</div>
<div className="stack">
<h3>Validation report</h3>
<DiagnosticList diagnostics={validation} />
</div>
</div>
)}
{tab === "sample" && (
<div className="editor-grid" role="tabpanel">
<div className="stack">
<div>
<p className="eyebrow">Bounded derivation</p>
<h2>Generate a reviewable example</h2>
<p className="muted">
Generation follows defaults, examples, enums, required
properties, first alternatives, and supplied local references.
XML generation is explicitly heuristic.
</p>
</div>
<div className="actions">
<button
className="primary"
type="button"
onClick={createSample}
>
Generate sample
</button>
{sample && (
<button
type="button"
onClick={() =>
download(
sample.output,
sample.mediaType === "application/json"
? "sample.json"
: "sample.xml",
`${sample.mediaType};charset=utf-8`,
)
}
>
Download sample
</button>
)}
</div>
{sample?.notices.map((notice) => (
<p className="notice" key={notice}>
{notice}
</p>
))}
</div>
<div className="stack">
<h3>Generated output</h3>
<pre className="output" aria-label="Generated sample">
{sample?.output ??
"Generate a sample to populate this inert preview."}
</pre>
</div>
</div>
)}
{tab === "references" && (
<div className="stack" role="tabpanel">
<div>
<p className="eyebrow">No network resolution</p>
<h2>Local reference graph</h2>
<p className="muted">
Fragments stay inside their document. Relative references
resolve only against supplied workspace filenames; schemes,
absolute paths, and escaping paths are blocked.
</p>
</div>
{!report.references.length ? (
<p className="empty">No references found.</p>
) : (
<div className="table-wrap">
<table>
<thead>
<tr>
<th>From</th>
<th>Reference</th>
<th>Target</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{report.references.map((item, index) => (
<tr key={`${item.from}-${item.path}-${index}`}>
<td>
{item.from}
<br />
<code>{item.path}</code>
</td>
<td>
<code>{item.reference}</code>
</td>
<td>{item.target ?? "—"}</td>
<td>
<span className={`status ${item.status}`}>
{item.status}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{tab === "compare" && (
<div className="stack" role="tabpanel">
<div>
<p className="eyebrow">Conservative signals</p>
<h2>Compare two schema revisions</h2>
<p className="muted">
Signals identify obvious removals, required additions, narrowed
types/enums, and operation changes. They are review aidsnot
proof of compatibility.
</p>
</div>
<div className="editor-grid">
<label className="field">
<span>Before</span>
<textarea
aria-label="Before schema"
value={before}
onChange={(event) => setBefore(event.target.value)}
spellCheck={false}
/>
</label>
<label className="field">
<span>After</span>
<textarea
aria-label="After schema"
value={after}
onChange={(event) => setAfter(event.target.value)}
spellCheck={false}
/>
</label>
</div>
<div className="actions">
<button className="primary" type="button" onClick={compare}>
Compare schemas
</button>
</div>
{!comparison.length ? (
<p className="empty">No comparison signals yet.</p>
) : (
<ul className="changes">
{comparison.map((item, index) => (
<li className={item.level} key={`${item.path}-${index}`}>
<span className="severity">{item.level}</span>
<div>
<code>{item.path}</code>
<p>{item.message}</p>
</div>
</li>
))}
</ul>
)}
</div>
)}
</section>
</main>
);
}