A self-hosted orchestration system crafted for power users. composia.xyz
Something went wrong. Try again.
Svelte
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279<script lang="ts"> import { onMount, onDestroy } from "svelte";
import { defaultKeymap, indentWithTab } from "@codemirror/commands"; import { LanguageDescription } from "@codemirror/language"; import { languages } from "@codemirror/language-data"; import { lintGutter, linter } from "@codemirror/lint"; import { githubDark } from "@fsegurai/codemirror-theme-github-dark"; import { githubLight } from "@fsegurai/codemirror-theme-github-light"; import { EditorState, Compartment, type Extension } from "@codemirror/state"; import { EditorView, keymap, lineNumbers } from "@codemirror/view"; import { basicSetup } from "codemirror";
import { composeLinter, isComposeFilePath, } from "$lib/codemirror/compose-lint"; import { envLanguage } from "$lib/codemirror/env-language"; import { envLinter, isEnvFilePath } from "$lib/codemirror/env-lint"; import { composiaMetaLinter, isComposiaMetaFilePath, } from "$lib/codemirror/meta-lint"; import { observeThemeChange } from "$lib/theme-observer"; import { getMessages } from "$lib/i18n";
const messages = getMessages(); interface Props { value?: string; path?: string; relatedFiles?: Record<string, string>; readOnly?: boolean; onchange?: (event: { value: string }) => void; onsave?: () => void; }
let { value = $bindable(""), path = "", relatedFiles = {}, readOnly = false, onchange, onsave, }: Props = $props();
let host: HTMLDivElement; let editorView: EditorView | null = null; let disconnectThemeObserver: (() => void) | null = null; let languageLoadRequest = 0;
const languageCompartment = new Compartment(); const editableCompartment = new Compartment(); const lintCompartment = new Compartment(); const themeCompartment = new Compartment(); const accessibilityCompartment = new Compartment();
function editorBorderRadius(): string { return ( getComputedStyle(document.documentElement).getPropertyValue( "--radius-xl", ) || "0.75rem" ); }
const editorChromeTheme = EditorView.theme({ "&": { height: "100%", borderRadius: editorBorderRadius(), overflow: "hidden", }, "&.cm-focused": { outline: "none", }, ".cm-scroller": { overflow: "auto", }, ".cm-content": { minHeight: "100%", }, });
// CodeMirror may resolve duplicate @codemirror/view versions in the lockfile, // which makes otherwise-compatible key bindings fail TypeScript checks. const editorKeymap = [ indentWithTab, ...defaultKeymap, { key: "Mod-s", run: () => { onsave?.(); return true; }, }, ] as unknown as Parameters<typeof keymap.of>[0];
onMount(() => { const root = document.documentElement;
editorView = new EditorView({ state: EditorState.create({ doc: value, extensions: [ basicSetup, lineNumbers(), themeCompartment.of(resolveTheme(root)), editorChromeTheme, keymap.of(editorKeymap), languageCompartment.of([]), lintCompartment.of(lintExtension(path)), editableCompartment.of(EditorView.editable.of(!readOnly)), accessibilityCompartment.of( EditorView.contentAttributes.of({ "aria-label": $messages.common.codeEditor, }), ), EditorView.updateListener.of((update) => { if (update.docChanged) { onchange?.({ value: update.state.doc.toString() }); } }), ], }), parent: host, });
void syncLanguage(path);
disconnectThemeObserver = observeThemeChange(() => syncTheme(root));
return () => { disconnectThemeObserver?.(); disconnectThemeObserver = null; }; });
onDestroy(() => { languageLoadRequest += 1; disconnectThemeObserver?.(); disconnectThemeObserver = null; editorView?.destroy(); editorView = null; });
$effect(() => { if (editorView) { const currentValue = editorView.state.doc.toString(); if (currentValue !== value) { editorView.dispatch({ changes: { from: 0, to: currentValue.length, insert: value }, }); } } });
$effect(() => { if (editorView) { void syncLanguage(path); editorView.dispatch({ effects: [ lintCompartment.reconfigure(lintExtension(path)), editableCompartment.reconfigure(EditorView.editable.of(!readOnly)), ], }); } });
$effect(() => { if (editorView) { editorView.dispatch({ effects: accessibilityCompartment.reconfigure( EditorView.contentAttributes.of({ "aria-label": $messages.common.codeEditor, }), ), }); } });
function resolveTheme(root: HTMLElement) { return root.classList.contains("dark") ? githubDark : githubLight; }
function syncTheme(root: HTMLElement) { if (!editorView) { return; }
editorView.dispatch({ effects: themeCompartment.reconfigure(resolveTheme(root)), }); }
function syncLanguage(filePath: string) { if (!editorView) { return; }
const requestId = ++languageLoadRequest;
if (isEnvFilePath(filePath)) { applyLanguageExtension(requestId, envLanguage.extension); return; }
const description = languageDescriptionForPath(filePath);
if (!description) { applyLanguageExtension(requestId, []); return; }
if (description.support) { applyLanguageExtension(requestId, description.support.extension); return; }
void description .load() .then((support) => { applyLanguageExtension(requestId, support.extension); }) .catch((error) => { if (import.meta.env.DEV) { console.error( `Failed to load CodeMirror language support for ${filePath}.`, error, ); } applyLanguageExtension(requestId, []); }); }
function applyLanguageExtension(requestId: number, extension: Extension) { if (!editorView || requestId !== languageLoadRequest) { return; }
editorView.dispatch({ effects: languageCompartment.reconfigure(extension), }); }
function languageDescriptionForPath(filePath: string) { const fileName = filePath.split("/").pop() ?? filePath; if (!fileName) { return null; }
// CodeMirror packages can be duplicated by the lockfile, so normalize the // language-data type to the public LanguageDescription API used here. const languageDescriptions = languages as unknown as readonly LanguageDescription[];
return LanguageDescription.matchFilename(languageDescriptions, fileName); }
function lintExtension(filePath: string) { if (isComposeFilePath(filePath)) { return [lintGutter(), linter(composeLinter(filePath, relatedFiles))]; }
if (isEnvFilePath(filePath)) { return [lintGutter(), linter(envLinter())]; }
if (isComposiaMetaFilePath(filePath)) { return [lintGutter(), linter(composiaMetaLinter())]; }
return []; }</script>
<div bind:this={host} class="h-full min-h-0 overflow-hidden rounded-xl" role="region" aria-label={$messages.common.codeEditor}></div>