Release Data Tools 0.1.0
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
import "./styles.css";
|
||||
import { ErrorBoundary } from "./components/ErrorBoundary";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { manifest } from "./toolbox/manifest";
|
||||
|
||||
const Workbench = lazy(async () => ({
|
||||
default: (await import("./components/Workbench")).Workbench,
|
||||
}));
|
||||
|
||||
export function App() {
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<p className="loading" role="status">
|
||||
Preparing Data Tools…
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Workbench />
|
||||
</Suspense>
|
||||
</AppShell>
|
||||
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Component, type ErrorInfo, type ReactNode } from "react";
|
||||
|
||||
export class ErrorBoundary extends Component<
|
||||
{ children: ReactNode },
|
||||
{ error?: Error }
|
||||
> {
|
||||
state: { error?: Error } = {};
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
console.error("Application failure", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<main className="fatal">
|
||||
<h1>Data Tools could not continue</h1>
|
||||
<p>{this.state.error.message}</p>
|
||||
<button type="button" onClick={() => location.reload()}>
|
||||
Reload
|
||||
</button>
|
||||
</main>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export function HelpDialog({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const dialog = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
const node = dialog.current;
|
||||
if (!node) return;
|
||||
if (open && !node.open) node.showModal();
|
||||
if (!open && node.open) node.close();
|
||||
}, [open]);
|
||||
return (
|
||||
<dialog
|
||||
ref={dialog}
|
||||
className="help-dialog"
|
||||
onClose={onClose}
|
||||
onCancel={onClose}
|
||||
aria-labelledby="help-title"
|
||||
>
|
||||
<div className="dialog-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Local-first help</p>
|
||||
<h2 id="help-title">About Data Tools</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label="Close help">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<p>
|
||||
Inspect, format, query, flatten and convert JSON, YAML 1.2, TOML, XML,
|
||||
CSV, TSV and NDJSON locally.
|
||||
</p>
|
||||
<p>
|
||||
All processing is performed in this browser. Imported data is treated as
|
||||
untrusted, parsed in a disposable worker and bounded to 16 MiB, 100,000
|
||||
nodes and depth 128.
|
||||
</p>
|
||||
<p>
|
||||
Conversions include a report for coercion or information loss. XML
|
||||
declarations with external semantics are rejected, and spreadsheet-safe
|
||||
CSV/TSV output is enabled by default.
|
||||
</p>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
type DragEvent,
|
||||
type KeyboardEvent,
|
||||
} from "react";
|
||||
import { convertDocument } from "../core/convert";
|
||||
import { assertBoundedFile, DATA_LIMITS } from "../core/limits";
|
||||
import { scalarText } from "../core/model";
|
||||
import { flattenRows, runQuery, tableFromNode, treeRows } from "../core/query";
|
||||
import { createParseTask, type ParseTask } from "../core/worker-client";
|
||||
import {
|
||||
DATA_FORMATS,
|
||||
DataToolsError,
|
||||
type ConversionResult,
|
||||
type DataFormat,
|
||||
type Diagnostic,
|
||||
type FormatSelection,
|
||||
type ParsedDocument,
|
||||
type QueryMatch,
|
||||
type TableModel,
|
||||
} from "../core/types";
|
||||
|
||||
const DEFAULT_SAMPLE = `{
|
||||
"project": "Data Tools",
|
||||
"exactInteger": 9007199254740993123456789,
|
||||
"features": ["detect", "inspect", "query", "convert"],
|
||||
"localOnly": true
|
||||
}`;
|
||||
|
||||
const views = [
|
||||
{ id: "source", label: "Source", short: "Input & diagnostics" },
|
||||
{ id: "tree", label: "Tree", short: "Paths & types" },
|
||||
{ id: "table", label: "Table", short: "Rows & columns" },
|
||||
{ id: "query", label: "Query", short: "Pointer & safe path" },
|
||||
{ id: "convert", label: "Convert", short: "Output & disclosure" },
|
||||
] as const;
|
||||
|
||||
type ViewId = (typeof views)[number]["id"];
|
||||
type ParseStatus = "idle" | "pending" | "ready" | "error";
|
||||
|
||||
function initialView(): ViewId {
|
||||
const hash = globalThis.location?.hash.replace(/^#/, "") as ViewId;
|
||||
return views.some((view) => view.id === hash) ? hash : "source";
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes < 1_024) return `${bytes} B`;
|
||||
if (bytes < 1_048_576) return `${(bytes / 1_024).toFixed(1)} KiB`;
|
||||
return `${(bytes / 1_048_576).toFixed(1)} MiB`;
|
||||
}
|
||||
|
||||
function extension(format: DataFormat): string {
|
||||
return format === "yaml" ? "yaml" : format === "ndjson" ? "jsonl" : format;
|
||||
}
|
||||
|
||||
function downloadText(text: string, format: DataFormat): void {
|
||||
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = `converted.${extension(format)}`;
|
||||
anchor.rel = "noopener";
|
||||
anchor.hidden = true;
|
||||
document.body.append(anchor);
|
||||
try {
|
||||
anchor.click();
|
||||
} finally {
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function Diagnostics({ diagnostics }: { diagnostics: Diagnostic[] }) {
|
||||
if (diagnostics.length === 0)
|
||||
return <p className="empty-state">No parser diagnostics.</p>;
|
||||
return (
|
||||
<ul className="diagnostic-list">
|
||||
{diagnostics.map((diagnostic, index) => (
|
||||
<li
|
||||
key={`${diagnostic.code}-${index}`}
|
||||
className={`diagnostic diagnostic--${diagnostic.severity}`}
|
||||
>
|
||||
<span className="diagnostic__severity">{diagnostic.severity}</span>
|
||||
<div>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<p>{diagnostic.message}</p>
|
||||
{diagnostic.location?.line ? (
|
||||
<small>
|
||||
Line {diagnostic.location.line}
|
||||
{diagnostic.location.column
|
||||
? `, column ${diagnostic.location.column}`
|
||||
: ""}
|
||||
</small>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function DataTable({ table, caption }: { table: TableModel; caption: string }) {
|
||||
if (table.columns.length === 0)
|
||||
return <p className="empty-state">This value has no table columns.</p>;
|
||||
return (
|
||||
<div
|
||||
className="table-scroll"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-label={caption}
|
||||
>
|
||||
<table>
|
||||
<caption className="sr-only">{caption}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
{table.columns.map((column, index) => (
|
||||
<th key={`${column}-${index}`} scope="col">
|
||||
{column || `Column ${index + 1}`}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{table.rows.map((row, rowIndex) => (
|
||||
<tr key={rowIndex}>
|
||||
{table.columns.map((_, columnIndex) => (
|
||||
<td key={columnIndex}>{row[columnIndex] ?? ""}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{table.truncatedRows || table.truncatedColumns ? (
|
||||
<p className="preview-note">
|
||||
Preview limit reached: {table.truncatedRows} more row(s),{" "}
|
||||
{table.truncatedColumns} more column(s).
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeView({ document }: { document: ParsedDocument }) {
|
||||
const preview = useMemo(() => treeRows(document.root), [document]);
|
||||
return (
|
||||
<div className="tree" role="tree" aria-label="Parsed data tree">
|
||||
{preview.rows.map((row, index) => (
|
||||
<div
|
||||
role="treeitem"
|
||||
aria-level={row.depth + 1}
|
||||
className="tree-row"
|
||||
key={`${row.pointer}-${index}`}
|
||||
style={{ "--tree-depth": row.depth } as CSSProperties}
|
||||
>
|
||||
<code className="tree-row__label">{row.label}</code>
|
||||
<span className={`type-chip type-chip--${row.type}`}>{row.type}</span>
|
||||
<span className="tree-row__summary">{row.summary}</span>
|
||||
<code className="tree-row__path">{row.pointer || "(root)"}</code>
|
||||
</div>
|
||||
))}
|
||||
{preview.truncated ? (
|
||||
<p className="preview-note">
|
||||
Tree preview stops after 2,000 nodes. Queries and conversions still
|
||||
use the complete bounded model.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function QueryResults({ matches }: { matches: QueryMatch[] }) {
|
||||
if (matches.length === 0)
|
||||
return <p className="empty-state">No values matched.</p>;
|
||||
return (
|
||||
<div className="query-results">
|
||||
<p className="result-count">
|
||||
{matches.length} match{matches.length === 1 ? "" : "es"}
|
||||
</p>
|
||||
<ol>
|
||||
{matches.map((match, index) => (
|
||||
<li key={`${match.pointer}-${index}`}>
|
||||
<code>{match.pointer || "(root)"}</code>
|
||||
<span className={`type-chip type-chip--${match.node.type}`}>
|
||||
{match.node.type}
|
||||
</span>
|
||||
<span>{scalarText(match.node)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConversionDisclosure({ result }: { result: ConversionResult }) {
|
||||
if (result.events.length === 0)
|
||||
return (
|
||||
<p className="disclosure disclosure--clean">
|
||||
No known loss or coercion in this conversion.
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div className="disclosure">
|
||||
<h3>Conversion report</h3>
|
||||
<ul>
|
||||
{result.events.map((event, index) => (
|
||||
<li
|
||||
key={`${event.code}-${event.path}-${index}`}
|
||||
className={`loss-event loss-event--${event.severity}`}
|
||||
>
|
||||
<strong>{event.severity}</strong>
|
||||
<span>
|
||||
{event.message}
|
||||
{event.count && event.count > 1
|
||||
? ` (${event.count} occurrences)`
|
||||
: ""}
|
||||
</span>
|
||||
<code>{event.path || "(root)"}</code>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Workbench() {
|
||||
const [source, setSource] = useState(DEFAULT_SAMPLE);
|
||||
const [selection, setSelection] = useState<FormatSelection>("auto");
|
||||
const [filename, setFilename] = useState<string>();
|
||||
const [documentModel, setDocumentModel] = useState<ParsedDocument>();
|
||||
const [failureDiagnostics, setFailureDiagnostics] = useState<Diagnostic[]>(
|
||||
[],
|
||||
);
|
||||
const [status, setStatus] = useState<ParseStatus>("idle");
|
||||
const [statusMessage, setStatusMessage] = useState(
|
||||
"Ready to inspect locally.",
|
||||
);
|
||||
const [active, setActive] = useState<ViewId>(initialView);
|
||||
const [firstRowHeader, setFirstRowHeader] = useState(false);
|
||||
const [queryMode, setQueryMode] = useState<"pointer" | "path">("pointer");
|
||||
const [query, setQuery] = useState("/features/0");
|
||||
const [target, setTarget] = useState<DataFormat>("yaml");
|
||||
const [spreadsheetSafe, setSpreadsheetSafe] = useState(true);
|
||||
const [actionNotice, setActionNotice] = useState("");
|
||||
const currentTask = useRef<ParseTask | undefined>(undefined);
|
||||
const requestSequence = useRef(0);
|
||||
|
||||
const analyze = useCallback(
|
||||
(value: string, format: FormatSelection, name?: string) => {
|
||||
currentTask.current?.cancel();
|
||||
const sequence = ++requestSequence.current;
|
||||
const task = createParseTask({ source: value, format, filename: name });
|
||||
currentTask.current = task;
|
||||
setStatus("pending");
|
||||
setStatusMessage("Analyzing in an isolated worker…");
|
||||
void task.promise
|
||||
.then((parsed) => {
|
||||
if (sequence !== requestSequence.current) return;
|
||||
setDocumentModel(parsed);
|
||||
setFailureDiagnostics([]);
|
||||
setStatus("ready");
|
||||
setStatusMessage(
|
||||
`Parsed as ${parsed.format.toUpperCase()}; ${parsed.stats.nodes.toLocaleString()} bounded nodes.`,
|
||||
);
|
||||
setTarget((current) =>
|
||||
current === parsed.format
|
||||
? parsed.format === "json"
|
||||
? "yaml"
|
||||
: "json"
|
||||
: current,
|
||||
);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (
|
||||
sequence !== requestSequence.current ||
|
||||
(error instanceof DOMException && error.name === "AbortError")
|
||||
)
|
||||
return;
|
||||
setStatus("error");
|
||||
setStatusMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The input could not be parsed.",
|
||||
);
|
||||
setFailureDiagnostics(
|
||||
error instanceof DataToolsError
|
||||
? error.diagnostics
|
||||
: [
|
||||
{
|
||||
code: "parse.unknown",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown parser failure.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(
|
||||
() => analyze(source, selection, filename),
|
||||
420,
|
||||
);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [analyze, filename, selection, source]);
|
||||
|
||||
useEffect(() => () => currentTask.current?.cancel(), []);
|
||||
|
||||
useEffect(() => {
|
||||
const onHashChange = () => setActive(initialView());
|
||||
globalThis.addEventListener("hashchange", onHashChange);
|
||||
return () => globalThis.removeEventListener("hashchange", onHashChange);
|
||||
}, []);
|
||||
|
||||
const diagnostics =
|
||||
status === "error"
|
||||
? failureDiagnostics
|
||||
: (documentModel?.diagnostics ?? []);
|
||||
const table = useMemo(
|
||||
() =>
|
||||
documentModel
|
||||
? tableFromNode(documentModel.root, firstRowHeader)
|
||||
: undefined,
|
||||
[documentModel, firstRowHeader],
|
||||
);
|
||||
const flattened = useMemo(
|
||||
() => (documentModel ? flattenRows(documentModel.root) : undefined),
|
||||
[documentModel],
|
||||
);
|
||||
const queryResult = useMemo(() => {
|
||||
if (!documentModel) return { matches: [] as QueryMatch[], error: "" };
|
||||
try {
|
||||
return {
|
||||
matches: runQuery(documentModel.root, queryMode, query),
|
||||
error: "",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
matches: [] as QueryMatch[],
|
||||
error: error instanceof Error ? error.message : "Invalid query.",
|
||||
};
|
||||
}
|
||||
}, [documentModel, query, queryMode]);
|
||||
const conversion = useMemo(() => {
|
||||
if (!documentModel) return { result: undefined, error: "" };
|
||||
try {
|
||||
return {
|
||||
result: convertDocument(documentModel, target, {
|
||||
spreadsheetSafe,
|
||||
firstRowHeader,
|
||||
}),
|
||||
error: "",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
result: undefined,
|
||||
error: error instanceof Error ? error.message : "Conversion failed.",
|
||||
};
|
||||
}
|
||||
}, [documentModel, firstRowHeader, spreadsheetSafe, target]);
|
||||
|
||||
function selectView(id: ViewId): void {
|
||||
setActive(id);
|
||||
history.replaceState(
|
||||
null,
|
||||
"",
|
||||
`${location.pathname}${location.search}#${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
function tabKeyDown(event: KeyboardEvent<HTMLButtonElement>): void {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return;
|
||||
event.preventDefault();
|
||||
const current = views.findIndex((view) => view.id === active);
|
||||
const index =
|
||||
event.key === "Home"
|
||||
? 0
|
||||
: event.key === "End"
|
||||
? views.length - 1
|
||||
: (current + (event.key === "ArrowRight" ? 1 : -1) + views.length) %
|
||||
views.length;
|
||||
const next = views[index];
|
||||
if (!next) return;
|
||||
selectView(next.id);
|
||||
requestAnimationFrame(() =>
|
||||
document.getElementById(`view-tab-${next.id}`)?.focus(),
|
||||
);
|
||||
}
|
||||
|
||||
async function readFile(file: File): Promise<void> {
|
||||
try {
|
||||
assertBoundedFile(file);
|
||||
const bytes = await file.arrayBuffer();
|
||||
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
||||
setFilename(file.name);
|
||||
setSelection("auto");
|
||||
setSource(text);
|
||||
setStatusMessage(`Loaded ${file.name} locally.`);
|
||||
} catch (error) {
|
||||
setStatus("error");
|
||||
setStatusMessage(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The file could not be read as UTF-8 text.",
|
||||
);
|
||||
setFailureDiagnostics([
|
||||
{
|
||||
code: "file.read",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The file could not be read.",
|
||||
severity: "error",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function fileChanged(event: ChangeEvent<HTMLInputElement>): void {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) void readFile(file);
|
||||
event.target.value = "";
|
||||
}
|
||||
|
||||
function fileDropped(event: DragEvent<HTMLElement>): void {
|
||||
event.preventDefault();
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (file) void readFile(file);
|
||||
}
|
||||
|
||||
async function copyOutput(): Promise<void> {
|
||||
if (!conversion.result) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(conversion.result.text);
|
||||
setActionNotice("Converted output copied.");
|
||||
} catch {
|
||||
setActionNotice(
|
||||
"Clipboard access was denied; select the output and copy it manually.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatSource(): void {
|
||||
if (!documentModel) return;
|
||||
try {
|
||||
const formatted = convertDocument(documentModel, documentModel.format, {
|
||||
spreadsheetSafe: false,
|
||||
firstRowHeader,
|
||||
});
|
||||
setSource(formatted.text);
|
||||
setActionNotice(
|
||||
formatted.events.length
|
||||
? `Formatted with ${formatted.events.length} disclosure item(s).`
|
||||
: "Source formatted locally.",
|
||||
);
|
||||
} catch (error) {
|
||||
setActionNotice(
|
||||
error instanceof Error ? error.message : "Formatting failed.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="workbench">
|
||||
<header className="hero">
|
||||
<div>
|
||||
<p className="eyebrow">Private structured-data workbench</p>
|
||||
<h1>Data Tools</h1>
|
||||
<p>
|
||||
Detect, inspect, query, flatten, format and convert data without
|
||||
sending it anywhere.
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className="privacy-pill"
|
||||
title="No uploads, telemetry or server processing"
|
||||
>
|
||||
Browser-local
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section
|
||||
className="panel editor-panel"
|
||||
aria-labelledby="input-heading"
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDrop={fileDropped}
|
||||
>
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Untrusted input</p>
|
||||
<h2 id="input-heading">Source</h2>
|
||||
</div>
|
||||
<div className="status-badge" data-status={status}>
|
||||
<span aria-hidden="true" />
|
||||
{status}
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-toolbar">
|
||||
<label className="field format-field">
|
||||
<span>Input format</span>
|
||||
<select
|
||||
value={selection}
|
||||
onChange={(event) =>
|
||||
setSelection(event.target.value as FormatSelection)
|
||||
}
|
||||
data-testid="format-select"
|
||||
>
|
||||
<option value="auto">Detect automatically</option>
|
||||
{DATA_FORMATS.map((format) => (
|
||||
<option value={format} key={format}>
|
||||
{format.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="file-button button">
|
||||
Open file
|
||||
<input
|
||||
type="file"
|
||||
accept=".json,.jsonl,.ndjson,.yaml,.yml,.toml,.xml,.csv,.tsv,.tab,text/*,application/json,application/xml"
|
||||
onChange={fileChanged}
|
||||
data-testid="data-file-input"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() => analyze(source, selection, filename)}
|
||||
>
|
||||
Analyze now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={formatSource}
|
||||
disabled={!documentModel || status === "pending"}
|
||||
>
|
||||
Format source
|
||||
</button>
|
||||
</div>
|
||||
<label className="field source-field">
|
||||
<span>
|
||||
Text input {filename ? <small>— {filename}</small> : null}
|
||||
</span>
|
||||
<textarea
|
||||
value={source}
|
||||
onChange={(event) => {
|
||||
setSource(event.target.value);
|
||||
setFilename(undefined);
|
||||
}}
|
||||
spellCheck={false}
|
||||
aria-describedby="parse-status"
|
||||
data-testid="source-editor"
|
||||
/>
|
||||
</label>
|
||||
<div className="status-line">
|
||||
<p id="parse-status" role="status" aria-live="polite">
|
||||
{statusMessage}
|
||||
</p>
|
||||
<p>Auto-analysis keeps the last successful result visible.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav className="workspace-tabs" aria-label="Data views">
|
||||
<div role="tablist" aria-label="Data views">
|
||||
{views.map((view) => (
|
||||
<button
|
||||
key={view.id}
|
||||
id={`view-tab-${view.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === view.id}
|
||||
aria-controls={`view-panel-${view.id}`}
|
||||
tabIndex={active === view.id ? 0 : -1}
|
||||
onClick={() => selectView(view.id)}
|
||||
onKeyDown={tabKeyDown}
|
||||
>
|
||||
<span>{view.label}</span>
|
||||
<small>{view.short}</small>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<section
|
||||
className="panel view-panel"
|
||||
role="tabpanel"
|
||||
id={`view-panel-${active}`}
|
||||
aria-labelledby={`view-tab-${active}`}
|
||||
>
|
||||
{active === "source" ? (
|
||||
<div className="source-dashboard">
|
||||
<div>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Detection</p>
|
||||
<h2>Document summary</h2>
|
||||
</div>
|
||||
</div>
|
||||
{documentModel ? (
|
||||
<>
|
||||
<dl className="stats">
|
||||
<div>
|
||||
<dt>Format</dt>
|
||||
<dd>{documentModel.format.toUpperCase()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Confidence</dt>
|
||||
<dd>{documentModel.detection.confidence}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Size</dt>
|
||||
<dd>{formatSize(documentModel.stats.bytes)}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Nodes</dt>
|
||||
<dd>{documentModel.stats.nodes.toLocaleString()}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Depth</dt>
|
||||
<dd>{documentModel.stats.maximumDepth}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Model</dt>
|
||||
<dd>{documentModel.model}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="detection-reason">
|
||||
{documentModel.detection.reason}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
Enter valid data to see its bounded model.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Parser feedback</p>
|
||||
<h2>Diagnostics</h2>
|
||||
</div>
|
||||
<span className="count-pill">{diagnostics.length}</span>
|
||||
</div>
|
||||
<Diagnostics diagnostics={diagnostics} />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{active === "tree" ? (
|
||||
<>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Bounded preview</p>
|
||||
<h2>Tree</h2>
|
||||
</div>
|
||||
</div>
|
||||
{documentModel ? (
|
||||
<TreeView document={documentModel} />
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
Analyze valid input to inspect its tree.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{active === "table" ? (
|
||||
<>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Projection</p>
|
||||
<h2>Table</h2>
|
||||
</div>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={firstRowHeader}
|
||||
onChange={(event) => setFirstRowHeader(event.target.checked)}
|
||||
/>
|
||||
<span>Use first row as headings</span>
|
||||
</label>
|
||||
</div>
|
||||
{table ? (
|
||||
<DataTable table={table} caption="Tabular data preview" />
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
Analyze valid input to create a table projection.
|
||||
</p>
|
||||
)}
|
||||
<details className="flatten-panel">
|
||||
<summary>Flatten scalar leaves by JSON Pointer</summary>
|
||||
{flattened ? (
|
||||
<DataTable
|
||||
table={flattened}
|
||||
caption="Flattened scalar leaves"
|
||||
/>
|
||||
) : null}
|
||||
</details>
|
||||
</>
|
||||
) : null}
|
||||
{active === "query" ? (
|
||||
<>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">No evaluation</p>
|
||||
<h2>Safe query</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="query-controls">
|
||||
<label className="field">
|
||||
<span>Query syntax</span>
|
||||
<select
|
||||
value={queryMode}
|
||||
onChange={(event) => {
|
||||
const mode = event.target.value as "pointer" | "path";
|
||||
setQueryMode(mode);
|
||||
setQuery(
|
||||
mode === "pointer" ? "/features/0" : "$.features[*]",
|
||||
);
|
||||
}}
|
||||
>
|
||||
<option value="pointer">JSON Pointer (RFC 6901)</option>
|
||||
<option value="path">Safe path subset</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{queryMode === "pointer" ? "Pointer" : "Path"}</span>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
maxLength={DATA_LIMITS.maxQueryCharacters}
|
||||
spellCheck={false}
|
||||
aria-invalid={Boolean(queryResult.error)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="field-help">
|
||||
Safe paths support <code>$.key</code>, <code>$['key']</code>,
|
||||
array indices and <code>[*]</code>. Filters, recursive descent and
|
||||
scripts are deliberately rejected.
|
||||
</p>
|
||||
{queryResult.error ? (
|
||||
<p className="field-error" role="alert">
|
||||
{queryResult.error}
|
||||
</p>
|
||||
) : (
|
||||
<QueryResults matches={queryResult.matches} />
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
{active === "convert" ? (
|
||||
<>
|
||||
<div className="section-heading">
|
||||
<div>
|
||||
<p className="eyebrow">Explicit trade-offs</p>
|
||||
<h2>Convert</h2>
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void copyOutput()}
|
||||
disabled={!conversion.result}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
onClick={() =>
|
||||
conversion.result &&
|
||||
downloadText(conversion.result.text, target)
|
||||
}
|
||||
disabled={!conversion.result}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="conversion-controls">
|
||||
<label className="field">
|
||||
<span>Output format</span>
|
||||
<select
|
||||
value={target}
|
||||
onChange={(event) =>
|
||||
setTarget(event.target.value as DataFormat)
|
||||
}
|
||||
>
|
||||
{DATA_FORMATS.map((format) => (
|
||||
<option key={format} value={format}>
|
||||
{format.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={spreadsheetSafe}
|
||||
onChange={(event) => setSpreadsheetSafe(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<strong>Spreadsheet-safe cells</strong>
|
||||
<small>
|
||||
Prefix formula-like CSV/TSV cells with an apostrophe
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{conversion.error ? (
|
||||
<p className="field-error" role="alert">
|
||||
{conversion.error}
|
||||
</p>
|
||||
) : conversion.result ? (
|
||||
<>
|
||||
<label className="field output-field">
|
||||
<span>Generated output</span>
|
||||
<textarea
|
||||
readOnly
|
||||
value={conversion.result.text}
|
||||
spellCheck={false}
|
||||
data-testid="conversion-output"
|
||||
/>
|
||||
</label>
|
||||
<ConversionDisclosure result={conversion.result} />
|
||||
</>
|
||||
) : (
|
||||
<p className="empty-state">
|
||||
Analyze valid input to generate output.
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</section>
|
||||
<p className="action-notice" role="status" aria-live="polite">
|
||||
{actionNotice}
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
import {
|
||||
isSafeNumber,
|
||||
LosslessNumber,
|
||||
stringify as stringifyLosslessJson,
|
||||
} from "lossless-json";
|
||||
import Papa from "papaparse";
|
||||
import { stringify as stringifyToml, TomlDate } from "smol-toml";
|
||||
import { stringify as stringifyYaml } from "yaml";
|
||||
import { assertBoundedOutput } from "./limits";
|
||||
import { objectValue, scalarText } from "./model";
|
||||
import type {
|
||||
ConversionOptions,
|
||||
ConversionResult,
|
||||
DataFormat,
|
||||
DataNode,
|
||||
LossEvent,
|
||||
ParsedDocument,
|
||||
} from "./types";
|
||||
|
||||
function addEvent(events: LossEvent[], event: LossEvent): void {
|
||||
const previous = events.find(
|
||||
(item) => item.code === event.code && item.message === event.message,
|
||||
);
|
||||
if (previous) previous.count = (previous.count ?? 1) + (event.count ?? 1);
|
||||
else events.push(event);
|
||||
}
|
||||
|
||||
function losslessValue(
|
||||
node: DataNode,
|
||||
events: LossEvent[],
|
||||
path: string,
|
||||
): unknown {
|
||||
switch (node.type) {
|
||||
case "null":
|
||||
return null;
|
||||
case "boolean":
|
||||
return node.value;
|
||||
case "string":
|
||||
return node.value;
|
||||
case "number":
|
||||
return new LosslessNumber(node.raw);
|
||||
case "date":
|
||||
addEvent(events, {
|
||||
code: "date.to-string",
|
||||
message: "Typed date/time values are represented as strings in JSON.",
|
||||
path,
|
||||
severity: "coercion",
|
||||
});
|
||||
return node.value;
|
||||
case "array":
|
||||
return node.items.map((item, index) =>
|
||||
losslessValue(item, events, `${path}/${index}`),
|
||||
);
|
||||
case "object": {
|
||||
const result: Record<string, unknown> = Object.create(null) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const entry of node.entries)
|
||||
result[entry.key] = losslessValue(
|
||||
entry.value,
|
||||
events,
|
||||
`${path}/${entry.key}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function interoperableValue(
|
||||
node: DataNode,
|
||||
target: "yaml" | "toml",
|
||||
events: LossEvent[],
|
||||
path: string,
|
||||
): unknown {
|
||||
switch (node.type) {
|
||||
case "null":
|
||||
if (target === "toml") {
|
||||
addEvent(events, {
|
||||
code: "toml.null",
|
||||
message: "TOML has no null value; nulls become empty strings.",
|
||||
path,
|
||||
severity: "loss",
|
||||
});
|
||||
return "";
|
||||
}
|
||||
return null;
|
||||
case "boolean":
|
||||
return node.value;
|
||||
case "string":
|
||||
return node.value;
|
||||
case "date": {
|
||||
if (target === "toml") {
|
||||
try {
|
||||
return new TomlDate(node.value);
|
||||
} catch {
|
||||
addEvent(events, {
|
||||
code: "toml.date-string",
|
||||
message:
|
||||
"A date could not be represented as a TOML date and remains a string.",
|
||||
path,
|
||||
severity: "coercion",
|
||||
});
|
||||
}
|
||||
}
|
||||
return node.value;
|
||||
}
|
||||
case "number": {
|
||||
if (/^-?(?:0|[1-9]\d*)$/u.test(node.raw)) return BigInt(node.raw);
|
||||
if (isSafeNumber(node.raw, { approx: false })) return Number(node.raw);
|
||||
addEvent(events, {
|
||||
code: `${target}.unsafe-number`,
|
||||
message: `A number outside exact JavaScript floating-point range becomes a string in ${target.toUpperCase()}.`,
|
||||
path,
|
||||
severity: "coercion",
|
||||
});
|
||||
return node.raw;
|
||||
}
|
||||
case "array":
|
||||
return node.items.map((item, index) =>
|
||||
interoperableValue(item, target, events, `${path}/${index}`),
|
||||
);
|
||||
case "object": {
|
||||
const result: Record<string, unknown> = Object.create(null) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (const entry of node.entries)
|
||||
result[entry.key] = interoperableValue(
|
||||
entry.value,
|
||||
target,
|
||||
events,
|
||||
`${path}/${entry.key}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function jsonText(
|
||||
node: DataNode,
|
||||
events: LossEvent[],
|
||||
indentation: number,
|
||||
): string {
|
||||
const text = stringifyLosslessJson(
|
||||
losslessValue(node, events, ""),
|
||||
null,
|
||||
indentation,
|
||||
);
|
||||
if (text === undefined)
|
||||
throw new TypeError("The value cannot be represented as JSON.");
|
||||
return text;
|
||||
}
|
||||
|
||||
function xmlEscapeText(value: string): string {
|
||||
return value
|
||||
.replace(/&/gu, "&")
|
||||
.replace(/</gu, "<")
|
||||
.replace(/>/gu, ">");
|
||||
}
|
||||
|
||||
function xmlEscapeAttribute(value: string): string {
|
||||
return xmlEscapeText(value)
|
||||
.replace(/"/gu, """)
|
||||
.replace(/\r/gu, " ")
|
||||
.replace(/\n/gu, " ");
|
||||
}
|
||||
|
||||
function nodeString(node: DataNode | undefined): string | undefined {
|
||||
return node?.type === "string" ? node.value : undefined;
|
||||
}
|
||||
|
||||
function xmlKind(node: DataNode): string | undefined {
|
||||
return nodeString(objectValue(node, "$kind"));
|
||||
}
|
||||
|
||||
function serializeExplicitXmlElement(node: DataNode, depth: number): string {
|
||||
const name = nodeString(objectValue(node, "name"));
|
||||
if (node.type !== "object" || xmlKind(node) !== "element" || !name)
|
||||
throw new TypeError("The explicit XML model is invalid.");
|
||||
const attributes = objectValue(node, "attributes");
|
||||
const children = objectValue(node, "children");
|
||||
const attributeText =
|
||||
attributes?.type === "object"
|
||||
? attributes.entries
|
||||
.map((entry) => {
|
||||
const raw =
|
||||
entry.value.type === "object"
|
||||
? nodeString(objectValue(entry.value, "value"))
|
||||
: nodeString(entry.value);
|
||||
return ` ${entry.key}="${xmlEscapeAttribute(raw ?? "")}"`;
|
||||
})
|
||||
.join("")
|
||||
: "";
|
||||
if (children?.type !== "array" || children.items.length === 0)
|
||||
return `<${name}${attributeText}/>`;
|
||||
const hasSignificantText = children.items.some(
|
||||
(child) =>
|
||||
xmlKind(child) === "text" &&
|
||||
Boolean(nodeString(objectValue(child, "value"))?.trim()),
|
||||
);
|
||||
const pieces = children.items.map((child) => {
|
||||
const kind = xmlKind(child);
|
||||
const value = nodeString(objectValue(child, "value")) ?? "";
|
||||
if (kind === "element")
|
||||
return serializeExplicitXmlElement(child, depth + 1);
|
||||
if (kind === "text") return xmlEscapeText(value);
|
||||
if (kind === "cdata") return `<![CDATA[${value}]]>`;
|
||||
if (kind === "comment") return `<!--${value}-->`;
|
||||
if (kind === "processing-instruction")
|
||||
return `<?${nodeString(objectValue(child, "name")) ?? "pi"}${value ? ` ${value}` : ""}?>`;
|
||||
return "";
|
||||
});
|
||||
if (hasSignificantText)
|
||||
return `<${name}${attributeText}>${pieces.join("")}</${name}>`;
|
||||
const indentation = " ".repeat(depth + 1);
|
||||
const inner = pieces
|
||||
.filter(Boolean)
|
||||
.map((piece) => `${indentation}${piece}`)
|
||||
.join("\n");
|
||||
return inner
|
||||
? `<${name}${attributeText}>\n${inner}\n${" ".repeat(depth)}</${name}>`
|
||||
: `<${name}${attributeText}></${name}>`;
|
||||
}
|
||||
|
||||
function validXmlName(name: string): boolean {
|
||||
return /^[A-Za-z_][\w.:-]*$/u.test(name);
|
||||
}
|
||||
|
||||
function genericXmlElement(
|
||||
name: string,
|
||||
node: DataNode,
|
||||
events: LossEvent[],
|
||||
path: string,
|
||||
depth: number,
|
||||
keyAttribute?: string,
|
||||
): string {
|
||||
const safeName = validXmlName(name) ? name : "item";
|
||||
const key =
|
||||
safeName === name
|
||||
? ""
|
||||
: ` key="${xmlEscapeAttribute(keyAttribute ?? name)}"`;
|
||||
const indent = " ".repeat(depth);
|
||||
if (node.type === "object") {
|
||||
const content = node.entries
|
||||
.map((entry) =>
|
||||
genericXmlElement(
|
||||
entry.key,
|
||||
entry.value,
|
||||
events,
|
||||
`${path}/${entry.key}`,
|
||||
depth + 1,
|
||||
entry.key,
|
||||
),
|
||||
)
|
||||
.join("\n");
|
||||
return content
|
||||
? `${indent}<${safeName}${key}>\n${content}\n${indent}</${safeName}>`
|
||||
: `${indent}<${safeName}${key}/>`;
|
||||
}
|
||||
if (node.type === "array") {
|
||||
const content = node.items
|
||||
.map((item, index) =>
|
||||
genericXmlElement("item", item, events, `${path}/${index}`, depth + 1),
|
||||
)
|
||||
.join("\n");
|
||||
return content
|
||||
? `${indent}<${safeName}${key}>\n${content}\n${indent}</${safeName}>`
|
||||
: `${indent}<${safeName}${key}/>`;
|
||||
}
|
||||
if (node.type === "null") {
|
||||
addEvent(events, {
|
||||
code: "xml.null",
|
||||
message: "Null becomes an empty XML element.",
|
||||
path,
|
||||
severity: "loss",
|
||||
});
|
||||
return `${indent}<${safeName}${key}/>`;
|
||||
}
|
||||
addEvent(events, {
|
||||
code: "xml.scalar-text",
|
||||
message: "Typed scalar values become XML text.",
|
||||
path,
|
||||
severity: "coercion",
|
||||
});
|
||||
return `${indent}<${safeName}${key}>${xmlEscapeText(scalarText(node))}</${safeName}>`;
|
||||
}
|
||||
|
||||
function xmlText(document: ParsedDocument, events: LossEvent[]): string {
|
||||
if (document.model === "xml-explicit") {
|
||||
addEvent(events, {
|
||||
code: "xml.prolog",
|
||||
message:
|
||||
"Formatting preserves the document element model; XML declaration and nodes outside it are not retained.",
|
||||
path: "",
|
||||
severity: "notice",
|
||||
});
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n${serializeExplicitXmlElement(document.root, 0)}\n`;
|
||||
}
|
||||
addEvent(events, {
|
||||
code: "xml.mapping",
|
||||
message:
|
||||
"Objects become named elements, arrays become repeated <item> elements, and invalid XML keys use a key attribute.",
|
||||
path: "",
|
||||
severity: "notice",
|
||||
});
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>\n${genericXmlElement("root", document.root, events, "", 0)}\n`;
|
||||
}
|
||||
|
||||
function delimitedCell(
|
||||
node: DataNode,
|
||||
events: LossEvent[],
|
||||
path: string,
|
||||
): string {
|
||||
if (node.type === "array" || node.type === "object") {
|
||||
addEvent(events, {
|
||||
code: "delimited.nested-json",
|
||||
message: "Nested values are encoded as compact JSON inside a cell.",
|
||||
path,
|
||||
severity: "coercion",
|
||||
});
|
||||
return jsonText(node, events, 0);
|
||||
}
|
||||
if (node.type === "null") {
|
||||
addEvent(events, {
|
||||
code: "delimited.null-empty",
|
||||
message: "Null becomes an empty cell.",
|
||||
path,
|
||||
severity: "loss",
|
||||
});
|
||||
return "";
|
||||
}
|
||||
if (node.type !== "string")
|
||||
addEvent(events, {
|
||||
code: "delimited.scalar-text",
|
||||
message: "Typed scalar values become cell text.",
|
||||
path,
|
||||
severity: "coercion",
|
||||
});
|
||||
return scalarText(node);
|
||||
}
|
||||
|
||||
function rowsFromNode(node: DataNode, events: LossEvent[]): string[][] {
|
||||
if (
|
||||
node.type === "array" &&
|
||||
node.items.every((item) => item.type === "array")
|
||||
) {
|
||||
return (node.items as Array<Extract<DataNode, { type: "array" }>>).map(
|
||||
(row, rowIndex) =>
|
||||
row.items.map((cell, columnIndex) =>
|
||||
delimitedCell(cell, events, `/${rowIndex}/${columnIndex}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (
|
||||
node.type === "array" &&
|
||||
node.items.every((item) => item.type === "object")
|
||||
) {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of node.items as Array<
|
||||
Extract<DataNode, { type: "object" }>
|
||||
>)
|
||||
for (const entry of item.entries)
|
||||
if (!seen.has(entry.key)) {
|
||||
seen.add(entry.key);
|
||||
keys.push(entry.key);
|
||||
}
|
||||
return [
|
||||
keys,
|
||||
...(node.items as Array<Extract<DataNode, { type: "object" }>>).map(
|
||||
(item, rowIndex) =>
|
||||
keys.map((key) => {
|
||||
const value = item.entries.find((entry) => entry.key === key)
|
||||
?.value ?? { type: "null" };
|
||||
return delimitedCell(value, events, `/${rowIndex}/${key}`);
|
||||
}),
|
||||
),
|
||||
];
|
||||
}
|
||||
if (node.type === "object") {
|
||||
addEvent(events, {
|
||||
code: "delimited.key-value",
|
||||
message: "A standalone object becomes Key and Value rows.",
|
||||
path: "",
|
||||
severity: "notice",
|
||||
});
|
||||
return [
|
||||
["Key", "Value"],
|
||||
...node.entries.map((entry) => [
|
||||
entry.key,
|
||||
delimitedCell(entry.value, events, `/${entry.key}`),
|
||||
]),
|
||||
];
|
||||
}
|
||||
return [[delimitedCell(node, events, "")]];
|
||||
}
|
||||
|
||||
function protectSpreadsheetCells(rows: string[][], events: LossEvent[]): void {
|
||||
let count = 0;
|
||||
for (const row of rows)
|
||||
for (let index = 0; index < row.length; index += 1) {
|
||||
const value = row[index] ?? "";
|
||||
if (/^[=+\-@\t\r]/u.test(value)) {
|
||||
row[index] = `'${value}`;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
if (count)
|
||||
addEvent(events, {
|
||||
code: "csv.formula-neutralized",
|
||||
message:
|
||||
"Potential spreadsheet formulas were prefixed with an apostrophe.",
|
||||
path: "",
|
||||
severity: "coercion",
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
function delimitedText(
|
||||
document: ParsedDocument,
|
||||
target: "csv" | "tsv",
|
||||
options: ConversionOptions,
|
||||
events: LossEvent[],
|
||||
): string {
|
||||
const rows = rowsFromNode(document.root, events);
|
||||
if (options.spreadsheetSafe) protectSpreadsheetCells(rows, events);
|
||||
else
|
||||
addEvent(events, {
|
||||
code: "csv.formula-unchecked",
|
||||
message:
|
||||
"Spreadsheet formula neutralisation is off; treat exported cells as untrusted when opening them in a spreadsheet.",
|
||||
path: "",
|
||||
severity: "notice",
|
||||
});
|
||||
return Papa.unparse(rows, {
|
||||
delimiter: target === "tsv" ? "\t" : ",",
|
||||
newline: "\r\n",
|
||||
quotes: false,
|
||||
});
|
||||
}
|
||||
|
||||
function ndjsonText(node: DataNode, events: LossEvent[]): string {
|
||||
const items = node.type === "array" ? node.items : [node];
|
||||
if (node.type !== "array")
|
||||
addEvent(events, {
|
||||
code: "ndjson.single-record",
|
||||
message: "The root value becomes one NDJSON record.",
|
||||
path: "",
|
||||
severity: "notice",
|
||||
});
|
||||
return `${items.map((item) => jsonText(item, events, 0).replace(/\n/gu, "")).join("\n")}\n`;
|
||||
}
|
||||
|
||||
export function convertDocument(
|
||||
document: ParsedDocument,
|
||||
target: DataFormat,
|
||||
options: ConversionOptions = {},
|
||||
): ConversionResult {
|
||||
const events: LossEvent[] = [];
|
||||
if (document.model === "xml-explicit" && target !== "xml") {
|
||||
addEvent(events, {
|
||||
code: "xml.explicit-conversion",
|
||||
message:
|
||||
"XML converts through the documented explicit element/attribute/children record model.",
|
||||
path: "",
|
||||
severity: "notice",
|
||||
});
|
||||
}
|
||||
let text: string;
|
||||
switch (target) {
|
||||
case "json":
|
||||
text = `${jsonText(document.root, events, 2)}\n`;
|
||||
break;
|
||||
case "yaml":
|
||||
text = stringifyYaml(
|
||||
interoperableValue(document.root, "yaml", events, ""),
|
||||
{ version: "1.2", schema: "core", indent: 2, lineWidth: 0 },
|
||||
);
|
||||
break;
|
||||
case "toml": {
|
||||
let value = interoperableValue(document.root, "toml", events, "");
|
||||
if (document.root.type !== "object") {
|
||||
addEvent(events, {
|
||||
code: "toml.root-table",
|
||||
message:
|
||||
"TOML requires a root table; the value is placed in a ‘value’ field.",
|
||||
path: "",
|
||||
severity: "coercion",
|
||||
});
|
||||
value = { value };
|
||||
}
|
||||
text = stringifyToml(value as never);
|
||||
break;
|
||||
}
|
||||
case "xml":
|
||||
text = xmlText(document, events);
|
||||
break;
|
||||
case "csv":
|
||||
text = delimitedText(document, "csv", options, events);
|
||||
break;
|
||||
case "tsv":
|
||||
text = delimitedText(document, "tsv", options, events);
|
||||
break;
|
||||
case "ndjson":
|
||||
text = ndjsonText(document.root, events);
|
||||
break;
|
||||
}
|
||||
return { text: assertBoundedOutput(text), events, target };
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { DataFormat, Detection } from "./types";
|
||||
|
||||
const EXTENSIONS: Readonly<Record<string, DataFormat>> = Object.freeze({
|
||||
json: "json",
|
||||
jsonl: "ndjson",
|
||||
ndjson: "ndjson",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
toml: "toml",
|
||||
xml: "xml",
|
||||
csv: "csv",
|
||||
tsv: "tsv",
|
||||
tab: "tsv",
|
||||
});
|
||||
|
||||
function extensionOf(filename: string | undefined): DataFormat | undefined {
|
||||
const match = filename?.toLocaleLowerCase().match(/\.([a-z0-9]+)$/u);
|
||||
return match?.[1] ? EXTENSIONS[match[1]] : undefined;
|
||||
}
|
||||
|
||||
function looksLikeDelimited(lines: string[], delimiter: string): boolean {
|
||||
if (lines.length < 2) return false;
|
||||
const counts = lines.slice(0, 12).map((line) => line.split(delimiter).length);
|
||||
const expected = counts[0] ?? 1;
|
||||
return (
|
||||
expected > 1 &&
|
||||
counts.filter((count) => count === expected).length >=
|
||||
Math.min(3, counts.length)
|
||||
);
|
||||
}
|
||||
|
||||
function detectFromContent(source: string): Detection {
|
||||
const trimmed = source.trim();
|
||||
if (!trimmed)
|
||||
return {
|
||||
format: "json",
|
||||
confidence: "low",
|
||||
reason: "Empty input defaults to JSON.",
|
||||
};
|
||||
if (/^<\?xml\b|^<[A-Za-z_][\w:.-]*(?:\s|>|\/)/u.test(trimmed)) {
|
||||
return {
|
||||
format: "xml",
|
||||
confidence: "high",
|
||||
reason: "The first token is an XML element.",
|
||||
};
|
||||
}
|
||||
const nonEmptyLines = trimmed.split(/\r?\n/u).filter((line) => line.trim());
|
||||
if (nonEmptyLines.length > 1) {
|
||||
try {
|
||||
if (nonEmptyLines.every((line) => (JSON.parse(line), true))) {
|
||||
return {
|
||||
format: "ndjson",
|
||||
confidence: "high",
|
||||
reason: "Every non-empty line is a JSON value.",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Continue with the other deterministic heuristics.
|
||||
}
|
||||
}
|
||||
try {
|
||||
JSON.parse(trimmed);
|
||||
return {
|
||||
format: "json",
|
||||
confidence: "high",
|
||||
reason: "The complete input is valid JSON.",
|
||||
};
|
||||
} catch {
|
||||
// A lossless parse will provide the authoritative JSON diagnostic later.
|
||||
}
|
||||
if (/^(?:\[\[?[^\]\r\n]+\]\]?|[A-Za-z0-9_.-]+\s*=)/mu.test(trimmed)) {
|
||||
return {
|
||||
format: "toml",
|
||||
confidence: "medium",
|
||||
reason: "TOML table or assignment syntax was found.",
|
||||
};
|
||||
}
|
||||
if (/^(?:---|%YAML\b)|^[^#\s][^:\r\n]*:\s*(?:$|[^/])/mu.test(trimmed)) {
|
||||
return {
|
||||
format: "yaml",
|
||||
confidence: "medium",
|
||||
reason: "YAML document or mapping syntax was found.",
|
||||
};
|
||||
}
|
||||
const lines = nonEmptyLines.slice(0, 12);
|
||||
if (looksLikeDelimited(lines, "\t")) {
|
||||
return {
|
||||
format: "tsv",
|
||||
confidence: "medium",
|
||||
reason: "Rows have a consistent tab-separated shape.",
|
||||
};
|
||||
}
|
||||
if (looksLikeDelimited(lines, ",")) {
|
||||
return {
|
||||
format: "csv",
|
||||
confidence: "medium",
|
||||
reason: "Rows have a consistent comma-separated shape.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
format: "yaml",
|
||||
confidence: "low",
|
||||
reason: "YAML can represent a standalone scalar; verify the format.",
|
||||
};
|
||||
}
|
||||
|
||||
export function detectFormat(source: string, filename?: string): Detection {
|
||||
const extension = extensionOf(filename);
|
||||
if (extension) {
|
||||
return {
|
||||
format: extension,
|
||||
confidence: "high",
|
||||
reason: `The .${filename?.split(".").pop() ?? ""} extension selects ${extension.toUpperCase()}.`,
|
||||
};
|
||||
}
|
||||
return detectFromContent(source);
|
||||
}
|
||||
|
||||
export function detectContentFormat(source: string): Detection {
|
||||
return detectFromContent(source);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { DataNode, DocumentStats } from "./types";
|
||||
|
||||
export const DATA_LIMITS = Object.freeze({
|
||||
maxInputBytes: 16 * 1024 * 1024,
|
||||
maxTextCharacters: 8_000_000,
|
||||
maxNodes: 100_000,
|
||||
maxDepth: 128,
|
||||
maxCells: 250_000,
|
||||
maxRows: 50_000,
|
||||
maxColumns: 2_000,
|
||||
maxFieldCharacters: 1_000_000,
|
||||
maxOutputCharacters: 16_000_000,
|
||||
maxQueryCharacters: 4_096,
|
||||
maxQueryMatches: 1_000,
|
||||
parseTimeoutMs: 30_000,
|
||||
maxPreviewRows: 250,
|
||||
maxPreviewColumns: 100,
|
||||
});
|
||||
|
||||
export class DataLimitError extends RangeError {
|
||||
readonly actual: number;
|
||||
readonly limit: number;
|
||||
|
||||
constructor(label: string, actual: number, limit: number) {
|
||||
super(
|
||||
`${label} is ${actual.toLocaleString()}; the limit is ${limit.toLocaleString()}.`,
|
||||
);
|
||||
this.name = "DataLimitError";
|
||||
this.actual = actual;
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBoundedSource(source: string): number {
|
||||
if (source.length > DATA_LIMITS.maxTextCharacters) {
|
||||
throw new DataLimitError(
|
||||
"Input character count",
|
||||
source.length,
|
||||
DATA_LIMITS.maxTextCharacters,
|
||||
);
|
||||
}
|
||||
const bytes = new TextEncoder().encode(source).byteLength;
|
||||
if (bytes > DATA_LIMITS.maxInputBytes) {
|
||||
throw new DataLimitError(
|
||||
"UTF-8 input size",
|
||||
bytes,
|
||||
DATA_LIMITS.maxInputBytes,
|
||||
);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export function assertBoundedFile(file: File): void {
|
||||
if (file.size > DATA_LIMITS.maxInputBytes) {
|
||||
throw new DataLimitError("File size", file.size, DATA_LIMITS.maxInputBytes);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertBoundedOutput(output: string): string {
|
||||
if (output.length > DATA_LIMITS.maxOutputCharacters) {
|
||||
throw new DataLimitError(
|
||||
"Generated output character count",
|
||||
output.length,
|
||||
DATA_LIMITS.maxOutputCharacters,
|
||||
);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function scanBracketDepth(
|
||||
source: string,
|
||||
open: string,
|
||||
close: string,
|
||||
): void {
|
||||
let depth = 0;
|
||||
let quoted = false;
|
||||
let escaped = false;
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index];
|
||||
if (quoted) {
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') quoted = false;
|
||||
continue;
|
||||
}
|
||||
if (character === '"') quoted = true;
|
||||
else if (open.includes(character ?? "")) {
|
||||
depth += 1;
|
||||
if (depth > DATA_LIMITS.maxDepth) {
|
||||
throw new DataLimitError(
|
||||
"Structural depth",
|
||||
depth,
|
||||
DATA_LIMITS.maxDepth,
|
||||
);
|
||||
}
|
||||
} else if (close.includes(character ?? "")) depth = Math.max(0, depth - 1);
|
||||
}
|
||||
}
|
||||
|
||||
export function measureNode(
|
||||
root: DataNode,
|
||||
bytes: number,
|
||||
characters: number,
|
||||
): DocumentStats {
|
||||
const stack: Array<{ node: DataNode; depth: number }> = [
|
||||
{ node: root, depth: 0 },
|
||||
];
|
||||
let nodes = 0;
|
||||
let maximumDepth = 0;
|
||||
let scalarCount = 0;
|
||||
while (stack.length > 0) {
|
||||
const item = stack.pop();
|
||||
if (!item) break;
|
||||
nodes += 1;
|
||||
if (nodes > DATA_LIMITS.maxNodes) {
|
||||
throw new DataLimitError(
|
||||
"Parsed node count",
|
||||
nodes,
|
||||
DATA_LIMITS.maxNodes,
|
||||
);
|
||||
}
|
||||
if (item.depth > DATA_LIMITS.maxDepth) {
|
||||
throw new DataLimitError(
|
||||
"Parsed depth",
|
||||
item.depth,
|
||||
DATA_LIMITS.maxDepth,
|
||||
);
|
||||
}
|
||||
maximumDepth = Math.max(maximumDepth, item.depth);
|
||||
if (item.node.type === "array") {
|
||||
for (let index = item.node.items.length - 1; index >= 0; index -= 1) {
|
||||
const child = item.node.items[index];
|
||||
if (child) stack.push({ node: child, depth: item.depth + 1 });
|
||||
}
|
||||
} else if (item.node.type === "object") {
|
||||
for (let index = item.node.entries.length - 1; index >= 0; index -= 1) {
|
||||
const child = item.node.entries[index]?.value;
|
||||
if (child) stack.push({ node: child, depth: item.depth + 1 });
|
||||
}
|
||||
} else scalarCount += 1;
|
||||
}
|
||||
return { bytes, characters, nodes, maximumDepth, scalarCount };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import { isLosslessNumber } from "lossless-json";
|
||||
import { TomlDate } from "smol-toml";
|
||||
import { DataToolsError, type DataNode, type Diagnostic } from "./types";
|
||||
import { DATA_LIMITS, DataLimitError } from "./limits";
|
||||
|
||||
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
||||
|
||||
interface NormalizeState {
|
||||
nodes: number;
|
||||
seen: WeakSet<object>;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
function dateKind(
|
||||
value: TomlDate,
|
||||
): Extract<DataNode, { type: "date" }>["dateKind"] {
|
||||
if (value.isDate()) return "local-date";
|
||||
if (value.isTime()) return "local-time";
|
||||
if (value.isLocal()) return "local-date-time";
|
||||
return "offset-date-time";
|
||||
}
|
||||
|
||||
export function normalizeValue(
|
||||
value: unknown,
|
||||
diagnostics: Diagnostic[] = [],
|
||||
representation: "exact" | "binary" = "binary",
|
||||
): DataNode {
|
||||
const state: NormalizeState = { nodes: 0, seen: new WeakSet(), diagnostics };
|
||||
|
||||
const visit = (current: unknown, depth: number, path: string): DataNode => {
|
||||
state.nodes += 1;
|
||||
if (state.nodes > DATA_LIMITS.maxNodes)
|
||||
throw new DataLimitError(
|
||||
"Parsed node count",
|
||||
state.nodes,
|
||||
DATA_LIMITS.maxNodes,
|
||||
);
|
||||
if (depth > DATA_LIMITS.maxDepth)
|
||||
throw new DataLimitError("Parsed depth", depth, DATA_LIMITS.maxDepth);
|
||||
if (current === null) return { type: "null" };
|
||||
if (typeof current === "string") return { type: "string", value: current };
|
||||
if (typeof current === "boolean")
|
||||
return { type: "boolean", value: current };
|
||||
if (isLosslessNumber(current))
|
||||
return { type: "number", raw: current.value, representation: "exact" };
|
||||
if (typeof current === "bigint")
|
||||
return {
|
||||
type: "number",
|
||||
raw: current.toString(),
|
||||
representation: "bigint",
|
||||
};
|
||||
if (typeof current === "number") {
|
||||
if (!Number.isFinite(current))
|
||||
throw new DataToolsError("Non-finite numbers are not supported.", [
|
||||
{
|
||||
code: "number.non-finite",
|
||||
message: `Non-finite number at ${path}.`,
|
||||
severity: "error",
|
||||
},
|
||||
]);
|
||||
return { type: "number", raw: String(current), representation };
|
||||
}
|
||||
if (current instanceof TomlDate) {
|
||||
return {
|
||||
type: "date",
|
||||
value: current.toISOString(),
|
||||
dateKind: dateKind(current),
|
||||
};
|
||||
}
|
||||
if (current instanceof Date) {
|
||||
return {
|
||||
type: "date",
|
||||
value: current.toISOString(),
|
||||
dateKind: "offset-date-time",
|
||||
};
|
||||
}
|
||||
if (typeof current !== "object") {
|
||||
throw new DataToolsError("The parser produced an unsupported value.", [
|
||||
{
|
||||
code: "model.unsupported",
|
||||
message: `Unsupported ${typeof current} value at ${path}.`,
|
||||
severity: "error",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (state.seen.has(current)) {
|
||||
throw new DataToolsError("Cyclic structures are not supported.", [
|
||||
{
|
||||
code: "model.cycle",
|
||||
message: `A cyclic reference was found at ${path}.`,
|
||||
severity: "error",
|
||||
},
|
||||
]);
|
||||
}
|
||||
state.seen.add(current);
|
||||
try {
|
||||
if (Array.isArray(current)) {
|
||||
return {
|
||||
type: "array",
|
||||
items: current.map((item, index) =>
|
||||
visit(item, depth + 1, `${path}/${index}`),
|
||||
),
|
||||
};
|
||||
}
|
||||
const pairs: Array<[string, unknown]> =
|
||||
current instanceof Map
|
||||
? Array.from(current.entries()).map(([key, item]) => [
|
||||
String(key),
|
||||
item,
|
||||
])
|
||||
: Object.entries(current);
|
||||
const seenKeys = new Set<string>();
|
||||
const entries: Array<{ key: string; value: DataNode }> = [];
|
||||
for (const [key, item] of pairs) {
|
||||
if (DANGEROUS_KEYS.has(key)) {
|
||||
throw new DataToolsError("A dangerous object key was rejected.", [
|
||||
{
|
||||
code: "object.dangerous-key",
|
||||
message: `The key “${key}” is prohibited at ${path}.`,
|
||||
severity: "error",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (seenKeys.has(key)) {
|
||||
throw new DataToolsError("A duplicate mapping key was rejected.", [
|
||||
{
|
||||
code: "object.duplicate-key",
|
||||
message: `Duplicate key “${key}” at ${path}.`,
|
||||
severity: "error",
|
||||
},
|
||||
]);
|
||||
}
|
||||
seenKeys.add(key);
|
||||
entries.push({
|
||||
key,
|
||||
value: visit(item, depth + 1, `${path}/${escapePointerToken(key)}`),
|
||||
});
|
||||
}
|
||||
return { type: "object", entries };
|
||||
} finally {
|
||||
state.seen.delete(current);
|
||||
}
|
||||
};
|
||||
return visit(value, 0, "");
|
||||
}
|
||||
|
||||
export function escapePointerToken(value: string): string {
|
||||
return value.replace(/~/gu, "~0").replace(/\//gu, "~1");
|
||||
}
|
||||
|
||||
export function unescapePointerToken(value: string): string {
|
||||
if (/~(?:[^01]|$)/u.test(value))
|
||||
throw new SyntaxError(`Invalid JSON Pointer escape in “${value}”.`);
|
||||
return value.replace(/~1/gu, "/").replace(/~0/gu, "~");
|
||||
}
|
||||
|
||||
export function objectValue(node: DataNode, key: string): DataNode | undefined {
|
||||
return node.type === "object"
|
||||
? node.entries.find((entry) => entry.key === key)?.value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function scalarText(node: DataNode): string {
|
||||
switch (node.type) {
|
||||
case "null":
|
||||
return "null";
|
||||
case "boolean":
|
||||
return node.value ? "true" : "false";
|
||||
case "number":
|
||||
return node.raw;
|
||||
case "string":
|
||||
return node.value;
|
||||
case "date":
|
||||
return node.value;
|
||||
case "array":
|
||||
return `[${node.items.length} items]`;
|
||||
case "object":
|
||||
return `{${node.entries.length} entries}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
import {
|
||||
DOMParser,
|
||||
type Document as XmlDocument,
|
||||
type Element as XmlElement,
|
||||
} from "@xmldom/xmldom";
|
||||
import { LosslessNumber, parse as parseLosslessJson } from "lossless-json";
|
||||
import Papa from "papaparse";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { parseDocument as parseYamlDocument } from "yaml";
|
||||
import { detectContentFormat, detectFormat } from "./detect";
|
||||
import {
|
||||
assertBoundedSource,
|
||||
DATA_LIMITS,
|
||||
measureNode,
|
||||
scanBracketDepth,
|
||||
} from "./limits";
|
||||
import { normalizeValue } from "./model";
|
||||
import {
|
||||
DataToolsError,
|
||||
type DataFormat,
|
||||
type DataNode,
|
||||
type Detection,
|
||||
type Diagnostic,
|
||||
type ParsedDocument,
|
||||
type ParseRequest,
|
||||
} from "./types";
|
||||
|
||||
function locationAt(source: string, offset: number | undefined) {
|
||||
if (offset === undefined || offset < 0) return undefined;
|
||||
const before = source.slice(0, offset);
|
||||
const lines = before.split(/\n/u);
|
||||
return {
|
||||
offset,
|
||||
line: lines.length,
|
||||
column: (lines.at(-1)?.length ?? 0) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
function parserFailure(
|
||||
message: string,
|
||||
code: string,
|
||||
source: string,
|
||||
offset?: number,
|
||||
): DataToolsError {
|
||||
return new DataToolsError(message, [
|
||||
{ code, message, severity: "error", location: locationAt(source, offset) },
|
||||
]);
|
||||
}
|
||||
|
||||
function rejectDangerousJsonKeys(source: string): void {
|
||||
const prohibited = new Set(["__proto__", "constructor", "prototype"]);
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
if (source[index] !== '"') continue;
|
||||
const start = index;
|
||||
let escaped = false;
|
||||
index += 1;
|
||||
while (index < source.length) {
|
||||
const character = source[index];
|
||||
if (escaped) escaped = false;
|
||||
else if (character === "\\") escaped = true;
|
||||
else if (character === '"') break;
|
||||
index += 1;
|
||||
}
|
||||
if (index >= source.length) return;
|
||||
const literal = source.slice(start, index + 1);
|
||||
let next = index + 1;
|
||||
while (/\s/u.test(source[next] ?? "")) next += 1;
|
||||
if (source[next] !== ":") continue;
|
||||
let key: unknown;
|
||||
try {
|
||||
key = JSON.parse(literal) as unknown;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (typeof key === "string" && prohibited.has(key)) {
|
||||
throw parserFailure(
|
||||
`A dangerous object key was rejected: “${key}”.`,
|
||||
"json.dangerous-key",
|
||||
source,
|
||||
start,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseJson(source: string): DataNode {
|
||||
scanBracketDepth(source, "[{", "]}");
|
||||
rejectDangerousJsonKeys(source);
|
||||
try {
|
||||
const value = parseLosslessJson(source, null, {
|
||||
parseNumber: (raw) => new LosslessNumber(raw),
|
||||
onDuplicateKey: ({ key, position }) => {
|
||||
throw parserFailure(
|
||||
`Duplicate JSON key “${key}”.`,
|
||||
"json.duplicate-key",
|
||||
source,
|
||||
position,
|
||||
);
|
||||
},
|
||||
});
|
||||
return normalizeValue(value, [], "exact");
|
||||
} catch (error) {
|
||||
if (error instanceof DataToolsError) throw error;
|
||||
const message = error instanceof Error ? error.message : "Invalid JSON.";
|
||||
const position = /position\s+(\d+)/iu.exec(message)?.[1];
|
||||
throw parserFailure(
|
||||
message,
|
||||
"json.syntax",
|
||||
source,
|
||||
position ? Number(position) : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function parseYaml(source: string, diagnostics: Diagnostic[]): DataNode {
|
||||
let document;
|
||||
try {
|
||||
document = parseYamlDocument(source, {
|
||||
version: "1.2",
|
||||
schema: "core",
|
||||
customTags: [],
|
||||
merge: false,
|
||||
resolveKnownTags: false,
|
||||
intAsBigInt: true,
|
||||
strict: true,
|
||||
stringKeys: true,
|
||||
uniqueKeys: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Invalid YAML.";
|
||||
throw parserFailure(message, "yaml.syntax", source);
|
||||
}
|
||||
for (const warning of document.warnings) {
|
||||
const diagnostic: Diagnostic = {
|
||||
code: `yaml.${warning.code.toLocaleLowerCase()}`,
|
||||
message: warning.message,
|
||||
severity: warning.code === "TAG_RESOLVE_FAILED" ? "error" : "warning",
|
||||
location: locationAt(source, warning.pos[0]),
|
||||
};
|
||||
if (diagnostic.severity === "error")
|
||||
throw new DataToolsError("Unsupported YAML tag.", [diagnostic]);
|
||||
diagnostics.push(diagnostic);
|
||||
}
|
||||
if (document.errors.length > 0) {
|
||||
throw new DataToolsError(
|
||||
"Invalid YAML 1.2 document.",
|
||||
document.errors.map((item) => ({
|
||||
code: `yaml.${item.code.toLocaleLowerCase()}`,
|
||||
message: item.message,
|
||||
severity: "error",
|
||||
location: locationAt(source, item.pos[0]),
|
||||
})),
|
||||
);
|
||||
}
|
||||
let value: unknown;
|
||||
try {
|
||||
value = document.toJS({ mapAsMap: true, maxAliasCount: 50 });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "YAML alias expansion failed.";
|
||||
throw parserFailure(message, "yaml.alias-limit", source);
|
||||
}
|
||||
const root = normalizeValue(value, diagnostics, "binary");
|
||||
if (containsKey(root, "<<")) {
|
||||
throw parserFailure(
|
||||
"YAML merge keys are disabled in this workbench.",
|
||||
"yaml.merge-disabled",
|
||||
source,
|
||||
);
|
||||
}
|
||||
if (containsRepresentation(root, "binary")) {
|
||||
diagnostics.push({
|
||||
code: "yaml.float-binary",
|
||||
message:
|
||||
"YAML floating-point values use the browser’s binary number representation; conversions disclose unsafe values.",
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function parseTomlDocument(
|
||||
source: string,
|
||||
diagnostics: Diagnostic[],
|
||||
): DataNode {
|
||||
try {
|
||||
const root = normalizeValue(
|
||||
parseToml(source, {
|
||||
integersAsBigInt: true,
|
||||
maxDepth: DATA_LIMITS.maxDepth,
|
||||
}),
|
||||
diagnostics,
|
||||
"binary",
|
||||
);
|
||||
if (containsRepresentation(root, "binary")) {
|
||||
diagnostics.push({
|
||||
code: "toml.float-binary",
|
||||
message:
|
||||
"TOML floats use the browser’s binary number representation; integers remain exact.",
|
||||
severity: "info",
|
||||
});
|
||||
}
|
||||
return root;
|
||||
} catch (error) {
|
||||
if (error instanceof DataToolsError) throw error;
|
||||
const message = error instanceof Error ? error.message : "Invalid TOML.";
|
||||
throw parserFailure(message, "toml.syntax", source);
|
||||
}
|
||||
}
|
||||
|
||||
function containsRepresentation(
|
||||
node: DataNode,
|
||||
representation: "binary",
|
||||
): boolean {
|
||||
if (node.type === "number") return node.representation === representation;
|
||||
if (node.type === "array")
|
||||
return node.items.some((child) =>
|
||||
containsRepresentation(child, representation),
|
||||
);
|
||||
if (node.type === "object")
|
||||
return node.entries.some((entry) =>
|
||||
containsRepresentation(entry.value, representation),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function containsKey(node: DataNode, key: string): boolean {
|
||||
if (node.type === "array")
|
||||
return node.items.some((child) => containsKey(child, key));
|
||||
if (node.type === "object") {
|
||||
return node.entries.some(
|
||||
(entry) => entry.key === key || containsKey(entry.value, key),
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseDelimited(
|
||||
source: string,
|
||||
delimiter: "," | "\t",
|
||||
diagnostics: Diagnostic[],
|
||||
): DataNode {
|
||||
preflightDelimited(source, delimiter);
|
||||
const result = Papa.parse<string[]>(source, {
|
||||
delimiter,
|
||||
dynamicTyping: false,
|
||||
header: false,
|
||||
skipEmptyLines: false,
|
||||
worker: false,
|
||||
});
|
||||
if (result.errors.length > 0) {
|
||||
const fatal = result.errors.find(
|
||||
(error) => error.type === "Quotes" || error.type === "Delimiter",
|
||||
);
|
||||
const messages = result.errors.slice(0, 20).map((error) => ({
|
||||
code: `delimited.${error.code.toLocaleLowerCase()}`,
|
||||
message: error.message,
|
||||
severity: fatal === error ? ("error" as const) : ("warning" as const),
|
||||
location: error.row === undefined ? undefined : { line: error.row + 1 },
|
||||
}));
|
||||
if (fatal)
|
||||
throw new DataToolsError("The delimited input is malformed.", messages);
|
||||
diagnostics.push(...messages);
|
||||
}
|
||||
const rows = result.data;
|
||||
if (rows.length > DATA_LIMITS.maxRows)
|
||||
throw new RangeError(
|
||||
`Row count exceeds ${DATA_LIMITS.maxRows.toLocaleString()}.`,
|
||||
);
|
||||
let cells = 0;
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
if (row.length > DATA_LIMITS.maxColumns)
|
||||
throw new RangeError(
|
||||
`Row ${rowIndex + 1} exceeds ${DATA_LIMITS.maxColumns.toLocaleString()} columns.`,
|
||||
);
|
||||
cells += row.length;
|
||||
if (cells > DATA_LIMITS.maxCells)
|
||||
throw new RangeError(
|
||||
`Cell count exceeds ${DATA_LIMITS.maxCells.toLocaleString()}.`,
|
||||
);
|
||||
for (const field of row) {
|
||||
if (field.length > DATA_LIMITS.maxFieldCharacters)
|
||||
throw new RangeError(
|
||||
`A field exceeds ${DATA_LIMITS.maxFieldCharacters.toLocaleString()} characters.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
diagnostics.push({
|
||||
code: "delimited.strings",
|
||||
message:
|
||||
"CSV and TSV fields remain strings; no automatic number, date or boolean coercion is applied.",
|
||||
severity: "info",
|
||||
});
|
||||
return normalizeValue(rows, diagnostics, "exact");
|
||||
}
|
||||
|
||||
function preflightDelimited(source: string, delimiter: "," | "\t"): void {
|
||||
let rows = 0;
|
||||
let cells = 0;
|
||||
let columns = 0;
|
||||
let fieldCharacters = 0;
|
||||
let quoted = false;
|
||||
let atFieldStart = true;
|
||||
|
||||
const finishField = () => {
|
||||
if (fieldCharacters > DATA_LIMITS.maxFieldCharacters)
|
||||
throw new RangeError(
|
||||
`A field exceeds ${DATA_LIMITS.maxFieldCharacters.toLocaleString()} characters.`,
|
||||
);
|
||||
columns += 1;
|
||||
cells += 1;
|
||||
if (columns > DATA_LIMITS.maxColumns)
|
||||
throw new RangeError(
|
||||
`A row exceeds ${DATA_LIMITS.maxColumns.toLocaleString()} columns.`,
|
||||
);
|
||||
if (cells > DATA_LIMITS.maxCells)
|
||||
throw new RangeError(
|
||||
`Cell count exceeds ${DATA_LIMITS.maxCells.toLocaleString()}.`,
|
||||
);
|
||||
fieldCharacters = 0;
|
||||
atFieldStart = true;
|
||||
};
|
||||
const finishRow = () => {
|
||||
finishField();
|
||||
rows += 1;
|
||||
if (rows > DATA_LIMITS.maxRows)
|
||||
throw new RangeError(
|
||||
`Row count exceeds ${DATA_LIMITS.maxRows.toLocaleString()}.`,
|
||||
);
|
||||
columns = 0;
|
||||
};
|
||||
|
||||
for (let index = 0; index < source.length; index += 1) {
|
||||
const character = source[index] ?? "";
|
||||
if (quoted) {
|
||||
if (character === '"') {
|
||||
if (source[index + 1] === '"') {
|
||||
fieldCharacters += 1;
|
||||
index += 1;
|
||||
} else quoted = false;
|
||||
} else fieldCharacters += 1;
|
||||
} else if (character === '"' && atFieldStart) {
|
||||
quoted = true;
|
||||
atFieldStart = false;
|
||||
} else if (character === delimiter) {
|
||||
finishField();
|
||||
} else if (character === "\r" || character === "\n") {
|
||||
if (character === "\r" && source[index + 1] === "\n") index += 1;
|
||||
finishRow();
|
||||
} else {
|
||||
fieldCharacters += 1;
|
||||
atFieldStart = false;
|
||||
}
|
||||
if (fieldCharacters > DATA_LIMITS.maxFieldCharacters)
|
||||
throw new RangeError(
|
||||
`A field exceeds ${DATA_LIMITS.maxFieldCharacters.toLocaleString()} characters.`,
|
||||
);
|
||||
}
|
||||
finishRow();
|
||||
}
|
||||
|
||||
function parseNdjson(source: string, diagnostics: Diagnostic[]): DataNode {
|
||||
const lines = source.split(/\r?\n/u);
|
||||
if (lines.length > DATA_LIMITS.maxRows)
|
||||
throw new RangeError(
|
||||
`Line count exceeds ${DATA_LIMITS.maxRows.toLocaleString()}.`,
|
||||
);
|
||||
const items: DataNode[] = [];
|
||||
let blankLines = 0;
|
||||
for (const [index, line] of lines.entries()) {
|
||||
if (!line.trim()) {
|
||||
blankLines += 1;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
items.push(parseJson(line));
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Invalid JSON value.";
|
||||
throw new DataToolsError(`NDJSON line ${index + 1} is invalid.`, [
|
||||
{
|
||||
code: "ndjson.invalid-line",
|
||||
message,
|
||||
severity: "error",
|
||||
location: { line: index + 1, column: 1 },
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (blankLines > 0)
|
||||
diagnostics.push({
|
||||
code: "ndjson.blank-lines",
|
||||
message: `${blankLines} blank line${blankLines === 1 ? " was" : "s were"} ignored.`,
|
||||
severity: "info",
|
||||
});
|
||||
return { type: "array", items };
|
||||
}
|
||||
|
||||
function scanXmlDepth(source: string): void {
|
||||
let depth = 0;
|
||||
const tags = source.match(/<\/?[A-Za-z_][^<>]*?>/gu) ?? [];
|
||||
for (const tag of tags) {
|
||||
if (tag.startsWith("</")) depth = Math.max(0, depth - 1);
|
||||
else if (!tag.endsWith("/>")) {
|
||||
depth += 1;
|
||||
if (depth > DATA_LIMITS.maxDepth)
|
||||
throw new RangeError(`XML depth exceeds ${DATA_LIMITS.maxDepth}.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function xmlElementModel(
|
||||
element: XmlElement,
|
||||
depth = 0,
|
||||
counter = { value: 0 },
|
||||
): unknown {
|
||||
counter.value += 1;
|
||||
if (counter.value > DATA_LIMITS.maxNodes)
|
||||
throw new RangeError(
|
||||
`XML node count exceeds ${DATA_LIMITS.maxNodes.toLocaleString()}.`,
|
||||
);
|
||||
if (depth > DATA_LIMITS.maxDepth)
|
||||
throw new RangeError(`XML depth exceeds ${DATA_LIMITS.maxDepth}.`);
|
||||
const attributes: Record<string, unknown> = Object.create(null) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
for (let index = 0; index < element.attributes.length; index += 1) {
|
||||
const attribute = element.attributes.item(index);
|
||||
if (!attribute) continue;
|
||||
attributes[attribute.name] = attribute.namespaceURI
|
||||
? { value: attribute.value, namespace: attribute.namespaceURI }
|
||||
: attribute.value;
|
||||
}
|
||||
const children: unknown[] = [];
|
||||
for (let child = element.firstChild; child; child = child.nextSibling) {
|
||||
counter.value += 1;
|
||||
if (counter.value > DATA_LIMITS.maxNodes)
|
||||
throw new RangeError(
|
||||
`XML node count exceeds ${DATA_LIMITS.maxNodes.toLocaleString()}.`,
|
||||
);
|
||||
if (child.nodeType === 1)
|
||||
children.push(xmlElementModel(child as XmlElement, depth + 1, counter));
|
||||
else if (child.nodeType === 3)
|
||||
children.push({ $kind: "text", value: child.nodeValue ?? "" });
|
||||
else if (child.nodeType === 4)
|
||||
children.push({ $kind: "cdata", value: child.nodeValue ?? "" });
|
||||
else if (child.nodeType === 8)
|
||||
children.push({ $kind: "comment", value: child.nodeValue ?? "" });
|
||||
else if (child.nodeType === 7)
|
||||
children.push({
|
||||
$kind: "processing-instruction",
|
||||
name: child.nodeName,
|
||||
value: child.nodeValue ?? "",
|
||||
});
|
||||
}
|
||||
return {
|
||||
$kind: "element",
|
||||
name: element.tagName,
|
||||
namespace: element.namespaceURI,
|
||||
attributes,
|
||||
children,
|
||||
};
|
||||
}
|
||||
|
||||
function parseXml(source: string, diagnostics: Diagnostic[]): DataNode {
|
||||
if (/<\s*!\s*(?:DOCTYPE|ENTITY)\b/iu.test(source)) {
|
||||
throw parserFailure(
|
||||
"XML DOCTYPE and entity declarations are rejected.",
|
||||
"xml.doctype-rejected",
|
||||
source,
|
||||
);
|
||||
}
|
||||
if (
|
||||
/<\s*xi:include\b/iu.test(source) ||
|
||||
/\bxmlns(?::[A-Za-z_][\w.-]*)?\s*=\s*["']http:\/\/www\.w3\.org\/2001\/XInclude["']/iu.test(
|
||||
source,
|
||||
)
|
||||
) {
|
||||
throw parserFailure(
|
||||
"XML XInclude is not supported.",
|
||||
"xml.xinclude-rejected",
|
||||
source,
|
||||
);
|
||||
}
|
||||
scanXmlDepth(source);
|
||||
const warnings: Diagnostic[] = [];
|
||||
let document: XmlDocument;
|
||||
try {
|
||||
document = new DOMParser({
|
||||
onError(level, message, context) {
|
||||
const diagnostic: Diagnostic = {
|
||||
code: `xml.${level.toLocaleLowerCase()}`,
|
||||
message,
|
||||
severity: level === "warning" ? "warning" : "error",
|
||||
location: context?.locator
|
||||
? {
|
||||
line: context.locator.lineNumber,
|
||||
column: context.locator.columnNumber,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
if (diagnostic.severity === "error")
|
||||
throw new DataToolsError("Invalid XML document.", [diagnostic]);
|
||||
warnings.push(diagnostic);
|
||||
},
|
||||
}).parseFromString(source, "application/xml");
|
||||
} catch (error) {
|
||||
if (error instanceof DataToolsError) throw error;
|
||||
const message = error instanceof Error ? error.message : "Invalid XML.";
|
||||
throw parserFailure(message, "xml.syntax", source);
|
||||
}
|
||||
if (!document.documentElement)
|
||||
throw parserFailure("XML has no document element.", "xml.no-root", source);
|
||||
diagnostics.push(...warnings, {
|
||||
code: "xml.explicit-model",
|
||||
message:
|
||||
"XML is represented explicitly as element, namespace, attribute and ordered child records. The XML declaration and nodes outside the document element are not retained.",
|
||||
severity: "info",
|
||||
});
|
||||
return normalizeValue(
|
||||
xmlElementModel(document.documentElement),
|
||||
diagnostics,
|
||||
"exact",
|
||||
);
|
||||
}
|
||||
|
||||
function selectedDetection(request: ParseRequest): {
|
||||
selected: DataFormat;
|
||||
detection: Detection;
|
||||
diagnostics: Diagnostic[];
|
||||
} {
|
||||
const content = detectContentFormat(request.source);
|
||||
const automatic = detectFormat(request.source, request.filename);
|
||||
const selected =
|
||||
request.format === "auto" ? automatic.format : request.format;
|
||||
const detection: Detection =
|
||||
request.format === "auto"
|
||||
? automatic
|
||||
: {
|
||||
format: selected,
|
||||
confidence: "high",
|
||||
reason: `${selected.toUpperCase()} was selected explicitly.`,
|
||||
};
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
if (content.confidence === "high" && content.format !== selected) {
|
||||
diagnostics.push({
|
||||
code: "format.possible-mismatch",
|
||||
message: `The selected ${selected.toUpperCase()} format differs from content that looks like ${content.format.toUpperCase()}.`,
|
||||
severity: "warning",
|
||||
});
|
||||
}
|
||||
return { selected, detection, diagnostics };
|
||||
}
|
||||
|
||||
export function parseDataDocument(request: ParseRequest): ParsedDocument {
|
||||
let bytes: number;
|
||||
try {
|
||||
bytes = assertBoundedSource(request.source);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Input exceeds a safety limit.";
|
||||
throw parserFailure(message, "limit.input", request.source);
|
||||
}
|
||||
const { selected, detection, diagnostics } = selectedDetection(request);
|
||||
let root: DataNode;
|
||||
try {
|
||||
switch (selected) {
|
||||
case "json":
|
||||
root = parseJson(request.source);
|
||||
break;
|
||||
case "yaml":
|
||||
root = parseYaml(request.source, diagnostics);
|
||||
break;
|
||||
case "toml":
|
||||
root = parseTomlDocument(request.source, diagnostics);
|
||||
break;
|
||||
case "xml":
|
||||
root = parseXml(request.source, diagnostics);
|
||||
break;
|
||||
case "csv":
|
||||
root = parseDelimited(request.source, ",", diagnostics);
|
||||
break;
|
||||
case "tsv":
|
||||
root = parseDelimited(request.source, "\t", diagnostics);
|
||||
break;
|
||||
case "ndjson":
|
||||
root = parseNdjson(request.source, diagnostics);
|
||||
break;
|
||||
}
|
||||
const stats = measureNode(root, bytes, request.source.length);
|
||||
return {
|
||||
format: selected,
|
||||
detection,
|
||||
root,
|
||||
diagnostics,
|
||||
stats,
|
||||
model:
|
||||
selected === "xml"
|
||||
? "xml-explicit"
|
||||
: selected === "csv" || selected === "tsv"
|
||||
? "tabular"
|
||||
: "native",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DataToolsError) throw error;
|
||||
const message =
|
||||
error instanceof Error ? error.message : "The input could not be parsed.";
|
||||
throw parserFailure(message, `parse.${selected}`, request.source);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
import { DATA_LIMITS, DataLimitError } from "./limits";
|
||||
import { escapePointerToken, scalarText, unescapePointerToken } from "./model";
|
||||
import type { DataNode, QueryMatch, TableModel } from "./types";
|
||||
|
||||
function childAt(node: DataNode, token: string): DataNode | undefined {
|
||||
if (node.type === "object")
|
||||
return node.entries.find((entry) => entry.key === token)?.value;
|
||||
if (node.type === "array" && /^(?:0|[1-9]\d*)$/u.test(token))
|
||||
return node.items[Number(token)];
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function queryPointer(root: DataNode, pointer: string): QueryMatch[] {
|
||||
assertBoundedQuery(pointer);
|
||||
if (pointer === "") return [{ pointer: "", node: root }];
|
||||
if (!pointer.startsWith("/"))
|
||||
throw new SyntaxError("A JSON Pointer is empty or starts with ‘/’.");
|
||||
const tokens = pointer.slice(1).split("/").map(unescapePointerToken);
|
||||
let current: DataNode | undefined = root;
|
||||
for (const token of tokens) {
|
||||
current = current ? childAt(current, token) : undefined;
|
||||
if (!current) return [];
|
||||
}
|
||||
return [{ pointer, node: current }];
|
||||
}
|
||||
|
||||
type PathStep =
|
||||
| { kind: "key"; key: string }
|
||||
| { kind: "index"; index: number }
|
||||
| { kind: "wildcard" };
|
||||
|
||||
function decodeSingleQuoted(value: string): string {
|
||||
return value.replace(/\\(['\\])/gu, "$1");
|
||||
}
|
||||
|
||||
export function parseSafePath(path: string): PathStep[] {
|
||||
assertBoundedQuery(path);
|
||||
if (!path.startsWith("$"))
|
||||
throw new SyntaxError("A safe path starts with ‘$’.");
|
||||
const steps: PathStep[] = [];
|
||||
let rest = path.slice(1);
|
||||
while (rest) {
|
||||
let match = /^\.([A-Za-z_$][\w$]*|\*)/u.exec(rest);
|
||||
if (match) {
|
||||
steps.push(
|
||||
match[1] === "*"
|
||||
? { kind: "wildcard" }
|
||||
: { kind: "key", key: match[1] ?? "" },
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
match = /^\[(\d+|\*)\]/u.exec(rest);
|
||||
if (match) {
|
||||
steps.push(
|
||||
match[1] === "*"
|
||||
? { kind: "wildcard" }
|
||||
: { kind: "index", index: Number(match[1]) },
|
||||
);
|
||||
rest = rest.slice(match[0].length);
|
||||
continue;
|
||||
}
|
||||
const doubleQuoted = /^\[("(?:[^"\\]|\\.)*")\]/u.exec(rest);
|
||||
if (doubleQuoted) {
|
||||
steps.push({
|
||||
kind: "key",
|
||||
key: JSON.parse(doubleQuoted[1] ?? '""') as string,
|
||||
});
|
||||
rest = rest.slice(doubleQuoted[0].length);
|
||||
continue;
|
||||
}
|
||||
const singleQuoted = /^\['((?:[^'\\]|\\['\\])*)'\]/u.exec(rest);
|
||||
if (singleQuoted) {
|
||||
steps.push({
|
||||
kind: "key",
|
||||
key: decodeSingleQuoted(singleQuoted[1] ?? ""),
|
||||
});
|
||||
rest = rest.slice(singleQuoted[0].length);
|
||||
continue;
|
||||
}
|
||||
throw new SyntaxError(
|
||||
`Unsupported path syntax near “${rest.slice(0, 24)}”. Filters, scripts and recursive descent are intentionally unavailable.`,
|
||||
);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function assertBoundedQuery(query: string): void {
|
||||
if (query.length > DATA_LIMITS.maxQueryCharacters) {
|
||||
throw new DataLimitError(
|
||||
"Query character count",
|
||||
query.length,
|
||||
DATA_LIMITS.maxQueryCharacters,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function querySafePath(root: DataNode, path: string): QueryMatch[] {
|
||||
let matches: QueryMatch[] = [{ pointer: "", node: root }];
|
||||
for (const step of parseSafePath(path)) {
|
||||
const next: QueryMatch[] = [];
|
||||
const add = (match: QueryMatch) => {
|
||||
next.push(match);
|
||||
if (next.length > DATA_LIMITS.maxQueryMatches) {
|
||||
throw new RangeError(
|
||||
`Query matches exceed the ${DATA_LIMITS.maxQueryMatches.toLocaleString()} result limit.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
for (const match of matches) {
|
||||
if (step.kind === "key") {
|
||||
const child = childAt(match.node, step.key);
|
||||
if (child)
|
||||
add({
|
||||
pointer: `${match.pointer}/${escapePointerToken(step.key)}`,
|
||||
node: child,
|
||||
});
|
||||
} else if (step.kind === "index") {
|
||||
const child =
|
||||
match.node.type === "array"
|
||||
? match.node.items[step.index]
|
||||
: undefined;
|
||||
if (child)
|
||||
add({ pointer: `${match.pointer}/${step.index}`, node: child });
|
||||
} else if (match.node.type === "array") {
|
||||
match.node.items.forEach((node, index) =>
|
||||
add({ pointer: `${match.pointer}/${index}`, node }),
|
||||
);
|
||||
} else if (match.node.type === "object") {
|
||||
match.node.entries.forEach((entry) =>
|
||||
add({
|
||||
pointer: `${match.pointer}/${escapePointerToken(entry.key)}`,
|
||||
node: entry.value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
matches = next;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function runQuery(
|
||||
root: DataNode,
|
||||
mode: "pointer" | "path",
|
||||
query: string,
|
||||
): QueryMatch[] {
|
||||
return mode === "pointer"
|
||||
? queryPointer(root, query)
|
||||
: querySafePath(root, query);
|
||||
}
|
||||
|
||||
export interface TreeRow {
|
||||
pointer: string;
|
||||
depth: number;
|
||||
label: string;
|
||||
type: DataNode["type"];
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export function treeRows(
|
||||
root: DataNode,
|
||||
maximum = 2_000,
|
||||
): { rows: TreeRow[]; truncated: boolean } {
|
||||
const rows: TreeRow[] = [];
|
||||
const stack: Array<{
|
||||
node: DataNode;
|
||||
pointer: string;
|
||||
label: string;
|
||||
depth: number;
|
||||
}> = [{ node: root, pointer: "", label: "$", depth: 0 }];
|
||||
let truncated = false;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
if (rows.length >= maximum) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
rows.push({
|
||||
pointer: current.pointer,
|
||||
depth: current.depth,
|
||||
label: current.label,
|
||||
type: current.node.type,
|
||||
summary: scalarText(current.node),
|
||||
});
|
||||
if (current.node.type === "array") {
|
||||
for (let index = current.node.items.length - 1; index >= 0; index -= 1) {
|
||||
const child = current.node.items[index];
|
||||
if (child)
|
||||
stack.push({
|
||||
node: child,
|
||||
pointer: `${current.pointer}/${index}`,
|
||||
label: `[${index}]`,
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
}
|
||||
} else if (current.node.type === "object") {
|
||||
for (
|
||||
let index = current.node.entries.length - 1;
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const entry = current.node.entries[index];
|
||||
if (entry)
|
||||
stack.push({
|
||||
node: entry.value,
|
||||
pointer: `${current.pointer}/${escapePointerToken(entry.key)}`,
|
||||
label: entry.key,
|
||||
depth: current.depth + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { rows, truncated };
|
||||
}
|
||||
|
||||
function cellText(node: DataNode): string {
|
||||
if (node.type === "array" || node.type === "object") return scalarText(node);
|
||||
return scalarText(node);
|
||||
}
|
||||
|
||||
function boundedTable(
|
||||
columns: string[],
|
||||
rows: string[][],
|
||||
totalRows = rows.length,
|
||||
totalColumns = columns.length,
|
||||
): TableModel {
|
||||
const columnLimit = DATA_LIMITS.maxPreviewColumns;
|
||||
const rowLimit = DATA_LIMITS.maxPreviewRows;
|
||||
const visibleColumns = columns.slice(0, columnLimit);
|
||||
return {
|
||||
columns: visibleColumns,
|
||||
rows: rows.slice(0, rowLimit).map((row) => row.slice(0, columnLimit)),
|
||||
truncatedRows: Math.max(0, totalRows - rowLimit),
|
||||
truncatedColumns: Math.max(0, totalColumns - columnLimit),
|
||||
};
|
||||
}
|
||||
|
||||
export function tableFromNode(
|
||||
root: DataNode,
|
||||
firstRowHeader = false,
|
||||
): TableModel {
|
||||
if (
|
||||
root.type === "array" &&
|
||||
root.items.every((item) => item.type === "array")
|
||||
) {
|
||||
const rowNodes = root.items as Array<Extract<DataNode, { type: "array" }>>;
|
||||
const width = rowNodes.reduce(
|
||||
(maximum, row) => Math.max(maximum, row.items.length),
|
||||
0,
|
||||
);
|
||||
const first = firstRowHeader ? rowNodes[0] : undefined;
|
||||
const visibleWidth = Math.min(width, DATA_LIMITS.maxPreviewColumns);
|
||||
const columns = first
|
||||
? Array.from(
|
||||
{ length: visibleWidth },
|
||||
(_, index) =>
|
||||
cellText(first.items[index] ?? { type: "string", value: "" }) ||
|
||||
`Column ${index + 1}`,
|
||||
)
|
||||
: Array.from(
|
||||
{ length: visibleWidth },
|
||||
(_, index) => `Column ${index + 1}`,
|
||||
);
|
||||
const rows = rowNodes
|
||||
.slice(first ? 1 : 0, (first ? 1 : 0) + DATA_LIMITS.maxPreviewRows)
|
||||
.map((row) =>
|
||||
Array.from({ length: visibleWidth }, (_, index) =>
|
||||
cellText(row.items[index] ?? { type: "string", value: "" }),
|
||||
),
|
||||
);
|
||||
return boundedTable(
|
||||
columns,
|
||||
rows,
|
||||
Math.max(0, rowNodes.length - (first ? 1 : 0)),
|
||||
width,
|
||||
);
|
||||
}
|
||||
if (
|
||||
root.type === "array" &&
|
||||
root.items.every((item) => item.type === "object")
|
||||
) {
|
||||
const keys: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const item of root.items as Array<
|
||||
Extract<DataNode, { type: "object" }>
|
||||
>) {
|
||||
for (const entry of item.entries)
|
||||
if (!seen.has(entry.key)) {
|
||||
seen.add(entry.key);
|
||||
keys.push(entry.key);
|
||||
}
|
||||
}
|
||||
const visibleKeys = keys.slice(0, DATA_LIMITS.maxPreviewColumns);
|
||||
const objectRows = root.items as Array<
|
||||
Extract<DataNode, { type: "object" }>
|
||||
>;
|
||||
const rows = objectRows.slice(0, DATA_LIMITS.maxPreviewRows).map((item) => {
|
||||
const values = new Map(
|
||||
item.entries.map((entry) => [entry.key, entry.value] as const),
|
||||
);
|
||||
return visibleKeys.map((key) => {
|
||||
const value = values.get(key);
|
||||
return value ? cellText(value) : "";
|
||||
});
|
||||
});
|
||||
return boundedTable(visibleKeys, rows, objectRows.length, keys.length);
|
||||
}
|
||||
if (root.type === "object") {
|
||||
return boundedTable(
|
||||
["Key", "Type", "Value"],
|
||||
root.entries
|
||||
.slice(0, DATA_LIMITS.maxPreviewRows)
|
||||
.map((entry) => [entry.key, entry.value.type, cellText(entry.value)]),
|
||||
root.entries.length,
|
||||
3,
|
||||
);
|
||||
}
|
||||
return boundedTable(
|
||||
["Path", "Type", "Value"],
|
||||
[["", root.type, cellText(root)]],
|
||||
);
|
||||
}
|
||||
|
||||
export function flattenRows(
|
||||
root: DataNode,
|
||||
maximum = DATA_LIMITS.maxQueryMatches,
|
||||
): TableModel {
|
||||
const rows: string[][] = [];
|
||||
const stack: Array<{ node: DataNode; pointer: string }> = [
|
||||
{ node: root, pointer: "" },
|
||||
];
|
||||
let omitted = 0;
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
if (current.node.type === "array") {
|
||||
for (let index = current.node.items.length - 1; index >= 0; index -= 1) {
|
||||
const child = current.node.items[index];
|
||||
if (child)
|
||||
stack.push({ node: child, pointer: `${current.pointer}/${index}` });
|
||||
}
|
||||
} else if (current.node.type === "object") {
|
||||
for (
|
||||
let index = current.node.entries.length - 1;
|
||||
index >= 0;
|
||||
index -= 1
|
||||
) {
|
||||
const entry = current.node.entries[index];
|
||||
if (entry)
|
||||
stack.push({
|
||||
node: entry.value,
|
||||
pointer: `${current.pointer}/${escapePointerToken(entry.key)}`,
|
||||
});
|
||||
}
|
||||
} else if (rows.length < maximum)
|
||||
rows.push([current.pointer, current.node.type, scalarText(current.node)]);
|
||||
else omitted += 1;
|
||||
}
|
||||
const table = boundedTable(["JSON Pointer", "Type", "Value"], rows);
|
||||
table.truncatedRows += omitted;
|
||||
return table;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
export const DATA_FORMATS = [
|
||||
"json",
|
||||
"yaml",
|
||||
"toml",
|
||||
"xml",
|
||||
"csv",
|
||||
"tsv",
|
||||
"ndjson",
|
||||
] as const;
|
||||
|
||||
export type DataFormat = (typeof DATA_FORMATS)[number];
|
||||
export type FormatSelection = DataFormat | "auto";
|
||||
export type Severity = "error" | "warning" | "info";
|
||||
|
||||
export interface SourceLocation {
|
||||
line?: number;
|
||||
column?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface Diagnostic {
|
||||
code: string;
|
||||
message: string;
|
||||
severity: Severity;
|
||||
location?: SourceLocation;
|
||||
}
|
||||
|
||||
export type DataNode =
|
||||
| { type: "null" }
|
||||
| { type: "boolean"; value: boolean }
|
||||
| { type: "string"; value: string }
|
||||
| {
|
||||
type: "number";
|
||||
raw: string;
|
||||
representation: "exact" | "bigint" | "binary";
|
||||
}
|
||||
| {
|
||||
type: "date";
|
||||
value: string;
|
||||
dateKind:
|
||||
"offset-date-time" | "local-date-time" | "local-date" | "local-time";
|
||||
}
|
||||
| { type: "array"; items: DataNode[] }
|
||||
| { type: "object"; entries: Array<{ key: string; value: DataNode }> };
|
||||
|
||||
export interface Detection {
|
||||
format: DataFormat;
|
||||
confidence: "high" | "medium" | "low";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface DocumentStats {
|
||||
bytes: number;
|
||||
characters: number;
|
||||
nodes: number;
|
||||
maximumDepth: number;
|
||||
scalarCount: number;
|
||||
}
|
||||
|
||||
export interface ParsedDocument {
|
||||
format: DataFormat;
|
||||
detection: Detection;
|
||||
root: DataNode;
|
||||
diagnostics: Diagnostic[];
|
||||
stats: DocumentStats;
|
||||
model: "native" | "tabular" | "xml-explicit";
|
||||
}
|
||||
|
||||
export interface ParseRequest {
|
||||
source: string;
|
||||
format: FormatSelection;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
export interface ParseFailureData {
|
||||
message: string;
|
||||
diagnostics: Diagnostic[];
|
||||
}
|
||||
|
||||
export interface LossEvent {
|
||||
code: string;
|
||||
message: string;
|
||||
path: string;
|
||||
severity: "loss" | "coercion" | "notice";
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface ConversionOptions {
|
||||
spreadsheetSafe?: boolean;
|
||||
firstRowHeader?: boolean;
|
||||
}
|
||||
|
||||
export interface ConversionResult {
|
||||
text: string;
|
||||
events: LossEvent[];
|
||||
target: DataFormat;
|
||||
}
|
||||
|
||||
export interface QueryMatch {
|
||||
pointer: string;
|
||||
node: DataNode;
|
||||
}
|
||||
|
||||
export interface TableModel {
|
||||
columns: string[];
|
||||
rows: string[][];
|
||||
truncatedRows: number;
|
||||
truncatedColumns: number;
|
||||
}
|
||||
|
||||
export class DataToolsError extends Error {
|
||||
readonly diagnostics: Diagnostic[];
|
||||
|
||||
constructor(message: string, diagnostics: Diagnostic[]) {
|
||||
super(message);
|
||||
this.name = "DataToolsError";
|
||||
this.diagnostics = diagnostics;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { parseDataDocument } from "./parse";
|
||||
import { DATA_LIMITS } from "./limits";
|
||||
import {
|
||||
DataToolsError,
|
||||
type ParsedDocument,
|
||||
type ParseRequest,
|
||||
} from "./types";
|
||||
|
||||
interface WorkerResponseOk {
|
||||
id: number;
|
||||
ok: true;
|
||||
document: ParsedDocument;
|
||||
}
|
||||
|
||||
interface WorkerResponseError {
|
||||
id: number;
|
||||
ok: false;
|
||||
message: string;
|
||||
diagnostics: DataToolsError["diagnostics"];
|
||||
}
|
||||
|
||||
export interface ParseTask {
|
||||
promise: Promise<ParsedDocument>;
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
export function createParseTask(request: ParseRequest): ParseTask {
|
||||
const id = ++requestId;
|
||||
if (typeof Worker === "undefined") {
|
||||
let cancelled = false;
|
||||
return {
|
||||
promise: Promise.resolve().then(() => {
|
||||
if (cancelled)
|
||||
throw new DOMException("Parsing cancelled.", "AbortError");
|
||||
return parseDataDocument(request);
|
||||
}),
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
const worker = new Worker(
|
||||
new URL("../workers/data.worker.ts", import.meta.url),
|
||||
{ type: "module", name: "data-tools-parser" },
|
||||
);
|
||||
let settled = false;
|
||||
let rejectPromise: ((reason?: unknown) => void) | undefined;
|
||||
const timeout = globalThis.setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.terminate();
|
||||
rejectPromise?.(
|
||||
new DataToolsError(
|
||||
`Parsing exceeded the ${DATA_LIMITS.parseTimeoutMs / 1000}-second safety limit.`,
|
||||
[
|
||||
{
|
||||
code: "worker.timeout",
|
||||
message:
|
||||
"The disposable parser worker was terminated before it completed.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
}, DATA_LIMITS.parseTimeoutMs);
|
||||
const finish = () => {
|
||||
if (settled) return false;
|
||||
settled = true;
|
||||
globalThis.clearTimeout(timeout);
|
||||
worker.terminate();
|
||||
return true;
|
||||
};
|
||||
const promise = new Promise<ParsedDocument>((resolve, reject) => {
|
||||
rejectPromise = reject;
|
||||
worker.onmessage = (
|
||||
event: MessageEvent<WorkerResponseOk | WorkerResponseError>,
|
||||
) => {
|
||||
if (event.data.id !== id || !finish()) return;
|
||||
if (event.data.ok) resolve(event.data.document);
|
||||
else
|
||||
reject(new DataToolsError(event.data.message, event.data.diagnostics));
|
||||
};
|
||||
worker.onerror = (event) => {
|
||||
if (!finish()) return;
|
||||
reject(
|
||||
new DataToolsError(event.message || "The parser worker failed.", [
|
||||
{
|
||||
code: "worker.failure",
|
||||
message: event.message || "The parser worker failed.",
|
||||
severity: "error",
|
||||
},
|
||||
]),
|
||||
);
|
||||
};
|
||||
worker.postMessage({ id, request });
|
||||
});
|
||||
return {
|
||||
promise,
|
||||
cancel() {
|
||||
if (!finish()) return;
|
||||
rejectPromise?.(new DOMException("Parsing cancelled.", "AbortError"));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
|
||||
if ("serviceWorker" in navigator && import.meta.env.PROD) {
|
||||
window.addEventListener("load", () => {
|
||||
const url = new URL("./sw.js", document.baseURI);
|
||||
void navigator.serviceWorker
|
||||
.register(url, { scope: new URL("./", document.baseURI).pathname })
|
||||
.catch(() => undefined);
|
||||
});
|
||||
}
|
||||
+911
@@ -0,0 +1,911 @@
|
||||
:root {
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #ffffff;
|
||||
--toolbox-surface-soft: #eff1f7;
|
||||
--toolbox-text: #202332;
|
||||
--toolbox-muted: #656b7d;
|
||||
--toolbox-border: #d9dce7;
|
||||
--toolbox-accent: #5b4ec4;
|
||||
--toolbox-accent-hover: #493caf;
|
||||
--toolbox-accent-soft: #ece9ff;
|
||||
--toolbox-accent-contrast: #ffffff;
|
||||
--toolbox-focus: #137d75;
|
||||
--toolbox-danger: #b42342;
|
||||
--data-success: #11715c;
|
||||
--data-warning: #9a5d06;
|
||||
--data-radius: 0.82rem;
|
||||
--data-shadow: 0 1px 2px rgb(24 31 65 / 4%), 0 10px 30px rgb(24 31 65 / 3%);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 20rem;
|
||||
min-height: 100%;
|
||||
background: var(--toolbox-background);
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 20rem;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: var(--toolbox-background);
|
||||
color: var(--toolbox-text);
|
||||
font-family:
|
||||
Inter,
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
sans-serif;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: color-mix(in srgb, var(--toolbox-focus) 38%, transparent);
|
||||
}
|
||||
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
padding: clamp(0.75rem, 1.8vw, 1.5rem);
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
.workbench :where(h1, h2, h3),
|
||||
.help-dialog :where(h2, h3),
|
||||
.fatal h1 {
|
||||
margin: 0;
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 760;
|
||||
letter-spacing: -0.027em;
|
||||
line-height: 1.16;
|
||||
}
|
||||
|
||||
.workbench h1 {
|
||||
font-size: clamp(1.75rem, 3vw, 2.55rem);
|
||||
}
|
||||
|
||||
.workbench h2,
|
||||
.help-dialog h2 {
|
||||
font-size: clamp(1.2rem, 2vw, 1.55rem);
|
||||
}
|
||||
|
||||
.workbench h3 {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.workbench p,
|
||||
.workbench ul,
|
||||
.workbench ol,
|
||||
.workbench dl {
|
||||
margin-block: 0;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute !important;
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
margin: -1px !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(0, 0, 0, 0) !important;
|
||||
white-space: nowrap !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.workbench :where(button, input, select, textarea),
|
||||
.help-dialog button {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workbench button,
|
||||
.workbench .button,
|
||||
.help-dialog button {
|
||||
min-height: 2.55rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.8rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.64rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 720;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
transition:
|
||||
border-color 140ms ease,
|
||||
background-color 140ms ease,
|
||||
transform 140ms ease;
|
||||
}
|
||||
|
||||
.workbench button:hover:not(:disabled),
|
||||
.workbench .button:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent) 58%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.workbench button:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.workbench button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.workbench .primary-button {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
|
||||
.workbench .primary-button:hover:not(:disabled) {
|
||||
border-color: var(--toolbox-accent-hover);
|
||||
background: var(--toolbox-accent-hover);
|
||||
}
|
||||
|
||||
:where(.workbench, .help-dialog)
|
||||
:where(button, input, select, textarea, summary):focus-visible,
|
||||
.table-scroll:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.workbench :where(input:not([type="checkbox"]), select, textarea) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 2.55rem;
|
||||
padding: 0.58rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.62rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.workbench textarea {
|
||||
resize: vertical;
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 0.84rem;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.workbench input[aria-invalid="true"] {
|
||||
border-color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.hero,
|
||||
.panel {
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: var(--data-radius);
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: var(--data-shadow);
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: clamp(1.1rem, 3vw, 2rem);
|
||||
}
|
||||
|
||||
.hero p:not(.eyebrow) {
|
||||
max-width: 55rem;
|
||||
margin-top: 0.55rem;
|
||||
color: var(--toolbox-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.3rem !important;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 820;
|
||||
letter-spacing: 0.115em;
|
||||
line-height: 1.35;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.privacy-pill,
|
||||
.count-pill {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.38rem 0.64rem;
|
||||
border-radius: 999px;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 780;
|
||||
}
|
||||
|
||||
.panel {
|
||||
min-width: 0;
|
||||
padding: clamp(0.9rem, 2vw, 1.2rem);
|
||||
}
|
||||
|
||||
.panel-heading,
|
||||
.section-heading {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.section-heading {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.editor-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(11rem, 1fr) auto auto auto;
|
||||
align-items: end;
|
||||
gap: 0.55rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.field {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.34rem;
|
||||
}
|
||||
|
||||
.field > span,
|
||||
.field-label {
|
||||
font-size: 0.76rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.field small,
|
||||
.toggle small {
|
||||
color: var(--toolbox-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.source-field textarea {
|
||||
min-height: clamp(12rem, 26vh, 21rem);
|
||||
}
|
||||
|
||||
.output-field textarea {
|
||||
min-height: 18rem;
|
||||
}
|
||||
|
||||
.file-button {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
width: 1px !important;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.34rem 0.55rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 999px;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge span {
|
||||
width: 0.48rem;
|
||||
height: 0.48rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.status-badge[data-status="ready"] {
|
||||
color: var(--data-success);
|
||||
}
|
||||
|
||||
.status-badge[data-status="error"] {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
|
||||
.status-badge[data-status="pending"] {
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.status-badge[data-status="pending"] span {
|
||||
animation: status-pulse 900ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes status-pulse {
|
||||
to {
|
||||
opacity: 0.25;
|
||||
}
|
||||
}
|
||||
|
||||
.status-line {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
margin-top: 0.55rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.status-line p:first-child {
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.workspace-tabs {
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
padding: 0.1rem 0 0.15rem;
|
||||
}
|
||||
|
||||
.workspace-tabs [role="tablist"] {
|
||||
min-width: max-content;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(9.5rem, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.workspace-tabs button {
|
||||
min-height: 3.55rem;
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
align-content: center;
|
||||
padding: 0.58rem 0.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.workspace-tabs button small {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.69rem;
|
||||
font-weight: 560;
|
||||
}
|
||||
|
||||
.workspace-tabs button[aria-selected="true"] {
|
||||
border-color: var(--toolbox-accent);
|
||||
background: var(--toolbox-accent);
|
||||
color: var(--toolbox-accent-contrast);
|
||||
}
|
||||
|
||||
.workspace-tabs button[aria-selected="true"] small {
|
||||
color: color-mix(in srgb, var(--toolbox-accent-contrast) 78%, transparent);
|
||||
}
|
||||
|
||||
.view-panel {
|
||||
min-height: 21rem;
|
||||
}
|
||||
|
||||
.source-dashboard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.source-dashboard > div + div {
|
||||
padding-left: 1.25rem;
|
||||
border-left: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.stats div {
|
||||
min-width: 0;
|
||||
padding: 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.64rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.stats dt {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.67rem;
|
||||
font-weight: 760;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stats dd {
|
||||
margin: 0.25rem 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.detection-reason,
|
||||
.empty-state,
|
||||
.field-help,
|
||||
.preview-note {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.detection-reason {
|
||||
margin-top: 0.75rem !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
padding: 1rem;
|
||||
border: 1px dashed var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.diagnostic-list {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.diagnostic {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 0.65rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-left: 3px solid var(--toolbox-muted);
|
||||
border-radius: 0.62rem;
|
||||
}
|
||||
|
||||
.diagnostic--error {
|
||||
border-left-color: var(--toolbox-danger);
|
||||
}
|
||||
.diagnostic--warning {
|
||||
border-left-color: var(--data-warning);
|
||||
}
|
||||
.diagnostic--info {
|
||||
border-left-color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.diagnostic__severity {
|
||||
padding: 0.2rem 0.35rem;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.61rem;
|
||||
font-weight: 820;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.diagnostic strong {
|
||||
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.diagnostic p {
|
||||
margin-top: 0.18rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.diagnostic small {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.tree {
|
||||
max-height: 42rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
min-width: 44rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 0.75fr) auto minmax(8rem, 1fr) minmax(
|
||||
12rem,
|
||||
1fr
|
||||
);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.38rem 0.55rem 0.38rem
|
||||
calc(0.55rem + min(var(--tree-depth), 12) * 0.9rem);
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.tree-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
.tree-row:hover {
|
||||
background: var(--toolbox-surface);
|
||||
}
|
||||
|
||||
.tree-row__label,
|
||||
.tree-row__summary,
|
||||
.tree-row__path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tree-row__label {
|
||||
color: var(--toolbox-text);
|
||||
font-weight: 720;
|
||||
}
|
||||
.tree-row__summary {
|
||||
color: var(--toolbox-muted);
|
||||
}
|
||||
.tree-row__path {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.type-chip {
|
||||
display: inline-flex;
|
||||
width: fit-content;
|
||||
padding: 0.17rem 0.35rem;
|
||||
border-radius: 0.3rem;
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.type-chip--string {
|
||||
color: var(--data-success);
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--data-success) 11%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
}
|
||||
.type-chip--number {
|
||||
color: #9a4f10;
|
||||
background: color-mix(in srgb, #c96814 12%, var(--toolbox-surface));
|
||||
}
|
||||
.type-chip--boolean {
|
||||
color: #235cb0;
|
||||
background: color-mix(in srgb, #3478cf 12%, var(--toolbox-surface));
|
||||
}
|
||||
.type-chip--null {
|
||||
color: var(--toolbox-muted);
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.table-scroll {
|
||||
max-height: 38rem;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
||||
.table-scroll table {
|
||||
min-width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.table-scroll :where(th, td) {
|
||||
max-width: 24rem;
|
||||
padding: 0.52rem 0.62rem;
|
||||
overflow: hidden;
|
||||
border-right: 1px solid var(--toolbox-border);
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
text-align: left;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-scroll th {
|
||||
position: sticky;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
background: var(--toolbox-surface-soft);
|
||||
font-weight: 760;
|
||||
}
|
||||
|
||||
.table-scroll tr:hover td {
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
.table-scroll :where(th, td):last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
.preview-note {
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--toolbox-text);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toggle input {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
flex: 0 0 auto;
|
||||
accent-color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.toggle > span {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.flatten-panel {
|
||||
margin-top: 0.85rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
}
|
||||
|
||||
.flatten-panel summary {
|
||||
padding: 0.72rem;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 740;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.flatten-panel .table-scroll {
|
||||
margin: 0 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.query-controls,
|
||||
.conversion-controls {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.query-controls {
|
||||
grid-template-columns: minmax(12rem, 0.45fr) minmax(14rem, 1fr);
|
||||
}
|
||||
.conversion-controls {
|
||||
grid-template-columns: minmax(12rem, 0.4fr) minmax(15rem, 1fr);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin-bottom: 0.75rem !important;
|
||||
}
|
||||
.field-error {
|
||||
margin: 0.6rem 0 !important;
|
||||
color: var(--toolbox-danger);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.query-results {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 740;
|
||||
}
|
||||
|
||||
.query-results ol {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.query-results li {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(9rem, 0.6fr) auto minmax(9rem, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
padding: 0.52rem 0.6rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.55rem;
|
||||
font-size: 0.77rem;
|
||||
}
|
||||
|
||||
.query-results li > :last-child {
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.disclosure {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.65rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.disclosure--clean {
|
||||
color: var(--data-success);
|
||||
font-size: 0.8rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.disclosure ul {
|
||||
display: grid;
|
||||
gap: 0.38rem;
|
||||
margin-top: 0.55rem;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.loss-event {
|
||||
display: grid;
|
||||
grid-template-columns: 5.2rem minmax(0, 1fr) minmax(7rem, auto);
|
||||
gap: 0.55rem;
|
||||
padding: 0.48rem 0.55rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--toolbox-surface);
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.loss-event strong {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.64rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.loss-event--loss strong {
|
||||
color: var(--toolbox-danger);
|
||||
}
|
||||
.loss-event--coercion strong {
|
||||
color: var(--data-warning);
|
||||
}
|
||||
.loss-event code {
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-muted);
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-notice {
|
||||
min-height: 1.2rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading,
|
||||
.fatal {
|
||||
width: min(100% - 2rem, 60rem);
|
||||
margin: 2rem auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.help-dialog {
|
||||
width: min(36rem, calc(100% - 2rem));
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.9rem;
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
.help-dialog::backdrop {
|
||||
background: rgb(20 24 45 / 55%);
|
||||
}
|
||||
.dialog-heading {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.help-dialog p {
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 54rem) {
|
||||
.editor-toolbar {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.source-dashboard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.source-dashboard > div + div {
|
||||
padding: 1rem 0 0;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
border-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 42rem) {
|
||||
.hero {
|
||||
flex-direction: column;
|
||||
}
|
||||
.privacy-pill {
|
||||
order: -1;
|
||||
}
|
||||
.editor-toolbar,
|
||||
.query-controls,
|
||||
.conversion-controls {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editor-toolbar > :not(.format-field) {
|
||||
width: 100%;
|
||||
}
|
||||
.status-line {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.panel-heading,
|
||||
.section-heading {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.section-heading:has(.toggle) {
|
||||
flex-direction: column;
|
||||
}
|
||||
.stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.query-results li {
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.query-results li > :last-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.loss-event {
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
.loss-event code {
|
||||
grid-column: 1 / -1;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.data-tools",
|
||||
"name": "Data Tools",
|
||||
"version": "0.1.0",
|
||||
"description": "Inspect, query and convert structured data locally in the browser.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
"categories": ["data", "developer", "documents"],
|
||||
"tags": ["json", "yaml", "toml", "xml", "csv", "tsv", "ndjson"],
|
||||
"integration": {
|
||||
"contextVersion": 1,
|
||||
"launchModes": ["navigate", "new-tab"],
|
||||
"embedding": "unsupported"
|
||||
},
|
||||
"requirements": {
|
||||
"secureContext": false,
|
||||
"workers": true,
|
||||
"indexedDb": false,
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false,
|
||||
"label": "Inputs stay in this browser; nothing is uploaded."
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/lotobo/data-tools",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"actions": [
|
||||
{
|
||||
"id": "source",
|
||||
"label": "Source",
|
||||
"url": "https://git.add-ideas.de/lotobo/data-tools"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,54 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { parseDataDocument } from "../core/parse";
|
||||
import {
|
||||
DataToolsError,
|
||||
type ParsedDocument,
|
||||
type ParseRequest,
|
||||
} from "../core/types";
|
||||
|
||||
interface WorkerRequest {
|
||||
id: number;
|
||||
request: ParseRequest;
|
||||
}
|
||||
|
||||
type WorkerResponse =
|
||||
| { id: number; ok: true; document: ParsedDocument }
|
||||
| {
|
||||
id: number;
|
||||
ok: false;
|
||||
message: string;
|
||||
diagnostics: DataToolsError["diagnostics"];
|
||||
};
|
||||
|
||||
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
|
||||
const { id, request } = event.data;
|
||||
let response: WorkerResponse;
|
||||
try {
|
||||
response = { id, ok: true, document: parseDataDocument(request) };
|
||||
} catch (error) {
|
||||
response = {
|
||||
id,
|
||||
ok: false,
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "The input could not be parsed.",
|
||||
diagnostics:
|
||||
error instanceof DataToolsError
|
||||
? error.diagnostics
|
||||
: [
|
||||
{
|
||||
code: "parse.unknown",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Unknown parser failure.",
|
||||
severity: "error",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
self.postMessage(response);
|
||||
};
|
||||
|
||||
export {};
|
||||
Reference in New Issue
Block a user