-
-
-
-
-
-
- {/* Regex Input Area */}
-
-
- {/* Test String Input Area */}
-
-
- Test String
-
-
-
- {/* Results Area */}
-
-
Results
- {error ? (
-
- Error: {error}
-
- ) : matches.length > 0 ? (
-
-
- Found {matches.length} match{matches.length === 1 ? '' : 'es'}
- {/* FIX: Only render execution time on the client after hydration */}
- {isClient && ` in ${executionTime.toFixed(2)}ms.`}
-
- {matches.map((match, i) => (
-
- ))}
-
- ) : (
-
No matches found.
- )}
-
-
-
- {/* Placeholder for the explainer */}
-
-
-
-
- ← Back to All Tools
-
-
-
-
+ let cancelled = false;
+ const timer = window.setTimeout(async () => {
+ if (!pattern) { setMatches([]); setError(''); setTime(0); return; }
+ setRunning(true);
+ try {
+ const result = await runRegexMatch(pattern, flags, text);
+ if (!cancelled) { setMatches(result.matches); setTime(result.executionTime); setError(''); }
+ } catch (err) {
+ if (!cancelled) { setMatches([]); setTime(0); setError((err as Error).message); }
+ } finally {
+ if (!cancelled) setRunning(false);
+ }
+ }, 120);
+ return () => { cancelled = true; window.clearTimeout(timer); };
+ }, [pattern, flags, text]);
+
+ const explanation = useMemo(() => explain(pattern), [pattern]);
+
+ return
+
+
+
Basic explanation {explanation.map((x, i) =>
{x}
)}
The explainer is intentionally lightweight; the tester itself uses the JavaScript RegExp engine built into the browser.
- );
+ ;
}
diff --git a/src/app/robots.ts b/src/app/robots.ts
new file mode 100644
index 0000000..d74af4f
--- /dev/null
+++ b/src/app/robots.ts
@@ -0,0 +1,3 @@
+import type { MetadataRoute } from 'next';
+const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+export default function robots(): MetadataRoute.Robots { return { rules: { userAgent: '*', allow: '/' }, sitemap: `${SITE_URL}/sitemap.xml` }; }
diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts
new file mode 100644
index 0000000..b7065cf
--- /dev/null
+++ b/src/app/sitemap.ts
@@ -0,0 +1,7 @@
+import type { MetadataRoute } from 'next';
+import { TOOLS } from '@/config/tools';
+
+const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
+export default function sitemap(): MetadataRoute.Sitemap {
+ return [{ url: SITE_URL, changeFrequency: 'weekly', priority: 1 }, ...TOOLS.map(item => ({ url: `${SITE_URL}${item.slug}`, changeFrequency: 'monthly' as const, priority: 0.8 }))];
+}
diff --git a/src/app/text-privacy-cleaner/page.tsx b/src/app/text-privacy-cleaner/page.tsx
new file mode 100644
index 0000000..314bceb
--- /dev/null
+++ b/src/app/text-privacy-cleaner/page.tsx
@@ -0,0 +1,6 @@
+'use client';
+import { useMemo, useState } from 'react';
+import LocalToolLayout from '@/components/LocalToolLayout';
+const HIDDEN=/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u200B-\u200F\u202A-\u202E\u2060\u2066-\u2069\uFEFF]/g;
+const names:Record
={0x200b:'ZERO WIDTH SPACE',0x200c:'ZERO WIDTH NON-JOINER',0x200d:'ZERO WIDTH JOINER',0x200e:'LEFT-TO-RIGHT MARK',0x200f:'RIGHT-TO-LEFT MARK',0xfeff:'ZERO WIDTH NO-BREAK SPACE/BOM'};
+export default function TextPrivacyCleanerPage(){const [text,setText]=useState('Paste text here\u200B to reveal hidden characters.');const findings=useMemo(()=>[...text.matchAll(HIDDEN)].map(m=>({index:m.index??0,char:m[0],code:m[0].codePointAt(0)!})),[text]);const cleaned=text.replace(HIDDEN,'').normalize('NFC');return
navigator.clipboard.writeText(cleaned)} className='rounded bg-slate-800 px-4 py-2 text-white'>Copy cleaned text setText(cleaned)} className='rounded bg-sky-600 px-4 py-2 text-white'>Replace input
Hidden/control characters found: {findings.length} {findings.length>0&&{findings.map((f,i)=>
index {f.index}: U+{f.code.toString(16).toUpperCase().padStart(4,'0')} {names[f.code]||'CONTROL / FORMAT CHARACTER'}
)}
} }
diff --git a/src/app/timestamp-tool/page.tsx b/src/app/timestamp-tool/page.tsx
new file mode 100644
index 0000000..a5afe54
--- /dev/null
+++ b/src/app/timestamp-tool/page.tsx
@@ -0,0 +1,25 @@
+'use client';
+import { useMemo, useState } from 'react';
+import LocalToolLayout from '@/components/LocalToolLayout';
+
+export default function TimestampToolPage() {
+ const [value, setValue] = useState('');
+ const parsed = useMemo(() => {
+ const input = value.trim();
+ if (!input) return null;
+ let date: Date;
+ if (/^\d{10}$/.test(input)) date = new Date(Number(input) * 1000);
+ else if (/^\d{13}$/.test(input)) date = new Date(Number(input));
+ else date = new Date(input);
+ return Number.isNaN(date.getTime()) ? null : date;
+ }, [value]);
+
+ const useCurrentTime = () => setValue(String(Math.floor(Date.now() / 1000)));
+
+ return
+
+ setValue(e.target.value)} className='min-w-0 flex-1 rounded border p-3 font-mono' placeholder='Unix timestamp or date string'/>Now
+ {!value.trim() ? Enter a Unix timestamp or date, or choose Now.
: parsed ? Unix seconds {Math.floor(parsed.getTime() / 1000)} Unix milliseconds {parsed.getTime()} ISO 8601 / UTC {parsed.toISOString()} Local time {parsed.toLocaleString()} UTC display {parsed.toUTCString()} : Could not parse that value.
}
+
+ ;
+}
diff --git a/src/app/unit-converter/page.tsx b/src/app/unit-converter/page.tsx
index 5eb4fab..1d3b4d7 100644
--- a/src/app/unit-converter/page.tsx
+++ b/src/app/unit-converter/page.tsx
@@ -1,252 +1,179 @@
-// src/app/unit-converter/page.tsx
'use client';
-import React, { useState, useMemo, ChangeEvent, useCallback } from 'react';
-import Link from 'next/link';
-import Navbar from '@/components/Navbar';
-import Footer from '@/components/Footer';
-import Breadcrumbs from '@/components/Breadcrumbs';
+import { useMemo, useState } from 'react';
+import LocalToolLayout from '@/components/LocalToolLayout';
-// Define the structure for our conversion data
-interface UnitData {
- [unit: string]: number;
-}
-
-interface ConversionCategory {
- label: string;
- baseUnit: string;
- units: UnitData;
-}
+type Category = { label: string; units: Record; temperature?: boolean };
-const CONVERSION_FACTORS: Record = {
+const CATEGORIES: Record = {
length: {
label: 'Length',
- baseUnit: 'meter',
units: {
meter: 1,
kilometer: 1000,
centimeter: 0.01,
millimeter: 0.001,
- mile: 1609.34,
+ mile: 1609.344,
yard: 0.9144,
foot: 0.3048,
inch: 0.0254,
},
},
- weight: {
- label: 'Weight',
- baseUnit: 'kilogram',
+ mass: {
+ label: 'Mass',
units: {
kilogram: 1,
gram: 0.001,
milligram: 0.000001,
tonne: 1000,
- pound: 0.453592,
- ounce: 0.0283495,
+ pound: 0.45359237,
+ ounce: 0.028349523125,
},
},
temperature: {
label: 'Temperature',
- baseUnit: 'celsius',
- units: {
- celsius: 1,
- fahrenheit: 1,
- kelvin: 1,
- },
+ units: { celsius: 1, fahrenheit: 1, kelvin: 1 },
+ temperature: true,
+ },
+ dataSI: {
+ label: 'Data Storage (SI, decimal)',
+ units: { byte: 1, kilobyte: 1e3, megabyte: 1e6, gigabyte: 1e9, terabyte: 1e12 },
},
- data: {
- label: 'Data Storage',
- baseUnit: 'byte',
+ dataIEC: {
+ label: 'Data Storage (IEC, binary)',
units: {
byte: 1,
- kilobyte: 1024,
- megabyte: 1024 ** 2,
- gigabyte: 1024 ** 3,
- terabyte: 1024 ** 4,
+ kibibyte: 1024,
+ mebibyte: 1024 ** 2,
+ gibibyte: 1024 ** 3,
+ tebibyte: 1024 ** 4,
},
},
speed: {
label: 'Speed',
- baseUnit: 'm/s',
- units: {
- 'm/s': 1,
- 'km/h': 0.277778,
- mph: 0.44704,
- knot: 0.514444,
- },
+ units: { 'm/s': 1, 'km/h': 1 / 3.6, mph: 0.44704, knot: 0.514444 },
},
};
-const formatNumber = (num: number) => {
- if (Math.abs(num) < 1e-6 && num !== 0) {
- return num.toExponential(4);
- }
- return parseFloat(num.toFixed(4)).toString();
-};
+function convertTemperature(value: number, from: string, to: string) {
+ const celsius =
+ from === 'fahrenheit'
+ ? ((value - 32) * 5) / 9
+ : from === 'kelvin'
+ ? value - 273.15
+ : value;
+
+ return to === 'fahrenheit'
+ ? (celsius * 9) / 5 + 32
+ : to === 'kelvin'
+ ? celsius + 273.15
+ : celsius;
+}
export default function UnitConverterPage() {
- const [category, setCategory] = useState('length');
- const [fromUnit, setFromUnit] = useState('meter');
- const [toUnit, setToUnit] = useState('foot');
- const [inputValue, setInputValue] = useState('1');
-
- const handleCategoryChange = (e: ChangeEvent) => {
- const newCategory = e.target.value;
- setCategory(newCategory);
- const units = Object.keys(CONVERSION_FACTORS[newCategory].units);
- setFromUnit(units[0]);
- setToUnit(units[1]);
- setInputValue('1');
+ const [category, setCategory] = useState('length');
+ const units = Object.keys(CATEGORIES[category].units);
+ const [from, setFrom] = useState('meter');
+ const [to, setTo] = useState('foot');
+ const [input, setInput] = useState('1');
+
+ const changeCategory = (next: string) => {
+ setCategory(next);
+ const nextUnits = Object.keys(CATEGORIES[next].units);
+ setFrom(nextUnits[0]);
+ setTo(nextUnits[1] ?? nextUnits[0]);
+ setInput('1');
};
- const convert = useCallback((value: number, from: string, to: string, cat: string) => {
- const categoryData = CONVERSION_FACTORS[cat];
- if (!categoryData) return 0;
-
- // Handle temperature separately due to its non-linear conversion
- if (cat === 'temperature') {
- let celsiusValue: number;
- if (from === 'fahrenheit') celsiusValue = ((value - 32) * 5) / 9;
- else if (from === 'kelvin') celsiusValue = value - 273.15;
- else celsiusValue = value;
+ const output = useMemo(() => {
+ const value = Number(input);
+ if (!Number.isFinite(value)) return '';
- if (to === 'fahrenheit') return (celsiusValue * 9) / 5 + 32;
- if (to === 'kelvin') return celsiusValue + 273.15;
- return celsiusValue; // to celsius
- }
+ const currentCategory = CATEGORIES[category];
+ const converted = currentCategory.temperature
+ ? convertTemperature(value, from, to)
+ : (value * currentCategory.units[from]) / currentCategory.units[to];
- const fromFactor = categoryData.units[from];
- const toFactor = categoryData.units[to];
- const baseValue = value * fromFactor;
- return baseValue / toFactor;
- }, []);
-
- const outputValue = useMemo(() => {
- const numValue = parseFloat(inputValue);
- if (isNaN(numValue)) return '';
- const result = convert(numValue, fromUnit, toUnit, category);
- return formatNumber(result);
- }, [inputValue, fromUnit, toUnit, category, convert]);
-
- const handleSwap = () => {
- // const currentInput = inputValue; // FIX: Removed unused variable
- const currentOutput = outputValue;
- setFromUnit(toUnit);
- setToUnit(fromUnit);
- setInputValue(currentOutput);
- };
+ return Number.isFinite(converted) ? Number(converted.toPrecision(12)).toString() : '';
+ }, [category, from, to, input]);
return (
-
-
-
-
-
-
-
-
-
- Category
-
+
+
+
+ Category
+ changeCategory(event.target.value)}
+ className='mt-2 w-full rounded border p-2'
+ >
+ {Object.entries(CATEGORIES).map(([key, value]) => (
+
+ {value.label}
+
+ ))}
+
+
+
+
+
+ From
- {Object.entries(CONVERSION_FACTORS).map(([key, { label }]) => (
-
- {label}
-
+ value={from}
+ onChange={(event) => setFrom(event.target.value)}
+ className='mt-1 w-full rounded border p-2'
+ >
+ {units.map((unit) => (
+ {unit}
))}
-
-
-
-
-
- From
-
- setFromUnit(e.target.value)}
- className='mt-1 block w-full p-2 border border-slate-300 rounded-md shadow-sm focus:ring-sky-500'>
- {Object.keys(CONVERSION_FACTORS[category].units).map((unit) => (
-
- {unit}
-
- ))}
-
-
-
-
-
- To
-
- setToUnit(e.target.value)}
- className='mt-1 block w-full p-2 border border-slate-300 rounded-md shadow-sm focus:ring-sky-500'>
- {Object.keys(CONVERSION_FACTORS[category].units).map((unit) => (
-
- {unit}
-
- ))}
-
-
-
-
-
-
- setInputValue(e.target.value)}
- className='w-full text-lg p-2 border border-slate-300 rounded-md'
- />
-
-
=
-
-
-
-
-
-
-
-
- ← Back to All Tools
-
+
+ {
+ setFrom(to);
+ setTo(from);
+ setInput(output);
+ }}
+ className='rounded bg-slate-100 p-2'
+ aria-label='Swap units'
+ >
+ β
+
+
+ To
+ setTo(event.target.value)}
+ className='mt-1 w-full rounded border p-2'
+ >
+ {units.map((unit) => (
+ {unit}
+ ))}
+
+
-
-
-
+
+
+ Value
+ setInput(event.target.value)}
+ inputMode='decimal'
+ className='mt-1 w-full rounded border p-3 font-mono'
+ />
+
+
+ Result
+
+
+
+
);
}
diff --git a/src/app/url-cleaner/page.tsx b/src/app/url-cleaner/page.tsx
new file mode 100644
index 0000000..bfbbfe0
--- /dev/null
+++ b/src/app/url-cleaner/page.tsx
@@ -0,0 +1,116 @@
+'use client';
+
+import { Fragment, useMemo, useState } from 'react';
+import LocalToolLayout from '@/components/LocalToolLayout';
+
+const TRACKING = new Set([
+ 'fbclid',
+ 'gclid',
+ 'dclid',
+ 'msclkid',
+ 'mc_cid',
+ 'mc_eid',
+ 'igshid',
+ 'yclid',
+ '_hsenc',
+ '_hsmi',
+ 'vero_conv',
+ 'vero_id',
+]);
+
+const isTracking = (key: string) =>
+ key.toLowerCase().startsWith('utm_') || TRACKING.has(key.toLowerCase());
+
+export default function UrlCleanerPage() {
+ const [input, setInput] = useState(
+ 'https://example.com/article?utm_source=newsletter&utm_campaign=test&id=42&fbclid=abc'
+ );
+
+ const data = useMemo(() => {
+ try {
+ const url = new URL(input);
+ const removed: string[] = [];
+
+ [...url.searchParams.keys()].forEach((key) => {
+ if (isTracking(key)) {
+ removed.push(key);
+ url.searchParams.delete(key);
+ }
+ });
+
+ return {
+ url: url.toString(),
+ removed,
+ error: '',
+ parts: {
+ protocol: url.protocol,
+ host: url.host,
+ path: url.pathname,
+ query: url.search,
+ fragment: url.hash,
+ },
+ };
+ } catch (error) {
+ return {
+ url: '',
+ removed: [],
+ error: (error as Error).message,
+ parts: null,
+ };
+ }
+ }, [input]);
+
+ return (
+
+
+ URL
+ setInput(event.target.value)}
+ className='mt-2 h-28 w-full rounded border p-3 font-mono'
+ />
+
+ {data.error ? (
+ Invalid URL
+ ) : (
+ <>
+ Clean URL
+
+
+ navigator.clipboard.writeText(data.url)}
+ className='rounded bg-sky-600 px-4 text-white'
+ >
+ Copy
+
+
+
+
+ {data.removed.length
+ ? `Removed: ${data.removed.join(', ')}`
+ : 'No known tracking parameters found.'}
+
+
+ {data.parts && (
+
+ {Object.entries(data.parts).map(([key, value]) => (
+
+ {key}
+ {value || 'β'}
+
+ ))}
+
+ )}
+ >
+ )}
+
+
+ );
+}
diff --git a/src/app/uuid-generator/page.tsx b/src/app/uuid-generator/page.tsx
new file mode 100644
index 0000000..98ced34
--- /dev/null
+++ b/src/app/uuid-generator/page.tsx
@@ -0,0 +1,7 @@
+'use client';
+import { useMemo, useState } from 'react';
+import LocalToolLayout from '@/components/LocalToolLayout';
+const UUID_RE=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+const CROCKFORD='0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+function ulid(){const time=Date.now();let t=time,out='';for(let i=0;i<10;i++){out=CROCKFORD[t%32]+out;t=Math.floor(t/32)}const bytes=new Uint8Array(16);crypto.getRandomValues(bytes);for(let i=0;i<16;i++)out+=CROCKFORD[bytes[i]%32];return out}
+export default function UuidGeneratorPage(){const [count,setCount]=useState(5);const [kind,setKind]=useState<'uuid'|'ulid'>('uuid');const [nonce,setNonce]=useState(0);const [check,setCheck]=useState('');const values=useMemo(()=>{void nonce;return Array.from({length:count},()=>kind==='uuid'?crypto.randomUUID():ulid())},[count,kind,nonce]);return }
diff --git a/src/app/xml-toolbox/page.tsx b/src/app/xml-toolbox/page.tsx
new file mode 100644
index 0000000..cc1ded4
--- /dev/null
+++ b/src/app/xml-toolbox/page.tsx
@@ -0,0 +1,32 @@
+'use client';
+import { useMemo, useState } from 'react';
+import LocalToolLayout from '@/components/LocalToolLayout';
+import { formatXml } from '@/lib/xml';
+
+type Mode = 'format' | 'minify' | 'json';
+function parseXml(value: string) {
+ if (/ [attribute.name, attribute.value]));
+ if (!children.length) return Object.keys(attributes).length ? { '@attributes': attributes, '#text': element.textContent ?? '' } : element.textContent ?? '';
+ const result: Record = {};
+ if (Object.keys(attributes).length) result['@attributes'] = attributes;
+ for (const child of children) {
+ const value = elementValue(child); const current = result[child.tagName];
+ if (current === undefined) result[child.tagName] = value;
+ else if (Array.isArray(current)) current.push(value);
+ else result[child.tagName] = [current, value];
+ }
+ return result;
+}
+export default function XmlToolboxPage() {
+ const [input, setInput] = useState('- ToolStack
- Local
'); const [mode, setMode] = useState('format');
+ const result = useMemo(() => { try { const document = parseXml(input); const serialized = new XMLSerializer().serializeToString(document); const output = mode === 'format' ? formatXml(serialized) : mode === 'minify' ? serialized.replace(/>\s+<') : JSON.stringify({ [document.documentElement.tagName]: elementValue(document.documentElement) }, null, 2); return { output, error: '' }; } catch (err) { return { output: '', error: (err as Error).message }; } }, [input, mode]);
+ return {(['format','minify','json'] as Mode[]).map(value => setMode(value)} className={`rounded px-4 py-2 ${mode === value ? 'bg-sky-600 text-white' : 'bg-slate-100 text-slate-700'}`}>{value === 'json' ? 'XML to JSON' : value[0].toUpperCase() + value.slice(1)} )}
setInput(event.target.value)} aria-label='XML input' className='h-96 rounded border p-4 font-mono'/>{result.error ? `Error: ${result.error}` : result.output} {!result.error && navigator.clipboard.writeText(result.output)} className='mt-4 rounded bg-slate-800 px-4 py-2 text-white'>Copy result } ;
+}
diff --git a/src/app/yaml-json/page.tsx b/src/app/yaml-json/page.tsx
new file mode 100644
index 0000000..3bc83dc
--- /dev/null
+++ b/src/app/yaml-json/page.tsx
@@ -0,0 +1,12 @@
+'use client';
+import { useMemo, useState } from 'react';
+import YAML from 'yaml';
+import LocalToolLayout from '@/components/LocalToolLayout';
+
+type Mode = 'yaml-to-json' | 'json-to-yaml';
+export default function YamlJsonPage() {
+ const [mode, setMode] = useState('yaml-to-json'); const [input, setInput] = useState('name: ToolStack\nprivacy: true\ntools:\n - PDF Toolkit\n - File Hash\n');
+ const result = useMemo(() => { try { const output = mode === 'yaml-to-json' ? JSON.stringify(YAML.parse(input), null, 2) : YAML.stringify(JSON.parse(input)); return { output, error: '' }; } catch (err) { return { output: '', error: (err as Error).message }; } }, [input, mode]);
+ const switchMode = (next: Mode) => { setMode(next); setInput(next === 'yaml-to-json' ? 'name: ToolStack\nprivacy: true\n' : '{\n "name": "ToolStack",\n "privacy": true\n}'); };
+ return switchMode('yaml-to-json')} className={`rounded px-4 py-2 ${mode === 'yaml-to-json' ? 'bg-sky-600 text-white' : 'bg-slate-100'}`}>YAML β JSON switchMode('json-to-yaml')} className={`rounded px-4 py-2 ${mode === 'json-to-yaml' ? 'bg-sky-600 text-white' : 'bg-slate-100'}`}>JSON β YAML
setInput(event.target.value)} className='h-96 rounded border p-4 font-mono'/>{result.error ? `Error: ${result.error}` : result.output} {!result.error && navigator.clipboard.writeText(result.output)} className='mt-4 rounded bg-slate-800 px-4 py-2 text-white'>Copy result } ;
+}
diff --git a/src/components/Breadcrumbs.tsx b/src/components/Breadcrumbs.tsx
index b0e52ff..bb36c91 100644
--- a/src/components/Breadcrumbs.tsx
+++ b/src/components/Breadcrumbs.tsx
@@ -1,47 +1,9 @@
-// src/components/Breadcrumbs.tsx
'use client';
-
import Link from 'next/link';
import { usePathname } from 'next/navigation';
-
-const Breadcrumbs = () => {
- const pathname = usePathname();
-
- // Don't show breadcrumbs on the home page
- if (pathname === '/') {
- return null;
- }
-
- // Generate a friendly name from the slug
- const pageName = pathname
- .replace('/', '')
- .split('-')
- .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
- .join(' ');
-
- return (
-
-
-
-
- Home
-
-
-
-
-
-
-
-
- {pageName}
-
-
-
- );
-};
-
-export default Breadcrumbs;
+import { TOOL_BY_SLUG } from '@/config/tools';
+const SPECIAL = new Map([['/license','MIT License'],['/offline','Offline']]);
+export default function Breadcrumbs() {
+ const pathname = usePathname(); if (pathname === '/') return null; const registered = TOOL_BY_SLUG.get(pathname); const fallback = pathname.replace(/^\//,'').split('/').pop()?.split('-').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' ') || 'Page'; const pageName = registered?.name ?? SPECIAL.get(pathname) ?? fallback;
+ return Home/ {pageName} ;
+}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 57525d3..bbaa3cb 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -1,20 +1,5 @@
-// src/components/Footer.tsx
-const Footer = () => {
- const currentYear = new Date().getFullYear();
- return (
-
- );
-};
-
-export default Footer;
+import Link from 'next/link';
+export default function Footer() {
+ const year = new Date().getFullYear();
+ return ;
+}
diff --git a/src/components/LocalToolLayout.tsx b/src/components/LocalToolLayout.tsx
new file mode 100644
index 0000000..0aeec2b
--- /dev/null
+++ b/src/components/LocalToolLayout.tsx
@@ -0,0 +1,8 @@
+import Navbar from './Navbar';
+import Footer from './Footer';
+import Breadcrumbs from './Breadcrumbs';
+import ToolPage from './ToolPage';
+
+export default function LocalToolLayout({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
+ return {children}
;
+}
diff --git a/src/components/PrivacyBadge.tsx b/src/components/PrivacyBadge.tsx
new file mode 100644
index 0000000..0095e95
--- /dev/null
+++ b/src/components/PrivacyBadge.tsx
@@ -0,0 +1,12 @@
+export default function PrivacyBadge() {
+ return (
+
+ π
+ Processed locally
+ β’
+ No upload
+ β’
+ No tracking
+
+ );
+}
diff --git a/src/components/PwaRegistration.tsx b/src/components/PwaRegistration.tsx
new file mode 100644
index 0000000..50ee199
--- /dev/null
+++ b/src/components/PwaRegistration.tsx
@@ -0,0 +1,3 @@
+'use client';
+import { useEffect } from 'react';
+export default function PwaRegistration() { useEffect(() => { if (process.env.NODE_ENV !== 'production' || !('serviceWorker' in navigator)) return; navigator.serviceWorker.register('/sw.js').catch(() => undefined); }, []); return null; }
diff --git a/src/components/SourceLink.tsx b/src/components/SourceLink.tsx
new file mode 100644
index 0000000..d829725
--- /dev/null
+++ b/src/components/SourceLink.tsx
@@ -0,0 +1,3 @@
+'use client';
+import { usePathname } from 'next/navigation';
+export default function SourceLink() { const pathname = usePathname(); if (!pathname || pathname === '/') return null; return View this tool source ; }
diff --git a/src/components/TagCloud.tsx b/src/components/TagCloud.tsx
deleted file mode 100644
index bd78c3e..0000000
--- a/src/components/TagCloud.tsx
+++ /dev/null
@@ -1,72 +0,0 @@
-// src/components/TagCloud.tsx
-'use client';
-
-export interface Tag {
- text: string;
- weight: number;
- slug: string; // We'll keep slug for potential future use but won't navigate
-}
-
-interface TagCloudProps {
- tags: Tag[];
- onTagClick: (tagText: string) => void;
- selectedTag: string | null;
-}
-
-// =================================================================
-// START: NEW HELPER FUNCTION
-// This function determines the CSS classes based on tag weight.
-// =================================================================
-const getTagStyle = (weight: number): string => {
- if (weight > 5) {
- // Most prominent tags (e.g., developer)
- return 'text-base font-bold px-5 py-2.5';
- }
- if (weight > 3) {
- // Very common tags (e.g., image, text)
- return 'text-base font-semibold px-4 py-2';
- }
- if (weight > 1) {
- // Common tags (e.g., color, design)
- return 'text-sm font-medium px-4 py-2';
- }
- // Least common tags
- return 'text-xs font-medium px-3 py-1.5';
-};
-// =================================================================
-// END: NEW HELPER FUNCTION
-// =================================================================
-
-const TagCloud = ({ tags, onTagClick, selectedTag }: TagCloudProps) => {
- if (!tags || tags.length === 0) {
- return No popular tags to display right now.
;
- }
-
- return (
-
- {tags.map((tag) => {
- const isSelected = selectedTag === tag.text;
- const sizeStyle = getTagStyle(tag.weight); // Get dynamic styles
-
- return (
- onTagClick(tag.text)}
- // Combine base styles with dynamic size styles
- className={`rounded-full transition-colors duration-150 ease-in-out shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-opacity-50
- ${sizeStyle}
- ${
- isSelected
- ? 'bg-sky-600 text-white hover:bg-sky-700'
- : 'bg-sky-100 text-sky-700 hover:bg-sky-200 hover:text-sky-800'
- }
- `}>
- {tag.text}
-
- );
- })}
-
- );
-};
-
-export default TagCloud;
diff --git a/src/components/ToolCard.tsx b/src/components/ToolCard.tsx
index b92846f..c8bb25e 100644
--- a/src/components/ToolCard.tsx
+++ b/src/components/ToolCard.tsx
@@ -1,33 +1,19 @@
-// src/components/ToolCard.tsx
import Link from 'next/link';
-import type { Tool } from '@/app/page'; // Or from '@/types'
+import type { Tool } from '@/types/tool';
-interface ToolCardProps {
- tool: Tool;
-}
-
-const ToolCard = ({ tool }: ToolCardProps) => {
- return (
-
- {/* You can add an icon here later if you have one */}
- {/* {tool.icon && } */}
-
- {tool.name}
- {tool.description}
+interface ToolCardProps { tool: Tool }
- {tool.tags && tool.tags.length > 0 && (
-
- {tool.tags.map((tag) => (
-
- {tag}
-
- ))}
-
- )}
-
- );
-};
-
-export default ToolCard;
+export default function ToolCard({ tool }: ToolCardProps) {
+ return
+
+
{tool.name}
+
+ {tool.new && New }
+ Local
+
+
+ {tool.description}
+ {tool.category}
+ {tool.tags.slice(0,5).map(tag => {tag} )}
+ ;
+}
diff --git a/src/components/ToolList.tsx b/src/components/ToolList.tsx
index 644ac5d..2bed586 100644
--- a/src/components/ToolList.tsx
+++ b/src/components/ToolList.tsx
@@ -1,23 +1,7 @@
-// src/components/ToolList.tsx
-import ToolCard from './ToolCard'; // Import the ToolCard component
-import type { Tool } from '@/app/page'; // Or from '@/types' if you moved it
+import ToolCard from './ToolCard';
+import type { Tool } from '@/types/tool';
-interface ToolListProps {
- tools: Tool[];
+export default function ToolList({ tools }: { tools: Tool[] }) {
+ if (tools.length === 0) return null;
+ return {tools.map(tool => )}
;
}
-
-const ToolList = ({ tools }: ToolListProps) => {
- if (!tools || tools.length === 0) {
- return No tools available at the moment.
;
- }
-
- return (
-
- {tools.map((tool) => (
-
- ))}
-
- );
-};
-
-export default ToolList;
diff --git a/src/components/ToolPage.tsx b/src/components/ToolPage.tsx
new file mode 100644
index 0000000..913f67d
--- /dev/null
+++ b/src/components/ToolPage.tsx
@@ -0,0 +1,6 @@
+import Link from 'next/link';
+import PrivacyBadge from './PrivacyBadge';
+import SourceLink from './SourceLink';
+export default function ToolPage({ title, description, children }: { title: string; description: string; children: React.ReactNode }) {
+ return {children} ← Back to all tools
;
+}
diff --git a/src/config/tools.ts b/src/config/tools.ts
new file mode 100644
index 0000000..7b3f028
--- /dev/null
+++ b/src/config/tools.ts
@@ -0,0 +1,52 @@
+import type { Tool, ToolCategory } from '@/types/tool';
+
+const tool = (slug: string, name: string, description: string, category: ToolCategory, tags: string[], options: Partial> = {}): Tool => ({
+ id: slug, slug: `/${slug}`, name, description, category, tags, processing: 'local', networkRequired: false, storesData: false, ...options,
+});
+
+export const TOOLS: Tool[] = [
+ tool('image-compressor','Image Compressor','Compress image batches locally with browser-side processing.','Images',['image','compress','optimize','media']),
+ tool('regex-tester','Regex Tester & Explainer','Test regular expressions in a disposable worker with a timeout.','Developer',['text','developer','regex','code']),
+ tool('text-diff','Text Diff Checker','Compare two pieces of text and highlight differences.','Text & Data',['text','compare','diff']),
+ tool('color-converter','Color Code Converter','Convert HEX, RGB and HSL color values.','Converters',['color','design','developer','converter']),
+ tool('placeholder-generator','Placeholder Image Generator','Create custom placeholder images locally.','Images',['image','design','placeholder']),
+ tool('text-analyzer','Text Statistics / Analyzer','Count words, characters, sentences and more.','Text & Data',['text','stats','analyzer','counter']),
+ tool('image-metadata','Image Metadata Remover','View and remove EXIF metadata from JPEG images.','Privacy & Security',['image','privacy','exif','metadata','security'],{featured:true}),
+ tool('markdown-editor','Markdown Editor','Edit Markdown with a sanitized live local preview.','Text & Data',['text','markdown','editor','writing']),
+ tool('csv-converter','CSV to JSON/XML Converter','Convert CSV data to JSON or XML locally.','Converters',['csv','json','xml','converter','data','developer']),
+ tool('contrast-checker','Text Contrast Checker','Check color contrast against accessibility guidance.','Accessibility',['accessibility','color','design','wcag']),
+ tool('image-resizer','Image Resizer','Resize images to specific dimensions locally.','Images',['image','resize','dimensions','optimize']),
+ tool('image-cropper','Image Cropper','Crop images locally in your browser.','Images',['image','crop','edit','dimensions']),
+ tool('lorem-ipsum-generator','Lorem Ipsum Generator','Generate placeholder text.','Generators',['text','generator','placeholder','design']),
+ tool('regex-generator','Easy Regex Generator','Build common regular expressions without memorizing syntax.','Developer',['regex','generator','developer','text','code']),
+ tool('find-replace','Find & Replace in Text','Find and replace text with optional regex support.','Text & Data',['text','find','replace','regex']),
+ tool('data-anonymizer','Data Anonymizer','Redact common sensitive-looking values locally.','Privacy & Security',['text','privacy','security','anonymize','redact'],{featured:true}),
+ tool('css-specificity-calculator','CSS Specificity Calculator','Calculate modern CSS selector specificity with an AST parser.','Developer',['css','developer','specificity','design']),
+ tool('unit-converter','Unit Converter','Convert common measurement units.','Converters',['converter','units','measurement','math']),
+ tool('http-status-codes','HTTP Status Code Explainer','Look up HTTP status codes and meanings.','Developer',['http','developer','reference','network']),
+ tool('list-cleaner','List Cleaner & Formatter','Sort, deduplicate and format text lists.','Text & Data',['text','list','format','cleaner','sort']),
+ tool('html-tag-explainer','HTML Tag Explainer','Reference standard HTML tags and their uses.','Developer',['html','developer','reference','code']),
+ tool('file-hash','File Hash & Checksum','Generate SHA-256, SHA-384 and SHA-512 checksums without uploading files.','Privacy & Security',['privacy','hash','checksum','file','security'],{featured:true,new:true}),
+ tool('json-toolbox','JSON Toolbox','Format, minify, validate, sort and structurally compare JSON locally.','Developer',['json','format','validate','diff','developer','data'],{new:true}),
+ tool('url-cleaner','Tracking URL Cleaner','Remove common tracking parameters and inspect URL parts locally.','Privacy & Security',['privacy','url','tracking','utm','cleaner'],{featured:true,new:true}),
+ tool('password-generator','Password & Passphrase Generator','Generate cryptographically secure passwords and passphrases locally.','Privacy & Security',['password','security','privacy','generator'],{featured:true,new:true}),
+ tool('encoder-decoder','Encoder / Decoder','Encode and decode Base64, URLs and HTML entities.','Developer',['base64','url','html','unicode','encode','decode'],{new:true}),
+ tool('jwt-inspector','JWT Inspector','Decode JWT headers and payloads locally without sending tokens anywhere.','Privacy & Security',['jwt','token','developer','privacy','security'],{new:true}),
+ tool('timestamp-tool','Timestamp & Date Toolkit','Convert Unix timestamps, ISO dates, UTC and local time.','Converters',['date','time','timestamp','unix','converter'],{new:true}),
+ tool('uuid-generator','UUID / ULID Generator','Generate and validate UUIDs and generate ULIDs locally.','Generators',['uuid','ulid','generator','developer','identifier'],{new:true}),
+ tool('browser-privacy','Browser Privacy Inspector','See what your browser exposes to websites without transmitting the results.','Privacy & Security',['privacy','browser','fingerprint','security'],{featured:true,new:true}),
+ tool('text-privacy-cleaner','Text Privacy Cleaner','Reveal or remove hidden Unicode, control and zero-width characters.','Privacy & Security',['privacy','text','unicode','cleaner','security'],{new:true}),
+ tool('image-converter','Image Format Converter','Convert supported images to PNG, JPEG or WebP locally.','Images',['image','convert','png','jpeg','webp'],{new:true}),
+ tool('file-encryption','File Encryption','Encrypt and decrypt files locally with AES-GCM and a password-derived key.','Privacy & Security',['privacy','encryption','aes','file','security'],{featured:true,new:true}),
+ tool('pdf-toolkit','PDF Toolkit','Merge, split, reorder, extract, delete and rotate PDF pages locally.','PDF & Documents',['pdf','merge','split','pages','documents','privacy'],{featured:true,new:true}),
+ tool('file-signature-inspector','File Type & Signature Inspector','Inspect file magic bytes instead of trusting only the extension.','Privacy & Security',['file','signature','magic bytes','security','inspector'],{new:true}),
+ tool('content-credentials','Content Credentials Inspector','Read embedded C2PA provenance metadata locally with bundled WebAssembly.','Privacy & Security',['c2pa','content credentials','provenance','metadata','privacy'],{featured:true,new:true}),
+ tool('xml-toolbox','XML Toolbox','Validate, format, minify and convert XML to JSON locally.','Converters',['xml','json','format','validate','converter'],{new:true}),
+ tool('yaml-json','YAML β JSON Converter','Convert YAML and JSON locally with a bundled parser.','Converters',['yaml','json','converter','developer','data'],{new:true}),
+ tool('csp-builder','CSP Builder & Analyzer','Draft and inspect Content Security Policy headers locally.','Developer',['csp','security','headers','developer','web'],{new:true}),
+ tool('hmac-tool','HMAC Generator & Verifier','Generate and verify HMAC-SHA signatures using Web Crypto.','Privacy & Security',['hmac','hash','signature','crypto','security'],{new:true}),
+ tool('cron-explainer','Cron Expression Explainer','Explain standard five-field cron expressions.','Developer',['cron','schedule','developer','automation'],{new:true}),
+];
+
+export const TOOL_BY_SLUG = new Map(TOOLS.map(item => [item.slug, item]));
+export const TOOL_CATEGORIES = [...new Set(TOOLS.map(item => item.category))];
diff --git a/src/lib/cron.ts b/src/lib/cron.ts
new file mode 100644
index 0000000..e9add2c
--- /dev/null
+++ b/src/lib/cron.ts
@@ -0,0 +1,36 @@
+const FIELDS = [
+ { name: 'minute', min: 0, max: 59 }, { name: 'hour', min: 0, max: 23 }, { name: 'day of month', min: 1, max: 31 },
+ { name: 'month', min: 1, max: 12 }, { name: 'day of week', min: 0, max: 7 },
+] as const;
+const MONTHS = ['','January','February','March','April','May','June','July','August','September','October','November','December'];
+const DAYS = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday','Sunday'];
+
+function labelValue(field: number, value: number) {
+ if (field === 3) return MONTHS[value] ?? String(value);
+ if (field === 4) return DAYS[value] ?? String(value);
+ return String(value);
+}
+
+function validateNumber(value: number, field: number) {
+ const rule = FIELDS[field];
+ if (!Number.isInteger(value) || value < rule.min || value > rule.max) throw new Error(`${rule.name} must be between ${rule.min} and ${rule.max}.`);
+}
+
+function describePart(part: string, field: number): string {
+ if (part === '*') return `every ${FIELDS[field].name}`;
+ const step = part.match(/^\*\/(\d+)$/);
+ if (step) { const value = Number(step[1]); if (value < 1) throw new Error('Step values must be positive.'); return `every ${value} ${FIELDS[field].name}${value === 1 ? '' : 's'}`; }
+ const range = part.match(/^(\d+)-(\d+)$/);
+ if (range) { const start = Number(range[1]); const end = Number(range[2]); validateNumber(start, field); validateNumber(end, field); return `${labelValue(field, start)} through ${labelValue(field, end)}`; }
+ if (/^\d+$/.test(part)) { const value = Number(part); validateNumber(value, field); return labelValue(field, value); }
+ throw new Error(`Unsupported ${FIELDS[field].name} token: ${part}`);
+}
+
+export function explainCron(expression: string) {
+ const fields = expression.trim().split(/\s+/);
+ if (fields.length !== 5) throw new Error('Enter a standard five-field cron expression: minute hour day-of-month month day-of-week.');
+ return fields.map((field, index) => {
+ const parts = field.split(',').map(part => describePart(part, index));
+ return { field: FIELDS[index].name, raw: field, explanation: parts.join(', ') };
+ });
+}
diff --git a/src/lib/csp.ts b/src/lib/csp.ts
new file mode 100644
index 0000000..61ed46a
--- /dev/null
+++ b/src/lib/csp.ts
@@ -0,0 +1,25 @@
+export interface CspFinding { level: 'warning' | 'info'; message: string }
+export function analyzeCsp(policy: string): CspFinding[] {
+ const directives = new Map();
+ for (const part of policy.split(';')) {
+ const tokens = part.trim().split(/\s+/).filter(Boolean);
+ if (tokens.length) directives.set(tokens[0].toLowerCase(), tokens.slice(1));
+ }
+ const findings: CspFinding[] = [];
+ if (!directives.has('default-src')) findings.push({ level: 'warning', message: 'Add default-src as a fallback policy.' });
+ const script = directives.get('script-src') ?? directives.get('default-src') ?? [];
+ if (script.includes("'unsafe-inline'")) findings.push({ level: 'warning', message: "script-src allows 'unsafe-inline'. Prefer nonces or hashes when practical." });
+ if (script.includes("'unsafe-eval'")) findings.push({ level: 'warning', message: "script-src allows 'unsafe-eval', which weakens script injection protection." });
+ if (script.includes('*')) findings.push({ level: 'warning', message: 'script-src contains a wildcard source.' });
+ const objectSrc = directives.get('object-src');
+ if (!objectSrc || !objectSrc.includes("'none'")) findings.push({ level: 'warning', message: "Set object-src 'none' unless legacy plug-in content is required." });
+ if (!directives.has('frame-ancestors')) findings.push({ level: 'info', message: 'Consider frame-ancestors to control embedding and clickjacking.' });
+ if (!directives.has('base-uri')) findings.push({ level: 'info', message: 'Consider base-uri to prevent injected base tags from changing relative URLs.' });
+ if (!directives.has('form-action')) findings.push({ level: 'info', message: 'Consider form-action to restrict form submission destinations.' });
+ for (const [name, sources] of directives) {
+ if (sources.includes('*')) findings.push({ level: 'warning', message: `${name} contains a wildcard source.` });
+ if (sources.some(source => source.startsWith('http:'))) findings.push({ level: 'warning', message: `${name} contains an insecure http: source.` });
+ }
+ if (!findings.length) findings.push({ level: 'info', message: 'No obvious issues found by this lightweight analyzer. Review the policy against your actual application needs.' });
+ return findings;
+}
diff --git a/src/lib/css-specificity.test.ts b/src/lib/css-specificity.test.ts
new file mode 100644
index 0000000..246276a
--- /dev/null
+++ b/src/lib/css-specificity.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest';
+import { calculateSpecificity } from './css-specificity';
+
+const score = (selector: string) => calculateSpecificity(selector)[0].specificity;
+
+describe('CSS specificity', () => {
+ it('counts basic selectors', () => expect(score('#id .class div')).toEqual([1,1,1]));
+ it(':where contributes zero specificity', () => expect(score(':where(#id,.x)')).toEqual([0,0,0]));
+ it(':is uses the highest argument specificity', () => expect(score(':is(#id,.x)')).toEqual([1,0,0]));
+ it(':has adds the highest argument to the selector around it', () => expect(score('.card:has(> #cta)')).toEqual([1,1,0]));
+ it('counts pseudo-elements like elements', () => expect(score('a::before')).toEqual([0,0,2]));
+});
diff --git a/src/lib/css-specificity.ts b/src/lib/css-specificity.ts
new file mode 100644
index 0000000..01b53f6
--- /dev/null
+++ b/src/lib/css-specificity.ts
@@ -0,0 +1,59 @@
+import selectorParser from 'postcss-selector-parser';
+
+export type Specificity = [number, number, number];
+export interface SpecificityResult {
+ selector: string;
+ specificity: Specificity;
+}
+
+type AstNode = {
+ type: string;
+ value?: string;
+ nodes?: AstNode[];
+ toString?: () => string;
+};
+
+const add = (a: Specificity, b: Specificity): Specificity => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
+const compare = (a: Specificity, b: Specificity) => a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
+const max = (values: Specificity[]) => values.reduce((best, value) => compare(value, best) > 0 ? value : best, [0, 0, 0] as Specificity);
+
+function selectorSpecificity(node: AstNode): Specificity {
+ return (node.nodes ?? []).reduce((score, child) => add(score, nodeSpecificity(child)), [0, 0, 0]);
+}
+
+function functionalPseudoMax(node: AstNode): Specificity {
+ const selectors = (node.nodes ?? []).filter((child) => child.type === 'selector');
+ return max(selectors.map(selectorSpecificity));
+}
+
+function nodeSpecificity(node: AstNode): Specificity {
+ switch (node.type) {
+ case 'id': return [1, 0, 0];
+ case 'class':
+ case 'attribute': return [0, 1, 0];
+ case 'tag': return [0, 0, 1];
+ case 'pseudo': {
+ const value = (node.value ?? '').toLowerCase();
+ if (value.startsWith('::')) return [0, 0, 1];
+ if (value === ':where') return [0, 0, 0];
+ if (value === ':is' || value === ':not' || value === ':has') return functionalPseudoMax(node);
+ if (value === ':host' || value === ':host-context') return add([0, 1, 0], functionalPseudoMax(node));
+ if (value === ':nth-child' || value === ':nth-last-child') return add([0, 1, 0], functionalPseudoMax(node));
+ return [0, 1, 0];
+ }
+ default: return [0, 0, 0];
+ }
+}
+
+export function calculateSpecificity(selector: string): SpecificityResult[] {
+ const root = selectorParser().astSync(selector, { lossless: false }) as unknown as AstNode;
+ const selectors = (root.nodes ?? []).filter((node) => node.type === 'selector');
+ return selectors.map((node) => ({
+ selector: node.toString?.() ?? selector,
+ specificity: selectorSpecificity(node),
+ }));
+}
+
+export function specificityLabel([a, b, c]: Specificity) {
+ return `${a}-${b}-${c}`;
+}
diff --git a/src/lib/file-signatures.test.ts b/src/lib/file-signatures.test.ts
new file mode 100644
index 0000000..56c0c0f
--- /dev/null
+++ b/src/lib/file-signatures.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import { detectFileSignature, extensionMatches } from './file-signatures';
+
+const bytes = (values: number[]) => new Uint8Array(values);
+
+describe('file signature detection', () => {
+ it('detects a PDF header', () => expect(detectFileSignature(bytes([0x25,0x50,0x44,0x46,0x2d]))?.mime).toBe('application/pdf'));
+ it('detects a PNG header', () => expect(detectFileSignature(bytes([0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]))?.mime).toBe('image/png'));
+ it('marks ZIP as a container format', () => expect(detectFileSignature(bytes([0x50,0x4b,0x03,0x04]))?.container).toBe(true));
+ it('checks filename consistency', () => {
+ const info = detectFileSignature(bytes([0x25,0x50,0x44,0x46,0x2d]));
+ expect(extensionMatches(info, 'report.pdf')).toBe(true);
+ expect(extensionMatches(info, 'report.jpg')).toBe(false);
+ });
+});
diff --git a/src/lib/file-signatures.ts b/src/lib/file-signatures.ts
new file mode 100644
index 0000000..293738e
--- /dev/null
+++ b/src/lib/file-signatures.ts
@@ -0,0 +1,51 @@
+export interface SignatureInfo {
+ name: string;
+ mime: string;
+ extensions: string[];
+ container?: boolean;
+}
+
+type Matcher = SignatureInfo & { match: (bytes: Uint8Array) => boolean };
+const at = (bytes: Uint8Array, signature: number[], offset = 0) => signature.every((value, index) => bytes[offset + index] === value);
+const ascii = (bytes: Uint8Array, text: string, offset = 0) => [...text].every((char, index) => bytes[offset + index] === char.charCodeAt(0));
+
+const MATCHERS: Matcher[] = [
+ { name: 'PDF document', mime: 'application/pdf', extensions: ['pdf'], match: b => ascii(b, '%PDF-') },
+ { name: 'PNG image', mime: 'image/png', extensions: ['png'], match: b => at(b, [0x89,0x50,0x4e,0x47,0x0d,0x0a,0x1a,0x0a]) },
+ { name: 'JPEG image', mime: 'image/jpeg', extensions: ['jpg','jpeg'], match: b => at(b, [0xff,0xd8,0xff]) },
+ { name: 'GIF image', mime: 'image/gif', extensions: ['gif'], match: b => ascii(b, 'GIF87a') || ascii(b, 'GIF89a') },
+ { name: 'WebP image', mime: 'image/webp', extensions: ['webp'], match: b => ascii(b, 'RIFF') && ascii(b, 'WEBP', 8) },
+ { name: 'WAV audio', mime: 'audio/wav', extensions: ['wav'], match: b => ascii(b, 'RIFF') && ascii(b, 'WAVE', 8) },
+ { name: 'AVI video', mime: 'video/x-msvideo', extensions: ['avi'], match: b => ascii(b, 'RIFF') && ascii(b, 'AVI ', 8) },
+ { name: 'ZIP-based archive', mime: 'application/zip', extensions: ['zip','docx','xlsx','pptx','odt','ods','odp','jar','apk'], container: true, match: b => at(b,[0x50,0x4b,0x03,0x04]) || at(b,[0x50,0x4b,0x05,0x06]) || at(b,[0x50,0x4b,0x07,0x08]) },
+ { name: 'GZIP archive', mime: 'application/gzip', extensions: ['gz','tgz'], match: b => at(b,[0x1f,0x8b]) },
+ { name: '7-Zip archive', mime: 'application/x-7z-compressed', extensions: ['7z'], match: b => at(b,[0x37,0x7a,0xbc,0xaf,0x27,0x1c]) },
+ { name: 'RAR archive', mime: 'application/vnd.rar', extensions: ['rar'], match: b => at(b,[0x52,0x61,0x72,0x21,0x1a,0x07]) },
+ { name: 'ELF executable', mime: 'application/x-elf', extensions: ['elf','so'], match: b => at(b,[0x7f,0x45,0x4c,0x46]) },
+ { name: 'Windows executable / PE container', mime: 'application/vnd.microsoft.portable-executable', extensions: ['exe','dll','sys'], match: b => at(b,[0x4d,0x5a]) },
+ { name: 'SQLite database', mime: 'application/vnd.sqlite3', extensions: ['sqlite','sqlite3','db'], match: b => ascii(b, 'SQLite format 3\u0000') },
+ { name: 'Ogg media', mime: 'application/ogg', extensions: ['ogg','oga','ogv','opus'], match: b => ascii(b, 'OggS') },
+ { name: 'MP4 / ISO Base Media', mime: 'video/mp4', extensions: ['mp4','m4a','m4v','mov','heic','avif'], container: true, match: b => ascii(b, 'ftyp', 4) },
+ { name: 'MP3 audio with ID3 tag', mime: 'audio/mpeg', extensions: ['mp3'], match: b => ascii(b, 'ID3') },
+ { name: 'BMP image', mime: 'image/bmp', extensions: ['bmp'], match: b => ascii(b, 'BM') },
+ { name: 'TIFF image', mime: 'image/tiff', extensions: ['tif','tiff'], match: b => at(b,[0x49,0x49,0x2a,0x00]) || at(b,[0x4d,0x4d,0x00,0x2a]) },
+ { name: 'ICO image', mime: 'image/x-icon', extensions: ['ico'], match: b => at(b,[0x00,0x00,0x01,0x00]) },
+];
+
+export function detectFileSignature(bytes: Uint8Array): SignatureInfo | null {
+ const found = MATCHERS.find(item => item.match(bytes));
+ if (!found) return null;
+ return { name: found.name, mime: found.mime, extensions: found.extensions, container: found.container };
+}
+
+export function fileExtension(name: string) {
+ const part = name.toLowerCase().split('.').pop();
+ return part && part !== name.toLowerCase() ? part : '';
+}
+
+export function extensionMatches(info: SignatureInfo | null, name: string) {
+ if (!info) return null;
+ const extension = fileExtension(name);
+ if (!extension) return null;
+ return info.extensions.includes(extension);
+}
diff --git a/src/lib/json-diff.test.ts b/src/lib/json-diff.test.ts
new file mode 100644
index 0000000..1434674
--- /dev/null
+++ b/src/lib/json-diff.test.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from 'vitest';
+import { diffJson } from './json-diff';
+
+describe('diffJson', () => {
+ it('reports changed, added and removed paths', () => {
+ const result = diffJson({ a: 1, b: 2, nested: { x: true } }, { a: 3, c: 4, nested: { x: true } });
+ expect(result).toEqual(expect.arrayContaining([
+ { path: '$.a', kind: 'changed', before: 1, after: 3 },
+ { path: '$.b', kind: 'removed', before: 2 },
+ { path: '$.c', kind: 'added', after: 4 },
+ ]));
+ });
+ it('uses array indexes in paths', () => expect(diffJson([1,2],[1,3])).toEqual([{ path: '$[1]', kind: 'changed', before: 2, after: 3 }]));
+});
diff --git a/src/lib/json-diff.ts b/src/lib/json-diff.ts
new file mode 100644
index 0000000..1f4e7e7
--- /dev/null
+++ b/src/lib/json-diff.ts
@@ -0,0 +1,31 @@
+export type JsonDiffKind = 'added' | 'removed' | 'changed';
+export interface JsonDiffEntry { path: string; kind: JsonDiffKind; before?: unknown; after?: unknown }
+const record = (value: unknown): value is Record => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+const childPath = (base: string, key: string) => /^[A-Za-z_$][\w$]*$/.test(key) ? `${base}.${key}` : `${base}[${JSON.stringify(key)}]`;
+
+export function diffJson(before: unknown, after: unknown, path = '$'): JsonDiffEntry[] {
+ if (Object.is(before, after)) return [];
+ if (Array.isArray(before) && Array.isArray(after)) {
+ const out: JsonDiffEntry[] = [];
+ const length = Math.max(before.length, after.length);
+ for (let index = 0; index < length; index++) {
+ const itemPath = `${path}[${index}]`;
+ if (index >= before.length) out.push({ path: itemPath, kind: 'added', after: after[index] });
+ else if (index >= after.length) out.push({ path: itemPath, kind: 'removed', before: before[index] });
+ else out.push(...diffJson(before[index], after[index], itemPath));
+ }
+ return out;
+ }
+ if (record(before) && record(after)) {
+ const out: JsonDiffEntry[] = [];
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
+ for (const key of keys) {
+ const itemPath = childPath(path, key);
+ if (!(key in before)) out.push({ path: itemPath, kind: 'added', after: after[key] });
+ else if (!(key in after)) out.push({ path: itemPath, kind: 'removed', before: before[key] });
+ else out.push(...diffJson(before[key], after[key], itemPath));
+ }
+ return out;
+ }
+ return [{ path, kind: 'changed', before, after }];
+}
diff --git a/src/lib/page-selection.test.ts b/src/lib/page-selection.test.ts
new file mode 100644
index 0000000..6f7da09
--- /dev/null
+++ b/src/lib/page-selection.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { parsePageSelection } from './page-selection';
+
+describe('parsePageSelection', () => {
+ it('selects every page when input is empty', () => expect(parsePageSelection('', 4)).toEqual([0,1,2,3]));
+ it('preserves explicit order and ranges', () => expect(parsePageSelection('1-3,5', 5)).toEqual([0,1,2,4]));
+ it('supports descending ranges for reordering', () => expect(parsePageSelection('4-2,1', 4)).toEqual([3,2,1,0]));
+ it('rejects pages outside the document', () => expect(() => parsePageSelection('1,6', 5)).toThrow(/outside 1-5/));
+ it('rejects invalid tokens', () => expect(() => parsePageSelection('1-three', 5)).toThrow(/Invalid page token/));
+});
diff --git a/src/lib/page-selection.ts b/src/lib/page-selection.ts
new file mode 100644
index 0000000..164b3b7
--- /dev/null
+++ b/src/lib/page-selection.ts
@@ -0,0 +1,24 @@
+export function parsePageSelection(input: string, totalPages: number) {
+ if (!Number.isInteger(totalPages) || totalPages < 1) throw new Error('The PDF has no pages.');
+ const value = input.trim();
+ if (!value) return Array.from({ length: totalPages }, (_, index) => index);
+ const pages: number[] = [];
+ for (const rawPart of value.split(',')) {
+ const part = rawPart.trim();
+ if (!part) continue;
+ const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
+ if (range) {
+ const start = Number(range[1]); const end = Number(range[2]);
+ if (start < 1 || start > totalPages || end < 1 || end > totalPages) throw new Error(`Page range ${part} is outside 1-${totalPages}.`);
+ const step = start <= end ? 1 : -1;
+ for (let page = start; page !== end + step; page += step) pages.push(page - 1);
+ continue;
+ }
+ if (!/^\d+$/.test(part)) throw new Error(`Invalid page token: ${part}`);
+ const page = Number(part);
+ if (page < 1 || page > totalPages) throw new Error(`Page ${page} is outside 1-${totalPages}.`);
+ pages.push(page - 1);
+ }
+ if (!pages.length) throw new Error('Select at least one page.');
+ return pages;
+}
diff --git a/src/lib/regex-worker.ts b/src/lib/regex-worker.ts
new file mode 100644
index 0000000..43dee02
--- /dev/null
+++ b/src/lib/regex-worker.ts
@@ -0,0 +1,77 @@
+export interface RegexMatch {
+ match: string;
+ index: number;
+ groups: Array;
+}
+
+export interface RegexMatchResult {
+ matches: RegexMatch[];
+ executionTime: number;
+}
+
+const MAX_INPUT_LENGTH = 500_000;
+const MAX_PATTERN_LENGTH = 4_000;
+const DEFAULT_TIMEOUT_MS = 500;
+
+const workerSource = `
+self.onmessage = (event) => {
+ const { mode, pattern, flags, text, replacement } = event.data;
+ const started = performance.now();
+ try {
+ const regex = new RegExp(pattern, flags);
+ if (mode === 'replace') {
+ self.postMessage({ ok: true, output: text.replace(regex, replacement), executionTime: performance.now() - started });
+ return;
+ }
+ const raw = regex.global ? Array.from(text.matchAll(regex)) : (() => { const match = regex.exec(text); return match ? [match] : []; })();
+ const matches = raw.map((match) => ({
+ match: match[0],
+ index: match.index ?? -1,
+ groups: Array.from(match).slice(1),
+ }));
+ self.postMessage({ ok: true, matches, executionTime: performance.now() - started });
+ } catch (error) {
+ self.postMessage({ ok: false, error: error instanceof Error ? error.message : String(error) });
+ }
+};
+`;
+
+function execute(payload: Record, timeoutMs = DEFAULT_TIMEOUT_MS): Promise {
+ const pattern = String(payload.pattern ?? '');
+ const text = String(payload.text ?? '');
+ if (pattern.length > MAX_PATTERN_LENGTH) return Promise.reject(new Error(`Pattern is limited to ${MAX_PATTERN_LENGTH.toLocaleString()} characters.`));
+ if (text.length > MAX_INPUT_LENGTH) return Promise.reject(new Error(`Input is limited to ${MAX_INPUT_LENGTH.toLocaleString()} characters for regex execution.`));
+ if (typeof Worker === 'undefined') return Promise.reject(new Error('Web Workers are not supported by this browser.'));
+
+ return new Promise((resolve, reject) => {
+ const url = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' }));
+ const worker = new Worker(url);
+ URL.revokeObjectURL(url);
+ const timer = window.setTimeout(() => {
+ worker.terminate();
+ reject(new Error(`Regex execution exceeded ${timeoutMs} ms and was stopped.`));
+ }, timeoutMs);
+
+ worker.onmessage = (event: MessageEvent>) => {
+ window.clearTimeout(timer);
+ worker.terminate();
+ if (event.data.ok) resolve(event.data as T);
+ else reject(new Error(String(event.data.error ?? 'Regex execution failed.')));
+ };
+ worker.onerror = () => {
+ window.clearTimeout(timer);
+ worker.terminate();
+ reject(new Error('Regex worker failed.'));
+ };
+ worker.postMessage(payload);
+ });
+}
+
+export function runRegexMatch(pattern: string, flags: string, text: string, timeoutMs?: number) {
+ return execute({ mode: 'match', pattern, flags, text }, timeoutMs);
+}
+
+export async function runRegexReplace(pattern: string, flags: string, text: string, replacement: string, timeoutMs?: number) {
+ const result = await execute<{ ok: true; output: string; executionTime: number }>({ mode: 'replace', pattern, flags, text, replacement }, timeoutMs);
+ return result.output;
+}
diff --git a/src/lib/xml.test.ts b/src/lib/xml.test.ts
new file mode 100644
index 0000000..5fdf509
--- /dev/null
+++ b/src/lib/xml.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { escapeXml, xmlName } from './xml';
+
+describe('XML helpers', () => {
+ it('escapes XML special characters', () => expect(escapeXml(`Tom & Jerry's `)).toBe('<tag a="1">Tom & Jerry's</tag>'));
+ it('normalizes invalid element names', () => {
+ expect(xmlName('123 full name', 0)).toBe('full_name');
+ expect(xmlName('***', 2)).toBe('field_3');
+ });
+});
diff --git a/src/lib/xml.ts b/src/lib/xml.ts
new file mode 100644
index 0000000..be4f5c2
--- /dev/null
+++ b/src/lib/xml.ts
@@ -0,0 +1,16 @@
+export const escapeXml = (value: unknown) => String(value ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+export const xmlName = (name: string, index = 0) => {
+ const cleaned = name.trim().replace(/^[^A-Za-z_]+/, '').replace(/[^A-Za-z0-9_.-]/g, '_');
+ return cleaned || `field_${index + 1}`;
+};
+export function formatXml(xml: string) {
+ const lines = xml.replace(/>\s*<').replace(/(>)(<)(\/?)/g, '$1\n$2$3').split('\n');
+ let depth = 0;
+ return lines.map(line => {
+ const trimmed = line.trim();
+ if (/^<\//.test(trimmed)) depth = Math.max(0, depth - 1);
+ const output = `${' '.repeat(depth)}${trimmed}`;
+ if (/^<[^!?/][^>]*[^/]?>/.test(trimmed) && !/<\/[^>]+>$/.test(trimmed) && !/\/>$/.test(trimmed)) depth++;
+ return output;
+ }).join('\n');
+}
diff --git a/src/types/tool.ts b/src/types/tool.ts
new file mode 100644
index 0000000..707c9a8
--- /dev/null
+++ b/src/types/tool.ts
@@ -0,0 +1,24 @@
+export type ToolCategory =
+ | 'Privacy & Security'
+ | 'PDF & Documents'
+ | 'Images'
+ | 'Text & Data'
+ | 'Developer'
+ | 'Accessibility'
+ | 'Converters'
+ | 'Generators';
+
+export interface Tool {
+ id: string;
+ name: string;
+ description: string;
+ slug: string;
+ category: ToolCategory;
+ tags: string[];
+ processing: 'local';
+ networkRequired: false;
+ storesData: false;
+ icon?: string;
+ featured?: boolean;
+ new?: boolean;
+}
diff --git a/tsconfig.json b/tsconfig.json
index c133409..19c51c8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -11,7 +11,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
- "jsx": "preserve",
+ "jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -22,6 +22,12 @@
"@/*": ["./src/*"]
}
},
- "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ ".next/types/**/*.ts",
+ ".next/dev/types/**/*.ts"
+ ],
"exclude": ["node_modules"]
}