├── .appcast.xml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets └── icon.png ├── docs └── assets │ ├── github-example-1.gif │ ├── github-example-2.gif │ ├── github-example-3.gif │ ├── github-off-on.gif │ └── github-plugin-screenshot.png ├── package-lock.json ├── package.json ├── src ├── artboards.js ├── cmd │ ├── validate-and-fix.js │ └── validate.js ├── constants.js ├── manifest.json ├── utils.js ├── validators.js └── wip-rows.js └── webpack.skpm.config.js /.appcast.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build artifacts 2 | monzo-file-cleaner.sketchplugin 3 | 4 | # npm 5 | node_modules 6 | .npm 7 | npm-debug.log 8 | 9 | # mac 10 | .DS_Store 11 | 12 | # WebStorm 13 | .idea 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5", 4 | "bracketSpacing": false 5 | } 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Monzo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # File Cleaner by Monzo 2 | 3 | File Cleaner is a plugin that keeps your Sketch files immaculately clean and in order. We’ve written all about why we built it and the way it works [on our blog](https://monzo.com/blog/2018/12/11/design-files-system/). 4 | 5 | ## What File Cleaner does 6 | By following an artboard naming convention of 100, 101, 102, File Cleaner generates a grid for your canvas and ensures that every screen is in the right place, no matter what you add or where you add it. Here’s what it does to your files:
7 | 8 | 9 |
10 | 11 | ### ✨ When you run Validate and Clean, it will: 12 | * Tell you if there’s anything missing (eg. page name, artboards without names) 13 | * Tell you if there’s any duplicate artboards 14 | * Align all the artboards to their correct place in the grid 15 | * Place new artboards in the correct place in their flow 16 | * Re-arrange the left panel so artboards are in the correct order 17 | 18 |
19 | 20 | ### 👀 Some examples of how File Cleaner works in practice: 21 | When you add a new screen to the end of your row it’ll give it the correct name and position: 22 | 23 |

24 | 25 | When you make one artboard taller than the others it’ll adjust the grid to reflect this: 26 | 27 |

28 | 29 | When you place a screen in the middle of a row, it’ll give it the correct name and update the names of all the following screen: 30 | 31 | 32 | 33 |
34 | 35 | ### 📄 The parameters for how it works: 36 | * There is one page per file, named `Master` 37 | * Each flow in the page is on its own row, and starts with a number that denotes it 38 | * Each artboard is named after the flow its in, followed by where it sits in that flow. So, the 5th artboard in the 2nd flow is named 205. 39 | 40 | *Top tip: if File Cleaner tells you when you’ve dropped an artboard into the middle of a flow and it has a duplicate name, you can add a suffix to the artboard name (eg “203#”) and it’ll position correctly*. 41 | 42 |
43 | 44 | ## Installation 45 | 46 | * [Download](https://github.com/monzo/file-cleaner/releases/latest) the latest release of the plugin 47 | * Un-zip 48 | * Double-click on monzo-file-cleaner.sketchplugin 49 | 50 |
51 | 52 | ### This is a plugin from [Monzo Design](http://monzo.com/design). 53 | -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monzo/file-cleaner/2ec5e7a4af778683b676cdcc768f3a068826d920/assets/icon.png -------------------------------------------------------------------------------- /docs/assets/github-example-1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monzo/file-cleaner/2ec5e7a4af778683b676cdcc768f3a068826d920/docs/assets/github-example-1.gif -------------------------------------------------------------------------------- /docs/assets/github-example-2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monzo/file-cleaner/2ec5e7a4af778683b676cdcc768f3a068826d920/docs/assets/github-example-2.gif -------------------------------------------------------------------------------- /docs/assets/github-example-3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monzo/file-cleaner/2ec5e7a4af778683b676cdcc768f3a068826d920/docs/assets/github-example-3.gif -------------------------------------------------------------------------------- /docs/assets/github-off-on.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monzo/file-cleaner/2ec5e7a4af778683b676cdcc768f3a068826d920/docs/assets/github-off-on.gif -------------------------------------------------------------------------------- /docs/assets/github-plugin-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/monzo/file-cleaner/2ec5e7a4af778683b676cdcc768f3a068826d920/docs/assets/github-plugin-screenshot.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "monzo-file-cleaner", 3 | "description": "A plugin that keeps your Sketch files in perfect shape.", 4 | "version": "1.3.0", 5 | "engines": { 6 | "sketch": ">=3.0" 7 | }, 8 | "skpm": { 9 | "name": "Monzo File Cleaner", 10 | "manifest": "src/manifest.json", 11 | "main": "monzo-file-cleaner.sketchplugin", 12 | "assets": [ 13 | "assets/**/*" 14 | ] 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/monzo/file-cleaner" 19 | }, 20 | "scripts": { 21 | "build": "skpm-build", 22 | "watch": "skpm-build --watch", 23 | "start": "skpm-build --watch --run", 24 | "postinstall": "npm run build && skpm-link" 25 | }, 26 | "devDependencies": { 27 | "@skpm/builder": "^0.7.5", 28 | "prettier": "^1.14.2" 29 | }, 30 | "author": "Monzo ", 31 | "dependencies": { 32 | "@babel/polyfill": "^7.6.0" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/artboards.js: -------------------------------------------------------------------------------- 1 | import {getMasterPage} from './utils'; 2 | import {validateAll} from './validators'; 3 | 4 | export function artboardsByName(page) { 5 | const artboards = page.artboards(); 6 | const artboardsByName = {}; 7 | for (let i = 0; i < artboards.length; i++) { 8 | const name = String(artboards[i].name()); 9 | artboardsByName[name] = artboards[i]; 10 | } 11 | 12 | return artboardsByName; 13 | } 14 | 15 | export function artboardRowsByName(page) { 16 | const artboards = page.artboards(); 17 | const rows = {}; 18 | for (let i = 0; i < artboards.length; i++) { 19 | const name = String(artboards[i].name()); 20 | const number = parseInt(name.match(/\d+/)[0], 10); 21 | const rowName = String(number - (number % 100)); 22 | const row = rows[rowName] || []; 23 | row.push(artboards[i]); 24 | rows[rowName] = row.sort((a, b) => a.frame().x() - b.frame().x()); 25 | } 26 | return rows; 27 | } 28 | 29 | export function artboardRowsByPosition(page) { 30 | const artboards = page.artboards(); 31 | 32 | // Find the row covers 33 | const rowCovers = []; 34 | for (let i = 0; i < artboards.length; i++) { 35 | const number = parseInt(artboards[i].name(), 10); 36 | if (number % 100 === 0) { 37 | rowCovers.push(artboards[i]); 38 | } 39 | } 40 | 41 | // Sort the existing covers 42 | rowCovers.sort((a, b) => a.frame().y() - b.frame().y()); 43 | 44 | // Build up the rows, starting with their covers 45 | const rows = {}; 46 | for (let i = 0; i < rowCovers.length; i++) { 47 | const rowName = ((i + 1) * 100).toString(); 48 | rows[rowName] = [rowCovers[i]]; 49 | } 50 | const yPositions = Object.entries(rows).map(([name, row]) => [ 51 | name, 52 | row[0].frame().y(), 53 | ]); 54 | 55 | // Add other artboards into the rows 56 | for (let i = 0; i < artboards.length; i++) { 57 | const artboard = artboards[i]; 58 | 59 | // Skip row covers, obvs... 60 | if (rowCovers.includes(artboard)) { 61 | continue; 62 | } 63 | 64 | const yPos = artboard.frame().y(); 65 | 66 | let closestRow; 67 | let smallestDist = Infinity; 68 | for (const [rowName, rowYPos] of yPositions) { 69 | const yDist = Math.abs(yPos - rowYPos); 70 | if (yDist < smallestDist) { 71 | closestRow = rowName; 72 | smallestDist = yDist; 73 | } 74 | } 75 | 76 | rows[closestRow].push(artboards[i]); 77 | } 78 | 79 | // Sort rows by board x position 80 | Object.values(rows).forEach(row => 81 | row.sort((a, b) => a.frame().x() - b.frame().x()) 82 | ); 83 | return rows; 84 | } 85 | 86 | export function autoAlignArtboards(page) { 87 | const rows = artboardRowsByPosition(page); 88 | 89 | const rowNames = Object.keys(rows).sort( 90 | (a, b) => parseInt(a, 10) - parseInt(b, 10) 91 | ); 92 | let y = 0; 93 | for (let rowName of rowNames) { 94 | const row = rows[rowName]; 95 | let x = 0; 96 | let nextYOffset = 1000; 97 | let sequenceNumber = 0; 98 | 99 | for (let artboard of row) { 100 | // Make sure they're in the right order, so the list on the left is sorted 101 | const parent = artboard.parentGroup(); 102 | artboard.removeFromParent(); 103 | parent.insertLayer_atIndex(artboard, 0); 104 | 105 | // Update name 106 | const artboardNumber = parseInt(rowName, 10) + sequenceNumber; 107 | artboard.name = artboardNumber.toString(); 108 | 109 | // Update artboard's position 110 | // artboard.frame().{x,y}() isn't always relatively to (0,0), and using 111 | // absoluteRect.ruler{X,Y} seems to solve this 112 | artboard.absoluteRect().rulerX = x; 113 | artboard.absoluteRect().rulerY = y; 114 | 115 | // Use the height of the largest artboard on this row to determine the 116 | // y-offset of the next row (plus a small buffer for labels) 117 | const height = artboard.frame().height() + 100; 118 | if (height > nextYOffset) { 119 | // Snap to a 500 unit grid 120 | nextYOffset = height + (500 - (height % 500)); 121 | } 122 | 123 | const width = artboard.frame().width() + 140; 124 | x += width; 125 | sequenceNumber++; 126 | } 127 | y += nextYOffset; 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/cmd/validate-and-fix.js: -------------------------------------------------------------------------------- 1 | import UI from 'sketch/ui'; 2 | import {getPageByName} from '../utils'; 3 | import {validateAll} from '../validators'; 4 | import {autoAlignArtboards} from '../artboards'; 5 | import {markWipRows} from '../wip-rows'; 6 | import {SCOPED_PAGE_NAMES} from '../constants'; 7 | 8 | export default async function validateAndFix(context) { 9 | try { 10 | await validateAll(context); 11 | } catch (error) { 12 | UI.message(`‼️ ${error.message}`); 13 | } 14 | 15 | SCOPED_PAGE_NAMES.forEach(pageName => { 16 | const page = getPageByName(context, pageName); 17 | 18 | // Fix artboard alignment on the page 19 | autoAlignArtboards(page); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/cmd/validate.js: -------------------------------------------------------------------------------- 1 | import UI from 'sketch/ui'; 2 | import {validateAll} from '../validators'; 3 | 4 | export default async function validate(context) { 5 | try { 6 | await validateAll(context); 7 | UI.message(`😍 Looks good!`); 8 | } catch (error) { 9 | UI.message(`‼️ ${error.message}`); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const SCOPED_PAGE_NAMES = ['iOS', 'Android', 'Master']; 2 | export const ALLOWED_PAGE_NAMES = [ 3 | 'iOS', 4 | 'Android', 5 | 'Master', 6 | 'Symbols', 7 | 'Preview', 8 | ]; 9 | -------------------------------------------------------------------------------- /src/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "compatibleVersion": 3, 3 | "bundleVersion": 1, 4 | "icon": "icon.png", 5 | "commands": [ 6 | { 7 | "name": "👮‍♀️ Validate", 8 | "description": "Check that the correct conventions are being followed 🤓", 9 | "identifier": "monzo.validate", 10 | "script": "./cmd/validate.js", 11 | "shortcut": "ctrl shift m" 12 | }, 13 | { 14 | "name": "✅ Validate and clean", 15 | "description": "Apply correct conventions automatically ️️️✨️", 16 | "identifier": "monzo.validate-and-fix", 17 | "script": "./cmd/validate-and-fix.js", 18 | "shortcut": "ctrl shift l" 19 | } 20 | ], 21 | "menu": { 22 | "title": "✨ File Cleaner", 23 | "items": ["monzo.validate", "monzo.validate-and-fix"] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function getPageByName(context, name) { 2 | const pages = context.document.pages(); 3 | for (let i = 0; i < pages.length; i++) { 4 | if (pages[i].name() == name) { 5 | return pages[i]; 6 | } 7 | } 8 | return null; 9 | } 10 | 11 | export function colorFromString(color) { 12 | return MSImmutableColor.colorWithSVGString(color).newMutableCounterpart(); 13 | } 14 | -------------------------------------------------------------------------------- /src/validators.js: -------------------------------------------------------------------------------- 1 | import {getPageByName} from './utils'; 2 | import {ALLOWED_PAGE_NAMES} from './constants'; 3 | 4 | const validators = [ 5 | validatePagePresence, 6 | validatePageNames, 7 | validateArtboardNames, 8 | ]; 9 | 10 | export default validators; 11 | 12 | export function validateAll(context) { 13 | return new Promise(async (resolve, reject) => { 14 | for (let validator of validators) { 15 | try { 16 | await validator(context); 17 | } catch (error) { 18 | reject(error); 19 | } 20 | } 21 | 22 | resolve(); 23 | }); 24 | } 25 | 26 | /** 27 | * Ensures that there is either a Master page, or that both iOS and Android exist 28 | */ 29 | export function validatePagePresence(context) { 30 | return new Promise((resolve, reject) => { 31 | if (getPageByName(context, 'iOS') && !getPageByName(context, 'Android')) { 32 | reject({message: `Missing page Android`}); 33 | } 34 | 35 | if (getPageByName(context, 'Android') && !getPageByName(context, 'iOS')) { 36 | reject({message: `Missing page iOS`}); 37 | } 38 | 39 | if ( 40 | !getPageByName(context, 'Master') && 41 | (!getPageByName(context, 'iOS') || !getPageByName(context, 'Android')) 42 | ) { 43 | reject({message: `Missing page Master`}); 44 | } 45 | 46 | resolve(); 47 | }); 48 | } 49 | 50 | export function validatePageNames(context) { 51 | return new Promise((resolve, reject) => { 52 | const pages = context.document.pages(); 53 | 54 | for (let i = 0; i < pages.length; i++) { 55 | const name = String(pages[i].name()); 56 | 57 | if (ALLOWED_PAGE_NAMES.indexOf(name) === -1) { 58 | reject({message: `Invalid page name '${name}'`}); 59 | } 60 | } 61 | 62 | resolve(); 63 | }); 64 | } 65 | 66 | export function validateArtboardNames(context) { 67 | return new Promise((resolve, reject) => { 68 | const pages = context.document.pages(); 69 | 70 | pages.forEach(page => { 71 | const artboards = page.artboards(); 72 | const artboardsByName = {}; 73 | 74 | let flowStart = false; 75 | 76 | artboards.forEach(artboard => { 77 | const name = String(artboard.name()); 78 | 79 | if (name.match(/[0]{2}$/)) { 80 | flowStart = true; 81 | } 82 | 83 | if (artboardsByName[name]) { 84 | reject({message: `Duplicate artboard name '${name}'`}); 85 | } 86 | 87 | artboardsByName[name] = name; 88 | 89 | if (!name.match(/^\d{3,4}(\.[A-Z]{1,2})?/)) { 90 | reject({message: `Invalid artboard name '${name}'`}); 91 | } 92 | }); 93 | 94 | if (!flowStart) { 95 | reject({message: `Missing x00 artboard to start the flow`}); 96 | } 97 | }); 98 | resolve(); 99 | }); 100 | } 101 | -------------------------------------------------------------------------------- /src/wip-rows.js: -------------------------------------------------------------------------------- 1 | import {Document} from 'sketch/dom'; 2 | import {artboardRowsByName} from './artboards'; 3 | import {colorFromString} from './utils'; 4 | 5 | const wipSymbolRegex = /\bWIP$/; 6 | const defaultArtboardColor = colorFromString('#000000'); 7 | const wipArtboardColor = colorFromString('#F43951'); 8 | 9 | export function markWipRows(context, page) { 10 | const wipRows = findWipRows(context, page); 11 | const rows = artboardRowsByName(page); 12 | for (let rowNumber of Object.keys(rows)) { 13 | const artboard = rows[rowNumber][0]; 14 | if (wipRows.includes(rowNumber)) { 15 | artboard.backgroundColor = wipArtboardColor; 16 | } else { 17 | // This conditional prevents us from e.g. changing green "approved" 18 | // artboards back to black 19 | if (artboard.backgroundColor().fuzzyIsEqual(wipArtboardColor)) { 20 | artboard.backgroundColor = defaultArtboardColor; 21 | } 22 | } 23 | } 24 | } 25 | 26 | export function findWipRows(context, page) { 27 | const symbolMaster = Document.fromNative(context.document) 28 | .getSymbols() 29 | .find(s => s.name.match(wipSymbolRegex)); 30 | 31 | if (!symbolMaster) { 32 | console.log("Couldn't find WIP symbol"); 33 | return []; 34 | } 35 | 36 | const wipRows = []; 37 | 38 | for (let instance of symbolMaster.getAllInstances()) { 39 | while (instance && instance.type != 'Artboard') { 40 | instance = instance.parent; 41 | } 42 | 43 | if (instance && instance.sketchObject.parentPage() == page) { 44 | const boardNumber = parseInt(instance.name, 10); 45 | const rowName = (boardNumber - (boardNumber % 100)).toString(); 46 | if (wipRows.indexOf(rowName) === -1) { 47 | wipRows.push(rowName); 48 | } 49 | } 50 | } 51 | return wipRows; 52 | } 53 | -------------------------------------------------------------------------------- /webpack.skpm.config.js: -------------------------------------------------------------------------------- 1 | module.exports = config => { 2 | config.entry = ['@babel/polyfill', config.entry]; // eslint-disable-line no-param-reassign 3 | }; 4 | --------------------------------------------------------------------------------