vscode-front-matter icon indicating copy to clipboard operation
vscode-front-matter copied to clipboard

Enhancement: Advanced/Conditional behavior in snippets

Open michaeltlombardi opened this issue 2 years ago • 3 comments

Is your feature request related to a problem? Please describe.

As I started to fill out the snippets for my theme, I started to run into cases where I have duplicated snippets to account for limitations in the current model. I'll include a few examples here to demonstrate.

Example: Links

I have defined two snippets for the ref and relref Hugo shortcodes. They are nearly identical.

Permalink Definition
{
  "title": "Permalink",
  "description": "Insert a fully-qualified permalink to another page.",
  "body": "{{< ref \"[[path]]\" />}}",
  "fields": [
    {
      "name": "path",
      "title": "Relative Page Path",
      "description": "Specify the relative path to another page from this one.",
      "type": "string",
      "single": true,
      "required": true
    }
  ]
}
Relative Permalink Definition
{
  "title": "Relative Permalink",
  "description": "Insert a site-relative permalink to another page.",
  "body": "{{< relref \"[[path]]\" />}}",
  "fields": [
    {
      "name": "path",
      "title": "Relative Page Path",
      "description": "Specify the relative path to another page from this one.",
      "type": "string",
      "single": true,
      "required": true
    }
  ]
}

The only substantive difference between them is that the relative permalink's tag is relref and the non-relative permalink's tag is ref. Without a way to define any logic, only to pass variables (or empty strings where those variables could be, if undefined, I could do this, but I think the UX would be a little confusing:

Workaround Definition for Links
{
  "title": "Combined Permalink",
  "description": "Insert a permalink to another page on the site.",
  "body": "{{< [[relative]]ref \"[[path]]\" />}}",
  "fields": [
    {
      "name": "path",
      "title": "Page Path",
      "description": "Specify the path to another page.",
      "type": "string",
      "single": true,
      "required": true
    },
    {
      "name": "relative",
      "title": "Use Relative Path",
      "description": "Specify 'rel' to treat the input path as relative to this page.",
      "type": "choice",
      "choices": [
        "",
        "rel"
      ]
    }
  ]
}

Unused Fields

I have some shortcodes which are relatively complex, and made moreso by the possibility of having inner content, but not requiring it. One particular problem is that anything defined in the snippet will be added to the eventual Markdown, even if the fields aren't used.

For example, this snippet defining an embed for an itch game:

Itch embed definition
{
  "title": "Itch Embed",
  "description": "Add an embedded widget to a project on itch.io",
  "body": [
    "{{< itchio id=\"[[id]]\"",
    "           square=\"[[square]]\"",
    "           linkback=\"[[linkback]]\"",
    "           dark=\"[[dark]]\"",
    "/>}}"
  ],
  "fields": [
    {
      "name": "id",
      "title": "ID: Specify the ID for the project's embeddable iframe",
      "type": "string",
      "single": true,
      "default": ""
    },
    {
      "name": "square",
      "title": "Should display as a square instead of a rectangle",
      "type": "choice",
      "choices": ["", "true", "false"],
      "default": ""
    },
    {
      "name": "linkback",
      "title": "linkback",
      "type": "choice",
      "choices": ["", "true", "false"],
      "default": ""
    },
    {
      "name": "dark",
      "title": "dark",
      "type": "choice",
      "choices": ["", "true", "false"],
      "default": ""
    }
  ]
}

Here, even if the user only supplies the mandatory value for id, we still get:

{{< itchio id="ExampleID"
           square=""
           linkback=""
           dark=""
/>}}

To work around this (and the limitation for not being able to specify boolean values), I've just set everything to default to false, which means updating the config in tandem with the theme because instead of allowing the theme to handle default behavior internally, I'm reimplementing it in the snippet definition.

Describe the solution you'd like

I can think of a few different directions for this, chiefly extending the syntax and allowing for opt-in to scripted snippets.

Possibility: Extend Syntax

I think it might be possible to extend the existing syntax to support (simple) conditionals.

For example, in the case of the link definition:

Extended Syntax: if
{
  "title": "Combined Permalink",
  "description": "Insert a permalink to another page on the site.",
  "body": "{{< [[if(relative)]]rel[[end]]ref \"[[path]]\" />}}",
  "fields": [
    {
      "name": "path",
      "title": "Page Path",
      "description": "Specify the path to another page.",
      "type": "string",
      "single": true,
      "required": true
    },
    {
      "name": "relative",
      "title": "Use Relative Path",
      "description": "Specify whether to treat the input path as relative to this page.",
      "type": "boolean",
    }
  ]
}

I think there's definitely some concerns here with regard to overloading and over-complicating things. The further this gets from "array of strings to interpolate", the less useful it is to the majority of people I think. Too much cognitive load is definitely not the goal.

An alternative here would possibly be to allow maps in addition to strings in the body array.

Extended Syntax: Maps in Body
{
  "title": "Combined Permalink",
  "description": "Insert a permalink to another page on the site.",
  "body": [
    {
      "text": "{{<",
      "after": " "
    },
    {
      "if": {
        "relative": true,
        "text": "relref"
      },
      "else": {
        "text": "ref"
      },
      "after": " "
    },
    "\"[[path]]\" />}}"
  ],
  "fields": [
    {
      "name": "path",
      "title": "Page Path",
      "description": "Specify the path to another page.",
      "type": "string",
      "single": true,
      "required": true
    },
    {
      "name": "relative",
      "title": "Use Relative Path",
      "description": "Specify 'rel' to treat the input path as relative to this page.",
      "type": "boolean"
    }
  ]
}

This isn't thought out in detail, of course - just proposes a minimal idea for supporting if/else on the truthiness of a variable and controlling the spacing after the map entry (instead of always getting a newline).

Possibility: Scripted Snippets

I think this is likely the more coherent and functional longterm option, thought it requires at least as much thought around implementation and presents security concerns (in the same sense as any other scripts, I suppose).

This would look something like adding a script key to the snippet definition and pointing at a script file (maybe limited to javascript initially) that the snippet arguments get passed to for building and returning the string.

For the itch embed, that might look like this:

Itch Embed Scripted Snippet JSON
{
  "title": "Itch Embed",
  "description": "Add an embedded widget to a project on itch.io",
  "script": "./itch.js",
  "fields": [
    {
      "name": "id",
      "title": "ID: Specify the ID for the project's embeddable iframe",
      "type": "boolean"
    },
    {
      "name": "square",
      "title": "Should display as a square instead of a rectangle",
      "type": "boolean"
    },
    {
      "name": "linkback",
      "title": "linkback",
      "type": "boolean"
    },
    {
      "name": "dark",
      "title": "dark",
      "type": "boolean"
    }
  ]
}
Itch Embed Scripted Snippet Script

Apologies for terrible code 😭

const arguments = process.argv;

if (arguments && arguments.length > 0) {
  const fields = JSON.parse(arguments[2]); // The field data the user filled in
  const fieldPrefix = "\n           "      // Newline/spacing for additinoal keys
  var shortcode = `{{< itchio id="${fields.id}"`
  var closeOnNewLine = false

  if (fields.square) {
    closeOnNewLine = true
    shortcode += `${fieldPrefix}square=${fields.square}`
  }
  if (fields.linkback) {
    closeOnNewLine = true
    shortcode += `${fieldPrefix}linkback=${fields.linkback}`
  }
  if (fields.dark) {
    closeOnNewLine = true
    shortcode += `${fieldPrefix}dark=${fields.dark}`
  }

  if (closeOnNewLine) {
    shortcode += "\n"
  } else {
    shortcode += ' '
  }
  shortcode += '>}}'

  console.log(shortcode);
}

I think this would provide a lot of flexibility for those who need/want it but keep it scoped so there's still a really useful simplified option for people to use. This could also be a place to gate additional support for values, like boolean. If you need something more advanced than string/choice, you may well want to write a scripted snippet.

Describe alternatives you've considered

For now, I've been duplicating my snippets, explicitly setting defaults, and ignoring the unused values - a user can delete them or leave them in place.

Additional context

N/A

michaeltlombardi avatar Nov 07 '22 22:11 michaeltlombardi

Thank you for providing so many details again @michaeltlombardi, and I see the need for it. Looking at the two solutions you provided, I think the scripted snippets come closest to what we already have. Plus, as you already mentioned, you can do more advanced things.

If I think about it for my website, a scripted snippet will allow me to insert an image and automatically generate the lqip (low quality image placeholder). At the moment, I got a custom action for this.

The only thing to think about is how to provide the arguments and in which structure/order.

estruyf avatar Nov 08 '22 09:11 estruyf

For the arguments, is there anything we need to pass that isn't defined in the snippet fields? I think the only major difference in need from the existing structure is a way to specify the & prefixed variables, like &selection or &mediaURL.

For passing the arguments, I think passing the JSON blob of the data itself is likely the easiest option. That makes this template functional for everyone:

const arguments = process.argv;

if (arguments && arguments.length > 0) {
  const fields = JSON.parse(arguments[2]); // The field data the user filled in
  var snippet = ""; // initialize the string of snippet text

  // Your logic for building the snippet here

  // Write the snippet text back to Front Matter for insertion
  console.log(snippet);
}

And the PowerShell equivalent would be

if ($Args.length -gt 0) {
  $Fields = ConvertFrom-Json $Args[0] # The field data the user filled in
  $Snippet = '' # Initialize the string of snippet text

  # Your logic for building the snippet here

  # Write the snippet text back to Front Matter for insertion
  $Snippet
}

I think trying to figure out how to order and pass the arguments as something other than a JSON blob is likely going to get a little complex and every language has pretty good methods for converting from a JSON blob into their native data structures (except bash, maybe? though in that case I would expect to use something like jq to handle the data).

michaeltlombardi avatar Nov 08 '22 13:11 michaeltlombardi

Probably a JSON string will be best indeed. In the script, you get the complete object as a sting, parse it, and are ready to go.

estruyf avatar Nov 09 '22 07:11 estruyf