Release Data Tools 0.1.0

This commit is contained in:
2026-09-01 02:47:11 +02:00
commit e773a2ffe8
66 changed files with 11596 additions and 0 deletions
+35
View File
@@ -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>
);
}
+27
View File
@@ -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;
}
}
+50
View File
@@ -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>
);
}
+848
View File
@@ -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>
);
}
+510
View File
@@ -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, "&amp;")
.replace(/</gu, "&lt;")
.replace(/>/gu, "&gt;");
}
function xmlEscapeAttribute(value: string): string {
return xmlEscapeText(value)
.replace(/"/gu, "&quot;")
.replace(/\r/gu, "&#13;")
.replace(/\n/gu, "&#10;");
}
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 };
}
+121
View File
@@ -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);
}
+143
View File
@@ -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 };
}
+180
View File
@@ -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}`;
}
}
+611
View File
@@ -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 browsers 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 browsers 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);
}
}
+364
View File
@@ -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;
}
+119
View File
@@ -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;
}
}
+106
View File
@@ -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"));
},
};
}
+18
View File
@@ -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
View File
@@ -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;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+41
View File
@@ -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"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+54
View File
@@ -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 {};