├── .gitignore ├── README.md ├── header.cocoascript ├── index.js ├── package.json └── test ├── count-artboards.cocoascript ├── fixtures ├── erroneous-code.cocoascript ├── five-lines-of-code.cocoascript └── set-response.cocoascript ├── index.js ├── responder.cocoascript └── test.sketch /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | tmp -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ⍚ Sketch Driver 2 | 3 | Sketch Driver is a node module to 'drive' [Sketch App](http://developer.sketchapp.com/introduction/) from javascript. 4 | 5 | It allows javascript developers to easily create code to open sketch documents, perform [CocoaScript](https://github.com/ccgus/CocoaScript) snippets and retrieve information of the current UI and document state. 6 | 7 | Sketch Driver makes it easier to develop and run sketch plugins from the outside of Sketch App. 8 | 9 | ``` 10 | npm install preciousforever/sketch-driver 11 | ``` 12 | 13 | ```js 14 | var sketch = require('sketch-driver'); 15 | sketch.run(`context.document.showMessage('Hello World');`}); 16 | ``` 17 | 18 | ## In contrast to sketchtool 19 | 20 | [Sketchtool](http://www.sketchapp.com/tool/) is great when you just want to work with the sketch file itself, e.g.: retrieve information about the objects or export artboards.. 21 | 22 | ## In contrast to sketch plugins 23 | 24 | Sketch Plugins - accessible from the UI - are great, unless you want to trigger a certain action by code or retrieve a certain information from the outside of Sketch. 25 | 26 | ## State 27 | 28 | Sketch Driver is at an early state (at 'sketch' level so to say), a rough concept as personal preparation for [Sketch Hackday](http://designtoolshackday.com/) 29 | 30 | ### Next Up 31 | - [x] relative imports 32 | - [x] improve logging 33 | - [x] simple verbosity options 34 | - [x] Promise based API 35 | - [ ] WIP improve linenumber logging when using import statements 36 | - [ ] upload to npm 37 | - [ ] enable (recursive) relative imports 38 | - [ ] execute files 39 | - [ ] add watch example 40 | - [ ] add server based commuincation in addition to [REPL](https://en.wikipedia.org/wiki/Read%E2%80%93eval%E2%80%93print_loop) 41 | 42 | --- 43 | 44 | # Using Sketch Driver 45 | 46 | 47 | ## Installation 48 | 49 | ``` 50 | npm install sketch-driver --save 51 | ``` 52 | 53 | ## Open Files 54 | 55 | ```js 56 | var sketch = require('sketch-driver'); 57 | sketch.open(path.join(__dirname, 'test.sketch')); 58 | ``` 59 | 60 | ## Execute code inside sketch 61 | 62 | ```js 63 | var sketch = require('sketch-driver'); 64 | sketch.run(`context.document.showMessage('Hello World');`}); 65 | ``` 66 | 67 | ## Retrieve information 68 | 69 | Use `run(script, callback(err, response, errorMessage))` with a callback function to retrieve information from inside Sketch. 70 | 71 | Use the `respond` method on the injected `$SD` object inside CocoaScript to respond with information. 72 | 73 | ```js 74 | var sketch = require('sketch-driver'); 75 | sketch.run(`$SD.respond({'artboardCount': context.document.artboards().count()});`, function(err, response) { 76 | console.log("current sketch document has " + response.data.artboardCount + "artboards"); 77 | }) 78 | 79 | ``` 80 | 81 | ## Imports 82 | 83 | You may use imports relative to the javascript file, where you require 'sketch-driver'. Sketch-Driver will fix the imports, so sketch is able to find the files. 84 | 85 | ```js 86 | /* 87 | Directory Structure 88 | - node_modules 89 | - snippets/count-artboard.cocoascript 90 | var artboardCount = context.document.artboards().count(); 91 | - index.js 92 | 93 | content of index.js below 94 | */ 95 | var sketch = require('sketch-driver'); 96 | sketch.run(`@import 'snippets/count-artboard.cocoascript' 97 | $SD.respond({'artboardCount': artboardCount});`, function(err, response) { 98 | console.log("current sketch document has " + response.data.artboardCount + "artboards"); 99 | }) 100 | ``` 101 | 102 | # Development 103 | 104 | ``` 105 | npm install 106 | npm test 107 | ``` -------------------------------------------------------------------------------- /header.cocoascript: -------------------------------------------------------------------------------- 1 | function SKETCH_DRIVER(requestid) { 2 | var logentries = []; 3 | var old_log = log; 4 | log = this.log; 5 | return { 6 | respond: function(data) { 7 | var response = { 8 | requestid: requestid, 9 | data: data, 10 | log: logentries 11 | } 12 | old_log(JSON.stringify(response)); 13 | }, 14 | log: function(s) { 15 | if(arguments.length > 1) { 16 | logentries.push(arguments); 17 | return; 18 | } 19 | logentries.push(s); 20 | } 21 | } 22 | }; -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var fs = require('fs'); 3 | var path = require('path'); 4 | var child_process = require('child_process'); 5 | var coscript = require('coscript'); 6 | 7 | var TMP_DIR = 'tmp'; 8 | var MAGIC_LINE_CORRECTION = 5; 9 | var MAGIC_LINE_IMPORT_CORRECTION = 5; 10 | 11 | if (!fs.existsSync(TMP_DIR)){ 12 | fs.mkdirSync(TMP_DIR); 13 | } 14 | 15 | var header = fs.readFileSync('header.cocoascript', 'utf-8'); 16 | 17 | function isErrorMessage(msg) { 18 | return (msg.indexOf('line: ') != -1 19 | && msg.indexOf('sourceURL: ') != -1 20 | && msg.indexOf('column: ') != -1) 21 | } 22 | 23 | class ErrorObject { 24 | 25 | constructor() { 26 | this.entries = {}; 27 | } 28 | addEntry(key, value) { 29 | this.entries[key] = value; 30 | } 31 | toString() { 32 | var msg = []; 33 | var entries = this.entries; 34 | Object.keys(entries).forEach(function(k) { 35 | msg.push(`${k}: ${entries[k]}`); 36 | }); 37 | return msg.join('\n'); 38 | } 39 | } 40 | 41 | function parseMsg(msg) { 42 | var error = new ErrorObject(); 43 | var msgParser = /^(\w*): (.*)/gm; 44 | var result; 45 | while ((result = msgParser.exec(msg)) !== null) { 46 | error.addEntry(result[1], result[2]); 47 | } 48 | 49 | return error; 50 | } 51 | 52 | function fixLineNumber(script, msg, config) { 53 | var errorObject = parseMsg(msg); 54 | var virtualErrorLineNumber = parseInt(errorObject.entries.line, 10); 55 | 56 | console.log("error on line: " + virtualErrorLineNumber); 57 | var libraryLines = header.split("\n").length + 2; 58 | //virtualErrorLineNumber -= libraryLines; 59 | 60 | var lineNumber = undefined; 61 | var correction = MAGIC_LINE_CORRECTION; 62 | var importParser = /@import \'(.*)\'/gm; 63 | var importLineParser = /@import \'(.*)\'/; 64 | var imports = script.match(importParser); 65 | 66 | if(imports) { 67 | 68 | // file contains imports 69 | var lines = script.split('\n'); 70 | var importedLines = 0; 71 | var seek = true; 72 | var file = 'script'; 73 | for(var i = 0; i < lines.length; i++) { 74 | var line = lines[i]; 75 | 76 | var match = importLineParser.exec(line); 77 | 78 | if(config && config.file) { 79 | file = config.file; 80 | } 81 | 82 | if(match) { 83 | file = match[1]; 84 | var importFileLines = fs.readFileSync(file).toString().split('\n').length; 85 | importedLines += importFileLines; 86 | //correction -= 1; //MAGIC_LINE_IMPORT_CORRECTION; 87 | if(i + importedLines >= virtualErrorLineNumber - correction) { 88 | // error inside import 89 | lineNumber = importFileLines - (i + importedLines - virtualErrorLineNumber) - 7; 90 | break; 91 | } 92 | } else { 93 | if(i + importedLines + 1 >= virtualErrorLineNumber - correction) { 94 | 95 | lineNumber = virtualErrorLineNumber - importedLines - libraryLines - MAGIC_LINE_CORRECTION; 96 | break; 97 | } 98 | } 99 | } 100 | errorObject.entries.sourceURL = file; 101 | } else { 102 | lineNumber = virtualErrorLineNumber - libraryLines - MAGIC_LINE_CORRECTION; 103 | if(config && config.file) { 104 | errorObject.entries.sourceURL = config.file; 105 | } 106 | } 107 | 108 | errorObject.entries.line = lineNumber; 109 | 110 | return errorObject; 111 | } 112 | 113 | function fixImportsForScript(script, root) { 114 | var importParser = /@import \'(.*)'/gm; 115 | 116 | // dangerous, might be wrong: http://stackoverflow.com/a/26877091 117 | var rootPath = root || path.dirname(module.parent.filename); 118 | 119 | script = script.replace(importParser, (match, p1, offset, string) => { 120 | var resolvedPath = path.resolve(rootPath, p1); 121 | return `@import '${resolvedPath}'`; 122 | }); 123 | return script; 124 | } 125 | 126 | function runFile(filePath, userConfig) { 127 | 128 | var defaultConfig = { 129 | root: null, 130 | file: filePath 131 | }; 132 | var config = Object.assign({}, defaultConfig, userConfig); 133 | 134 | // dangerous, might be wrong: http://stackoverflow.com/a/26877091 135 | var rootPath = config.root || path.dirname(module.parent.filename); 136 | var absPath = path.resolve(rootPath, filePath); 137 | 138 | if(!fs.existsSync(absPath)) { 139 | return new Promise((resolve, reject) => { 140 | reject(new Error('file ' + absPath + ' does not exist')); 141 | }); 142 | } 143 | var script = fs.readFileSync(path.resolve(rootPath, filePath), 'utf-8'); 144 | 145 | return run(script, config); 146 | 147 | } 148 | 149 | function run(cocoascript, userConfig) { 150 | 151 | var defaultConfig = { 152 | root: null, 153 | verbose: true 154 | }; 155 | var config = Object.assign({}, defaultConfig, userConfig); 156 | 157 | function log(s) { 158 | if(config.verbose) { 159 | console.log(s); 160 | } 161 | } 162 | 163 | // @TODO: refactor callback signature 164 | var identifier = new Date().getTime(); 165 | var file = path.join(__dirname, TMP_DIR, identifier + '.cocoascript'); 166 | 167 | var script = header; 168 | script += "\n" + '$SD = SKETCH_DRIVER("' + identifier + '"); log = $SD.log;'; 169 | script += "\n" + cocoascript; 170 | 171 | script = fixImportsForScript(script, config.root); 172 | fs.writeFileSync(file, script); 173 | 174 | return new Promise((resolve, reject) => { 175 | 176 | var e = "[[[COScript app:\\\"Sketch\\\"] delegate] runPluginAtURL:[NSURL fileURLWithPath:\\\"" + file + "\\\"]]"; 177 | child_process.exec(coscript + ' -e "' + e + '"', function (err, stdout, stderr) { 178 | //fs.unlinkSync(file); 179 | if(err) throw err; 180 | if(stderr) { 181 | log('Process Error: ' + stderr); 182 | reject(stderr); 183 | return; 184 | } 185 | if(stdout.length <= 1) { 186 | log('Missing REPL response'); 187 | resolve(); 188 | return; 189 | } 190 | var response = {}; 191 | try { 192 | var response = JSON.parse(stdout); 193 | resolve(response); 194 | return; 195 | } catch(e) { 196 | log('Stdout: ' + stdout); 197 | if (isErrorMessage(stdout)) { 198 | log('Sketch Error: ' + stdout); 199 | var error = fixLineNumber(script, stdout, config); 200 | log('Improved Sketch Error: ' + error); 201 | reject(error); 202 | return; 203 | } 204 | log('Parsing Error: ' + e); 205 | reject(e); 206 | return; 207 | } 208 | 209 | }); 210 | 211 | }); 212 | 213 | } 214 | 215 | function open(url) { 216 | return run(` 217 | var path = "${url}"; 218 | [[NSWorkspace sharedWorkspace] openFile:path]; 219 | $SD.respond({}); 220 | `); 221 | } 222 | 223 | module.exports.open = open; 224 | module.exports.run = run; 225 | module.exports.runFile = runFile; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sketch-driver", 3 | "version": "1.0.0", 4 | "description": "A node module to drive the Sketch App from outside", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "mocha" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "devDependencies": { 12 | "chai": "^3.5.0", 13 | "chai-as-promised": "^5.3.0", 14 | "mocha": "^2.4.5" 15 | }, 16 | "dependencies": { 17 | "coscript": "^1.0.0" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /test/count-artboards.cocoascript: -------------------------------------------------------------------------------- 1 | var artboardCount = context.document.artboards().count(); -------------------------------------------------------------------------------- /test/fixtures/erroneous-code.cocoascript: -------------------------------------------------------------------------------- 1 | 2 | 3 | context.document.showMessageX('Hello World'); 4 | 5 | var moreCodeHere = true; 6 | var andEvenMoreCodeHere = true; -------------------------------------------------------------------------------- /test/fixtures/five-lines-of-code.cocoascript: -------------------------------------------------------------------------------- 1 | var line1 = 1; 2 | var line2 = 2; 3 | var line3 = 3; 4 | var line4 = 4; 5 | var line5 = 5; -------------------------------------------------------------------------------- /test/fixtures/set-response.cocoascript: -------------------------------------------------------------------------------- 1 | var SD_TEST_response = 'ABC' -------------------------------------------------------------------------------- /test/index.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | var sketch = require('../'); 3 | var path = require('path'); 4 | var chai = require('chai'); 5 | var chaiAsPromised = require("chai-as-promised"); 6 | 7 | chai.use(chaiAsPromised); 8 | var expect = chai.expect; 9 | 10 | describe('Opening Sketch Files', function() { 11 | 12 | it('should open a file', function() { 13 | return expect( 14 | sketch.open(path.join(__dirname, 'test.sketch')) 15 | ).to.be.fulfilled; 16 | }); 17 | 18 | }); 19 | 20 | describe('Running Inline Scripts', function() { 21 | 22 | before(function() { 23 | return sketch.open(path.join(__dirname, 'test.sketch')); 24 | }); 25 | 26 | it('should run a script', function() { 27 | return expect( 28 | sketch.run(` 29 | context.document.showMessage('Hello World'); 30 | `) 31 | ).to.be.fulfilled; 32 | }); 33 | 34 | it('should return a response', function() { 35 | return sketch.run(` 36 | $SD.respond({'hello': 'world'}); 37 | `).then((response) => { 38 | expect(response).to.have.keys(['data', 'log', 'requestid']); 39 | expect(response.data).to.have.keys(['hello']); 40 | expect(response.data.hello).to.equal('world'); 41 | }); 42 | }); 43 | 44 | it('should be able to read information', function() { 45 | return sketch.run(` 46 | $SD.respond({'artboardCount': context.document.artboards().count()}); 47 | `).then((response) => { 48 | expect(response.data.artboardCount).to.equal(1); 49 | }); 50 | }); 51 | 52 | it('should reject errornoues code', function() { 53 | return expect( 54 | sketch.run(` 55 | context.document.showMessageX('Hello World'); 56 | `)).be.rejected; 57 | }); 58 | 59 | }); 60 | 61 | describe('Running Scripts from files', function() { 62 | 63 | before(function() { 64 | return sketch.open(path.join(__dirname, 'test.sketch')); 65 | }); 66 | 67 | it('should run a script from a file', function() { 68 | return sketch.runFile('test.cocoascript').then((response) => { 69 | expect(response.data.artboardCount).to.equal(1); 70 | }); 71 | }); 72 | 73 | it('should throw an error if file does not exist', function() { 74 | return expect( 75 | sketch.runFile('testXXX.cocoascript') 76 | ).to.be.rejectedWith(Error); 77 | }); 78 | 79 | }); 80 | 81 | describe('Using Imports', function() { 82 | 83 | before(function() { 84 | return sketch.open(path.join(__dirname, 'test.sketch')); 85 | }); 86 | 87 | it('should resolve relative file path', function() { 88 | return sketch.run(` 89 | SD_TEST_response = 'hello from responder.sketch'; 90 | @import './responder.cocoascript' 91 | `).then((response) => { 92 | expect(response.data).to.have.keys(['msg']); 93 | expect(response.data.msg).to.equal('hello from responder.sketch'); 94 | }); 95 | }); 96 | 97 | it('should resolve relative file path', function(done) { 98 | return sketch.run(` 99 | @import './count-artboards.cocoascript' 100 | SD_TEST_response = artboardCount; 101 | @import './responder.cocoascript' 102 | `).then((response) => { 103 | expect(response.data).to.have.keys(['msg']); 104 | expect(response.data.msg).to.equal(1); 105 | done(); 106 | }); 107 | }); 108 | 109 | 110 | it('should resolve relative file path based on config', function(done) { 111 | return sketch.run(` 112 | @import 'fixtures/set-response.cocoascript' 113 | @import './responder.cocoascript' 114 | `).then((response) => { 115 | expect(response.data).to.have.keys(['msg']); 116 | expect(response.data.msg).to.equal('ABC'); 117 | done(); 118 | }); 119 | }); 120 | 121 | }); 122 | 123 | 124 | describe('Fix Line Numbers', function() { 125 | 126 | before(function() { 127 | return sketch.open(path.join(__dirname, 'test.sketch')); 128 | }); 129 | 130 | it('should fix line numbers in error messages', function() { 131 | return sketch.run(`context.document.showMessageX('Hello World');`) 132 | .catch((error) => { 133 | expect(error.entries.line).to.equal(1); 134 | }); 135 | }); 136 | 137 | it('should fix line numbers in error messages', function() { 138 | return sketch.run(` 139 | 140 | 141 | 142 | context.document.showMessage('Hello World'); 143 | context.document.showMessageX('Hello World'); 144 | `).catch((error) => { 145 | expect(error.entries.line).to.equal(6); 146 | }); 147 | }); 148 | 149 | it('should return error with sourceURL', () => { 150 | return sketch.runFile('fixtures/erroneous-code.cocoascript').catch((error) => { 151 | expect(error.entries.line).to.equal(3); 152 | expect(error.entries.sourceURL).to.equal('fixtures/erroneous-code.cocoascript'); 153 | }); 154 | }); 155 | 156 | it('should return error with line numbers that reflect imports', function() { 157 | return sketch.run(` 158 | @import 'fixtures/erroneous-code.cocoascript' 159 | `).catch((error) => { 160 | expect(error.entries.sourceURL).to.equal(__dirname + '/fixtures/erroneous-code.cocoascript'); 161 | expect(error.entries.line).to.equal(3); 162 | }); 163 | }); 164 | 165 | it('independet of the position of the import statement', function() { 166 | return sketch.run(` 167 | 168 | 169 | @import 'fixtures/erroneous-code.cocoascript' 170 | `).catch((error) => { 171 | expect(error.entries.sourceURL).to.equal(__dirname + '/fixtures/erroneous-code.cocoascript'); 172 | expect(error.entries.line).to.equal(3); 173 | }); 174 | }); 175 | 176 | it('when doing more than two imports', function() { 177 | return sketch.run(` 178 | @import 'fixtures/five-lines-of-code.cocoascript' 179 | @import 'fixtures/erroneous-code.cocoascript' 180 | `).catch((error) => { 181 | expect(error.entries.sourceURL).to.equal(__dirname + '/fixtures/erroneous-code.cocoascript'); 182 | expect(error.entries.line).to.equal(3); 183 | }); 184 | }); 185 | 186 | it('should return error with line numbers that reflect imports, when error is in main file', function() { 187 | return sketch.run(` 188 | @import 'fixtures/five-lines-of-code.cocoascript' 189 | 190 | context.document.showMessageX('Hello World') 191 | `).catch((error) => { 192 | expect(error.entries.line).to.equal(4); 193 | }); 194 | }); 195 | 196 | xit('when doing more than more than two import', function() { 197 | return sketch.run(` 198 | @import 'fixtures/five-lines-of-code.cocoascript' 199 | @import 'fixtures/five-lines-of-code.cocoascript' 200 | @import 'fixtures/erroneous-code.cocoascript' 201 | @import 'fixtures/five-lines-of-code.cocoascript' 202 | `).catch((error) => { 203 | expect(error.entries.sourceURL).to.equal(__dirname + '/fixtures/erroneous-code.cocoascript'); 204 | expect(error.entries.line).to.equal(3); 205 | }); 206 | }); 207 | 208 | xit('when doing more than more three import before import with error', function() { 209 | return sketch.run(` 210 | @import 'fixtures/five-lines-of-code.cocoascript' 211 | @import 'fixtures/five-lines-of-code.cocoascript' 212 | @import 'fixtures/five-lines-of-code.cocoascript' 213 | @import 'fixtures/erroneous-code.cocoascript' 214 | `).catch((error) => { 215 | expect(error.entries.sourceURL).to.equal(__dirname + '/fixtures/erroneous-code.cocoascript'); 216 | expect(error.entries.line).to.equal(3); 217 | }); 218 | }); 219 | 220 | }); 221 | 222 | -------------------------------------------------------------------------------- /test/responder.cocoascript: -------------------------------------------------------------------------------- 1 | $SD.respond({'msg': SD_TEST_response}); -------------------------------------------------------------------------------- /test/test.sketch: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dataliterate/sketchapp-driver/79070b2a4e4a1de31d79231d4991d388cb22bf87/test/test.sketch --------------------------------------------------------------------------------