diff --git a/README.md b/README.md index 2784594..577106e 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Here is the list of all utilities: - [Internet Speed Test](https://jam.dev/utilities/internet-speed-test) - [Random String Generator](https://jam.dev/utilities/random-string-generator) - [CSV file viewer](https://jam.dev/utilities/csv-file-viewer) +- [JSONL Validator](https://jam.dev/utilities/jsonl-validator) ### Built With diff --git a/components/seo/JsonlValidatorSEO.tsx b/components/seo/JsonlValidatorSEO.tsx new file mode 100644 index 0000000..6fe6b63 --- /dev/null +++ b/components/seo/JsonlValidatorSEO.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; + +export default function JsonlValidatorSEO() { + return ( +
+
+

+ JSONL (JSON Lines) is a backbone format for AI pipelines, + observability exports, and event-driven backends. Use this validator + to catch bad rows fast, jump to exact error lines, and export clean + records as a JSON array. +

+
+ +
+

How to use this JSONL validator

+ +
+ +
+

Built for modern developer workflows

+ +
+ +
+

Why this validator is useful

+ +
+ +
+

Related tools

+ +
+
+ ); +} diff --git a/components/utils/jsonl-validator.utils.test.ts b/components/utils/jsonl-validator.utils.test.ts new file mode 100644 index 0000000..24890f9 --- /dev/null +++ b/components/utils/jsonl-validator.utils.test.ts @@ -0,0 +1,77 @@ +import { parseJsonLines, toJsonArrayString } from "./jsonl-validator.utils"; + +describe("jsonl-validator.utils", () => { + describe("parseJsonLines", () => { + it("returns an empty result for empty input", () => { + expect(parseJsonLines("")).toEqual({ + totalLines: 0, + emptyLines: 0, + validLines: 0, + invalidLines: 0, + records: [], + errors: [], + keyFrequency: {}, + }); + }); + + it("parses valid JSONL lines and computes key frequency", () => { + const input = [ + '{"id":1,"level":"info","message":"ok"}', + '{"id":2,"level":"warn"}', + '{"id":3,"level":"error","code":"E_TIMEOUT"}', + ].join("\n"); + + const result = parseJsonLines(input); + + expect(result.totalLines).toBe(3); + expect(result.emptyLines).toBe(0); + expect(result.validLines).toBe(3); + expect(result.invalidLines).toBe(0); + expect(result.records).toHaveLength(3); + expect(result.keyFrequency).toEqual({ + id: 3, + level: 3, + message: 1, + code: 1, + }); + }); + + it("ignores empty lines and reports invalid lines with line numbers", () => { + const input = ['{"ok": true}', "", "{", "not-json", '{"ok": false}'].join( + "\n" + ); + + const result = parseJsonLines(input); + + expect(result.totalLines).toBe(4); + expect(result.emptyLines).toBe(1); + expect(result.validLines).toBe(2); + expect(result.invalidLines).toBe(2); + expect(result.errors[0].lineNumber).toBe(3); + expect(result.errors[0].lineContent).toBe("{"); + expect(result.errors[0].columnNumber).toBeGreaterThan(0); + expect(result.errors[1].lineNumber).toBe(4); + expect(result.errors[1].lineContent).toBe("not-json"); + expect(result.errors[1].columnNumber).toBeUndefined(); + }); + + it("accepts non-object JSON values as valid records", () => { + const input = ['"text"', "42", "true", "null", "[1,2,3]"].join("\n"); + + const result = parseJsonLines(input); + + expect(result.validLines).toBe(5); + expect(result.invalidLines).toBe(0); + expect(result.keyFrequency).toEqual({}); + }); + }); + + describe("toJsonArrayString", () => { + it("formats records as pretty-printed JSON array", () => { + const output = toJsonArrayString([{ id: 1 }, { id: 2 }]); + expect(output).toBe( + '[\n {\n "id": 1\n },\n {\n "id": 2\n }\n]' + ); + }); + }); +}); diff --git a/components/utils/jsonl-validator.utils.ts b/components/utils/jsonl-validator.utils.ts new file mode 100644 index 0000000..11c506f --- /dev/null +++ b/components/utils/jsonl-validator.utils.ts @@ -0,0 +1,102 @@ +export type JsonlParseError = { + lineNumber: number; + columnNumber?: number; + message: string; + lineContent: string; +}; + +export type JsonlValidationResult = { + totalLines: number; + emptyLines: number; + validLines: number; + invalidLines: number; + records: unknown[]; + errors: JsonlParseError[]; + keyFrequency: Record; +}; + +const isPlainObject = (value: unknown): value is Record => { + return typeof value === "object" && value !== null && !Array.isArray(value); +}; + +const extractColumnNumber = (message: string) => { + const columnMatch = message.match(/column\s+(\d+)/i); + if (columnMatch) { + const parsedColumn = Number(columnMatch[1]); + return Number.isNaN(parsedColumn) ? undefined : parsedColumn; + } + + const positionMatch = message.match(/position\s+(\d+)/i); + if (positionMatch) { + const parsedPosition = Number(positionMatch[1]); + if (!Number.isNaN(parsedPosition)) { + return parsedPosition + 1; + } + } + + return undefined; +}; + +export const parseJsonLines = (input: string): JsonlValidationResult => { + if (input.trim() === "") { + return { + totalLines: 0, + emptyLines: 0, + validLines: 0, + invalidLines: 0, + records: [], + errors: [], + keyFrequency: {}, + }; + } + + const lines = input.split(/\r?\n/); + const records: unknown[] = []; + const errors: JsonlParseError[] = []; + const keyFrequency: Record = {}; + let emptyLines = 0; + + lines.forEach((line, index) => { + const trimmedLine = line.trim(); + + if (trimmedLine === "") { + emptyLines += 1; + return; + } + + try { + const parsed = JSON.parse(trimmedLine); + records.push(parsed); + + if (isPlainObject(parsed)) { + Object.keys(parsed).forEach((key) => { + keyFrequency[key] = (keyFrequency[key] || 0) + 1; + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : "Invalid JSON"; + errors.push({ + lineNumber: index + 1, + columnNumber: extractColumnNumber(message), + message, + lineContent: line, + }); + } + }); + + const totalLines = lines.length - emptyLines; + + return { + totalLines, + emptyLines, + validLines: records.length, + invalidLines: errors.length, + records, + errors, + keyFrequency, + }; +}; + +export const toJsonArrayString = (records: unknown[]) => { + return JSON.stringify(records, null, 2); +}; diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index 41fbb43..f234072 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -17,6 +17,12 @@ export const tools = [ "Format and beautify your JSON data for better readability and debugging. Quickly visualize and organize your JSON data with ease.", link: "/utilities/json-formatter", }, + { + title: "JSONL Validator", + description: + "Validate JSON Lines instantly, find broken rows by line number, and convert valid records to a clean JSON array.", + link: "/utilities/jsonl-validator", + }, { title: "YAML to JSON", description: diff --git a/pages/utilities/jsonl-validator.tsx b/pages/utilities/jsonl-validator.tsx new file mode 100644 index 0000000..96a3a4c --- /dev/null +++ b/pages/utilities/jsonl-validator.tsx @@ -0,0 +1,386 @@ +import CallToActionGrid from "@/components/CallToActionGrid"; +import { CMDK } from "@/components/CMDK"; +import { Button } from "@/components/ds/ButtonComponent"; +import { Card } from "@/components/ds/CardComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import { Textarea } from "@/components/ds/TextareaComponent"; +import Header from "@/components/Header"; +import { useCopyToClipboard } from "@/components/hooks/useCopyToClipboard"; +import Meta from "@/components/Meta"; +import PageHeader from "@/components/PageHeader"; +import JsonlValidatorSEO from "@/components/seo/JsonlValidatorSEO"; +import type { JsonlParseError } from "@/components/utils/jsonl-validator.utils"; +import { + parseJsonLines, + toJsonArrayString, +} from "@/components/utils/jsonl-validator.utils"; +import { cn } from "@/lib/utils"; +import { + ChangeEvent, + UIEvent, + useCallback, + useMemo, + useRef, + useState, +} from "react"; + +const EXAMPLE_JSONL = [ + '{"timestamp":"2025-02-15T14:10:00Z","level":"info","event":"build_started"}', + '{"timestamp":"2025-02-15T14:11:12Z","level":"warn","event":"cache_miss","service":"api"}', + '{"timestamp":"2025-02-15T14:12:41Z","level":"error","event":"timeout","service":"search","duration_ms":3200}', +].join("\n"); +const LINE_HEIGHT_PX = 24; +const MAX_ISSUES_SHOWN = 20; + +export default function JsonlValidator() { + const [input, setInput] = useState(""); + const [activeIssueLine, setActiveIssueLine] = useState(null); + const { buttonText, handleCopy } = useCopyToClipboard(); + const textareaRef = useRef(null); + const gutterRef = useRef(null); + + const result = useMemo(() => parseJsonLines(input), [input]); + const hasErrors = result.invalidLines > 0; + const output = useMemo(() => { + if (result.validLines === 0) { + return ""; + } + + return toJsonArrayString(result.records); + }, [result.validLines, result.records]); + + const topKeys = useMemo(() => { + return Object.entries(result.keyFrequency) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + }, [result.keyFrequency]); + + const lineNumbers = useMemo(() => { + const lineCount = Math.max(1, input.split(/\r?\n/).length); + return Array.from({ length: lineCount }, (_, index) => index + 1); + }, [input]); + + const errorByLine = useMemo(() => { + const map = new Map(); + + result.errors.forEach((error) => { + if (!map.has(error.lineNumber)) { + map.set(error.lineNumber, error); + } + }); + + return map; + }, [result.errors]); + + const liveSummary = useMemo(() => { + if (input.trim() === "") { + return ""; + } + + if (hasErrors) { + return `${result.invalidLines} invalid rows found. Use the issues list to jump to exact lines.`; + } + + return `${result.validLines} valid rows. No invalid lines found.`; + }, [hasErrors, input, result.invalidLines, result.validLines]); + + const handleJumpToIssue = useCallback( + (lineNumber: number, columnNumber?: number) => { + const textarea = textareaRef.current; + if (!textarea) { + return; + } + + const lines = textarea.value.split("\n"); + if (lines.length === 0) { + return; + } + + const safeLine = Math.min(Math.max(1, lineNumber), lines.length); + const safeColumn = Math.max(1, columnNumber ?? 1); + + let caretOffset = 0; + for (let lineIndex = 0; lineIndex < safeLine - 1; lineIndex++) { + caretOffset += lines[lineIndex].length + 1; + } + + const lineContent = lines[safeLine - 1] ?? ""; + const clampedColumn = Math.min(safeColumn - 1, lineContent.length); + const caretPosition = caretOffset + clampedColumn; + + textarea.focus(); + textarea.setSelectionRange(caretPosition, caretPosition); + + const targetScrollTop = Math.max(0, (safeLine - 2) * LINE_HEIGHT_PX); + textarea.scrollTop = targetScrollTop; + if (gutterRef.current) { + gutterRef.current.scrollTop = targetScrollTop; + } + + setActiveIssueLine(safeLine); + }, + [] + ); + + const handleInputScroll = useCallback( + (event: UIEvent) => { + if (gutterRef.current) { + gutterRef.current.scrollTop = event.currentTarget.scrollTop; + } + }, + [] + ); + + const handleInputChange = useCallback( + (event: ChangeEvent) => { + setInput(event.currentTarget.value); + if (activeIssueLine !== null) { + setActiveIssueLine(null); + } + }, + [activeIssueLine] + ); + + const handleLoadExample = useCallback(() => { + setInput(EXAMPLE_JSONL); + setActiveIssueLine(null); + }, []); + + const handleClear = useCallback(() => { + setInput(""); + setActiveIssueLine(null); + if (textareaRef.current) { + textareaRef.current.scrollTop = 0; + } + if (gutterRef.current) { + gutterRef.current.scrollTop = 0; + } + }, []); + + return ( +
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+
+ +

+ {liveSummary} +

+ +
+
+ + +