Turning Umbraco tags into social hashtags with a custom Automate action
When using my custom Automate connections, I wanted to add hashtags when pushing to my socials, such as LinkedIn, Mastodon and Bluesky. On each of my blogs I have a tag property editor which would be ideal for my hashtags and since the whole process of pushing to my socials is automatic, I needed a way to push these tags as hashtags.
I started to create some new workflows within Automate, playing with if statements, loops and foreach actions. I could find a way to read the property editor with the tags, I could iterate over the values but I couldn't get just the values e.g. umbraco automate coding.
I headed over to the official docs for Automate but still couldn't find a way to do what I needed, so I built a custom parser action.
Why the built in actions weren't enough
Automate passes data between steps using bindings. These are the ${ ... } placeholders you drop into a field. There are a handful of built in filters too, like uppercase, truncate, stripHtml and json, and you can loop over a collection with a For Each.
The problem is there's no filter that takes a list and maps over it. There's nothing that says put a # in front of every item and join them back together with spaces. For Each will iterate my tags happily enough, but there's no easy way to collect the results back into a single string to pass to the next step.
There's also the question of how the tags arrive. The Tags property editor stores its value as a JSON array, so by the time I fetch it with a Get Content Property step, the value looks like this:
["umbraco","automate","coding"]
I don't want that. I want #umbraco #automate #coding.
No combination of loops and filters was going to get me there, so I wrote a small custom action instead. I put it in its own package called OC.Automate.Transforms so it's reusable and kept separate from my social connections. I may release it as a separate package or you can just use this blog to make your own.
The custom parser action
An Automate action is a class with an [Action] attribute that inherits from ActionBase. Mine produces an output for later steps to use, so it inherits from ActionBase<TSettings, TOutput>. One type is for the settings the editor fills in and the other is for the value I hand back.
First, the settings the editor sees in the backoffice:
using Umbraco.Automate.Core.Settings;
namespace OC.Automate.Transforms.Settings;
public sealed class FormatHashtagsSettings
{
[Field(
Label = "Tags",
Description = "Point this at your tags data - use the binding picker, or a path such as steps.getContentProperty.value.",
SupportsBindings = true,
SortOrder = 0)]
public string Tags { get; set; } = string.Empty;
// Nullable, so the field is optional. A non-nullable string here is treated
// as [Required] by the validation layer and fails if left blank.
[Field(
Label = "Separator",
Description = "Optional. Text placed BETWEEN the hashtags. The # is added automatically. Leave blank for a single space.",
SortOrder = 1)]
public string? Separator { get; set; }
}
The output, a single string my later steps can bind to:
namespace OC.Automate.Transforms.Settings;
public sealed class FormatHashtagsOutput
{
public string Hashtags { get; set; } = string.Empty;
}
And the action itself:
using System.Text.Json;
using OC.Automate.Transforms.Settings;
using Umbraco.Automate.Core.Actions;
namespace OC.Automate.Transforms.Actions;
[Action("ocTransformsFormatHashtags", "Format Hashtags",
Description = "Turns a tags array into a separated list of #hashtags.",
Group = "Transforms",
Icon = "icon-tags")]
public sealed class FormatHashtagsAction : ActionBase<FormatHashtagsSettings, FormatHashtagsOutput>
{
private static readonly char[] Delimiters = [',', '\n', '\r'];
public FormatHashtagsAction(ActionInfrastructure infrastructure)
: base(infrastructure)
{
}
public override Task<ActionResult> ExecuteAsync(ActionContext context, CancellationToken cancellationToken)
{
var settings = context.GetSettings<FormatHashtagsSettings>();
var separator = string.IsNullOrEmpty(settings.Separator) ? " " : settings.Separator;
var hashtags = string.Join(separator, ParseTags(settings.Tags).Select(ToHashtag));
return Task.FromResult(Success(new FormatHashtagsOutput { Hashtags = hashtags }));
}
// Prefixes a tag with '#', dropping any existing '#' and inner spaces.
private static string ToHashtag(string tag) => '#' + tag.TrimStart('#').Replace(" ", string.Empty);
// Accepts a JSON array (how the Tags editor stores its value) or a comma/newline list.
private static IEnumerable<string> ParseTags(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return [];
}
raw = raw.Trim();
var tags = raw.StartsWith('[') && TryParseJsonArray(raw, out var parsed)
? parsed
: raw.Split(Delimiters, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tags
.Where(tag => !string.IsNullOrWhiteSpace(tag))
.Select(tag => tag.Trim());
}
private static bool TryParseJsonArray(string json, out string[] values)
{
try
{
values = JsonSerializer.Deserialize<string[]>(json) ?? [];
return true;
}
catch (JsonException)
{
values = [];
return false;
}
}
}
The last piece is registering it in a composer so Automate picks it up:
using OC.Automate.Transforms.Actions;
using Umbraco.Automate.Core.Actions;
using Umbraco.Cms.Core.Composing;
using Umbraco.Cms.Core.DependencyInjection;
namespace OC.Automate.Transforms.Composers;
public class TransformsComposer : IComposer
{
public void Compose(IUmbracoBuilder builder)
{
builder.WithCollectionBuilder<ActionCollectionBuilder>()
.Add<FormatHashtagsAction>();
}
}
The part doing the work is ParseTags. If the value looks like a JSON array (["umbraco","automate"]) it deserialises it. If that fails, or if the value is a plain comma or newline separated list, it splits on delimiters instead. Either way it gives back a clean list of tags. ToHashtag puts a # on the front of each one and strips any stray # or inner spaces, then string.Join puts them together.
Wiring it into the automation
Once it's registered, the action shows up in the automation builder under a Transforms group. My workflow looks like this:
- Trigger on Content Published.
- Get Content Property, pointed at my
tagsproperty. Itsvalueoutput is the JSON array. - Format Hashtags, with the
Tagsfield bound to${ steps.getContentProperty.value }and the Separator left blank. - Send to socials, my Mastodon, Bluesky or LinkedIn action, with the hashtags dropped into the post body:
${ steps.someContentStep.value }
${ steps.ocTransformsFormatHashtags.hashtags }
A couple of gotchas
Two things caught me out, both worth flagging if you're building your own action.
The first was that string fields which aren't nullable get treated as required. My Separator started out as a plain string. With nullable reference types turned on, the validation layer treats that as [Required], so leaving the field blank killed the whole step with a ConfigurationError before my code even ran. Changing it to string? fixed it. Optional fields need to be nullable.
The second was the binding path. My first attempt bound the Tags field to ${ steps.getContentProperty.value.tags }. That .tags on the end doesn't exist. The tags are the value, so the binding resolved to the string "null" and I got a single #null out the other end. Changing it to ${ steps.getContentProperty.value } sorted it. The lesson is to use the binding picker rather than typing paths by hand, as it shows you exactly what each step exposes.
The result
Publish a post tagged umbraco, automate and coding, and the automation now appends:
#umbraco #automate #coding
to every post that goes out to Mastodon, Bluesky and LinkedIn. There's no copying and pasting and no separate list of hashtags to maintain. They come straight from the tags I was already adding to the blog.
The whole thing lives in a small reusable package called OC.Automate.Transforms, which I can add more transform actions to whenever a workflow needs one.