Release Network Tools 0.1.0

This commit is contained in:
2026-09-01 02:44:20 +02:00
commit 729430295b
61 changed files with 8357 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 Network 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>Network Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+41
View File
@@ -0,0 +1,41 @@
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 Network Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>Calculate and construct network values locally in the browser.</p>
<p>
All processing is performed in this browser. Imported data is treated as
untrusted and bounded before parsing.
</p>
</dialog>
);
}
+487
View File
@@ -0,0 +1,487 @@
import { useMemo, useState } from "react";
import { parseCidr } from "@add-ideas/toolbox-helpers";
import { buildDnsRecord, type DnsRecordType } from "../network/dns";
import { buildCsp, inspectHeaders } from "../network/http";
import { lookupMime } from "../network/mime";
import { inspectUrl } from "../network/url";
const tabs = [
["cidr", "IP & CIDR"],
["url", "URL"],
["dns", "DNS"],
["http", "HTTP & CSP"],
["mime", "MIME types"],
] as const;
type Tab = (typeof tabs)[number][0];
function attempt<T>(operation: () => T): { value?: T; error?: string } {
try {
return { value: operation() };
} catch (error) {
return {
error:
error instanceof Error
? error.message
: "The value could not be processed.",
};
}
}
function CidrWorkspace() {
const [source, setSource] = useState("192.168.10.42/24");
const parsed = useMemo(() => attempt(() => parseCidr(source)), [source]);
return (
<section className="workspace" aria-labelledby="cidr-heading">
<div>
<h2 id="cidr-heading">IP address and subnet calculator</h2>
<p className="muted">
Canonicalise IPv4 or IPv6 CIDR notation and inspect its address range.
</p>
</div>
<label className="field">
<span>Address with prefix</span>
<input
value={source}
onChange={(event) => setSource(event.target.value)}
spellCheck={false}
/>
</label>
{parsed.error ? (
<p className="error" role="alert">
{parsed.error}
</p>
) : (
parsed.value && (
<dl className="facts">
<div>
<dt>Canonical</dt>
<dd>{parsed.value.canonical}</dd>
</div>
<div>
<dt>Network</dt>
<dd>{parsed.value.network.canonical}</dd>
</div>
<div>
<dt>First address</dt>
<dd>{parsed.value.first.canonical}</dd>
</div>
<div>
<dt>Last address</dt>
<dd>{parsed.value.last.canonical}</dd>
</div>
{parsed.value.broadcast && (
<div>
<dt>IPv4 broadcast</dt>
<dd>{parsed.value.broadcast.canonical}</dd>
</div>
)}
<div>
<dt>Addresses</dt>
<dd>{parsed.value.size.toLocaleString()}</dd>
</div>
</dl>
)
)}
</section>
);
}
function UrlWorkspace() {
const [source, setSource] = useState(
"https://example.com/docs?q=local#result",
);
const [base, setBase] = useState("");
const inspection = useMemo(
() => attempt(() => inspectUrl(source, base)),
[source, base],
);
return (
<section className="workspace" aria-labelledby="url-heading">
<div>
<h2 id="url-heading">URL inspector</h2>
<p className="muted">
Resolve and split a URL without requesting it. Passwords are removed
from displayed output.
</p>
</div>
<div className="form-grid">
<label className="field">
<span>URL or reference</span>
<input
value={source}
onChange={(event) => setSource(event.target.value)}
spellCheck={false}
/>
</label>
<label className="field">
<span>Optional base URL</span>
<input
value={base}
onChange={(event) => setBase(event.target.value)}
placeholder="https://example.com/a/"
spellCheck={false}
/>
</label>
</div>
{inspection.error ? (
<p className="error" role="alert">
{inspection.error}
</p>
) : (
inspection.value && (
<>
<dl className="facts">
<div>
<dt>Safe URL</dt>
<dd>{inspection.value.href}</dd>
</div>
<div>
<dt>Scheme</dt>
<dd>{inspection.value.scheme}</dd>
</div>
<div>
<dt>Host</dt>
<dd>
{inspection.value.hostname || "—"}
{inspection.value.port ? `:${inspection.value.port}` : ""}
</dd>
</div>
<div>
<dt>Path</dt>
<dd>{inspection.value.pathname}</dd>
</div>
<div>
<dt>Fragment</dt>
<dd>{inspection.value.fragment || "—"}</dd>
</div>
</dl>
<h3>Query parameters</h3>
{inspection.value.query.length ? (
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody>
{inspection.value.query.map((entry, index) => (
<tr key={`${entry.key}-${index}`}>
<td>{entry.key}</td>
<td>{entry.value}</td>
</tr>
))}
</tbody>
</table>
) : (
<p className="muted">No query parameters.</p>
)}
{inspection.value.warnings.length > 0 && (
<ul className="compact-list warning">
{inspection.value.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</>
)
)}
</section>
);
}
function DnsWorkspace() {
const [owner, setOwner] = useState("www.example.com.");
const [ttl, setTtl] = useState(3600);
const [type, setType] = useState<DnsRecordType>("A");
const [value, setValue] = useState("192.0.2.10");
const [priority, setPriority] = useState(10);
const [weight, setWeight] = useState(5);
const [port, setPort] = useState(443);
const record = useMemo(
() =>
attempt(() =>
buildDnsRecord({ owner, ttl, type, value, priority, weight, port }),
),
[owner, ttl, type, value, priority, weight, port],
);
return (
<section className="workspace" aria-labelledby="dns-heading">
<div>
<h2 id="dns-heading">DNS record builder</h2>
<p className="muted">
Validate and compose common zone-file records. This does not query or
change DNS.
</p>
</div>
<div className="form-grid">
<label className="field">
<span>Owner</span>
<input
value={owner}
onChange={(event) => setOwner(event.target.value)}
spellCheck={false}
/>
</label>
<label className="field">
<span>TTL (seconds)</span>
<input
type="number"
min="0"
value={ttl}
onChange={(event) => setTtl(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Record type</span>
<select
value={type}
onChange={(event) => setType(event.target.value as DnsRecordType)}
>
{(["A", "AAAA", "CNAME", "MX", "SRV", "CAA", "TXT"] as const).map(
(candidate) => (
<option key={candidate}>{candidate}</option>
),
)}
</select>
</label>
<label className="field">
<span>Value or target</span>
<input
value={value}
onChange={(event) => setValue(event.target.value)}
spellCheck={false}
/>
</label>
{(type === "MX" || type === "SRV") && (
<label className="field">
<span>Priority</span>
<input
type="number"
min="0"
max="65535"
value={priority}
onChange={(event) => setPriority(event.target.valueAsNumber)}
/>
</label>
)}
{type === "SRV" && (
<>
<label className="field">
<span>Weight</span>
<input
type="number"
min="0"
max="65535"
value={weight}
onChange={(event) => setWeight(event.target.valueAsNumber)}
/>
</label>
<label className="field">
<span>Port</span>
<input
type="number"
min="0"
max="65535"
value={port}
onChange={(event) => setPort(event.target.valueAsNumber)}
/>
</label>
</>
)}
</div>
{record.error ? (
<p className="error" role="alert">
{record.error}
</p>
) : (
<pre>{record.value}</pre>
)}
</section>
);
}
function HttpWorkspace() {
const [headers, setHeaders] = useState(
"Content-Type: text/html; charset=utf-8\nX-Content-Type-Options: nosniff\nReferrer-Policy: strict-origin-when-cross-origin",
);
const [scriptSources, setScriptSources] = useState("'self'");
const [styleSources, setStyleSources] = useState("'self' 'unsafe-inline'");
const parsed = useMemo(
() => attempt(() => inspectHeaders(headers)),
[headers],
);
const csp = useMemo(
() =>
attempt(() =>
buildCsp({
defaultSrc: "'self'",
scriptSrc: scriptSources,
styleSrc: styleSources,
imgSrc: "'self' data: blob:",
connectSrc: "'self'",
workerSrc: "'self' blob:",
}),
),
[scriptSources, styleSources],
);
return (
<section className="workspace" aria-labelledby="http-heading">
<div>
<h2 id="http-heading">HTTP headers and CSP</h2>
<p className="muted">
Inspect pasted response headers and draft a conservative Content
Security Policy.
</p>
</div>
<label className="field">
<span>Response headers</span>
<textarea
value={headers}
onChange={(event) => setHeaders(event.target.value)}
/>
</label>
{parsed.error ? (
<p className="error" role="alert">
{parsed.error}
</p>
) : (
parsed.value && (
<ul className="compact-list">
{parsed.value.findings.length ? (
parsed.value.findings.map((finding) => (
<li key={finding}>{finding}</li>
))
) : (
<li>No baseline header gaps detected.</li>
)}
</ul>
)
)}
<div className="form-grid">
<label className="field">
<span>script-src values</span>
<input
value={scriptSources}
onChange={(event) => setScriptSources(event.target.value)}
spellCheck={false}
/>
</label>
<label className="field">
<span>style-src values</span>
<input
value={styleSources}
onChange={(event) => setStyleSources(event.target.value)}
spellCheck={false}
/>
</label>
</div>
{csp.error ? (
<p className="error" role="alert">
{csp.error}
</p>
) : (
csp.value && (
<>
<pre>{csp.value.policy}</pre>
{csp.value.warnings.length > 0 && (
<ul className="compact-list warning">
{csp.value.warnings.map((warning) => (
<li key={warning}>{warning}</li>
))}
</ul>
)}
</>
)
)}
</section>
);
}
function MimeWorkspace() {
const [query, setQuery] = useState("");
const matches = useMemo(() => lookupMime(query), [query]);
return (
<section className="workspace" aria-labelledby="mime-heading">
<div>
<h2 id="mime-heading">MIME type reference</h2>
<p className="muted">
Search a compact offline reference by extension or media type.
</p>
</div>
<label className="field">
<span>Extension or media type</span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="wasm or image/"
/>
</label>
<table>
<thead>
<tr>
<th>Extension</th>
<th>MIME type</th>
</tr>
</thead>
<tbody>
{matches.map((match) => (
<tr key={match.extension}>
<td>.{match.extension}</td>
<td>{match.mime}</td>
</tr>
))}
</tbody>
</table>
</section>
);
}
export function Workbench() {
const fromHash = window.location.hash.slice(1) as Tab;
const [tab, setTab] = useState<Tab>(
tabs.some(([key]) => key === fromHash) ? fromHash : "cidr",
);
const choose = (next: Tab) => {
setTab(next);
window.history.replaceState(null, "", `#${next}`);
};
return (
<main className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Local-first workbench</p>
<h1>Network Tools</h1>
<p>
Calculate, inspect, and construct common network values without
making network requests.
</p>
</div>
<span className="privacy-pill">No queries sent</span>
</header>
<nav
className="workspace-tabs panel"
aria-label="Network workspaces"
role="tablist"
>
{tabs.map(([key, label]) => (
<button
key={key}
type="button"
role="tab"
aria-selected={tab === key}
onClick={() => choose(key)}
>
{label}
</button>
))}
</nav>
{tab === "cidr" && <CidrWorkspace />}
{tab === "url" && <UrlWorkspace />}
{tab === "dns" && <DnsWorkspace />}
{tab === "http" && <HttpWorkspace />}
{tab === "mime" && <MimeWorkspace />}
</main>
);
}
+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);
});
}
+86
View File
@@ -0,0 +1,86 @@
import { parseIpv4, parseIpv6 } from "@add-ideas/toolbox-helpers";
export type DnsRecordType =
"A" | "AAAA" | "CAA" | "CNAME" | "MX" | "SRV" | "TXT";
export interface DnsRecordInput {
owner: string;
ttl: number;
type: DnsRecordType;
value: string;
priority?: number;
weight?: number;
port?: number;
flag?: number;
tag?: "issue" | "issuewild" | "iodef";
}
const LABEL =
/^(?:[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?)(?:\.(?:[A-Za-z0-9_](?:[A-Za-z0-9_-]{0,61}[A-Za-z0-9_])?))*\.?$/u;
function integer(
value: number | undefined,
name: string,
maximum = 65_535,
): number {
if (!Number.isInteger(value) || value! < 0 || value! > maximum)
throw new Error(`${name} must be an integer from 0 to ${maximum}.`);
return value!;
}
function domain(value: string, name: string): string {
const candidate = value.trim();
if (candidate === "@") return candidate;
if (!candidate || candidate.length > 253 || !LABEL.test(candidate))
throw new Error(`${name} must be a valid ASCII DNS name.`);
return candidate;
}
function quoteTxt(value: string): string {
const bytes = new TextEncoder().encode(value);
if (bytes.length > 255)
throw new Error("A TXT character-string is limited to 255 UTF-8 bytes.");
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
}
export function buildDnsRecord(input: DnsRecordInput): string {
const owner = domain(input.owner, "Owner");
const ttl = integer(input.ttl, "TTL", 2_147_483_647);
const value = input.value.trim();
if (!value) throw new Error("Record value is required.");
let data: string;
switch (input.type) {
case "A":
try {
data = parseIpv4(value).canonical;
} catch {
throw new Error("A records require a dotted-decimal IPv4 address.");
}
break;
case "AAAA":
try {
data = parseIpv6(value).canonical;
} catch {
throw new Error("AAAA records require an IPv6 address.");
}
break;
case "CNAME":
data = domain(value, "Target");
break;
case "MX":
data = `${integer(input.priority, "Priority")} ${domain(value, "Mail exchanger")}`;
break;
case "SRV":
data = `${integer(input.priority, "Priority")} ${integer(input.weight, "Weight")} ${integer(input.port, "Port")} ${domain(value, "Target")}`;
break;
case "CAA": {
const tag = input.tag ?? "issue";
data = `${integer(input.flag ?? 0, "Flag", 255)} ${tag} ${quoteTxt(value)}`;
break;
}
case "TXT":
data = quoteTxt(value);
break;
}
return `${owner} ${ttl} IN ${input.type} ${data}`;
}
+121
View File
@@ -0,0 +1,121 @@
export interface HeaderEntry {
name: string;
value: string;
}
export interface HeaderInspection {
headers: HeaderEntry[];
duplicates: string[];
findings: string[];
}
const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/u;
function hasInvalidHeaderControl(value: string): boolean {
return [...value].some((character) => {
const code = character.codePointAt(0)!;
return (code < 32 && code !== 9) || code === 127;
});
}
export function inspectHeaders(source: string): HeaderInspection {
if (source.length > 256 * 1024)
throw new Error("Header block exceeds 256 KiB.");
const headers: HeaderEntry[] = [];
for (const [index, raw] of source
.replaceAll("\r\n", "\n")
.split("\n")
.entries()) {
if (!raw.trim()) continue;
if (/^[ \t]/u.test(raw))
throw new Error(
`Line ${index + 1}: obsolete folded headers are rejected.`,
);
const colon = raw.indexOf(":");
if (colon <= 0)
throw new Error(
`Line ${index + 1}: expected a name followed by a colon.`,
);
const name = raw.slice(0, colon).trim();
const value = raw.slice(colon + 1).trim();
if (!TOKEN.test(name))
throw new Error(`Line ${index + 1}: invalid header name.`);
if (hasInvalidHeaderControl(value))
throw new Error(`Line ${index + 1}: invalid control character.`);
headers.push({ name, value });
}
const counts = new Map<string, number>();
for (const header of headers) {
const key = header.name.toLowerCase();
counts.set(key, (counts.get(key) ?? 0) + 1);
}
const duplicates = [...counts]
.filter(([, count]) => count > 1)
.map(([name]) => name);
const names = new Set(counts.keys());
const findings: string[] = [];
if (!names.has("content-security-policy"))
findings.push("No Content-Security-Policy header is present.");
if (!names.has("x-content-type-options"))
findings.push("Add X-Content-Type-Options: nosniff.");
if (!names.has("referrer-policy"))
findings.push("No Referrer-Policy header is present.");
if (!names.has("permissions-policy"))
findings.push("No Permissions-Policy header is present.");
if (headers.some((header) => header.name.toLowerCase() === "server"))
findings.push("The Server header may disclose implementation details.");
return { headers, duplicates, findings };
}
export interface CspInput {
defaultSrc: string;
scriptSrc: string;
styleSrc: string;
imgSrc: string;
connectSrc: string;
workerSrc: string;
}
function sources(value: string): string {
const tokens = value.trim().split(/\s+/u).filter(Boolean);
if (tokens.length === 0) return "'none'";
for (const token of tokens) {
if (/[;,\r\n]/u.test(token))
throw new Error(`Invalid CSP source token: ${token}`);
}
return [...new Set(tokens)].join(" ");
}
export function buildCsp(input: CspInput): {
policy: string;
warnings: string[];
} {
const directives = [
["default-src", input.defaultSrc],
["base-uri", "'self'"],
["object-src", "'none'"],
["frame-ancestors", "'none'"],
["form-action", "'self'"],
["script-src", input.scriptSrc],
["style-src", input.styleSrc],
["img-src", input.imgSrc],
["connect-src", input.connectSrc],
["worker-src", input.workerSrc],
] as const;
const policy = directives
.map(([name, value]) => `${name} ${sources(value)}`)
.join("; ");
const warnings: string[] = [];
if (/\*/u.test(policy))
warnings.push("Wildcard sources broaden trust substantially.");
if (policy.includes("'unsafe-eval'"))
warnings.push("unsafe-eval permits string-to-code execution.");
if (policy.includes("'unsafe-inline'"))
warnings.push(
"unsafe-inline weakens script or style injection protection.",
);
if (/script-src[^;]*\bdata:/u.test(policy))
warnings.push("data: in script-src can enable injected code.");
return { policy, warnings };
}
+49
View File
@@ -0,0 +1,49 @@
const MIME_TYPES: Record<string, string> = {
avif: "image/avif",
bin: "application/octet-stream",
css: "text/css",
csv: "text/csv",
epub: "application/epub+zip",
gif: "image/gif",
gz: "application/gzip",
html: "text/html",
ico: "image/x-icon",
jpeg: "image/jpeg",
jpg: "image/jpeg",
js: "text/javascript",
json: "application/json",
jsonld: "application/ld+json",
mjs: "text/javascript",
mp3: "audio/mpeg",
mp4: "video/mp4",
odp: "application/vnd.oasis.opendocument.presentation",
ods: "application/vnd.oasis.opendocument.spreadsheet",
odt: "application/vnd.oasis.opendocument.text",
pdf: "application/pdf",
png: "image/png",
svg: "image/svg+xml",
tar: "application/x-tar",
txt: "text/plain",
wasm: "application/wasm",
webm: "video/webm",
webmanifest: "application/manifest+json",
webp: "image/webp",
xml: "application/xml",
zip: "application/zip",
};
export function lookupMime(
value: string,
): { extension: string; mime: string }[] {
const query = value.trim().toLowerCase().replace(/^\./u, "");
if (!query)
return Object.entries(MIME_TYPES).map(([extension, mime]) => ({
extension,
mime,
}));
return Object.entries(MIME_TYPES)
.filter(
([extension, mime]) => extension.includes(query) || mime.includes(query),
)
.map(([extension, mime]) => ({ extension, mime }));
}
+92
View File
@@ -0,0 +1,92 @@
export interface QueryEntry {
key: string;
value: string;
}
export interface UrlInspection {
href: string;
scheme: string;
username: string;
hasPassword: boolean;
hostname: string;
port: string;
origin: string;
pathname: string;
query: QueryEntry[];
fragment: string;
warnings: string[];
}
function containsControl(value: string): boolean {
return [...value].some((character) => {
const code = character.codePointAt(0)!;
return code < 32 || code === 127;
});
}
export function inspectUrl(input: string, base?: string): UrlInspection {
const trimmed = input.trim();
if (!trimmed) throw new Error("Enter a URL to inspect.");
if (trimmed.length > 16_384) throw new Error("URL exceeds the 16 KiB limit.");
if (containsControl(trimmed))
throw new Error("URL contains control characters.");
let parsed: URL;
try {
parsed = base?.trim()
? new URL(trimmed, new URL(base.trim()))
: new URL(trimmed);
} catch {
throw new Error(
"The value is not a valid absolute URL (or relative to the supplied base).",
);
}
const warnings: string[] = [];
const hasPassword = parsed.password !== "";
if (hasPassword)
warnings.push(
"The URL contains a password; it was removed from the result.",
);
if (parsed.username) warnings.push("The URL contains user information.");
if (parsed.protocol !== "https:" && parsed.protocol !== "http:")
warnings.push(
`The ${parsed.protocol.slice(0, -1)} scheme is not an HTTP URL.`,
);
if (parsed.protocol === "http:" && parsed.hostname !== "localhost")
warnings.push(
"Plain HTTP does not protect content or credentials in transit.",
);
if (parsed.hostname.includes("xn--"))
warnings.push(
"The hostname contains an internationalized-domain punycode label.",
);
parsed.password = "";
return {
href: parsed.href,
scheme: parsed.protocol.slice(0, -1),
username: parsed.username,
hasPassword,
hostname: parsed.hostname,
port: parsed.port,
origin: parsed.origin,
pathname: parsed.pathname,
query: [...parsed.searchParams.entries()].map(([key, value]) => ({
key,
value,
})),
fragment: parsed.hash.slice(1),
warnings,
};
}
export function buildUrl(
inspection: UrlInspection,
query = inspection.query,
): string {
const parsed = new URL(inspection.href);
parsed.search = "";
for (const { key, value } of query) parsed.searchParams.append(key, value);
return parsed.href;
}
+274
View File
@@ -0,0 +1,274 @@
:root {
--toolbox-background: #f6f7fb;
--toolbox-surface: #fff;
--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: #fff;
--toolbox-focus: #137d75;
--toolbox-danger: #b42342;
}
* {
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, sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.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.65rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-weight: 720;
cursor: pointer;
}
button:hover:not(:disabled),
.button:hover {
border-color: var(--toolbox-accent);
background: var(--toolbox-surface-soft);
}
:where(button, input, select, textarea, a):focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
}
input,
select,
textarea {
width: 100%;
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);
}
textarea {
min-height: 10rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
line-height: 1.48;
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
}
.hero,
.panel,
.workspace {
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero h1,
.workspace h2,
.workspace h3,
.help-dialog h2,
.fatal h1 {
margin: 0;
letter-spacing: -0.025em;
}
.hero p:not(.eyebrow) {
max-width: 52rem;
margin: 0.55rem 0 0;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem;
color: var(--toolbox-accent);
font-size: 0.69rem;
font-weight: 820;
letter-spacing: 0.115em;
text-transform: uppercase;
}
.privacy-pill {
flex: 0 0 auto;
padding: 0.38rem 0.62rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 760;
}
.panel {
padding: 1rem;
}
.workspace-tabs {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding-bottom: 0.8rem;
}
.workspace-tabs button[aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.workspace {
display: grid;
gap: 1rem;
padding: clamp(1rem, 2vw, 1.35rem);
}
.workspace h2 + p {
margin: 0.35rem 0 0;
}
.workspace h3 {
font-size: 1rem;
}
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 0.8rem;
}
.field {
display: grid;
gap: 0.35rem;
}
.field > span {
font-size: 0.76rem;
font-weight: 750;
}
.muted {
color: var(--toolbox-muted);
}
.facts {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: 0.65rem;
margin: 0;
}
.facts div {
min-width: 0;
padding: 0.75rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
}
.facts dt {
color: var(--toolbox-muted);
font-size: 0.73rem;
font-weight: 760;
}
.facts dd {
margin: 0.25rem 0 0;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.compact-list {
margin: 0;
padding-left: 1.25rem;
line-height: 1.55;
}
.warning {
color: #8a5a00;
}
.error {
margin: 0;
padding: 0.75rem;
border: 1px solid
color-mix(in srgb, var(--toolbox-danger) 45%, var(--toolbox-border));
border-radius: 0.65rem;
background: color-mix(
in srgb,
var(--toolbox-danger) 8%,
var(--toolbox-surface)
);
color: var(--toolbox-danger);
}
pre {
margin: 0;
padding: 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.68rem;
background: var(--toolbox-surface-soft);
white-space: pre-wrap;
overflow-wrap: anywhere;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
th,
td {
padding: 0.55rem 0.65rem;
border-bottom: 1px solid var(--toolbox-border);
text-align: left;
overflow-wrap: anywhere;
}
th {
color: var(--toolbox-muted);
font-size: 0.73rem;
text-transform: uppercase;
}
.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;
}
@media (max-width: 42rem) {
.hero {
flex-direction: column;
}
.privacy-pill {
order: -1;
}
}
+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.network-tools",
"name": "Network Tools",
"version": "0.1.0",
"description": "Calculate and construct network values locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["network", "developer", "security"],
"tags": ["ip", "cidr", "url", "dns", "http", "csp"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": false,
"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/network-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/network-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" />