diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e43f57a..e43f477 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -32,6 +32,14 @@ jobs: - name: Install dependencies run: npm ci + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + + - name: Run Biome + run: biome ci . + - name: Build run: npm run build @@ -52,4 +60,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 \ No newline at end of file + uses: actions/deploy-pages@v4 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index dcc97be..699ed73 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,3 @@ { - "recommendations": [ - "biomejs.biome" - ] + "recommendations": ["biomejs.biome"] } diff --git a/biome.json b/biome.json index 363150d..603a6fd 100644 --- a/biome.json +++ b/biome.json @@ -19,7 +19,10 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "correctness": { + "noUndeclaredVariables": "error" + } }, "ignore": ["public/**/*"] }, diff --git a/package.json b/package.json index bebc334..b9900b9 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,7 @@ "build": "vite build", "preview": "vite preview" }, - "keywords": [ - "lando" - ], + "keywords": ["lando"], "author": "Aaron Feledy", "license": "GPL-3.0-or-later", "dependencies": { diff --git a/postcss.config.cjs b/postcss.config.cjs index 0cc9a9d..12a703d 100644 --- a/postcss.config.cjs +++ b/postcss.config.cjs @@ -3,4 +3,4 @@ module.exports = { tailwindcss: {}, autoprefixer: {}, }, -} \ No newline at end of file +}; diff --git a/src/App.jsx b/src/App.jsx index d392098..e378ed6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,8 +1,8 @@ -import React from 'react'; -import { Editor } from './components/ui/editor'; -import { ShareDialog } from './components/ui/share-dialog'; -import { useDialogStore } from './lib/dialog'; -import { Toaster } from './components/ui/toaster'; +import React from "react"; +import { Editor } from "./components/ui/editor"; +import { ShareDialog } from "./components/ui/share-dialog"; +import { useDialogStore } from "./lib/dialog"; +import { Toaster } from "./components/ui/toaster"; export default function App() { const { isShareDialogOpen, shareUrl, closeShareDialog } = useDialogStore(); diff --git a/src/components/ui/alert-dialog.jsx b/src/components/ui/alert-dialog.jsx index 33af045..3f99390 100644 --- a/src/components/ui/alert-dialog.jsx +++ b/src/components/ui/alert-dialog.jsx @@ -1,86 +1,200 @@ -import * as React from "react" -import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" +import * as React from "react"; +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; -import { cn } from "@/lib/utils" -import { buttonVariants } from "@/components/ui/button" +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; -const AlertDialog = AlertDialogPrimitive.Root +/** + * AlertDialog component. + * + * This component wraps the AlertDialogPrimitive.Root component from @radix-ui/react-alert-dialog. + * It is used to create a dialog that can be triggered to open or close. + */ +const AlertDialog = AlertDialogPrimitive.Root; -const AlertDialogTrigger = AlertDialogPrimitive.Trigger +/** + * AlertDialogTrigger component. + * + * This component wraps the AlertDialogPrimitive.Trigger component from @radix-ui/react-alert-dialog. + * It is used to trigger the opening of the AlertDialog. + */ +const AlertDialogTrigger = AlertDialogPrimitive.Trigger; -const AlertDialogPortal = AlertDialogPrimitive.Portal +/** + * AlertDialogPortal component. + * + * This component wraps the AlertDialogPrimitive.Portal component from @radix-ui/react-alert-dialog. + * It is used to portal the AlertDialogContent to the end of the document. + */ +const AlertDialogPortal = AlertDialogPrimitive.Portal; +/** + * AlertDialogOverlay component. + * + * This component wraps the AlertDialogPrimitive.Overlay component from @radix-ui/react-alert-dialog. + * It is used to create the overlay that covers the background when the AlertDialog is open. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The AlertDialogOverlay component. + */ const AlertDialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( -)) -AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + ref={ref} + /> +)); +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName; +/** + * AlertDialogContent component. + * + * This component wraps the AlertDialogPrimitive.Content component from @radix-ui/react-alert-dialog. + * It is used to create the content of the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The AlertDialogContent component. + */ const AlertDialogContent = React.forwardRef(({ className, ...props }, ref) => ( + {...props} + /> -)) -AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName +)); +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName; -const AlertDialogHeader = ({ - className, - ...props -}) => ( +/** + * AlertDialogHeader component. + * + * This component is used to create the header of the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @returns The AlertDialogHeader component. + */ +const AlertDialogHeader = ({ className, ...props }) => (
-) -AlertDialogHeader.displayName = "AlertDialogHeader" + className={cn( + "flex flex-col space-y-2 text-center sm:text-left", + className, + )} + {...props} + /> +); +AlertDialogHeader.displayName = "AlertDialogHeader"; -const AlertDialogFooter = ({ - className, - ...props -}) => ( +/** + * AlertDialogFooter component. + * + * This component is used to create the footer of the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @returns The AlertDialogFooter component. + */ +const AlertDialogFooter = ({ className, ...props }) => (
-) -AlertDialogFooter.displayName = "AlertDialogFooter" + className={cn( + "flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", + className, + )} + {...props} + /> +); +AlertDialogFooter.displayName = "AlertDialogFooter"; +/** + * AlertDialogTitle component. + * + * This component wraps the AlertDialogPrimitive.Title component from @radix-ui/react-alert-dialog. + * It is used to create the title of the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The AlertDialogTitle component. + */ const AlertDialogTitle = React.forwardRef(({ className, ...props }, ref) => ( - -)) -AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName - -const AlertDialogDescription = React.forwardRef(({ className, ...props }, ref) => ( - -)) + className={cn("text-lg font-semibold", className)} + {...props} + /> +)); +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName; + +/** + * AlertDialogDescription component. + * + * This component wraps the AlertDialogPrimitive.Description component from @radix-ui/react-alert-dialog. + * It is used to create the description of the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The AlertDialogDescription component. + */ +const AlertDialogDescription = React.forwardRef( + ({ className, ...props }, ref) => ( + + ), +); AlertDialogDescription.displayName = - AlertDialogPrimitive.Description.displayName + AlertDialogPrimitive.Description.displayName; +/** + * AlertDialogAction component. + * + * This component wraps the AlertDialogPrimitive.Action component from @radix-ui/react-alert-dialog. + * It is used to create an action button within the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The AlertDialogAction component. + */ const AlertDialogAction = React.forwardRef(({ className, ...props }, ref) => ( - -)) -AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +)); +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName; +/** + * AlertDialogCancel component. + * + * This component wraps the AlertDialogPrimitive.Cancel component from @radix-ui/react-alert-dialog. + * It is used to create a cancel button within the AlertDialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The AlertDialogCancel component. + */ const AlertDialogCancel = React.forwardRef(({ className, ...props }, ref) => ( -)) -AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + className={cn( + buttonVariants({ variant: "outline" }), + "mt-2 sm:mt-0", + className, + )} + {...props} + /> +)); +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName; export { AlertDialog, @@ -94,4 +208,4 @@ export { AlertDialogDescription, AlertDialogAction, AlertDialogCancel, -} +}; diff --git a/src/components/ui/badge.jsx b/src/components/ui/badge.jsx index deae51c..8b9de81 100644 --- a/src/components/ui/badge.jsx +++ b/src/components/ui/badge.jsx @@ -1,8 +1,16 @@ -import * as React from "react" +import * as React from "react"; import { cva } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; +/** + * badgeVariants function. + * + * This function generates a set of class names for the Badge component based on the variant. + * It uses the class-variance-authority library to define the base and variant classes. + * + * @returns {Object} - An object containing the class names for the Badge component. + */ const badgeVariants = cva( "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", { @@ -20,15 +28,24 @@ const badgeVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); -function Badge({ - className, - variant, - ...props -}) { - return (
); +/** + * Badge component. + * + * This component renders a badge with a variant based on the props passed. + * It uses the cn utility function from @/lib/utils to concatenate the base and variant class names. + * + * @param {Object} props - The props passed to the component. + * @param {String} props.className - Additional class names to be applied to the component. + * @param {String} props.variant - The variant of the badge to be rendered. + * @returns {React.ReactElement} - The Badge component. + */ +function Badge({ className, variant, ...props }) { + return ( +
+ ); } -export { Badge, badgeVariants } +export { Badge, badgeVariants }; diff --git a/src/components/ui/button.jsx b/src/components/ui/button.jsx index b4cbcfe..5d90d94 100644 --- a/src/components/ui/button.jsx +++ b/src/components/ui/button.jsx @@ -1,9 +1,17 @@ -import * as React from "react" -import { Slot } from "@radix-ui/react-slot" +import * as React from "react"; +import { Slot } from "@radix-ui/react-slot"; import { cva } from "class-variance-authority"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; +/** + * buttonVariants function. + * + * This function generates a set of class names for the Button component based on the variant and size. + * It uses the class-variance-authority library to define the base and variant classes. + * + * @returns {Object} - An object containing the class names for the Button component. + */ const buttonVariants = cva( "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", { @@ -30,18 +38,35 @@ const buttonVariants = cva( variant: "default", size: "default", }, - } -) + }, +); -const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => { - const Comp = asChild ? Slot : "button" - return ( - () - ); -}) -Button.displayName = "Button" +/** + * Button component. + * + * This component renders a button with a variant and size based on the props passed. + * It uses the cn utility function from @/lib/utils to concatenate the base and variant class names. + * + * @param {Object} props - The props passed to the component. + * @param {String} props.className - Additional class names to be applied to the component. + * @param {String} props.variant - The variant of the button to be rendered. + * @param {String} props.size - The size of the button to be rendered. + * @param {Boolean} props.asChild - Indicates if the component should be rendered as a child of another component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns {React.ReactElement} - The Button component. + */ +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button"; + return ( + + ); + }, +); +Button.displayName = "Button"; -export { Button, buttonVariants } +export { Button, buttonVariants }; diff --git a/src/components/ui/dialog.jsx b/src/components/ui/dialog.jsx index f530cde..7209b67 100644 --- a/src/components/ui/dialog.jsx +++ b/src/components/ui/dialog.jsx @@ -1,93 +1,174 @@ -import * as React from "react" -import * as DialogPrimitive from "@radix-ui/react-dialog" -import { X } from "lucide-react" +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const Dialog = DialogPrimitive.Root +/** + * Dialog component. + * + * This component wraps the DialogPrimitive.Root component from @radix-ui/react-dialog. + * It is used to create a dialog that can be triggered to open or close. + */ +const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger +/** + * DialogTrigger component. + * + * This component wraps the DialogPrimitive.Trigger component from @radix-ui/react-dialog. + * It is used to trigger the opening of the Dialog. + */ +const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal +/** + * DialogPortal component. + * + * This component wraps the DialogPrimitive.Portal component from @radix-ui/react-dialog. + * It is used to portal the DialogContent to the end of the document. + */ +const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close +/** + * DialogClose component. + * + * This component wraps the DialogPrimitive.Close component from @radix-ui/react-dialog. + * It is used to close the Dialog. + */ +const DialogClose = DialogPrimitive.Close; +/** + * DialogOverlay component. + * + * This component wraps the DialogPrimitive.Overlay component from @radix-ui/react-dialog. + * It is used to create the overlay that covers the background when the Dialog is open. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The DialogOverlay component. + */ const DialogOverlay = React.forwardRef(({ className, ...props }, ref) => ( -)) -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + {...props} + /> +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; -const DialogContent = React.forwardRef(({ className, children, ...props }, ref) => ( - - - - {children} - - - Close - - - -)) -DialogContent.displayName = DialogPrimitive.Content.displayName +/** + * DialogContent component. + * + * This component wraps the DialogPrimitive.Content component from @radix-ui/react-dialog. + * It is used to create the content of the Dialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The DialogContent component. + */ +const DialogContent = React.forwardRef( + ({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + + ), +); +DialogContent.displayName = DialogPrimitive.Content.displayName; -const DialogHeader = ({ - className, - ...props -}) => ( +/** + * DialogHeader component. + * + * This component is used to create the header of the Dialog. + * + * @param {Object} props - The props passed to the component. + * @returns The DialogHeader component. + */ +const DialogHeader = ({ className, ...props }) => (
-) -DialogHeader.displayName = "DialogHeader" + {...props} + /> +); +DialogHeader.displayName = "DialogHeader"; -const DialogFooter = ({ - className, - ...props -}) => ( +/** + * DialogFooter component. + * + * This component is used to create the footer of the Dialog. + * + * @param {Object} props - The props passed to the component. + * @returns The DialogFooter component. + */ +const DialogFooter = ({ className, ...props }) => (
-) -DialogFooter.displayName = "DialogFooter" + {...props} + /> +); +DialogFooter.displayName = "DialogFooter"; +/** + * DialogTitle component. + * + * This component wraps the DialogPrimitive.Title component from @radix-ui/react-dialog. + * It is used to create the title of the Dialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The DialogTitle component. + */ const DialogTitle = React.forwardRef(({ className, ...props }, ref) => ( -)) -DialogTitle.displayName = DialogPrimitive.Title.displayName + {...props} + /> +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; +/** + * DialogDescription component. + * + * This component wraps the DialogPrimitive.Description component from @radix-ui/react-dialog. + * It is used to create the description of the Dialog. + * + * @param {Object} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The DialogDescription component. + */ const DialogDescription = React.forwardRef(({ className, ...props }, ref) => ( -)) -DialogDescription.displayName = DialogPrimitive.Description.displayName + {...props} + /> +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; export { Dialog, @@ -100,4 +181,4 @@ export { DialogFooter, DialogTitle, DialogDescription, -} +}; diff --git a/src/components/ui/editor-menu.jsx b/src/components/ui/editor-menu.jsx index 841b092..fc6107f 100644 --- a/src/components/ui/editor-menu.jsx +++ b/src/components/ui/editor-menu.jsx @@ -1,10 +1,10 @@ -import React from 'react'; -import { MarkerSeverity } from 'monaco-editor/esm/vs/platform/markers/common/markers'; -import * as monaco from 'monaco-editor'; -import { formatYaml, formatDocument } from '../../lib/format-yaml'; -import { generateShareUrl } from '../../share'; -import { showShareDialog } from '../../lib/dialog'; -import { debug } from '../../debug'; +import React from "react"; +import * as monaco from "monaco-editor"; +import { MarkerSeverity } from "monaco-editor/esm/vs/platform/markers/common/markers"; +import { formatYaml, formatDocument } from "@/lib/format-yaml"; +import { generateShareUrl } from "@/lib/share"; +import { showShareDialog } from "@/lib/dialog"; +import { debug } from "@/lib/debug"; export function EditorMenu({ editor, toast, isOpen, onToggle }) { const handleFormat = () => { @@ -19,22 +19,23 @@ export function EditorMenu({ editor, toast, isOpen, onToggle }) { showShareDialog(shareUrl); onToggle(false); } catch (error) { - debug.error('Failed to share:', error); + debug.error("Failed to share:", error); toast({ - description: 'Failed to generate share URL', + description: "Failed to generate share URL", duration: 5000, - className: 'bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200', + className: + "bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200", }); } }; const handleSave = () => { const content = editor.getValue(); - const blob = new Blob([content], { type: 'text/yaml' }); + const blob = new Blob([content], { type: "text/yaml" }); const url = URL.createObjectURL(blob); - const a = document.createElement('a'); + const a = document.createElement("a"); a.href = url; - a.download = '_.lando.yml'; + a.download = "_.lando.yml"; document.body.appendChild(a); a.click(); document.body.removeChild(a); @@ -42,7 +43,8 @@ export function EditorMenu({ editor, toast, isOpen, onToggle }) { onToggle(false); toast({ - description: 'Remember to remove the underscore from "_.lando.yml" after downloading', + description: + 'Remember to remove the underscore from "_.lando.yml" after downloading', duration: 5000, }); }; @@ -52,11 +54,12 @@ export function EditorMenu({ editor, toast, isOpen, onToggle }) { if (!file) return; if (!file.name.match(/^\.lando(\..*)?\.yml$/i)) { - debug.warn('Invalid file type:', file.name); + debug.warn("Invalid file type:", file.name); toast({ - description: 'Only .lando.yml and .lando.*.yml files are supported', + description: "Only .lando.yml and .lando.*.yml files are supported", duration: 5000, - className: 'bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200', + className: + "bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200", }); return; } @@ -66,36 +69,56 @@ export function EditorMenu({ editor, toast, isOpen, onToggle }) { const formatted = formatYaml(content); if (formatted !== content) { toast({ - description: 'File was automatically formatted', + description: "File was automatically formatted", duration: 2000, }); } - editor.executeEdits('format', [{ - range: editor.getModel().getFullModelRange(), - text: formatted, - }]); - debug.log('File loaded successfully:', file.name); + editor.executeEdits("format", [ + { + range: editor.getModel().getFullModelRange(), + text: formatted, + }, + ]); + debug.log("File loaded successfully:", file.name); onToggle(false); } catch (error) { - debug.error('Error reading file:', error); - monaco.editor.setModelMarkers(editor.getModel(), 'yaml', [{ - severity: MarkerSeverity.Error, - message: `Error reading file: ${error.message}`, - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: 1, - }]); + debug.error("Error reading file:", error); + monaco.editor.setModelMarkers(editor.getModel(), "yaml", [ + { + severity: MarkerSeverity.Error, + message: `Error reading file: ${error.message}`, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, + }, + ]); } }; return ( -
+
diff --git a/src/components/ui/editor.jsx b/src/components/ui/editor.jsx index ba46794..1bf22ec 100644 --- a/src/components/ui/editor.jsx +++ b/src/components/ui/editor.jsx @@ -1,42 +1,98 @@ -import React, { useEffect, useRef, useState, useLayoutEffect } from 'react'; -import * as monaco from 'monaco-editor'; -import { TokenizationRegistry } from 'monaco-editor/esm/vs/editor/common/languages'; -import { MarkerSeverity } from 'monaco-editor/esm/vs/platform/markers/common/markers'; -import editorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; -import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; -import 'monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution'; -import { loadSchema, validateYaml, getHoverInfo } from '../../schema'; -import { debug } from '../../debug'; -import { formatYaml, setupYamlFormatting } from '../../lib/format-yaml'; -import { useToast } from "@/hooks/use-toast" -import { generateShareUrl, getSharedContent } from '../../share'; -import { showShareDialog } from '../../lib/dialog'; -import { registerCompletionProvider } from '../../completions'; -import { EditorMenu } from './editor-menu'; -import { saveEditorContent, loadEditorContent } from '../../lib/storage'; - +import React, { useEffect, useRef, useState } from "react"; +import * as monaco from "monaco-editor"; +import { MarkerSeverity } from "monaco-editor/esm/vs/platform/markers/common/markers"; +import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"; +import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"; +import "monaco-editor/esm/vs/basic-languages/yaml/yaml.contribution"; +import { + setupEditorFeatures, + updateDiagnostics, +} from "@/lib/schema-validation"; +import { debug } from "@/lib/debug"; +import { formatYaml } from "@/lib/format-yaml"; +import { useToast } from "@/hooks/use-toast"; +import { getSharedContent } from "@/lib/share"; +import { EditorMenu } from "./editor-menu"; +import { saveEditorContent } from "@/lib/storage"; +import { getEditorOptions } from "@/lib/editor-config"; + +/** + * @fileoverview + * Monaco Editor component for Lando configuration files. + * This component provides a full-featured YAML editor with Lando-specific functionality, + * including schema validation, autocompletion, and custom formatting. + * It handles file drag-and-drop, content sharing, and local storage persistence. + */ + +/** + * Configures Monaco editor web workers for JSON and general editor functionality + */ +const setupMonacoWorkers = () => { + debug.log("Setting up Monaco workers"); + self.MonacoEnvironment = { + getWorker(_, label) { + debug.log("Requesting worker for language:", label); + if (label === "json") { + return new jsonWorker(); + } + return new editorWorker(); + }, + }; +}; + +/** + * A Monaco-based YAML editor component specialized for Lando configuration files. + * Provides features including: + * - Syntax highlighting + * - Schema validation + * - Auto-completion + * - File drag & drop + * - Content sharing + * - Local storage persistence + * - Auto-formatting + * + * @returns {JSX.Element} The editor component + */ export function Editor() { + /** + * Reference to the DOM container element for the Monaco editor + * @type {React.RefObject} + */ const containerRef = useRef(null); + + /** + * Reference to the Monaco editor instance + * @type {React.RefObject} + */ const editorRef = useRef(null); + + /** + * Flag to prevent multiple editor initializations + * @type {React.RefObject} + */ const initializingRef = useRef(false); + + /** + * Toast notification hook for displaying user feedback + */ const { toast } = useToast(); + + /** + * State for controlling the editor menu visibility + * @type {[boolean, React.Dispatch>]} + */ const [isMenuOpen, setIsMenuOpen] = useState(false); - const [isEditorReady, setIsEditorReady] = useState(false); - // Setup Monaco workers - const setupMonacoWorkers = () => { - debug.log('Setting up Monaco workers'); - self.MonacoEnvironment = { - getWorker(_, label) { - debug.log('Requesting worker for language:', label); - if (label === 'json') { - return new jsonWorker(); - } - return new editorWorker(); - }, - }; - }; + /** + * State indicating whether the editor has completed initialization + * @type {[boolean, React.Dispatch>]} + */ + const [isEditorReady, setIsEditorReady] = useState(false); + /** + * Effect hook for initializing the Monaco editor + * Sets up the editor instance, workers, and features + */ useEffect(() => { // Wait for next frame to ensure container is ready requestAnimationFrame(() => { @@ -45,30 +101,33 @@ export function Editor() { initializingRef.current = true; try { - debug.log('Initializing editor...'); + debug.log("Initializing editor..."); if (!containerRef.current) { - throw new Error('Editor container not found'); + throw new Error("Editor container not found"); } setupMonacoWorkers(); - editorRef.current = monaco.editor.create(containerRef.current, getEditorOptions()); - await setupEditorFeatures(); + editorRef.current = monaco.editor.create( + containerRef.current, + getEditorOptions(), + ); + await setupEditorFeatures(editorRef.current, toast); setupEventListeners(); handleSharedContent(); setIsEditorReady(true); // Fade out loader setTimeout(() => { - const loader = document.getElementById('editor-loader'); + const loader = document.getElementById("editor-loader"); if (loader) { - loader.style.opacity = '0'; - loader.addEventListener('transitionend', () => loader.remove()); + loader.style.opacity = "0"; + loader.addEventListener("transitionend", () => loader.remove()); } }, 500); - debug.log('Editor initialized successfully'); + debug.log("Editor initialized successfully"); } catch (error) { - debug.error('Failed to initialize editor:', error); + debug.error("Failed to initialize editor:", error); throw error; } }; @@ -81,195 +140,63 @@ export function Editor() { editorRef.current.dispose(); } }; - }, []); + }, [toast]); + /** + * Effect hook for handling clicks outside the editor menu + * Closes the menu when clicking outside its boundaries + */ useEffect(() => { const handleClickOutside = (e) => { - if (isMenuOpen && !e.target.closest('.editor-drawer') && !e.target.closest('#editor-menu-button')) { + if ( + isMenuOpen && + !e.target.closest(".editor-drawer") && + !e.target.closest("#editor-menu-button") + ) { setIsMenuOpen(false); } }; - document.addEventListener('click', handleClickOutside); - return () => document.removeEventListener('click', handleClickOutside); + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); }, [isMenuOpen]); - const getEditorOptions = () => ({ - value: getDefaultContent(), - language: 'yaml', - theme: document.documentElement.classList.contains('dark') ? 'lando' : 'vs', - automaticLayout: true, - minimap: { enabled: false }, - scrollBeyondLastLine: false, - fontSize: 18, - lineNumbers: 'on', - renderWhitespace: 'selection', - tabSize: 2, - fixedOverflowWidgets: true, - quickSuggestions: true, - suggestOnTriggerCharacters: true, - wordBasedSuggestions: false, - parameterHints: { enabled: true }, - suggest: { - snippetsPreventQuickSuggestions: false, - showWords: false, - filterGraceful: false, - showSnippets: true, - showProperties: true, - localityBonus: true, - insertMode: 'insert', - insertHighlight: true, - selectionMode: 'always', - }, - acceptSuggestionOnEnter: 'on', - acceptSuggestionOnCommitCharacter: true, - snippetSuggestions: 'inline', - tabCompletion: 'on', - snippetOptions: { exitOnEnter: true }, - }); - - const setupEditorFeatures = async () => { - // Set up YAML tokenization - const existingTokensProvider = TokenizationRegistry.get('yaml'); - if (existingTokensProvider) { - const originalTokenize = existingTokensProvider.tokenize.bind(existingTokensProvider); - monaco.languages.setMonarchTokensProvider('yaml', { - ...existingTokensProvider, - tokenize: (line, state) => { - const tokens = originalTokenize(line, state); - if (tokens && tokens.tokens) { - tokens.tokens = tokens.tokens.map(token => { - if (token.scopes.includes('type.yaml')) { - return { ...token, scopes: ['key'] }; - } - return token; - }); - } - return tokens; - }, - }); - } - - // Register hover provider - monaco.languages.registerHoverProvider('yaml', { - provideHover: (model, position) => { - const content = model.getValue(); - return getHoverInfo(content, position); - } - }); - - // Load schema and set up validation - debug.log('Loading schema...'); - const schemaContent = await loadSchema(); - - if (!schemaContent) { - handleSchemaLoadFailure(); - } else { - debug.log('Schema loaded successfully'); - setupSchemaValidation(schemaContent); - } - - // Register YAML completion provider - if (schemaContent) { - registerCompletionProvider(schemaContent); - } - - // Add format action setup - setupYamlFormatting(editorRef.current, toast); - }; - - const handleSchemaLoadFailure = () => { - debug.warn('Schema failed to load - editor will continue without validation'); - monaco.editor.setModelMarkers(editorRef.current.getModel(), 'yaml', [{ - severity: MarkerSeverity.Warning, - message: 'Schema validation unavailable - schema failed to load', - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: 1, - }]); - }; - - const setupSchemaValidation = (schemaContent) => { - const updateDiagnostics = () => { - const content = editorRef.current.getValue(); - try { - debug.log('Validating YAML content...'); - const diagnostics = validateYaml(content, schemaContent); - debug.log('Validation results:', diagnostics); - monaco.editor.setModelMarkers(editorRef.current.getModel(), 'yaml', diagnostics); - } catch (e) { - debug.error('Validation error:', e); - } - }; - - // Update diagnostics on content change - editorRef.current.onDidChangeModelContent(() => { - debug.log('Content changed, updating diagnostics...'); - updateDiagnostics(); - }); - - // Initial validation - updateDiagnostics(); - }; - + /** + * Handles shared content from URL parameters + * Loads the content into the editor and shows a notification + */ const handleSharedContent = () => { const sharedContent = getSharedContent(); if (sharedContent) { try { editorRef.current.setValue(sharedContent); toast({ - description: 'Loaded shared Landofile', + description: "Loaded shared Landofile", duration: 2000, }); // Clear the URL parameter without reloading the page const url = new URL(window.location.href); - url.searchParams.delete('s'); - window.history.replaceState({}, '', url.toString()); + url.searchParams.delete("s"); + window.history.replaceState({}, "", url.toString()); } catch (error) { - debug.error('Failed to load shared content:', error); + debug.error("Failed to load shared content:", error); toast({ - description: 'Failed to load shared content', + description: "Failed to load shared content", duration: 5000, - className: 'bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200', + className: + "bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200", }); } } }; - const getDefaultContent = () => { - // First try to load shared content - const sharedContent = getSharedContent(); - if (sharedContent) { - return sharedContent; - } - - // Then try to load from local storage - const savedContent = loadEditorContent(); - if (savedContent) { - return savedContent; - } - - // Fall back to default template - return `name: my-lando-app -recipe: lamp -config: - php: '8.3' - webroot: . - database: mysql:8.0 - xdebug: false - -services: - node: - type: node:20 - build: - - npm install - command: vite --host 0.0.0.0 - port: 5173 - ssl: true -`; - }; - + /** + * Sets up event listeners for the editor including: + * - Paste formatting + * - Window resize handling + * - File drag and drop + * - Content change persistence + */ const setupEventListeners = () => { // Handle paste events editorRef.current.onDidPaste(() => { @@ -279,60 +206,65 @@ services: // Only format and show toast if content changed if (formatted !== content) { - editorRef.current.executeEdits('format', [{ - range: editorRef.current.getModel().getFullModelRange(), - text: formatted, - }]); + editorRef.current.executeEdits("format", [ + { + range: editorRef.current.getModel().getFullModelRange(), + text: formatted, + }, + ]); toast({ - description: 'Content was automatically formatted', + description: "Content was automatically formatted", duration: 2000, }); } } catch (error) { - debug.error('Failed to format pasted content:', error); + debug.error("Failed to format pasted content:", error); toast({ description: `Failed to format: ${error.message}`, duration: 5000, - className: 'bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200', + className: + "bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200", }); } }); // Handle window resizing - window.addEventListener('resize', () => { - debug.log('Window resized, updating editor layout...'); + window.addEventListener("resize", () => { + debug.log("Window resized, updating editor layout..."); editorRef.current.layout(); }); // Update drag and drop handling to target the main container const editorContainer = containerRef.current; if (editorContainer) { - editorContainer.addEventListener('dragover', (e) => { + editorContainer.addEventListener("dragover", (e) => { e.preventDefault(); - editorContainer.classList.add('drag-over'); + editorContainer.classList.add("drag-over"); }); - editorContainer.addEventListener('dragleave', () => { - editorContainer.classList.remove('drag-over'); + editorContainer.addEventListener("dragleave", () => { + editorContainer.classList.remove("drag-over"); }); - editorContainer.addEventListener('drop', async (e) => { + editorContainer.addEventListener("drop", async (e) => { e.preventDefault(); - editorContainer.classList.remove('drag-over'); + editorContainer.classList.remove("drag-over"); const file = e.dataTransfer.files[0]; if (!file) return; if (!file.name.match(/^\.lando(\..*)?\.yml$/i)) { - debug.warn('Invalid file type:', file.name); - monaco.editor.setModelMarkers(editorRef.current.getModel(), 'yaml', [{ - severity: MarkerSeverity.Error, - message: 'Only .lando.yml and .lando.*.yml files are supported', - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: 1, - }]); + debug.warn("Invalid file type:", file.name); + monaco.editor.setModelMarkers(editorRef.current.getModel(), "yaml", [ + { + severity: MarkerSeverity.Error, + message: "Only .lando.yml and .lando.*.yml files are supported", + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, + }, + ]); return; } @@ -341,37 +273,42 @@ services: const formatted = formatYaml(content); if (formatted !== content) { toast({ - description: 'File was automatically formatted', + description: "File was automatically formatted", duration: 2000, }); } - editorRef.current.executeEdits('format', [{ - range: editorRef.current.getModel().getFullModelRange(), - text: formatted, - }]); - debug.log('File loaded successfully:', file.name); + editorRef.current.executeEdits("format", [ + { + range: editorRef.current.getModel().getFullModelRange(), + text: formatted, + }, + ]); + debug.log("File loaded successfully:", file.name); } catch (error) { - debug.error('Error reading file:', error); + debug.error("Error reading file:", error); toast({ description: `Error reading file: ${error.message}`, duration: 5000, - className: 'bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200', + className: + "bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200", }); - monaco.editor.setModelMarkers(editorRef.current.getModel(), 'yaml', [{ - severity: MarkerSeverity.Error, - message: `Error reading file: ${error.message}`, - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: 1, - }]); + monaco.editor.setModelMarkers(editorRef.current.getModel(), "yaml", [ + { + severity: MarkerSeverity.Error, + message: `Error reading file: ${error.message}`, + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, + }, + ]); } }); } // Add content change listener to save to localStorage editorRef.current.onDidChangeModelContent(() => { - debug.log('Content changed, saving to localStorage...'); + debug.log("Content changed, saving to localStorage..."); const content = editorRef.current.getValue(); saveEditorContent(content); // Existing validation call @@ -384,15 +321,29 @@ services:
{editorRef.current && ( diff --git a/src/components/ui/input.jsx b/src/components/ui/input.jsx index 70831a4..c223acc 100644 --- a/src/components/ui/input.jsx +++ b/src/components/ui/input.jsx @@ -1,19 +1,31 @@ -import * as React from "react" - -import { cn } from "@/lib/utils" +import * as React from "react"; +import { cn } from "@/lib/utils"; +/** + * Input component. + * + * This component renders an input field with a default set of styles. + * It uses the cn utility function from @/lib/utils to concatenate the base and variant class names. + * + * @param {Object} props - The props passed to the component. + * @param {String} props.className - Additional class names to be applied to the component. + * @param {String} props.type - The type of the input field. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns {React.ReactElement} - The Input component. + */ const Input = React.forwardRef(({ className, type, ...props }, ref) => { return ( - () + {...props} + /> ); -}) -Input.displayName = "Input" +}); +Input.displayName = "Input"; -export { Input } +export { Input }; diff --git a/src/components/ui/share-dialog.jsx b/src/components/ui/share-dialog.jsx index 2f8652c..39103d2 100644 --- a/src/components/ui/share-dialog.jsx +++ b/src/components/ui/share-dialog.jsx @@ -1,11 +1,11 @@ -import React from 'react' +import React from "react"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, -} from "@/components/ui/dialog" +} from "@/components/ui/dialog"; export function ShareDialog({ isOpen, onClose, shareUrl }) { return ( @@ -25,10 +25,11 @@ export function ShareDialog({ isOpen, onClose, shareUrl }) { onClick={(e) => e.target.select()} />
- ) + ); } diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx index 2a7aaaf..b7bee25 100644 --- a/src/components/ui/toast.tsx +++ b/src/components/ui/toast.tsx @@ -1,14 +1,30 @@ -"use client" +"use client"; -import * as React from "react" -import * as ToastPrimitives from "@radix-ui/react-toast" -import { cva, type VariantProps } from "class-variance-authority" -import { X } from "lucide-react" +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { cva, type VariantProps } from "class-variance-authority"; +import { X } from "lucide-react"; -import { cn } from "@/lib/utils" +import { cn } from "@/lib/utils"; -const ToastProvider = ToastPrimitives.Provider +/** + * ToastProvider component. + * + * This component wraps the ToastPrimitives.Provider component from @radix-ui/react-toast. + * It is used to provide context to the toast components. + */ +const ToastProvider = ToastPrimitives.Provider; +/** + * ToastViewport component. + * + * This component wraps the ToastPrimitives.Viewport component from @radix-ui/react-toast. + * It is used to render the viewport for the toast notifications. + * + * @param {React.ComponentPropsWithoutRef} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The ToastViewport component. + */ const ToastViewport = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -17,13 +33,21 @@ const ToastViewport = React.forwardRef< ref={ref} className={cn( "fixed bottom-4 left-1/2 z-[100] flex max-h-screen w-full -translate-x-1/2 flex-col items-center justify-center p-4", - className + className, )} {...props} /> -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; +/** + * toastVariants function. + * + * This function generates a set of class names for the Toast component based on the variant. + * It uses the class-variance-authority library to define the base and variant classes. + * + * @returns {Object} - An object containing the class names for the Toast component. + */ const toastVariants = cva( `group pointer-events-auto relative flex w-auto min-w-[300px] max-w-[500px] items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 shadow-lg @@ -42,13 +66,24 @@ const toastVariants = cva( defaultVariants: { variant: "default", }, - } -) + }, +); +/** + * Toast component. + * + * This component wraps the ToastPrimitives.Root component from @radix-ui/react-toast. + * It is used to render a toast notification with a variant based on the props passed. + * It uses the cn utility function from @/lib/utils to concatenate the base and variant class names. + * + * @param {React.ComponentPropsWithoutRef & VariantProps} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The Toast component. + */ const Toast = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef & - VariantProps + VariantProps >(({ className, variant, ...props }, ref) => { return ( - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; +/** + * ToastAction component. + * + * This component wraps the ToastPrimitives.Action component from @radix-ui/react-toast. + * It is used to render an action button within the toast notification. + * + * @param {React.ComponentPropsWithoutRef} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The ToastAction component. + */ const ToastAction = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -68,13 +113,23 @@ const ToastAction = React.forwardRef< ref={ref} className={cn( "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive", - className + className, )} {...props} /> -)) -ToastAction.displayName = ToastPrimitives.Action.displayName +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; +/** + * ToastClose component. + * + * This component wraps the ToastPrimitives.Close component from @radix-ui/react-toast. + * It is used to render the close button within the toast notification. + * + * @param {React.ComponentPropsWithoutRef} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The ToastClose component. + */ const ToastClose = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -83,16 +138,26 @@ const ToastClose = React.forwardRef< ref={ref} className={cn( "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600", - className + className, )} toast-close="" {...props} > -)) -ToastClose.displayName = ToastPrimitives.Close.displayName +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; +/** + * ToastTitle component. + * + * This component wraps the ToastPrimitives.Title component from @radix-ui/react-toast. + * It is used to render the title of the toast notification. + * + * @param {React.ComponentPropsWithoutRef} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The ToastTitle component. + */ const ToastTitle = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -102,9 +167,19 @@ const ToastTitle = React.forwardRef< className={cn("text-md font-semibold", className)} {...props} /> -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; +/** + * ToastDescription component. + * + * This component wraps the ToastPrimitives.Description component from @radix-ui/react-toast. + * It is used to render the description of the toast notification. + * + * @param {React.ComponentPropsWithoutRef} props - The props passed to the component. + * @param {React.RefObject} ref - The ref object passed to the component. + * @returns The ToastDescription component. + */ const ToastDescription = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef @@ -114,12 +189,12 @@ const ToastDescription = React.forwardRef< className={cn("text-md opacity-90", className)} {...props} /> -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName +)); +ToastDescription.displayName = ToastPrimitives.Description.displayName; -type ToastProps = React.ComponentPropsWithoutRef +type ToastProps = React.ComponentPropsWithoutRef; -type ToastActionElement = React.ReactElement +type ToastActionElement = React.ReactElement; export { type ToastProps, @@ -131,4 +206,4 @@ export { ToastDescription, ToastClose, ToastAction, -} +}; diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 171beb4..cee93b0 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -1,6 +1,6 @@ -"use client" +"use client"; -import { useToast } from "@/hooks/use-toast" +import { useToast } from "@/hooks/use-toast"; import { Toast, ToastClose, @@ -8,28 +8,24 @@ import { ToastProvider, ToastTitle, ToastViewport, -} from "@/components/ui/toast" +} from "@/components/ui/toast"; export function Toaster() { - const { toasts } = useToast() + const { toasts } = useToast(); return ( - {toasts.map(function ({ id, title, description, action, ...props }) { - return ( - -
- {title && {title}} - {description && ( - {description} - )} -
- {action} - -
- ) - })} + {toasts.map(({ id, title, description, action, ...props }) => ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ))}
- ) + ); } diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts index 53e4d67..796fa7c 100644 --- a/src/hooks/use-toast.ts +++ b/src/hooks/use-toast.ts @@ -1,105 +1,170 @@ -import * as React from "react" - -import type { - ToastActionElement, - ToastProps, -} from "@/components/ui/toast" - -const TOAST_LIMIT = 3 -const TOAST_REMOVE_DELAY = 3000 - +import * as React from "react"; + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; + +/** + * The maximum number of toasts that can be displayed at once. + */ +const TOAST_LIMIT = 3; + +/** + * The delay in milliseconds before a toast is automatically removed. + */ +const TOAST_REMOVE_DELAY = 3000; + +/** + * Represents a toast with an additional `id` property. + * + * @property {string} id - A unique identifier for the toast. + * @property {React.ReactNode} [title] - The title of the toast. + * @property {React.ReactNode} [description] - The description of the toast. + * @property {ToastActionElement} [action] - The action element of the toast. + */ type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; + +/** + * Enum for toast action types. + * + * @enum {string} + * @property {string} ADD_TOAST - Action type for adding a toast. + * @property {string} UPDATE_TOAST - Action type for updating a toast. + * @property {string} DISMISS_TOAST - Action type for dismissing a toast. + * @property {string} REMOVE_TOAST - Action type for removing a toast. + */ const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", DISMISS_TOAST: "DISMISS_TOAST", REMOVE_TOAST: "REMOVE_TOAST", -} as const - -let count = 0 - +} as const; + +/** + * A counter for generating unique toast IDs. + */ +let count = 0; + +/** + * Generates a unique ID for a toast. + * + * @returns {string} A unique string identifier. + */ function genId() { - count = (count + 1) % Number.MAX_VALUE - return count.toString() + count = (count + 1) % Number.MAX_VALUE; + return count.toString(); } -type ActionType = typeof actionTypes - +/** + * Union type for toast actions. + * + * @property {ActionType["ADD_TOAST"]} ADD_TOAST - Action for adding a toast. + * @property {ActionType["UPDATE_TOAST"]} UPDATE_TOAST - Action for updating a toast. + * @property {ActionType["DISMISS_TOAST"]} DISMISS_TOAST - Action for dismissing a toast. + * @property {ActionType["REMOVE_TOAST"]} REMOVE_TOAST - Action for removing a toast. + */ +type ActionType = typeof actionTypes; + +/** + * Represents an action that can be dispatched to the toast reducer. + * + * @property {ActionType} type - The type of the action. + * @property {ToasterToast} [toast] - The toast to be added or updated. + * @property {string} [toastId] - The ID of the toast to be dismissed or removed. + */ type Action = | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; } | { - type: ActionType["UPDATE_TOAST"] - toast: Partial + type: ActionType["UPDATE_TOAST"]; + toast: Partial; } | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; } | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } - + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +/** + * Represents the state of the toast reducer. + * + * @property {ToasterToast[]} toasts - An array of toasts. + */ interface State { - toasts: ToasterToast[] + toasts: ToasterToast[]; } -const toastTimeouts = new Map>() +/** + * A map to keep track of timeouts for toast removal. + */ +const toastTimeouts = new Map>(); +/** + * Adds a toast to the removal queue. + * + * @param {string} toastId - The ID of the toast to be added to the removal queue. + */ const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { - clearTimeout(toastTimeouts.get(toastId)) - toastTimeouts.delete(toastId) + clearTimeout(toastTimeouts.get(toastId)); + toastTimeouts.delete(toastId); } const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) + toastTimeouts.delete(toastId); dispatch({ type: "REMOVE_TOAST", toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) - - toastTimeouts.set(toastId, timeout) -} - + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +/** + * The reducer function for managing toast state. + * + * @param {State} state - The current state of the toasts. + * @param {Action} action - The action to be applied to the state. + * @returns {State} The new state after applying the action. + */ export const reducer = (state: State, action: Action): State => { switch (action.type) { - case "ADD_TOAST": - const newToast = action.toast - addToRemoveQueue(newToast.id) + case "ADD_TOAST": { + const newToast = action.toast; + addToRemoveQueue(newToast.id); return { ...state, toasts: [newToast, ...state.toasts].slice(0, TOAST_LIMIT), - } + }; + } case "UPDATE_TOAST": return { ...state, toasts: state.toasts.map((t) => - t.id === action.toast.id ? { ...t, ...action.toast } : t + t.id === action.toast.id ? { ...t, ...action.toast } : t, ), - } + }; case "DISMISS_TOAST": { - const { toastId } = action + const { toastId } = action; if (toastId) { - addToRemoveQueue(toastId) + addToRemoveQueue(toastId); } else { - state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) + for (const toast of state.toasts) { + addToRemoveQueue(toast.id); + } } return { @@ -110,80 +175,105 @@ export const reducer = (state: State, action: Action): State => { ...t, open: false, } - : t + : t, ), - } + }; } case "REMOVE_TOAST": if (action.toastId === undefined) { toastTimeouts.forEach((timeout, id) => { - clearTimeout(timeout) - toastTimeouts.delete(id) - }) + clearTimeout(timeout); + toastTimeouts.delete(id); + }); return { ...state, toasts: [], - } + }; } return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId), - } + }; } -} - -const listeners: Array<(state: State) => void> = [] - -let memoryState: State = { toasts: [] } - +}; + +/** + * An array to keep track of listeners for state changes. + */ +const listeners: Array<(state: State) => void> = []; + +/** + * The initial state of the toasts. + */ +let memoryState: State = { toasts: [] }; + +/** + * Dispatches an action to the reducer and notifies listeners. + * + * @param {Action} action - The action to be dispatched. + */ function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) + memoryState = reducer(memoryState, action); + for (const listener of listeners) { + listener(memoryState); + } } -type Toast = Omit - +/** + * Represents a toast without an `id` property. + * + * @property {React.ReactNode} [title] - The title of the toast. + * @property {React.ReactNode} [description] - The description of the toast. + * @property {ToastActionElement} [action] - The action element of the toast. + */ +type Toast = Omit; + +/** + * Creates a new toast or updates an existing one. + * + * @param {Toast} props - The properties of the toast. + * @returns {{id: string, dismiss: () => void, update: (props: ToasterToast) => void}} - An object with the toast's ID, a function to dismiss the toast, and a function to update the toast. + */ function toast({ ...props }: Toast) { - const id = genId() - const currentToasts = memoryState.toasts + const id = genId(); + const currentToasts = memoryState.toasts; if (currentToasts.length > 0) { - const lastToast = document.querySelector('[data-state="open"]') + const lastToast = document.querySelector('[data-state="open"]'); if (lastToast) { - const peekToast = document.createElement('div') - peekToast.className = 'toast-peek border border-pink-500 bg-[var(--c-bg-lighter)] p-6 rounded-md shadow-lg' + const peekToast = document.createElement("div"); + peekToast.className = + "toast-peek border border-pink-500 bg-[var(--c-bg-lighter)] p-6 rounded-md shadow-lg"; - const content = document.createElement('div') - content.className = 'grid gap-2' + const content = document.createElement("div"); + content.className = "grid gap-2"; if (props.title) { - const title = document.createElement('div') - title.className = 'text-base font-semibold' - title.textContent = props.title as string - content.appendChild(title) + const title = document.createElement("div"); + title.className = "text-base font-semibold"; + title.textContent = props.title as string; + content.appendChild(title); } if (props.description) { - const desc = document.createElement('div') - desc.className = 'text-base opacity-90' - desc.textContent = props.description as string - content.appendChild(desc) + const desc = document.createElement("div"); + desc.className = "text-base opacity-90"; + desc.textContent = props.description as string; + content.appendChild(desc); } - peekToast.appendChild(content) - document.body.appendChild(peekToast) + peekToast.appendChild(content); + document.body.appendChild(peekToast); requestAnimationFrame(() => { - peekToast.classList.add('show') - }) + peekToast.classList.add("show"); + }); setTimeout(() => { - peekToast.remove() - }, TOAST_REMOVE_DELAY) + peekToast.remove(); + }, TOAST_REMOVE_DELAY); } } @@ -191,8 +281,8 @@ function toast({ ...props }: Toast) { dispatch({ type: "UPDATE_TOAST", toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); dispatch({ type: "ADD_TOAST", @@ -201,36 +291,53 @@ function toast({ ...props }: Toast) { id, open: true, onOpenChange: (open) => { - if (!open) dismiss() + if (!open) dismiss(); }, }, - }) + }); return { id: id, dismiss, update, - } + }; } -function useToast() { - const [state, setState] = React.useState(memoryState) +/** + * Interface for the return value of the useToast hook + */ +interface UseToastReturn extends State { + toast: (props: Toast) => { + id: string; + dismiss: () => void; + update: (props: ToasterToast) => void; + }; + dismiss: (toastId?: string) => void; +} + +/** + * Hook to use the toast functionality. + * + * @returns {UseToastReturn} Object containing toast state and control functions + */ +function useToast(): UseToastReturn { + const [state, setState] = React.useState(memoryState); React.useEffect(() => { - listeners.push(setState) + listeners.push(setState); return () => { - const index = listeners.indexOf(setState) + const index = listeners.indexOf(setState); if (index > -1) { - listeners.splice(index, 1) + listeners.splice(index, 1); } - } - }, [state]) + }; + }, []); return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } + }; } -export { useToast, toast } +export { useToast, toast }; diff --git a/src/index.css b/src/index.css index ee98b09..c1a2f2e 100644 --- a/src/index.css +++ b/src/index.css @@ -1,4 +1,4 @@ -@import './styles/monaco.css'; +@import "./styles/monaco.css"; @tailwind base; @tailwind components; diff --git a/src/completions.js b/src/lib/completions.js similarity index 61% rename from src/completions.js rename to src/lib/completions.js index ba7fcc1..f725367 100644 --- a/src/completions.js +++ b/src/lib/completions.js @@ -1,18 +1,18 @@ -import * as monaco from 'monaco-editor'; -import { debug } from './debug'; +import * as monaco from "monaco-editor"; +import { debug } from "./debug"; export function registerCompletionProvider(schema) { - return monaco.languages.registerCompletionItemProvider('yaml', { + return monaco.languages.registerCompletionItemProvider("yaml", { async provideCompletionItems(model, position) { try { if (!schema) { - debug.warn('No schema available for completions'); + debug.warn("No schema available for completions"); return { suggestions: [] }; } return getCompletionItems(model, position, schema); } catch (error) { - debug.error('Error in completion provider:', error); + debug.error("Error in completion provider:", error); return { suggestions: [] }; } }, @@ -23,7 +23,7 @@ function getCompletionItems(model, position, schema) { try { // Get the current path in the YAML document const path = findPathAtPosition(model.getValue(), position); - debug.log('Getting completions for path:', path); + debug.log("Getting completions for path:", path); // Get current line and word const lineContent = model.getLineContent(position.lineNumber); @@ -36,20 +36,20 @@ function getCompletionItems(model, position, schema) { }; // At root level - if (!lineContent.startsWith(' ')) { + if (!lineContent.startsWith(" ")) { return getRootCompletions(schema, range); } // Get the schema section for the current path const currentSchema = getSchemaAtPath(schema, path); if (!currentSchema) { - debug.log('No schema found for path:', path); + debug.log("No schema found for path:", path); return { suggestions: [] }; } return getCompletionsForSchema(currentSchema, range); } catch (error) { - debug.error('Error getting completion items:', error); + debug.error("Error getting completion items:", error); return { suggestions: [] }; } } @@ -65,7 +65,8 @@ function getRootCompletions(schema, range) { isTrusted: true, }, insertText: createInsertText(key, prop), - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, range: range, sortText: key, preselect: true, @@ -84,69 +85,79 @@ function getCompletionsForSchema(schema, range) { } // Process each schema - schemas.forEach(currentSchema => { + for (const currentSchema of schemas) { // Handle enum values if (currentSchema.enum) { - suggestions.push(...currentSchema.enum.map(value => ({ - label: String(value), - kind: monaco.languages.CompletionItemKind.Value, - documentation: 'Allowed value', - insertText: String(value), - range: range, - sortText: '1' + String(value), - preselect: true, - }))); + suggestions.push( + ...currentSchema.enum.map((value) => ({ + label: String(value), + kind: monaco.languages.CompletionItemKind.Value, + documentation: "Allowed value", + insertText: String(value), + range: range, + sortText: `1${String(value)}`, + preselect: true, + })), + ); } // Handle examples as value suggestions if (currentSchema.examples) { - suggestions.push(...currentSchema.examples.map(example => ({ - label: String(example), - kind: monaco.languages.CompletionItemKind.Value, - documentation: 'Example value', - insertText: String(example), - range: range, - sortText: '2' + String(example), - preselect: true, - }))); + suggestions.push( + ...currentSchema.examples.map((example) => ({ + label: String(example), + kind: monaco.languages.CompletionItemKind.Value, + documentation: "Example value", + insertText: String(example), + range: range, + sortText: `2${String(example)}`, + preselect: true, + })), + ); } // Handle properties for objects if (currentSchema.properties) { - suggestions.push(...Object.entries(currentSchema.properties).map(([key, prop]) => ({ - label: key, - kind: monaco.languages.CompletionItemKind.Field, - documentation: { - value: formatPropertyDocs(prop), - isTrusted: true, - }, - insertText: createInsertText(key, prop), - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range: range, - sortText: '3' + key, - preselect: true, - }))); + suggestions.push( + ...Object.entries(currentSchema.properties).map(([key, prop]) => ({ + label: key, + kind: monaco.languages.CompletionItemKind.Field, + documentation: { + value: formatPropertyDocs(prop), + isTrusted: true, + }, + insertText: createInsertText(key, prop), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: range, + sortText: `3${key}`, + preselect: true, + })), + ); } // Handle pattern properties if (currentSchema.patternProperties) { - Object.entries(currentSchema.patternProperties).forEach(([pattern, prop]) => { + for (const [, prop] of Object.entries(currentSchema.patternProperties)) { if (prop.examples) { - suggestions.push(...prop.examples.map(example => ({ - label: String(example), - kind: monaco.languages.CompletionItemKind.Field, - documentation: { - value: formatPropertyDocs(prop), - isTrusted: true, - }, - insertText: createInsertText(String(example), prop), - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range: range, - sortText: '4' + String(example), - preselect: true, - }))); + suggestions.push( + ...prop.examples.map((example) => ({ + label: String(example), + kind: monaco.languages.CompletionItemKind.Field, + documentation: { + value: formatPropertyDocs(prop), + isTrusted: true, + }, + insertText: createInsertText(String(example), prop), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: range, + sortText: `4${String(example)}`, + preselect: true, + })), + ); } - }); + } } // Add default value suggestion @@ -154,19 +165,19 @@ function getCompletionsForSchema(schema, range) { suggestions.push({ label: String(currentSchema.default), kind: monaco.languages.CompletionItemKind.Value, - documentation: 'Default value', + documentation: "Default value", insertText: String(currentSchema.default), range: range, - sortText: '0' + String(currentSchema.default), + sortText: `0${String(currentSchema.default)}`, preselect: true, }); } - }); + } // Remove duplicates const seen = new Set(); return { - suggestions: suggestions.filter(suggestion => { + suggestions: suggestions.filter((suggestion) => { const key = suggestion.label + suggestion.kind; if (seen.has(key)) return false; seen.add(key); @@ -177,7 +188,7 @@ function getCompletionsForSchema(schema, range) { function formatPropertyDocs(prop) { const parts = []; - + if (prop.description) { parts.push(prop.description); } @@ -187,7 +198,7 @@ function formatPropertyDocs(prop) { } if (prop.enum?.length) { - parts.push(`**Allowed values:**\n- ${prop.enum.join('\n- ')}`); + parts.push(`**Allowed values:**\n- ${prop.enum.join("\n- ")}`); } if (prop.default !== undefined) { @@ -195,14 +206,16 @@ function formatPropertyDocs(prop) { } if (prop.examples?.length) { - parts.push(`**Examples:**\n\`\`\`yaml\n${prop.examples.map(ex => JSON.stringify(ex)).join('\n')}\n\`\`\``); + parts.push( + `**Examples:**\n\`\`\`yaml\n${prop.examples.map((ex) => JSON.stringify(ex)).join("\n")}\n\`\`\``, + ); } - return parts.join('\n\n'); + return parts.join("\n\n"); } function createInsertText(key, prop) { - if (prop.type === 'object') { + if (prop.type === "object") { // No snippets for objects, just add newline and indentation return `${key}:\n `; } @@ -216,34 +229,35 @@ function createInsertText(key, prop) { } else if (prop.default !== undefined) { insertText += `\${1:${prop.default}}`; } else { - insertText += '${1}'; + insertText += "${1}"; } - return insertText + '\n'; + return `${insertText}\n`; } function getSchemaAtPath(schema, path) { let current = schema; - + for (const segment of path) { if (!current) return null; - + // Check properties first if (current.properties?.[segment]) { current = current.properties[segment]; continue; } - + // Check pattern properties if (current.patternProperties) { - const patternMatch = Object.entries(current.patternProperties) - .find(([pattern]) => new RegExp(pattern).test(segment)); + const patternMatch = Object.entries(current.patternProperties).find( + ([pattern]) => new RegExp(pattern).test(segment), + ); if (patternMatch) { current = patternMatch[1]; continue; } } - + // Check if we have a $ref if (current.$ref) { const refSchema = resolveRef(current.$ref, schema); @@ -252,42 +266,42 @@ function getSchemaAtPath(schema, path) { continue; } } - + return null; } - + return current; } function resolveRef($ref, rootSchema) { - const path = $ref.replace('#/', '').split('/'); + const path = $ref.replace("#/", "").split("/"); let current = rootSchema; - + for (const segment of path) { if (!current[segment]) return null; current = current[segment]; } - + return current; } function findPathAtPosition(content, position) { - const lines = content.split('\n'); + const lines = content.split("\n"); let currentPath = []; - + for (let i = 0; i < position.lineNumber; i++) { const line = lines[i]; const match = line.match(/^(\s*)(\w+):/); - + if (match) { const [, indent, key] = match; const level = indent.length / 2; - + // Update current path based on indentation currentPath = currentPath.slice(0, level); currentPath[level] = key; } } - + return currentPath.filter(Boolean); -} \ No newline at end of file +} diff --git a/src/constants.js b/src/lib/constants.js similarity index 80% rename from src/constants.js rename to src/lib/constants.js index 4aa1887..36583d1 100644 --- a/src/constants.js +++ b/src/lib/constants.js @@ -1,8 +1,8 @@ -import * as monaco from 'monaco-editor'; +import * as monaco from "monaco-editor"; export const MarkerSeverity = { Hint: monaco.MarkerSeverity.Hint, Info: monaco.MarkerSeverity.Info, Warning: monaco.MarkerSeverity.Warning, Error: monaco.MarkerSeverity.Error, -}; \ No newline at end of file +}; diff --git a/src/debug.js b/src/lib/debug.js similarity index 78% rename from src/debug.js rename to src/lib/debug.js index ad84aa6..aa2578a 100644 --- a/src/debug.js +++ b/src/lib/debug.js @@ -1,16 +1,16 @@ class Debug { constructor(enabled = false) { this.enabled = enabled; - this.prefix = '[Landofile Editor]'; + this.prefix = "[Landofile Editor]"; } enable() { this.enabled = true; - this.log('Debug mode enabled'); + this.log("Debug mode enabled"); } disable() { - this.log('Debug mode disabled'); + this.log("Debug mode disabled"); this.enabled = false; } @@ -39,7 +39,7 @@ class Debug { } } -export const debug = new Debug(localStorage.getItem('debug') === 'true'); +export const debug = new Debug(localStorage.getItem("debug") === "true"); // Enable debug mode with: localStorage.setItem('debug', 'true') -// Disable debug mode with: localStorage.setItem('debug', 'false') \ No newline at end of file +// Disable debug mode with: localStorage.setItem('debug', 'false') diff --git a/src/lib/dialog.js b/src/lib/dialog.js index f66b3e2..7eab5a3 100644 --- a/src/lib/dialog.js +++ b/src/lib/dialog.js @@ -1,14 +1,42 @@ -import { create } from 'zustand' +import { create } from "zustand"; +/** + * Creates a Zustand store for managing the state of the share dialog. + * + * @returns {Object} - The Zustand store with methods to manage the share dialog state. + */ const useDialogStore = create((set) => ({ + /** + * Indicates if the share dialog is currently open. + * + * @type {boolean} + */ isShareDialogOpen: false, - shareUrl: '', + /** + * The URL to be shared. + * + * @type {string} + */ + shareUrl: "", + /** + * Opens the share dialog with the specified URL. + * + * @param {string} url - The URL to be shared. + */ openShareDialog: (url) => set({ isShareDialogOpen: true, shareUrl: url }), + /** + * Closes the share dialog. + */ closeShareDialog: () => set({ isShareDialogOpen: false }), -})) +})); +/** + * Shows the share dialog with the specified URL. + * + * @param {string} shareUrl - The URL to be shared. + */ export const showShareDialog = (shareUrl) => { - useDialogStore.getState().openShareDialog(shareUrl) -} + useDialogStore.getState().openShareDialog(shareUrl); +}; -export { useDialogStore } +export { useDialogStore }; diff --git a/src/lib/editor-config.js b/src/lib/editor-config.js new file mode 100644 index 0000000..c31a8a5 --- /dev/null +++ b/src/lib/editor-config.js @@ -0,0 +1,65 @@ +import landofileExample from "@/templates/example-landofile.yml?raw"; +import { getSharedContent } from "@/lib/share"; +import { loadEditorContent } from "@/lib/storage"; + +/** + * Returns the configuration options for the Monaco editor instance + * @returns {import('monaco-editor').editor.IStandaloneEditorConstructionOptions} + */ +const getEditorOptions = () => ({ + value: getDefaultContent(), + language: "yaml", + theme: document.documentElement.classList.contains("dark") ? "lando" : "vs", + automaticLayout: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + fontSize: 18, + lineNumbers: "on", + renderWhitespace: "selection", + tabSize: 2, + fixedOverflowWidgets: true, + quickSuggestions: true, + suggestOnTriggerCharacters: true, + wordBasedSuggestions: false, + parameterHints: { enabled: true }, + suggest: { + snippetsPreventQuickSuggestions: false, + showWords: false, + filterGraceful: false, + showSnippets: true, + showProperties: true, + localityBonus: true, + insertMode: "insert", + insertHighlight: true, + selectionMode: "always", + }, + acceptSuggestionOnEnter: "on", + acceptSuggestionOnCommitCharacter: true, + snippetSuggestions: "inline", + tabCompletion: "on", + snippetOptions: { exitOnEnter: true }, +}); + +/** + * Returns the initial content for the editor + * Priority: URL shared content > localStorage > default template + * @returns {string} The initial editor content + */ +const getDefaultContent = () => { + // First try to load shared content + const sharedContent = getSharedContent(); + if (sharedContent) { + return sharedContent; + } + + // Then try to load from local storage + const savedContent = loadEditorContent(); + if (savedContent) { + return savedContent; + } + + // Fall back to example template + return landofileExample; +}; + +export { getEditorOptions, getDefaultContent }; diff --git a/src/lib/format-yaml.js b/src/lib/format-yaml.js index 321aaf6..5b85a69 100644 --- a/src/lib/format-yaml.js +++ b/src/lib/format-yaml.js @@ -1,14 +1,20 @@ -import * as monaco from 'monaco-editor'; -import { parse, stringify, parseDocument } from 'yaml'; -import { debug } from '../debug'; +import * as monaco from "monaco-editor"; +import { MarkerSeverity } from "monaco-editor/esm/vs/platform/markers/common/markers"; +import { parseDocument } from "yaml"; +import { debug } from "./debug"; +/** + * Formats the YAML content while preserving comments and structure. + * @param {string} content - The YAML content to be formatted. + * @returns {string} - The formatted YAML content. + */ export function formatYaml(content) { try { // First normalize line endings const normalizedContent = content - .replace(/\r\n/g, '\n') + .replace(/\r\n/g, "\n") // Replace lines with only whitespace with empty lines - .replace(/^\s+$/gm, ''); + .replace(/^\s+$/gm, ""); // Parse document while preserving structure const doc = parseDocument(normalizedContent, { @@ -34,107 +40,139 @@ export function formatYaml(content) { }); // Clean up multiple empty lines while preserving single ones - return formatted - .replace(/\n{3,}/g, '\n\n') - + (formatted.endsWith('\n') ? '' : '\n'); + return ( + formatted.replace(/\n{3,}/g, "\n\n") + + (formatted.endsWith("\n") ? "" : "\n") + ); } catch (error) { - debug.error('Failed to format YAML:', error); + debug.error("Failed to format YAML:", error); throw error; } } -// Helper function to format nodes while preserving comments +/** + * Helper function to format nodes while preserving comments. + * @param {Object} node - The node to be formatted. + */ function formatNode(node) { if (!node) return; // Helper to determine correct comment indentation const getCommentIndent = (comment, isBeforeNode) => { if (!comment) return comment; - const lines = comment.split('\n'); - return lines.map((line, i) => { - // If this is a comment before a node, use the node's indentation - // If it's after a node, keep its current indentation - if (isBeforeNode && line.startsWith('#')) { - // Find the first non-empty line after this comment - const nextContent = node.value || node; - const indent = nextContent?.type === 'MAP' ? ' ' : ''; - return indent + line.trim(); - } - return line; - }).join('\n'); + const lines = comment.split("\n"); + return lines + .map((line, i) => { + // If this is a comment before a node, use the node's indentation + // If it's after a node, keep its current indentation + if (isBeforeNode && line.startsWith("#")) { + // Find the first non-empty line after this comment + const nextContent = node.value || node; + const indent = nextContent?.type === "MAP" ? " " : ""; + return indent + line.trim(); + } + return line; + }) + .join("\n"); }; // Preserve comments with correct indentation - if (node.comment) node.comment = getCommentIndent(node.comment.trimEnd(), false); - if (node.commentBefore) node.commentBefore = getCommentIndent(node.commentBefore.trimEnd(), true); + if (node.comment) + node.comment = getCommentIndent(node.comment.trimEnd(), false); + if (node.commentBefore) + node.commentBefore = getCommentIndent(node.commentBefore.trimEnd(), true); // Format collection items if (node.items) { - node.items.forEach(item => { - if (item.comment) item.comment = getCommentIndent(item.comment.trimEnd(), false); - if (item.commentBefore) item.commentBefore = getCommentIndent(item.commentBefore.trimEnd(), true); + for (const item of node.items) { + if (item.comment) + item.comment = getCommentIndent(item.comment.trimEnd(), false); + if (item.commentBefore) + item.commentBefore = getCommentIndent( + item.commentBefore.trimEnd(), + true, + ); if (item.value) formatNode(item.value); - }); + } } // Format key/value pairs if (node.pairs) { - node.pairs.forEach(pair => { - if (pair.comment) pair.comment = getCommentIndent(pair.comment.trimEnd(), false); - if (pair.commentBefore) pair.commentBefore = getCommentIndent(pair.commentBefore.trimEnd(), true); + for (const pair of node.pairs) { + if (pair.comment) + pair.comment = getCommentIndent(pair.comment.trimEnd(), false); + if (pair.commentBefore) + pair.commentBefore = getCommentIndent( + pair.commentBefore.trimEnd(), + true, + ); if (pair.key) formatNode(pair.key); if (pair.value) formatNode(pair.value); - }); + } } } +/** + * Sets up the YAML formatting for the Monaco editor. + * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The Monaco editor instance. + * @param {Function} toast - The toast function to display messages. + */ export function setupYamlFormatting(editor, toast) { // Add format action to context menu editor.addAction({ - id: 'format-yaml', - label: 'Format Document', + id: "format-yaml", + label: "Format Document", keybindings: [ monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KeyF, ], - contextMenuGroupId: '1_modification', + contextMenuGroupId: "1_modification", contextMenuOrder: 1.5, run: () => formatDocument(editor, toast), }); } +/** + * Formats the YAML document in the Monaco editor. + * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The Monaco editor instance. + * @param {Function} toast - The toast function to display messages. + */ export function formatDocument(editor, toast) { try { const content = editor.getValue(); const formatted = formatYaml(content); - editor.executeEdits('format', [{ - range: editor.getModel().getFullModelRange(), - text: formatted, - }]); - debug.log('YAML formatted successfully'); + editor.executeEdits("format", [ + { + range: editor.getModel().getFullModelRange(), + text: formatted, + }, + ]); + debug.log("YAML formatted successfully"); toast({ - description: 'Document formatted successfully', + description: "Document formatted successfully", duration: 2000, }); } catch (error) { - debug.error('Format failed:', error); + debug.error("Format failed:", error); const errorMatch = error.message.match(/at line (\d+), column (\d+)/i); - const startLine = errorMatch ? parseInt(errorMatch[1]) : 1; - const startCol = errorMatch ? parseInt(errorMatch[2]) : 1; + const startLine = errorMatch ? Number.parseInt(errorMatch[1]) : 1; + const startCol = errorMatch ? Number.parseInt(errorMatch[2]) : 1; toast({ description: `Failed to format: ${error.message}`, duration: 5000, - className: 'bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200', + className: "bg-red-50 dark:bg-red-900/10 text-red-800 dark:text-red-200", }); - monaco.editor.setModelMarkers(editor.getModel(), 'yaml', [{ - severity: MarkerSeverity.Error, - message: `Failed to format: ${error.message}`, - startLineNumber: startLine, - startColumn: startCol, - endLineNumber: startLine, - endColumn: startCol + 1, - }]); + monaco.editor.setModelMarkers(editor.getModel(), "yaml", [ + { + severity: MarkerSeverity.Error, + message: `Failed to format: ${error.message}`, + startLineNumber: startLine, + startColumn: startCol, + endLineNumber: startLine, + endColumn: startCol + 1, + }, + ]); } } diff --git a/src/lib/schema-validation.js b/src/lib/schema-validation.js new file mode 100644 index 0000000..9c38664 --- /dev/null +++ b/src/lib/schema-validation.js @@ -0,0 +1,1104 @@ +import * as YAML from "yaml"; +import Ajv from "ajv"; +import { TokenizationRegistry } from "monaco-editor/esm/vs/editor/common/languages"; +import { registerCompletionProvider } from "@/lib/completions"; +import { setupYamlFormatting } from "@/lib/format-yaml"; +import { debug } from "@/lib/debug"; +import { MarkerSeverity } from "@/lib/constants"; +import * as monaco from "monaco-editor"; + +/** + * Ajv instance configured for schema validation + * @type {import('ajv').Ajv} + */ +const ajv = new Ajv({ + allErrors: true, + verbose: true, + allowUnionTypes: true, +}); + +/** @type {Object.|null} Schema definitions flattened for hover support */ +let schemaDefinitions = null; + +/** @type {Object|null} Compiled JSON schema */ +let compiledSchema = null; + +// Try to import local schema, but don't fail if it doesn't exist +let localSchema = null; +try { + if (import.meta.env.DEV) { + debug.log("Development mode detected, attempting to use local schema..."); + try { + const schema = await import("../../landofile-spec.json"); + localSchema = JSON.stringify(schema.default); + debug.log("Local schema loaded successfully"); + } catch (importError) { + throw new Error(`Failed to import local schema: ${importError.message}`); + } + } +} catch (e) { + debug.warn("Failed to load local schema:", e); +} + +/** + * Loads and compiles the JSON schema for Landofile validation + * @returns {Promise} The loaded schema object, or null if loading fails + */ +export async function loadSchema() { + try { + // If we already have a compiled schema, return it + if (compiledSchema) { + return compiledSchema; + } + + const isDev = import.meta.env.DEV; + let schema = null; + + if (isDev && localSchema) { + try { + schema = JSON.parse(localSchema); + debug.log("Local schema parsed successfully"); + } catch (localError) { + debug.warn("Failed to parse local schema:", localError); + } + } + + if (!schema) { + const schemaUrl = + "https://4lando.github.io/lando-spec/landofile-spec.json"; + debug.log("Fetching schema from:", schemaUrl); + + const response = await fetch(schemaUrl); + if (!response.ok) { + debug.error("Schema fetch failed:", { + status: response.status, + statusText: response.statusText, + url: response.url, + }); + return null; + } + + schema = await response.json(); + debug.log("Remote schema loaded:", schema); + } + + if (!schema || typeof schema !== "object") { + debug.error("Invalid schema format:", schema); + return null; + } + + // Store schema definitions for hover support + schemaDefinitions = flattenSchema(schema); + debug.log("Schema definitions:", schemaDefinitions); + + // Remove $id to prevent Ajv from caching it + const schemaToCompile = { ...schema }; + schemaToCompile.$id = undefined; + + // Verify schema can be compiled + try { + ajv.compile(schemaToCompile); + compiledSchema = schema; // Store the original schema for completions + return schema; + } catch (compileError) { + debug.error("Schema compilation failed:", compileError); + return null; + } + } catch (error) { + debug.error("Failed to load schema:", { + name: error.name, + message: error.message, + stack: error.stack, + }); + return null; + } +} + +/** + * Flattens a nested JSON schema into a map of paths to schema definitions + * @param {Object} schema - The schema to flatten + * @param {string} [prefix=''] - Current path prefix for nested properties + * @param {Object} [result={}] - Accumulated result of flattened schema + * @param {Set} [visited=new Set()] - Set of visited schema objects to prevent cycles + * @param {Object} [rootSchema=null] - Root schema object for resolving refs + * @returns {Object} Flattened schema with paths as keys + */ +function flattenSchema( + schema, + prefix = "", + result = {}, + visited = new Set(), + rootSchema = null, +) { + if (!schema || visited.has(schema)) { + return result; + } + visited.add(schema); + + // Store root schema on first call + const effectiveRootSchema = rootSchema || schema; + debug.log( + "Root schema initialized with keys:", + Object.keys(effectiveRootSchema), + ); + + // Handle $ref resolution first + if (schema.$ref) { + debug.log("Resolving $ref:", schema.$ref); + const refPath = schema.$ref.replace("#/", "").split("/"); + let refSchema = effectiveRootSchema; + + for (const part of refPath) { + if (!refSchema[part]) { + debug.warn("Failed to resolve ref part:", part); + return result; + } + refSchema = refSchema[part]; + } + + // Merge properties from the referenced schema, but don't override existing ones + const merged = { + ...refSchema, + ...schema, + // Ensure description comes from the referenced schema unless explicitly set + description: schema.description || refSchema.description, + }; + Object.assign(schema, merged); + debug.log("Resolved and merged reference:", refPath.join("/"), merged); + } + + // Handle pattern properties + if (schema.patternProperties) { + debug.log("Processing pattern properties at prefix:", prefix); + for (const [pattern, value] of Object.entries(schema.patternProperties)) { + const wildcardPath = prefix ? `${prefix}/*` : "*"; + debug.log("Processing pattern:", pattern, "at path:", wildcardPath); + + // First resolve any references in the pattern property value + if (value.$ref) { + const refPath = value.$ref.replace("#/", "").split("/"); + let refSchema = effectiveRootSchema; + + for (const part of refPath) { + if (!refSchema[part]) { + debug.warn("Failed to resolve ref part:", part); + return; + } + refSchema = refSchema[part]; + } + + // Create a new object for the pattern property to avoid sharing references + result[wildcardPath] = { + description: refSchema.description || "", + type: refSchema.type || value.type || "", + pattern, + oneOf: refSchema.oneOf || value.oneOf || [], + additionalProperties: + refSchema.additionalProperties || value.additionalProperties, + }; + } else { + // Handle non-ref pattern properties + result[wildcardPath] = { + description: value.description || "", + type: value.type || "", + pattern, + oneOf: value.oneOf || [], + additionalProperties: value.additionalProperties, + }; + } + + // Process the value schema (which might have its own properties or refs) + flattenSchema(value, wildcardPath, result, visited, effectiveRootSchema); + } + } + + // Handle oneOf schemas + if (schema.oneOf) { + debug.log("Processing oneOf at prefix:", prefix); + schema.oneOf.forEach((subSchema, index) => { + const oneOfPath = `${prefix}#${index}`; + flattenSchema(subSchema, oneOfPath, result, visited, effectiveRootSchema); + }); + } + + // Handle regular properties + if (schema.properties) { + debug.log("Processing regular properties at prefix:", prefix); + for (const [key, value] of Object.entries(schema.properties)) { + const path = prefix ? `${prefix}/${key}` : key; + result[path] = { + description: value.description || "", + type: value.type || "", + enum: value.enum || [], + examples: value.examples || [], + default: value.default, + oneOf: value.oneOf || [], + additionalProperties: value.additionalProperties, + }; + + // Continue flattening nested schemas + flattenSchema(value, path, result, visited, effectiveRootSchema); + } + } else if (prefix && !schema.patternProperties) { + // Handle non-object schemas + debug.log("Adding non-object schema at prefix:", prefix); + result[prefix] = { + description: schema.description || "", + type: schema.type || "", + enum: schema.enum || [], + examples: schema.examples || [], + default: schema.default, + oneOf: schema.oneOf || [], + additionalProperties: schema.additionalProperties, + }; + } + + // Process $defs + if (schema.$defs) { + debug.log("Processing $defs"); + for (const [key, value] of Object.entries(schema.$defs)) { + const defsPath = `$defs/${key}`; + debug.log("Processing $def:", defsPath); + flattenSchema(value, defsPath, result, visited, effectiveRootSchema); + } + } + + return result; +} + +/** + * Formats a schema example as YAML + * @param {string} key - The property key + * @param {any} example - The example value to format + * @returns {string} YAML formatted example + */ +function formatExample(key, example) { + try { + // If example is an object or array, convert to YAML + if (typeof example === "object" && example !== null) { + const obj = { [key]: example }; + return YAML.stringify(obj).trim(); + } + + // For primitive types, format as YAML key-value pair + return `${key}: ${YAML.stringify(example)}`; + } catch (error) { + debug.error("Error formatting example:", error); + return `${key}: ${JSON.stringify(example)}`; + } +} + +/** + * Gets hover information for a position in the YAML content + * @param {string} content - The YAML content + * @param {Object} position - Position object with lineNumber and column + * @param {number} position.lineNumber - Line number in the document (1-based) + * @param {number} position.column - Column in the line (1-based) + * @returns {Object|null} Hover information with contents and range, or null if none found + */ +export function getHoverInfo(content, position) { + try { + // Find the key at the current position + const lines = content.split("\n"); + const line = lines[position.lineNumber - 1]; + const match = line.match(/^(\s*)(\w+):/); + + if (match) { + const [, indent, key] = match; + const level = indent.length / 2; + + // Build path to current key + const path = findPathAtPosition(content, position); + + debug.log("Looking up hover info for path:", path); + + // Try to find schema info using different path patterns + let info = null; + const possiblePaths = generatePossiblePaths(path); + + for (const schemaPath of possiblePaths) { + debug.log("Trying schema path:", schemaPath); + if (schemaDefinitions?.[schemaPath]) { + info = schemaDefinitions[schemaPath]; + debug.log("Found schema info at path:", schemaPath); + break; + } + } + + if (info) { + const contents = []; + + // Description + if (info.description) { + contents.push({ value: info.description }); + } + + // Type information + if (info.type) { + contents.push({ value: `Type: ${info.type}` }); + } + + // Pattern (for pattern properties) + if (info.pattern) { + contents.push({ value: `Pattern: ${info.pattern}` }); + } + + // Enum values + if (info.enum?.length) { + contents.push({ value: `Allowed values: ${info.enum.join(", ")}` }); + } + + // Default value + if (info.default !== undefined) { + contents.push({ value: "Default:" }); + contents.push({ + value: `\`\`\`yaml\n${formatExample(key, info.default)}\`\`\``, + }); + } + + // Examples + if (info.examples?.length) { + contents.push({ value: "\nExamples:" }); + for (const example of info.examples) { + contents.push({ + value: `\`\`\`yaml\n${formatExample(key, example)}\`\`\``, + }); + } + } + + // OneOf options + if (info.oneOf?.length) { + contents.push({ value: "\nPossible Formats:" }); + for (const [index, option] of info.oneOf.entries()) { + if (option.description) { + contents.push({ value: `${index + 1}. ${option.description}` }); + } + } + } + + return { + contents, + range: { + startLineNumber: position.lineNumber, + startColumn: indent.length + 1, + endLineNumber: position.lineNumber, + endColumn: indent.length + key.length + 1, + }, + }; + } + } + } catch (error) { + debug.error("Error getting hover info:", error); + } + return null; +} + +/** + * Generates possible schema paths including wildcards for pattern matching + * @param {string[]} path - Array of path segments + * @returns {string[]} Array of possible path patterns + */ +function generatePossiblePaths(path) { + const paths = []; + + // Start with the most specific full path + if (path.length > 0) { + paths.push(path.join("/")); + } + + // Then try wildcard variations, still maintaining specificity + if (path.length > 1) { + // For a path like ['services', 'node', 'type'], try: + // 1. services/*/type + // 2. services/node/* + // 3. services/* + for (let i = path.length - 1; i > 0; i--) { + const wildcardPath = [ + ...path.slice(0, i), + "*", + ...path.slice(i + 1), + ].join("/"); + paths.push(wildcardPath); + } + } + + // Add root level wildcard last (lowest priority) + paths.push("*"); + + debug.log("Generated possible paths:", paths); + return paths.filter(Boolean); // Remove any empty paths +} + +/** + * Validates YAML content against the JSON schema + * @param {string} content - YAML content to validate + * @param {Object} schema - JSON schema to validate against + * @returns {Array} Array of validation errors with position information + */ +export function validateYaml(content, schema) { + try { + // Skip validation for empty content + if (!content.trim()) { + return []; + } + + debug.log("Parsing YAML content:", content); + let parsed = YAML.parse(content); + + // Convert undefined/null values after colons to empty objects + const lines = content.split("\n"); + lines.forEach((line, index) => { + const match = line.match(/^(\s*)(\w+):(\s*)$/); + if (match) { + // If we have a key with nothing after the colon + const [, indent, key] = match; + const path = findPathAtPosition(content, { lineNumber: index + 1 }); + + // Build the path to this property + parsed = parsed || {}; + let current = parsed; + path.forEach((segment, i) => { + if (i === path.length - 1) { + // Last segment is our current key + current[segment] = current[segment] ?? {}; + } else { + current[segment] = current[segment] ?? {}; + current = current[segment]; + } + }); + + // Set empty object for the current key if it's undefined + if (path.length === 0) { + parsed[key] = parsed[key] ?? {}; + } else { + current[key] = current[key] ?? {}; + } + } + }); + + debug.log("Parsed YAML with empty objects:", parsed); + + // Validate against schema + const validate = ajv.compile(schema); + const valid = validate(parsed); + + if (!valid) { + debug.log("Schema validation errors:", validate.errors); + + // Group errors by location to prevent duplicates + const errorsByLocation = new Map(); + + for (const error of validate.errors) { + const path = error.instancePath.split("/").filter(Boolean); + const location = findLocationInYaml(content, path); + const locationKey = `${location.line}:${location.column}`; + + // Only keep the first error for each location + if (!errorsByLocation.has(locationKey)) { + if (error.keyword === "additionalProperties") { + // Find the location of the unexpected property + const path = error.instancePath.split("/").filter(Boolean); + const unexpectedProp = error.params.additionalProperty; + path.push(unexpectedProp); // Add the unexpected property to the path + const location = findLocationInYaml(content, path); + + errorsByLocation.set(locationKey, { + startLineNumber: location.line, + endLineNumber: location.line, + startColumn: location.column, + endColumn: location.column + unexpectedProp.length, + message: `Unexpected property "${unexpectedProp}"`, + severity: MarkerSeverity.Error, + source: "JSON Schema", + }); + } else { + errorsByLocation.set(locationKey, { + startLineNumber: location.line, + endLineNumber: location.line, + startColumn: location.column, + endColumn: location.column + (location.length || 1), + message: error.instancePath + ? `${error.instancePath.split("/").pop()} ${error.message} at ${error.instancePath}` + : error.message, + severity: MarkerSeverity.Error, + source: "JSON Schema", + }); + } + } + } + + return Array.from(errorsByLocation.values()); + } + + return []; + } catch (error) { + debug.warn("YAML parsing error:", error); + + if (error instanceof YAML.YAMLParseError) { + const { message, pos, linePos } = error; + debug.log("Parse error details:", { message, pos, linePos }); + + return [ + { + startLineNumber: linePos ? linePos[0].line : 1, + endLineNumber: linePos ? linePos[1].line : 1, + startColumn: linePos ? linePos[0].col : 1, + endColumn: linePos ? linePos[1].col : content.length, + message: message, + severity: MarkerSeverity.Error, + source: "YAML Parser", + }, + ]; + } + + return [ + { + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: content.length, + message: error.message, + severity: MarkerSeverity.Error, + source: "YAML Parser", + }, + ]; + } +} + +/** + * Finds the location of a path in YAML content + * @param {string} content - YAML content to search + * @param {string[]} path - Array of path segments to locate + * @returns {Object} Location object with line, column, and length + */ +function findLocationInYaml(content, path) { + const lines = content.split("\n"); + let currentPath = []; + let arrayIndex = 0; + let currentArrayPath = null; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + + // Handle array items + const arrayMatch = line.match(/^(\s*)-/); + if (arrayMatch) { + const [, indent] = arrayMatch; + const level = indent.length / 2; + + // Reset array index when starting a new array at a different path + if (currentArrayPath !== level) { + arrayIndex = 0; + currentArrayPath = level; + } else { + arrayIndex++; + } + + // Check if this array index matches our path + const targetIndex = Number.parseInt(path[level]); + if (!Number.isNaN(targetIndex) && targetIndex === arrayIndex) { + return { + line: i + 1, + column: indent.length + 1, + length: line.length - indent.length, + }; + } + } else { + // Reset array tracking when we're not in an array + currentArrayPath = null; + arrayIndex = 0; + + // Handle regular object properties + const match = line.match(/^(\s*)(\w+):/); + if (match) { + const [, indent, key] = match; + const level = indent.length / 2; + + currentPath = currentPath.slice(0, level); + currentPath[level] = key; + + if (pathsMatch(currentPath, path)) { + return { + line: i + 1, + column: indent.length + 1, + length: key.length, + }; + } + } + } + } + + return { line: 1, column: 1, length: 1 }; +} + +/** + * Checks if two paths match + * @param {string[]} currentPath - Current path being checked + * @param {string[]} targetPath - Target path to match against + * @returns {boolean} True if paths match + */ +function pathsMatch(currentPath, targetPath) { + return targetPath.every((segment, i) => currentPath[i] === segment); +} + +/** + * Finds the path to a position in YAML content + * @param {string} content - YAML content + * @param {Object} position - Position object with lineNumber + * @returns {string[]} Array of path segments to the position + */ +function findPathAtPosition(content, position) { + const lines = content.split("\n"); + let currentPath = []; + + for (let i = 0; i < position.lineNumber; i++) { + const line = lines[i]; + const match = line.match(/^(\s*)(\w+):/); + + if (match) { + const [, indent, key] = match; + const level = indent.length / 2; + + // Update current path based on indentation + currentPath = currentPath.slice(0, level); + currentPath[level] = key; + } + } + + return currentPath.filter(Boolean); +} + +/** + * Gets completion items for a position in the editor + * @param {import('monaco-editor').editor.ITextModel} model - Monaco editor model + * @param {import('monaco-editor').Position} position - Position in the editor + * @param {Object} schema - JSON schema for completions + * @returns {Object} Completion suggestions + */ +function getCompletionItems(model, position, schema) { + try { + // Get the current path in the YAML document + const path = findPathAtPosition(model.getValue(), position); + debug.log("Getting completions for path:", path); + + // Get current line and word + const lineContent = model.getLineContent(position.lineNumber); + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + + // At root level + if (!lineContent.startsWith(" ")) { + return getRootCompletions(schema, range); + } + + // Get the schema section for the current path + const currentSchema = getSchemaAtPath(schema, path); + if (!currentSchema) { + debug.log("No schema found for path:", path); + return { suggestions: [] }; + } + + return getCompletionsForSchema(currentSchema, range); + } catch (error) { + debug.error("Error getting completion items:", error); + return { suggestions: [] }; + } +} + +/** + * Gets completion items for root level properties + * @param {Object} schema - JSON schema + * @param {import('monaco-editor').IRange} range - Range for the completion + * @returns {Object} Completion suggestions for root properties + */ +function getRootCompletions(schema, range) { + if (!schema.properties) return { suggestions: [] }; + + const suggestions = Object.entries(schema.properties).map(([key, prop]) => ({ + label: key, + kind: monaco.languages.CompletionItemKind.Field, + documentation: { + value: formatPropertyDocs(prop), + isTrusted: true, + }, + insertText: createInsertText(key, prop), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: range, + sortText: key, + })); + + return { suggestions }; +} + +/** + * Gets completion items for a specific schema section + * @param {Object} schema - Schema section + * @param {import('monaco-editor').IRange} range - Range for the completion + * @returns {Object} Completion suggestions for the schema + */ +function getCompletionsForSchema(schema, range) { + const suggestions = []; + + // Collect all possible schemas (including from oneOf) + const schemas = [schema]; + if (schema.oneOf) { + schemas.push(...schema.oneOf); + } + + // Process each schema + for (const currentSchema of schemas) { + // Handle enum values + if (currentSchema.enum) { + suggestions.push( + ...currentSchema.enum.map((value) => ({ + label: String(value), + kind: monaco.languages.CompletionItemKind.Value, + documentation: "Allowed value", + insertText: String(value), + range: range, + sortText: `1${String(value)}`, + })), + ); + } + + // Handle examples as value suggestions + if (currentSchema.examples) { + suggestions.push( + ...currentSchema.examples.map((example) => ({ + label: String(example), + kind: monaco.languages.CompletionItemKind.Value, + documentation: "Example value", + insertText: String(example), + range: range, + sortText: `2${String(example)}`, + })), + ); + } + + // Handle properties for objects + if (currentSchema.properties) { + suggestions.push( + ...Object.entries(currentSchema.properties).map(([key, prop]) => ({ + label: key, + kind: monaco.languages.CompletionItemKind.Field, + documentation: { + value: formatPropertyDocs(prop), + isTrusted: true, + }, + insertText: createInsertText(key, prop), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: range, + sortText: `3${key}`, + })), + ); + } + + // Handle pattern properties + if (currentSchema.patternProperties) { + for (const [pattern, prop] of Object.entries( + currentSchema.patternProperties, + )) { + if (prop.examples) { + suggestions.push( + ...prop.examples.map((example) => ({ + label: String(example), + kind: monaco.languages.CompletionItemKind.Field, + documentation: { + value: formatPropertyDocs(prop), + isTrusted: true, + }, + insertText: createInsertText(String(example), prop), + insertTextRules: + monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, + range: range, + sortText: `4${String(example)}`, + })), + ); + } + } + } + + // Add default value suggestion + if (currentSchema.default !== undefined) { + suggestions.push({ + label: String(currentSchema.default), + kind: monaco.languages.CompletionItemKind.Value, + documentation: "Default value", + insertText: String(currentSchema.default), + range: range, + sortText: `0${String(currentSchema.default)}`, + }); + } + } + + // Remove duplicates + const seen = new Set(); + return { + suggestions: suggestions.filter((suggestion) => { + const key = suggestion.label + suggestion.kind; + if (seen.has(key)) return false; + seen.add(key); + return true; + }), + }; +} + +/** + * Formats documentation for a schema property + * @param {Object} prop - Schema property + * @returns {string} Formatted markdown documentation + */ +function formatPropertyDocs(prop) { + const parts = []; + + if (prop.description) { + parts.push(prop.description); + } + + if (prop.type) { + parts.push(`**Type:** ${prop.type}`); + } + + if (prop.enum?.length) { + parts.push(`**Allowed values:**\n- ${prop.enum.join("\n- ")}`); + } + + if (prop.default !== undefined) { + parts.push(`**Default:** ${JSON.stringify(prop.default)}`); + } + + if (prop.examples?.length) { + parts.push( + `**Examples:**\n\`\`\`yaml\n${prop.examples.map((ex) => JSON.stringify(ex)).join("\n")}\n\`\`\``, + ); + } + + return parts.join("\n\n"); +} + +/** + * Creates insert text for a completion item + * @param {string} key - Property key + * @param {Object} prop - Schema property + * @returns {string} Snippet text for insertion + */ +function createInsertText(key, prop) { + if (prop.type === "object") { + // No space after colon for objects + return `${key}:\n \${1}`; + } + + // Space after colon for non-objects + let insertText = `${key}: `; + if (prop.examples?.length) { + insertText += `\${1:${prop.examples[0]}}`; + } else if (prop.enum?.length) { + insertText += `\${1:${prop.enum[0]}}`; + } else if (prop.default !== undefined) { + insertText += `\${1:${prop.default}}`; + } else { + insertText += "${1}"; + } + + return `${insertText}\n`; +} + +/** + * Gets schema section for a specific path + * @param {Object} schema - Root schema + * @param {string[]} path - Path to schema section + * @returns {Object|null} Schema section or null if not found + */ +function getSchemaAtPath(schema, path) { + let current = schema; + + for (const segment of path) { + if (!current) return null; + + // Check properties first + if (current.properties?.[segment]) { + current = current.properties[segment]; + continue; + } + + // Check pattern properties + if (current.patternProperties) { + const patternMatch = Object.entries(current.patternProperties).find( + ([pattern]) => new RegExp(pattern).test(segment), + ); + if (patternMatch) { + current = patternMatch[1]; + continue; + } + } + + // Check if we have a $ref + if (current.$ref) { + const refSchema = resolveRef(current.$ref, schema); + if (refSchema) { + current = refSchema; + continue; + } + } + + return null; + } + + return current; +} + +/** + * Resolves a JSON schema $ref + * @param {string} $ref - Reference string (e.g. "#/definitions/something") + * @param {Object} rootSchema - Root schema containing the definitions + * @returns {Object|null} Resolved schema or null if not found + */ +function resolveRef($ref, rootSchema) { + const path = $ref.replace("#/", "").split("/"); + let current = rootSchema; + + for (const segment of path) { + if (!current[segment]) return null; + current = current[segment]; + } + + return current; +} + +/** + * Sets up editor features including: + * - YAML tokenization + * - Hover provider + * - Schema validation + * - Completion provider + * - YAML formatting + * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The Monaco editor instance + * @param {Function} toast - Toast notification function + * @returns {Promise} + */ +const setupEditorFeatures = async (editor, toast) => { + // Set up YAML tokenization + const existingTokensProvider = TokenizationRegistry.get("yaml"); + if (existingTokensProvider) { + const originalTokenize = existingTokensProvider.tokenize.bind( + existingTokensProvider, + ); + monaco.languages.setMonarchTokensProvider("yaml", { + ...existingTokensProvider, + tokenize: (line, state) => { + const tokens = originalTokenize(line, state); + if (tokens?.tokens) { + tokens.tokens = tokens.tokens.map((token) => { + if (token.scopes.includes("type.yaml")) { + return { ...token, scopes: ["key"] }; + } + return token; + }); + } + return tokens; + }, + }); + } + + // Register hover provider + monaco.languages.registerHoverProvider("yaml", { + provideHover: (model, position) => { + const content = model.getValue(); + return getHoverInfo(content, position); + }, + }); + + // Load schema and set up validation + debug.log("Loading schema..."); + const schemaContent = await loadSchema(); + + if (!schemaContent) { + handleSchemaLoadFailure(editor); + } else { + debug.log("Schema loaded successfully"); + setupSchemaValidation(editor, schemaContent); + } + + // Register YAML completion provider + if (schemaContent) { + registerCompletionProvider(schemaContent); + } + + // Add format action setup + setupYamlFormatting(editor, toast); +}; + +/** + * Handles schema loading failure by displaying a warning marker + * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The Monaco editor instance + */ +const handleSchemaLoadFailure = (editor) => { + debug.warn("Schema failed to load - editor will continue without validation"); + monaco.editor.setModelMarkers(editor.getModel(), "yaml", [ + { + severity: MarkerSeverity.Warning, + message: "Schema validation unavailable - schema failed to load", + startLineNumber: 1, + startColumn: 1, + endLineNumber: 1, + endColumn: 1, + }, + ]); +}; + +/** + * Updates the diagnostics for the YAML content + * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The Monaco editor instance + * @param {Object} schemaContent - The schema content for validation + */ +const updateDiagnostics = (editor, schemaContent) => { + const content = editor.getValue(); + try { + debug.log("Validating YAML content..."); + const diagnostics = validateYaml(content, schemaContent); + debug.log("Validation results:", diagnostics); + monaco.editor.setModelMarkers(editor.getModel(), "yaml", diagnostics); + } catch (e) { + debug.error("Validation error:", e); + } +}; + +/** + * Configures schema validation and sets up content change listeners + * @param {import('monaco-editor').editor.IStandaloneCodeEditor} editor - The Monaco editor instance + * @param {Object} schemaContent - The loaded schema content + */ +const setupSchemaValidation = (editor, schemaContent) => { + // Update diagnostics on content change + editor.onDidChangeModelContent(() => { + debug.log("Content changed, updating diagnostics..."); + updateDiagnostics(editor, schemaContent); + }); + + // Initial validation + updateDiagnostics(editor, schemaContent); +}; + +/** + * @type {Object.|null} Flattened schema definitions for hover support + */ +export { schemaDefinitions }; + +/** + * @type {Function} Function to get completion items + */ +export { getCompletionItems }; + +export { + setupEditorFeatures, + handleSchemaLoadFailure, + setupSchemaValidation, + updateDiagnostics, +}; diff --git a/src/share.js b/src/lib/share.js similarity index 60% rename from src/share.js rename to src/lib/share.js index abfb354..f7a7b6a 100644 --- a/src/share.js +++ b/src/lib/share.js @@ -1,34 +1,37 @@ -import { debug } from './debug'; -import { formatYaml } from './lib/format-yaml'; -import { parseDocument } from 'yaml'; -import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'; +import { debug } from "@/lib/debug"; +import { formatYaml } from "@/lib/format-yaml"; +import { parseDocument } from "yaml"; +import { + compressToEncodedURIComponent, + decompressFromEncodedURIComponent, +} from "lz-string"; // Common YAML strings to replace with shorter versions const compressionMap = { - 'recipe: ': '~rp:', - 'config: ': '~cp:', - 'services: ': '~ss:', - 'webroot: ': '~wr:', - 'database: ': '~db:', - 'type: ': '~t:', - 'build: ': '~b:', - 'command: ': '~cm:', - 'port: ': '~pt:', - 'name: ': '~n:', - 'tooling:': '~tl:', - 'service:': '~s:', - 'description:': '~dsc:', - 'environment:': '~e:', - 'image:': '~i:', - 'volumes:': '~v:', - ': false': ': f', - ': true': ': t', + "recipe: ": "~rp:", + "config: ": "~cp:", + "services: ": "~ss:", + "webroot: ": "~wr:", + "database: ": "~db:", + "type: ": "~t:", + "build: ": "~b:", + "command: ": "~cm:", + "port: ": "~pt:", + "name: ": "~n:", + "tooling:": "~tl:", + "service:": "~s:", + "description:": "~dsc:", + "environment:": "~e:", + "image:": "~i:", + "volumes:": "~v:", + ": false": ": f", + ": true": ": t", }; export function generateShareUrl(content) { try { // Format content before sharing - let formatted = formatYaml(content); + const formatted = formatYaml(content); // Parse as YAML to preserve comments const doc = parseDocument(formatted, { @@ -47,17 +50,17 @@ export function generateShareUrl(content) { // Apply compression map let compressed = yamlStr; - Object.entries(compressionMap).forEach(([key, value]) => { + for (const [key, value] of Object.entries(compressionMap)) { compressed = compressed.replaceAll(key, value); - }); + } // Compress the shortened content const encoded = compressToEncodedURIComponent(compressed); const url = new URL(window.location.href); - url.searchParams.set('s', encoded); + url.searchParams.set("s", encoded); return url.toString(); } catch (error) { - debug.error('Failed to generate share URL:', error); + debug.error("Failed to generate share URL:", error); throw error; } } @@ -65,16 +68,16 @@ export function generateShareUrl(content) { export function getSharedContent() { try { const url = new URL(window.location.href); - const encoded = url.searchParams.get('s'); + const encoded = url.searchParams.get("s"); if (!encoded) return null; let content = decompressFromEncodedURIComponent(encoded); if (!content) return null; // Reverse compression map - Object.entries(compressionMap).forEach(([key, value]) => { + for (const [key, value] of Object.entries(compressionMap)) { content = content.replaceAll(value, key); - }); + } // Parse and re-stringify to ensure proper formatting const doc = parseDocument(content, { @@ -90,7 +93,7 @@ export function getSharedContent() { doubleQuotedAsJSON: false, }); } catch (error) { - debug.error('Failed to get shared content:', error); + debug.error("Failed to get shared content:", error); return null; } } diff --git a/src/lib/storage.js b/src/lib/storage.js index 9a34da5..c826574 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -1,18 +1,32 @@ -const STORAGE_KEY = 'lando_editor_content'; +/** + * The key used to store the editor content in localStorage. + * @type {string} + */ +const STORAGE_KEY = "lando_editor_content"; +/** + * Saves the editor content to localStorage. + * + * @param {string} content - The content to be saved. + */ export function saveEditorContent(content) { try { localStorage.setItem(STORAGE_KEY, content); } catch (error) { - console.warn('Failed to save editor content:', error); + console.warn("Failed to save editor content:", error); } } +/** + * Loads the editor content from localStorage. + * + * @returns {(string|null)} - The loaded content or null if failed. + */ export function loadEditorContent() { try { return localStorage.getItem(STORAGE_KEY); } catch (error) { - console.warn('Failed to load editor content:', error); + console.warn("Failed to load editor content:", error); return null; } } diff --git a/src/lib/theme.js b/src/lib/theme.js index 8da5e71..9abc3b3 100644 --- a/src/lib/theme.js +++ b/src/lib/theme.js @@ -1,69 +1,96 @@ -import * as monaco from 'monaco-editor'; -import { debug } from '../debug'; +import * as monaco from "monaco-editor"; +import { debug } from "@/lib/debug"; -// Define Lando theme colors +/** + * @typedef {Object} ThemeRule + * @property {string} token - The token to apply the rule to. + * @property {string} foreground - The foreground color for the token. + * @property {string} fontStyle - The font style for the token. + */ + +/** + * @typedef {Object} Theme + * @property {string} base - The base theme to inherit from. + * @property {boolean} inherit - Whether to inherit from the base theme. + * @property {ThemeRule[]} rules - The rules to apply to the theme. + * @property {Object} colors - The colors to apply to the theme. + */ + +/** + * Define Lando theme colors + * @type {Theme} + * @property {string} base - The base theme to inherit from. + * @property {boolean} inherit - Whether to inherit from the base theme. + * @property {ThemeRule[]} rules - The rules to apply to the theme. + * @property {ThemeColors} colors - The colors to apply to the theme. + */ const landoTheme = { - base: 'vs-dark', + base: "vs-dark", inherit: true, rules: [ - { token: '', foreground: 'f8f8f2' }, - { token: 'key', foreground: 'de3f8f' }, - { token: 'type.yaml', foreground: 'de3f8f' }, - { token: 'string.yaml', foreground: 'f1fa8c' }, - { token: 'number.yaml', foreground: 'bd93f9' }, - { token: 'keyword.yaml', foreground: 'bd93f9' }, - { token: 'comment.yaml', foreground: '6272a4', fontStyle: 'italic' }, - { token: 'operator.yaml', foreground: 'dd3f8f' }, - { token: 'delimiter.bracket', foreground: 'dd3f8f' }, - { token: 'delimiter.square', foreground: 'dd3f8f' }, + { token: "", foreground: "f8f8f2" }, + { token: "key", foreground: "de3f8f" }, + { token: "type.yaml", foreground: "de3f8f" }, + { token: "string.yaml", foreground: "f1fa8c" }, + { token: "number.yaml", foreground: "bd93f9" }, + { token: "keyword.yaml", foreground: "bd93f9" }, + { token: "comment.yaml", foreground: "6272a4", fontStyle: "italic" }, + { token: "operator.yaml", foreground: "dd3f8f" }, + { token: "delimiter.bracket", foreground: "dd3f8f" }, + { token: "delimiter.square", foreground: "dd3f8f" }, ], colors: { - 'editor.background': '#261D2D', - 'editor.foreground': '#f8f8f2', - 'editor.lineHighlightBackground': '#44475a60', - 'editor.selectionBackground': '#8be9fd40', - 'editor.inactiveSelectionBackground': '#8be9fd20', - 'editorCursor.foreground': '#f8f8f2', - 'editorWhitespace.foreground': '#44475a', - 'editorIndentGuide.background': '#44475a', - 'editorIndentGuide.activeBackground': '#6272a4', - 'editor.selectionHighlightBackground': '#424450', - 'editor.wordHighlightBackground': '#8be9fd20', - 'editor.wordHighlightStrongBackground': '#50fa7b50', - 'editorLineNumber.foreground': '#6272a4', - 'editorLineNumber.activeForeground': '#f8f8f2', - 'editorError.foreground': '#ff5555', - 'editorWarning.foreground': '#ffb86c', - 'editorInfo.foreground': '#8be9fd', - 'editorHint.foreground': '#50fa7b', - 'editorBracketMatch.background': '#44475a', - 'editorBracketMatch.border': '#dd3f8f', - 'editorHoverWidget.background': '#382A3D', - 'editorHoverWidget.border': '#de3f8f', - 'editorHoverWidget.foreground': '#f8f8f2', - 'editorHoverWidget.statusBarBackground': '#382A3D', + "editor.background": "#261D2D", + "editor.foreground": "#f8f8f2", + "editor.lineHighlightBackground": "#44475a60", + "editor.selectionBackground": "#8be9fd40", + "editor.inactiveSelectionBackground": "#8be9fd20", + "editorCursor.foreground": "#f8f8f2", + "editorWhitespace.foreground": "#44475a", + "editorIndentGuide.background": "#44475a", + "editorIndentGuide.activeBackground": "#6272a4", + "editor.selectionHighlightBackground": "#424450", + "editor.wordHighlightBackground": "#8be9fd20", + "editor.wordHighlightStrongBackground": "#50fa7b50", + "editorLineNumber.foreground": "#6272a4", + "editorLineNumber.activeForeground": "#f8f8f2", + "editorError.foreground": "#ff5555", + "editorWarning.foreground": "#ffb86c", + "editorInfo.foreground": "#8be9fd", + "editorHint.foreground": "#50fa7b", + "editorBracketMatch.background": "#44475a", + "editorBracketMatch.border": "#dd3f8f", + "editorHoverWidget.background": "#382A3D", + "editorHoverWidget.border": "#de3f8f", + "editorHoverWidget.foreground": "#f8f8f2", + "editorHoverWidget.statusBarBackground": "#382A3D", }, }; +/** + * Initializes the Lando theme. + * This function registers the Lando theme with Monaco Editor and sets the initial theme based on the current dark mode state. + * It also listens for theme changes and updates the Monaco Editor theme accordingly. + */ export function initTheme() { - debug.log('Registering Lando theme...'); - monaco.editor.defineTheme('lando', landoTheme); + debug.log("Registering Lando theme..."); + monaco.editor.defineTheme("lando", landoTheme); // Set initial Monaco theme based on current dark mode state - const isDark = document.documentElement.classList.contains('dark'); - debug.log('Initial theme:', isDark ? 'dark' : 'light'); + const isDark = document.documentElement.classList.contains("dark"); + debug.log("Initial theme:", isDark ? "dark" : "light"); // Listen for theme changes - window.addEventListener('themechange', (e) => { + window.addEventListener("themechange", (e) => { const isDark = e.detail.isDark; - debug.log('Theme changed to:', isDark ? 'dark' : 'light'); + debug.log("Theme changed to:", isDark ? "dark" : "light"); // Update Monaco Editor theme const editors = monaco.editor.getEditors(); - editors.forEach(editor => { + for (const editor of editors) { editor.updateOptions({ - theme: isDark ? 'lando' : 'vs', + theme: isDark ? "lando" : "vs", }); - }); + } }); } diff --git a/src/lib/utils.js b/src/lib/utils.js deleted file mode 100644 index 6f706bf..0000000 --- a/src/lib/utils.js +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx } from "clsx" -import { twMerge } from "tailwind-merge" - -export function cn(...inputs) { - return twMerge(clsx(inputs)) -} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..6a1bf4f --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,16 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +/** + * Concatenates multiple class names into a single string. + * + * This function takes advantage of both `clsx` and `tailwind-merge` to merge class names. + * It first uses `clsx` to merge the input class names, then passes the result to `twMerge` + * to ensure the classes are correctly merged according to Tailwind CSS's rules. + * + * @param inputs - The class names to be merged + * @returns The merged class names as a single string + */ +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)); +} diff --git a/src/main.jsx b/src/main.jsx index a3c174d..08153b1 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,42 +1,42 @@ -import React from 'react' -import ReactDOM from 'react-dom/client' -import App from './App' -import { initTheme } from './lib/theme' -import './index.css' -import './style.css' +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App"; +import { initTheme } from "./lib/theme"; +import "./index.css"; +import "./style.css"; // Initialize theme before rendering -initTheme() +initTheme(); function initApp() { // Find the editor container in the existing HTML - const editorContainer = document.querySelector('.editor-wrapper') + const editorContainer = document.querySelector(".editor-wrapper"); if (editorContainer) { // Keep the loader, just remove other content if any - const loader = editorContainer.querySelector('#editor-loader') + const loader = editorContainer.querySelector("#editor-loader"); // Create a new container for the React app that sits behind the loader - const appContainer = document.createElement('div') - appContainer.style.position = 'absolute' - appContainer.style.inset = '0' - editorContainer.appendChild(appContainer) + const appContainer = document.createElement("div"); + appContainer.style.position = "absolute"; + appContainer.style.inset = "0"; + editorContainer.appendChild(appContainer); // Mount React app ReactDOM.createRoot(appContainer).render( , - ) + ); // Keep the loader on top if (loader) { - editorContainer.appendChild(loader) + editorContainer.appendChild(loader); } } } // Initialize when the DOM is ready -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initApp) +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initApp); } else { - initApp() + initApp(); } diff --git a/src/schema.js b/src/schema.js deleted file mode 100644 index 11708b5..0000000 --- a/src/schema.js +++ /dev/null @@ -1,839 +0,0 @@ -import * as YAML from 'yaml'; -import Ajv from 'ajv'; -import { debug } from './debug'; -import { MarkerSeverity } from './constants'; -import * as monaco from 'monaco-editor'; - -const ajv = new Ajv({ - allErrors: true, - verbose: true, - allowUnionTypes: true, -}); - -let schemaDefinitions = null; -let compiledSchema = null; - -// Try to import local schema, but don't fail if it doesn't exist -let localSchema = null; -try { - if (import.meta.env.DEV) { - debug.log('Development mode detected, attempting to use local schema...'); - try { - const schema = await import('../landofile-spec.json'); - localSchema = JSON.stringify(schema.default); - debug.log('Local schema loaded successfully'); - } catch (importError) { - throw new Error(`Failed to import local schema: ${importError.message}`); - } - } -} catch (e) { - debug.warn('Failed to load local schema:', e); -} - -export async function loadSchema() { - try { - // If we already have a compiled schema, return it - if (compiledSchema) { - return compiledSchema; - } - - const isDev = import.meta.env.DEV; - let schema = null; - - if (isDev && localSchema) { - try { - schema = JSON.parse(localSchema); - debug.log('Local schema parsed successfully'); - } catch (localError) { - debug.warn('Failed to parse local schema:', localError); - } - } - - if (!schema) { - const schemaUrl = 'https://4lando.github.io/lando-spec/landofile-spec.json'; - debug.log('Fetching schema from:', schemaUrl); - - const response = await fetch(schemaUrl); - if (!response.ok) { - debug.error('Schema fetch failed:', { - status: response.status, - statusText: response.statusText, - url: response.url, - }); - return null; - } - - schema = await response.json(); - debug.log('Remote schema loaded:', schema); - } - - if (!schema || typeof schema !== 'object') { - debug.error('Invalid schema format:', schema); - return null; - } - - // Store schema definitions for hover support - schemaDefinitions = flattenSchema(schema); - debug.log('Schema definitions:', schemaDefinitions); - - // Remove $id to prevent Ajv from caching it - const schemaToCompile = { ...schema }; - delete schemaToCompile.$id; - - // Verify schema can be compiled - try { - ajv.compile(schemaToCompile); - compiledSchema = schema; // Store the original schema for completions - return schema; - } catch (compileError) { - debug.error('Schema compilation failed:', compileError); - return null; - } - } catch (error) { - debug.error('Failed to load schema:', { - name: error.name, - message: error.message, - stack: error.stack, - }); - return null; - } -} - -// Flatten schema for easier lookup -function flattenSchema(schema, prefix = '', result = {}, visited = new Set(), rootSchema = null) { - if (!schema || visited.has(schema)) { - return result; - } - visited.add(schema); - - // Store root schema on first call - if (!rootSchema) { - rootSchema = schema; - debug.log('Root schema initialized with keys:', Object.keys(rootSchema)); - } - - // Handle $ref resolution first - if (schema.$ref) { - debug.log('Resolving $ref:', schema.$ref); - const refPath = schema.$ref.replace('#/', '').split('/'); - let refSchema = rootSchema; - - for (const part of refPath) { - if (!refSchema[part]) { - debug.warn('Failed to resolve ref part:', part); - return result; - } - refSchema = refSchema[part]; - } - - // Merge properties from the referenced schema, but don't override existing ones - const merged = { - ...refSchema, - ...schema, - // Ensure description comes from the referenced schema unless explicitly set - description: schema.description || refSchema.description, - }; - Object.assign(schema, merged); - debug.log('Resolved and merged reference:', refPath.join('/'), merged); - } - - // Handle pattern properties - if (schema.patternProperties) { - debug.log('Processing pattern properties at prefix:', prefix); - Object.entries(schema.patternProperties).forEach(([pattern, value]) => { - const wildcardPath = prefix ? `${prefix}/*` : '*'; - debug.log('Processing pattern:', pattern, 'at path:', wildcardPath); - - // First resolve any references in the pattern property value - if (value.$ref) { - const refPath = value.$ref.replace('#/', '').split('/'); - let refSchema = rootSchema; - - for (const part of refPath) { - if (!refSchema[part]) { - debug.warn('Failed to resolve ref part:', part); - return; - } - refSchema = refSchema[part]; - } - - // Create a new object for the pattern property to avoid sharing references - result[wildcardPath] = { - description: refSchema.description || '', - type: refSchema.type || value.type || '', - pattern, - oneOf: refSchema.oneOf || value.oneOf || [], - additionalProperties: refSchema.additionalProperties || value.additionalProperties, - }; - } else { - // Handle non-ref pattern properties - result[wildcardPath] = { - description: value.description || '', - type: value.type || '', - pattern, - oneOf: value.oneOf || [], - additionalProperties: value.additionalProperties, - }; - } - - // Process the value schema (which might have its own properties or refs) - flattenSchema(value, wildcardPath, result, visited, rootSchema); - }); - } - - // Handle oneOf schemas - if (schema.oneOf) { - debug.log('Processing oneOf at prefix:', prefix); - schema.oneOf.forEach((subSchema, index) => { - const oneOfPath = `${prefix}#${index}`; - flattenSchema(subSchema, oneOfPath, result, visited, rootSchema); - }); - } - - // Handle regular properties - if (schema.properties) { - debug.log('Processing regular properties at prefix:', prefix); - Object.entries(schema.properties).forEach(([key, value]) => { - const path = prefix ? `${prefix}/${key}` : key; - result[path] = { - description: value.description || '', - type: value.type || '', - enum: value.enum || [], - examples: value.examples || [], - default: value.default, - oneOf: value.oneOf || [], - additionalProperties: value.additionalProperties, - }; - - // Continue flattening nested schemas - flattenSchema(value, path, result, visited, rootSchema); - }); - } else if (prefix && !schema.patternProperties) { - // Handle non-object schemas - debug.log('Adding non-object schema at prefix:', prefix); - result[prefix] = { - description: schema.description || '', - type: schema.type || '', - enum: schema.enum || [], - examples: schema.examples || [], - default: schema.default, - oneOf: schema.oneOf || [], - additionalProperties: schema.additionalProperties, - }; - } - - // Process $defs - if (schema.$defs) { - debug.log('Processing $defs'); - Object.entries(schema.$defs).forEach(([key, value]) => { - const defsPath = `$defs/${key}`; - debug.log('Processing $def:', defsPath); - flattenSchema(value, defsPath, result, visited, rootSchema); - }); - } - - return result; -} - -function formatExample(key, example) { - try { - // If example is an object or array, convert to YAML - if (typeof example === 'object' && example !== null) { - const obj = { [key]: example }; - return YAML.stringify(obj).trim(); - } - - // For primitive types, format as YAML key-value pair - return `${key}: ${YAML.stringify(example)}`; - } catch (error) { - debug.error('Error formatting example:', error); - return `${key}: ${JSON.stringify(example)}`; - } -} - -export function getHoverInfo(content, position) { - try { - // Find the key at the current position - const lines = content.split('\n'); - const line = lines[position.lineNumber - 1]; - const match = line.match(/^(\s*)(\w+):/); - - if (match) { - const [, indent, key] = match; - const level = indent.length / 2; - - // Build path to current key - const path = findPathAtPosition(content, position); - - debug.log('Looking up hover info for path:', path); - - // Try to find schema info using different path patterns - let info = null; - const possiblePaths = generatePossiblePaths(path); - - for (const schemaPath of possiblePaths) { - debug.log('Trying schema path:', schemaPath); - if (schemaDefinitions && schemaDefinitions[schemaPath]) { - info = schemaDefinitions[schemaPath]; - debug.log('Found schema info at path:', schemaPath); - break; - } - } - - if (info) { - let contents = []; - - // Description - if (info.description) { - contents.push({ value: info.description }); - } - - // Type information - if (info.type) { - contents.push({ value: `Type: ${info.type}` }); - } - - // Pattern (for pattern properties) - if (info.pattern) { - contents.push({ value: `Pattern: ${info.pattern}` }); - } - - // Enum values - if (info.enum && info.enum.length) { - contents.push({ value: `Allowed values: ${info.enum.join(', ')}` }); - } - - // Default value - if (info.default !== undefined) { - contents.push({ value: 'Default:' }); - contents.push({ - value: '```yaml\n' + formatExample(key, info.default) + '\n```' - }); - } - - // Examples - if (info.examples && info.examples.length) { - contents.push({ value: '\nExamples:' }); - info.examples.forEach(example => { - contents.push({ - value: '```yaml\n' + formatExample(key, example) + '\n```' - }); - }); - } - - // OneOf options - if (info.oneOf && info.oneOf.length) { - contents.push({ value: '\nPossible Formats:' }); - info.oneOf.forEach((option, index) => { - if (option.description) { - contents.push({ value: `${index + 1}. ${option.description}` }); - } - }); - } - - return { - contents, - range: { - startLineNumber: position.lineNumber, - startColumn: indent.length + 1, - endLineNumber: position.lineNumber, - endColumn: indent.length + key.length + 1 - } - }; - } - } - } catch (error) { - debug.error('Error getting hover info:', error); - } - return null; -} - -// Helper function to generate possible schema paths including wildcards -function generatePossiblePaths(path) { - const paths = []; - - // Start with the most specific full path - if (path.length > 0) { - paths.push(path.join('/')); - } - - // Then try wildcard variations, still maintaining specificity - if (path.length > 1) { - // For a path like ['services', 'node', 'type'], try: - // 1. services/*/type - // 2. services/node/* - // 3. services/* - for (let i = path.length - 1; i > 0; i--) { - const wildcardPath = [ - ...path.slice(0, i), - '*', - ...path.slice(i + 1), - ].join('/'); - paths.push(wildcardPath); - } - } - - // Add root level wildcard last (lowest priority) - paths.push('*'); - - debug.log('Generated possible paths:', paths); - return paths.filter(Boolean); // Remove any empty paths -} - -export function validateYaml(content, schema) { - try { - // Skip validation for empty content - if (!content.trim()) { - return []; - } - - debug.log('Parsing YAML content:', content); - let parsed = YAML.parse(content); - - // Convert undefined/null values after colons to empty objects - const lines = content.split('\n'); - lines.forEach((line, index) => { - const match = line.match(/^(\s*)(\w+):(\s*)$/); - if (match) { - // If we have a key with nothing after the colon - const [, indent, key] = match; - const path = findPathAtPosition(content, { lineNumber: index + 1 }); - - // Build the path to this property - let current = parsed = parsed || {}; - path.forEach((segment, i) => { - if (i === path.length - 1) { - // Last segment is our current key - current[segment] = current[segment] ?? {}; - } else { - current[segment] = current[segment] ?? {}; - current = current[segment]; - } - }); - - // Set empty object for the current key if it's undefined - if (path.length === 0) { - parsed[key] = parsed[key] ?? {}; - } else { - current[key] = current[key] ?? {}; - } - } - }); - - debug.log('Parsed YAML with empty objects:', parsed); - - // Validate against schema - const validate = ajv.compile(schema); - const valid = validate(parsed); - - if (!valid) { - debug.log('Schema validation errors:', validate.errors); - - // Group errors by location to prevent duplicates - const errorsByLocation = new Map(); - - validate.errors.forEach(error => { - const path = error.instancePath.split('/').filter(Boolean); - const location = findLocationInYaml(content, path); - const locationKey = `${location.line}:${location.column}`; - - // Only keep the first error for each location - if (!errorsByLocation.has(locationKey)) { - if (error.keyword === 'additionalProperties') { - // Find the location of the unexpected property - const path = error.instancePath.split('/').filter(Boolean); - const unexpectedProp = error.params.additionalProperty; - path.push(unexpectedProp); // Add the unexpected property to the path - const location = findLocationInYaml(content, path); - - errorsByLocation.set(locationKey, { - startLineNumber: location.line, - endLineNumber: location.line, - startColumn: location.column, - endColumn: location.column + unexpectedProp.length, - message: `Unexpected property "${unexpectedProp}"`, - severity: MarkerSeverity.Error, - source: 'JSON Schema' - }); - } else { - errorsByLocation.set(locationKey, { - startLineNumber: location.line, - endLineNumber: location.line, - startColumn: location.column, - endColumn: location.column + (location.length || 1), - message: error.instancePath ? `${error.instancePath.split('/').pop()} ${error.message} at ${error.instancePath}` : error.message, - severity: MarkerSeverity.Error, - source: 'JSON Schema' - }); - } - } - }); - - return Array.from(errorsByLocation.values()); - } - - return []; - } catch (error) { - debug.warn('YAML parsing error:', error); - - if (error instanceof YAML.YAMLParseError) { - const { message, pos, linePos } = error; - debug.log('Parse error details:', { message, pos, linePos }); - - return [{ - startLineNumber: linePos ? linePos[0].line : 1, - endLineNumber: linePos ? linePos[1].line : 1, - startColumn: linePos ? linePos[0].col : 1, - endColumn: linePos ? linePos[1].col : content.length, - message: message, - severity: MarkerSeverity.Error, - source: 'YAML Parser' - }]; - } - - return [{ - startLineNumber: 1, - startColumn: 1, - endLineNumber: 1, - endColumn: content.length, - message: error.message, - severity: MarkerSeverity.Error, - source: 'YAML Parser' - }]; - } -} - -function findLocationInYaml(content, path) { - const lines = content.split('\n'); - let currentLine = 0; - let currentPath = []; - let arrayIndex = 0; - let currentArrayPath = null; - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - - // Handle array items - const arrayMatch = line.match(/^(\s*)-/); - if (arrayMatch) { - const [, indent] = arrayMatch; - const level = indent.length / 2; - - // Reset array index when starting a new array at a different path - if (currentArrayPath !== level) { - arrayIndex = 0; - currentArrayPath = level; - } else { - arrayIndex++; - } - - // Check if this array index matches our path - const targetIndex = parseInt(path[level]); - if (!isNaN(targetIndex) && targetIndex === arrayIndex) { - return { - line: i + 1, - column: indent.length + 1, - length: line.length - indent.length - }; - } - } else { - // Reset array tracking when we're not in an array - currentArrayPath = null; - arrayIndex = 0; - - // Handle regular object properties - const match = line.match(/^(\s*)(\w+):/); - if (match) { - const [, indent, key] = match; - const level = indent.length / 2; - - currentPath = currentPath.slice(0, level); - currentPath[level] = key; - - if (pathsMatch(currentPath, path)) { - return { - line: i + 1, - column: indent.length + 1, - length: key.length - }; - } - } - } - } - - return { line: 1, column: 1, length: 1 }; -} - -function pathsMatch(currentPath, targetPath) { - return targetPath.every((segment, i) => currentPath[i] === segment); -} - -function findPathAtPosition(content, position) { - const lines = content.split('\n'); - let currentPath = []; - - for (let i = 0; i < position.lineNumber; i++) { - const line = lines[i]; - const match = line.match(/^(\s*)(\w+):/); - - if (match) { - const [, indent, key] = match; - const level = indent.length / 2; - - // Update current path based on indentation - currentPath = currentPath.slice(0, level); - currentPath[level] = key; - } - } - - return currentPath.filter(Boolean); -} - -function getCompletionItems(model, position, schema) { - try { - // Get the current path in the YAML document - const path = findPathAtPosition(model.getValue(), position); - debug.log('Getting completions for path:', path); - - // Get current line and word - const lineContent = model.getLineContent(position.lineNumber); - const word = model.getWordUntilPosition(position); - const range = { - startLineNumber: position.lineNumber, - endLineNumber: position.lineNumber, - startColumn: word.startColumn, - endColumn: word.endColumn, - }; - - // At root level - if (!lineContent.startsWith(' ')) { - return getRootCompletions(schema, range); - } - - // Get the schema section for the current path - const currentSchema = getSchemaAtPath(schema, path); - if (!currentSchema) { - debug.log('No schema found for path:', path); - return { suggestions: [] }; - } - - return getCompletionsForSchema(currentSchema, range); - } catch (error) { - debug.error('Error getting completion items:', error); - return { suggestions: [] }; - } -} - -function getRootCompletions(schema, range) { - if (!schema.properties) return { suggestions: [] }; - - const suggestions = Object.entries(schema.properties).map(([key, prop]) => ({ - label: key, - kind: monaco.languages.CompletionItemKind.Field, - documentation: { - value: formatPropertyDocs(prop), - isTrusted: true, - }, - insertText: createInsertText(key, prop), - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range: range, - sortText: key, - })); - - return { suggestions }; -} - -function getCompletionsForSchema(schema, range) { - const suggestions = []; - - // Collect all possible schemas (including from oneOf) - const schemas = [schema]; - if (schema.oneOf) { - schemas.push(...schema.oneOf); - } - - // Process each schema - schemas.forEach(currentSchema => { - // Handle enum values - if (currentSchema.enum) { - suggestions.push(...currentSchema.enum.map(value => ({ - label: String(value), - kind: monaco.languages.CompletionItemKind.Value, - documentation: 'Allowed value', - insertText: String(value), - range: range, - sortText: '1' + String(value), - }))); - } - - // Handle examples as value suggestions - if (currentSchema.examples) { - suggestions.push(...currentSchema.examples.map(example => ({ - label: String(example), - kind: monaco.languages.CompletionItemKind.Value, - documentation: 'Example value', - insertText: String(example), - range: range, - sortText: '2' + String(example), - }))); - } - - // Handle properties for objects - if (currentSchema.properties) { - suggestions.push(...Object.entries(currentSchema.properties).map(([key, prop]) => ({ - label: key, - kind: monaco.languages.CompletionItemKind.Field, - documentation: { - value: formatPropertyDocs(prop), - isTrusted: true, - }, - insertText: createInsertText(key, prop), - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range: range, - sortText: '3' + key, - }))); - } - - // Handle pattern properties - if (currentSchema.patternProperties) { - Object.entries(currentSchema.patternProperties).forEach(([pattern, prop]) => { - if (prop.examples) { - suggestions.push(...prop.examples.map(example => ({ - label: String(example), - kind: monaco.languages.CompletionItemKind.Field, - documentation: { - value: formatPropertyDocs(prop), - isTrusted: true, - }, - insertText: createInsertText(String(example), prop), - insertTextRules: monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet, - range: range, - sortText: '4' + String(example), - }))); - } - }); - } - - // Add default value suggestion - if (currentSchema.default !== undefined) { - suggestions.push({ - label: String(currentSchema.default), - kind: monaco.languages.CompletionItemKind.Value, - documentation: 'Default value', - insertText: String(currentSchema.default), - range: range, - sortText: '0' + String(currentSchema.default), - }); - } - }); - - // Remove duplicates - const seen = new Set(); - return { - suggestions: suggestions.filter(suggestion => { - const key = suggestion.label + suggestion.kind; - if (seen.has(key)) return false; - seen.add(key); - return true; - }), - }; -} - -function formatPropertyDocs(prop) { - const parts = []; - - if (prop.description) { - parts.push(prop.description); - } - - if (prop.type) { - parts.push(`**Type:** ${prop.type}`); - } - - if (prop.enum?.length) { - parts.push(`**Allowed values:**\n- ${prop.enum.join('\n- ')}`); - } - - if (prop.default !== undefined) { - parts.push(`**Default:** ${JSON.stringify(prop.default)}`); - } - - if (prop.examples?.length) { - parts.push(`**Examples:**\n\`\`\`yaml\n${prop.examples.map(ex => JSON.stringify(ex)).join('\n')}\n\`\`\``); - } - - return parts.join('\n\n'); -} - -function createInsertText(key, prop) { - if (prop.type === 'object') { - // No space after colon for objects - return `${key}:\n \${1}`; - } - - // Space after colon for non-objects - let insertText = `${key}: `; - if (prop.examples?.length) { - insertText += `\${1:${prop.examples[0]}}`; - } else if (prop.enum?.length) { - insertText += `\${1:${prop.enum[0]}}`; - } else if (prop.default !== undefined) { - insertText += `\${1:${prop.default}}`; - } else { - insertText += '${1}'; - } - - return insertText + '\n'; -} - -function getSchemaAtPath(schema, path) { - let current = schema; - - for (const segment of path) { - if (!current) return null; - - // Check properties first - if (current.properties?.[segment]) { - current = current.properties[segment]; - continue; - } - - // Check pattern properties - if (current.patternProperties) { - const patternMatch = Object.entries(current.patternProperties) - .find(([pattern]) => new RegExp(pattern).test(segment)); - if (patternMatch) { - current = patternMatch[1]; - continue; - } - } - - // Check if we have a $ref - if (current.$ref) { - const refSchema = resolveRef(current.$ref, schema); - if (refSchema) { - current = refSchema; - continue; - } - } - - return null; - } - - return current; -} - -function resolveRef($ref, rootSchema) { - const path = $ref.replace('#/', '').split('/'); - let current = rootSchema; - - for (const segment of path) { - if (!current[segment]) return null; - current = current[segment]; - } - - return current; -} - -// Export the new function -export { getCompletionItems, schemaDefinitions }; diff --git a/src/style.css b/src/style.css index 4f513a2..3fa23c1 100644 --- a/src/style.css +++ b/src/style.css @@ -5,22 +5,24 @@ } .dark { - --c-bg: #261D2D; - --c-bg-lighter: #382A3D; + --c-bg: #261d2d; + --c-bg-lighter: #382a3d; } /* Apply gradient background */ body { - background: radial-gradient(121.65% 71.43% at 91.1% 13.63%, - var(--c-bg-lighter) 0%, - var(--c-bg) 100%); + background: radial-gradient( + 121.65% 71.43% at 91.1% 13.63%, + var(--c-bg-lighter) 0%, + var(--c-bg) 100% + ); min-height: 100vh; } /* Update drag and drop styles to target the main container */ .monaco-editor-container.drag-over::after, .editor-container .monaco-editor-container:has(+ .drag-over)::after { - content: 'Drop .lando.yml or .lando.*.yml file here'; + content: "Drop .lando.yml or .lando.*.yml file here"; position: absolute; top: 0; left: 0; @@ -69,7 +71,8 @@ body { padding: 0.75rem 1.5rem; background-color: var(--c-bg-lighter); border-radius: 0.5rem; - box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px + rgba(0, 0, 0, 0.06); opacity: 0; transition: all 0.4s cubic-bezier(0.15, 1.15, 0.4, 1.2); border-color: #df4090; diff --git a/src/templates/example-landofile.yml b/src/templates/example-landofile.yml new file mode 100644 index 0000000..1adc69d --- /dev/null +++ b/src/templates/example-landofile.yml @@ -0,0 +1,16 @@ +name: my-lando-app +recipe: lamp +config: + php: '8.3' + webroot: . + database: mysql:8.0 + xdebug: false + +services: + node: + type: node:20 + build: + - npm install + command: vite --host 0.0.0.0 + port: 5173 + ssl: true diff --git a/tailwind.config.js b/tailwind.config.js index aa71134..e007c84 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,10 +1,7 @@ /** @type {import('tailwindcss').Config} */ module.exports = { darkMode: ["class"], - content: [ - "./index.html", - "./src/**/*.{ts,tsx,js,jsx}", - ], + content: ["./index.html", "./src/**/*.{ts,tsx,js,jsx}"], theme: { extend: { colors: { @@ -43,23 +40,21 @@ module.exports = { }, }, keyframes: { - 'toast-in': { - '0%': { transform: 'translateY(100%)' }, - '60%': { transform: 'translateY(-12px)' }, - '100%': { transform: 'translateY(0)' } + "toast-in": { + "0%": { transform: "translateY(100%)" }, + "60%": { transform: "translateY(-12px)" }, + "100%": { transform: "translateY(0)" }, + }, + "toast-out": { + "0%": { transform: "translateY(0)" }, + "100%": { transform: "translateY(100%)" }, }, - 'toast-out': { - '0%': { transform: 'translateY(0)' }, - '100%': { transform: 'translateY(100%)' } - } }, animation: { - 'toast-in': 'toast-in 0.5s cubic-bezier(0.15, 1.15, 0.4, 1.2) forwards', - 'toast-out': 'toast-out 0.4s ease-in-out forwards' - } + "toast-in": "toast-in 0.5s cubic-bezier(0.15, 1.15, 0.4, 1.2) forwards", + "toast-out": "toast-out 0.4s ease-in-out forwards", + }, }, }, - plugins: [ - require('@tailwindcss/typography'), - ], -} + plugins: [require("@tailwindcss/typography")], +}; diff --git a/tsconfig.app.json b/tsconfig.app.json index 3db291e..115315e 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -5,18 +5,9 @@ "composite": true, "baseUrl": ".", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } }, - "include": [ - "src/**/*.ts", - "src/**/*.tsx", - "src/**/*.js", - "src/**/*.jsx" - ], - "exclude": [ - "node_modules" - ] + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.js", "src/**/*.jsx"], + "exclude": ["node_modules"] } diff --git a/tsconfig.json b/tsconfig.json index 887ef51..da50051 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,11 +9,7 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": [ - "ES2020", - "DOM", - "DOM.Iterable" - ], + "lib": ["ES2020", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -25,9 +21,7 @@ "noUnusedParameters": true, "baseUrl": ".", "paths": { - "@/*": [ - "./src/*" - ] + "@/*": ["./src/*"] } } } diff --git a/vite.config.js b/vite.config.js index 7d4e14d..0372e65 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,6 +1,6 @@ -import { defineConfig } from 'vite'; -import path from 'node:path'; -import react from '@vitejs/plugin-react'; +import { defineConfig } from "vite"; +import path from "node:path"; +import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], @@ -8,18 +8,18 @@ export default defineConfig({ open: true, fs: { // Allow serving files from one level up to the src directory - allow: ['..'], + allow: [".."], }, }, resolve: { alias: { - '@': path.resolve(__dirname, 'src'), - 'monaco-editor': path.resolve(__dirname, 'node_modules/monaco-editor'), + "@": path.resolve(__dirname, "src"), + "monaco-editor": path.resolve(__dirname, "node_modules/monaco-editor"), }, preserveSymlinks: true, }, worker: { - format: 'es', + format: "es", plugins: [], rollupOptions: { output: { @@ -29,27 +29,27 @@ export default defineConfig({ }, optimizeDeps: { include: [ - 'monaco-editor/esm/vs/language/json/json.worker', - 'monaco-editor/esm/vs/editor/editor.worker', - 'react', - 'react-dom', + "monaco-editor/esm/vs/language/json/json.worker", + "monaco-editor/esm/vs/editor/editor.worker", + "react", + "react-dom", ], - exclude: ['monaco-editor'], + exclude: ["monaco-editor"], }, build: { - outDir: 'public', - assetsDir: 'assets', + outDir: "public", + assetsDir: "assets", sourcemap: true, // Enable source maps for main bundle rollupOptions: { output: { manualChunks: { - monaco: ['monaco-editor'], - editor: ['monaco-editor/esm/vs/editor/editor.worker'], - json: ['monaco-editor/esm/vs/language/json/json.worker'], - vendor: ['react', 'react-dom'], + monaco: ["monaco-editor"], + editor: ["monaco-editor/esm/vs/editor/editor.worker"], + json: ["monaco-editor/esm/vs/language/json/json.worker"], + vendor: ["react", "react-dom"], }, }, }, }, - publicDir: 'static', + publicDir: "static", });