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

@@ -1 +1,3 @@
export * from './debounce'; export * from './debounce';
export * from './formatSize';
export * from './templateFunction';

View File

@@ -0,0 +1,49 @@
import type {
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} from '@yaakapp-internal/plugins';
export function validateTemplateFunctionArgs(
fnName: string,
args: TemplateFunctionArg[],
values: CallTemplateFunctionArgs['values'],
): string | null {
for (const arg of args) {
if ('inputs' in arg && arg.inputs) {
// Recurse down
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
if (err) return err;
}
if (!('name' in arg)) continue;
if (arg.optional) continue;
if (arg.defaultValue != null) continue;
if (arg.hidden) continue;
if (values[arg.name] != null) continue;
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
}
return null;
}
/** Recursively apply form input defaults to a set of values */
export function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonPrimitive | undefined },
) {
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
for (const input of inputs) {
if ('defaultValue' in input && values[input.name] === undefined) {
newValues[input.name] = input.defaultValue;
}
if (input.type === 'checkbox' && values[input.name] === undefined) {
newValues[input.name] = false;
}
// Recurse down to all child inputs
if ('inputs' in input) {
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
}
}
return newValues;
}

View File

@@ -450,7 +450,11 @@ export type TemplateFunction = { name: string, previewType?: TemplateFunctionPre
* Also support alternative names. This is useful for not breaking existing * Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property * tags when changing the `name` property
*/ */
aliases?: Array<string>, args: Array<TemplateFunctionArg>, }; aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
previewArgs?: Array<string>, };
/** /**
* Similar to FormInput, but contains * Similar to FormInput, but contains

View File

@@ -1,3 +1,4 @@
import { validateTemplateFunctionArgs } from '@yaakapp-internal/lib/templateFunction';
import { import {
BootRequest, BootRequest,
DeleteKeyValueResponse, DeleteKeyValueResponse,
@@ -28,7 +29,6 @@ import path from 'node:path';
import { import {
applyDynamicFormInput, applyDynamicFormInput,
applyFormInputDefaults, applyFormInputDefaults,
validateTemplateFunctionArgs,
} from './common'; } from './common';
import { EventChannel } from './EventChannel'; import { EventChannel } from './EventChannel';
import { migrateTemplateFunctionSelectOptions } from './migrations'; import { migrateTemplateFunctionSelectOptions } from './migrations';

View File

@@ -1,31 +1,6 @@
import { import { CallHttpAuthenticationActionArgs, CallTemplateFunctionArgs } from '@yaakapp-internal/plugins';
CallHttpAuthenticationActionArgs,
CallTemplateFunctionArgs,
JsonPrimitive,
TemplateFunctionArg,
} from '@yaakapp-internal/plugins';
import { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api'; import { Context, DynamicAuthenticationArg, DynamicTemplateFunctionArg } from '@yaakapp/api';
/** Recursively apply form input defaults to a set of values */
export function applyFormInputDefaults(
inputs: TemplateFunctionArg[],
values: { [p: string]: JsonPrimitive | undefined },
) {
let newValues: { [p: string]: JsonPrimitive | undefined } = { ...values };
for (const input of inputs) {
if ('defaultValue' in input && values[input.name] === undefined) {
newValues[input.name] = input.defaultValue;
} else if (input.type === 'checkbox' && values[input.name] === undefined) {
newValues[input.name] = false;
}
// Recurse down to all child inputs
if ('inputs' in input) {
newValues = applyFormInputDefaults(input.inputs ?? [], newValues);
}
}
return newValues;
}
export async function applyDynamicFormInput( export async function applyDynamicFormInput(
ctx: Context, ctx: Context,
args: DynamicTemplateFunctionArg[], args: DynamicTemplateFunctionArg[],
@@ -60,26 +35,3 @@ export async function applyDynamicFormInput(
} }
return resolvedArgs; return resolvedArgs;
} }
export function validateTemplateFunctionArgs(
fnName: string,
args: TemplateFunctionArg[],
values: CallTemplateFunctionArgs['values'],
): string | null {
for (const arg of args) {
if ('inputs' in arg && arg.inputs) {
// Recurse down
const err = validateTemplateFunctionArgs(fnName, arg.inputs, values);
if (err) return err;
}
if (!('name' in arg)) continue;
if (arg.optional) continue;
if (arg.defaultValue != null) continue;
if (arg.hidden) continue;
if (values[arg.name] != null) continue;
return `Missing required argument "${arg.label || arg.name}" for template function ${fnName}()`;
}
return null;
}

View File

@@ -28,6 +28,7 @@ export const plugin: PluginDefinition = {
{ {
name: '1password.item', name: '1password.item',
description: 'Get a secret', description: 'Get a secret',
previewArgs: ['field'],
args: [ args: [
{ {
name: 'token', name: 'token',

View File

@@ -5,15 +5,18 @@ export const plugin: PluginDefinition = {
{ {
name: 'cookie.value', name: 'cookie.value',
description: 'Read the value of a cookie in the jar, by name', description: 'Read the value of a cookie in the jar, by name',
previewArgs: ['name'],
args: [ args: [
{ {
type: 'text', type: 'text',
name: 'cookie_name', name: 'name',
label: 'Cookie Name', label: 'Cookie Name',
}, },
], ],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
return ctx.cookies.getValue({ name: String(args.values.cookie_name) }); // The legacy name was cookie_name, but we changed it
const name = args.values.cookie_name ?? args.values.name;
return ctx.cookies.getValue({ name: String(name) });
}, },
}, },
], ],

View File

@@ -16,6 +16,7 @@ export const plugin: PluginDefinition = {
{ {
name: 'fs.readFile', name: 'fs.readFile',
description: 'Read the contents of a file as utf-8', description: 'Read the contents of a file as utf-8',
previewArgs: ['encoding'],
args: [ args: [
{ title: 'Select File', type: 'file', name: 'path', label: 'File' }, { title: 'Select File', type: 'file', name: 'path', label: 'File' },
{ {
@@ -26,14 +27,21 @@ export const plugin: PluginDefinition = {
description: "Specifies how the file's bytes are decoded into text when read", description: "Specifies how the file's bytes are decoded into text when read",
options, options,
}, },
{
type: 'checkbox',
name: 'trim',
label: 'Trim Whitespace',
description: 'Remove leading and trailing whitespace from the file contents',
},
], ],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
if (!args.values.path || !args.values.encoding) return null; if (!args.values.path || !args.values.encoding) return null;
try { try {
return fs.promises.readFile(String(args.values.path ?? ''), { const v = await fs.promises.readFile(String(args.values.path ?? ''), {
encoding: String(args.values.encoding ?? 'utf-8') as BufferEncoding, encoding: String(args.values.encoding ?? 'utf-8') as BufferEncoding,
}); });
return args.values.trim ? v.trim() : v;
} catch { } catch {
return null; return null;
} }

View File

@@ -11,6 +11,7 @@ export const plugin: PluginDefinition = {
{ {
name: 'json.jsonpath', name: 'json.jsonpath',
description: 'Filter JSON-formatted text using JSONPath syntax', description: 'Filter JSON-formatted text using JSONPath syntax',
previewArgs: ['query'],
args: [ args: [
{ {
type: 'editor', type: 'editor',

View File

@@ -16,8 +16,22 @@ export const plugin: PluginDefinition = {
name: 'prompt.text', name: 'prompt.text',
description: 'Prompt the user for input when sending a request', description: 'Prompt the user for input when sending a request',
previewType: 'click', previewType: 'click',
previewArgs: ['label'],
args: [ args: [
{ type: 'text', name: 'label', label: 'Label', optional: true }, {
type: 'text',
name: 'label',
label: 'Label',
optional: true,
dynamic(_ctx, args) {
if (
args.values.store === STORE_EXPIRE ||
(args.values.store === STORE_FOREVER && !args.values.key)
) {
return { optional: false };
}
},
},
{ {
type: 'select', type: 'select',
name: 'store', name: 'store',
@@ -68,21 +82,24 @@ export const plugin: PluginDefinition = {
{ {
type: 'banner', type: 'banner',
color: 'info', color: 'info',
inputs: [],
dynamic(_ctx, args) { dynamic(_ctx, args) {
return { hidden: args.values.store === STORE_NONE }; let key: string;
}, try {
inputs: [ key = buildKey(args);
{ } catch (err) {
type: 'markdown', return { color: 'danger', inputs: [{ type: 'markdown', content: String(err) }] };
content: '', }
async dynamic(_ctx, args) { return {
const key = buildKey(args); hidden: args.values.store === STORE_NONE,
return { inputs: [
{
type: 'markdown',
content: [`Value will be saved under: \`${key}\``].join('\n\n'), content: [`Value will be saved under: \`${key}\``].join('\n\n'),
}; },
}, ],
}, };
], },
}, },
{ {
type: 'accordion', type: 'accordion',
@@ -139,7 +156,7 @@ export const plugin: PluginDefinition = {
function buildKey(args: CallTemplateFunctionArgs) { function buildKey(args: CallTemplateFunctionArgs) {
if (!args.values.key && !args.values.label) { if (!args.values.key && !args.values.label) {
throw new Error('Key or Label is required when storing values'); throw new Error('A label or key is required when storing values');
} }
return [args.values.namespace, args.values.key || args.values.label] return [args.values.namespace, args.values.key || args.values.label]
.filter((v) => !!v) .filter((v) => !!v)

View File

@@ -5,6 +5,7 @@ export const plugin: PluginDefinition = {
{ {
name: 'random.range', name: 'random.range',
description: 'Generate a random number between two values', description: 'Generate a random number between two values',
previewArgs: ['min', 'max'],
args: [ args: [
{ {
type: 'text', type: 'text',

View File

@@ -24,6 +24,7 @@ export const plugin: PluginDefinition = {
name: 'regex.match', name: 'regex.match',
description: 'Extract text using a regular expression', description: 'Extract text using a regular expression',
args: [inputArg, regexArg], args: [inputArg, regexArg],
previewArgs: [regexArg.name],
async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> { async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const input = String(args.values.input ?? ''); const input = String(args.values.input ?? '');
const regex = new RegExp(String(args.values.regex ?? '')); const regex = new RegExp(String(args.values.regex ?? ''));
@@ -37,6 +38,7 @@ export const plugin: PluginDefinition = {
{ {
name: 'regex.replace', name: 'regex.replace',
description: 'Replace text using a regular expression', description: 'Replace text using a regular expression',
previewArgs: [regexArg.name],
args: [ args: [
inputArg, inputArg,
regexArg, regexArg,

View File

@@ -1,11 +1,20 @@
import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api';
import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models'; import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models';
import type { GenericCompletionOption } from '@yaakapp-internal/plugins'; import type { GenericCompletionOption } from '@yaakapp-internal/plugins';
import type { JSONPathResult } from '../../template-function-json';
import { filterJSONPath } from '../../template-function-json';
import type { XPathResult } from '../../template-function-xml';
import { filterXPath } from '../../template-function-xml';
const RETURN_FIRST = 'first';
const RETURN_ALL = 'all';
const RETURN_JOIN = 'join';
export const plugin: PluginDefinition = { export const plugin: PluginDefinition = {
templateFunctions: [ templateFunctions: [
{ {
name: 'request.body', name: 'request.body.raw',
aliases: ['request.body'],
args: [ args: [
{ {
name: 'requestId', name: 'requestId',
@@ -25,8 +34,115 @@ export const plugin: PluginDefinition = {
); );
}, },
}, },
{
name: 'request.body.path',
previewArgs: ['path'],
args: [
{ name: 'requestId', label: 'Http Request', type: 'http_request' },
{
type: 'h_stack',
inputs: [
{
type: 'select',
name: 'result',
label: 'Return Format',
defaultValue: RETURN_FIRST,
options: [
{ label: 'First result', value: RETURN_FIRST },
{ label: 'All results', value: RETURN_ALL },
{ label: 'Join with separator', value: RETURN_JOIN },
],
},
{
name: 'join',
type: 'text',
label: 'Separator',
optional: true,
defaultValue: ', ',
dynamic(_ctx, args) {
return { hidden: args.values.result !== RETURN_JOIN };
},
},
],
},
{
type: 'text',
name: 'path',
label: 'JSONPath or XPath',
placeholder: '$.books[0].id or /books[0]/id',
dynamic: async (ctx, args) => {
const requestId = String(args.values.requestId ?? 'n/a');
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const contentType =
httpRequest.headers
.find((h) => h.name.toLowerCase() === 'content-type')
?.value.toLowerCase() ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) {
return {
label: 'XPath',
placeholder: '/books[0]/id',
description: 'Enter an XPath expression used to filter the results',
};
}
return {
label: 'JSONPath',
placeholder: '$.books[0].id',
description: 'Enter a JSONPath expression used to filter the results',
};
},
},
],
async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise<string | null> {
const requestId = String(args.values.requestId ?? 'n/a');
const httpRequest = await ctx.httpRequest.getById({ id: requestId });
if (httpRequest == null) return null;
const body = httpRequest.body?.text ?? '';
try {
const result: JSONPathResult =
args.values.result === RETURN_ALL
? 'all'
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
return filterJSONPath(
body,
String(args.values.path || ''),
result,
args.values.join == null ? null : String(args.values.join),
);
} catch {
// Probably not JSON, try XPath
}
try {
const result: XPathResult =
args.values.result === RETURN_ALL
? 'all'
: args.values.result === RETURN_JOIN
? 'join'
: 'first';
return filterXPath(
body,
String(args.values.path || ''),
result,
args.values.join == null ? null : String(args.values.join),
);
} catch {
// Probably not XML
}
return null; // Bail out
},
},
{ {
name: 'request.header', name: 'request.header',
description: 'Read the value of a request header, by name',
previewArgs: ['header'],
args: [ args: [
{ {
name: 'requestId', name: 'requestId',

View File

@@ -62,6 +62,7 @@ export const plugin: PluginDefinition = {
{ {
name: 'response.header', name: 'response.header',
description: 'Read the value of a response header, by name', description: 'Read the value of a response header, by name',
previewArgs: ['header'],
args: [ args: [
requestArg, requestArg,
behaviorArgs, behaviorArgs,
@@ -108,6 +109,7 @@ export const plugin: PluginDefinition = {
name: 'response.body.path', name: 'response.body.path',
description: 'Access a field of the response body using JsonPath or XPath', description: 'Access a field of the response body using JsonPath or XPath',
aliases: ['response'], aliases: ['response'],
previewArgs: ['path'],
args: [ args: [
requestArg, requestArg,
behaviorArgs, behaviorArgs,
@@ -155,7 +157,9 @@ export const plugin: PluginDefinition = {
} }
const contentType = const contentType =
resp?.headers.find((h) => h.name.toLowerCase() === 'content-type')?.value ?? ''; resp?.headers
.find((h) => h.name.toLowerCase() === 'content-type')
?.value.toLowerCase() ?? '';
if (contentType.includes('xml') || contentType?.includes('html')) { if (contentType.includes('xml') || contentType?.includes('html')) {
return { return {
label: 'XPath', label: 'XPath',
@@ -187,9 +191,10 @@ export const plugin: PluginDefinition = {
return null; return null;
} }
const BOM = '\ufeff';
let body: string; let body: string;
try { try {
body = readFileSync(response.bodyPath, 'utf-8'); body = readFileSync(response.bodyPath, 'utf-8').replace(BOM, '');
} catch { } catch {
return null; return null;
} }

View File

@@ -81,12 +81,14 @@ export const plugin: PluginDefinition = {
name: 'timestamp.format', name: 'timestamp.format',
description: 'Format a date using a dayjs-compatible format string', description: 'Format a date using a dayjs-compatible format string',
args: [dateArg, formatArg], args: [dateArg, formatArg],
previewArgs: [formatArg.name],
onRender: async (_ctx, args) => formatDatetime(args.values), onRender: async (_ctx, args) => formatDatetime(args.values),
}, },
{ {
name: 'timestamp.offset', name: 'timestamp.offset',
description: 'Get the offset of a date based on an expression', description: 'Get the offset of a date based on an expression',
args: [dateArg, expressionArg], args: [dateArg, expressionArg],
previewArgs: [expressionArg.name],
onRender: async (_ctx, args) => calculateDatetime(args.values), onRender: async (_ctx, args) => calculateDatetime(args.values),
}, },
], ],

View File

@@ -11,6 +11,7 @@ export const plugin: PluginDefinition = {
{ {
name: 'xml.xpath', name: 'xml.xpath',
description: 'Filter XML-formatted text using XPath syntax', description: 'Filter XML-formatted text using XPath syntax',
previewArgs: ['query'],
args: [ args: [
{ {
type: 'text', type: 'text',

View File

@@ -4,7 +4,7 @@ const Downloader = require('nodejs-file-downloader');
const { rmSync, cpSync, mkdirSync, existsSync } = require('node:fs'); const { rmSync, cpSync, mkdirSync, existsSync } = require('node:fs');
const { execSync } = require('node:child_process'); const { execSync } = require('node:child_process');
const NODE_VERSION = 'v24.4.0'; const NODE_VERSION = 'v24.11.1';
// `${process.platform}_${process.arch}` // `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64'; const MAC_ARM = 'darwin_arm64';
@@ -80,7 +80,7 @@ rmSync(tmpDir, { recursive: true, force: true });
function tryExecSync(cmd) { function tryExecSync(cmd) {
try { try {
return execSync(cmd, { stdio: 'inherit' }).toString('utf-8'); return execSync(cmd, { stdio: 'pipe' }).toString('utf-8');
} catch (_) { } catch (_) {
return ''; return '';
} }

View File

@@ -4,7 +4,7 @@ const path = require('node:path');
const { rmSync, mkdirSync, cpSync, existsSync, statSync, chmodSync } = require('node:fs'); const { rmSync, mkdirSync, cpSync, existsSync, statSync, chmodSync } = require('node:fs');
const { execSync } = require('node:child_process'); const { execSync } = require('node:child_process');
const VERSION = '28.3'; const VERSION = '33.1';
// `${process.platform}_${process.arch}` // `${process.platform}_${process.arch}`
const MAC_ARM = 'darwin_arm64'; const MAC_ARM = 'darwin_arm64';
@@ -81,7 +81,7 @@ mkdirSync(dstDir, { recursive: true });
function tryExecSync(cmd) { function tryExecSync(cmd) {
try { try {
return execSync(cmd, { stdio: 'inherit' }).toString('utf-8'); return execSync(cmd, { stdio: 'pipe' }).toString('utf-8');
} catch (_) { } catch (_) {
return ''; return '';
} }

View File

@@ -450,7 +450,11 @@ export type TemplateFunction = { name: string, previewType?: TemplateFunctionPre
* Also support alternative names. This is useful for not breaking existing * Also support alternative names. This is useful for not breaking existing
* tags when changing the `name` property * tags when changing the `name` property
*/ */
aliases?: Array<string>, args: Array<TemplateFunctionArg>, }; aliases?: Array<string>, args: Array<TemplateFunctionArg>,
/**
* A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
*/
previewArgs?: Array<string>, };
/** /**
* Similar to FormInput, but contains * Similar to FormInput, but contains

View File

@@ -758,6 +758,10 @@ pub struct TemplateFunction {
#[ts(optional)] #[ts(optional)]
pub aliases: Option<Vec<String>>, pub aliases: Option<Vec<String>>,
pub args: Vec<TemplateFunctionArg>, pub args: Vec<TemplateFunctionArg>,
/// A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons).
#[ts(optional)]
pub preview_args: Option<Vec<String>>,
} }
/// Similar to FormInput, but contains /// Similar to FormInput, but contains

View File

@@ -22,6 +22,7 @@ pub(crate) fn template_function_secure() -> TemplateFunction {
preview_type: Some(TemplateFunctionPreviewType::None), preview_type: Some(TemplateFunctionPreviewType::None),
description: Some("Securely store encrypted text".to_string()), description: Some("Securely store encrypted text".to_string()),
aliases: None, aliases: None,
preview_args: None,
args: vec![TemplateFunctionArg::FormInput(FormInput::Text( args: vec![TemplateFunctionArg::FormInput(FormInput::Text(
FormInputText { FormInputText {
multi_line: Some(true), multi_line: Some(true),
@@ -68,6 +69,7 @@ pub(crate) fn template_function_keyring() -> TemplateFunction {
preview_type: Some(TemplateFunctionPreviewType::Live), preview_type: Some(TemplateFunctionPreviewType::Live),
description: Some(meta.description), description: Some(meta.description),
aliases: Some(vec!["keyring".to_string()]), aliases: Some(vec!["keyring".to_string()]),
preview_args: Some(vec!["service".to_string(), "account".to_string()]),
args: vec![ args: vec![
TemplateFunctionArg::FormInput(FormInput::Banner(FormInputBanner { TemplateFunctionArg::FormInput(FormInput::Banner(FormInputBanner {
inputs: Some(vec![FormInput::Markdown(FormInputMarkdown { inputs: Some(vec![FormInput::Markdown(FormInputMarkdown {

View File

@@ -101,12 +101,33 @@
.template-tag { .template-tag {
/* Colors */ /* 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 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; -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 { .hyperlink-widget {

View File

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

View File

@@ -3,6 +3,8 @@ import type { Range } from '@codemirror/state';
import type { DecorationSet, ViewUpdate } from '@codemirror/view'; import type { DecorationSet, ViewUpdate } from '@codemirror/view';
import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view'; import { Decoration, EditorView, ViewPlugin, WidgetType } from '@codemirror/view';
import type { SyntaxNodeRef } from '@lezer/common'; 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 { parseTemplate } from '@yaakapp-internal/templates';
import type { TwigCompletionOption } from './completion'; import type { TwigCompletionOption } from './completion';
import { collectArgumentValues } from './util'; import { collectArgumentValues } from './util';
@@ -42,7 +44,8 @@ class TemplateTagWidget extends WidgetType {
}`; }`;
elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? ''); elt.title = this.option.invalid ? 'Not Found' : (this.option.value ?? '');
elt.setAttribute('data-tag-type', this.option.type); 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); elt.addEventListener('click', this.#clickListenerCallback);
return elt; return elt;
} }
@@ -109,15 +112,11 @@ function templateTags(
if (option.type === 'function') { if (option.type === 'function') {
const tokens = parseTemplate(rawTag); const tokens = parseTemplate(rawTag);
const values = collectArgumentValues(tokens, option); const rawValues = collectArgumentValues(tokens, option);
for (const arg of option.args) { const values = applyFormInputDefaults(option.args, rawValues);
if (!('optional' in arg)) continue; const label = makeFunctionLabel(option, values);
if (!arg.optional && values[arg.name] == null) { const validationErr = validateTemplateFunctionArgs(option.name, option.args, values);
// Clone so we don't mutate the original option = { ...option, label, invalid: !!validationErr }; // Clone so we don't mutate the original
option = { ...option, invalid: true };
break;
}
}
} }
const widget = new TemplateTagWidget(option, rawTag, node.from); const widget = new TemplateTagWidget(option, rawTag, node.from);
@@ -169,3 +168,57 @@ function isSelectionInsideNode(view: EditorView, node: SyntaxNodeRef) {
} }
return false; 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;
}