647 lines
22 KiB
TypeScript
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 signals—not 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 aids—not
|
|
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>
|
|
);
|
|
}
|