├── .editorconfig ├── .gitattributes ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .npmrc ├── index.d.ts ├── index.js ├── index.test-d.ts ├── license ├── package.json ├── readme.md ├── screenshot.png └── test.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | end_of_line = lf 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.yml] 11 | indent_style = space 12 | indent_size = 2 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | node-version: 13 | - 20 14 | - 18 15 | steps: 16 | - uses: actions/checkout@v4 17 | - uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - run: npm install 21 | - run: npm test 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | type XbarOptions = { 2 | /** 3 | The text to show. The only required property. 4 | */ 5 | readonly text: string; 6 | 7 | /** 8 | The URL that will be opened when the menu item is clicked. 9 | */ 10 | readonly href?: string; 11 | 12 | /** 13 | Change the text color. 14 | */ 15 | readonly color?: string; 16 | 17 | /** 18 | Change the text font. 19 | */ 20 | readonly font?: string; 21 | 22 | /** 23 | Change the text size. 24 | */ 25 | readonly size?: number; 26 | 27 | /** 28 | Run a script. 29 | */ 30 | readonly bash?: string; 31 | 32 | /** 33 | Parameters to specify as arguments for the script, specified in `bash`. 34 | */ 35 | readonly param1?: string; 36 | 37 | /** 38 | Parameters to specify as arguments for the script, specified in `bash`. 39 | */ 40 | readonly param2?: string; 41 | 42 | /** 43 | Parameters to specify as arguments for the script, specified in `bash`. 44 | */ 45 | readonly param3?: string; 46 | 47 | /** 48 | Parameters to specify as arguments for the script, specified in `bash`. 49 | */ 50 | readonly param4?: string; 51 | 52 | /** 53 | Parameters to specify as arguments for the script, specified in `bash`. 54 | */ 55 | readonly param5?: string; 56 | 57 | /** 58 | Start the script with Terminal. 59 | */ 60 | readonly terminal?: boolean; 61 | 62 | /** 63 | Refresh the plugin. 64 | */ 65 | readonly refresh?: boolean; 66 | 67 | /** 68 | Show the item in the dropdown. 69 | */ 70 | readonly dropdown?: boolean; 71 | 72 | /** 73 | Truncate the line to the specified number of characters. 74 | */ 75 | readonly length?: number; 76 | 77 | /** 78 | Trim the leading and trailing whitespace from the text. 79 | */ 80 | readonly trim?: boolean; 81 | 82 | /** 83 | Mark the line as an alternate to the previous line, for when the Option key is pressed, in the dropdown. 84 | */ 85 | readonly alternate?: boolean; 86 | 87 | /** 88 | Set an image for the item. It must be a Base64 encoded string. 89 | */ 90 | readonly templateImage?: string; 91 | 92 | /** 93 | Set an image for this item. It must be a base64 encoded string. 94 | */ 95 | readonly image?: string; 96 | 97 | /** 98 | Convert text to emojis, such as `:mushroom:`. 99 | */ 100 | readonly emojize?: boolean; 101 | 102 | /** 103 | Enable parsing of ANSI codes. 104 | */ 105 | readonly ansi?: boolean; 106 | }; 107 | 108 | export type Options = { 109 | /** 110 | Add a submenu to the item. A submenu is composed of an array of items. 111 | */ 112 | readonly submenu?: Array; 113 | } & XbarOptions; 114 | 115 | export type TopLevelOptions = Omit; 116 | 117 | /** 118 | Add a separator. 119 | */ 120 | export const separator: unique symbol; 121 | 122 | /** 123 | Check whether dark mode is enabled. 124 | */ 125 | export const isDarkMode: boolean; 126 | 127 | /** 128 | Check whether the script is running from xbar. 129 | */ 130 | export const isXbar: boolean; 131 | 132 | /** 133 | Create a plugin for xbar. 134 | 135 | @param items - xbar items. 136 | @param options - Options for all xbar items. 137 | 138 | @example 139 | ``` 140 | #!/usr/bin/env node --input-type=module 141 | import xbar, {separator, isDarkMode} from 'xbar'; 142 | 143 | xbar([ 144 | { 145 | text: '❤', 146 | color: isDarkMode ? 'white' : 'red', 147 | dropdown: false 148 | }, 149 | separator, 150 | { 151 | text: 'Unicorns', 152 | color: '#ff79d7', 153 | submenu: [ 154 | { 155 | text: ':tv: Video', 156 | href: 'https://www.youtube.com/watch?v=9auOCbH5Ns4' 157 | }, 158 | { 159 | text: ':book: Wiki', 160 | href: 'https://en.wikipedia.org/wiki/Unicorn' 161 | } 162 | ] 163 | }, 164 | separator, 165 | 'Ponies' 166 | ]); 167 | ``` 168 | */ 169 | export default function xbar( 170 | items: ReadonlyArray, 171 | options?: TopLevelOptions 172 | ): void; 173 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | 3 | export const separator = Symbol('separator'); 4 | 5 | export const isDarkMode = process.env.XBARDarkMode === 'true'; 6 | 7 | export const isXbar = process.env.__CFBundleIdentifier === 'com.xbarapp.app'; 8 | 9 | const encodeHref = url => { 10 | url = encodeURI(url); 11 | url = url.replaceAll('\'', '%27'); 12 | url = url.replaceAll('&', '%26'); 13 | return url; 14 | }; 15 | 16 | export const _create = (input, options = {}, menuLevel = 0) => { 17 | if (options.text !== undefined) { 18 | throw new TypeError('The `text` option is not supported as a top-level option. Use it on an item instead.'); 19 | } 20 | 21 | return input.map(line => { 22 | if (typeof line === 'string') { 23 | line = {text: line}; 24 | } 25 | 26 | if (line === separator) { 27 | return '--'.repeat(menuLevel) + '---'; 28 | } 29 | 30 | line = { 31 | ...options, 32 | ...line, 33 | }; 34 | 35 | const {text} = line; 36 | if (typeof text !== 'string') { 37 | throw new TypeError('The `text` property is required and should be a string'); 38 | } 39 | 40 | delete line.text; 41 | 42 | let submenuText = ''; 43 | if (typeof line.submenu === 'object' && line.submenu.length > 0) { 44 | submenuText = `\n${_create(line.submenu, options, menuLevel + 1)}`; 45 | delete line.submenu; 46 | } 47 | 48 | const prefix = '--'.repeat(menuLevel); 49 | 50 | return text.split('\n').map(textLine => { 51 | const options = Object.entries(line).map(([key, value]) => { 52 | const finalValue = key === 'href' ? encodeHref(value) : value; 53 | return `${key}="${finalValue}"`; 54 | }).join(' '); 55 | 56 | return `${prefix}${textLine}|${options}`; 57 | }).join('\n') + submenuText; 58 | }).join('\n'); 59 | }; 60 | 61 | export default function xbar(input, options) { 62 | console.log(_create(input, options)); 63 | } 64 | -------------------------------------------------------------------------------- /index.test-d.ts: -------------------------------------------------------------------------------- 1 | import {expectAssignable, expectNotAssignable, expectType} from 'tsd'; 2 | import xbar, {type TopLevelOptions, separator, isDarkMode} from './index.js'; 3 | 4 | expectType( 5 | xbar([ 6 | { 7 | text: '❤', 8 | color: isDarkMode ? 'white' : 'red', 9 | dropdown: false, 10 | }, 11 | separator, 12 | { 13 | text: 'Unicorns', 14 | color: '#ff79d7', 15 | submenu: [ 16 | { 17 | text: ':tv: Video', 18 | href: 'https://www.youtube.com/watch?v=9auOCbH5Ns4', 19 | }, 20 | { 21 | text: ':book: Wiki', 22 | href: 'https://en.wikipedia.org/wiki/Unicorn', 23 | }, 24 | ], 25 | }, 26 | separator, 27 | 'Ponies', 28 | ]), 29 | ); 30 | 31 | expectType(isDarkMode); 32 | 33 | expectNotAssignable({text: 'Unicorns'}); 34 | 35 | expectAssignable({font: 'Comic Sans MS'}); 36 | -------------------------------------------------------------------------------- /license: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Sindre Sorhus (https://sindresorhus.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "xbar", 3 | "version": "3.0.0", 4 | "description": "Simplifies xbar app plugin creation", 5 | "license": "MIT", 6 | "repository": "sindresorhus/xbar", 7 | "funding": "https://github.com/sponsors/sindresorhus", 8 | "author": { 9 | "name": "Sindre Sorhus", 10 | "email": "sindresorhus@gmail.com", 11 | "url": "https://sindresorhus.com" 12 | }, 13 | "type": "module", 14 | "exports": { 15 | "types": "./index.d.ts", 16 | "default": "./index.js" 17 | }, 18 | "sideEffects": false, 19 | "engines": { 20 | "node": ">=18" 21 | }, 22 | "scripts": { 23 | "test": "xo && ava && tsd" 24 | }, 25 | "files": [ 26 | "index.js", 27 | "index.d.ts" 28 | ], 29 | "keywords": [ 30 | "xbar", 31 | "bitbar", 32 | "macos", 33 | "menubar", 34 | "app", 35 | "bit", 36 | "bar" 37 | ], 38 | "devDependencies": { 39 | "ava": "^5.3.1", 40 | "tsd": "^0.29.0", 41 | "xo": "^0.56.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # xbar 2 | 3 | > Simplifies [xbar](https://github.com/matryer/xbar) app plugin creation 4 | 5 | Create your plugin using a nice API instead of having to manually construct a big string. 6 | 7 | *Requires xbar v2 or later.* 8 | 9 | 10 | 11 | ## Install 12 | 13 | ```sh 14 | npm install xbar 15 | ``` 16 | 17 | ## Usage 18 | 19 | ```js 20 | #!/usr/bin/env node 21 | import xbar, {separator, isDarkMode} from 'xbar'; 22 | 23 | xbar([ 24 | { 25 | text: '❤', 26 | color: isDarkMode ? 'white' : 'red', 27 | dropdown: false 28 | }, 29 | separator, 30 | { 31 | text: 'Unicorns', 32 | color: '#ff79d7', 33 | submenu: [ 34 | { 35 | text: ':tv: Video', 36 | href: 'https://www.youtube.com/watch?v=9auOCbH5Ns4' 37 | }, 38 | { 39 | text: ':book: Wiki', 40 | href: 'https://en.wikipedia.org/wiki/Unicorn' 41 | } 42 | ] 43 | }, 44 | separator, 45 | 'Ponies' 46 | ]); 47 | ``` 48 | 49 | Create a file with the above code in the xbar plugins directory and make sure to `chmod +x filename.js` it. [Read more.](https://github.com/matryer/xbar#installing-plugins) 50 | 51 | **Note:** You need to either have a `package.json` file with `{"type": "module"}` or use the `.mjs` extension instead of `.js`. 52 | 53 | *Change `node` in `#!/usr/bin/env node` to the path of your Node.js binary. This is a [known issue](https://github.com/matryer/xbar/issues/36) in xbar.* 54 | 55 | ## API 56 | 57 | ### xbar(items, options?) 58 | 59 | #### items 60 | 61 | Type: `Array` 62 | 63 | An item can be a string with the text or an object with the text in a `text` property and any of the `options`. The text can be multiple lines, but for the first item, only the first line will be shown in the menubar. 64 | 65 | ##### submenu 66 | 67 | Type: `Array` 68 | 69 | It will add a submenu to the current item. A submenu is composed of an array of items. 70 | 71 | #### options 72 | 73 | Type: `object` 74 | 75 | You can use any of the [supported options](https://github.com/matryer/xbar-plugins/blob/main/CONTRIBUTING.md#plugin-api). 76 | 77 | Applies to all items unless overridden in the item. 78 | 79 | ### separator 80 | 81 | Add a separator. 82 | 83 | ### isDarkMode 84 | 85 | Type: `boolean` 86 | 87 | Check whether dark mode is enabled. 88 | 89 | ### isXbar 90 | 91 | Type: `boolean` 92 | 93 | Check whether the script is running from xbar. 94 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sindresorhus/xbar/0d3caed57a20857dcb5764d564fc8796d62c997a/screenshot.png -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | import test from 'ava'; 2 | import xbar, {_create, separator} from './index.js'; 3 | 4 | test('main', t => { 5 | const actual = _create([ 6 | { 7 | text: '❤', 8 | color: 'red', 9 | dropdown: false, 10 | }, 11 | separator, 12 | { 13 | text: 'Unicorns', 14 | color: '#ff79d7', 15 | href: 'https://www.youtube.com/watch?v=9auOCbH5Ns4', 16 | submenu: [ 17 | { 18 | text: '1st Level Submenu - A', 19 | submenu: [ 20 | { 21 | text: '2nd level Submenu', 22 | }, 23 | ], 24 | }, 25 | { 26 | text: '1st Level Submenu - B', 27 | }, 28 | separator, 29 | { 30 | text: '1st Level Submenu - C', 31 | }, 32 | ], 33 | }, 34 | separator, 35 | 'Ponies', 36 | ]); 37 | 38 | const expected = ` 39 | ❤|color="red" dropdown="false" 40 | --- 41 | Unicorns|color="#ff79d7" href="https://www.youtube.com/watch?v=9auOCbH5Ns4" 42 | --1st Level Submenu - A| 43 | ----2nd level Submenu| 44 | --1st Level Submenu - B| 45 | ----- 46 | --1st Level Submenu - C| 47 | --- 48 | Ponies| 49 | `.trim(); 50 | 51 | t.is(actual, expected); 52 | }); 53 | 54 | test('`text` property validation', t => { 55 | const errorMessage = 'The `text` property is required and should be a string'; 56 | 57 | t.throws(() => { 58 | xbar([{dropdown: false}]); 59 | }, {message: errorMessage}); 60 | 61 | t.throws(() => { 62 | xbar([{text: 1}]); 63 | }, {message: errorMessage}); 64 | }); 65 | 66 | test('correctly encodes special characters in the `href` option', t => { 67 | const actual = _create([ 68 | { 69 | text: 'Single Quote', 70 | href: 'https://www.youtube.com/watch?v=\'9auOCbH5Ns4', 71 | }, 72 | { 73 | text: 'Double Quotes', 74 | href: 'https://www.youtube.com/watch?v="9auOCbH5Ns4"', 75 | }, 76 | { 77 | text: 'Ampercent', 78 | href: 'https://www.youtube.com/watch?v=&9auOCbH5Ns4', 79 | }, 80 | ]); 81 | const expected = ` 82 | Single Quote|href="https://www.youtube.com/watch?v=%279auOCbH5Ns4" 83 | Double Quotes|href="https://www.youtube.com/watch?v=%229auOCbH5Ns4%22" 84 | Ampercent|href="https://www.youtube.com/watch?v=%269auOCbH5Ns4" 85 | `.trim(); 86 | 87 | t.is(actual, expected); 88 | }); 89 | 90 | test('item options overrides top-level options', t => { 91 | const actual = _create([ 92 | { 93 | text: 'Default font', 94 | }, 95 | { 96 | text: 'Overriden font', 97 | font: 'Comic Sans MS', 98 | }, 99 | ], { 100 | font: 'Arial', 101 | }); 102 | 103 | const expected = ` 104 | Default font|font="Arial" 105 | Overriden font|font="Comic Sans MS" 106 | `.trim(); 107 | 108 | t.is(actual, expected); 109 | }); 110 | 111 | test('`text` property on top-level options throws TypeError', t => { 112 | const errorMessage = 'The `text` option is not supported as a top-level option. Use it on an item instead.'; 113 | 114 | t.throws(() => { 115 | xbar([ 116 | { 117 | text: '❤', 118 | }, 119 | ], { 120 | text: 'Override', 121 | }); 122 | }, {message: errorMessage}); 123 | }); 124 | --------------------------------------------------------------------------------