ソースを参照

Reorganize project structure

simov 6 年 前
コミット
a6a1526e75

+ 2 - 2
background/inject.js

@@ -12,7 +12,7 @@ md.inject = ({storage: {state}}) => (id) => {
     runAt: 'document_start'
   })
 
-  chrome.tabs.insertCSS(id, {file: 'css/content.css', runAt: 'document_start'})
+  chrome.tabs.insertCSS(id, {file: 'content/index.css', runAt: 'document_start'})
   chrome.tabs.insertCSS(id, {file: 'vendor/prism.min.css', runAt: 'document_start'})
 
   chrome.tabs.executeScript(id, {file: 'vendor/mithril.min.js', runAt: 'document_start'})
@@ -20,5 +20,5 @@ md.inject = ({storage: {state}}) => (id) => {
   if (state.content.emoji) {
     chrome.tabs.executeScript(id, {file: 'content/emoji.js', runAt: 'document_start'})
   }
-  chrome.tabs.executeScript(id, {file: 'content/content.js', runAt: 'document_start'})
+  chrome.tabs.executeScript(id, {file: 'content/index.js', runAt: 'document_start'})
 }

+ 5 - 1
background/messages.js

@@ -76,11 +76,15 @@ md.messages = ({storage: {defaults, state, set}, compilers, mathjax, webrequest}
     }
 
     // options
-    else if (req.message === 'options') {
+    else if (req.message === 'options.origins') {
       sendResponse({
         origins: state.origins,
         header: state.header,
         match: state.match,
+      })
+    }
+    else if (req.message === 'options.themes') {
+      sendResponse({
         themes: state.themes,
       })
     }

+ 0 - 0
css/content.css → content/index.css


+ 0 - 0
content/content.js → content/index.js


+ 0 - 555
content/options.js

@@ -1,555 +0,0 @@
-
-var defaults = {
-  // storage
-  origins: {},
-  header: false,
-  match: '',
-  themes: [],
-  // UI
-  scheme: 'https',
-  host: '',
-  timeout: null,
-  file: true,
-  theme: {},
-  // static
-  schemes: ['https', 'http', '*'],
-  encodings: {
-    'Unicode': ['UTF-8', 'UTF-16LE'],
-    'Arabic': ['ISO-8859-6', 'Windows-1256'],
-    'Baltic': ['ISO-8859-4', 'ISO-8859-13', 'Windows-1257'],
-    'Celtic': ['ISO-8859-14'],
-    'Central European': ['ISO-8859-2', 'Windows-1250'],
-    'Chinese Simplified': ['GB18030', 'GBK'],
-    'Chinese Traditional': ['BIG5'],
-    'Cyrillic': ['ISO-8859-5', 'IBM866', 'KOI8-R', 'KOI8-U', 'Windows-1251'],
-    'Greek': ['ISO-8859-7', 'Windows-1253'],
-    'Hebrew': ['Windows-1255', 'ISO-8859-8', 'ISO-8859-8-I'],
-    'Japanese': ['EUC-JP', 'ISO-2022-JP', 'Shift_JIS'],
-    'Korean': ['EUC-KR'],
-    'Nordic': ['ISO-8859-10'],
-    'Romanian': ['ISO-8859-16'],
-    'South European': ['ISO-8859-3'],
-    'Thai': ['Windows-874'],
-    'Turkish': ['Windows-1254'],
-    'Vietnamese': ['Windows-1258'],
-    'Western': ['ISO-8859-15', 'Windows-1252', 'Macintosh'],
-  },
-  // chrome
-  permissions: {},
-}
-
-var state = Object.assign({}, defaults)
-
-chrome.extension.isAllowedFileSchemeAccess((isAllowedAccess) => {
-  state.file = /Firefox/.test(navigator.userAgent)
-    ? true // ff: `Allow access to file URLs` option isn't available
-    : isAllowedAccess
-  m.redraw()
-})
-
-chrome.runtime.sendMessage({message: 'options'}, (res) => {
-  state = Object.assign({}, defaults, {file: state.file}, res)
-  chrome.permissions.getAll(({origins}) => {
-    state.permissions = origins.reduce((all, origin) =>
-      (all[origin.replace(/(.*)\/\*$/, '$1')] = true, all), {})
-    m.redraw()
-  })
-})
-
-var events = {
-  file: () => {
-    chrome.tabs.create({url: `chrome://extensions/?id=${chrome.runtime.id}`})
-  },
-
-  header: (e) => {
-    state.header = !state.header
-    chrome.runtime.sendMessage({
-      message: 'options.header',
-      header: state.header,
-    })
-  },
-
-  origin: {
-    scheme: (e) => {
-      state.scheme = state.schemes[e.target.selectedIndex]
-    },
-
-    host: (e) => {
-      state.host = e.target.value.replace(/.*:\/\/([^/]+).*/i, '$1')
-    },
-
-    add: (all) => () => {
-      if (!all && !state.host) {
-        return
-      }
-      var origin = all ? '*://*' : `${state.scheme}://${state.host}`
-      chrome.permissions.request({origins: [`${origin}/*`]}, (granted) => {
-        if (granted) {
-          chrome.runtime.sendMessage({message: 'origin.add', origin})
-          state.origins[origin] = {
-            match: state.match,
-            csp: false,
-            encoding: '',
-          }
-          state.host = ''
-          state.permissions[origin] = true
-          m.redraw()
-        }
-      })
-    },
-
-    remove: (origin) => () => {
-      chrome.permissions.remove({origins: [`${origin}/*`]}, (removed) => {
-        if (removed) {
-          chrome.runtime.sendMessage({message: 'origin.remove', origin})
-          delete state.origins[origin]
-          delete state.permissions[origin]
-          m.redraw()
-        }
-      })
-    },
-
-    refresh: (origin) => () => {
-      chrome.permissions.request({origins: [`${origin}/*`]}, (granted) => {
-        if (granted) {
-          state.permissions[origin] = true
-          m.redraw()
-        }
-      })
-    },
-
-    match: (origin) => (e) => {
-      state.origins[origin].match = e.target.value
-      clearTimeout(state.timeout)
-      state.timeout = setTimeout(() => {
-        var {match, csp, encoding} = state.origins[origin]
-        chrome.runtime.sendMessage({
-          message: 'origin.update',
-          origin,
-          options: {match, csp, encoding},
-        })
-      }, 750)
-    },
-
-    csp: (origin) => () => {
-      state.origins[origin].csp = !state.origins[origin].csp
-      var {match, csp, encoding} = state.origins[origin]
-      chrome.runtime.sendMessage({
-        message: 'origin.update',
-        origin,
-        options: {match, csp, encoding},
-      })
-    },
-
-    encoding: (origin) => (e) => {
-      state.origins[origin].encoding = e.target.value
-      var {match, csp, encoding} = state.origins[origin]
-      chrome.runtime.sendMessage({
-        message: 'origin.update',
-        origin,
-        options: {match, csp, encoding},
-      })
-    },
-  },
-
-  themes: {
-    name: (e) => {
-      state.theme.name = e.target.value
-    },
-
-    url: (e) => {
-      state.theme.url = e.target.value
-    },
-
-    add: () => {
-      if (!state.theme.name || !state.theme.url) {
-        return
-      }
-      var all = chrome.runtime.getManifest().web_accessible_resources
-        .filter((file) => file.indexOf('/themes/') === 0)
-        .map((file) => file.replace(/\/themes\/(.*)\.css/, '$1'))
-        .concat(state.themes.map(({name}) => name))
-      if (all.includes(state.theme.name)) {
-        return
-      }
-      state.themes.push({
-        name: state.theme.name,
-        url: state.theme.url,
-      })
-      chrome.runtime.sendMessage({
-        message: 'themes',
-        themes: state.themes.map(({name, url}) => ({name, url}))
-      })
-      state.theme.name = ''
-      state.theme.url = ''
-      m.redraw()
-    },
-
-    update: {
-      name: (theme) => (e) => {
-        theme.name = e.target.value
-        clearTimeout(state.timeout)
-        state.timeout = setTimeout(() => {
-          chrome.runtime.sendMessage({
-            message: 'themes',
-            themes: state.themes.map(({name, url}) => ({name, url}))
-          })
-          m.redraw()
-        }, 750)
-      },
-
-      url: (theme) => (e) => {
-        theme.url = e.target.value
-        clearTimeout(state.timeout)
-        state.timeout = setTimeout(() => {
-          chrome.runtime.sendMessage({
-            message: 'themes',
-            themes: state.themes.map(({name, url}) => ({name, url}))
-          })
-        }, 750)
-      }
-    },
-
-    remove: (theme) => () => {
-      var index = state.themes.findIndex(({name}) => name === theme.name)
-      state.themes.splice(index, 1)
-      chrome.runtime.sendMessage({
-        message: 'themes',
-        themes: state.themes.map(({name, url}) => ({name, url}))
-      })
-      m.redraw()
-    },
-  }
-}
-
-var oncreate = {
-  ripple: (vnode) => {
-    mdc.ripple.MDCRipple.attachTo(vnode.dom)
-  },
-  textfield: (vnode) => {
-    mdc.textfield.MDCTextField.attachTo(vnode.dom)
-  }
-}
-
-var onupdate = {
-  header: (vnode) => {
-    if (vnode.dom.classList.contains('is-checked') !== state.header) {
-      vnode.dom.classList.toggle('is-checked')
-    }
-  },
-  csp: (origin) => (vnode) => {
-    if (vnode.dom.classList.contains('is-checked') !== state.origins[origin].csp) {
-      vnode.dom.classList.toggle('is-checked')
-    }
-  }
-}
-
-m.mount(document.querySelector('main'), {
-  view: () => [
-    // allowed origins
-    m('.bs-callout m-origins',
-
-      // add origin
-      m('.m-add-origin',
-        m('h4.mdc-typography--headline5', 'Allowed Origins'),
-        m('select.mdc-elevation--z2 m-select', {
-          onchange: events.origin.scheme
-          },
-          state.schemes.map((scheme) =>
-          m('option', {
-            value: scheme,
-            selected: scheme === state.scheme
-            },
-            scheme + '://'
-          )
-        )),
-        m('.mdc-text-field m-textfield', {
-          oncreate: oncreate.textfield,
-          },
-          m('input.mdc-text-field__input', {
-            type: 'text',
-            value: state.host,
-            onchange: events.origin.host,
-            placeholder: 'raw.githubusercontent.com'
-          }),
-          m('.mdc-line-ripple')
-        ),
-        m('button.mdc-button mdc-button--raised m-button', {
-          oncreate: oncreate.ripple,
-          onclick: events.origin.add()
-          },
-          'Add'
-        ),
-        m('button.mdc-button mdc-button--raised m-button', {
-          oncreate: oncreate.ripple,
-          onclick: events.origin.add(true)
-          },
-          'Allow All'
-        )
-      ),
-
-      // global options
-      m('.m-global',
-        (
-          (
-            // header detection - ff: disabled
-            !/Firefox/.test(navigator.userAgent) &&
-            Object.keys(state.origins).length > 1
-          )
-          || null
-        ) &&
-        m('label.mdc-switch m-switch', {
-          onupdate: onupdate.header,
-          title: 'Toggle header detection'
-          },
-          m('input.mdc-switch__native-control', {
-            type: 'checkbox',
-            checked: state.header,
-            onchange: events.header
-          }),
-          m('.mdc-switch__background', m('.mdc-switch__knob')),
-          m('span.mdc-switch-label',
-            'Detect ',
-            m('code', 'text/markdown'),
-            ' and ',
-            m('code', 'text/x-markdown'),
-            ' content type'
-          )
-        ),
-
-        // file access is disabled
-        (!state.file || null) &&
-        m('button.mdc-button mdc-button--raised m-button', {
-          oncreate: oncreate.ripple,
-          onclick: events.file
-          },
-          'Allow Access to file:// URLs'
-        )
-      ),
-
-      // allowed origins
-      (state.file || Object.keys(state.origins).length > 1 || null) &&
-      m('ul.m-list',
-        Object.keys(state.origins).sort((a, b) => a < b ? 1 : a > b ? -1 : 0).map((origin) =>
-          (
-            (
-              state.file && origin === 'file://' &&
-              // ff: access to file:// URLs is not allowed
-              !/Firefox/.test(navigator.userAgent)
-            )
-            || origin !== 'file://' || null
-          ) &&
-          m('li.mdc-elevation--z2', {
-            class: state.origins[origin].expanded ? 'm-expanded' : null,
-            },
-            m('.m-summary', {
-              onclick: (e) => state.origins[origin].expanded = !state.origins[origin].expanded
-              },
-              m('.m-title', origin),
-              m('.m-options',
-                !state.permissions[origin] ? m('span', m('strong', 'refresh')) : null,
-                state.origins[origin].match !== state.match ? m('span', 'match') : null,
-                state.origins[origin].csp ? m('span', 'csp') : null,
-                state.origins[origin].encoding ? m('span', 'encoding') : null,
-              ),
-              m('i.material-icons', {
-                class: state.origins[origin].expanded ? 'icon-arrow-up' : 'icon-arrow-down'
-              })
-            ),
-            m('.m-content',
-              // match
-              m('.m-option m-match',
-                m('.m-name', m('span', 'match')),
-                m('.m-control',
-                  m('.mdc-text-field m-textfield', {
-                    oncreate: oncreate.textfield
-                    },
-                    m('input.mdc-text-field__input', {
-                      type: 'text',
-                      onkeyup: events.origin.match(origin),
-                      value: state.origins[origin].match,
-                    }),
-                    m('.mdc-line-ripple')
-                  )
-                )
-              ),
-              // csp
-              (origin !== 'file://' || null) &&
-              m('.m-option m-csp',
-                m('.m-name', m('span', 'csp')),
-                m('.m-control',
-                  m('label.mdc-switch m-switch', {
-                    onupdate: onupdate.csp(origin),
-                    },
-                    m('input.mdc-switch__native-control', {
-                      type: 'checkbox',
-                      checked: state.origins[origin].csp,
-                      onchange: events.origin.csp(origin)
-                    }),
-                    m('.mdc-switch__background', m('.mdc-switch__knob')),
-                    m('span.mdc-switch-label',
-                      'Disable ',
-                      m('code', 'Content Security Policy'),
-                    )
-                  )
-                )
-              ),
-              // encoding
-              (origin !== 'file://' || null) &&
-              m('.m-option m-encoding',
-                m('.m-name', m('span', 'encoding')),
-                m('.m-control',
-                  m('select.mdc-elevation--z2 m-select', {
-                    onchange: events.origin.encoding(origin),
-                    },
-                    m('option', {
-                      value: '',
-                      selected: state.origins[origin].encoding === ''
-                      },
-                      'auto'
-                    ),
-                    Object.keys(state.encodings).map((label) =>
-                      m('optgroup', {label}, state.encodings[label].map((encoding) =>
-                        m('option', {
-                          value: encoding,
-                          selected: state.origins[origin].encoding === encoding
-                          },
-                          encoding
-                        )
-                      ))
-                    )
-                  )
-                )
-              ),
-              // refresh/remove
-              (origin !== 'file://' || null) &&
-              m('.m-footer',
-                m('span',
-                  (!state.permissions[origin] || null) &&
-                  m('button.mdc-button mdc-button--raised m-button', {
-                    oncreate: oncreate.ripple,
-                    onclick: events.origin.refresh(origin)
-                    },
-                    'Refresh'
-                  ),
-                  m('button.mdc-button mdc-button--raised m-button', {
-                    oncreate: oncreate.ripple,
-                    onclick: events.origin.remove(origin)
-                    },
-                    'Remove'
-                  )
-                )
-              )
-            )
-          )
-        )
-      )
-    ),
-
-    // custom themes
-    m('.bs-callout m-themes',
-
-      // add theme
-      m('.m-add-theme',
-        m('h4.mdc-typography--headline5', 'Custom Themes'),
-        // name
-        m('.mdc-text-field m-textfield m-name', {
-          oncreate: oncreate.textfield,
-          },
-          m('input.mdc-text-field__input', {
-            type: 'text',
-            value: state.theme.name,
-            onchange: events.themes.name,
-            placeholder: 'Name'
-          }),
-          m('.mdc-line-ripple')
-        ),
-        // url
-        m('.mdc-text-field m-textfield m-url', {
-          oncreate: oncreate.textfield,
-          },
-          m('input.mdc-text-field__input', {
-            type: 'text',
-            value: state.theme.url,
-            onchange: events.themes.url,
-            placeholder: 'URL - file:///home.. | http://localhost..'
-          }),
-          m('.mdc-line-ripple')
-        ),
-        m('button.mdc-button mdc-button--raised m-button', {
-          oncreate: oncreate.ripple,
-          onclick: events.themes.add
-          },
-          'Add'
-        ),
-      ),
-
-      // themes list
-      (state.themes.length || null) &&
-      m('ul.m-list', state.themes.map((theme) =>
-        m('li.mdc-elevation--z2', {
-          class: theme.expanded ? 'm-expanded' : null,
-          },
-          m('.m-summary', {
-            onclick: (e) => theme.expanded = !theme.expanded
-            },
-            m('.m-title', theme.name),
-            m('i.material-icons', {
-              class: theme.expanded ? 'icon-arrow-up' : 'icon-arrow-down'
-            })
-          ),
-          m('.m-content',
-            // name
-            m('.m-option m-theme',
-              m('.m-name', m('span', 'Name')),
-              m('.m-control',
-                m('.mdc-text-field m-textfield', {
-                  oncreate: oncreate.textfield
-                  },
-                  m('input.mdc-text-field__input', {
-                    type: 'text',
-                    onkeyup: events.themes.update.name(theme),
-                    value: theme.name,
-                  }),
-                  m('.mdc-line-ripple')
-                )
-              )
-            ),
-            // url
-            m('.m-option m-theme',
-              m('.m-name', m('span', 'URL')),
-              m('.m-control',
-                m('.mdc-text-field m-textfield', {
-                  oncreate: oncreate.textfield
-                  },
-                  m('input.mdc-text-field__input', {
-                    type: 'text',
-                    onkeyup: events.themes.update.url(theme),
-                    value: theme.url,
-                  }),
-                  m('.mdc-line-ripple')
-                )
-              )
-            ),
-            // update/remove
-            m('.m-footer',
-              m('span',
-                m('button.mdc-button mdc-button--raised m-button', {
-                  oncreate: oncreate.ripple,
-                  onclick: events.themes.remove(theme)
-                  },
-                  'Remove'
-                )
-              )
-            )
-          )
-        )
-      ))
-    )
-  ]
-})
-
-// ff: set appropriate footer icon
-document.querySelector(
-  '.icon-' + (/Firefox/.test(navigator.userAgent) ? 'firefox' : 'chrome')
-).classList.remove('icon-hidden')

+ 0 - 0
images/icon128.png → icons/icon128.png


+ 0 - 0
images/icon16.png → icons/icon16.png


+ 0 - 0
images/icon19.png → icons/icon19.png


+ 0 - 0
images/icon38.png → icons/icon38.png


+ 0 - 0
images/icon48.png → icons/icon48.png


+ 7 - 7
manifest.firefox.json

@@ -7,15 +7,15 @@
   "browser_action": {
     "browser_style": false,
     "default_icon": {
-      "19" : "/images/icon19.png",
-      "38" : "/images/icon38.png"
+      "19" : "/icons/icon19.png",
+      "38" : "/icons/icon38.png"
     },
     "default_title": "Markdown Viewer",
-    "default_popup": "/content/popup.html"
+    "default_popup": "/popup/index.html"
   },
 
   "options_ui": {
-    "page": "content/options.html"
+    "page": "/options/index.html"
   },
 
   "background" : {
@@ -64,9 +64,9 @@
   ],
 
   "icons": {
-    "16" : "/images/icon16.png",
-    "48" : "/images/icon48.png",
-    "128": "/images/icon128.png"
+    "16" : "/icons/icon16.png",
+    "48" : "/icons/icon48.png",
+    "128": "/icons/icon128.png"
   },
 
   "homepage_url": "https://addons.mozilla.org/en-US/firefox/addon/markdown-viewer-chrome/",

+ 7 - 7
manifest.json

@@ -6,14 +6,14 @@
 
   "browser_action": {
     "default_icon": {
-      "19" : "/images/icon19.png",
-      "38" : "/images/icon38.png"
+      "19" : "/icons/icon19.png",
+      "38" : "/icons/icon38.png"
     },
     "default_title": "Markdown Viewer",
-    "default_popup": "/content/popup.html"
+    "default_popup": "/popup/index.html"
   },
 
-  "options_page": "/content/options.html",
+  "options_page": "/options/index.html",
 
   "background" : {
     "scripts": [
@@ -62,9 +62,9 @@
   ],
 
   "icons": {
-    "16" : "/images/icon16.png",
-    "48" : "/images/icon48.png",
-    "128": "/images/icon128.png"
+    "16" : "/icons/icon16.png",
+    "48" : "/icons/icon48.png",
+    "128": "/icons/icon128.png"
   },
 
   "homepage_url": "https://chrome.google.com/webstore/detail/markdown-viewer/ckkdlimhmcjmikdlpkmbgfkaikojcbjk",

+ 7 - 7
manifest.test.json

@@ -6,14 +6,14 @@
 
   "browser_action": {
     "default_icon": {
-      "19" : "/images/icon19.png",
-      "38" : "/images/icon38.png"
+      "19" : "/icons/icon19.png",
+      "38" : "/icons/icon38.png"
     },
     "default_title": "Markdown Viewer",
-    "default_popup": "/content/popup.html"
+    "default_popup": "/popup/index.html"
   },
 
-  "options_page": "/content/options.html",
+  "options_page": "/options/index.html",
 
   "background" : {
     "scripts": [
@@ -62,9 +62,9 @@
   ],
 
   "icons": {
-    "16" : "/images/icon16.png",
-    "48" : "/images/icon48.png",
-    "128": "/images/icon128.png"
+    "16" : "/icons/icon16.png",
+    "48" : "/icons/icon48.png",
+    "128": "/icons/icon128.png"
   },
 
   "homepage_url": "https://chrome.google.com/webstore/detail/markdown-viewer/ckkdlimhmcjmikdlpkmbgfkaikojcbjk",

+ 0 - 0
css/icons.css → options/icons.css


+ 0 - 0
css/icons.ttf → options/icons.ttf


+ 0 - 0
css/options.css → options/index.css


+ 5 - 3
content/options.html → options/index.html

@@ -5,8 +5,8 @@
   <meta name="viewport" content="width=device-width, initial-scale=1" />
   <title>Markdown Viewer</title>
   <link href="/vendor/mdc.min.css" rel="stylesheet" type="text/css" media="all" />
-  <link href="/css/icons.css" rel="stylesheet" type="text/css" media="all" />
-  <link href="/css/options.css" rel="stylesheet" type="text/css" media="all" />
+  <link href="/options/icons.css" rel="stylesheet" type="text/css" media="all" />
+  <link href="/options/index.css" rel="stylesheet" type="text/css" media="all" />
   <script src="/vendor/mdc.min.js" type="text/javascript" charset="utf-8"></script>
   <script src="/vendor/mithril.min.js" type="text/javascript" charset="utf-8"></script>
 </head>
@@ -29,5 +29,7 @@
     </nav>
   </footer>
 </body>
-<script src="/content/options.js" type="text/javascript" charset="utf-8"></script>
+<script src="/options/origins.js" type="text/javascript" charset="utf-8"></script>
+<script src="/options/themes.js" type="text/javascript" charset="utf-8"></script>
+<script src="/options/index.js" type="text/javascript" charset="utf-8"></script>
 </html>

+ 17 - 0
options/index.js

@@ -0,0 +1,17 @@
+
+var origins = Origins()
+var themes = Themes()
+
+m.mount(document.querySelector('main'), {
+  view: () => [
+    // allowed origins
+    origins.render(),
+    // custom themes
+    themes.render(),
+  ]
+})
+
+// ff: set appropriate footer icon
+document.querySelector(
+  '.icon-' + (/Firefox/.test(navigator.userAgent) ? 'firefox' : 'chrome')
+).classList.remove('icon-hidden')

+ 378 - 0
options/origins.js

@@ -0,0 +1,378 @@
+
+var Origins = () => {
+  var defaults = {
+    // storage
+    origins: {},
+    header: false,
+    match: '',
+    // UI
+    scheme: 'https',
+    host: '',
+    timeout: null,
+    file: true,
+    // static
+    schemes: ['https', 'http', '*'],
+    encodings: {
+      'Unicode': ['UTF-8', 'UTF-16LE'],
+      'Arabic': ['ISO-8859-6', 'Windows-1256'],
+      'Baltic': ['ISO-8859-4', 'ISO-8859-13', 'Windows-1257'],
+      'Celtic': ['ISO-8859-14'],
+      'Central European': ['ISO-8859-2', 'Windows-1250'],
+      'Chinese Simplified': ['GB18030', 'GBK'],
+      'Chinese Traditional': ['BIG5'],
+      'Cyrillic': ['ISO-8859-5', 'IBM866', 'KOI8-R', 'KOI8-U', 'Windows-1251'],
+      'Greek': ['ISO-8859-7', 'Windows-1253'],
+      'Hebrew': ['Windows-1255', 'ISO-8859-8', 'ISO-8859-8-I'],
+      'Japanese': ['EUC-JP', 'ISO-2022-JP', 'Shift_JIS'],
+      'Korean': ['EUC-KR'],
+      'Nordic': ['ISO-8859-10'],
+      'Romanian': ['ISO-8859-16'],
+      'South European': ['ISO-8859-3'],
+      'Thai': ['Windows-874'],
+      'Turkish': ['Windows-1254'],
+      'Vietnamese': ['Windows-1258'],
+      'Western': ['ISO-8859-15', 'Windows-1252', 'Macintosh'],
+    },
+    // chrome
+    permissions: {},
+  }
+
+  var state = Object.assign({}, defaults)
+
+  chrome.extension.isAllowedFileSchemeAccess((isAllowedAccess) => {
+    state.file = /Firefox/.test(navigator.userAgent)
+      ? true // ff: `Allow access to file URLs` option isn't available
+      : isAllowedAccess
+    m.redraw()
+  })
+
+  chrome.runtime.sendMessage({message: 'options.origins'}, (res) => {
+    Object.assign(state, {file: state.file}, res)
+    chrome.permissions.getAll(({origins}) => {
+      state.permissions = origins.reduce((all, origin) =>
+        (all[origin.replace(/(.*)\/\*$/, '$1')] = true, all), {})
+      m.redraw()
+    })
+  })
+
+  var events = {
+    file: () => {
+      chrome.tabs.create({url: `chrome://extensions/?id=${chrome.runtime.id}`})
+    },
+
+    header: (e) => {
+      state.header = !state.header
+      chrome.runtime.sendMessage({
+        message: 'options.header',
+        header: state.header,
+      })
+    },
+
+    origin: {
+      scheme: (e) => {
+        state.scheme = state.schemes[e.target.selectedIndex]
+      },
+
+      host: (e) => {
+        state.host = e.target.value.replace(/.*:\/\/([^/]+).*/i, '$1')
+      },
+
+      add: (all) => () => {
+        if (!all && !state.host) {
+          return
+        }
+        var origin = all ? '*://*' : `${state.scheme}://${state.host}`
+        chrome.permissions.request({origins: [`${origin}/*`]}, (granted) => {
+          if (granted) {
+            chrome.runtime.sendMessage({message: 'origin.add', origin})
+            state.origins[origin] = {
+              match: state.match,
+              csp: false,
+              encoding: '',
+            }
+            state.host = ''
+            state.permissions[origin] = true
+            m.redraw()
+          }
+        })
+      },
+
+      remove: (origin) => () => {
+        chrome.permissions.remove({origins: [`${origin}/*`]}, (removed) => {
+          if (removed) {
+            chrome.runtime.sendMessage({message: 'origin.remove', origin})
+            delete state.origins[origin]
+            delete state.permissions[origin]
+            m.redraw()
+          }
+        })
+      },
+
+      refresh: (origin) => () => {
+        chrome.permissions.request({origins: [`${origin}/*`]}, (granted) => {
+          if (granted) {
+            state.permissions[origin] = true
+            m.redraw()
+          }
+        })
+      },
+
+      match: (origin) => (e) => {
+        state.origins[origin].match = e.target.value
+        clearTimeout(state.timeout)
+        state.timeout = setTimeout(() => {
+          var {match, csp, encoding} = state.origins[origin]
+          chrome.runtime.sendMessage({
+            message: 'origin.update',
+            origin,
+            options: {match, csp, encoding},
+          })
+        }, 750)
+      },
+
+      csp: (origin) => () => {
+        state.origins[origin].csp = !state.origins[origin].csp
+        var {match, csp, encoding} = state.origins[origin]
+        chrome.runtime.sendMessage({
+          message: 'origin.update',
+          origin,
+          options: {match, csp, encoding},
+        })
+      },
+
+      encoding: (origin) => (e) => {
+        state.origins[origin].encoding = e.target.value
+        var {match, csp, encoding} = state.origins[origin]
+        chrome.runtime.sendMessage({
+          message: 'origin.update',
+          origin,
+          options: {match, csp, encoding},
+        })
+      },
+    },
+  }
+
+  var oncreate = {
+    ripple: (vnode) => {
+      mdc.ripple.MDCRipple.attachTo(vnode.dom)
+    },
+    textfield: (vnode) => {
+      mdc.textfield.MDCTextField.attachTo(vnode.dom)
+    }
+  }
+
+  var onupdate = {
+    header: (vnode) => {
+      if (vnode.dom.classList.contains('is-checked') !== state.header) {
+        vnode.dom.classList.toggle('is-checked')
+      }
+    },
+    csp: (origin) => (vnode) => {
+      if (vnode.dom.classList.contains('is-checked') !== state.origins[origin].csp) {
+        vnode.dom.classList.toggle('is-checked')
+      }
+    }
+  }
+
+  var render = () =>
+    m('.bs-callout m-origins',
+
+      // add origin
+      m('.m-add-origin',
+        m('h4.mdc-typography--headline5', 'Allowed Origins'),
+        m('select.mdc-elevation--z2 m-select', {
+          onchange: events.origin.scheme
+          },
+          state.schemes.map((scheme) =>
+          m('option', {
+            value: scheme,
+            selected: scheme === state.scheme
+            },
+            scheme + '://'
+          )
+        )),
+        m('.mdc-text-field m-textfield', {
+          oncreate: oncreate.textfield,
+          },
+          m('input.mdc-text-field__input', {
+            type: 'text',
+            value: state.host,
+            onchange: events.origin.host,
+            placeholder: 'raw.githubusercontent.com'
+          }),
+          m('.mdc-line-ripple')
+        ),
+        m('button.mdc-button mdc-button--raised m-button', {
+          oncreate: oncreate.ripple,
+          onclick: events.origin.add()
+          },
+          'Add'
+        ),
+        m('button.mdc-button mdc-button--raised m-button', {
+          oncreate: oncreate.ripple,
+          onclick: events.origin.add(true)
+          },
+          'Allow All'
+        )
+      ),
+
+      // global options
+      m('.m-global',
+        (
+          (
+            // header detection - ff: disabled
+            !/Firefox/.test(navigator.userAgent) &&
+            Object.keys(state.origins).length > 1
+          )
+          || null
+        ) &&
+        m('label.mdc-switch m-switch', {
+          onupdate: onupdate.header,
+          title: 'Toggle header detection'
+          },
+          m('input.mdc-switch__native-control', {
+            type: 'checkbox',
+            checked: state.header,
+            onchange: events.header
+          }),
+          m('.mdc-switch__background', m('.mdc-switch__knob')),
+          m('span.mdc-switch-label',
+            'Detect ',
+            m('code', 'text/markdown'),
+            ' and ',
+            m('code', 'text/x-markdown'),
+            ' content type'
+          )
+        ),
+
+        // file access is disabled
+        (!state.file || null) &&
+        m('button.mdc-button mdc-button--raised m-button', {
+          oncreate: oncreate.ripple,
+          onclick: events.file
+          },
+          'Allow Access to file:// URLs'
+        )
+      ),
+
+      // allowed origins
+      (state.file || Object.keys(state.origins).length > 1 || null) &&
+      m('ul.m-list',
+        Object.keys(state.origins).sort((a, b) => a < b ? 1 : a > b ? -1 : 0).map((origin) =>
+          (
+            (
+              state.file && origin === 'file://' &&
+              // ff: access to file:// URLs is not allowed
+              !/Firefox/.test(navigator.userAgent)
+            )
+            || origin !== 'file://' || null
+          ) &&
+          m('li.mdc-elevation--z2', {
+            class: state.origins[origin].expanded ? 'm-expanded' : null,
+            },
+            m('.m-summary', {
+              onclick: (e) => state.origins[origin].expanded = !state.origins[origin].expanded
+              },
+              m('.m-title', origin),
+              m('.m-options',
+                !state.permissions[origin] ? m('span', m('strong', 'refresh')) : null,
+                state.origins[origin].match !== state.match ? m('span', 'match') : null,
+                state.origins[origin].csp ? m('span', 'csp') : null,
+                state.origins[origin].encoding ? m('span', 'encoding') : null,
+              ),
+              m('i.material-icons', {
+                class: state.origins[origin].expanded ? 'icon-arrow-up' : 'icon-arrow-down'
+              })
+            ),
+            m('.m-content',
+              // match
+              m('.m-option m-match',
+                m('.m-name', m('span', 'match')),
+                m('.m-control',
+                  m('.mdc-text-field m-textfield', {
+                    oncreate: oncreate.textfield
+                    },
+                    m('input.mdc-text-field__input', {
+                      type: 'text',
+                      onkeyup: events.origin.match(origin),
+                      value: state.origins[origin].match,
+                    }),
+                    m('.mdc-line-ripple')
+                  )
+                )
+              ),
+              // csp
+              (origin !== 'file://' || null) &&
+              m('.m-option m-csp',
+                m('.m-name', m('span', 'csp')),
+                m('.m-control',
+                  m('label.mdc-switch m-switch', {
+                    onupdate: onupdate.csp(origin),
+                    },
+                    m('input.mdc-switch__native-control', {
+                      type: 'checkbox',
+                      checked: state.origins[origin].csp,
+                      onchange: events.origin.csp(origin)
+                    }),
+                    m('.mdc-switch__background', m('.mdc-switch__knob')),
+                    m('span.mdc-switch-label',
+                      'Disable ',
+                      m('code', 'Content Security Policy'),
+                    )
+                  )
+                )
+              ),
+              // encoding
+              (origin !== 'file://' || null) &&
+              m('.m-option m-encoding',
+                m('.m-name', m('span', 'encoding')),
+                m('.m-control',
+                  m('select.mdc-elevation--z2 m-select', {
+                    onchange: events.origin.encoding(origin),
+                    },
+                    m('option', {
+                      value: '',
+                      selected: state.origins[origin].encoding === ''
+                      },
+                      'auto'
+                    ),
+                    Object.keys(state.encodings).map((label) =>
+                      m('optgroup', {label}, state.encodings[label].map((encoding) =>
+                        m('option', {
+                          value: encoding,
+                          selected: state.origins[origin].encoding === encoding
+                          },
+                          encoding
+                        )
+                      ))
+                    )
+                  )
+                )
+              ),
+              // refresh/remove
+              (origin !== 'file://' || null) &&
+              m('.m-footer',
+                m('span',
+                  (!state.permissions[origin] || null) &&
+                  m('button.mdc-button mdc-button--raised m-button', {
+                    oncreate: oncreate.ripple,
+                    onclick: events.origin.refresh(origin)
+                    },
+                    'Refresh'
+                  ),
+                  m('button.mdc-button mdc-button--raised m-button', {
+                    oncreate: oncreate.ripple,
+                    onclick: events.origin.remove(origin)
+                    },
+                    'Remove'
+                  )
+                )
+              )
+            )
+          )
+        )
+      )
+    )
+
+  return {state, render}
+}

+ 207 - 0
options/themes.js

@@ -0,0 +1,207 @@
+
+var Themes = () => {
+  var defaults = {
+    // storage
+    themes: [],
+    // UI
+    timeout: null,
+    theme: {},
+    // static
+  }
+
+  var state = Object.assign({}, defaults)
+
+  chrome.runtime.sendMessage({message: 'options.themes'}, (res) => {
+    Object.assign(state, res)
+    m.redraw()
+  })
+
+  var events = {
+    name: (e) => {
+      state.theme.name = e.target.value
+    },
+
+    url: (e) => {
+      state.theme.url = e.target.value
+    },
+
+    add: () => {
+      if (!state.theme.name || !state.theme.url) {
+        return
+      }
+      var all = chrome.runtime.getManifest().web_accessible_resources
+        .filter((file) => file.indexOf('/themes/') === 0)
+        .map((file) => file.replace(/\/themes\/(.*)\.css/, '$1'))
+        .concat(state.themes.map(({name}) => name))
+      if (all.includes(state.theme.name)) {
+        return
+      }
+      state.themes.push({
+        name: state.theme.name,
+        url: state.theme.url,
+      })
+      chrome.runtime.sendMessage({
+        message: 'themes',
+        themes: state.themes.map(({name, url}) => ({name, url}))
+      })
+      state.theme.name = ''
+      state.theme.url = ''
+      m.redraw()
+    },
+
+    update: {
+      name: (theme) => (e) => {
+        theme.name = e.target.value
+        clearTimeout(state.timeout)
+        state.timeout = setTimeout(() => {
+          chrome.runtime.sendMessage({
+            message: 'themes',
+            themes: state.themes.map(({name, url}) => ({name, url}))
+          })
+          m.redraw()
+        }, 750)
+      },
+
+      url: (theme) => (e) => {
+        theme.url = e.target.value
+        clearTimeout(state.timeout)
+        state.timeout = setTimeout(() => {
+          chrome.runtime.sendMessage({
+            message: 'themes',
+            themes: state.themes.map(({name, url}) => ({name, url}))
+          })
+        }, 750)
+      }
+    },
+
+    remove: (theme) => () => {
+      var index = state.themes.findIndex(({name}) => name === theme.name)
+      state.themes.splice(index, 1)
+      chrome.runtime.sendMessage({
+        message: 'themes',
+        themes: state.themes.map(({name, url}) => ({name, url}))
+      })
+      m.redraw()
+    }
+  }
+
+  var oncreate = {
+    ripple: (vnode) => {
+      mdc.ripple.MDCRipple.attachTo(vnode.dom)
+    },
+    textfield: (vnode) => {
+      mdc.textfield.MDCTextField.attachTo(vnode.dom)
+    }
+  }
+
+  var onupdate = {
+    cache: (theme) => (vnode) => {
+      if (vnode.dom.classList.contains('is-checked') !== state.themes[theme.name].cache) {
+        vnode.dom.classList.toggle('is-checked')
+      }
+    }
+  }
+
+  var render = () =>
+    m('.bs-callout m-themes',
+
+      // add theme
+      m('.m-add-theme',
+        m('h4.mdc-typography--headline5', 'Custom Themes'),
+        // name
+        m('.mdc-text-field m-textfield m-name', {
+          oncreate: oncreate.textfield,
+          },
+          m('input.mdc-text-field__input', {
+            type: 'text',
+            value: state.theme.name,
+            onchange: events.name,
+            placeholder: 'Name'
+          }),
+          m('.mdc-line-ripple')
+        ),
+        // url
+        m('.mdc-text-field m-textfield m-url', {
+          oncreate: oncreate.textfield,
+          },
+          m('input.mdc-text-field__input', {
+            type: 'text',
+            value: state.theme.url,
+            onchange: events.url,
+            placeholder: 'URL - file:///home.. | http://localhost..'
+          }),
+          m('.mdc-line-ripple')
+        ),
+        m('button.mdc-button mdc-button--raised m-button', {
+          oncreate: oncreate.ripple,
+          onclick: events.add
+          },
+          'Add'
+        ),
+      ),
+
+      // themes list
+      (state.themes.length || null) &&
+      m('ul.m-list', state.themes.map((theme) =>
+        m('li.mdc-elevation--z2', {
+          class: theme.expanded ? 'm-expanded' : null,
+          },
+          m('.m-summary', {
+            onclick: (e) => theme.expanded = !theme.expanded
+            },
+            m('.m-title', theme.name),
+            m('i.material-icons', {
+              class: theme.expanded ? 'icon-arrow-up' : 'icon-arrow-down'
+            })
+          ),
+          m('.m-content',
+            // name
+            m('.m-option m-theme',
+              m('.m-name', m('span', 'Name')),
+              m('.m-control',
+                m('.mdc-text-field m-textfield', {
+                  oncreate: oncreate.textfield
+                  },
+                  m('input.mdc-text-field__input', {
+                    type: 'text',
+                    onkeyup: events.update.name(theme),
+                    value: theme.name,
+                  }),
+                  m('.mdc-line-ripple')
+                )
+              )
+            ),
+            // url
+            m('.m-option m-theme',
+              m('.m-name', m('span', 'URL')),
+              m('.m-control',
+                m('.mdc-text-field m-textfield', {
+                  oncreate: oncreate.textfield
+                  },
+                  m('input.mdc-text-field__input', {
+                    type: 'text',
+                    onkeyup: events.update.url(theme),
+                    value: theme.url,
+                  }),
+                  m('.mdc-line-ripple')
+                )
+              )
+            ),
+            // update/remove
+            m('.m-footer',
+              m('span',
+                m('button.mdc-button mdc-button--raised m-button', {
+                  oncreate: oncreate.ripple,
+                  onclick: events.remove(theme)
+                  },
+                  'Remove'
+                )
+              )
+            )
+          )
+        )
+      ))
+    )
+
+  return {state, render}
+}

+ 0 - 0
css/popup.css → popup/index.css


+ 2 - 2
content/popup.html → popup/index.html

@@ -5,10 +5,10 @@
   <meta name="viewport" content="width=device-width, initial-scale=1" />
   <title>Markdown Viewer</title>
   <link href="/vendor/mdc.min.css" rel="stylesheet" type="text/css" media="all" />
-  <link href="/css/popup.css" rel="stylesheet" type="text/css" media="all" />
+  <link href="/popup/index.css" rel="stylesheet" type="text/css" media="all" />
   <script src="/vendor/mdc.min.js" type="text/javascript" charset="utf-8"></script>
   <script src="/vendor/mithril.min.js" type="text/javascript" charset="utf-8"></script>
 </head>
 <body></body>
-<script src="/content/popup.js" type="text/javascript" charset="utf-8"></script>
+<script src="/popup/index.js" type="text/javascript" charset="utf-8"></script>
 </html>

+ 0 - 0
content/popup.js → popup/index.js


+ 6 - 6
test/defaults-options.js

@@ -11,20 +11,20 @@ module.exports = ({advanced}) => {
   it('access to file URLs', async () => {
     t.strictEqual(
       await advanced.evaluate(() =>
-        state.file
+        origins.state.file
       ),
       true,
-      'state.file should be true'
+      'origins.state.file should be true'
     )
   })
 
   it('header detection', async () => {
     t.strictEqual(
       await advanced.evaluate(() =>
-        state.header
+        origins.state.header
       ),
       true,
-      'state.header should be true'
+      'origins.state.header should be true'
     )
     t.strictEqual(
       await advanced.evaluate(() =>
@@ -38,7 +38,7 @@ module.exports = ({advanced}) => {
   it('allowed origins', async () => {
     t.deepStrictEqual(
       await advanced.evaluate(() =>
-        state.origins
+        origins.state.origins
       ),
       {
         'file://': {
@@ -47,7 +47,7 @@ module.exports = ({advanced}) => {
           encoding: ''
         }
       },
-      'state.origins should contain the file:// origin'
+      'origins.state.origins should contain the file:// origin'
     )
     t.equal(
       await advanced.evaluate(() =>

+ 2 - 2
test/index.js

@@ -50,10 +50,10 @@ describe('markdown-viewer', () => {
     )
 
     var popup = await browser.newPage()
-    await popup.goto(`chrome-extension://${id}/content/popup.html`)
+    await popup.goto(`chrome-extension://${id}/popup/index.html`)
 
     var advanced = await browser.newPage()
-    await advanced.goto(`chrome-extension://${id}/content/options.html`)
+    await advanced.goto(`chrome-extension://${id}/options/index.html`)
 
     var content = await browser.newPage()
 

+ 9 - 9
test/origin-csp.js

@@ -22,7 +22,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable csp
-      if (!await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (!await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
 
@@ -52,7 +52,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable csp
-      if (!await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (!await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
 
@@ -82,7 +82,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable csp
-      if (!await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (!await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
 
@@ -108,7 +108,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // disable csp
-      if (await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
       await advanced.waitFor(300)
@@ -135,7 +135,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable csp
-      if (!await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (!await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
       await advanced.waitFor(300)
@@ -157,7 +157,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // disable csp
-      if (await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
       await advanced.waitFor(300)
@@ -187,7 +187,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable csp
-      if (!await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (!await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
       await advanced.reload()
@@ -208,7 +208,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // disable csp
-      if (await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
       await advanced.reload()
@@ -232,7 +232,7 @@ module.exports = ({extensions, popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable csp
-      if (!await advanced.evaluate(() => state.origins['http://localhost:3000'].csp)) {
+      if (!await advanced.evaluate(() => origins.state.origins['http://localhost:3000'].csp)) {
         await advanced.click('.m-list li:nth-of-type(1) .m-switch')
       }
 

+ 3 - 3
test/origin-match.js

@@ -14,7 +14,7 @@ module.exports = ({popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // disable header detection
-      if (await advanced.evaluate(() => state.header)) {
+      if (await advanced.evaluate(() => origins.state.header)) {
         await advanced.click('.m-switch')
       }
 
@@ -47,7 +47,7 @@ module.exports = ({popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable header detection
-      if (!await advanced.evaluate(() => state.header)) {
+      if (!await advanced.evaluate(() => origins.state.header)) {
         await advanced.click('.m-switch')
       }
 
@@ -94,7 +94,7 @@ module.exports = ({popup, advanced, content}) => {
       await advanced.bringToFront()
 
       // enable header detection
-      if (!await advanced.evaluate(() => state.header)) {
+      if (!await advanced.evaluate(() => origins.state.header)) {
         await advanced.click('.m-switch')
       }
 

+ 2 - 2
test/utils/defaults.js

@@ -10,12 +10,12 @@ module.exports = async ({popup, advanced, content}) => {
   await advanced.bringToFront()
 
   // enable header detection
-  if (!await advanced.evaluate(() => state.header)) {
+  if (!await advanced.evaluate(() => origins.state.header)) {
     await advanced.click('.m-switch')
   }
 
   // remove origin
-  if (await advanced.evaluate(() => Object.keys(state.origins).length > 1)) {
+  if (await advanced.evaluate(() => Object.keys(origins.state.origins).length > 1)) {
     // expand origin
     if (!await advanced.evaluate(() => document.querySelector('.m-list li:nth-of-type(1)').classList.contains('m-expanded'))) {
       await advanced.click('.m-list li:nth-of-type(1)')