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