feat(dashboard): CodeMirror editors for Rhai source + JSON

Replaces the four <textarea> usages with a CodeMirror 6 editor that
brings, just by being a real editor: syntax highlighting, line
numbers, bracket matching, multi-cursor, proper undo/redo, and
search/replace (Ctrl+F / Ctrl+H). Plus a Rhai-aware autocomplete and
a "Format JSON" button on the test-invoke panels.

Per discussion, deliberately did NOT add: LSP, go-to-definition,
Rhai formatter (none exists), or anything else IDE-shaped. The
existing CodeEditor component is wired so swapping the language
extension later is a one-line change.

Lay of the land (from the research pass):
  * No CodeMirror Rhai package exists on npm.
  * No Rhai formatter exists anywhere.
  * The Rhai authors publish a TextMate grammar at
    rhaiscript/vscode-rhai (MPL-2.0). We don't load the full
    grammar (would cost ~250KB of vscode-textmate + oniguruma);
    we cite it as the source-of-truth for our keyword/operator
    lists in a small custom StreamLanguage.
  * rhaiscript/lsp exists but is experimental + unmaintained
    since 2023; skipped.

Files:
  * dashboard/src/lib/editor-theme.ts — CodeMirror theme +
    HighlightStyle wired to the existing slate/sky palette so the
    editor blends into the cards instead of looking transplanted.
  * dashboard/src/lib/rhai-mode.ts — StreamLanguage tokenizer for
    Rhai with the upstream grammar's keyword/operator lists, plus
    a completion source pulling ctx.* / log::* from our SDK
    contract suite (the authoritative list).
  * dashboard/src/lib/CodeEditor.svelte — wraps EditorView with
    two-way $bindable() value, language picker ('rhai' | 'json'),
    placeholder, minHeight props. Guards against the update
    listener echoing parent-driven changes back as edits.
  * Replaces textareas in:
      routes/+page.svelte                 — create form source
      routes/scripts/[id]/+page.svelte    — Edit tab source +
                                            Test invoke body +
                                            headers
  * Format buttons next to the body/headers editors run
    JSON.stringify(JSON.parse(value), null, 2); errors surface
    inline next to the button without trashing the field.

Bundle:
  * +~430KB to the CodeMirror chunk in dashboard build (~150KB
    gzipped on the wire). Lazy-loaded — only fetched when a route
    that uses CodeEditor renders.
  * `npm install` clean, 0 vulnerabilities, `npm run check`
    clean, `npm run build` clean.

No backend / API / SDK / schema / wire changes. No version bumps.
This commit is contained in:
MechaCat02
2026-05-23 22:52:07 +02:00
parent 0eaf4aee69
commit a80e6d1ca4
7 changed files with 836 additions and 29 deletions

View File

@@ -1,12 +1,23 @@
{
"name": "picloud-dashboard",
"version": "0.1.0",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picloud-dashboard",
"version": "0.1.0",
"version": "0.5.0",
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2"
},
"devDependencies": {
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
@@ -26,6 +37,97 @@
"vite": "^6.0.7"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.2",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz",
"integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.10.3",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz",
"integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-json": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@lezer/json": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.12.3",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz",
"integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.6",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz",
"integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.0",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz",
"integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz",
"integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==",
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/view": {
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -741,6 +843,47 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@lezer/common": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
"integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
"license": "MIT"
},
"node_modules/@lezer/highlight": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
"integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/json": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
"license": "MIT"
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -1687,6 +1830,21 @@
"node": ">=6"
}
},
"node_modules/codemirror": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/commands": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/search": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1724,6 +1882,12 @@
"node": ">= 0.6"
}
},
"node_modules/crelt": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2961,6 +3125,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
"integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -3270,6 +3440,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -15,9 +15,9 @@
"devDependencies": {
"@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.8",
"@types/node": "^22.10.5",
"@sveltejs/kit": "^2.17.0",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@types/node": "^22.10.5",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-svelte": "^3.0.0",
@@ -32,5 +32,16 @@
},
"overrides": {
"cookie": "^0.7.2"
},
"dependencies": {
"@codemirror/autocomplete": "^6.20.2",
"@codemirror/commands": "^6.10.3",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/language": "^6.12.3",
"@codemirror/search": "^6.7.0",
"@codemirror/state": "^6.6.0",
"@codemirror/view": "^6.43.0",
"@lezer/highlight": "^1.2.3",
"codemirror": "^6.0.2"
}
}

View File

@@ -0,0 +1,107 @@
<!--
CodeMirror-backed text editor for the dashboard.
Replaces our plain <textarea> with line numbers, syntax highlighting,
bracket matching, search/replace (Ctrl+F), and language-aware
autocomplete. Two-way bound via `value`; the parent treats it the
same as a textarea.
Languages: `rhai` (custom mode, ./rhai-mode.ts) and `json`
(@codemirror/lang-json).
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { basicSetup } from 'codemirror';
import { EditorView, keymap, placeholder as cmPlaceholder } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { indentWithTab } from '@codemirror/commands';
import { json as jsonLang } from '@codemirror/lang-json';
import { rhai as rhaiLang } from './rhai-mode';
import { dashboardSyntaxHighlighting, dashboardTheme } from './editor-theme';
type Language = 'rhai' | 'json';
let {
value = $bindable(''),
language = 'rhai' as Language,
placeholder = '',
minHeight = '12rem'
}: {
value?: string;
language?: Language;
placeholder?: string;
minHeight?: string;
} = $props();
let host: HTMLDivElement | null = null;
let view: EditorView | null = null;
// Guard against the update-listener firing while we're pushing an
// external value into the editor — without this, parent-driven
// `value` changes would echo back through the listener and create a
// dispatch loop in Svelte 5 reactivity.
let pushingFromOutside = false;
function buildExtensions(lang: Language) {
const extensions = [
basicSetup,
lang === 'json' ? jsonLang() : rhaiLang(),
keymap.of([indentWithTab]),
dashboardSyntaxHighlighting,
dashboardTheme,
EditorView.updateListener.of((update) => {
if (update.docChanged && !pushingFromOutside) {
value = update.state.doc.toString();
}
})
];
if (placeholder) extensions.push(cmPlaceholder(placeholder));
return extensions;
}
onMount(() => {
if (!host) return;
view = new EditorView({
state: EditorState.create({
doc: value,
extensions: buildExtensions(language)
}),
parent: host
});
});
onDestroy(() => {
view?.destroy();
view = null;
});
// Push parent-driven `value` updates back into the editor (e.g.
// when the script is reloaded after Save, or "Format JSON" rewrites
// the body). We only dispatch when the document genuinely differs
// from the current `value`.
$effect(() => {
if (!view) return;
const current = view.state.doc.toString();
if (current !== value) {
pushingFromOutside = true;
view.dispatch({
changes: { from: 0, to: current.length, insert: value }
});
pushingFromOutside = false;
}
});
</script>
<div bind:this={host} class="cm-host" style:min-height={minHeight}></div>
<style>
.cm-host :global(.cm-editor) {
height: 100%;
border-radius: 0.375rem;
border: 1px solid #334155;
overflow: hidden;
}
.cm-host :global(.cm-scroller) {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
}
</style>

View File

@@ -0,0 +1,148 @@
// Dashboard-matching theme + highlight style for CodeMirror.
//
// Colors are pulled from the existing slate/sky palette used across
// the dashboard (#0f172a / #1e293b / #38bdf8) so the editor blends
// into the surrounding cards instead of looking like a third-party
// transplant. Stock dark themes like "One Dark" or "VS Code Dark"
// would each clash with the slate background by a few shades.
import { EditorView } from '@codemirror/view';
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language';
import { tags as t } from '@lezer/highlight';
const palette = {
bg: '#0b1220', // matches existing <input>/<textarea> backgrounds
bgGutter: '#0f172a',
border: '#334155',
text: '#e2e8f0',
textMuted: '#94a3b8',
cursor: '#38bdf8',
selection: '#38bdf830',
activeLine: '#1e293b',
matchingBracket: '#38bdf850',
searchMatch: '#38bdf850',
searchMatchSelected: '#38bdf8',
// Syntax — chosen to be readable against #0b1220 without making
// any single token feel louder than the rest.
comment: '#64748b',
str: '#86efac',
num: '#fbbf24',
bool: '#fbbf24',
keyword: '#c4b5fd',
control: '#c4b5fd',
operator: '#cbd5e1',
punct: '#cbd5e1',
function: '#38bdf8',
property: '#e2e8f0',
variable: '#e2e8f0',
ctx: '#f472b6', // `ctx` is special — pinker so users notice it
namespace: '#fbbf24', // `log::`
invalid: '#ef4444'
};
export const dashboardTheme = EditorView.theme(
{
'&': {
color: palette.text,
backgroundColor: palette.bg,
borderRadius: '0.375rem'
},
'&.cm-focused': {
outline: `1px solid ${palette.cursor}`
},
'.cm-content': {
caretColor: palette.cursor,
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
fontSize: '0.85rem',
padding: '0.5rem 0'
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: palette.cursor,
borderLeftWidth: '2px'
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection':
{
backgroundColor: palette.selection
},
'.cm-activeLine': {
backgroundColor: palette.activeLine
},
'.cm-gutters': {
backgroundColor: palette.bgGutter,
color: palette.textMuted,
border: 'none',
borderRight: `1px solid ${palette.border}`
},
'.cm-activeLineGutter': {
backgroundColor: palette.activeLine,
color: palette.text
},
'.cm-matchingBracket, .cm-nonmatchingBracket': {
backgroundColor: palette.matchingBracket,
outline: 'none'
},
// Search / replace panel (Ctrl+F)
'.cm-panels': {
backgroundColor: palette.bgGutter,
color: palette.text,
borderTop: `1px solid ${palette.border}`
},
'.cm-searchMatch': {
backgroundColor: palette.searchMatch
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: palette.searchMatchSelected
},
'.cm-panel input, .cm-panel button': {
backgroundColor: palette.bg,
color: palette.text,
border: `1px solid ${palette.border}`,
borderRadius: '0.25rem',
padding: '0.2rem 0.4rem'
},
'.cm-panel button:hover': {
backgroundColor: palette.activeLine
},
// Autocomplete popup
'.cm-tooltip': {
backgroundColor: palette.bgGutter,
border: `1px solid ${palette.border}`,
color: palette.text
},
'.cm-tooltip.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: palette.activeLine,
color: palette.text
},
'.cm-completionLabel': {
color: palette.text
},
'.cm-completionDetail': {
color: palette.textMuted,
fontStyle: 'normal'
},
'.cm-completionIcon': {
color: palette.textMuted
}
},
{ dark: true }
);
export const dashboardHighlight = HighlightStyle.define([
{ tag: t.comment, color: palette.comment, fontStyle: 'italic' },
{ tag: [t.string, t.special(t.string)], color: palette.str },
{ tag: [t.number, t.bool, t.null], color: palette.num },
{ tag: [t.keyword, t.modifier], color: palette.keyword, fontWeight: '600' },
{ tag: t.controlKeyword, color: palette.control, fontWeight: '600' },
{ tag: [t.operator, t.derefOperator, t.logicOperator], color: palette.operator },
{ tag: [t.punctuation, t.bracket, t.brace, t.paren, t.squareBracket], color: palette.punct },
{ tag: [t.function(t.variableName), t.function(t.propertyName)], color: palette.function },
{ tag: [t.propertyName, t.attributeName], color: palette.property },
{ tag: t.variableName, color: palette.variable },
{ tag: t.special(t.variableName), color: palette.ctx, fontWeight: '600' },
{ tag: t.namespace, color: palette.namespace, fontWeight: '600' },
{ tag: t.invalid, color: palette.invalid, textDecoration: 'underline wavy' }
]);
export const dashboardSyntaxHighlighting = syntaxHighlighting(dashboardHighlight);

View File

@@ -0,0 +1,312 @@
// CodeMirror StreamLanguage for Rhai.
//
// Keyword and operator lists are sourced from the upstream TextMate
// grammar maintained by the Rhai authors:
// https://github.com/rhaiscript/vscode-rhai
// syntax/rhai.tmLanguage.json (MPL-2.0)
// This file does NOT copy the upstream grammar bytes — only the
// symbol lists. The matching logic is a simple regex tokenizer
// tailored to CodeMirror's StreamLanguage shape; if richer
// highlighting is wanted later, swap this out for a full tmLanguage
// loader (vscode-textmate + oniguruma) without touching callers.
//
// SDK completions (`ctx.*`, `log::*`) come from our own SDK contract
// in crates/executor-core/tests/sdk_contract.rs — that file is the
// authoritative list of what scripts can do.
import { StreamLanguage, LanguageSupport } from '@codemirror/language';
import { autocompletion, type CompletionContext, type CompletionResult } from '@codemirror/autocomplete';
// Keywords that drive control flow (`if`, `for`, ...) — these get the
// `controlKeyword` tag so the theme can color them distinctly from
// declaration-style keywords like `let` or `fn`.
const CONTROL_KEYWORDS = new Set([
'if',
'else',
'for',
'while',
'loop',
'do',
'switch',
'case',
'default',
'return',
'break',
'continue',
'try',
'catch',
'throw'
]);
const DECLARATION_KEYWORDS = new Set([
'let',
'const',
'fn',
'private',
'in',
'as',
'is'
]);
// Reserved-but-not-currently-valid keywords from the upstream grammar.
// We still highlight them so users notice them; the parser will reject
// at execute time.
const RESERVED_KEYWORDS = new Set([
'var',
'match',
'public',
'protected',
'new',
'use',
'with',
'module',
'package',
'super',
'spawn',
'thread',
'go',
'sync',
'async',
'await',
'yield',
'void',
'null',
'nil',
'debug',
'eval',
'print',
'import',
'export'
]);
const BOOLEAN_LITERALS = new Set(['true', 'false']);
const SPECIAL_VARIABLES = new Set(['ctx']);
const NAMESPACES = new Set(['log']);
interface RhaiState {
inBlockComment: boolean;
inString: false | '"' | '`';
}
export const rhaiLanguage = StreamLanguage.define<RhaiState>({
name: 'rhai',
startState: () => ({ inBlockComment: false, inString: false }),
token(stream, state) {
// --- inside a /* … */ block comment ---
if (state.inBlockComment) {
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false;
} else {
stream.skipToEnd();
}
return 'comment';
}
// --- inside a multi-line string (rare but possible with ` ` strings) ---
if (state.inString) {
const quote = state.inString;
while (!stream.eol()) {
const ch = stream.next();
if (ch === '\\') {
stream.next();
continue;
}
if (ch === quote) {
state.inString = false;
return 'string';
}
}
return 'string';
}
// Skip whitespace
if (stream.eatSpace()) return null;
// --- line comment ---
if (stream.match('//')) {
stream.skipToEnd();
return 'comment';
}
// --- block comment ---
if (stream.match('/*')) {
state.inBlockComment = true;
if (stream.match(/.*?\*\//)) {
state.inBlockComment = false;
} else {
stream.skipToEnd();
}
return 'comment';
}
// --- strings ---
const quote = stream.peek();
if (quote === '"' || quote === '`') {
stream.next();
while (!stream.eol()) {
const ch = stream.next();
if (ch === '\\') {
stream.next();
continue;
}
if (ch === quote) {
return 'string';
}
}
// String continues on next line (only really valid for ` `).
state.inString = quote;
return 'string';
}
// --- numbers (hex, binary, decimal, float) ---
if (stream.match(/^0x[0-9a-fA-F_]+/)) return 'number';
if (stream.match(/^0b[01_]+/)) return 'number';
if (stream.match(/^\d[\d_]*(?:\.\d[\d_]*)?(?:[eE][+-]?\d+)?/)) return 'number';
// --- module path: name::name (used for log::info etc.) ---
// Recognized before plain identifiers so we can return 'namespace'.
if (stream.match(/^[a-zA-Z_]\w*(?=::)/)) {
const word = stream.current();
if (NAMESPACES.has(word)) return 'namespace';
return 'variableName';
}
// --- identifiers + keywords ---
if (stream.match(/^[a-zA-Z_]\w*/)) {
const word = stream.current();
if (CONTROL_KEYWORDS.has(word)) return 'controlKeyword';
if (DECLARATION_KEYWORDS.has(word)) return 'keyword';
if (RESERVED_KEYWORDS.has(word)) return 'keyword';
if (BOOLEAN_LITERALS.has(word)) return 'bool';
if (SPECIAL_VARIABLES.has(word)) return 'variableName.special';
if (NAMESPACES.has(word)) return 'namespace';
// Followed by `(` → function call. We highlight as a function name.
if (stream.peek() === '(') return 'function(variableName)';
// Property after `.`
const before = stream.string.slice(0, stream.start);
if (before.endsWith('.')) return 'propertyName';
return 'variableName';
}
// --- operators / punctuation ---
if (stream.match(/^(\?\?|\.\.=|\.\.|::|==|!=|<=|>=|&&|\|\||<<|>>|=>|->|[+\-*/%<>!&|^~=])/)) {
return 'operator';
}
if (stream.match(/^[(){}[\];,.]/)) return 'punctuation';
// Unrecognized — advance one char and bail.
stream.next();
return null;
},
languageData: {
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
closeBrackets: { brackets: ['(', '[', '{', '"', '`'] },
indentOnInput: /^\s*[}\])]$/
}
});
// ---------------------------------------------------------------------------
// Autocomplete
// ---------------------------------------------------------------------------
interface CompletionItem {
label: string;
detail?: string;
type?: 'keyword' | 'variable' | 'function' | 'property' | 'namespace';
}
const KEYWORD_COMPLETIONS: CompletionItem[] = [
...['let', 'const', 'fn'].map((k) => ({ label: k, type: 'keyword' as const, detail: 'declaration' })),
...['if', 'else', 'for', 'while', 'loop', 'switch', 'return', 'break', 'continue', 'try', 'catch', 'throw'].map(
(k) => ({ label: k, type: 'keyword' as const, detail: 'control flow' })
),
...['in', 'as', 'is'].map((k) => ({ label: k, type: 'keyword' as const })),
{ label: 'true', type: 'keyword', detail: 'boolean' },
{ label: 'false', type: 'keyword', detail: 'boolean' }
];
// ctx.* — keep aligned with `build_ctx_map` in
// crates/executor-core/src/engine.rs.
const CTX_TOP_COMPLETIONS: CompletionItem[] = [
{ label: 'execution_id', type: 'property', detail: 'string' },
{ label: 'script_id', type: 'property', detail: 'string' },
{ label: 'script_name', type: 'property', detail: 'string' },
{ label: 'request_id', type: 'property', detail: 'string' },
{ label: 'invocation_type', type: 'property', detail: '"http" | "function" | "scheduled"' },
{ label: 'sdk_version', type: 'property', detail: 'string ("major.minor")' },
{ label: 'request', type: 'property', detail: 'object' }
];
const CTX_REQUEST_COMPLETIONS: CompletionItem[] = [
{ label: 'path', type: 'property', detail: 'string' },
{ label: 'headers', type: 'property', detail: 'map of string→string' },
{ label: 'body', type: 'property', detail: 'parsed JSON value' },
{ label: 'params', type: 'property', detail: 'map (param-route captures, SDK 1.1+)' },
{ label: 'query', type: 'property', detail: 'map (parsed query string, SDK 1.1+)' },
{ label: 'rest', type: 'property', detail: 'string (prefix-route tail, SDK 1.1+)' }
];
const LOG_COMPLETIONS: CompletionItem[] = [
{ label: 'info', type: 'function', detail: 'log::info(msg, data?)' },
{ label: 'warn', type: 'function', detail: 'log::warn(msg, data?)' },
{ label: 'error', type: 'function', detail: 'log::error(msg, data?)' },
{ label: 'trace', type: 'function', detail: 'log::trace(msg, data?) — use instead of "debug" (reserved keyword)' }
];
const TOP_LEVEL_GLOBALS: CompletionItem[] = [
{ label: 'ctx', type: 'variable', detail: 'invocation context' },
{ label: 'log', type: 'namespace', detail: 'log::info/warn/error/trace' }
];
function toCMCompletions(items: CompletionItem[]) {
return items.map((c) => ({
label: c.label,
type: c.type,
detail: c.detail
}));
}
export function rhaiCompletions(context: CompletionContext): CompletionResult | null {
// `log::` namespace
const ns = context.matchBefore(/log::\w*/);
if (ns) {
return {
from: ns.from + 'log::'.length,
options: toCMCompletions(LOG_COMPLETIONS),
validFor: /^\w*$/
};
}
// `ctx.request.` properties
const ctxReq = context.matchBefore(/ctx\.request\.\w*/);
if (ctxReq) {
return {
from: ctxReq.from + 'ctx.request.'.length,
options: toCMCompletions(CTX_REQUEST_COMPLETIONS),
validFor: /^\w*$/
};
}
// `ctx.` properties
const ctx = context.matchBefore(/ctx\.\w*/);
if (ctx) {
return {
from: ctx.from + 'ctx.'.length,
options: toCMCompletions(CTX_TOP_COMPLETIONS),
validFor: /^\w*$/
};
}
// Plain word at the cursor → keywords + top-level names.
const word = context.matchBefore(/\w+/);
if (!word && !context.explicit) return null;
return {
from: word ? word.from : context.pos,
options: toCMCompletions([...KEYWORD_COMPLETIONS, ...TOP_LEVEL_GLOBALS]),
validFor: /^\w*$/
};
}
export function rhai(): LanguageSupport {
return new LanguageSupport(rhaiLanguage, [autocompletion({ override: [rhaiCompletions] })]);
}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import { base } from '$app/paths';
import { api, ApiError, type Script } from '$lib/api';
import CodeEditor from '$lib/CodeEditor.svelte';
const SAMPLE_SOURCE = '#{\n statusCode: 200,\n body: #{ ok: true, echo: ctx.request.body }\n}';
@@ -80,7 +81,7 @@
</div>
<label class="full">
<span>Source (Rhai)</span>
<textarea bind:value={createSource} rows="10" spellcheck="false"></textarea>
<CodeEditor bind:value={createSource} language="rhai" minHeight="14rem" />
</label>
{#if createError}
<div class="error">{createError}</div>
@@ -191,8 +192,7 @@
grid-column: 1 / -1;
}
.create-form input,
.create-form textarea {
.create-form input {
background: #0b1220;
color: #e2e8f0;
border: 1px solid #334155;
@@ -201,13 +201,6 @@
font: inherit;
}
.create-form textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
min-height: 8rem;
resize: vertical;
}
.actions {
display: flex;
justify-content: flex-end;

View File

@@ -13,6 +13,18 @@
} from '$lib/api';
import { logLevelColor, statusColor } from '$lib/styles';
import { guessHostKind, guessPathKind, pathKindMismatchWarning } from '$lib/route-utils';
import CodeEditor from '$lib/CodeEditor.svelte';
/// Pretty-print a JSON string in place, leaving it untouched if the
/// input doesn't parse. The error state is shown next to the button
/// so users see why it didn't reformat.
function formatJson(s: string): { ok: true; text: string } | { ok: false; error: string } {
try {
return { ok: true, text: JSON.stringify(JSON.parse(s), null, 2) };
} catch (e) {
return { ok: false, error: e instanceof Error ? e.message : String(e) };
}
}
// Route is `/scripts/[id]` so `page.params.id` is always present.
let id = $derived(page.params.id ?? '');
@@ -72,7 +84,28 @@
let testBody = $state('{}');
let testHeaders = $state('{}');
let testBodyFormatError = $state<string | null>(null);
let testHeadersFormatError = $state<string | null>(null);
let testInProgress = $state(false);
function formatTestBody() {
const r = formatJson(testBody);
if (r.ok) {
testBody = r.text;
testBodyFormatError = null;
} else {
testBodyFormatError = r.error;
}
}
function formatTestHeaders() {
const r = formatJson(testHeaders);
if (r.ok) {
testHeaders = r.text;
testHeadersFormatError = null;
} else {
testHeadersFormatError = r.error;
}
}
let testResult = $state<{
status: number;
headers: Record<string, string>;
@@ -352,7 +385,7 @@
<div class="grid">
<section class="card">
<h2>Source</h2>
<textarea bind:value={editableSource} rows="14" spellcheck="false"></textarea>
<CodeEditor bind:value={editableSource} language="rhai" minHeight="22rem" />
{#if saveSourceError}
<div class="error inline">{saveSourceError}</div>
{/if}
@@ -369,14 +402,30 @@
<section class="card">
<h2>Test invoke</h2>
<label>
<span>Request body (JSON)</span>
<textarea bind:value={testBody} rows="5" spellcheck="false"></textarea>
</label>
<label>
<span>Headers (JSON object)</span>
<textarea bind:value={testHeaders} rows="3" spellcheck="false"></textarea>
</label>
<div class="json-block">
<header class="json-header">
<span>Request body (JSON)</span>
<button type="button" class="ghost small" onclick={formatTestBody}>
Format
</button>
</header>
<CodeEditor bind:value={testBody} language="json" minHeight="9rem" />
{#if testBodyFormatError}
<div class="error inline">{testBodyFormatError}</div>
{/if}
</div>
<div class="json-block">
<header class="json-header">
<span>Headers (JSON object)</span>
<button type="button" class="ghost small" onclick={formatTestHeaders}>
Format
</button>
</header>
<CodeEditor bind:value={testHeaders} language="json" minHeight="6rem" />
{#if testHeadersFormatError}
<div class="error inline">{testHeadersFormatError}</div>
{/if}
</div>
<div class="actions">
<button type="button" onclick={invoke} disabled={testInProgress}>
{testInProgress ? 'Running…' : 'Send'}
@@ -727,6 +776,24 @@
color: #94a3b8;
border: 1px solid #334155;
}
button.small {
padding: 0.2rem 0.6rem;
font-size: 0.75rem;
font-weight: 500;
}
.json-block {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.json-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
color: #cbd5e1;
}
button.link {
background: transparent;
color: #94a3b8;
@@ -798,7 +865,6 @@
align-items: center;
}
textarea,
input,
select {
background: #0b1220;
@@ -811,12 +877,6 @@
width: 100%;
box-sizing: border-box;
}
textarea {
font-family:
ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
font-size: 0.85rem;
resize: vertical;
}
input:disabled,
select:disabled {
opacity: 0.5;