From 1ff64c47fbaa4a6c29419bae3f159fb0b2dd3d28 Mon Sep 17 00:00:00 2001 From: Cu Thanh Cam Date: Sun, 5 Jul 2026 16:37:54 +0700 Subject: [PATCH 1/2] Enhance JSON YAML conversion service --- .../json-yaml/json-yaml.service.test.ts | 40 ++- src/features/json-yaml/json-yaml.service.ts | 338 ++++++++++++++++-- 2 files changed, 343 insertions(+), 35 deletions(-) diff --git a/src/features/json-yaml/json-yaml.service.test.ts b/src/features/json-yaml/json-yaml.service.test.ts index 26a45bc..b7729cf 100644 --- a/src/features/json-yaml/json-yaml.service.test.ts +++ b/src/features/json-yaml/json-yaml.service.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { hasJsonYamlInput, normalizeJsonYamlInput } from "./json-yaml.service"; +import { + convertJsonYaml, + hasJsonYamlInput, + normalizeJsonYamlInput, +} from "./json-yaml.service"; describe("json-yaml service", () => { it("normalizes user input", () => { @@ -9,4 +13,38 @@ describe("json-yaml service", () => { it("detects empty input", () => { expect(hasJsonYamlInput(" ")).toBe(false); }); + + it("converts nested JSON to YAML", () => { + expect( + convertJsonYaml( + '{"name":"Forge","tools":["json","yaml"],"meta":{"local":true,"version":1}}', + "json-to-yaml", + ).value, + ).toBe("name: Forge\ntools:\n - json\n - yaml\nmeta:\n local: true\n version: 1"); + }); + + it("converts nested YAML to JSON", () => { + expect( + convertJsonYaml( + "name: Forge\ntools:\n - json\n - yaml\nmeta:\n local: true\n version: 1", + "yaml-to-json", + ).value, + ).toBe( + '{\n "name": "Forge",\n "tools": [\n "json",\n "yaml"\n ],\n "meta": {\n "local": true,\n "version": 1\n }\n}', + ); + }); + + it("supports custom indentation", () => { + expect(convertJsonYaml('{"a":{"b":1}}', "json-to-yaml", 4).value).toBe( + "a:\n b: 1", + ); + expect(convertJsonYaml("a:\n b: 1", "yaml-to-json", 4).value).toContain(' "b"'); + }); + + it("returns parser position for invalid JSON", () => { + const result = convertJsonYaml('{\n "a": true,\n}', "json-to-yaml"); + + expect(result.error).toBeTruthy(); + expect(result.errorLine).toBe(3); + }); }); diff --git a/src/features/json-yaml/json-yaml.service.ts b/src/features/json-yaml/json-yaml.service.ts index 34415d7..0ea82df 100644 --- a/src/features/json-yaml/json-yaml.service.ts +++ b/src/features/json-yaml/json-yaml.service.ts @@ -3,12 +3,33 @@ export interface JsonYamlInput { } export type JsonYamlDirection = "json-to-yaml" | "yaml-to-json"; +export type JsonYamlIndent = number | "tab"; + +export interface JsonYamlStats { + arrays: number; + booleans: number; + keys: number; + nulls: number; + numbers: number; + objects: number; + strings: number; +} export interface JsonYamlResult { error?: string; + errorColumn?: number; + errorLine?: number; + parsed?: unknown; + stats?: JsonYamlStats; value: string; } +interface YamlLine { + indent: number; + number: number; + text: string; +} + export function normalizeJsonYamlInput(input: string): string { return input.trim(); } @@ -20,35 +41,56 @@ export function hasJsonYamlInput(input: string): boolean { export function convertJsonYaml( input: string, direction: JsonYamlDirection, + indent: JsonYamlIndent = 2, ): JsonYamlResult { const normalized = normalizeJsonYamlInput(input); if (!normalized) { - return { value: "" }; + return { stats: createEmptyStats(), value: "" }; } try { - if (direction === "json-to-yaml") { - return { value: stringifyYaml(JSON.parse(normalized)) }; - } + const parsed = + direction === "json-to-yaml" + ? (JSON.parse(normalized) as unknown) + : parseYaml(normalized); + const value = + direction === "json-to-yaml" + ? stringifyYaml(parsed, getIndentText(indent)) + : JSON.stringify(parsed, null, getJsonIndent(indent)); - return { value: JSON.stringify(parseSimpleYaml(normalized), null, 2) }; + return { + parsed, + stats: getJsonYamlStats(parsed), + value, + }; } catch (error) { + const message = error instanceof Error ? error.message : "Unable to convert input."; + return { - error: error instanceof Error ? error.message : "Unable to convert input.", + error: message, + ...(direction === "json-to-yaml" ? getJsonErrorPosition(input, message) : {}), value: "", }; } } -function stringifyYaml(value: unknown, depth = 0): string { - const indent = " ".repeat(depth); +export function getJsonYamlStats(value: unknown): JsonYamlStats { + const stats = createEmptyStats(); + + visitValue(value, stats); + + return stats; +} + +function stringifyYaml(value: unknown, indentText: string, depth = 0): string { + const indent = indentText.repeat(depth); if (Array.isArray(value)) { return value .map((item) => { if (isPlainObject(item) || Array.isArray(item)) { - return `${indent}-\n${stringifyYaml(item, depth + 1)}`; + return `${indent}-\n${stringifyYaml(item, indentText, depth + 1)}`; } return `${indent}- ${formatYamlScalar(item)}`; @@ -59,11 +101,13 @@ function stringifyYaml(value: unknown, depth = 0): string { if (isPlainObject(value)) { return Object.entries(value) .map(([key, item]) => { + const safeKey = formatYamlKey(key); + if (isPlainObject(item) || Array.isArray(item)) { - return `${indent}${key}:\n${stringifyYaml(item, depth + 1)}`; + return `${indent}${safeKey}:\n${stringifyYaml(item, indentText, depth + 1)}`; } - return `${indent}${key}: ${formatYamlScalar(item)}`; + return `${indent}${safeKey}: ${formatYamlScalar(item)}`; }) .join("\n"); } @@ -71,36 +115,165 @@ function stringifyYaml(value: unknown, depth = 0): string { return `${indent}${formatYamlScalar(value)}`; } -function parseSimpleYaml(input: string): unknown { - const lines = input - .split(/\r\n|\n|\r/) - .map((line) => line.replace(/\s+#.*$/, "")) - .filter((line) => line.trim().length > 0); +function parseYaml(input: string): unknown { + const lines = prepareYamlLines(input); - if (lines.every((line) => line.trim().startsWith("- "))) { - return lines.map((line) => parseYamlScalar(line.trim().slice(2))); + if (lines.length === 0) { + return {}; } + return parseYamlBlock(lines, 0, lines[0].indent).value; +} + +function parseYamlBlock( + lines: YamlLine[], + startIndex: number, + indent: number, +): { nextIndex: number; value: unknown } { + const isArray = + lines[startIndex]?.indent === indent && lines[startIndex].text.startsWith("- "); + + return isArray + ? parseYamlArray(lines, startIndex, indent) + : parseYamlObject(lines, startIndex, indent); +} + +function parseYamlArray( + lines: YamlLine[], + startIndex: number, + indent: number, +): { nextIndex: number; value: unknown[] } { + const output: unknown[] = []; + let index = startIndex; + + while (index < lines.length) { + const line = lines[index]; + + if (line.indent < indent || line.indent !== indent || !line.text.startsWith("- ")) { + break; + } + + const rawValue = line.text.slice(2).trim(); + const nextLine = lines[index + 1]; + + if (!rawValue) { + if (!nextLine || nextLine.indent <= indent) { + output.push(null); + index += 1; + } else { + const nested = parseYamlBlock(lines, index + 1, nextLine.indent); + output.push(nested.value); + index = nested.nextIndex; + } + } else if (rawValue.includes(":") && !isQuoted(rawValue)) { + output.push(parseInlineObject(rawValue, line.number)); + index += 1; + } else { + output.push(parseYamlScalar(rawValue)); + index += 1; + } + } + + return { nextIndex: index, value: output }; +} + +function parseYamlObject( + lines: YamlLine[], + startIndex: number, + indent: number, +): { nextIndex: number; value: Record } { const output: Record = {}; + let index = startIndex; - for (const line of lines) { - if (/^\s/.test(line)) { - throw new Error("Nested YAML parsing is limited. Convert nested data from JSON."); + while (index < lines.length) { + const line = lines[index]; + + if (line.indent < indent || line.indent !== indent || line.text.startsWith("- ")) { + break; } - const separatorIndex = line.indexOf(":"); + const separatorIndex = findYamlSeparator(line.text); if (separatorIndex === -1) { - throw new Error(`Invalid YAML line: ${line}`); + throw new Error(`Invalid YAML at line ${line.number}: ${line.text}`); + } + + const key = parseYamlKey(line.text.slice(0, separatorIndex).trim()); + const rawValue = line.text.slice(separatorIndex + 1).trim(); + const nextLine = lines[index + 1]; + + if (!rawValue) { + if (!nextLine || nextLine.indent <= line.indent) { + output[key] = null; + index += 1; + } else { + const nested = parseYamlBlock(lines, index + 1, nextLine.indent); + output[key] = nested.value; + index = nested.nextIndex; + } + } else { + output[key] = parseYamlScalar(rawValue); + index += 1; + } + } + + return { nextIndex: index, value: output }; +} + +function prepareYamlLines(input: string): YamlLine[] { + return input + .split(/\r\n|\n|\r/) + .map((line, index) => ({ + indent: line.match(/^\s*/)?.[0].replace(/\t/g, " ").length ?? 0, + number: index + 1, + text: stripYamlComment(line).trimEnd(), + })) + .filter((line) => line.text.trim().length > 0) + .map((line) => ({ ...line, text: line.text.trimStart() })); +} + +function stripYamlComment(line: string): string { + let quote: '"' | "'" | null = null; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + + if ((char === '"' || char === "'") && line[index - 1] !== "\\") { + quote = quote === char ? null : (quote ?? char); } - const key = line.slice(0, separatorIndex).trim(); - const value = line.slice(separatorIndex + 1).trim(); + if (char === "#" && !quote && (index === 0 || /\s/.test(line[index - 1]))) { + return line.slice(0, index); + } + } + + return line; +} + +function parseInlineObject(input: string, lineNumber: number): Record { + const separatorIndex = findYamlSeparator(input); + + if (separatorIndex === -1) { + throw new Error(`Invalid YAML at line ${lineNumber}: ${input}`); + } + + return { + [parseYamlKey(input.slice(0, separatorIndex).trim())]: parseYamlScalar( + input.slice(separatorIndex + 1).trim(), + ), + }; +} - output[key] = parseYamlScalar(value); +function formatYamlKey(key: string): string { + return /^[A-Za-z0-9_-]+$/.test(key) ? key : JSON.stringify(key); +} + +function parseYamlKey(key: string): string { + if (isQuoted(key)) { + return String(parseYamlScalar(key)); } - return output; + return key; } function formatYamlScalar(value: unknown): string { @@ -114,11 +287,13 @@ function formatYamlScalar(value: unknown): string { const text = typeof value === "string" ? value : JSON.stringify(value); - return /^[A-Za-z0-9_./ -]+$/.test(text) ? text : JSON.stringify(text); + return /^[A-Za-z0-9_./ -]+$/.test(text) && text.trim() === text + ? text + : JSON.stringify(text); } function parseYamlScalar(value: string): unknown { - if (value === "null" || value === "~") { + if (value === "" || value === "null" || value === "~") { return null; } @@ -130,18 +305,113 @@ function parseYamlScalar(value: string): unknown { return false; } - if (/^-?\d+(\.\d+)?$/.test(value)) { + if (/^-?\d+(?:\.\d+)?$/.test(value)) { return Number(value); } - if ( + if (isQuoted(value)) { + return value.startsWith('"') ? (JSON.parse(value) as string) : value.slice(1, -1); + } + + return value; +} + +function isQuoted(value: string): boolean { + return ( (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'")) - ) { - return value.slice(1, -1); + ); +} + +function findYamlSeparator(line: string): number { + let quote: '"' | "'" | null = null; + + for (let index = 0; index < line.length; index += 1) { + const char = line[index]; + + if ((char === '"' || char === "'") && line[index - 1] !== "\\") { + quote = quote === char ? null : (quote ?? char); + } + + if (char === ":" && !quote) { + return index; + } } - return value; + return -1; +} + +function getIndentText(indent: JsonYamlIndent): string { + return indent === "tab" ? "\t" : " ".repeat(indent); +} + +function getJsonIndent(indent: JsonYamlIndent): number | "\t" { + return indent === "tab" ? "\t" : indent; +} + +function getJsonErrorPosition( + input: string, + message: string, +): Pick { + const positionMatch = message.match(/position\s+(\d+)/i); + + if (!positionMatch) { + return {}; + } + + const position = Number(positionMatch[1]); + const beforeError = input.slice(0, Math.max(0, position)); + const lines = beforeError.split(/\r\n|\n|\r/); + + return { + errorColumn: lines[lines.length - 1].length + 1, + errorLine: lines.length, + }; +} + +function createEmptyStats(): JsonYamlStats { + return { + arrays: 0, + booleans: 0, + keys: 0, + nulls: 0, + numbers: 0, + objects: 0, + strings: 0, + }; +} + +function visitValue(value: unknown, stats: JsonYamlStats): void { + if (Array.isArray(value)) { + stats.arrays += 1; + value.forEach((item) => visitValue(item, stats)); + + return; + } + + if (value === null) { + stats.nulls += 1; + + return; + } + + if (typeof value === "object") { + stats.objects += 1; + Object.entries(value).forEach(([, item]) => { + stats.keys += 1; + visitValue(item, stats); + }); + + return; + } + + if (typeof value === "string") { + stats.strings += 1; + } else if (typeof value === "number") { + stats.numbers += 1; + } else if (typeof value === "boolean") { + stats.booleans += 1; + } } function isPlainObject(value: unknown): value is Record { From f11a61b85ce5b7c9df34052ca4c68278cdd28e5f Mon Sep 17 00:00:00 2001 From: Cu Thanh Cam Date: Sun, 5 Jul 2026 16:37:58 +0700 Subject: [PATCH 2/2] Polish JSON YAML converter workspace --- src/features/json-yaml/JsonYamlPage.tsx | 792 ++++++++++++++++++++++-- 1 file changed, 746 insertions(+), 46 deletions(-) diff --git a/src/features/json-yaml/JsonYamlPage.tsx b/src/features/json-yaml/JsonYamlPage.tsx index a057429..c574a1e 100644 --- a/src/features/json-yaml/JsonYamlPage.tsx +++ b/src/features/json-yaml/JsonYamlPage.tsx @@ -1,62 +1,172 @@ -import type { JSX } from "react"; +import type { ChangeEvent, JSX, KeyboardEvent, ReactNode, UIEvent } from "react"; import { useMemo, useState } from "react"; -import { ArrowLeftRight, Copy, RotateCcw } from "lucide-react"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { + ArrowLeftRight, + Check, + CheckCircle2, + ChevronDown, + ChevronRight, + Code2, + Copy, + Download, + FileCode2, + FileJson2, + ListTree, + RotateCcw, + TriangleAlert, + WrapText, +} from "lucide-react"; +import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; import { Tooltip } from "@/shared/ui/tooltip"; +import { PaneHeader, ToolSurface, ToolToolbar } from "@/shared/components/ToolSurface"; import { - PaneHeader, - ToolOutput, - ToolSurface, - ToolTextarea, - ToolToolbar, - ToolTitle, -} from "@/shared/components/ToolSurface"; -import { convertJsonYaml, type JsonYamlDirection } from "./json-yaml.service"; + convertJsonYaml, + type JsonYamlDirection, + type JsonYamlIndent, + type JsonYamlResult, + type JsonYamlStats, +} from "./json-yaml.service"; + +type ConverterView = "text" | "tree"; +type SyntaxKind = "json" | "yaml"; + +const initialJson = `{ + "name": "Forge", + "local": true, + "tools": ["json", "yaml", "diff"], + "release": { + "channel": "developer", + "features": { + "convert": true, + "treeView": true + } + } +}`; export function JsonYamlPage(): JSX.Element { const [direction, setDirection] = useState("json-to-yaml"); - const [input, setInput] = useState( - '{"name":"Forge","category":"Developer tools","local":true}', + const [input, setInput] = useState(initialJson); + const [indent, setIndent] = useState(2); + const [view, setView] = useState("text"); + const [resultLineWrap, setResultLineWrap] = useState(true); + const result = useMemo( + () => convertJsonYaml(input, direction, indent), + [direction, indent, input], ); - const result = useMemo(() => convertJsonYaml(input, direction), [direction, input]); + const outputLineCount = result.value ? result.value.split(/\r\n|\n|\r/).length : 0; + const inputSyntax = direction === "json-to-yaml" ? "json" : "yaml"; + const outputSyntax = direction === "json-to-yaml" ? "yaml" : "json"; function swapDirection(): void { setDirection((value) => (value === "json-to-yaml" ? "yaml-to-json" : "json-to-yaml")); - setInput(result.value); + + if (result.value) { + setInput(result.value); + } } async function copyOutput(): Promise { await navigator.clipboard.writeText(result.value); } + function downloadOutput(): void { + const blob = new Blob([result.value], { + type: + direction === "json-to-yaml" + ? "application/yaml;charset=utf-8" + : "application/json;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + + anchor.href = url; + anchor.download = + direction === "json-to-yaml" ? "forge-data.yaml" : "forge-data.json"; + anchor.click(); + URL.revokeObjectURL(url); + } + return ( - - - +
+