Add previewArgs support for template functions and enhance validation logic for form inputs

This commit is contained in:
Gregory Schier
2025-11-27 12:55:39 -08:00
parent 0c7034eefc
commit 8d1b17cac1
24 changed files with 340 additions and 92 deletions

View File

@@ -101,12 +101,33 @@
.template-tag {
/* Colors */
@apply bg-surface text-text-subtle border-border-subtle whitespace-nowrap;
@apply bg-surface text-text border-border-subtle whitespace-nowrap;
@apply hover:border-border-subtle hover:text-text hover:bg-surface-highlight;
@apply inline border px-1 mx-[0.5px] rounded cursor-default dark:shadow;
@apply inline border px-1 mx-[0.5px] rounded dark:shadow;
-webkit-text-security: none;
* {
@apply cursor-default;
}
.fn {
@apply inline-block;
.fn-inner {
@apply text-text-subtle max-w-[40em] italic inline-flex items-end whitespace-pre text-[0.9em];
}
.fn-arg-name {
/* Nothing yet */
@apply opacity-60;
}
.fn-arg-value {
@apply inline-block truncate;
}
.fn-bracket {
@apply text-text-subtle opacity-30;
}
}
}
.hyperlink-widget {

View File

@@ -23,7 +23,7 @@ export type TwigCompletionOption = (
| TwigCompletionOptionNamespace
) & {
name: string;
label: string;
label: string | HTMLElement;
description?: string;
onClick: (rawTag: string, startPos: number) => void;
value: string | null;
@@ -34,7 +34,7 @@ export interface TwigCompletionConfig {
options: TwigCompletionOption[];
}
const MIN_MATCH_NAME = 2;
const MIN_MATCH_NAME = 1;
export function twigCompletion({ options }: TwigCompletionConfig) {
return function completions(context: CompletionContext) {
@@ -44,7 +44,7 @@ export function twigCompletion({ options }: TwigCompletionConfig) {
if (toMatch === null) return null;
const matchLen = toMatch.to - toMatch.from;
if (toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
if (!context.explicit && toMatch.from > 0 && matchLen < MIN_MATCH_NAME) {
return null;
}

View File

@@ -3,6 +3,8 @@ 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';
@@ -42,7 +44,8 @@ class TemplateTagWidget extends WidgetType {
}`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type);
elt.textContent = this.option.label;
if (typeof this.option.label === 'string') elt.textContent = this.option.label;
else elt.appendChild(this.option.label);
elt.addEventListener('click', this.#clickListenerCallback);
return elt;
}
@@ -109,15 +112,11 @@ function templateTags(
if (option.type === 'function') {
const tokens = parseTemplate(rawTag);
const values = collectArgumentValues(tokens, option);
for (const arg of option.args) {
if (!('optional' in arg)) continue;
if (!arg.optional && values[arg.name] == null) {
// Clone so we don't mutate the original
option = { ...option, invalid: true };
break;
}
}
const rawValues = collectArgumentValues(tokens, option);
const values = applyFormInputDefaults(option.args, rawValues);
const label = makeFunctionLabel(option, values);
const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);
option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original
}
const widget = new TemplateTagWidget(option, rawTag, node.from);
@@ -169,3 +168,57 @@ function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
}
return false;
}
function makeFunctionLabel(
fn: TemplateFunction,
values: { [p: string]: JsonPrimitive | undefined },
): 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 = '(';
$outer.appendChild(document.createTextNode(fn.name));
$outer.appendChild($bOpen);
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] || '');
if (!v) return;
if (all.length > 1) {
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;
$inner.appendChild($v);
});
fn.args.forEach((a: FormInput, i: number) => {
if (!('name' in a)) return;
const v = values[a.name];
if (v == null) return;
if (i > 0) $inner.title += '\n';
$inner.title += `${a.name} = ${JSON.stringify(v)}`;
});
if ($inner.childNodes.length === 0) {
$inner.appendChild(document.createTextNode('…'));
}
$outer.appendChild($inner);
const $bClose = document.createElement('span');
$bClose.className = 'fn-bracket';
$bClose.textContent = ')';
$outer.appendChild($bClose);
return $outer;
}