diff --git a/README.md b/README.md index 572ed36..67b2708 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,7 @@ [![React](https://img.shields.io/badge/React-18-61DAFB?style=flat&logo=react)](https://react.dev) [![Material-UI](https://img.shields.io/badge/Material--UI-5-007FFF?style=flat&logo=mui)](https://mui.com) [![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) +[![Docker Pulls](https://img.shields.io/docker/pulls/polinux/web-cli?style=flat&logo=docker)](https://hub.docker.com/r/polinux/web-cli) A powerful web-based interface for executing commands on local and remote Linux servers. Built with Go and React with Material-UI for a professional, modern user experience. @@ -71,6 +72,13 @@ A powerful web-based interface for executing commands on local and remote Linux - **Script Presets**: Save execution configurations (server, SSH key, user, env vars) - **Environment Integration**: Inject stored environment variables into scripts +### Developer Tools +- **YAML Validator**: Validate, format, and auto-fix YAML documents with Monaco Editor +- **JSON Validator**: Validate, format, and auto-fix JSON data with Monaco Editor +- **Auto-Fix**: Automatic repair of common issues (tabs, indentation, trailing commas, etc.) +- **Diff View**: Side-by-side comparison showing exactly what was changed by auto-fix +- **Syntax Highlighting**: Full VS Code-style editing experience + ### Security - **Authentication**: HTTP Basic Auth and Bearer token support (configurable) - **TLS/HTTPS Support**: Native TLS support with optional HTTPS enforcement @@ -108,9 +116,12 @@ A powerful web-based interface for executing commands on local and remote Linux - **[React Router v6](https://reactrouter.com/)**: Client-side routing - **[Material-UI (MUI) v5](https://mui.com/)**: Professional component library - **[xterm.js](https://xtermjs.org/)**: Terminal emulator for the browser +- **[Monaco Editor](https://microsoft.github.io/monaco-editor/)**: VS Code editor for YAML/JSON tools - **[Vite](https://vitejs.dev/)**: Fast build tool and dev server - **[Emotion](https://emotion.sh/)**: CSS-in-JS styling - **[Swagger UI](https://swagger.io/tools/swagger-ui/)**: Interactive API documentation +- **[js-yaml](https://github.com/nodeca/js-yaml)**: YAML parsing and formatting +- **[jsonrepair](https://github.com/josdejong/jsonrepair)**: JSON auto-fix and repair ## 📸 Screenshots @@ -682,12 +693,48 @@ curl -H "Authorization: Bearer your-token" http://localhost:7777/api/health Web CLI is available as a Docker image for easy deployment. **Image Details:** -- **Registry:** Docker Hub (`polinux/web-cli`) +- **Registry:** Docker Hub ([`polinux/web-cli`](https://hub.docker.com/r/polinux/web-cli)) - **Base Image:** Debian Bookworm (slim) - provides full bash support for script execution - **Platforms:** `linux/amd64`, `linux/arm64` - **Size:** ~100MB compressed +- **Tags:** `latest`, `dev`, `main`, version tags (e.g., `v0.2.3`) + +**Quick Start - Standalone Docker Compose:** + +Create a `docker-compose.yml` file anywhere on your system: + +```yaml +services: + web-cli: + image: polinux/web-cli:latest + container_name: web-cli + restart: unless-stopped + ports: + - "7777:7777" + volumes: + - web-cli-data:/data + environment: + - AUTH_ENABLED=true + - AUTH_USERNAME=admin + - AUTH_PASSWORD=changeme123 + +volumes: + web-cli-data: +``` + +Then run: + +```bash +# Start the container +docker compose up -d + +# View logs +docker compose logs -f + +# Access at http://localhost:7777 +``` -**Quick Start with Docker Compose:** +**Quick Start with Repository:** ```bash # Clone the repository diff --git a/frontend/package-lock.json b/frontend/package-lock.json index cc44a89..5d75472 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,11 +10,14 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "ansi-to-react": "^6.1.6", + "js-yaml": "^4.1.1", + "jsonrepair": "^3.13.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0" @@ -1002,6 +1005,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@mui/core-downloads-tracker": { "version": "5.18.0", "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-5.18.0.tgz", @@ -1826,7 +1852,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-buffer-byte-length": { @@ -3845,7 +3870,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -3906,6 +3930,15 @@ "node": ">=6" } }, + "node_modules/jsonrepair": { + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/jsonrepair/-/jsonrepair-3.13.1.tgz", + "integrity": "sha512-WJeiE0jGfxYmtLwBTEk8+y/mYcaleyLXWaqp5bJu0/ZTSeG0KQq/wWQ8pmnkKenEdN6pdnn6QtcoSUkbqDHWNw==", + "license": "ISC", + "bin": { + "jsonrepair": "bin/cli.js" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -4919,6 +4952,12 @@ "node": ">=0.10.0" } }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, "node_modules/stop-iteration-iterator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index b205ebd..94cb544 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,11 +12,14 @@ "dependencies": { "@emotion/react": "^11.11.1", "@emotion/styled": "^11.11.0", + "@monaco-editor/react": "^4.7.0", "@mui/icons-material": "^5.15.0", "@mui/material": "^5.15.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "ansi-to-react": "^6.1.6", + "js-yaml": "^4.1.1", + "jsonrepair": "^3.13.1", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.21.0" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index cae4ab1..b42ce4d 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -13,6 +13,9 @@ import ScriptsPage from './components/ScriptsPage'; import LocalScripts from './components/LocalScripts'; import RemoteScripts from './components/RemoteScripts'; import Terminal from './components/Terminal'; +import ToolsPage from './components/ToolsPage'; +import YamlValidator from './components/YamlValidator'; +import JsonValidator from './components/JsonValidator'; /** * Main App component - handles theme management and renders the application with routing @@ -53,6 +56,9 @@ function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/components/Dashboard.jsx b/frontend/src/components/Dashboard.jsx index 5517e6b..14e973f 100644 --- a/frontend/src/components/Dashboard.jsx +++ b/frontend/src/components/Dashboard.jsx @@ -17,6 +17,7 @@ import { PlayArrow, CloudQueue, Computer, + Build, } from '@mui/icons-material'; import { useNavigate } from 'react-router-dom'; @@ -87,6 +88,14 @@ const Dashboard = () => { action: 'Open Terminal', onClick: () => navigate('/terminal'), }, + // Row 5: Developer Tools + { + icon: , + title: 'Tools', + description: 'Validate and format YAML, JSON, and other data formats', + action: 'Open Tools', + onClick: () => navigate('/tools'), + }, ]; return ( diff --git a/frontend/src/components/JsonValidator.jsx b/frontend/src/components/JsonValidator.jsx new file mode 100644 index 0000000..6a3737e --- /dev/null +++ b/frontend/src/components/JsonValidator.jsx @@ -0,0 +1,426 @@ +import React, { useState, useRef } from 'react'; +import Editor, { DiffEditor } from '@monaco-editor/react'; +import { jsonrepair } from 'jsonrepair'; +import { + Paper, + Grid, + Button, + Typography, + Box, + Snackbar, + Alert, + Collapse, + IconButton, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + CheckCircle, + FormatAlignLeft, + AutoFixHigh, + ContentCopy, + Clear, + CompareArrows, + Close, +} from '@mui/icons-material'; +import ToolLayout from './shared/ToolLayout'; +import ValidationResult from './shared/ValidationResult'; + +/** + * JsonValidator - JSON validation, formatting, and beautification tool + * Uses Monaco Editor for syntax highlighting and native JSON.parse for validation + */ +const JsonValidator = () => { + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const [errors, setErrors] = useState([]); + const [isValid, setIsValid] = useState(null); + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }); + const [showDiff, setShowDiff] = useState(false); + const [diffOriginal, setDiffOriginal] = useState(''); + const [diffModified, setDiffModified] = useState(''); + const editorRef = useRef(null); + + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + // Store editor reference for programmatic updates + const handleEditorDidMount = (editor) => { + editorRef.current = editor; + }; + + // Update editor value programmatically + const updateEditorValue = (newValue) => { + setInput(newValue); + if (editorRef.current) { + const model = editorRef.current.getModel(); + if (model) { + model.setValue(newValue); + } + } + }; + + // Helper to extract line/column from JSON parse error + const parseJsonError = (error, jsonString) => { + const message = error.message; + + // Try to extract position from error message + // Format: "... at position 123" or "Unexpected token ... at position 123" + const positionMatch = message.match(/position\s+(\d+)/i); + + if (positionMatch) { + const position = parseInt(positionMatch[1], 10); + const lines = jsonString.substring(0, position).split('\n'); + const line = lines.length; + const column = lines[lines.length - 1].length + 1; + + return { + line, + column, + message: message, + }; + } + + return { message }; + }; + + // Validate JSON syntax + const handleValidate = () => { + if (!input.trim()) { + setErrors([{ message: 'Please enter some JSON content to validate' }]); + setIsValid(false); + return; + } + + try { + JSON.parse(input); + setErrors([]); + setIsValid(true); + setOutput(input); + } catch (e) { + const errorInfo = parseJsonError(e, input); + setErrors([errorInfo]); + setIsValid(false); + setOutput(''); + } + }; + + // Format/beautify JSON with configurable indentation + const handleFormat = () => { + if (!input.trim()) { + showSnackbar('Please enter some JSON content first', 'warning'); + return; + } + + try { + const parsed = JSON.parse(input); + const formatted = JSON.stringify(parsed, null, 2); + setOutput(formatted); + setErrors([]); + setIsValid(true); + showSnackbar('JSON formatted successfully', 'success'); + } catch (e) { + const errorInfo = parseJsonError(e, input); + setErrors([errorInfo]); + setIsValid(false); + } + }; + + // Auto-fix JSON issues using jsonrepair library + // Handles: trailing commas, missing quotes, missing commas, missing brackets, + // comments, single quotes, unquoted keys, and structural issues + const handleAutoFix = () => { + if (!input.trim()) { + showSnackbar('Please enter some JSON content first', 'warning'); + return; + } + + try { + const originalInput = input; + + // Use jsonrepair to fix structural and syntax issues + const repaired = jsonrepair(input); + + // Parse and re-stringify for consistent formatting + const parsed = JSON.parse(repaired); + const formatted = JSON.stringify(parsed, null, 2); + + // Detect what was fixed + const fixes = []; + if (originalInput !== repaired) { + if (/,\s*[}\]]/.test(originalInput)) fixes.push('trailing commas'); + if (/\/\/|\/\*/.test(originalInput)) fixes.push('comments'); + if (/'/.test(originalInput) && !/'/.test(formatted)) fixes.push('quotes'); + if (/}\s*,\s*}/.test(originalInput)) fixes.push('structural issues'); + if ((originalInput.match(/{/g) || []).length !== (originalInput.match(/}/g) || []).length) { + fixes.push('mismatched braces'); + } + if ((originalInput.match(/\[/g) || []).length !== (originalInput.match(/]/g) || []).length) { + fixes.push('mismatched brackets'); + } + // Generic fix message if we can't detect specifics + if (fixes.length === 0) fixes.push('syntax errors'); + } + + updateEditorValue(formatted); + setOutput(formatted); + setErrors([]); + setIsValid(true); + + // Set diff state to show changes + if (originalInput !== formatted) { + setDiffOriginal(originalInput); + setDiffModified(formatted); + setShowDiff(true); + } + + if (fixes.length > 0) { + showSnackbar(`Auto-fixed: ${fixes.join(', ')}. View diff below.`, 'success'); + } else { + showSnackbar('JSON is already valid, formatted successfully', 'success'); + } + } catch (repairError) { + // jsonrepair couldn't fix it - show the error + const errorInfo = parseJsonError(repairError, input); + setErrors([errorInfo]); + setIsValid(false); + setShowDiff(false); + showSnackbar('Could not auto-fix: ' + repairError.message, 'error'); + } + }; + + // Minify JSON (compact format) + const handleMinify = () => { + if (!input.trim()) { + showSnackbar('Please enter some JSON content first', 'warning'); + return; + } + + try { + const parsed = JSON.parse(input); + const minified = JSON.stringify(parsed); + setOutput(minified); + setErrors([]); + setIsValid(true); + showSnackbar('JSON minified successfully', 'success'); + } catch (e) { + const errorInfo = parseJsonError(e, input); + setErrors([errorInfo]); + setIsValid(false); + } + }; + + // Copy output to clipboard + const handleCopy = async () => { + if (!output) { + showSnackbar('No output to copy', 'warning'); + return; + } + + try { + await navigator.clipboard.writeText(output); + showSnackbar('Copied to clipboard!', 'success'); + } catch (err) { + showSnackbar('Failed to copy to clipboard', 'error'); + } + }; + + // Clear all state + const handleClear = () => { + updateEditorValue(''); + setOutput(''); + setErrors([]); + setIsValid(null); + setShowDiff(false); + setDiffOriginal(''); + setDiffModified(''); + }; + + // Show snackbar notification + const showSnackbar = (message, severity = 'success') => { + setSnackbar({ open: true, message, severity }); + }; + + const handleCloseSnackbar = () => { + setSnackbar({ ...snackbar, open: false }); + }; + + // Monaco editor options + const editorOptions = { + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: 'on', + scrollBeyondLastLine: false, + wordWrap: 'on', + automaticLayout: true, + formatOnPaste: true, + }; + + return ( + + + + {/* Input Section */} + + + Input + + + setInput(value || '')} + onMount={handleEditorDidMount} + options={editorOptions} + /> + + + + + + + + + + + {/* Output Section */} + + + Output + + + + + + + {showDiff && ( + + )} + + + + + + + + + {/* Diff View - Shows changes made by Auto-Fix */} + + + + + Changes Made by Auto-Fix + + setShowDiff(false)} size="small" aria-label="Close diff view"> + + + + + Red = removed, Green = added. Left side shows original, right side shows fixed version. + + + + + + + + {/* Snackbar for notifications */} + + + {snackbar.message} + + + + ); +}; + +export default JsonValidator; diff --git a/frontend/src/components/ToolsPage.jsx b/frontend/src/components/ToolsPage.jsx new file mode 100644 index 0000000..f842f2c --- /dev/null +++ b/frontend/src/components/ToolsPage.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { + Container, + Grid, + Card, + CardContent, + Typography, + Button, + Box, +} from '@mui/material'; +import { ArrowBack, Code, DataObject } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; + +/** + * ToolsPage - Hub page displaying available developer tools as cards + * Follows the same pattern as Dashboard for consistency + */ +const ToolsPage = () => { + const navigate = useNavigate(); + + const tools = [ + { + icon: , + title: 'YAML Validator', + description: 'Validate, format, and auto-fix YAML documents with syntax highlighting', + action: 'Open YAML Tool', + onClick: () => navigate('/tools/yaml'), + }, + { + icon: , + title: 'JSON Validator', + description: 'Validate, format, and beautify JSON data with error detection', + action: 'Open JSON Tool', + onClick: () => navigate('/tools/json'), + }, + ]; + + return ( + + + + + + + + Developer Tools + + + Validate, format, and fix common data formats + + + + + {tools.map((tool, index) => ( + + + + + {tool.icon} + + + {tool.title} + + + {tool.description} + + + + + + ))} + + + ); +}; + +export default ToolsPage; diff --git a/frontend/src/components/YamlValidator.jsx b/frontend/src/components/YamlValidator.jsx new file mode 100644 index 0000000..7f67898 --- /dev/null +++ b/frontend/src/components/YamlValidator.jsx @@ -0,0 +1,507 @@ +import React, { useState, useRef } from 'react'; +import Editor, { DiffEditor } from '@monaco-editor/react'; +import yaml from 'js-yaml'; +import { + Paper, + Grid, + Button, + Typography, + Box, + Snackbar, + Alert, + Collapse, + IconButton, +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + CheckCircle, + FormatAlignLeft, + AutoFixHigh, + ContentCopy, + Clear, + CompareArrows, + Close, +} from '@mui/icons-material'; +import ToolLayout from './shared/ToolLayout'; +import ValidationResult from './shared/ValidationResult'; + +/** + * YamlValidator - YAML validation, formatting, and auto-fix tool + * Uses Monaco Editor for syntax highlighting and js-yaml for parsing + */ +const YamlValidator = () => { + const [input, setInput] = useState(''); + const [output, setOutput] = useState(''); + const [errors, setErrors] = useState([]); + const [isValid, setIsValid] = useState(null); + const [snackbar, setSnackbar] = useState({ open: false, message: '', severity: 'success' }); + const [showDiff, setShowDiff] = useState(false); + const [diffOriginal, setDiffOriginal] = useState(''); + const [diffModified, setDiffModified] = useState(''); + const editorRef = useRef(null); + + const theme = useTheme(); + const isDarkMode = theme.palette.mode === 'dark'; + + // Store editor reference for programmatic updates + const handleEditorDidMount = (editor) => { + editorRef.current = editor; + }; + + // Update editor value programmatically + const updateEditorValue = (newValue) => { + setInput(newValue); + if (editorRef.current) { + const model = editorRef.current.getModel(); + if (model) { + model.setValue(newValue); + } + } + }; + + // Validate YAML syntax + const handleValidate = () => { + if (!input.trim()) { + setErrors([{ message: 'Please enter some YAML content to validate' }]); + setIsValid(false); + return; + } + + try { + yaml.load(input, { schema: yaml.DEFAULT_SCHEMA }); + setErrors([]); + setIsValid(true); + setOutput(input); + } catch (e) { + if (e.mark) { + setErrors([{ + line: e.mark.line + 1, + column: e.mark.column + 1, + message: e.reason || e.message, + }]); + } else { + setErrors([{ message: e.message }]); + } + setIsValid(false); + setOutput(''); + } + }; + + // Format/beautify YAML + const handleFormat = () => { + if (!input.trim()) { + showSnackbar('Please enter some YAML content first', 'warning'); + return; + } + + try { + const parsed = yaml.load(input); + const formatted = yaml.dump(parsed, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + setOutput(formatted); + setErrors([]); + setIsValid(true); + showSnackbar('YAML formatted successfully', 'success'); + } catch (e) { + if (e.mark) { + setErrors([{ + line: e.mark.line + 1, + column: e.mark.column + 1, + message: e.reason || e.message, + }]); + } else { + setErrors([{ message: e.message }]); + } + setIsValid(false); + } + }; + + // Auto-fix common YAML issues + const handleAutoFix = () => { + if (!input.trim()) { + showSnackbar('Please enter some YAML content first', 'warning'); + return; + } + + const originalInput = input; + let fixed = input; + let fixes = []; + + // Remove BOM if present + if (fixed.charCodeAt(0) === 0xFEFF) { + fixed = fixed.slice(1); + fixes.push('BOM removed'); + } + + // Convert tabs to 2 spaces (YAML doesn't allow tabs for indentation) + if (fixed.includes('\t')) { + fixed = fixed.replace(/\t/g, ' '); + fixes.push('tabs converted to spaces'); + } + + // Remove trailing whitespace from each line + const beforeTrailing = fixed; + fixed = fixed.replace(/[ \t]+$/gm, ''); + if (beforeTrailing !== fixed) { + fixes.push('trailing whitespace removed'); + } + + // Remove multiple consecutive blank lines + const beforeBlankLines = fixed; + fixed = fixed.replace(/\n{3,}/g, '\n\n'); + if (beforeBlankLines !== fixed) { + fixes.push('extra blank lines removed'); + } + + // Quote values that contain ': ' (colon-space) which confuses the parser + // This is needed because js-yaml is strict about colons in unquoted values + const quoteProblematicValues = (text) => { + const lines = text.split('\n'); + const result = []; + + for (const line of lines) { + // Skip empty lines and comments + if (!line.trim() || line.trim().startsWith('#')) { + result.push(line); + continue; + } + + // Match key: value pattern + const match = line.match(/^(\s*)([^:]+):\s+(.+)$/); + if (match) { + const [, indent, key, value] = match; + // Check if value contains ': ' and is not already quoted + if (value.includes(': ') && !value.startsWith('"') && !value.startsWith("'")) { + // Quote the value with double quotes, escaping any existing quotes + const escapedValue = value.replace(/"/g, '\\"'); + result.push(`${indent}${key}: "${escapedValue}"`); + continue; + } + } + result.push(line); + } + + return result.join('\n'); + }; + + const beforeQuoting = fixed; + fixed = quoteProblematicValues(fixed); + if (beforeQuoting !== fixed) { + fixes.push('problematic values quoted'); + } + + // Normalize indentation to multiples of 2 spaces + // This fixes cases like 3-space indent which YAML doesn't accept + const normalizeIndentation = (text) => { + const lines = text.split('\n'); + const result = []; + + for (const line of lines) { + // Skip empty lines - preserve as-is + if (!line.trim()) { + result.push(line); + continue; + } + + // Get leading whitespace + const match = line.match(/^(\s*)/); + const indent = match ? match[1].length : 0; + const content = line.trimStart(); + + // Round indent to nearest multiple of 2 + // If indent is odd, round down (e.g., 3 -> 2, 5 -> 4) + const normalizedIndent = Math.floor(indent / 2) * 2; + + result.push(' '.repeat(normalizedIndent) + content); + } + + return result.join('\n'); + }; + + const beforeIndentNorm = fixed; + fixed = normalizeIndentation(fixed); + if (beforeIndentNorm !== fixed) { + fixes.push('indentation normalized'); + } + + // Ensure trailing newline + if (fixed.length > 0 && !fixed.endsWith('\n')) { + fixed += '\n'; + fixes.push('trailing newline added'); + } + + // Try to parse and re-format for consistent output + // This will also fix indentation issues by re-serializing + try { + const parsed = yaml.load(fixed); + const formatted = yaml.dump(parsed, { + indent: 2, + lineWidth: -1, + noRefs: true, + sortKeys: false, + }); + + fixed = formatted; + fixes.push('reformatted'); + + setErrors([]); + setIsValid(true); + setOutput(formatted); + + updateEditorValue(fixed); + + // Set diff state to show changes + if (originalInput !== fixed) { + setDiffOriginal(originalInput); + setDiffModified(fixed); + setShowDiff(true); + } + + if (fixes.length > 0) { + showSnackbar(`Auto-fixed: ${fixes.join(', ')}. View diff below.`, 'success'); + } else { + showSnackbar('No issues found to fix', 'info'); + } + } catch (e) { + // If parsing fails, show the error and the partially fixed content + if (e.mark) { + setErrors([{ + line: e.mark.line + 1, + column: e.mark.column + 1, + message: e.reason || e.message, + }]); + } else { + setErrors([{ message: e.message }]); + } + setIsValid(false); + setOutput(''); + + // Still update the editor with partial fixes (tabs, whitespace) + updateEditorValue(fixed); + + if (originalInput !== fixed) { + setDiffOriginal(originalInput); + setDiffModified(fixed); + setShowDiff(true); + } + + const partialFixes = fixes.filter(f => f !== 'reformatted'); + if (partialFixes.length > 0) { + showSnackbar(`Partial fix applied: ${partialFixes.join(', ')}. Manual fixes still needed.`, 'warning'); + } else { + showSnackbar('Could not auto-fix: ' + (e.reason || e.message), 'error'); + } + } + }; + + // Copy output to clipboard + const handleCopy = async () => { + if (!output) { + showSnackbar('No output to copy', 'warning'); + return; + } + + try { + await navigator.clipboard.writeText(output); + showSnackbar('Copied to clipboard!', 'success'); + } catch (err) { + showSnackbar('Failed to copy to clipboard', 'error'); + } + }; + + // Clear all state + const handleClear = () => { + updateEditorValue(''); + setOutput(''); + setErrors([]); + setIsValid(null); + setShowDiff(false); + setDiffOriginal(''); + setDiffModified(''); + }; + + // Show snackbar notification + const showSnackbar = (message, severity = 'success') => { + setSnackbar({ open: true, message, severity }); + }; + + const handleCloseSnackbar = () => { + setSnackbar({ ...snackbar, open: false }); + }; + + // Monaco editor options + const editorOptions = { + minimap: { enabled: false }, + fontSize: 14, + lineNumbers: 'on', + scrollBeyondLastLine: false, + wordWrap: 'on', + automaticLayout: true, + }; + + return ( + + + + {/* Input Section */} + + + Input + + + setInput(value || '')} + onMount={handleEditorDidMount} + options={editorOptions} + /> + + + + + + + + + + {/* Output Section */} + + + Output + + + + + + + {showDiff && ( + + )} + + + + + + + + + {/* Diff View - Shows changes made by Auto-Fix */} + + + + + Changes Made by Auto-Fix + + setShowDiff(false)} size="small" aria-label="Close diff view"> + + + + + Red = removed, Green = added. Left side shows original, right side shows fixed version. + + + + + + + + {/* Snackbar for notifications */} + + + {snackbar.message} + + + + ); +}; + +export default YamlValidator; diff --git a/frontend/src/components/shared/ToolLayout.jsx b/frontend/src/components/shared/ToolLayout.jsx new file mode 100644 index 0000000..d04c033 --- /dev/null +++ b/frontend/src/components/shared/ToolLayout.jsx @@ -0,0 +1,48 @@ +import React, { memo } from 'react'; +import PropTypes from 'prop-types'; +import { Container, Box, Typography, Button } from '@mui/material'; +import { ArrowBack } from '@mui/icons-material'; +import { useNavigate } from 'react-router-dom'; + +/** + * ToolLayout - Common page layout wrapper for tool pages + * Provides consistent styling with back button, title, and description + */ +const ToolLayout = memo(({ title, description, backPath = '/tools', children }) => { + const navigate = useNavigate(); + + return ( + + + + + {title} + + + {description} + + + + {children} + + + ); +}); + +ToolLayout.displayName = 'ToolLayout'; + +ToolLayout.propTypes = { + title: PropTypes.string.isRequired, + description: PropTypes.string, + backPath: PropTypes.string, + children: PropTypes.node.isRequired, +}; + +export default ToolLayout; diff --git a/frontend/src/components/shared/ValidationResult.jsx b/frontend/src/components/shared/ValidationResult.jsx new file mode 100644 index 0000000..36292a5 --- /dev/null +++ b/frontend/src/components/shared/ValidationResult.jsx @@ -0,0 +1,87 @@ +import React, { memo } from 'react'; +import PropTypes from 'prop-types'; +import { Alert, Typography, List, ListItem, ListItemIcon, ListItemText } from '@mui/material'; +import { CheckCircle, Error as ErrorIcon } from '@mui/icons-material'; + +/** + * ValidationResult - Displays validation success or error messages + * Shows line numbers and descriptions for each error + */ +const ValidationResult = memo(({ isValid, errors = [], successMessage = 'Valid!' }) => { + // Not validated yet + if (isValid === null) { + return null; + } + + // Valid state + if (isValid) { + return ( + } + sx={{ mb: 2 }} + > + {successMessage} + + ); + } + + // Error state + return ( + } + sx={{ mb: 2 }} + > + + Validation Failed + + {errors.length > 0 && ( + + {errors.map((error, index) => ( + + + + + + {error.line && error.column + ? `Line ${error.line}, Column ${error.column}: ` + : error.line + ? `Line ${error.line}: ` + : '' + } + {error.message} + + } + secondary={error.suggestion && ( + + Suggestion: {error.suggestion} + + )} + /> + + ))} + + )} + + ); +}); + +ValidationResult.displayName = 'ValidationResult'; + +ValidationResult.propTypes = { + isValid: PropTypes.bool, + errors: PropTypes.arrayOf( + PropTypes.shape({ + line: PropTypes.number, + column: PropTypes.number, + message: PropTypes.string.isRequired, + suggestion: PropTypes.string, + }) + ), + successMessage: PropTypes.string, +}; + +export default ValidationResult;