Run oxfmt across repo, add format script and docs

Add .oxfmtignore to skip generated bindings and wasm-pack output.
Add npm format script, update DEVELOPMENT.md for Vite+ toolchain,
and format all non-generated files with oxfmt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-03-13 10:15:49 -07:00
parent 45262edfbd
commit b4a1c418bb
664 changed files with 13638 additions and 13492 deletions

View File

@@ -1,4 +1,4 @@
import { type DecorationSet, MatchDecorator, type ViewUpdate } from '@codemirror/view';
import { type DecorationSet, MatchDecorator, type ViewUpdate } from "@codemirror/view";
/**
* This is a custom MatchDecorator that will not decorate a match if the selection is inside it

View File

@@ -1,11 +1,11 @@
import { yaml } from '@codemirror/lang-yaml';
import { syntaxHighlighting } from '@codemirror/language';
import { MergeView } from '@codemirror/merge';
import { EditorView } from '@codemirror/view';
import classNames from 'classnames';
import { useEffect, useRef } from 'react';
import './DiffViewer.css';
import { readonlyExtensions, syntaxHighlightStyle } from './extensions';
import { yaml } from "@codemirror/lang-yaml";
import { syntaxHighlighting } from "@codemirror/language";
import { MergeView } from "@codemirror/merge";
import { EditorView } from "@codemirror/view";
import classNames from "classnames";
import { useEffect, useRef } from "react";
import "./DiffViewer.css";
import { readonlyExtensions, syntaxHighlightStyle } from "./extensions";
interface Props {
/** Original/previous version (left side) */
@@ -45,7 +45,7 @@ export function DiffViewer({ original, modified, className }: Props) {
collapseUnchanged: { margin: 2, minSize: 3 },
highlightChanges: false,
gutter: true,
orientation: 'a-b',
orientation: "a-b",
revertControls: undefined,
});
@@ -58,7 +58,7 @@ export function DiffViewer({ original, modified, className }: Props) {
return (
<div
ref={containerRef}
className={classNames('cm-wrapper cm-multiline h-full w-full', className)}
className={classNames("cm-wrapper cm-multiline h-full w-full", className)}
/>
);
}

View File

@@ -438,7 +438,7 @@
}
input.cm-textfield {
@apply cursor-text;
@apply cursor-text;
}
.cm-search label {

View File

@@ -1,21 +1,21 @@
import { startCompletion } from '@codemirror/autocomplete';
import { defaultKeymap, historyField, indentWithTab } from '@codemirror/commands';
import { foldState, forceParsing } from '@codemirror/language';
import type { EditorStateConfig, Extension } from '@codemirror/state';
import { Compartment, EditorState } from '@codemirror/state';
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from '@codemirror/view';
import { emacs } from '@replit/codemirror-emacs';
import { vim } from '@replit/codemirror-vim';
import { startCompletion } from "@codemirror/autocomplete";
import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands";
import { foldState, forceParsing } from "@codemirror/language";
import type { EditorStateConfig, Extension } from "@codemirror/state";
import { Compartment, EditorState } from "@codemirror/state";
import { EditorView, keymap, placeholder as placeholderExt, tooltips } from "@codemirror/view";
import { emacs } from "@replit/codemirror-emacs";
import { vim } from "@replit/codemirror-vim";
import { vscodeKeymap } from '@replit/codemirror-vscode-keymap';
import type { EditorKeymap } from '@yaakapp-internal/models';
import { settingsAtom } from '@yaakapp-internal/models';
import type { EditorLanguage, TemplateFunction } from '@yaakapp-internal/plugins';
import classNames from 'classnames';
import type { GraphQLSchema } from 'graphql';
import { useAtomValue } from 'jotai';
import { md5 } from 'js-md5';
import type { ReactNode, RefObject } from 'react';
import { vscodeKeymap } from "@replit/codemirror-vscode-keymap";
import type { EditorKeymap } from "@yaakapp-internal/models";
import { settingsAtom } from "@yaakapp-internal/models";
import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins";
import classNames from "classnames";
import type { GraphQLSchema } from "graphql";
import { useAtomValue } from "jotai";
import { md5 } from "js-md5";
import type { ReactNode, RefObject } from "react";
import {
Children,
cloneElement,
@@ -25,32 +25,32 @@ import {
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { activeEnvironmentAtom } from '../../../hooks/useActiveEnvironment';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { useEnvironmentVariables } from '../../../hooks/useEnvironmentVariables';
import { eventMatchesHotkey } from '../../../hooks/useHotKey';
import { useRequestEditor } from '../../../hooks/useRequestEditor';
import { useTemplateFunctionCompletionOptions } from '../../../hooks/useTemplateFunctions';
import { editEnvironment } from '../../../lib/editEnvironment';
import { tryFormatJson, tryFormatXml } from '../../../lib/formatters';
import { jotaiStore } from '../../../lib/jotai';
import { withEncryptionEnabled } from '../../../lib/setupOrConfigureEncryption';
import { TemplateFunctionDialog } from '../../TemplateFunctionDialog';
import { IconButton } from '../IconButton';
import { HStack } from '../Stacks';
import './Editor.css';
} from "react";
import { activeEnvironmentAtom } from "../../../hooks/useActiveEnvironment";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { useEnvironmentVariables } from "../../../hooks/useEnvironmentVariables";
import { eventMatchesHotkey } from "../../../hooks/useHotKey";
import { useRequestEditor } from "../../../hooks/useRequestEditor";
import { useTemplateFunctionCompletionOptions } from "../../../hooks/useTemplateFunctions";
import { editEnvironment } from "../../../lib/editEnvironment";
import { tryFormatJson, tryFormatXml } from "../../../lib/formatters";
import { jotaiStore } from "../../../lib/jotai";
import { withEncryptionEnabled } from "../../../lib/setupOrConfigureEncryption";
import { TemplateFunctionDialog } from "../../TemplateFunctionDialog";
import { IconButton } from "../IconButton";
import { HStack } from "../Stacks";
import "./Editor.css";
import {
baseExtensions,
getLanguageExtension,
multiLineExtensions,
readonlyExtensions,
} from './extensions';
import type { GenericCompletionConfig } from './genericCompletion';
import { singleLineExtensions } from './singleLine';
} from "./extensions";
import type { GenericCompletionConfig } from "./genericCompletion";
import { singleLineExtensions } from "./singleLine";
// VSCode's Tab actions mess with the single-line editor tab actions, so remove it.
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== 'Tab');
const vsCodeWithoutTab = vscodeKeymap.filter((k) => k.key !== "Tab");
const keymapExtensions: Record<EditorKeymap, Extension> = {
vim: vim(),
@@ -74,10 +74,10 @@ export interface EditorProps {
forcedEnvironmentId?: string;
forceUpdateKey?: string | number;
format?: (v: string) => Promise<string>;
heightMode?: 'auto' | 'full';
heightMode?: "auto" | "full";
hideGutter?: boolean;
id?: string;
language?: EditorLanguage | 'pairs' | 'url' | 'timeline' | null;
language?: EditorLanguage | "pairs" | "url" | "timeline" | null;
lintExtension?: Extension;
graphQLSchema?: GraphQLSchema | null;
onBlur?: () => void;
@@ -92,7 +92,7 @@ export interface EditorProps {
containerOnly?: boolean;
stateKey: string | null;
tooltipContainer?: HTMLElement;
type?: 'text' | 'password';
type?: "text" | "password";
wrapLines?: boolean;
setRef?: (view: EditorView | null) => void;
}
@@ -147,7 +147,7 @@ function EditorInner({
const useTemplating = !!(autocompleteFunctions || autocompleteVariables || autocomplete);
const environmentVariables = useMemo(() => {
if (!autocompleteVariables) return emptyVariables;
return typeof autocompleteVariables === 'function'
return typeof autocompleteVariables === "function"
? allEnvironmentVariables.filter(autocompleteVariables)
: allEnvironmentVariables;
}, [allEnvironmentVariables, autocompleteVariables]);
@@ -163,18 +163,18 @@ function EditorInner({
if (
singleLine ||
language == null ||
language === 'text' ||
language === 'url' ||
language === 'pairs'
language === "text" ||
language === "url" ||
language === "pairs"
) {
disableTabIndent = true;
}
if (format == null && !readOnly) {
format =
language === 'json'
language === "json"
? tryFormatJson
: language === 'xml' || language === 'html'
: language === "xml" || language === "html"
? tryFormatXml
: undefined;
}
@@ -182,37 +182,37 @@ function EditorInner({
const cm = useRef<{ view: EditorView; languageCompartment: Compartment } | null>(null);
// Use ref so we can update the handler without re-initializing the editor
const handleChange = useRef<EditorProps['onChange']>(onChange);
const handleChange = useRef<EditorProps["onChange"]>(onChange);
useEffect(() => {
handleChange.current = onChange;
}, [onChange]);
// Use ref so we can update the handler without re-initializing the editor
const handlePaste = useRef<EditorProps['onPaste']>(onPaste);
const handlePaste = useRef<EditorProps["onPaste"]>(onPaste);
useEffect(() => {
handlePaste.current = onPaste;
}, [onPaste]);
// Use ref so we can update the handler without re-initializing the editor
const handlePasteOverwrite = useRef<EditorProps['onPasteOverwrite']>(onPasteOverwrite);
const handlePasteOverwrite = useRef<EditorProps["onPasteOverwrite"]>(onPasteOverwrite);
useEffect(() => {
handlePasteOverwrite.current = onPasteOverwrite;
}, [onPasteOverwrite]);
// Use ref so we can update the handler without re-initializing the editor
const handleFocus = useRef<EditorProps['onFocus']>(onFocus);
const handleFocus = useRef<EditorProps["onFocus"]>(onFocus);
useEffect(() => {
handleFocus.current = onFocus;
}, [onFocus]);
// Use ref so we can update the handler without re-initializing the editor
const handleBlur = useRef<EditorProps['onBlur']>(onBlur);
const handleBlur = useRef<EditorProps["onBlur"]>(onBlur);
useEffect(() => {
handleBlur.current = onBlur;
}, [onBlur]);
// Use ref so we can update the handler without re-initializing the editor
const handleKeyDown = useRef<EditorProps['onKeyDown']>(onKeyDown);
const handleKeyDown = useRef<EditorProps["onKeyDown"]>(onKeyDown);
useEffect(() => {
handleKeyDown.current = onKeyDown;
}, [onKeyDown]);
@@ -236,10 +236,10 @@ function EditorInner({
if (cm.current === null) return;
const current = keymapCompartment.current.get(cm.current.view.state) ?? [];
// PERF: This is expensive with hundreds of editors on screen, so only do it when necessary
if (settings.editorKeymap === 'default' && current === keymapExtensions.default) return; // Nothing to do
if (settings.editorKeymap === 'vim' && current === keymapExtensions.vim) return; // Nothing to do
if (settings.editorKeymap === 'vscode' && current === keymapExtensions.vscode) return; // Nothing to do
if (settings.editorKeymap === 'emacs' && current === keymapExtensions.emacs) return; // Nothing to do
if (settings.editorKeymap === "default" && current === keymapExtensions.default) return; // Nothing to do
if (settings.editorKeymap === "vim" && current === keymapExtensions.vim) return; // Nothing to do
if (settings.editorKeymap === "vscode" && current === keymapExtensions.vscode) return; // Nothing to do
if (settings.editorKeymap === "emacs" && current === keymapExtensions.emacs) return; // Nothing to do
const ext = keymapExtensions[settings.editorKeymap] ?? keymapExtensions.default;
const effects = keymapCompartment.current.reconfigure(ext);
@@ -289,7 +289,7 @@ function EditorInner({
TemplateFunctionDialog.show(fn, tagValue, startPos, cm.current.view);
};
if (fn.name === 'secure') {
if (fn.name === "secure") {
withEncryptionEnabled(show);
} else {
show();
@@ -309,7 +309,7 @@ function EditorInner({
const onClickMissingVariable = useCallback(async (name: string) => {
const activeEnvironment = jotaiStore.get(activeEnvironmentAtom);
await editEnvironment(activeEnvironment, {
addOrFocusVariable: { name, value: '', enabled: true },
addOrFocusVariable: { name, value: "", enabled: true },
});
}, []);
@@ -414,9 +414,9 @@ function EditorInner({
: []),
];
const cachedJsonState = getCachedEditorState(defaultValue ?? '', stateKey);
const cachedJsonState = getCachedEditorState(defaultValue ?? "", stateKey);
const doc = `${defaultValue ?? ''}`;
const doc = `${defaultValue ?? ""}`;
const config: EditorStateConfig = { extensions, doc };
const state = cachedJsonState
@@ -439,7 +439,7 @@ function EditorInner({
}
setRef?.(view);
} catch (e) {
console.log('Failed to initialize Codemirror', e);
console.log("Failed to initialize Codemirror", e);
}
},
[forceUpdateKey],
@@ -449,7 +449,7 @@ function EditorInner({
useEffect(
function updateReadOnlyEditor() {
if (readOnly && cm.current?.view != null) {
updateContents(cm.current.view, defaultValue || '');
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue, readOnly],
@@ -460,7 +460,7 @@ function EditorInner({
function updateNonFocusedEditor() {
const notFocused = !cm.current?.view.hasFocus;
if (notFocused && cm.current != null) {
updateContents(cm.current.view, defaultValue || '');
updateContents(cm.current.view, defaultValue || "");
}
},
[defaultValue],
@@ -470,7 +470,7 @@ function EditorInner({
const decoratedActions = useMemo(() => {
const results = [];
const actionClassName = classNames(
'bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow',
"bg-surface transition-opacity transform-gpu opacity-0 group-hover:opacity-100 hover:!opacity-100 shadow",
);
if (format) {
@@ -517,12 +517,12 @@ function EditorInner({
ref={initEditorRef}
className={classNames(
className,
'cm-wrapper text-base',
disabled && 'opacity-disabled',
type === 'password' && 'cm-obscure-text',
heightMode === 'auto' ? 'cm-auto-height' : 'cm-full-height',
singleLine ? 'cm-singleline' : 'cm-multiline',
readOnly && 'cm-readonly',
"cm-wrapper text-base",
disabled && "opacity-disabled",
type === "password" && "cm-obscure-text",
heightMode === "auto" ? "cm-auto-height" : "cm-full-height",
singleLine ? "cm-singleline" : "cm-multiline",
readOnly && "cm-readonly",
)}
/>
);
@@ -539,8 +539,8 @@ function EditorInner({
space={1}
justifyContent="end"
className={classNames(
'absolute bottom-2 left-0 right-0',
'pointer-events-none', // No pointer events, so we don't block the editor
"absolute bottom-2 left-0 right-0",
"pointer-events-none", // No pointer events, so we don't block the editor
)}
>
{decoratedActions}
@@ -562,20 +562,20 @@ function getExtensions({
onFocus,
onBlur,
onKeyDown,
}: Pick<EditorProps, 'singleLine' | 'readOnly' | 'hideGutter'> & {
stateKey: EditorProps['stateKey'];
}: Pick<EditorProps, "singleLine" | "readOnly" | "hideGutter"> & {
stateKey: EditorProps["stateKey"];
container: HTMLDivElement | null;
onChange: RefObject<EditorProps['onChange']>;
onPaste: RefObject<EditorProps['onPaste']>;
onPasteOverwrite: RefObject<EditorProps['onPasteOverwrite']>;
onFocus: RefObject<EditorProps['onFocus']>;
onBlur: RefObject<EditorProps['onBlur']>;
onKeyDown: RefObject<EditorProps['onKeyDown']>;
onChange: RefObject<EditorProps["onChange"]>;
onPaste: RefObject<EditorProps["onPaste"]>;
onPasteOverwrite: RefObject<EditorProps["onPasteOverwrite"]>;
onFocus: RefObject<EditorProps["onFocus"]>;
onBlur: RefObject<EditorProps["onBlur"]>;
onKeyDown: RefObject<EditorProps["onKeyDown"]>;
}) {
// TODO: Ensure tooltips render inside the dialog if we are in one.
const parent =
container?.closest<HTMLDivElement>('[role="dialog"]') ??
document.querySelector<HTMLDivElement>('#cm-portal') ??
document.querySelector<HTMLDivElement>("#cm-portal") ??
undefined;
return [
@@ -589,7 +589,7 @@ function getExtensions({
},
keydown: (e, view) => {
// Check if the hotkey matches the editor.autocomplete action
if (eventMatchesHotkey(e, 'editor.autocomplete')) {
if (eventMatchesHotkey(e, "editor.autocomplete")) {
e.preventDefault();
startCompletion(view);
return true;
@@ -597,7 +597,7 @@ function getExtensions({
onKeyDown.current?.(e);
},
paste: (e, v) => {
const textData = e.clipboardData?.getData('text/plain') ?? '';
const textData = e.clipboardData?.getData("text/plain") ?? "";
onPaste.current?.(textData);
if (v.state.selection.main.from === 0 && v.state.selection.main.to === v.state.doc.length) {
onPasteOverwrite.current?.(e, textData);
@@ -605,7 +605,7 @@ function getExtensions({
},
}),
tooltips({ parent }),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== 'Enter') : defaultKeymap),
keymap.of(singleLine ? defaultKeymap.filter((k) => k.key !== "Enter") : defaultKeymap),
...(singleLine ? [singleLineExtensions()] : []),
...(!singleLine ? multiLineExtensions({ hideGutter }) : []),
...(readOnly ? readonlyExtensions : []),
@@ -627,10 +627,10 @@ function getExtensions({
}
const placeholderElFromText = (text: string | undefined) => {
const el = document.createElement('div');
const el = document.createElement("div");
// Default to <SPACE> because codemirror needs it for sizing. I'm not sure why, but probably something
// to do with how Yaak "hacks" it with CSS for single line input.
el.innerHTML = text ? text.replaceAll('\n', '<br/>') : ' ';
el.innerHTML = text ? text.replaceAll("\n", "<br/>") : " ";
return el;
};
@@ -646,7 +646,7 @@ function saveCachedEditorState(stateKey: string | null, state: EditorState | nul
try {
sessionStorage.setItem(computeFullStateKey(stateKey), JSON.stringify(stateObj));
} catch (err) {
console.log('Failed to save to editor state', stateKey, err);
console.log("Failed to save to editor state", stateKey, err);
}
}
@@ -667,7 +667,7 @@ function getCachedEditorState(doc: string, stateKey: string | null) {
state.doc = doc;
return state;
} catch (err) {
console.log('Failed to restore editor storage', stateKey, err);
console.log("Failed to restore editor storage", stateKey, err);
}
return null;

View File

@@ -1,7 +1,7 @@
import { lazy, Suspense } from 'react';
import type { EditorProps } from './Editor';
import { lazy, Suspense } from "react";
import type { EditorProps } from "./Editor";
const Editor_ = lazy(() => import('./Editor').then((m) => ({ default: m.Editor })));
const Editor_ = lazy(() => import("./Editor").then((m) => ({ default: m.Editor })));
export function Editor(props: EditorProps) {
return (

View File

@@ -3,15 +3,15 @@ import {
closeBrackets,
closeBracketsKeymap,
completionKeymap,
} from '@codemirror/autocomplete';
import { history, historyKeymap } from '@codemirror/commands';
import { go } from '@codemirror/lang-go';
import { java } from '@codemirror/lang-java';
import { javascript } from '@codemirror/lang-javascript';
import { markdown } from '@codemirror/lang-markdown';
import { php } from '@codemirror/lang-php';
import { python } from '@codemirror/lang-python';
import { xml } from '@codemirror/lang-xml';
} from "@codemirror/autocomplete";
import { history, historyKeymap } from "@codemirror/commands";
import { go } from "@codemirror/lang-go";
import { java } from "@codemirror/lang-java";
import { javascript } from "@codemirror/lang-javascript";
import { markdown } from "@codemirror/lang-markdown";
import { php } from "@codemirror/lang-php";
import { python } from "@codemirror/lang-python";
import { xml } from "@codemirror/lang-xml";
import {
bracketMatching,
codeFolding,
@@ -22,20 +22,20 @@ import {
LanguageSupport,
StreamLanguage,
syntaxHighlighting,
} from '@codemirror/language';
import { c, csharp, kotlin, objectiveC } from '@codemirror/legacy-modes/mode/clike';
import { clojure } from '@codemirror/legacy-modes/mode/clojure';
import { http } from '@codemirror/legacy-modes/mode/http';
import { oCaml } from '@codemirror/legacy-modes/mode/mllike';
import { powerShell } from '@codemirror/legacy-modes/mode/powershell';
import { r } from '@codemirror/legacy-modes/mode/r';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { swift } from '@codemirror/legacy-modes/mode/swift';
import { linter, lintGutter, lintKeymap } from '@codemirror/lint';
import { search, searchKeymap } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { EditorState } from '@codemirror/state';
} from "@codemirror/language";
import { c, csharp, kotlin, objectiveC } from "@codemirror/legacy-modes/mode/clike";
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
import { http } from "@codemirror/legacy-modes/mode/http";
import { oCaml } from "@codemirror/legacy-modes/mode/mllike";
import { powerShell } from "@codemirror/legacy-modes/mode/powershell";
import { r } from "@codemirror/legacy-modes/mode/r";
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
import { shell } from "@codemirror/legacy-modes/mode/shell";
import { swift } from "@codemirror/legacy-modes/mode/swift";
import { linter, lintGutter, lintKeymap } from "@codemirror/lint";
import { search, searchKeymap } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { EditorState } from "@codemirror/state";
import {
crosshairCursor,
drawSelection,
@@ -46,51 +46,51 @@ import {
keymap,
lineNumbers,
rectangularSelection,
} from '@codemirror/view';
import { tags as t } from '@lezer/highlight';
import { jsonc, jsoncLanguage } from '@shopify/lang-jsonc';
import { graphql } from 'cm6-graphql';
import type { GraphQLSchema } from 'graphql';
import { activeRequestIdAtom } from '../../../hooks/useActiveRequestId';
import type { WrappedEnvironmentVariable } from '../../../hooks/useEnvironmentVariables';
import { jotaiStore } from '../../../lib/jotai';
import { renderMarkdown } from '../../../lib/markdown';
import { pluralizeCount } from '../../../lib/pluralize';
import { showGraphQLDocExplorerAtom } from '../../graphql/graphqlAtoms';
import type { EditorProps } from './Editor';
import { jsonParseLinter } from './json-lint';
import { pairs } from './pairs/extension';
import { searchMatchCount } from './searchMatchCount';
import { text } from './text/extension';
import { timeline } from './timeline/extension';
import type { TwigCompletionOption } from './twig/completion';
import { twig } from './twig/extension';
import { pathParametersPlugin } from './twig/pathParameters';
import { url } from './url/extension';
} from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
import { jsonc, jsoncLanguage } from "@shopify/lang-jsonc";
import { graphql } from "cm6-graphql";
import type { GraphQLSchema } from "graphql";
import { activeRequestIdAtom } from "../../../hooks/useActiveRequestId";
import type { WrappedEnvironmentVariable } from "../../../hooks/useEnvironmentVariables";
import { jotaiStore } from "../../../lib/jotai";
import { renderMarkdown } from "../../../lib/markdown";
import { pluralizeCount } from "../../../lib/pluralize";
import { showGraphQLDocExplorerAtom } from "../../graphql/graphqlAtoms";
import type { EditorProps } from "./Editor";
import { jsonParseLinter } from "./json-lint";
import { pairs } from "./pairs/extension";
import { searchMatchCount } from "./searchMatchCount";
import { text } from "./text/extension";
import { timeline } from "./timeline/extension";
import type { TwigCompletionOption } from "./twig/completion";
import { twig } from "./twig/extension";
import { pathParametersPlugin } from "./twig/pathParameters";
import { url } from "./url/extension";
export const syntaxHighlightStyle = HighlightStyle.define([
{
tag: [t.documentMeta, t.blockComment, t.lineComment, t.docComment, t.comment],
color: 'var(--textSubtlest)',
color: "var(--textSubtlest)",
},
{
tag: [t.emphasis],
textDecoration: 'underline',
textDecoration: "underline",
},
{
tag: [t.angleBracket, t.paren, t.bracket, t.squareBracket, t.brace, t.separator, t.punctuation],
color: 'var(--textSubtle)',
color: "var(--textSubtle)",
},
{
tag: [t.link, t.name, t.tagName, t.angleBracket, t.docString, t.number],
color: 'var(--info)',
color: "var(--info)",
},
{ tag: [t.variableName], color: 'var(--success)' },
{ tag: [t.bool], color: 'var(--warning)' },
{ tag: [t.attributeName, t.propertyName], color: 'var(--primary)' },
{ tag: [t.attributeValue], color: 'var(--warning)' },
{ tag: [t.string], color: 'var(--notice)' },
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: 'var(--danger)' },
{ tag: [t.variableName], color: "var(--success)" },
{ tag: [t.bool], color: "var(--warning)" },
{ tag: [t.attributeName, t.propertyName], color: "var(--primary)" },
{ tag: [t.attributeValue], color: "var(--warning)" },
{ tag: [t.string], color: "var(--notice)" },
{ tag: [t.atom, t.meta, t.operator, t.bool, t.null, t.keyword], color: "var(--danger)" },
]);
const syntaxTheme = EditorView.theme({}, { dark: true });
@@ -102,7 +102,7 @@ const legacyLang = (mode: Parameters<typeof StreamLanguage.define>[0]) => {
};
const syntaxExtensions: Record<
NonNullable<EditorProps['language']>,
NonNullable<EditorProps["language"]>,
null | (() => LanguageSupport)
> = {
graphql: null,
@@ -134,11 +134,11 @@ const syntaxExtensions: Record<
swift: legacyLang(swift),
};
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ['json', 'javascript', 'graphql'];
const closeBracketsFor: (keyof typeof syntaxExtensions)[] = ["json", "javascript", "graphql"];
export function getLanguageExtension({
useTemplating,
language = 'text',
language = "text",
lintExtension,
environmentVariables,
autocomplete,
@@ -156,10 +156,10 @@ export function getLanguageExtension({
onClickPathParameter: (name: string) => void;
completionOptions: TwigCompletionOption[];
graphQLSchema: GraphQLSchema | null;
} & Pick<EditorProps, 'language' | 'autocomplete' | 'hideGutter' | 'lintExtension'>) {
} & Pick<EditorProps, "language" | "autocomplete" | "hideGutter" | "lintExtension">) {
const extraExtensions: Extension[] = [];
if (language === 'url') {
if (language === "url") {
extraExtensions.push(pathParametersPlugin(onClickPathParameter));
}
@@ -169,13 +169,13 @@ export function getLanguageExtension({
}
// GraphQL is a special exception
if (language === 'graphql') {
if (language === "graphql") {
return [
graphql(graphQLSchema ?? undefined, {
async onCompletionInfoRender(gqlCompletionItem): Promise<Node | null> {
if (!gqlCompletionItem.documentation) return null;
const innerHTML = await renderMarkdown(gqlCompletionItem.documentation);
const span = document.createElement('span');
const span = document.createElement("span");
span.innerHTML = innerHTML;
return span;
},
@@ -192,11 +192,11 @@ export function getLanguageExtension({
];
}
if (language === 'json') {
if (language === "json") {
extraExtensions.push(lintExtension ?? linter(jsonParseLinter()));
extraExtensions.push(
jsoncLanguage.data.of({
commentTokens: { line: '//', block: { open: '/*', close: '*/' } },
commentTokens: { line: "//", block: { open: "/*", close: "*/" } },
}),
);
if (!hideGutter) {
@@ -205,7 +205,7 @@ export function getLanguageExtension({
}
const maybeBase = language ? syntaxExtensions[language] : null;
const base = typeof maybeBase === 'function' ? maybeBase() : null;
const base = typeof maybeBase === "function" ? maybeBase() : null;
if (base == null) {
return [];
}
@@ -229,10 +229,10 @@ export function getLanguageExtension({
// Filter out autocomplete start triggers from completionKeymap since we handle it via configurable hotkeys.
// Keep navigation keys (ArrowUp/Down, Enter, Escape, etc.) but remove startCompletion bindings.
const filteredCompletionKeymap = completionKeymap.filter((binding) => {
const key = binding.key?.toLowerCase() ?? '';
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? '';
const key = binding.key?.toLowerCase() ?? "";
const mac = (binding as { mac?: string }).mac?.toLowerCase() ?? "";
// Filter out Ctrl-Space and Mac-specific autocomplete triggers (Alt-`, Alt-i)
const isStartTrigger = key.includes('space') || mac.includes('alt-') || mac.includes('`');
const isStartTrigger = key.includes("space") || mac.includes("alt-") || mac.includes("`");
return !isStartTrigger;
});
@@ -242,7 +242,7 @@ export const baseExtensions = [
dropCursor(),
drawSelection(),
autocompletion({
tooltipClass: () => 'x-theme-menu',
tooltipClass: () => "x-theme-menu",
closeOnBlur: true, // Set to `false` for debugging in devtools without closing it
defaultKeymap: false, // We handle the trigger via configurable hotkeys
compareCompletions: (a, b) => {
@@ -257,7 +257,7 @@ export const baseExtensions = [
export const readonlyExtensions = [
EditorState.readOnly.of(true),
EditorView.contentAttributes.of({ tabindex: '-1' }),
EditorView.contentAttributes.of({ tabindex: "-1" }),
];
export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) => [
@@ -269,11 +269,11 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
lineNumbers(),
foldGutter({
markerDOM: (open) => {
const el = document.createElement('div');
el.classList.add('fold-gutter-icon');
const el = document.createElement("div");
el.classList.add("fold-gutter-icon");
el.tabIndex = -1;
if (open) {
el.setAttribute('data-open', '');
el.setAttribute("data-open", "");
}
return el;
},
@@ -281,12 +281,12 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
],
codeFolding({
placeholderDOM(_view, onclick, prepared) {
const el = document.createElement('span');
const el = document.createElement("span");
el.onclick = onclick;
el.className = 'cm-foldPlaceholder';
el.innerText = prepared || '…';
el.title = 'unfold';
el.ariaLabel = 'folded code';
el.className = "cm-foldPlaceholder";
el.innerText = prepared || "…";
el.title = "unfold";
el.ariaLabel = "folded code";
return el;
},
/**
@@ -295,15 +295,15 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
*/
preparePlaceholder(state, range) {
let count: number | undefined;
let startToken = '{';
let endToken = '}';
let startToken = "{";
let endToken = "}";
const prevLine = state.doc.lineAt(range.from).text;
const isArray = prevLine.lastIndexOf('[') > prevLine.lastIndexOf('{');
const isArray = prevLine.lastIndexOf("[") > prevLine.lastIndexOf("{");
if (isArray) {
startToken = '[';
endToken = ']';
startToken = "[";
endToken = "]";
}
const internal = state.sliceDoc(range.from, range.to);
@@ -317,7 +317,7 @@ export const multiLineExtensions = ({ hideGutter }: { hideGutter?: boolean }) =>
}
if (count !== undefined) {
const label = isArray ? 'item' : 'key';
const label = isArray ? "item" : "key";
return pluralizeCount(label, count);
}
},

View File

@@ -1,8 +1,8 @@
import type { Completion, CompletionContext, CompletionResult } from '@codemirror/autocomplete';
import { autocompletion, startCompletion } from '@codemirror/autocomplete';
import { LanguageSupport, LRLanguage, syntaxTree } from '@codemirror/language';
import type { SyntaxNode } from '@lezer/common';
import { parser } from './filter';
import type { Completion, CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
import { LanguageSupport, LRLanguage, syntaxTree } from "@codemirror/language";
import type { SyntaxNode } from "@lezer/common";
import { parser } from "./filter";
export interface FieldDef {
name: string;
@@ -43,7 +43,7 @@ function inPhrase(ctx: CompletionContext): boolean {
// Lezer node names from your grammar: Phrase is the quoted token
let n: SyntaxNode | null = syntaxTree(ctx.state).resolveInner(ctx.pos, -1);
while (n) {
if (n.name === 'Phrase') return true;
if (n.name === "Phrase") return true;
n = n.parent;
}
return false;
@@ -53,7 +53,7 @@ function inPhrase(ctx: CompletionContext): boolean {
function inUnclosedQuote(doc: string, pos: number): boolean {
let quotes = 0;
for (let i = 0; i < pos; i++) {
if (doc[i] === '"' && doc[i - 1] !== '\\') quotes++;
if (doc[i] === '"' && doc[i - 1] !== "\\") quotes++;
}
return quotes % 2 === 1; // odd = inside an open quote
}
@@ -64,13 +64,13 @@ function inUnclosedQuote(doc: string, pos: number): boolean {
* - Otherwise, we're in a field name or bare term position.
*/
function contextInfo(stateDoc: string, pos: number) {
const lastColon = stateDoc.lastIndexOf(':', pos - 1);
const lastColon = stateDoc.lastIndexOf(":", pos - 1);
const lastBoundary = Math.max(
stateDoc.lastIndexOf(' ', pos - 1),
stateDoc.lastIndexOf('\t', pos - 1),
stateDoc.lastIndexOf('\n', pos - 1),
stateDoc.lastIndexOf('(', pos - 1),
stateDoc.lastIndexOf(')', pos - 1),
stateDoc.lastIndexOf(" ", pos - 1),
stateDoc.lastIndexOf("\t", pos - 1),
stateDoc.lastIndexOf("\n", pos - 1),
stateDoc.lastIndexOf("(", pos - 1),
stateDoc.lastIndexOf(")", pos - 1),
);
const inValue = lastColon > lastBoundary;
@@ -96,7 +96,7 @@ function contextInfo(stateDoc: string, pos: number) {
function fieldNameCompletions(fieldNames: string[]): Completion[] {
return fieldNames.map((name) => ({
label: name,
type: 'property',
type: "property",
apply: (view, _completion, from, to) => {
// Insert "name:" (leave cursor right after colon)
view.dispatch({
@@ -117,7 +117,7 @@ function fieldValueCompletions(
return vals.map((v) => ({
label: v.match(IDENT_ONLY) ? v : `"${v}"`,
displayLabel: v,
type: 'constant',
type: "constant",
}));
}
@@ -169,7 +169,7 @@ function makeCompletionSource(opts: FilterOptions) {
}
const language = LRLanguage.define({
name: 'filter',
name: "filter",
parser,
languageData: {
autocompletion: {},

View File

@@ -1,20 +1,20 @@
/* oxlint-disable */
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { highlight } from './highlight';
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"%QOVQPOOPeOPOOOVQPO'#CfOjQPO'#ChO!XQPO'#CgOOQO'#Cc'#CcOVQPO'#CaOOQO'#Ca'#CaO!oQPO'#C`O!|QPO'#C_OOQO'#C^'#C^QOQPOOPOOO'#Cp'#CpP#XOPO)C>jO#`QPO,59QO#eQPO,59ROOQO,58{,58{OVQPO'#CqOOQO'#Cq'#CqO#mQPO,58zOVQPO'#CrO#zQPO,58yPOOO-E6n-E6nOOQO1G.l1G.lOOQO'#Cm'#CmOOQO'#Ck'#CkOOQO1G.m1G.mOOQO,59],59]OOQO-E6o-E6oOOQO,59^,59^OOQO-E6p-E6p",
stateData:
'$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~',
goto: '#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne',
"$]~OiPQ~OUUOXQO]RO`TO~Oi[O~OUaXXaX]aX^[X`aXbaXcaXgaXWaX~O^_O~OUUOXQO]RO`TObaO~OcSXgSXWSX~P!^OcdOgRXWRX~Oi[O~Qh]WgO~O]hO`iO~OcSagSaWSa~P!^OcdOgRaWRa~OUbc]c~",
goto: "#hgPPhnryP!YPP!c!c!lPP!uP!xPP#U#[#bQZOR^QTYOQSXOQRmdUWOQdQ`USbWcRka_VOQUWacd_TOQUWacd_SOQUWacdRj_^TOQUWacdRi_Q]PRf]QcWRlcQeXRne",
nodeNames:
'⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or',
"⚠ Query Expr OrExpr AndExpr Unary Not Primary RParen LParen Group Field FieldName Word Colon FieldValue Phrase Term And Or",
maxTerm: 25,
nodeProps: [
['openedBy', 8, 'LParen'],
['closedBy', 9, 'RParen'],
["openedBy", 8, "LParen"],
["closedBy", 9, "RParen"],
],
propSources: [highlight],
skippedNodes: [0, 20],

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
// Boolean operators
@@ -16,6 +16,6 @@ export const highlight = styleTags({
Phrase: t.string, // "quoted string"
// Fields
'FieldName/Word': t.attributeName,
'FieldValue/Term/Word': t.attributeValue,
"FieldName/Word": t.attributeName,
"FieldValue/Term/Word": t.attributeValue,
});

View File

@@ -1,33 +1,33 @@
// query.ts
// A tiny query language parser with NOT/AND/OR, parentheses, phrases, negation, and field:value.
import { fuzzyMatch } from 'fuzzbunny';
import { fuzzyMatch } from "fuzzbunny";
/////////////////////////
// AST
/////////////////////////
export type Ast =
| { type: 'Term'; value: string } // foo
| { type: 'Phrase'; value: string } // "hi there"
| { type: 'Field'; field: string; value: string } // method:POST or title:"exact phrase"
| { type: 'Not'; node: Ast } // -foo or NOT foo
| { type: 'And'; left: Ast; right: Ast } // a AND b
| { type: 'Or'; left: Ast; right: Ast }; // a OR b
| { type: "Term"; value: string } // foo
| { type: "Phrase"; value: string } // "hi there"
| { type: "Field"; field: string; value: string } // method:POST or title:"exact phrase"
| { type: "Not"; node: Ast } // -foo or NOT foo
| { type: "And"; left: Ast; right: Ast } // a AND b
| { type: "Or"; left: Ast; right: Ast }; // a OR b
/////////////////////////
// Tokenizer
/////////////////////////
type Tok =
| { kind: 'LPAREN' }
| { kind: 'RPAREN' }
| { kind: 'AND' }
| { kind: 'OR' }
| { kind: 'NOT' } // explicit NOT
| { kind: 'MINUS' } // unary minus before term/phrase/paren group
| { kind: 'COLON' }
| { kind: 'WORD'; text: string } // bareword (unquoted)
| { kind: 'PHRASE'; text: string } // "quoted phrase"
| { kind: 'EOF' };
| { kind: "LPAREN" }
| { kind: "RPAREN" }
| { kind: "AND" }
| { kind: "OR" }
| { kind: "NOT" } // explicit NOT
| { kind: "MINUS" } // unary minus before term/phrase/paren group
| { kind: "COLON" }
| { kind: "WORD"; text: string } // bareword (unquoted)
| { kind: "PHRASE"; text: string } // "quoted phrase"
| { kind: "EOF" };
const isSpace = (c: string) => /\s/.test(c);
const isIdent = (c: string) => /[A-Za-z0-9_\-./]/.test(c);
@@ -37,11 +37,11 @@ export function tokenize(input: string): Tok[] {
let i = 0;
const n = input.length;
const peek = () => input[i] ?? '';
const peek = () => input[i] ?? "";
const advance = () => input[i++];
const readWord = () => {
let s = '';
let s = "";
while (i < n && isIdent(peek())) s += advance();
return s;
};
@@ -49,11 +49,11 @@ export function tokenize(input: string): Tok[] {
const readPhrase = () => {
// assumes current char is opening quote
advance(); // consume opening "
let s = '';
let s = "";
while (i < n) {
const c = advance();
if (c === `"`) break;
if (c === '\\' && i < n) {
if (c === "\\" && i < n) {
// escape \" and \\ (simple)
const next = advance();
s += next;
@@ -72,28 +72,28 @@ export function tokenize(input: string): Tok[] {
continue;
}
if (c === '(') {
toks.push({ kind: 'LPAREN' });
if (c === "(") {
toks.push({ kind: "LPAREN" });
i++;
continue;
}
if (c === ')') {
toks.push({ kind: 'RPAREN' });
if (c === ")") {
toks.push({ kind: "RPAREN" });
i++;
continue;
}
if (c === ':') {
toks.push({ kind: 'COLON' });
if (c === ":") {
toks.push({ kind: "COLON" });
i++;
continue;
}
if (c === `"`) {
const text = readPhrase();
toks.push({ kind: 'PHRASE', text });
toks.push({ kind: "PHRASE", text });
continue;
}
if (c === '-') {
toks.push({ kind: 'MINUS' });
if (c === "-") {
toks.push({ kind: "MINUS" });
i++;
continue;
}
@@ -102,10 +102,10 @@ export function tokenize(input: string): Tok[] {
if (isIdent(c)) {
const w = readWord();
const upper = w.toUpperCase();
if (upper === 'AND') toks.push({ kind: 'AND' });
else if (upper === 'OR') toks.push({ kind: 'OR' });
else if (upper === 'NOT') toks.push({ kind: 'NOT' });
else toks.push({ kind: 'WORD', text: w });
if (upper === "AND") toks.push({ kind: "AND" });
else if (upper === "OR") toks.push({ kind: "OR" });
else if (upper === "NOT") toks.push({ kind: "NOT" });
else toks.push({ kind: "WORD", text: w });
continue;
}
@@ -113,7 +113,7 @@ export function tokenize(input: string): Tok[] {
i++;
}
toks.push({ kind: 'EOF' });
toks.push({ kind: "EOF" });
return toks;
}
@@ -122,20 +122,20 @@ class Parser {
constructor(private toks: Tok[]) {}
private peek(): Tok {
return this.toks[this.i] ?? { kind: 'EOF' };
return this.toks[this.i] ?? { kind: "EOF" };
}
private advance(): Tok {
return this.toks[this.i++] ?? { kind: 'EOF' };
return this.toks[this.i++] ?? { kind: "EOF" };
}
private at(kind: Tok['kind']) {
private at(kind: Tok["kind"]) {
return this.peek().kind === kind;
}
// Top-level: parse OR-precedence chain, allowing implicit AND.
parse(): Ast | null {
if (this.at('EOF')) return null;
if (this.at("EOF")) return null;
const expr = this.parseOr();
if (!this.at('EOF')) {
if (!this.at("EOF")) {
// Optionally, consume remaining tokens or throw
}
return expr;
@@ -144,10 +144,10 @@ class Parser {
// Precedence: NOT (highest), AND, OR (lowest)
private parseOr(): Ast {
let node = this.parseAnd();
while (this.at('OR')) {
while (this.at("OR")) {
this.advance();
const rhs = this.parseAnd();
node = { type: 'Or', left: node, right: rhs };
node = { type: "Or", left: node, right: rhs };
}
return node;
}
@@ -155,31 +155,31 @@ class Parser {
private parseAnd(): Ast {
let node = this.parseUnary();
// Implicit AND: if next token starts a primary, treat as AND.
while (this.at('AND') || this.startsPrimary()) {
if (this.at('AND')) this.advance();
while (this.at("AND") || this.startsPrimary()) {
if (this.at("AND")) this.advance();
const rhs = this.parseUnary();
node = { type: 'And', left: node, right: rhs };
node = { type: "And", left: node, right: rhs };
}
return node;
}
private parseUnary(): Ast {
if (this.at('NOT') || this.at('MINUS')) {
if (this.at("NOT") || this.at("MINUS")) {
this.advance();
const node = this.parseUnary();
return { type: 'Not', node };
return { type: "Not", node };
}
return this.parsePrimaryOrField();
}
private startsPrimary(): boolean {
const k = this.peek().kind;
return k === 'WORD' || k === 'PHRASE' || k === 'LPAREN' || k === 'MINUS' || k === 'NOT';
return k === "WORD" || k === "PHRASE" || k === "LPAREN" || k === "MINUS" || k === "NOT";
}
private parsePrimaryOrField(): Ast {
// Parenthesized group
if (this.at('LPAREN')) {
if (this.at("LPAREN")) {
this.advance();
const inside = this.parseOr();
// if (!this.at('RPAREN')) throw new Error("Missing closing ')'");
@@ -188,59 +188,59 @@ class Parser {
}
// Phrase
if (this.at('PHRASE')) {
const t = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
return { type: 'Phrase', value: t.text };
if (this.at("PHRASE")) {
const t = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
return { type: "Phrase", value: t.text };
}
// Field or bare word
if (this.at('WORD')) {
const wordTok = this.advance() as Extract<Tok, { kind: 'WORD' }>;
if (this.at("WORD")) {
const wordTok = this.advance() as Extract<Tok, { kind: "WORD" }>;
if (this.at('COLON')) {
if (this.at("COLON")) {
// field:value or field:"phrase"
this.advance(); // :
let value: string;
if (this.at('PHRASE')) {
const p = this.advance() as Extract<Tok, { kind: 'PHRASE' }>;
if (this.at("PHRASE")) {
const p = this.advance() as Extract<Tok, { kind: "PHRASE" }>;
value = p.text;
} else if (this.at('WORD')) {
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
} else if (this.at("WORD")) {
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
value = w.text;
} else {
// Anything else after colon is treated literally as a single Term token.
const t = this.advance();
value = tokText(t);
}
return { type: 'Field', field: wordTok.text, value };
return { type: "Field", field: wordTok.text, value };
}
// plain term
return { type: 'Term', value: wordTok.text };
return { type: "Term", value: wordTok.text };
}
const w = this.advance() as Extract<Tok, { kind: 'WORD' }>;
return { type: 'Phrase', value: 'text' in w ? w.text : '' };
const w = this.advance() as Extract<Tok, { kind: "WORD" }>;
return { type: "Phrase", value: "text" in w ? w.text : "" };
}
}
function tokText(t: Tok): string {
if ('text' in t) return t.text;
if ("text" in t) return t.text;
switch (t.kind) {
case 'COLON':
return ':';
case 'LPAREN':
return '(';
case 'RPAREN':
return ')';
case "COLON":
return ":";
case "LPAREN":
return "(";
case "RPAREN":
return ")";
default:
return '';
return "";
}
}
export function parseQuery(q: string): Ast | null {
if (q.trim() === '') return null;
if (q.trim() === "") return null;
const toks = tokenize(q);
const parser = new Parser(toks);
return parser.parse();
@@ -251,45 +251,45 @@ export type Doc = {
fields?: Record<string, unknown>;
};
type Technique = 'substring' | 'fuzzy' | 'strict';
type Technique = "substring" | "fuzzy" | "strict";
function includes(hay: string | undefined, needle: string, technique: Technique): boolean {
if (!hay || !needle) return false;
if (technique === 'strict') return hay === needle;
if (technique === 'fuzzy') return !!fuzzyMatch(hay, needle);
if (technique === "strict") return hay === needle;
if (technique === "fuzzy") return !!fuzzyMatch(hay, needle);
return hay.indexOf(needle) !== -1;
}
export function evaluate(ast: Ast | null, doc: Doc): boolean {
if (!ast) return true; // Match everything if no query is provided
const text = (doc.text ?? '').toLowerCase();
const text = (doc.text ?? "").toLowerCase();
const fieldsNorm: Record<string, string[]> = {};
for (const [k, v] of Object.entries(doc.fields ?? {})) {
if (!(typeof v === 'string' || Array.isArray(v))) continue;
if (!(typeof v === "string" || Array.isArray(v))) continue;
fieldsNorm[k.toLowerCase()] = Array.isArray(v)
? v.filter((v) => typeof v === 'string').map((s) => s.toLowerCase())
: [String(v ?? '').toLowerCase()];
? v.filter((v) => typeof v === "string").map((s) => s.toLowerCase())
: [String(v ?? "").toLowerCase()];
}
const evalNode = (node: Ast): boolean => {
switch (node.type) {
case 'Term':
return includes(text, node.value.toLowerCase(), 'fuzzy');
case 'Phrase':
case "Term":
return includes(text, node.value.toLowerCase(), "fuzzy");
case "Phrase":
// Quoted phrases match exactly
return includes(text, node.value.toLowerCase(), 'substring');
case 'Field': {
return includes(text, node.value.toLowerCase(), "substring");
case "Field": {
const vals = fieldsNorm[node.field.toLowerCase()] ?? [];
if (vals.length === 0) return false;
return vals.some((v) => includes(v, node.value.toLowerCase(), 'substring'));
return vals.some((v) => includes(v, node.value.toLowerCase(), "substring"));
}
case 'Not':
case "Not":
return !evalNode(node.node);
case 'And':
case "And":
return evalNode(node.left) && evalNode(node.right);
case 'Or':
case "Or":
return evalNode(node.left) || evalNode(node.right);
}
};

View File

@@ -1,6 +1,6 @@
import type { CompletionContext } from '@codemirror/autocomplete';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import { defaultBoost } from './twig/completion';
import type { CompletionContext } from "@codemirror/autocomplete";
import type { GenericCompletionOption } from "@yaakapp-internal/plugins";
import { defaultBoost } from "./twig/completion";
export interface GenericCompletionConfig {
minMatch?: number;

View File

@@ -1,9 +1,9 @@
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from '@codemirror/view';
import { activeWorkspaceIdAtom } from '../../../../hooks/useActiveWorkspace';
import { copyToClipboard } from '../../../../lib/copy';
import { createRequestAndNavigate } from '../../../../lib/createRequestAndNavigate';
import { jotaiStore } from '../../../../lib/jotai';
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, hoverTooltip, MatchDecorator, ViewPlugin } from "@codemirror/view";
import { activeWorkspaceIdAtom } from "../../../../hooks/useActiveWorkspace";
import { copyToClipboard } from "../../../../lib/copy";
import { createRequestAndNavigate } from "../../../../lib/createRequestAndNavigate";
import { jotaiStore } from "../../../../lib/jotai";
const REGEX =
/(https?:\/\/([-a-zA-Z0-9@:%._+*~#=]{1,256})+(\.[a-zA-Z0-9()]{1,6})?\b([-a-zA-Z0-9()@:%_+*.~#?&/={}[\]]*))/g;
@@ -39,26 +39,26 @@ const tooltip = hoverTooltip(
create() {
const workspaceId = jotaiStore.get(activeWorkspaceIdAtom);
const link = text.substring(found?.start - from, found?.end - from);
const dom = document.createElement('div');
const dom = document.createElement("div");
const $open = document.createElement('a');
$open.textContent = 'Open in browser';
const $open = document.createElement("a");
$open.textContent = "Open in browser";
$open.href = link;
$open.target = '_blank';
$open.rel = 'noopener noreferrer';
$open.target = "_blank";
$open.rel = "noopener noreferrer";
const $copy = document.createElement('button');
$copy.textContent = 'Copy to clipboard';
$copy.addEventListener('click', () => {
const $copy = document.createElement("button");
$copy.textContent = "Copy to clipboard";
$copy.addEventListener("click", () => {
copyToClipboard(link);
});
const $create = document.createElement('button');
$create.textContent = 'Create new request';
$create.addEventListener('click', async () => {
const $create = document.createElement("button");
$create.textContent = "Create new request";
$create.addEventListener("click", async () => {
await createRequestAndNavigate({
model: 'http_request',
workspaceId: workspaceId ?? 'n/a',
model: "http_request",
workspaceId: workspaceId ?? "n/a",
url: link,
});
});
@@ -94,12 +94,12 @@ const decorator = () => {
const groupMatch = match[1];
if (groupMatch == null) {
// Should never happen, but make TS happy
console.warn('Group match was empty', match);
console.warn("Group match was empty", match);
return Decoration.replace({});
}
return Decoration.mark({
class: 'hyperlink-widget',
class: "hyperlink-widget",
});
},
});

View File

@@ -1,6 +1,6 @@
import type { Diagnostic } from '@codemirror/lint';
import type { EditorView } from '@codemirror/view';
import { parse as jsonLintParse } from '@prantlf/jsonlint';
import type { Diagnostic } from "@codemirror/lint";
import type { EditorView } from "@codemirror/view";
import { parse as jsonLintParse } from "@prantlf/jsonlint";
const TEMPLATE_SYNTAX_REGEX = /\$\{\[[\s\S]*?]}/g;
@@ -15,14 +15,14 @@ export function jsonParseLinter(options?: JsonLintOptions) {
const doc = view.state.doc.toString();
// We need lint to not break on stuff like {"foo:" ${[ ... ]}} so we'll replace all template
// syntax with repeating `1` characters, so it's valid JSON and the position is still correct.
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => '1'.repeat(m.length));
const escapedDoc = doc.replace(TEMPLATE_SYNTAX_REGEX, (m) => "1".repeat(m.length));
jsonLintParse(escapedDoc, {
mode: (options?.allowComments ?? true) ? 'cjson' : 'json',
mode: (options?.allowComments ?? true) ? "cjson" : "json",
ignoreTrailingCommas: options?.allowTrailingCommas ?? false,
});
// oxlint-disable-next-line no-explicit-any
} catch (err: any) {
if (!('location' in err)) {
if (!("location" in err)) {
return [];
}
@@ -33,7 +33,7 @@ export function jsonParseLinter(options?: JsonLintOptions) {
{
from: err.location.start.offset,
to: err.location.start.offset,
severity: 'error',
severity: "error",
message: err.message,
},
];

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './pairs';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./pairs";
const language = LRLanguage.define({
name: 'pairs',
name: "pairs",
parser,
languageData: {},
});

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
Sep: t.bracket,

View File

@@ -1,12 +1,12 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { highlight } from './highlight';
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states: "zQQOPOOOVOQO'#CaQQOPOOO[OSO,58{OOOO-E6_-E6_OaOQO1G.gOOOO7+$R7+$R",
stateData: 'f~OQPO~ORRO~OSTO~OVUO~O',
goto: ']UPPPPPVQQORSQ',
nodeNames: '⚠ pairs Key Sep Value',
stateData: "f~OQPO~ORRO~OSTO~OVUO~O",
goto: "]UPPPPPVQQORSQ",
nodeNames: "⚠ pairs Key Sep Value",
maxTerm: 7,
propSources: [highlight],
skippedNodes: [0],
@@ -17,13 +17,13 @@ export const parser = LRParser.deserialize({
topRules: { pairs: [0, 1] },
tokenPrec: 0,
termNames: {
'0': '⚠',
'1': '@top',
'2': 'Key',
'3': 'Sep',
'4': 'Value',
'5': '(Key Sep Value "\\n")+',
'6': '␄',
'7': '"\\n"',
"0": "⚠",
"1": "@top",
"2": "Key",
"3": "Sep",
"4": "Value",
"5": '(Key Sep Value "\\n")+',
"6": "␄",
"7": '"\\n"',
},
});

View File

@@ -1,6 +1,6 @@
import { getSearchQuery, searchPanelOpen } from '@codemirror/search';
import type { Extension } from '@codemirror/state';
import { type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view';
import { getSearchQuery, searchPanelOpen } from "@codemirror/search";
import type { Extension } from "@codemirror/state";
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
/**
* A CodeMirror extension that displays the total number of search matches
@@ -41,7 +41,7 @@ export function searchMatchCount(): Extension {
if (!query.search) {
if (this.countEl) {
this.countEl.textContent = '0/0';
this.countEl.textContent = "0/0";
}
return;
}
@@ -64,7 +64,7 @@ export function searchMatchCount(): Extension {
if (count > MAX_COUNT) {
this.countEl.textContent = `${MAX_COUNT}+`;
} else if (count === 0) {
this.countEl.textContent = '0/0';
this.countEl.textContent = "0/0";
} else if (currentIndex > 0) {
this.countEl.textContent = `${currentIndex}/${count}`;
} else {
@@ -75,7 +75,7 @@ export function searchMatchCount(): Extension {
private ensureCountEl() {
// Find the search panel in the editor DOM
const panel = this.view.dom.querySelector('.cm-search');
const panel = this.view.dom.querySelector(".cm-search");
if (!panel) {
this.countEl = null;
return;
@@ -85,11 +85,11 @@ export function searchMatchCount(): Extension {
return; // Already attached
}
this.countEl = document.createElement('span');
this.countEl.className = 'cm-search-match-count';
this.countEl = document.createElement("span");
this.countEl.className = "cm-search-match-count";
// Reorder: insert prev button, then next button, then count after the search input
const searchInput = panel.querySelector('input');
const searchInput = panel.querySelector("input");
const prevBtn = panel.querySelector('button[name="prev"]');
const nextBtn = panel.querySelector('button[name="next"]');
if (searchInput && searchInput.parentElement === panel) {

View File

@@ -1,5 +1,5 @@
import type { Extension, TransactionSpec } from '@codemirror/state';
import { EditorSelection, EditorState, Transaction } from '@codemirror/state';
import type { Extension, TransactionSpec } from "@codemirror/state";
import { EditorSelection, EditorState, Transaction } from "@codemirror/state";
/**
* A CodeMirror extension that forces single-line input by stripping
@@ -17,14 +17,14 @@ import { EditorSelection, EditorState, Transaction } from '@codemirror/state';
export function singleLineExtensions(): Extension {
return EditorState.transactionFilter.of(
(tr: Transaction): TransactionSpec | readonly TransactionSpec[] => {
if (!tr.isUserEvent('input') || tr.isUserEvent('input.type.compose')) return tr;
if (!tr.isUserEvent("input") || tr.isUserEvent("input.type.compose")) return tr;
const changes: { from: number; to: number; insert: string }[] = [];
tr.changes.iterChanges((_fromA, toA, fromB, _toB, inserted) => {
let insert = '';
let insert = "";
for (const line of inserted.iterLines()) {
insert += line.replace(/\n/g, '');
insert += line.replace(/\n/g, "");
}
if (insert !== inserted.toString()) {

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './text';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./text";
export const textLanguage = LRLanguage.define({
name: 'text',
name: "text",
parser,
languageData: {},
});

View File

@@ -1,11 +1,11 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { LRParser } from "@lezer/lr";
export const parser = LRParser.deserialize({
version: 14,
states: '[OQOPOOQOOOOO',
stateData: 'V~OQPO~O',
goto: 'QPP',
nodeNames: '⚠ Template Text',
states: "[OQOPOOQOOOOO",
stateData: "V~OQPO~O",
goto: "QPP",
nodeNames: "⚠ Template Text",
maxTerm: 3,
skippedNodes: [0],
repeatNodeCount: 0,

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './timeline';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./timeline";
export const timelineLanguage = LRLanguage.define({
name: 'timeline',
name: "timeline",
parser,
languageData: {},
});

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
OutgoingText: t.propertyName, // > lines - primary color (matches timeline icons)

View File

@@ -1,6 +1,5 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
Timeline = 1,
export const Timeline = 1,
OutgoingLine = 2,
OutgoingText = 3,
Newline = 4,
@@ -9,4 +8,4 @@ export const
InfoLine = 7,
InfoText = 8,
PlainLine = 9,
PlainText = 10
PlainText = 10;

View File

@@ -1,18 +1,21 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
import {highlight} from "./highlight"
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states: "!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
states:
"!pQQOPOOO`OPO'#C^OeOPO'#CaOjOPO'#CcOoOPO'#CeOOOO'#Ci'#CiOOOO'#Cg'#CgQQOPOOOOOO,58x,58xOOOO,58{,58{OOOO,58},58}OOOO,59P,59POOOO-E6e-E6e",
stateData: "z~ORPOUQOWROYSO~OSWO~OSXO~OSYO~OSZO~ORUWYW~",
goto: "m^PP_PP_P_P_PcPiTTOVQVOR[VTUOV",
nodeNames: "⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
nodeNames:
"⚠ Timeline OutgoingLine OutgoingText Newline IncomingLine IncomingText InfoLine InfoText PlainLine PlainText",
maxTerm: 13,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 1,
tokenData: "%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
tokenData:
"%h~RZOYtYZ!]Zztz{!b{!^t!^!_#d!_!`t!`!a$f!a;'St;'S;=`!V<%lOt~ySY~OYtZ;'St;'S;=`!V<%lOt~!YP;=`<%lt~!bOS~~!gUY~OYtZptpq!yq;'St;'S;=`!V<%lOt~#QSW~Y~OY!yZ;'S!y;'S;=`#^<%lO!y~#aP;=`<%l!y~#iUY~OYtZptpq#{q;'St;'S;=`!V<%lOt~$SSU~Y~OY#{Z;'S#{;'S;=`$`<%lO#{~$cP;=`<%l#{~$kUY~OYtZptpq$}q;'St;'S;=`!V<%lOt~%USR~Y~OY$}Z;'S$};'S;=`%b<%lO$}~%eP;=`<%l$}",
tokenizers: [0],
topRules: {"Timeline":[0,1]},
tokenPrec: 36
})
topRules: { Timeline: [0, 1] },
tokenPrec: 36,
});

View File

@@ -1,20 +1,20 @@
import type { Completion, CompletionContext } from '@codemirror/autocomplete';
import { startCompletion } from '@codemirror/autocomplete';
import type { TemplateFunction } from '@yaakapp-internal/plugins';
import type { Completion, CompletionContext } from "@codemirror/autocomplete";
import { startCompletion } from "@codemirror/autocomplete";
import type { TemplateFunction } from "@yaakapp-internal/plugins";
const openTag = '${[ ';
const closeTag = ' ]}';
const openTag = "${[ ";
const closeTag = " ]}";
export type TwigCompletionOptionVariable = {
type: 'variable';
type: "variable";
};
export type TwigCompletionOptionNamespace = {
type: 'namespace';
type: "namespace";
};
export type TwigCompletionOptionFunction = TemplateFunction & {
type: 'function';
type: "function";
};
export type TwigCompletionOption = (
@@ -50,17 +50,17 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
const completions: Completion[] = options
.flatMap((o): Completion[] => {
const matchSegments = toMatch.text.replace(/^\$/, '').split('.');
const optionSegments = o.name.split('.');
const matchSegments = toMatch.text.replace(/^\$/, "").split(".");
const optionSegments = o.name.split(".");
// If not on the last segment, only complete the namespace
if (matchSegments.length < optionSegments.length) {
const prefix = optionSegments.slice(0, matchSegments.length).join('.');
const prefix = optionSegments.slice(0, matchSegments.length).join(".");
return [
{
label: `${prefix}.*`,
type: 'namespace',
detail: 'namespace',
type: "namespace",
detail: "namespace",
apply: (view, _completion, from, to) => {
const insert = `${prefix}.`;
view.dispatch({
@@ -75,13 +75,13 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
}
// If on the last segment, wrap the entire tag
const inner = o.type === 'function' ? `${o.name}()` : o.name;
const inner = o.type === "function" ? `${o.name}()` : o.name;
return [
{
label: o.name,
info: o.description,
detail: o.type,
type: o.type === 'variable' ? 'variable' : 'function',
type: o.type === "variable" ? "variable" : "function",
apply: (view, _completion, from, to) => {
const insert = openTag + inner + closeTag;
view.dispatch({
@@ -94,7 +94,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
})
.filter((v) => v != null);
const uniqueCompletions = uniqueBy(completions, 'label');
const uniqueCompletions = uniqueBy(completions, "label");
const sortedCompletions = uniqueCompletions.sort((a, b) => {
const boostDiff = defaultBoost(b) - defaultBoost(a);
if (boostDiff !== 0) return boostDiff;
@@ -119,9 +119,9 @@ export function uniqueBy<T, K extends keyof T>(arr: T[], key: K): T[] {
}
export function defaultBoost(o: Completion) {
if (o.type === 'variable') return 4;
if (o.type === 'constant') return 3;
if (o.type === 'function') return 2;
if (o.type === 'namespace') return 1;
if (o.type === "variable") return 4;
if (o.type === "constant") return 3;
if (o.type === "function") return 2;
if (o.type === "namespace") return 1;
return 0;
}

View File

@@ -1,15 +1,15 @@
import type { LanguageSupport } from '@codemirror/language';
import { LRLanguage } from '@codemirror/language';
import type { Extension } from '@codemirror/state';
import { parseMixed } from '@lezer/common';
import type { WrappedEnvironmentVariable } from '../../../../hooks/useEnvironmentVariables';
import type { GenericCompletionConfig } from '../genericCompletion';
import { genericCompletion } from '../genericCompletion';
import { textLanguage } from '../text/extension';
import type { TwigCompletionOption } from './completion';
import { twigCompletion } from './completion';
import { templateTagsPlugin } from './templateTags';
import { parser as twigParser } from './twig';
import type { LanguageSupport } from "@codemirror/language";
import { LRLanguage } from "@codemirror/language";
import type { Extension } from "@codemirror/state";
import { parseMixed } from "@lezer/common";
import type { WrappedEnvironmentVariable } from "../../../../hooks/useEnvironmentVariables";
import type { GenericCompletionConfig } from "../genericCompletion";
import { genericCompletion } from "../genericCompletion";
import { textLanguage } from "../text/extension";
import type { TwigCompletionOption } from "./completion";
import { twigCompletion } from "./completion";
import { templateTagsPlugin } from "./templateTags";
import { parser as twigParser } from "./twig";
export function twig({
base,
@@ -35,7 +35,7 @@ export function twig({
environmentVariables.map((v) => ({
name: v.variable.name,
value: v.variable.value,
type: 'variable',
type: "variable",
label: v.variable.name,
description: `Inherited from ${v.source}`,
onClick: (rawTag: string, startPos: number) => onClickVariable(v, rawTag, startPos),
@@ -74,12 +74,12 @@ function mixLanguage(base: LanguageSupport): LRLanguage {
return {
parser: base.language.parser,
overlay: (node) => node.type.name === 'Text',
overlay: (node) => node.type.name === "Text",
};
}),
});
const language = LRLanguage.define({ name: 'twig', parser });
const language = LRLanguage.define({ name: "twig", parser });
mixedLanguagesCache[base.language.name] = language;
return language;
}

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
TagOpen: t.bracket,

View File

@@ -1,7 +1,7 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
class PathPlaceholderWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -22,15 +22,15 @@ class PathPlaceholderWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
elt.className = 'x-theme-templateTag x-theme-templateTag--secondary template-tag';
const elt = document.createElement("span");
elt.className = "x-theme-templateTag x-theme-templateTag--secondary template-tag";
elt.textContent = this.rawText;
elt.addEventListener('click', this.#clickListenerCallback);
elt.addEventListener("click", this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
dom.removeEventListener("click", this.#clickListenerCallback);
super.destroy(dom);
}
@@ -50,14 +50,14 @@ function pathParameters(
from,
to,
enter(node) {
if (node.name === 'Text') {
if (node.name === "Text") {
// Find the `url` node and then jump into it to find the placeholders
for (let i = node.from; i < node.to; i++) {
const innerTree = syntaxTree(view.state).resolveInner(i);
if (innerTree.node.name === 'url') {
if (innerTree.node.name === "url") {
innerTree.toTree().iterate({
enter(node) {
if (node.name !== 'Placeholder') return;
if (node.name !== "Placeholder") return;
const globalFrom = innerTree.node.from + node.from;
const globalTo = innerTree.node.from + node.to;
const rawText = view.state.doc.sliceString(globalFrom, globalTo);

View File

@@ -1,13 +1,13 @@
import { syntaxTree } from '@codemirror/language';
import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common';
import { applyFormInputDefaults, validateTemplateFunctionArgs } from '@yaakapp-internal/lib';
import type { FormInput, JsonPrimitive, TemplateFunction } from '@yaakapp-internal/plugins';
import { parseTemplate } from '@yaakapp-internal/templates';
import type { TwigCompletionOption } from './completion';
import { collectArgumentValues } from './util';
import { syntaxTree } from "@codemirror/language";
import type { Range } from "@codemirror/state";
import type { DecorationSet, ViewUpdate } from "@codemirror/view";
import { Decoration, EditorView, ViewPlugin, WidgetType } from "@codemirror/view";
import type { SyntaxNodeRef } from "@lezer/common";
import { applyFormInputDefaults, validateTemplateFunctionArgs } from "@yaakapp-internal/lib";
import type { FormInput, JsonPrimitive, TemplateFunction } from "@yaakapp-internal/plugins";
import { parseTemplate } from "@yaakapp-internal/templates";
import type { TwigCompletionOption } from "./completion";
import { collectArgumentValues } from "./util";
class TemplateTagWidget extends WidgetType {
readonly #clickListenerCallback: () => void;
@@ -34,24 +34,24 @@ class TemplateTagWidget extends WidgetType {
}
toDOM() {
const elt = document.createElement('span');
const elt = document.createElement("span");
elt.className = `x-theme-templateTag template-tag ${
this.option.invalid
? 'x-theme-templateTag--danger'
: this.option.type === 'variable'
? 'x-theme-templateTag--primary'
: 'x-theme-templateTag--info'
? "x-theme-templateTag--danger"
: this.option.type === "variable"
? "x-theme-templateTag--primary"
: "x-theme-templateTag--info"
}`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type);
if (typeof this.option.label === 'string') elt.textContent = this.option.label;
elt.title = this.option.invalid ? "Not Found" : (this.option.value ?? "");
elt.setAttribute("data-tag-type", this.option.type);
if (typeof this.option.label === "string") elt.textContent = this.option.label;
else elt.appendChild(this.option.label);
elt.addEventListener('click', this.#clickListenerCallback);
elt.addEventListener("click", this.#clickListenerCallback);
return elt;
}
destroy(dom: HTMLElement) {
dom.removeEventListener('click', this.#clickListenerCallback);
dom.removeEventListener("click", this.#clickListenerCallback);
super.destroy(dom);
}
@@ -72,34 +72,34 @@ function templateTags(
from,
to,
enter(node) {
if (node.name === 'Tag') {
if (node.name === "Tag") {
// Don't decorate if the cursor is inside the match
if (isSelectionInsideNode(view, node)) return;
const rawTag = view.state.doc.sliceString(node.from, node.to);
// TODO: Search `node.tree` instead of using Regex here
const inner = rawTag.replace(/^\$\{\[\s*/, '').replace(/\s*]}$/, '');
const inner = rawTag.replace(/^\$\{\[\s*/, "").replace(/\s*]}$/, "");
let name = inner.match(/([\w.]+)[(]/)?.[1] ?? inner;
if (inner.includes('\n')) {
if (inner.includes("\n")) {
return;
}
// The beta named the function `Response` but was changed in stable.
// Keep this here for a while because there's no easy way to migrate
if (name === 'Response') {
name = 'response';
if (name === "Response") {
name = "response";
}
let option = options.find(
(o) => o.name === name || (o.type === 'function' && o.aliases?.includes(name)),
(o) => o.name === name || (o.type === "function" && o.aliases?.includes(name)),
);
if (option == null) {
const from = node.from; // Cache here so the reference doesn't change
option = {
type: 'variable',
type: "variable",
invalid: true,
name: inner,
value: null,
@@ -110,7 +110,7 @@ function templateTags(
};
}
if (option.type === 'function') {
if (option.type === "function") {
const tokens = parseTemplate(rawTag);
const rawValues = collectArgumentValues(tokens, option);
const values = applyFormInputDefaults(option.args, rawValues);
@@ -175,49 +175,49 @@ function makeFunctionLabel(
): HTMLElement | string {
if (fn.args.length === 0) return fn.name;
const $outer = document.createElement('span');
$outer.className = 'fn';
const $bOpen = document.createElement('span');
$bOpen.className = 'fn-bracket';
$bOpen.textContent = '(';
const $outer = document.createElement("span");
$outer.className = "fn";
const $bOpen = document.createElement("span");
$bOpen.className = "fn-bracket";
$bOpen.textContent = "(";
$outer.appendChild(document.createTextNode(fn.name));
$outer.appendChild($bOpen);
const $inner = document.createElement('span');
$inner.className = 'fn-inner';
$inner.title = '';
const $inner = document.createElement("span");
$inner.className = "fn-inner";
$inner.title = "";
fn.previewArgs?.forEach((name: string, i: number, all: string[]) => {
const v = String(values[name] || '');
const v = String(values[name] || "");
if (!v) return;
if (all.length > 1) {
const $c = document.createElement('span');
$c.className = 'fn-arg-name';
const $c = document.createElement("span");
$c.className = "fn-arg-name";
$c.textContent = i > 0 ? `, ${name}=` : `${name}=`;
$inner.appendChild($c);
}
const $v = document.createElement('span');
$v.className = 'fn-arg-value';
$v.textContent = v.includes(' ') ? `'${v}'` : v;
const $v = document.createElement("span");
$v.className = "fn-arg-value";
$v.textContent = v.includes(" ") ? `'${v}'` : v;
$inner.appendChild($v);
});
fn.args.forEach((a: FormInput, i: number) => {
if (!('name' in a)) return;
if (!("name" in a)) return;
const v = values[a.name];
if (v == null) return;
if (i > 0) $inner.title += '\n';
if (i > 0) $inner.title += "\n";
$inner.title += `${a.name} = ${JSON.stringify(v)}`;
});
if ($inner.childNodes.length === 0) {
$inner.appendChild(document.createTextNode('…'));
$inner.appendChild(document.createTextNode("…"));
}
$outer.appendChild($inner);
const $bClose = document.createElement('span');
$bClose.className = 'fn-bracket';
$bClose.textContent = ')';
const $bClose = document.createElement("span");
$bClose.className = "fn-bracket";
$bClose.textContent = ")";
$outer.appendChild($bClose);
return $outer;

View File

@@ -1,14 +1,14 @@
/* oxlint-disable no-template-curly-in-string */
import { describe, expect, test } from 'vite-plus/test';
import { parser } from './twig';
import { describe, expect, test } from "vite-plus/test";
import { parser } from "./twig";
function getNodeNames(input: string): string[] {
const tree = parser.parse(input);
const nodes: string[] = [];
const cursor = tree.cursor();
do {
if (cursor.name !== 'Template') {
if (cursor.name !== "Template") {
nodes.push(cursor.name);
}
} while (cursor.next());
@@ -16,93 +16,93 @@ function getNodeNames(input: string): string[] {
}
function hasTag(input: string): boolean {
return getNodeNames(input).includes('Tag');
return getNodeNames(input).includes("Tag");
}
function hasError(input: string): boolean {
return getNodeNames(input).includes('⚠');
return getNodeNames(input).includes("⚠");
}
describe('twig grammar', () => {
describe('${[var]} format (valid template tags)', () => {
test('parses simple variable as Tag', () => {
expect(hasTag('${[var]}')).toBe(true);
expect(hasError('${[var]}')).toBe(false);
describe("twig grammar", () => {
describe("${[var]} format (valid template tags)", () => {
test("parses simple variable as Tag", () => {
expect(hasTag("${[var]}")).toBe(true);
expect(hasError("${[var]}")).toBe(false);
});
test('parses variable with whitespace as Tag', () => {
expect(hasTag('${[ var ]}')).toBe(true);
expect(hasError('${[ var ]}')).toBe(false);
test("parses variable with whitespace as Tag", () => {
expect(hasTag("${[ var ]}")).toBe(true);
expect(hasError("${[ var ]}")).toBe(false);
});
test('parses embedded variable as Tag', () => {
expect(hasTag('hello ${[name]} world')).toBe(true);
expect(hasError('hello ${[name]} world')).toBe(false);
test("parses embedded variable as Tag", () => {
expect(hasTag("hello ${[name]} world")).toBe(true);
expect(hasError("hello ${[name]} world")).toBe(false);
});
test('parses function call as Tag', () => {
expect(hasTag('${[fn()]}')).toBe(true);
expect(hasError('${[fn()]}')).toBe(false);
test("parses function call as Tag", () => {
expect(hasTag("${[fn()]}")).toBe(true);
expect(hasError("${[fn()]}")).toBe(false);
});
});
describe('${var} format (should be plain text, not tags)', () => {
test('parses ${var} as plain Text without errors', () => {
expect(hasTag('${var}')).toBe(false);
expect(hasError('${var}')).toBe(false);
describe("${var} format (should be plain text, not tags)", () => {
test("parses ${var} as plain Text without errors", () => {
expect(hasTag("${var}")).toBe(false);
expect(hasError("${var}")).toBe(false);
});
test('parses embedded ${var} as plain Text', () => {
expect(hasTag('hello ${name} world')).toBe(false);
expect(hasError('hello ${name} world')).toBe(false);
test("parses embedded ${var} as plain Text", () => {
expect(hasTag("hello ${name} world")).toBe(false);
expect(hasError("hello ${name} world")).toBe(false);
});
test('parses JSON with ${var} as plain Text', () => {
test("parses JSON with ${var} as plain Text", () => {
const json = '{"key": "${value}"}';
expect(hasTag(json)).toBe(false);
expect(hasError(json)).toBe(false);
});
test('parses multiple ${var} as plain Text', () => {
expect(hasTag('${a} and ${b}')).toBe(false);
expect(hasError('${a} and ${b}')).toBe(false);
test("parses multiple ${var} as plain Text", () => {
expect(hasTag("${a} and ${b}")).toBe(false);
expect(hasError("${a} and ${b}")).toBe(false);
});
});
describe('mixed content', () => {
test('distinguishes ${var} from ${[var]} in same string', () => {
const input = '${plain} and ${[tag]}';
describe("mixed content", () => {
test("distinguishes ${var} from ${[var]} in same string", () => {
const input = "${plain} and ${[tag]}";
expect(hasTag(input)).toBe(true);
expect(hasError(input)).toBe(false);
});
test('parses JSON with ${[var]} as having Tag', () => {
test("parses JSON with ${[var]} as having Tag", () => {
const json = '{"key": "${[value]}"}';
expect(hasTag(json)).toBe(true);
expect(hasError(json)).toBe(false);
});
});
describe('edge cases', () => {
test('handles $ at end of string', () => {
expect(hasError('hello$')).toBe(false);
expect(hasTag('hello$')).toBe(false);
describe("edge cases", () => {
test("handles $ at end of string", () => {
expect(hasError("hello$")).toBe(false);
expect(hasTag("hello$")).toBe(false);
});
test('handles ${ at end of string without crash', () => {
test("handles ${ at end of string without crash", () => {
// Incomplete syntax may produce errors, but should not crash
expect(() => parser.parse('hello${')).not.toThrow();
expect(() => parser.parse("hello${")).not.toThrow();
});
test('handles ${[ without closing without crash', () => {
test("handles ${[ without closing without crash", () => {
// Unclosed tag may produce partial match, but should not crash
expect(() => parser.parse('${[unclosed')).not.toThrow();
expect(() => parser.parse("${[unclosed")).not.toThrow();
});
test('handles empty ${[]}', () => {
test("handles empty ${[]}", () => {
// Empty tags may or may not be valid depending on grammar
// Just ensure no crash
expect(() => parser.parse('${[]}')).not.toThrow();
expect(() => parser.parse("${[]}")).not.toThrow();
});
});
});

View File

@@ -1,20 +1,20 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LocalTokenGroup, LRParser } from '@lezer/lr';
import { highlight } from './highlight';
import { LocalTokenGroup, LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!^QQOPOOOOOO'#C_'#C_OYOQO'#C^OOOO'#Cc'#CcQQOPOOOOOO'#Cd'#CdO_OQO,58xOOOO-E6a-E6aOOOO-E6b-E6bOOOO1G.d1G.d",
stateData: 'g~OUROYPO~OSTO~OSTOTXO~O',
goto: 'nXPPY^PPPbhTROSTQOSQSORVSQUQRWU',
nodeNames: '⚠ Template Tag TagOpen TagContent TagClose Text',
stateData: "g~OUROYPO~OSTO~OSTOTXO~O",
goto: "nXPPY^PPPbhTROSTQOSQSORVSQUQRWU",
nodeNames: "⚠ Template Tag TagOpen TagContent TagClose Text",
maxTerm: 10,
propSources: [highlight],
skippedNodes: [0],
repeatNodeCount: 2,
tokenData:
"#{~RTOtbtu!zu;'Sb;'S;=`!o<%lOb~gTU~Otbtuvu;'Sb;'S;=`!o<%lOb~yVO#ob#o#p!`#p;'Sb;'S;=`!o<%l~b~Ob~~!u~!cSO!}b#O;'Sb;'S;=`!o<%lOb~!rP;=`<%lb~!zOU~~!}VO#ob#o#p#d#p;'Sb;'S;=`!o<%l~b~Ob~~!u~#gTO!}b!}#O#v#O;'Sb;'S;=`!o<%lOb~#{OY~",
tokenizers: [1, new LocalTokenGroup('b~RP#P#QU~XP#q#r[~aOT~~', 17, 4)],
tokenizers: [1, new LocalTokenGroup("b~RP#P#QU~XP#q#r[~aOT~~", 17, 4)],
topRules: { Template: [0, 1] },
tokenPrec: 0,
});

View File

@@ -1,5 +1,5 @@
import type { FormInput, TemplateFunction } from '@yaakapp-internal/plugins';
import type { Tokens } from '@yaakapp-internal/templates';
import type { FormInput, TemplateFunction } from "@yaakapp-internal/plugins";
import type { Tokens } from "@yaakapp-internal/templates";
/**
* Process the initial tokens from the template and merge those with the default values pulled from
@@ -8,21 +8,21 @@ import type { Tokens } from '@yaakapp-internal/templates';
export function collectArgumentValues(initialTokens: Tokens, templateFunction: TemplateFunction) {
const initial: Record<string, string | boolean> = {};
const initialArgs =
initialTokens.tokens[0]?.type === 'tag' && initialTokens.tokens[0]?.val.type === 'fn'
initialTokens.tokens[0]?.type === "tag" && initialTokens.tokens[0]?.val.type === "fn"
? initialTokens.tokens[0]?.val.args
: [];
const processArg = (arg: FormInput) => {
if ('inputs' in arg && arg.inputs) {
if ("inputs" in arg && arg.inputs) {
arg.inputs.forEach(processArg);
}
if (!('name' in arg)) return;
if (!("name" in arg)) return;
const initialArg = initialArgs.find((a) => a.name === arg.name);
const initialArgValue =
initialArg?.value.type === 'str'
initialArg?.value.type === "str"
? initialArg?.value.text
: initialArg?.value.type === 'bool'
: initialArg?.value.type === "bool"
? initialArg.value.value
: undefined;
const value = initialArgValue ?? arg.defaultValue;

View File

@@ -1,9 +1,9 @@
import { genericCompletion } from '../genericCompletion';
import { genericCompletion } from "../genericCompletion";
export const completions = genericCompletion({
options: [
{ label: 'http://', type: 'constant' },
{ label: 'https://', type: 'constant' },
{ label: "http://", type: "constant" },
{ label: "https://", type: "constant" },
],
minMatch: 1,
});

View File

@@ -1,8 +1,8 @@
import { LanguageSupport, LRLanguage } from '@codemirror/language';
import { parser } from './url';
import { LanguageSupport, LRLanguage } from "@codemirror/language";
import { parser } from "./url";
const urlLanguage = LRLanguage.define({
name: 'url',
name: "url",
parser,
languageData: {},
});

View File

@@ -1,4 +1,4 @@
import { styleTags, tags as t } from '@lezer/highlight';
import { styleTags, tags as t } from "@lezer/highlight";
export const highlight = styleTags({
Protocol: t.comment,

View File

@@ -1,13 +1,13 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import { LRParser } from '@lezer/lr';
import { highlight } from './highlight';
import { LRParser } from "@lezer/lr";
import { highlight } from "./highlight";
export const parser = LRParser.deserialize({
version: 14,
states:
"!|OQOPOOQYOPOOOTOPOOObOQO'#CdOjOPO'#C`OuOSO'#CcQOOOOOQ]OPOOOOOO,59O,59OOOOO-E6b-E6bOzOPO,58}O!SOSO'#CeO!XOPO1G.iOOOO,59P,59POOOO-E6c-E6c",
stateData: '!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~',
goto: 'nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[',
nodeNames: '⚠ url Protocol Host Path Placeholder PathSegment Query',
stateData: "!g~OQQORPO~OZRO[TO~OTWOUWO~OZROYSX[SX~O]YO~O^ZOYVa~O]]O~O^ZOYVi~OQRTUT~",
goto: "nYPPPPZPP^bhRVPTUPVQSPRXSQ[YR^[",
nodeNames: "⚠ url Protocol Host Path Placeholder PathSegment Query",
maxTerm: 14,
propSources: [highlight],
skippedNodes: [0],