diff --git a/packages/common-lib/index.ts b/packages/common-lib/index.ts index 8a03cb24..7297c64e 100644 --- a/packages/common-lib/index.ts +++ b/packages/common-lib/index.ts @@ -1 +1,3 @@ export * from './debounce'; +export * from './formatSize'; +export * from './templateFunction'; diff --git a/packages/common-lib/templateFunction.ts b/packages/common-lib/templateFunction.ts new file mode 100644 index 00000000..c4e81123 --- /dev/null +++ b/packages/common-lib/templateFunction.ts @@ -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; +} diff --git a/packages/plugin-runtime-types/src/bindings/gen_events.ts b/packages/plugin-runtime-types/src/bindings/gen_events.ts index 9aeb7b68..a4fd3ff2 100644 --- a/packages/plugin-runtime-types/src/bindings/gen_events.ts +++ b/packages/plugin-runtime-types/src/bindings/gen_events.ts @@ -450,7 +450,11 @@ export type TemplateFunction = { name: string, previewType?: TemplateFunctionPre * Also support alternative names. This is useful for not breaking existing * tags when changing the `name` property */ -aliases?: Array, args: Array, }; +aliases?: Array, args: Array, +/** + * A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons). + */ +previewArgs?: Array, }; /** * Similar to FormInput, but contains diff --git a/packages/plugin-runtime/src/PluginInstance.ts b/packages/plugin-runtime/src/PluginInstance.ts index 056f0ddb..84864187 100644 --- a/packages/plugin-runtime/src/PluginInstance.ts +++ b/packages/plugin-runtime/src/PluginInstance.ts @@ -1,3 +1,4 @@ +import { validateTemplateFunctionArgs } from '@yaakapp-internal/lib/templateFunction'; import { BootRequest, DeleteKeyValueResponse, @@ -28,7 +29,6 @@ import path from 'node:path'; import { applyDynamicFormInput, applyFormInputDefaults, - validateTemplateFunctionArgs, } from './common'; import { EventChannel } from './EventChannel'; import { migrateTemplateFunctionSelectOptions } from './migrations'; diff --git a/packages/plugin-runtime/src/common.ts b/packages/plugin-runtime/src/common.ts index c871ac8b..cfdcdf0f 100644 --- a/packages/plugin-runtime/src/common.ts +++ b/packages/plugin-runtime/src/common.ts @@ -1,31 +1,6 @@ -import { - CallHttpAuthenticationActionArgs, - CallTemplateFunctionArgs, - JsonPrimitive, - TemplateFunctionArg, -} from '@yaakapp-internal/plugins'; +import { CallHttpAuthenticationActionArgs, CallTemplateFunctionArgs } from '@yaakapp-internal/plugins'; 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( ctx: Context, args: DynamicTemplateFunctionArg[], @@ -60,26 +35,3 @@ export async function applyDynamicFormInput( } 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; -} diff --git a/plugins/template-function-1password/src/index.ts b/plugins/template-function-1password/src/index.ts index af920185..bee4054a 100644 --- a/plugins/template-function-1password/src/index.ts +++ b/plugins/template-function-1password/src/index.ts @@ -28,6 +28,7 @@ export const plugin: PluginDefinition = { { name: '1password.item', description: 'Get a secret', + previewArgs: ['field'], args: [ { name: 'token', diff --git a/plugins/template-function-cookie/src/index.ts b/plugins/template-function-cookie/src/index.ts index 0415b533..34baa045 100644 --- a/plugins/template-function-cookie/src/index.ts +++ b/plugins/template-function-cookie/src/index.ts @@ -5,15 +5,18 @@ export const plugin: PluginDefinition = { { name: 'cookie.value', description: 'Read the value of a cookie in the jar, by name', + previewArgs: ['name'], args: [ { type: 'text', - name: 'cookie_name', + name: 'name', label: 'Cookie Name', }, ], async onRender(ctx: Context, args: CallTemplateFunctionArgs): Promise { - 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) }); }, }, ], diff --git a/plugins/template-function-fs/src/index.ts b/plugins/template-function-fs/src/index.ts index c3ee7efb..18799e93 100644 --- a/plugins/template-function-fs/src/index.ts +++ b/plugins/template-function-fs/src/index.ts @@ -16,6 +16,7 @@ export const plugin: PluginDefinition = { { name: 'fs.readFile', description: 'Read the contents of a file as utf-8', + previewArgs: ['encoding'], args: [ { 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", 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 { if (!args.values.path || !args.values.encoding) return null; 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, }); + return args.values.trim ? v.trim() : v; } catch { return null; } diff --git a/plugins/template-function-json/src/index.ts b/plugins/template-function-json/src/index.ts index dc7c36a3..0492dfc2 100755 --- a/plugins/template-function-json/src/index.ts +++ b/plugins/template-function-json/src/index.ts @@ -11,6 +11,7 @@ export const plugin: PluginDefinition = { { name: 'json.jsonpath', description: 'Filter JSON-formatted text using JSONPath syntax', + previewArgs: ['query'], args: [ { type: 'editor', diff --git a/plugins/template-function-prompt/src/index.ts b/plugins/template-function-prompt/src/index.ts index fac43348..bc8a008d 100644 --- a/plugins/template-function-prompt/src/index.ts +++ b/plugins/template-function-prompt/src/index.ts @@ -16,8 +16,22 @@ export const plugin: PluginDefinition = { name: 'prompt.text', description: 'Prompt the user for input when sending a request', previewType: 'click', + previewArgs: ['label'], 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', name: 'store', @@ -68,21 +82,24 @@ export const plugin: PluginDefinition = { { type: 'banner', color: 'info', + inputs: [], dynamic(_ctx, args) { - return { hidden: args.values.store === STORE_NONE }; - }, - inputs: [ - { - type: 'markdown', - content: '', - async dynamic(_ctx, args) { - const key = buildKey(args); - return { + let key: string; + try { + key = buildKey(args); + } catch (err) { + return { color: 'danger', inputs: [{ type: 'markdown', content: String(err) }] }; + } + return { + hidden: args.values.store === STORE_NONE, + inputs: [ + { + type: 'markdown', content: [`Value will be saved under: \`${key}\``].join('\n\n'), - }; - }, - }, - ], + }, + ], + }; + }, }, { type: 'accordion', @@ -139,7 +156,7 @@ export const plugin: PluginDefinition = { function buildKey(args: CallTemplateFunctionArgs) { 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] .filter((v) => !!v) diff --git a/plugins/template-function-random/src/index.ts b/plugins/template-function-random/src/index.ts index a4591f7e..8212b745 100644 --- a/plugins/template-function-random/src/index.ts +++ b/plugins/template-function-random/src/index.ts @@ -5,6 +5,7 @@ export const plugin: PluginDefinition = { { name: 'random.range', description: 'Generate a random number between two values', + previewArgs: ['min', 'max'], args: [ { type: 'text', diff --git a/plugins/template-function-regex/src/index.ts b/plugins/template-function-regex/src/index.ts index 1b42d9c2..53a16b91 100644 --- a/plugins/template-function-regex/src/index.ts +++ b/plugins/template-function-regex/src/index.ts @@ -24,6 +24,7 @@ export const plugin: PluginDefinition = { name: 'regex.match', description: 'Extract text using a regular expression', args: [inputArg, regexArg], + previewArgs: [regexArg.name], async onRender(_ctx: Context, args: CallTemplateFunctionArgs): Promise { const input = String(args.values.input ?? ''); const regex = new RegExp(String(args.values.regex ?? '')); @@ -37,6 +38,7 @@ export const plugin: PluginDefinition = { { name: 'regex.replace', description: 'Replace text using a regular expression', + previewArgs: [regexArg.name], args: [ inputArg, regexArg, diff --git a/plugins/template-function-request/src/index.ts b/plugins/template-function-request/src/index.ts index 94b1b998..fe379c3f 100755 --- a/plugins/template-function-request/src/index.ts +++ b/plugins/template-function-request/src/index.ts @@ -1,11 +1,20 @@ import type { CallTemplateFunctionArgs, Context, PluginDefinition } from '@yaakapp/api'; import type { AnyModel, HttpUrlParameter } from '@yaakapp-internal/models'; 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 = { templateFunctions: [ { - name: 'request.body', + name: 'request.body.raw', + aliases: ['request.body'], args: [ { 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 { + 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', + description: 'Read the value of a request header, by name', + previewArgs: ['header'], args: [ { name: 'requestId', diff --git a/plugins/template-function-response/src/index.ts b/plugins/template-function-response/src/index.ts index 4b19f600..6294d208 100644 --- a/plugins/template-function-response/src/index.ts +++ b/plugins/template-function-response/src/index.ts @@ -62,6 +62,7 @@ export const plugin: PluginDefinition = { { name: 'response.header', description: 'Read the value of a response header, by name', + previewArgs: ['header'], args: [ requestArg, behaviorArgs, @@ -108,6 +109,7 @@ export const plugin: PluginDefinition = { name: 'response.body.path', description: 'Access a field of the response body using JsonPath or XPath', aliases: ['response'], + previewArgs: ['path'], args: [ requestArg, behaviorArgs, @@ -155,7 +157,9 @@ export const plugin: PluginDefinition = { } 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')) { return { label: 'XPath', @@ -187,9 +191,10 @@ export const plugin: PluginDefinition = { return null; } + const BOM = '\ufeff'; let body: string; try { - body = readFileSync(response.bodyPath, 'utf-8'); + body = readFileSync(response.bodyPath, 'utf-8').replace(BOM, ''); } catch { return null; } diff --git a/plugins/template-function-timestamp/src/index.ts b/plugins/template-function-timestamp/src/index.ts index 3f6cffb6..556b9d00 100755 --- a/plugins/template-function-timestamp/src/index.ts +++ b/plugins/template-function-timestamp/src/index.ts @@ -81,12 +81,14 @@ export const plugin: PluginDefinition = { name: 'timestamp.format', description: 'Format a date using a dayjs-compatible format string', args: [dateArg, formatArg], + previewArgs: [formatArg.name], onRender: async (_ctx, args) => formatDatetime(args.values), }, { name: 'timestamp.offset', description: 'Get the offset of a date based on an expression', args: [dateArg, expressionArg], + previewArgs: [expressionArg.name], onRender: async (_ctx, args) => calculateDatetime(args.values), }, ], diff --git a/plugins/template-function-xml/src/index.ts b/plugins/template-function-xml/src/index.ts index 9c62ce53..bb1e732b 100755 --- a/plugins/template-function-xml/src/index.ts +++ b/plugins/template-function-xml/src/index.ts @@ -11,6 +11,7 @@ export const plugin: PluginDefinition = { { name: 'xml.xpath', description: 'Filter XML-formatted text using XPath syntax', + previewArgs: ['query'], args: [ { type: 'text', diff --git a/scripts/vendor-node.cjs b/scripts/vendor-node.cjs index 4a759abe..4b1ec26a 100644 --- a/scripts/vendor-node.cjs +++ b/scripts/vendor-node.cjs @@ -4,7 +4,7 @@ const Downloader = require('nodejs-file-downloader'); const { rmSync, cpSync, mkdirSync, existsSync } = require('node:fs'); const { execSync } = require('node:child_process'); -const NODE_VERSION = 'v24.4.0'; +const NODE_VERSION = 'v24.11.1'; // `${process.platform}_${process.arch}` const MAC_ARM = 'darwin_arm64'; @@ -80,7 +80,7 @@ rmSync(tmpDir, { recursive: true, force: true }); function tryExecSync(cmd) { try { - return execSync(cmd, { stdio: 'inherit' }).toString('utf-8'); + return execSync(cmd, { stdio: 'pipe' }).toString('utf-8'); } catch (_) { return ''; } diff --git a/scripts/vendor-protoc.cjs b/scripts/vendor-protoc.cjs index bb8af68e..2e943c17 100644 --- a/scripts/vendor-protoc.cjs +++ b/scripts/vendor-protoc.cjs @@ -4,7 +4,7 @@ const path = require('node:path'); const { rmSync, mkdirSync, cpSync, existsSync, statSync, chmodSync } = require('node:fs'); const { execSync } = require('node:child_process'); -const VERSION = '28.3'; +const VERSION = '33.1'; // `${process.platform}_${process.arch}` const MAC_ARM = 'darwin_arm64'; @@ -81,7 +81,7 @@ mkdirSync(dstDir, { recursive: true }); function tryExecSync(cmd) { try { - return execSync(cmd, { stdio: 'inherit' }).toString('utf-8'); + return execSync(cmd, { stdio: 'pipe' }).toString('utf-8'); } catch (_) { return ''; } diff --git a/src-tauri/yaak-plugins/bindings/gen_events.ts b/src-tauri/yaak-plugins/bindings/gen_events.ts index 9aeb7b68..a4fd3ff2 100644 --- a/src-tauri/yaak-plugins/bindings/gen_events.ts +++ b/src-tauri/yaak-plugins/bindings/gen_events.ts @@ -450,7 +450,11 @@ export type TemplateFunction = { name: string, previewType?: TemplateFunctionPre * Also support alternative names. This is useful for not breaking existing * tags when changing the `name` property */ -aliases?: Array, args: Array, }; +aliases?: Array, args: Array, +/** + * A list of arg names to show in the inline preview. If not provided, none will be shown (for privacy reasons). + */ +previewArgs?: Array, }; /** * Similar to FormInput, but contains diff --git a/src-tauri/yaak-plugins/src/events.rs b/src-tauri/yaak-plugins/src/events.rs index 6e529766..008e3cc4 100644 --- a/src-tauri/yaak-plugins/src/events.rs +++ b/src-tauri/yaak-plugins/src/events.rs @@ -758,6 +758,10 @@ pub struct TemplateFunction { #[ts(optional)] pub aliases: Option>, pub args: Vec, + + /// 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>, } /// Similar to FormInput, but contains diff --git a/src-tauri/yaak-plugins/src/native_template_functions.rs b/src-tauri/yaak-plugins/src/native_template_functions.rs index 9f05d700..fb1dd575 100644 --- a/src-tauri/yaak-plugins/src/native_template_functions.rs +++ b/src-tauri/yaak-plugins/src/native_template_functions.rs @@ -22,6 +22,7 @@ pub(crate) fn template_function_secure() -> TemplateFunction { preview_type: Some(TemplateFunctionPreviewType::None), description: Some("Securely store encrypted text".to_string()), aliases: None, + preview_args: None, args: vec![TemplateFunctionArg::FormInput(FormInput::Text( FormInputText { multi_line: Some(true), @@ -68,6 +69,7 @@ pub(crate) fn template_function_keyring() -> TemplateFunction { preview_type: Some(TemplateFunctionPreviewType::Live), description: Some(meta.description), aliases: Some(vec!["keyring".to_string()]), + preview_args: Some(vec!["service".to_string(), "account".to_string()]), args: vec![ TemplateFunctionArg::FormInput(FormInput::Banner(FormInputBanner { inputs: Some(vec![FormInput::Markdown(FormInputMarkdown { diff --git a/src-web/components/core/Editor/Editor.css b/src-web/components/core/Editor/Editor.css index bf544c70..72467b11 100644 --- a/src-web/components/core/Editor/Editor.css +++ b/src-web/components/core/Editor/Editor.css @@ -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 { diff --git a/src-web/components/core/Editor/twig/completion.ts b/src-web/components/core/Editor/twig/completion.ts index 35c7e021..5c9bdde2 100644 --- a/src-web/components/core/Editor/twig/completion.ts +++ b/src-web/components/core/Editor/twig/completion.ts @@ -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; } diff --git a/src-web/components/core/Editor/twig/templateTags.ts b/src-web/components/core/Editor/twig/templateTags.ts index 273ec5c1..41701fad 100644 --- a/src-web/components/core/Editor/twig/templateTags.ts +++ b/src-web/components/core/Editor/twig/templateTags.ts @@ -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; +}