Not too long ago, Bohemian Coding created a JavaScript API for their design application, Sketch.

The API is now friendlier than it originally was. If you looked at it initially and were put off, I’d encourage you to take another look. The documentation isn’t perfect. However, I was able to muddle through and produce a plugin in short order – and I am no JavaScript wizard!

This post provides a birds–eye walk-through. It covers how to make and update a simple plugin for Sketch with JavaScript. I’d suggest you don’t concern yourself too much with what this plugin does, it was purely to scratch my own itch. Instead, consider the possibilities of what you might do with the API.

In a prior post I described making a language switcher with JavaScript. This was to test whether the longer and shorter equivalent strings of different languages work in the same situation. Longer strings can dictate different design choices; it’s useful to know these things up front. A colleague suggested this functionality would be useful in Sketch.

This seemed like a golden opportunity to further cut my teeth with JavaScript. This became the challenge to surmount with JavaScript and the Sketch API.

The Sketch developer documentation gives a great starting point for making a Sketch plugin. Particularly the ‘Your First Plugin’ page. Follow that tutorial through so your dev environment is set up. Then you are ready to make something more meaningful.

Initially, I had an issue installing the Sketch Package Manager. I had a prior version of Node caching something or other. Long story short, if you have a similar issue, try running the following two commands in Terminal. Then re-install Node from nodejs.org


rm /usr/local/bin/node
rm -rf /usr/local/lib/node_modules/npm

I’m continuing here as if you have followed that prior Sketch tutorial through. The current developer experience is that there is a local folder for your plugin that contains the following folders:

  • assets this folder contains any images and can also contain your appcast.xml. More on the appcast.xml file shortly.
  • node_modules the Sketch plugin dev environment runs on Node so no surprises here
  • src this folder contains your plugin JavaScript file(s) alongside a manifest.json

Also in the root are a package.json, a package-lock.json a .gitignore and a README.md. It is only package.json that needs to be edited. Of the folder, it is only the src and assets folders you should concern yourself with while writing a plugin.

If you follow the ‘Your First Plugin’ tutorial through, you will know you can run npm run watch so that changes to your files are monitored and the plugin will re-build. Setting the flag defaults write ~/Library/Preferences/com.bohemiancoding.sketch3.plist AlwaysReloadScript -bool YES in Terminal also means you don’t have to constantly restart Sketch on each edit.

I did get in a weird situation where the plugin kept caching a local version. I was guided to a workaround on the Sketch API GitHub.

The basics of writing a plugin for Sketch in JavaScript

First of all, rename your plugin and files to something more meaningful. Open the src/manifest.json file. You can see here where I have amended mine to “translateText.js” or “Translate Text”.

{
  "compatibleVersion": 3,
  "bundleVersion": 1,
  "icon": "icon.png",
  "appcast": "appcast.xml",
  "commands": [
    {
      "name": "Translate Text",
      "identifier": "my-command-identifier",
      "script": "./translateStrings.js"
    }
  ],
  "menu": {
    "title": "Translate",
    "items": ["my-command-identifier"]
  }
}

The script key is where you reference the actual ‘meat and potatoes’ of your plugin. In this case, my main JS file was ‘translateStrings.js’ and you can name yours appropriately. The name key is the text that will appear in the menu of the Sketch interface for users.

So, I am now going to concentrate on writing the actual plugin in that translateText.js file. By the way, the completed translateText.js plugin file is included at the end of this post.

If you have ever written a Gulpfile or done anything in Node you will feel immediately at home. You import things you want to use like this:

var mlStrings = require("./mlStrings");

And your plugin should be the default export from the file. For example:

export default function(context) {
  // your plugin code
}

That is the mechanics of things. Let’s look at what you can do with Sketch. We will explore this a little by way of wiring up some parts of this language string switcher.

The remit of this plugin

I have a ‘dictionary’ of phrases as JavaScript objects inside an Array with a different language key for each value. For example:

var MLstrings = [
  {
    English: "Sausages",
    Polish: "Kiełbaski",
    German: "Würste",
    Russian: "Колбасные изделия",
    Traditional: "香腸"
  },
  {
    English: "Carrot",
    Polish: "Marchewka",
    German: "Karotte",
    Russian: "Морковь",
    Traditional: "胡蘿蔔"
  }
];

In Sketch I wanted the user to be able to select a layer(s) or symbol(s). Then choose a language from a selection menu and have any matching strings swapped out with that languages equivalent string.

It was suggested an option should exist to ‘stress test’. This would swap a selection with the longest possible alternative, regardless of language. Therefore, in the above example, if my text was “Sausages”, it would replace the text with “Колбасные изделия” as it is the longest matching string.

Grabbing documents, layers and symbols

So imagine that the user is in Sketch, they browse to our plugin in the menu and activate it. This is when our script gets executed. So, first of all, you might want to grab some ‘stuff’. This is covered in the ‘Document’ section of the JS API docs: https://developer.sketchapp.com/reference/api/#document

I needed the current document, the selected layers and how many layers were selected. I could grab those like this:

const document = Document.getSelectedDocument();
const selection = document.selectedLayers;
const selectedLayers = selection.layers;
const selectedCount = selectedLayers.length;

The first bit of control flow I wanted to introduce was to return early. This should pop up a message if the user activated the plugin but hadn’t selected anything. That was achieved by getting the number of selected layers and warning if it was zero:

// User hasn't selected a layer:
if (selectedCount === 0) {
    context.document.showMessage(
        "Throw me a frikkin' bone here Scott; you need to select a layer for this to work"
    );
    return;
}

The context.document.showMessage() method is what let’s you show a message on the Sketch interface.

I then wanted to show a drop-down menu to the user asking them to choose a language. Plus a default menu option to ‘Stress Test’.

You can show a menu in Sketch like this:

var choice = UI.getSelectionFromUser("Which Language?", listOfLanguages);

Where listOfLanguages is an array of strings – which become the choices in the menu.

[!img]

You know when a user has clicked ‘OK’ and the choice they made like this:

var ok = choice[2];
var value = listOfLanguages[choice[1]];

So, the flow can go like this if the user clicks OK on the menu:

if (ok) {
    // Do this code if the user has clicked OK.
}

When the user clicked OK, I wanted to loop through each layer and any symbol overrides, switching out the text. I went for this inside the OK block:

// Once OK is clicked on the menu
if (ok) {
    selectedLayers.forEach((layer, idx) => {
        let existingString = layer.text;
        layer.text = resolveMLString(existingString);
        layer.overrides.forEach(override => {
            let existingOverrideValue = override.value;
            override.value = resolveMLString(existingOverrideValue);
        });
    });
}

You can see that the layer text is accessed with layer.text and overrides are accessed with layer.overrides.

Sketch Developer Tools and debugging

By the way, logging things out is easier if you install the Sketch Developer Tools plugin. The Sketch documentation says it polyfills console.log so that it logs to the Sketch Developer Tools console but I didn’t find that to be the case. Instead I used the log() command. It is also worth knowing you can debug with Safari web inspector. There are more details on that here https://developer.sketchapp.com/guides/debugging-plugins/

Publishing a plugin locally

You can publish a plugin publicly using skpm publish but that isn’t something I have tried. I only wanted this plugin to available locally to team members. I did however want to give users the ability to update the plugin easily when new versions (read: bug fixes) were available. Thankfully there is a way to do this documented on the Sketch site.

The basics of this mechanism are having a appcast.xml file. This XML file follows the Sparkle update framework for OS X. When you want to issue an update, you add an extra item element to the appcast.xml file. For example:

<item>
  <title>Version 0.1.3</title>
  <description>
    <![CDATA[
      <ul>
        <li>"Stress Test" now matches line breaks as well as spaces for substrings</li>
      </ul>
    ]]>
  </description>
  <enclosure url="https://yourUrl/Sketch-Plugins/Translate-Text/builds/Translate-Text.0.1.3.zip" sparkle:version="1.1" />
</item>

Use any text you like for the title. But you need to ensure that the url for your plugin in the enclosure is accessible at the URL provided; that is where the update download is resolved from.

Assets folder

I had the appcast.xml in my assets folder. This meant the Sketch build tool would automatically put it into the correct folder to make the plugin. Sketch creates a folder for the plugin which is the name of your plugin plus the file extension .sketchplugin. So in my case, I had a folder called TranslateText.sketchplugin.

When you are happy with a plugin, zip the yourPlugin.sketchplugin file up, name it and place it appropriately – matching the name and url in your appcast.xml.

Updating the package.json in the project root

When you have a new version ready to upload, you need to update the version value in the package.json in the root of your project. For example, if I have just updated to version 0.1.4 I would change the value to:

"version": "0.1.4",

That value change updates the manifest.json file inside the plugin automatically. Which in turn provides a reference for what version a user has installed.

In summary, if updating locally, you need a new entry in the appcast.xml and a version bump to the number in the package.json.

Summary

I wouldn’t describe it as a perfect developer experience but developing a plugin for an application like Sketch is pretty fun. With JavaScript APIs popping up for things like FitBit devices and of course already present for code editors like VS Code and Atom, day to day tools have never been more ‘tweakable’ and approachable for casual JavaScript programmers.

Appendix: complete plugin code

In case it is of interest, here is the complete contents of the translateText.js file that made up all the logic for my little plugin:

var mlStrings = require("./mlStrings");
var UI = require("sketch/ui");
var Document = require("sketch/dom").Document;
var SymbolMaster = require("sketch/dom").SymbolMaster;
var SymbolInstance = require("sketch/dom").SymbolInstance;

export default function(context) {
    const document = Document.getSelectedDocument();
    const selection = document.selectedLayers;
    const selectedLayers = selection.layers;
    const selectedCount = selectedLayers.length;
    const stressTextMenuString = "Stress Test";

    // User hasn't selected a layer:
    if (selectedCount === 0) {
        context.document.showMessage(
            "Throw me a frikkin' bone here Scott; you need to select a layer for this to work"
        );
        return;
    }

    // Create a list of languages for the dropdown
    var listOfLanguages = Object.keys(mlStrings[0]);
    listOfLanguages.unshift(stressTextMenuString);

    // Get choice of drop-down from user
    var choice = UI.getSelectionFromUser("Which Language?", listOfLanguages);
    var ok = choice[2];
    var value = listOfLanguages[choice[1]];

    // Once OK is clicked on the menu
    if (ok) {
        selectedLayers.forEach((layer, idx) => {
            let existingString = layer.text;
            layer.text = resolveMLString(existingString);
            layer.overrides.forEach(override => {
                let existingOverrideValue = override.value;
                override.value = resolveMLString(existingOverrideValue);
            });
        });
    }

    // Utility to get the longest string from the values of an object
    function returnLongestValueFromObject(inputObject) {
        let objectValuesAsArray = Object.values(inputObject);
        return objectValuesAsArray.sort(function(a, b) {
            return b.length - a.length;
        })[0];
    }

    function resolveMLString(stringToBeResolved) {
        // Check if stringToBeResolved is actually something and we aren't being sent non-object
        if (stringToBeResolved) {
            var objectFoundInArrayThatIncludesString = mlStrings.find(function(
                stringObj
            ) {
                // Create an array of the objects values:
                let stringValues = Object.values(stringObj);
                // Now return if we can find that string anywhere in there

                return stringValues.includes(stringToBeResolved);
            });
            // We had a complete match here so send back the new string
            if (objectFoundInArrayThatIncludesString) {
                if (value === stressTextMenuString) {
                    return returnLongestValueFromObject(
                        objectFoundInArrayThatIncludesString
                    );
                } else {
                    return objectFoundInArrayThatIncludesString[value];
                }
            } else {
                // If we don't have a match in our language strings, first try a partial match otherwise return the original
                let arrayOfStringPartsSplitBySpace = stringToBeResolved.split(
                    /\s/
                );

                if (arrayOfStringPartsSplitBySpace) {
                    arrayOfStringPartsSplitBySpace.forEach((substring, idx) => {
                        let objectFoundInArrayThatIncludesSubString = mlStrings.find(
                            function(stringObj) {
                                // Create an array of the objects values:
                                let stringValues = Object.values(stringObj);

                                // Match inside a string here
                                return stringValues.includes(substring);
                            }
                        );
                        if (objectFoundInArrayThatIncludesSubString) {
                            if (value === stressTextMenuString) {
                                arrayOfStringPartsSplitBySpace[
                                    idx
                                ] = returnLongestValueFromObject(
                                    objectFoundInArrayThatIncludesSubString
                                );
                            } else {
                                arrayOfStringPartsSplitBySpace[idx] =
                                    objectFoundInArrayThatIncludesSubString[
                                        value
                                    ];
                            }
                        }
                    });
                    return arrayOfStringPartsSplitBySpace.join(" ");
                } else {
                    return stringToBeResolved;
                }
            }
        }
        return;
    }
}