Release Schema Tools v0.1.0
This commit is contained in:
@@ -0,0 +1,646 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user