diff --git a/.eslintrc.json b/.eslintrc.json index dff77b56..9c33ef15 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -4,6 +4,9 @@ "rules": { "jsx-a11y/anchor-is-valid": ["error", { "specialLink": ["to"] + }], + "jsx-a11y/label-has-for": ["error", { + "required": { "some": ["nesting", "id"] } }] } } diff --git a/README.md b/README.md index c89c86b0..f427b115 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,16 @@ # Tickety-Tick [![Build Status](https://travis-ci.org/bitcrowd/tickety-tick.svg?branch=master)](https://travis-ci.org/bitcrowd/tickety-tick) -*How do you name this branch? What is the message for that commit?* +> #### How do you name this branch? What is the message for that commit? +> A browser extension to generate these for you, +> based on the ticket you're working on. + +![Tickety-Tick's user interface](./screenshots/interface.png) At bitcrowd we love conventions. One of them is how we name branches and commits. This makes it easy to relate a particular branch or commit to a certain ticket. -![screenshot](./screenshot.png) - -**Branches** always follow the format `type/id-title`, where: +**Branches** follow the format `type/id-title` by default, where: - `type` is usually one of: - `feature` (default) @@ -18,10 +20,15 @@ certain ticket. - `id` is the identifier of the ticket in your ticketing system - `title` is a lowercase, dasherized version of the ticket title -**Commits** always contain `[#id] title`. +**Commits** contain `[#id] title` by default. + +Additionally, Tickety-Tick generates [**commands**](#generated-commands) to +set up a branch with the proper name and to prepare the commit message for your +source code management tool. Out of the box, it is set up to work with Git. -Additionally, Tickety-Tick generates [**git commands**](#generated-commands) to -set up a branch with the proper name and to prepare the commit message. +If you need your commit messages, branch names or commands to look different, +you can [configure](#advanced-configuration) Tickety-Tick to use a custom +format. ## Supported ticket systems @@ -42,7 +49,7 @@ Tickety-Tick is available for every major browser: - [Chrome/Chromium](https://chrome.google.com/webstore/detail/ciakolhgmfijpjbpcofoalfjiladihbg) - [Firefox](https://addons.mozilla.org/firefox/addon/tickety-tick/) - [Opera](https://addons.opera.com/extensions/details/tickety-tick/) -- For Safari, you need to build it yourself (see below) +- For Safari, you need to build it yourself ([see below](#safari)) ## Keyboard Shortcuts @@ -118,12 +125,20 @@ To test-drive a development version, you can use: ```shell yarn open:chrome -yarn open:firefox +yarn open:firefox-local ``` You can run both `watch:[browser]` and `open:[browser]` scripts in parallel to automatically rebuild and reload the extension as you make changes. +### Running automated checks + +To execute the automated source code checks, run: + +```shell +yarn checks +``` + ### Generating coverage reports In order to generate code coverage reports locally, just run: @@ -144,16 +159,16 @@ open coverage/lcov-report/index.html ### Generated commands As mentioned earlier, in addition to branch names and commit messages, -Tickety-Tick generates git commands to set up a branch with the proper name +Tickety-Tick generates commands to set up a branch with the proper name and to prepare the commit message. -The code generated for copying will look like this: +By default, the code generated for copying will look like this: ```shell git checkout -b BRANCH-NAME && git commit --allow-empty -m COMMIT-MESSAGE ``` -The generated commands make a few assumptions: +These default generated commands make a few assumptions: 1. *You're using git (obviously).* The branch names and commit messages Tickety-Tick generates may work with other version control systems, @@ -165,6 +180,23 @@ The generated commands make a few assumptions: message title generated by Tickety-Tick when setting up the branch. This approach works nicely with our git workflow, for which the above -assumptions are true. Yours may be different though, in which case you may -still like Tickety-Tick's ability to generate the branch names and commit -messages for you. +assumptions are true. Yours may be different though, in which case you might +want to [configure](#advanced-configuration) Tickety-Tick differently. + +### Advanced configuration + +If you have different conventions regarding commit messages, branch names or +you're just using a different source code management tool, Tickety-Tick allows +you to customize the output format for all of these. + +In Firefox, open `about:addons` and select the Tickety-Tick preferences. + +![Firefox preferences](./screenshots/firefox-preferences.png) + +In Chrome, open `chrome://extensions/` and select "Options". + +![Chrome preferences](./screenshots/chrome-preferences.png) + +In Safari, open "Preferences" (`cmd + ,`), then select "Extensions". + +![Safari preferences](./screenshots/safari-preferences.png) diff --git a/package.json b/package.json index b2be9391..59622023 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "private": true, - "version": "1.3.0", + "version": "2.0.0", "name": "tickety-tick", "description": "A browser extension that helps you to create commit messages and branch names from story trackers.", "author": "Bodo Tasche ", @@ -18,13 +18,13 @@ "watch:firefox-local": "npm run build:firefox-local -- --watch", "watch:safari": "npm run build:safari -- --watch", "open:chrome": "./script/open-in-chrome ./dist/chrome https://github.com/bitcrowd/tickety-tick", - "open:firefox": "web-ext run --source-dir ./dist/firefox --pref startup.homepage_welcome_url=https://github.com/bitcrowd/tickety-tick", + "open:firefox-local": "web-ext run --source-dir ./dist/firefox-local --pref startup.homepage_welcome_url=https://github.com/bitcrowd/tickety-tick", "bundle:chrome": "cross-env BUNDLE=true npm run build:chrome", "bundle:firefox": "cross-env BUNDLE=true npm run build:firefox", "lint": "eslint '**/*.{js,jsx}'", "test": "cross-env NODE_ENV=test jasmine", "test:coverage": "nyc npm test", - "test:watch": "cross-env NODE_ENV=test nodemon --exec jasmine", + "test:watch": "cross-env NODE_ENV=test nodemon --ext js,jsx,json --exec jasmine", "checks": "npm run lint && npm run test" }, "devDependencies": { diff --git a/screenshots/chrome-preferences.png b/screenshots/chrome-preferences.png new file mode 100644 index 00000000..4d4bafe1 Binary files /dev/null and b/screenshots/chrome-preferences.png differ diff --git a/screenshots/firefox-preferences.png b/screenshots/firefox-preferences.png new file mode 100644 index 00000000..2c2c99a6 Binary files /dev/null and b/screenshots/firefox-preferences.png differ diff --git a/screenshot.png b/screenshots/interface.png similarity index 100% rename from screenshot.png rename to screenshots/interface.png diff --git a/screenshots/safari-preferences.png b/screenshots/safari-preferences.png new file mode 100644 index 00000000..a7870d0a Binary files /dev/null and b/screenshots/safari-preferences.png differ diff --git a/script/open-in-chrome b/script/open-in-chrome index 1c2ff894..31d8ec04 100755 --- a/script/open-in-chrome +++ b/script/open-in-chrome @@ -9,7 +9,7 @@ const dir = process.argv[2] || path.join(__dirname, '..', 'dist', 'chrome'); const url = process.argv[3] || 'https://github.com/bitcrowd/tickety-tick'; const options = { - chromeFlags: [`--load-extension=${dir}`], + chromeFlags: ['--no-default-browser-check', `--load-extension=${dir}`], enableExtensions: true, startingUrl: url, }; diff --git a/spec/enhance-spec.js b/spec/enhance-spec.js new file mode 100644 index 00000000..ab61d9f1 --- /dev/null +++ b/spec/enhance-spec.js @@ -0,0 +1,26 @@ +import enhance from '../src/common/enhance'; +import format, { defaults } from '../src/common/format'; + +describe('ticket enhancer', () => { + const ticket = { + id: 'BTC-042', + title: 'Add more tests for src/common/format.js', + type: 'enhancement', + }; + + const templates = defaults; + + it('attaches format output to tickets as "fmt" property', () => { + const formatter = format(templates); + const enhancer = enhance(templates); + + expect(enhancer(ticket)).toEqual({ + fmt: { + branch: formatter.branch(ticket), + commit: formatter.commit(ticket), + command: formatter.command(ticket), + }, + ...ticket, + }); + }); +}); diff --git a/spec/format-spec.js b/spec/format-spec.js new file mode 100644 index 00000000..91b61fe5 --- /dev/null +++ b/spec/format-spec.js @@ -0,0 +1,138 @@ +import format, { helpers } from '../src/common/format'; + +describe('ticket formatting', () => { + const ticket = { + id: 'BTC-042', + title: 'Add more tests for src/common/format.js', + type: 'enhancement', + }; + + describe('default format', () => { + const fmt = format({}); + + describe('commit', () => { + it('includes ticket id and title', () => { + const formatted = fmt.commit(ticket); + expect(formatted).toBe(`[#${ticket.id}] ${ticket.title}`); + }); + }); + + describe('branch', () => { + const { slugify } = helpers; + + it('includes ticket type, id and title', () => { + const formatted = fmt.branch(ticket); + expect(formatted).toBe(`${ticket.type}/${slugify(ticket.id)}-${slugify(ticket.title)}`); + }); + + it('formats type to "feature" if not provided', () => { + const typeless = { id: ticket.id, title: ticket.title }; + const formatted = fmt.branch(typeless); + expect(formatted).toBe(`feature/${slugify(ticket.id)}-${slugify(ticket.title)}`); + }); + }); + + describe('command', () => { + const { shellquote } = helpers; + + it('includes the quoted branch name and commit message', () => { + const branch = fmt.branch(ticket); + const commit = fmt.commit(ticket); + + const formatted = fmt.command(ticket); + + expect(formatted).toBe(`git checkout -b ${shellquote(branch)}` + + ` && git commit --allow-empty -m ${shellquote(commit)}`); + }); + }); + }); + + describe('helpers', () => { + describe('lowercase', () => { + const { lowercase } = helpers; + + it('lowercases strings', () => { + expect(lowercase('QUIET')).toBe('quiet'); + }); + }); + + describe('shellquote', () => { + const { shellquote } = helpers; + + it('wraps the input in single-quotes', () => { + expect(shellquote('echo "pwned"')).toBe('\'echo "pwned"\''); + }); + + it('escapes any single-quotes in the input', () => { + const input = 'you\'; echo aren\'t "pwned"'; + const quoted = '\'you\'\\\'\'; echo aren\'\\\'\'t "pwned"\''; + expect(shellquote(input)).toBe(quoted); + }); + }); + + describe('slugify', () => { + const { slugify } = helpers; + + it('formats normal strings', () => { + const formatted = slugify('hello'); + expect(formatted).toBe('hello'); + }); + + it('lowercases strings', () => { + const formatted = slugify('Bitcrowd'); + expect(formatted).toBe('bitcrowd'); + }); + + it('formats spaces to dashes', () => { + const formatted = slugify('hello bitcrowd'); + expect(formatted).toBe('hello-bitcrowd'); + }); + + it('formats special characters', () => { + const formatted = slugify('Señor Dévèloper'); + expect(formatted).toBe('senor-developer'); + }); + + it('formats umlauts', () => { + const formatted = slugify('äöüß'); + expect(formatted).toBe('aeoeuess'); + }); + + it('strips brackets', () => { + const formatted = slugify('[#23] Add (more)'); + expect(formatted).toBe('23-add-more'); + }); + + it('formats slashes to dashes', () => { + const formatted = slugify('src/js/format'); + expect(formatted).toBe('src-js-format'); + }); + + it('formats dots to dashes', () => { + const formatted = slugify('format.js'); + expect(formatted).toBe('format-js'); + }); + + it('strips hashes', () => { + const formatted = slugify('##23 #hashtag'); + expect(formatted).toBe('23-hashtag'); + }); + }); + + describe('trim', () => { + const { trim } = helpers; + + it('removes leading and trailing whitespace', () => { + expect(trim('\t black\t\t ')).toBe('black'); + }); + }); + + describe('uppercase', () => { + const { uppercase } = helpers; + + it('uppercases strings', () => { + expect(uppercase('loud')).toBe('LOUD'); + }); + }); + }); +}); diff --git a/spec/options/components/form-spec.jsx b/spec/options/components/form-spec.jsx new file mode 100644 index 00000000..c9086e9b --- /dev/null +++ b/spec/options/components/form-spec.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import Form from '../../../src/web-extension/options/components/form'; +import TemplateInput from '../../../src/web-extension/options/components/template-input'; + +import { defaults as fallbacks, helpers } from '../../../src/common/format'; + +describe('form', () => { + const render = (overrides) => { + const store = jasmine.createSpyObj('store', ['get', 'set']); + const defaults = { store }; + + const props = { ...defaults, ...overrides }; + const wrapper = shallow(
); + const instance = wrapper.instance(); + + return { wrapper, instance, props }; + }; + + const inputs = wrapper => wrapper.find(TemplateInput); + const input = (wrapper, name) => inputs(wrapper).find({ name }); + const value = (wrapper, name) => input(wrapper, name).prop('value'); + + const change = (wrapper, instance, name, val) => { + const event = { target: { name, value: val } }; + instance.handleChanged(event); + wrapper.update(); + }; + + it('renders a template-input for the branch name format', () => { + const { wrapper, instance } = render({}); + expect(input(wrapper, 'branch').props()).toEqual({ + label: 'Branch Name Format', + id: 'branch-name-format', + name: 'branch', + value: '', + fallback: fallbacks.branch, + disabled: true, + onChange: instance.handleChanged, + }); + }); + + it('renders a template-input for the commit message format', () => { + const { wrapper, instance } = render({}); + expect(input(wrapper, 'commit').props()).toEqual({ + label: 'Commit Message Format', + id: 'commit-message-format', + name: 'commit', + value: '', + fallback: fallbacks.commit, + disabled: true, + onChange: instance.handleChanged, + }); + }); + + it('renders a template-input for the command format', () => { + const { wrapper, instance } = render({}); + expect(input(wrapper, 'command').props()).toEqual({ + label: 'Command Format', + id: 'command-format', + name: 'command', + value: '', + fallback: fallbacks.command, + disabled: true, + onChange: instance.handleChanged, + }); + }); + + it('renders the names of available template helpers', () => { + const { wrapper } = render({}); + const text = wrapper.text(); + + Object.keys(helpers).forEach((name) => { + expect(text).toContain(name); + }); + }); + + it('loads stored templates on mount', () => { + const store = jasmine.createSpyObj('store', ['get', 'set']); + const data = { templates: { branch: 'a', commit: 'b', command: 'c' } }; + store.get.and.callFake((_, fn) => fn(data)); + + const { instance } = render({ store }); + spyOn(instance, 'handleLoaded'); + + instance.componentDidMount(); + + expect(store.get).toHaveBeenCalledWith(null, instance.handleLoaded); + expect(instance.handleLoaded).toHaveBeenCalledWith(data); + }); + + it('updates the form inputs once templates are loaded', () => { + const { wrapper, instance } = render({}); + const data = { templates: { branch: 'x', commit: 'y', command: 'z' } }; + + instance.handleLoaded(data); + wrapper.update(); + + expect(value(wrapper, 'branch')).toBe('x'); + expect(value(wrapper, 'commit')).toBe('y'); + expect(value(wrapper, 'command')).toBe('z'); + + expect(inputs(wrapper).every({ disabled: false })).toBe(true); + }); + + it('updates the form inputs on changes', () => { + const { wrapper, instance } = render({}); + + change(wrapper, instance, 'branch', 'branch++'); + expect(value(wrapper, 'branch')).toBe('branch++'); + + change(wrapper, instance, 'commit', 'commit++'); + expect(value(wrapper, 'commit')).toBe('commit++'); + + change(wrapper, instance, 'command', 'command++'); + expect(value(wrapper, 'command')).toBe('command++'); + }); + + it('stores templates on submit', () => { + const store = jasmine.createSpyObj('store', ['get', 'set']); + store.set.and.callFake((_, fn) => fn()); + + const { wrapper, instance } = render({ store }); + spyOn(instance, 'handleSaved'); + + change(wrapper, instance, 'branch', 'branch++'); + change(wrapper, instance, 'commit', 'commit++'); + change(wrapper, instance, 'command', 'command++'); + + const event = { preventDefault: jasmine.createSpy('preventDefault') }; + wrapper.simulate('submit', event); + + expect(event.preventDefault).toHaveBeenCalled(); + + const templates = { branch: 'branch++', commit: 'commit++', command: 'command++' }; + expect(store.set).toHaveBeenCalledWith({ templates }, instance.handleSaved); + expect(wrapper.find('button[type="submit"]').prop('disabled')).toBe(true); + expect(inputs(wrapper).every({ disabled: true })).toBe(true); + expect(instance.handleSaved).toHaveBeenCalled(); + }); + + it('re-enables the form elements once templates are stored', () => { + const { wrapper, instance } = render({}); + + wrapper.simulate('submit', { preventDefault: () => {} }); + instance.handleSaved(); + wrapper.update(); + + expect(wrapper.find('button[type="submit"]').prop('disabled')).toBe(false); + expect(inputs(wrapper).every({ disabled: false })).toBe(true); + }); +}); diff --git a/spec/options/components/template-input-spec.jsx b/spec/options/components/template-input-spec.jsx new file mode 100644 index 00000000..73a45dd9 --- /dev/null +++ b/spec/options/components/template-input-spec.jsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { shallow } from 'enzyme'; + +import TemplateInput from '../../../src/web-extension/options/components/template-input'; + +describe('template-input', () => { + const render = (overrides) => { + const defaults = { + id: 'template-input-id', + name: 'template-input-name', + label: 'template-label', + value: 'template-value', + fallback: 'template-fallback', + disabled: false, + onChange: jasmine.createSpy('onChange'), + }; + + const props = { ...defaults, ...overrides }; + const wrapper = shallow(); + + return { wrapper, props }; + }; + + it('renders an input label', () => { + const { wrapper } = render({ id: 'id-1', label: 'Awesome Template Label' }); + const label = wrapper.find('label'); + expect(label.prop('htmlFor')).toBe('id-1'); + expect(label.text()).toBe('Awesome Template Label'); + }); + + it('renders an input field', () => { + const { wrapper } = render({ id: 'id-2', name: 'name-2', value: 'vvv' }); + const input = wrapper.find('input'); + expect(input.prop('id')).toBe('id-2'); + expect(input.prop('name')).toBe('name-2'); + expect(input.prop('value')).toBe('vvv'); + }); + + it('notifies about input changes', () => { + const onChange = () => 'called on change'; + const { wrapper } = render({ onChange }); + const input = wrapper.find('input'); + expect(input.prop('onChange')).toBe(onChange); + }); + + it('disables the input if requested', () => { + const { wrapper } = render({ disabled: true }); + const input = wrapper.find('input'); + expect(input.prop('disabled')).toBe(true); + }); + + it('renders the default value used as a fallback', () => { + const { wrapper } = render({ fallback: 'fbfbfb' }); + expect(wrapper.text()).toContain('fbfbfb'); + }); +}); diff --git a/spec/popup/components/header-spec.jsx b/spec/popup/components/header-spec.jsx index ed48ea24..c92d0446 100644 --- a/spec/popup/components/header-spec.jsx +++ b/spec/popup/components/header-spec.jsx @@ -4,10 +4,11 @@ import { shallow } from 'enzyme'; import Header from '../../../src/common/popup/components/header'; import CopyButton from '../../../src/common/popup/components/copy-button'; -import fmt from '../../../src/common/popup/utils/format'; + +import { ticket as make } from '../../support/factories'; describe('header', () => { - const tickets = ['jedan', 'dva', 'tri'].map((title, i) => ({ id: `${i + 1}`, title })); + const tickets = ['jedan', 'dva', 'tri'].map((title, i) => make({ id: `${i + 1}`, title })); it('renders a link to the about page', () => { const wrapper = shallow(
); @@ -24,7 +25,7 @@ describe('header', () => { it('renders a button for copying a summary of all tickets', () => { const wrapper = shallow(
); - const value = tickets.map(fmt.commit).join(', '); + const value = tickets.map(ticket => ticket.fmt.commit).join(', '); const button = (Summary); expect(wrapper.containsMatchingElement(button)).toBe(true); }); diff --git a/spec/popup/components/ticket-list-spec.jsx b/spec/popup/components/ticket-list-spec.jsx index 1fb112c2..64092bcd 100644 --- a/spec/popup/components/ticket-list-spec.jsx +++ b/spec/popup/components/ticket-list-spec.jsx @@ -3,20 +3,21 @@ import { shallow } from 'enzyme'; import TicketList, { TicketListItem } from '../../../src/common/popup/components/ticket-list'; import CopyButton from '../../../src/common/popup/components/copy-button'; -import fmt from '../../../src/common/popup/utils/format'; + +import { ticket as make } from '../../support/factories'; describe('ticket-list', () => { - const tickets = ['uno', 'due', 'tre'].map((title, i) => ({ id: `${i + 1}`, title })); + const tickets = ['uno', 'due', 'tre'].map((title, i) => make({ id: `${i + 1}`, title })); it('renders a list of tickets', () => { const wrapper = shallow(); const items = tickets.map(t => ()); - expect(wrapper.find('ul').containsAllMatchingElements(items)).toBe(true); + expect(wrapper.find('ul > li').containsAllMatchingElements(items)).toBe(true); }); }); describe('ticket-list-item', () => { - const ticket = { id: '1', title: 'a ticket for señior developer' }; + const ticket = make({ id: '1', title: 'a ticket for señior developer' }); let wrapper; @@ -24,26 +25,22 @@ describe('ticket-list-item', () => { wrapper = shallow(); }); - it('renders a list item', () => { - expect(wrapper.is('li')).toBe(true); - }); - it('renders the ticket title', () => { expect(wrapper.contains(ticket.title)).toBe(true); }); it('renders a copy-button for the commit messsage', () => { const buttons = wrapper.find(CopyButton); - expect(buttons.someWhere(b => b.prop('value') === fmt.commit(ticket))).toBe(true); + expect(buttons.someWhere(b => b.prop('value') === ticket.fmt.commit)).toBe(true); }); it('renders a copy-button for the branch name', () => { const buttons = wrapper.find(CopyButton); - expect(buttons.someWhere(b => b.prop('value') === fmt.branch(ticket))).toBe(true); + expect(buttons.someWhere(b => b.prop('value') === ticket.fmt.branch)).toBe(true); }); it('renders a copy-button for the git commands', () => { const buttons = wrapper.find(CopyButton); - expect(buttons.someWhere(b => b.prop('value') === fmt.command(ticket))).toBe(true); + expect(buttons.someWhere(b => b.prop('value') === ticket.fmt.command)).toBe(true); }); }); diff --git a/spec/popup/components/tool-spec.jsx b/spec/popup/components/tool-spec.jsx index 0c7f7226..0eaa35ec 100644 --- a/spec/popup/components/tool-spec.jsx +++ b/spec/popup/components/tool-spec.jsx @@ -6,8 +6,10 @@ import TicketList from '../../../src/common/popup/components/ticket-list'; import NoTickets from '../../../src/common/popup/components/no-tickets'; import Header from '../../../src/common/popup/components/header'; +import { ticket as make } from '../../support/factories'; + describe('tool', () => { - const tickets = ['un', 'deux', 'trois'].map((title, i) => ({ id: `${i + 1}`, title })); + const tickets = ['un', 'deux', 'trois'].map((title, i) => make({ id: `${i + 1}`, title })); let wrapper; diff --git a/spec/popup/format-spec.js b/spec/popup/format-spec.js deleted file mode 100644 index 3e7fc6df..00000000 --- a/spec/popup/format-spec.js +++ /dev/null @@ -1,113 +0,0 @@ -import format from '../../src/common/popup/utils/format'; - -describe('format util', () => { - const ticket = { - id: 'BTC-042', - title: 'Add more tests for src/common/popup/utils/format.js', - type: 'bug', - }; - - describe('commit', () => { - it('includes ticket id and title', () => { - const formatted = format.commit(ticket); - expect(formatted).toBe(`[#${ticket.id}] ${ticket.title}`); - }); - }); - - describe('branch', () => { - it('includes ticket type, id and title', () => { - const formatted = format.branch(ticket); - expect(formatted) - .toBe(`${ticket.type}/${ticket.id}-${format.normalize(ticket.title)}`); - }); - - it('formats type to feature if not provided', () => { - const typeless = { - id: ticket.id, - title: ticket.title, - }; - const formatted = format.branch(typeless); - expect(formatted).toBe(`feature/${ticket.id}-${format.normalize(ticket.title)}`); - }); - }); - - describe('command', () => { - it('includes the quoted branch name and commit message', () => { - const quote = arg => (`'quoted-${arg}'`); - - const branchname = 'branch-name'; - const message = 'commit-message'; - - spyOn(format, 'shellquote').and.callFake(quote); - - spyOn(format, 'branch').and.returnValue(branchname); - spyOn(format, 'commit').and.returnValue(message); - - const formatted = format.command(ticket); - - expect(format.shellquote.calls.count()).toBe(2); - - expect(formatted).toBe(`git checkout -b ${quote(branchname)}` - + ` && git commit --allow-empty -m ${quote(message)}`); - }); - }); - - describe('shellquote', () => { - it('wraps the input in single-quotes', () => { - expect(format.shellquote('echo "pwned"')).toBe('\'echo "pwned"\''); - }); - - it('escapes any single-quotes in the input', () => { - const input = 'you\'; echo aren\'t "pwned"'; - const quoted = '\'you\'\\\'\'; echo aren\'\\\'\'t "pwned"\''; - expect(format.shellquote(input)).toBe(quoted); - }); - }); - - describe('normalize', () => { - it('formats normal strings', () => { - const formatted = format.normalize('hello'); - expect(formatted).toBe('hello'); - }); - - it('lowercases strings', () => { - const formatted = format.normalize('Bitcrowd'); - expect(formatted).toBe('bitcrowd'); - }); - - it('formats spaces to dashes', () => { - const formatted = format.normalize('hello bitcrowd'); - expect(formatted).toBe('hello-bitcrowd'); - }); - - it('formats special characters', () => { - const formatted = format.normalize('Señor Dévèloper'); - expect(formatted).toBe('senor-developer'); - }); - - it('formats umlauts', () => { - const formatted = format.normalize('äöüß'); - expect(formatted).toBe('aeoeuess'); - }); - - it('strips brackets', () => { - const formatted = format.normalize('[#23] Add (more)'); - expect(formatted).toBe('23-add-more'); - }); - - it('formats slashes to dashes', () => { - const formatted = format.normalize('src/js/format'); - expect(formatted).toBe('src-js-format'); - }); - - it('formats dots to dashes', () => { - const formatted = format.normalize('format.js'); - expect(formatted).toBe('format-js'); - }); - - it('strips hashes', () => { - const formatted = format.normalize('##23 #hashtag'); - expect(formatted).toBe('23-hashtag'); - }); - }); -}); diff --git a/spec/support/factories/index.js b/spec/support/factories/index.js new file mode 100644 index 00000000..da37a28d --- /dev/null +++ b/spec/support/factories/index.js @@ -0,0 +1,15 @@ +/* eslint-disable import/prefer-default-export */ + +export const ticket = (overrides = {}) => { + const defaults = { id: '1', title: 'ticket title', type: 'feature' }; + + const base = { ...defaults, ...overrides }; + + const branch = `branch-${base.id}`; + const commit = `commit-${base.id}`; + const command = `command-${base.id}`; + + const fmt = { branch, commit, command }; + + return { ...base, fmt }; +}; diff --git a/spec/template-spec.js b/spec/template-spec.js new file mode 100644 index 00000000..f4bf1c3e --- /dev/null +++ b/spec/template-spec.js @@ -0,0 +1,42 @@ +import compile from '../src/common/template'; + +describe('template', () => { + it('replaces any value occurrences', () => { + const render = compile('{number} => "{word}"'); + const output = render({ number: 12, word: 'dodici' }); + expect(output).toBe('12 => "dodici"'); + }); + + it('handles missing values', () => { + const transforms = { sparkle: s => `*${s}*` }; + const render = compile('--{nope | sparkle}', transforms); + expect(render({})).toBe('--**'); + expect(render()).toBe('--**'); + }); + + it('applies value transformations', () => { + const lowercase = jasmine.createSpy('lowercase').and.callFake(s => s.toLowerCase()); + const dasherize = jasmine.createSpy('dasherize').and.callFake(s => s.replace(/\s+/g, '-')); + const transforms = { lowercase, dasherize }; + + const render = compile('result: {title | lowercase | dasherize}', transforms); + const output = render({ title: 'A B C' }); + + expect(lowercase).toHaveBeenCalledWith('A B C'); + expect(dasherize).toHaveBeenCalledWith('a b c'); + expect(output).toBe('result: a-b-c'); + }); + + it('handles missing transformations', () => { + const render = compile('a{a | ??}', {}); + const output = render({ a: '++' }); + expect(output).toBe('a++'); + }); + + it('ignores whitespace within template expressions', () => { + const transforms = { triple: a => a * 3, square: a => a * a }; + const render = compile('({ a } * 3)**2 = { a | triple | square }', transforms); + const output = render({ a: 2 }); + expect(output).toBe('(2 * 3)**2 = 36'); + }); +}); diff --git a/src/common/enhance.js b/src/common/enhance.js new file mode 100644 index 00000000..c1bed27a --- /dev/null +++ b/src/common/enhance.js @@ -0,0 +1,16 @@ +import format from './format'; + +export default (templates) => { + const fmt = format(templates); + + const enhance = ticket => ({ + fmt: { + branch: fmt.branch(ticket), + commit: fmt.commit(ticket), + command: fmt.command(ticket), + }, + ...ticket, + }); + + return enhance; +}; diff --git a/src/common/format.js b/src/common/format.js new file mode 100644 index 00000000..8c52b5b4 --- /dev/null +++ b/src/common/format.js @@ -0,0 +1,48 @@ +import { createSlug } from 'speakingurl'; + +import compile from './template'; + +/* eslint-disable no-template-curly-in-string */ +export const defaults = { + commit: '[#{id}] {title}', + branch: '{type}/{id | slugify}-{title | slugify}', + command: 'git checkout -b {branch | shellquote} && git commit --allow-empty -m {commit | shellquote}', +}; +/* eslint-enable no-template-curly-in-string */ + +const lowercase = s => s.toLowerCase(); +const shellquote = s => (typeof s === 'string' ? `'${s.replace(/'/g, '\'\\\'\'')}'` : '\'\''); +const slugify = createSlug({ separator: '-' }); +const trim = s => s.replace(/^\s+|\s+$/g, ''); +const uppercase = s => s.toUpperCase(); + +export const helpers = { + lowercase, + shellquote, + slugify, + trim, + uppercase, +}; + +const fallbacks = { + type: 'feature', +}; + +export default (templates = {}) => { + const renderer = (name) => { + const render = compile(templates[name] || defaults[name], helpers); + return values => render({ ...fallbacks, ...values }); + }; + + const commit = renderer('commit'); + const branch = renderer('branch'); + const cmd = renderer('command'); + + const command = values => cmd({ + branch: branch(values), + commit: commit(values), + ...values, + }); + + return { branch, commit, command }; +}; diff --git a/src/common/popup/components/env-provider.jsx b/src/common/popup/components/env-provider.jsx index 100a5f8c..3474158e 100644 --- a/src/common/popup/components/env-provider.jsx +++ b/src/common/popup/components/env-provider.jsx @@ -1,7 +1,7 @@ -import React from 'react'; +import { Component } from 'react'; import PropTypes from 'prop-types'; -class EnvProvider extends React.Component { +class EnvProvider extends Component { getChildContext() { const { openext, grab } = this.props; return { openext, grab }; diff --git a/src/common/popup/components/header.jsx b/src/common/popup/components/header.jsx index 5f58d8c0..8752ecb9 100644 --- a/src/common/popup/components/header.jsx +++ b/src/common/popup/components/header.jsx @@ -4,25 +4,21 @@ import { Link } from 'react-router-dom'; import CopyButton from './copy-button'; import TicketShape from '../utils/ticket-shape'; -import fmt from '../utils/format'; -function Header(props) { - const btn = ((tickets) => { - if (tickets.length === 0) return null; +const button = (tickets) => { + const summary = tickets.map(ticket => ticket.fmt.commit).join(', '); - const summary = tickets.map(fmt.commit).join(', '); - - return ( -
- Summary - - {tickets.length} {(tickets.length === 1 ? 'ticket' : 'tickets')} - -
- ); - })(props.tickets); + return ( +
+ Summary + + {tickets.length} {tickets.length === 1 ? 'ticket' : 'tickets'} + +
+ ); +}; - /* eslint-disable jsx-a11y/anchor-is-valid */ +function Header({ tickets }) { return (
    @@ -30,10 +26,9 @@ function Header(props) { Info
- {btn} + {tickets.length > 0 ? button(tickets) : null}
); - /* eslint-enable jsx-a11y/anchor-is-valid */ } Header.propTypes = { diff --git a/src/common/popup/components/ticket-list.jsx b/src/common/popup/components/ticket-list.jsx index cba6258a..43948ff1 100644 --- a/src/common/popup/components/ticket-list.jsx +++ b/src/common/popup/components/ticket-list.jsx @@ -4,53 +4,46 @@ import octicons from 'octicons'; import CopyButton from './copy-button'; import TicketShape from '../utils/ticket-shape'; -import fmt from '../utils/format'; const svg = name => ({ __html: octicons[name].toSVG() }); /* eslint-disable jsx-a11y/tabindex-no-positive, react/no-danger */ function TicketListItem({ ticket }) { - const commit = fmt.commit(ticket); - const branch = fmt.branch(ticket); - const command = fmt.command(ticket); - return ( -
  • -
    -
    -
    -
    {ticket.title}
    -
    -
    - - - - - - - - - -
    +
    +
    +
    +
    {ticket.title}
    +
    +
    + + + + + + + + +
    -
  • + ); } @@ -61,14 +54,16 @@ TicketListItem.propTypes = { }; function TicketList({ tickets }) { - const item = ticket => ( - + const itemize = ticket => ( +
  • + +
  • ); return (
      - {tickets.map(item)} + {tickets.map(itemize)}
    ); diff --git a/src/common/popup/components/tool.jsx b/src/common/popup/components/tool.jsx index e21da3b1..33a6d107 100644 --- a/src/common/popup/components/tool.jsx +++ b/src/common/popup/components/tool.jsx @@ -6,14 +6,14 @@ import NoTickets from './no-tickets'; import Header from './header'; import TicketShape from '../utils/ticket-shape'; -function Tool(props) { - const content = (props.tickets && props.tickets.length > 0) - ? +function Tool({ tickets }) { + const content = (tickets && tickets.length > 0) + ? : ; return (
    -
    +
    {content}
    ); diff --git a/src/common/popup/utils/format.js b/src/common/popup/utils/format.js deleted file mode 100644 index 42956459..00000000 --- a/src/common/popup/utils/format.js +++ /dev/null @@ -1,30 +0,0 @@ -import { createSlug } from 'speakingurl'; - -const slugify = createSlug({ separator: '-' }); - -const format = {}; - -format.shellquote = function shellquote(s) { - if (typeof s === 'string') return `'${s.replace(/'/g, '\'\\\'\'')}'`; - return '\'\''; -}; - -format.normalize = function normalize(s) { - return slugify(s); -}; - -format.commit = function commit(ticket) { - return `[#${ticket.id}] ${ticket.title}`; -}; - -format.branch = function branch(ticket) { - return `${ticket.type || 'feature'}/${ticket.id}-${format.normalize(ticket.title)}`; -}; - -format.command = function command(ticket) { - const branchname = format.shellquote(format.branch(ticket)); - const message = format.shellquote(format.commit(ticket)); - return `git checkout -b ${branchname} && git commit --allow-empty -m ${message}`; -}; - -export default format; diff --git a/src/common/popup/utils/ticket-shape.js b/src/common/popup/utils/ticket-shape.js index 41d6d7c8..1de4c49f 100644 --- a/src/common/popup/utils/ticket-shape.js +++ b/src/common/popup/utils/ticket-shape.js @@ -1,9 +1,16 @@ import PropTypes from 'prop-types'; +const FmtShape = PropTypes.shape({ + commit: PropTypes.string.isRequired, + branch: PropTypes.string.isRequired, + command: PropTypes.string.isRequired, +}); + const TicketShape = PropTypes.shape({ id: PropTypes.string.isRequired, title: PropTypes.string.isRequired, type: PropTypes.string, + fmt: FmtShape.isRequired, }); export default TicketShape; diff --git a/src/common/template.js b/src/common/template.js new file mode 100644 index 00000000..807048b6 --- /dev/null +++ b/src/common/template.js @@ -0,0 +1,25 @@ +const trim = s => s.replace(/^\s+|\s+$/g, ''); +const identity = x => x; + +function compile(template, transforms = {}) { + const parts = template.match(/\{[^}]*\}|[^{]+/g); + + const fns = parts.map((part) => { + if (part[0] === '{' && part[part.length - 1] === '}') { + const [key, ...procs] = part + .replace(/^\{|\}$/g, '') + .split('|') + .map(trim); + + const pipeline = procs.map(name => transforms[name] || identity); + + return values => pipeline.reduce((v, fn) => fn(v), values[key] || ''); + } + + return () => part; + }); + + return (values = {}) => fns.map(fn => fn(values)).join(''); +} + +export default compile; diff --git a/src/safari-extension/Info.plist b/src/safari-extension/Info.plist index d6b9d1c4..8b0208ee 100644 --- a/src/safari-extension/Info.plist +++ b/src/safari-extension/Info.plist @@ -5,7 +5,7 @@ Author Bitcrowd Builder Version - 10600.3.18 + 12604.4.7.1.4 CFBundleDisplayName tickety-tick CFBundleIdentifier @@ -64,7 +64,7 @@ Description Create branches and commits based on tickets DeveloperIdentifier - G2U9HY25P2 + 0000000000 ExtensionInfoDictionaryVersion 1.0 Permissions diff --git a/src/safari-extension/Settings.plist b/src/safari-extension/Settings.plist new file mode 100644 index 00000000..4ac0915e --- /dev/null +++ b/src/safari-extension/Settings.plist @@ -0,0 +1,36 @@ + + + + + + DefaultValue + + Key + commitMessageFormat + Title + Commit Message Format + Type + TextField + + + DefaultValue + + Key + branchNameFormat + Title + Branch Name Format + Type + TextField + + + DefaultValue + + Key + commandFormat + Title + Command Format + Type + TextField + + + diff --git a/src/safari-extension/popup/popup.jsx b/src/safari-extension/popup/popup.js similarity index 73% rename from src/safari-extension/popup/popup.jsx rename to src/safari-extension/popup/popup.js index 17ac6e0c..72f26b69 100644 --- a/src/safari-extension/popup/popup.jsx +++ b/src/safari-extension/popup/popup.js @@ -2,9 +2,11 @@ /* global safari */ import render from '../../common/popup/render'; +import enhance from '../../common/enhance'; import '../../common/popup/popup.scss'; const app = safari.application; +const { settings } = safari.extension; function pbcopy(text) { prompt('Here you go:', text); // eslint-disable-line no-alert @@ -38,7 +40,17 @@ function onPopover(event) { function onMessage(event) { if (event.name === 'tickets') { - render(event.message, { grab, openext }); + const templates = { + commit: settings.commitMessageFormat, + branch: settings.branchNameFormat, + command: settings.commandFormat, + }; + + const tickets = event.message + ? event.message.map(enhance(templates)) + : null; + + render(tickets, { grab, openext }); } } diff --git a/src/web-extension/manifest.json b/src/web-extension/manifest.json index 9ecb7e24..6666988e 100644 --- a/src/web-extension/manifest.json +++ b/src/web-extension/manifest.json @@ -25,15 +25,19 @@ "web_accessible_resources": [ ], "permissions": [ - "tabs", "clipboardRead", - "clipboardWrite" + "clipboardWrite", + "tabs", + "storage" ], "browser_action": { "default_title": "Git Branch/Message", "default_icon": "icon-128.png", "default_popup": "popup.html" }, + "options_ui": { + "page": "options.html" + }, "commands": { "_execute_browser_action": { "suggested_key": { diff --git a/src/web-extension/options/components/form.jsx b/src/web-extension/options/components/form.jsx new file mode 100644 index 00000000..f0ef5f79 --- /dev/null +++ b/src/web-extension/options/components/form.jsx @@ -0,0 +1,127 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +import { defaults, helpers } from '../../../common/format'; +import TemplateInput from './template-input'; + +class Form extends Component { + constructor(props) { + super(props); + + this.state = { + loading: true, + branch: '', + commit: '', + command: '', + }; + + this.handleLoaded = this.handleLoaded.bind(this); + this.handleChanged = this.handleChanged.bind(this); + this.handleSubmit = this.handleSubmit.bind(this); + this.handleSaved = this.handleSaved.bind(this); + } + + componentDidMount() { + const { store } = this.props; + store.get(null, this.handleLoaded); + } + + handleLoaded(data) { + const { templates } = data || {}; + this.setState(() => ({ + loading: false, + ...templates, + })); + } + + handleChanged({ target }) { + const { name, value } = target; + this.setState({ [name]: value }); + } + + handleSubmit(event) { + event.preventDefault(); + + const { store } = this.props; + const { branch, commit, command } = this.state; + const templates = { branch, commit, command }; + + this.setState(() => ({ loading: true }), () => { + store.set({ templates }, this.handleSaved); + }); + } + + handleSaved() { + this.setState(() => ({ loading: false })); + } + + render() { + const { + branch, + commit, + command, + loading, + } = this.state; + + const fields = [ + { + label: 'Commit Message Format', + id: 'commit-message-format', + name: 'commit', + value: commit, + fallback: defaults.commit, + }, + { + label: 'Branch Name Format', + id: 'branch-name-format', + name: 'branch', + value: branch, + fallback: defaults.branch, + }, + { + label: 'Command Format', + id: 'command-format', + name: 'command', + value: command, + fallback: defaults.command, + }, + ]; + + const input = props => ( + + ); + + return ( + + {fields.map(input)} + +
    + +
    + Available Helpers: +
      + {Object.keys(helpers).sort().map(name =>
    • {name}
    • )} +
    +
    + +
    + +
    + + ); + } +} + +Form.propTypes = { + store: PropTypes.shape({ + get: PropTypes.func.isRequired, + set: PropTypes.func.isRequired, + }).isRequired, +}; + +export default Form; diff --git a/src/web-extension/options/components/template-input.jsx b/src/web-extension/options/components/template-input.jsx new file mode 100644 index 00000000..6a048136 --- /dev/null +++ b/src/web-extension/options/components/template-input.jsx @@ -0,0 +1,46 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +function TemplateInput(props) { + const { + id, + name, + label, + value, + fallback, + disabled, + onChange, + } = props; + + return ( +
    + + + + Default: {fallback} + +
    + ); +} + + +TemplateInput.propTypes = { + disabled: PropTypes.bool.isRequired, + fallback: PropTypes.string.isRequired, + id: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + value: PropTypes.string.isRequired, +}; + +export default TemplateInput; diff --git a/src/web-extension/options/options.html b/src/web-extension/options/options.html new file mode 100644 index 00000000..341bc494 --- /dev/null +++ b/src/web-extension/options/options.html @@ -0,0 +1,10 @@ + + + + + Options + + +
    + + diff --git a/src/web-extension/options/options.jsx b/src/web-extension/options/options.jsx new file mode 100644 index 00000000..119c81fd --- /dev/null +++ b/src/web-extension/options/options.jsx @@ -0,0 +1,12 @@ +/* eslint-env browser */ + +import React from 'react'; +import ReactDOM from 'react-dom'; + +import './options.scss'; +import Form from './components/form'; +import store from '../store'; + +const root = document.getElementById('options-root'); +const element = (
    ); +ReactDOM.render(element, root); diff --git a/src/web-extension/options/options.scss b/src/web-extension/options/options.scss new file mode 100644 index 00000000..9333abe5 --- /dev/null +++ b/src/web-extension/options/options.scss @@ -0,0 +1,17 @@ +// Core variables and mixins +@import "~bootstrap/scss/variables"; +@import "~bootstrap/scss/mixins"; + +// Reset and dependencies +@import "~bootstrap/scss/normalize"; + +// Core CSS +@import "~bootstrap/scss/type"; +@import "~bootstrap/scss/buttons"; +@import "~bootstrap/scss/utilities"; + +// Form classes +@import "~bootstrap/scss/forms"; + +// Popup style +@import "styles/additions"; diff --git a/src/web-extension/options/styles/_additions.scss b/src/web-extension/options/styles/_additions.scss new file mode 100644 index 00000000..c595b722 --- /dev/null +++ b/src/web-extension/options/styles/_additions.scss @@ -0,0 +1,7 @@ +body { + padding: .5rem; +} + +label { + margin-bottom: .5rem; +} diff --git a/src/web-extension/popup/popup.jsx b/src/web-extension/popup/popup.js similarity index 68% rename from src/web-extension/popup/popup.jsx rename to src/web-extension/popup/popup.js index d083f987..fee4ecfc 100644 --- a/src/web-extension/popup/popup.jsx +++ b/src/web-extension/popup/popup.js @@ -2,8 +2,11 @@ /* global chrome */ import render from '../../common/popup/render'; +import enhance from '../../common/enhance'; import '../../common/popup/popup.scss'; +import store from '../store'; + const { extension } = chrome; const background = extension.getBackgroundPage(); @@ -30,8 +33,14 @@ function openext() { } function load() { - background.getTickets((tickets) => { - render(tickets, { grab, openext }); + store.get(null, ({ templates }) => { + background.getTickets((tickets) => { + const result = tickets + ? tickets.map(enhance(templates)) + : null; + + render(result, { grab, openext }); + }); }); } diff --git a/src/web-extension/store.js b/src/web-extension/store.js new file mode 100644 index 00000000..13e18fac --- /dev/null +++ b/src/web-extension/store.js @@ -0,0 +1,6 @@ +/* global chrome */ + +// Store preferences in synced storage if available, use local as a fallback: +// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/sync +// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/API/storage/local +export default chrome.storage.sync || chrome.storage.local; diff --git a/webpack.common.js b/webpack.common.js index c4ebef16..fb7d97b7 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -29,9 +29,13 @@ export function dist(...p) { // - entry // - popup entry point differs between web-extension browsers and safari // - content script differs between web-extension browsers and safari -// - web-extension browsers include a background.js, safari does not +// - web-extension browsers include a background.js and options.js // - output.path: we create separate output directories for each browser -// - copy-webpack-plugin copy patterns: manifest.json != Info.plist +// - plugins: +// - copy: +// - web-extension browsers add manifest.json +// - safari adds Info.plist and Settings.plist +// - options-html: web-extension browsers add options.html const config = new Config(); @@ -94,7 +98,7 @@ config.plugin('html') config.plugin('extract') .use(ExtractTextPlugin, [{ - filename: 'popup.css', + filename: '[name].css', }]); config.plugin('copy') diff --git a/webpack.safari.babel.js b/webpack.safari.babel.js index 147f2135..e8f48d2f 100644 --- a/webpack.safari.babel.js +++ b/webpack.safari.babel.js @@ -4,14 +4,14 @@ import config, { src, dist } from './webpack.common'; // Configure separate entry points. -config.entry('popup').add(src.safari('popup', 'popup.jsx')); +config.entry('popup').add(src.safari('popup', 'popup.js')); config.entry('content').add(src.safari('content.js')); // Set browser-specific output path. config.output.path(dist('tickety-tick.safariextension')); -// Copy the Info.plist in addition to the common files. +// Copy Info.plist and Settings.plist in addition to the common files. config.plugin('copy').tap(([patterns]) => [[ ...patterns, @@ -19,6 +19,10 @@ config.plugin('copy').tap(([patterns]) => [[ from: src.safari('Info.plist'), flatten: true, }, + { + from: src.safari('Settings.plist'), + flatten: true, + }, ]]); export default config.toConfig(); diff --git a/webpack.webext.babel.js b/webpack.webext.babel.js index 6a629b83..2f84f5a3 100644 --- a/webpack.webext.babel.js +++ b/webpack.webext.babel.js @@ -1,5 +1,6 @@ /* eslint-disable import/no-extraneous-dependencies */ +import HtmlWebpackPlugin from 'html-webpack-plugin'; import ZipWebpackPlugin from 'zip-webpack-plugin'; import config, { src, dist } from './webpack.common'; @@ -12,7 +13,8 @@ const variant = process.env.VARIANT; // Configure separate entry points. -config.entry('popup').add(src.webext('popup', 'popup.jsx')); +config.entry('options').add(src.webext('options', 'options.jsx')); +config.entry('popup').add(src.webext('popup', 'popup.js')); config.entry('content').add(src.webext('content.js')); config.entry('background').add(src.webext('background.js')); @@ -20,7 +22,21 @@ config.entry('background').add(src.webext('background.js')); config.output.path(dist(variant)); -// Copy the manifest.json template in addition to default files. +// Create the options.html in addition to common files. + +config.plugin('options-html') + .use(HtmlWebpackPlugin, [{ + template: src.webext('options', 'options.html'), + filename: 'options.html', + chunks: ['options'], + inject: true, + minify: { + collapseWhitespace: true, + removeScriptTypeAttributes: true, + }, + }]); + +// Copy the manifest.json template in addition to common files. config.plugin('copy').tap(([patterns]) => [[ ...patterns, @@ -41,6 +57,12 @@ config.plugin('copy').tap(([patterns]) => [[ }; } + if (['firefox', 'firefox-local'].includes(variant)) { + mf.options_ui.browser_style = true; + } else { + mf.options_ui.chrome_style = true; + } + return JSON.stringify(mf); }, },