Explorar o código

feat(plugin): merge implementation

charlie %!s(int64=4) %!d(string=hai) anos
pai
achega
f3edb9b77f

+ 1 - 0
.gitignore

@@ -31,3 +31,4 @@ strings.csv
 resources/electron.js
 .clj-kondo/
 .lsp/
+/libs/dist/

+ 2 - 1
deps.edn

@@ -34,7 +34,8 @@
   thheller/shadow-cljs        {:mvn/version "2.12.5"}
   expound/expound             {:mvn/version "0.8.6"}
   com.lambdaisland/glogi      {:mvn/version "1.0.116"}
-  binaryage/devtools          {:mvn/version "1.0.2"}}
+  binaryage/devtools          {:mvn/version "1.0.2"}
+  camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.2"}}
 
  :aliases {:cljs {:extra-paths ["src/dev-cljs/" "src/test/" "src/electron/"]
                   :extra-deps  {org.clojure/clojurescript   {:mvn/version "1.10.844"}

+ 2 - 0
libs/.npmignore

@@ -0,0 +1,2 @@
+src/
+webpack.*

+ 17 - 0
libs/README.md

@@ -0,0 +1,17 @@
+## @logseq/libs
+
+🚀 Logseq SDK libraries [WIP].
+
+#### Installation
+
+```shell
+yarn add @logseq/libs
+```
+
+#### Usage
+
+Load `logseq` plugin sdk as global namespace
+
+```js
+import "@logseq/libs"
+```

+ 5 - 0
libs/index.d.ts

@@ -0,0 +1,5 @@
+import { ILSPluginUser } from './dist/LSPlugin'
+
+declare global {
+  var logseq: ILSPluginUser
+}

+ 34 - 0
libs/package.json

@@ -0,0 +1,34 @@
+{
+  "name": "@logseq/libs",
+  "version": "0.0.1-alpha.6",
+  "description": "Logseq SDK libraries",
+  "main": "dist/lsplugin.user.js",
+  "typings": "index.d.ts",
+  "private": false,
+  "scripts": {
+    "build:user": "webpack --mode production",
+    "dev:user": "npm run build:user -- --mode development --watch",
+    "build:core": "webpack --config webpack.config.core.js --mode production",
+    "dev:core": "npm run build:core -- --mode development --watch",
+    "build": "tsc && rm dist/*.js && cp src/*.d.ts dist/ && npm run build:user"
+  },
+  "dependencies": {
+    "debug": "^4.3.1",
+    "dompurify": "^2.2.7",
+    "eventemitter3": "^4.0.7",
+    "path": "^0.12.7",
+    "postmate": "^1.5.2",
+    "snake-case": "^3.0.4"
+  },
+  "devDependencies": {
+    "@types/debug": "^4.1.5",
+    "@types/dompurify": "^2.2.1",
+    "@types/lodash-es": "^4.17.4",
+    "@types/postmate": "^1.5.1",
+    "ts-loader": "^8.0.17",
+    "typescript": "^4.2.2",
+    "webpack": "^5.24.3",
+    "webpack-bundle-analyzer": "^4.4.0",
+    "webpack-cli": "^4.5.0"
+  }
+}

+ 286 - 0
libs/src/LSPlugin.caller.ts

@@ -0,0 +1,286 @@
+import Postmate from 'postmate'
+import EventEmitter from 'eventemitter3'
+import { PluginLocal } from './LSPlugin.core'
+import Debug from 'debug'
+import { deferred } from './helpers'
+import { LSPluginShadowFrame } from './LSPlugin.shadow'
+
+const debug = Debug('LSPlugin:caller')
+
+type DeferredActor = ReturnType<typeof deferred>
+
+export const LSPMSG = '#lspmsg#'
+export const LSPMSG_SETTINGS = '#lspmsg#settings#'
+export const LSPMSG_SYNC = '#lspmsg#reply#'
+export const LSPMSG_READY = '#lspmsg#ready#'
+export const LSPMSGFn = (id: string) => `${LSPMSG}${id}`
+
+/**
+ * Call between core and user
+ */
+class LSPluginCaller extends EventEmitter {
+  private _connected: boolean = false
+
+  private _parent?: Postmate.ParentAPI
+  private _child?: Postmate.ChildAPI
+
+  private _shadow?: LSPluginShadowFrame
+
+  private _status?: 'pending' | 'timeout'
+  private _userModel: any = {}
+
+  private _call?: (type: string, payload: any, actor?: DeferredActor) => Promise<any>
+  private _callUserModel?: (type: string, payload: any) => Promise<any>
+
+  constructor (
+    private _pluginLocal: PluginLocal | null
+  ) {
+    super()
+  }
+
+  async connectToChild () {
+    if (this._connected) return
+
+    const { shadow } = this._pluginLocal!
+
+    if (shadow) {
+      await this._setupShadowSandbox()
+    } else {
+      await this._setupIframeSandbox()
+    }
+  }
+
+  async connectToParent (userModel = {}) {
+    if (this._connected) return
+
+    const caller = this
+    const isShadowMode = this._pluginLocal != null
+
+    let syncGCTimer: any = 0
+    let syncTag = 0
+    const syncActors = new Map<number, DeferredActor>()
+    const readyDeferred = deferred()
+
+    const model: any = this._extendUserModel({
+      [LSPMSG_READY]: async () => {
+        await readyDeferred.resolve()
+      },
+
+      [LSPMSG_SETTINGS]: async ({ type, payload }) => {
+        caller.emit('settings:changed', payload)
+      },
+
+      [LSPMSG]: async ({ ns, type, payload }: any) => {
+        debug(`[call from host #${this._pluginLocal?.id}]`, ns, type, payload)
+
+        if (ns && ns.startsWith('hook')) {
+          caller.emit(`${ns}:${type}`, payload)
+          return
+        }
+
+        caller.emit(type, payload)
+      },
+
+      [LSPMSG_SYNC]: ({ _sync, result }: any) => {
+        debug(`sync reply #${_sync}`, result)
+        if (syncActors.has(_sync)) {
+          // TODO: handle exception
+          syncActors.get(_sync)?.resolve(result)
+          syncActors.delete(_sync)
+        }
+      },
+
+      ...userModel
+    })
+
+    if (isShadowMode) {
+      await readyDeferred.promise
+      return JSON.parse(JSON.stringify(this._pluginLocal?.toJSON()))
+    }
+
+    const handshake = new Postmate.Model(model)
+
+    this._status = 'pending'
+
+    await handshake.then(refParent => {
+      this._child = refParent
+      this._connected = true
+
+      this._call = async (type, payload = {}, actor) => {
+        if (actor) {
+          const tag = ++syncTag
+          syncActors.set(tag, actor)
+          payload._sync = tag
+
+          actor.setTag(`async call #${tag}`)
+          debug('async call #', tag)
+        }
+
+        refParent.emit(LSPMSGFn(model.baseInfo.id), { type, payload })
+
+        return actor?.promise as Promise<any>
+      }
+
+      this._callUserModel = async (type, payload) => {
+        try {
+          model[type](payload)
+        } catch (e) {
+          debug(`model method #${type} not existed`)
+        }
+      }
+
+      // actors GC
+      syncGCTimer = setInterval(() => {
+        if (syncActors.size > 100) {
+          for (const [k, v] of syncActors) {
+            if (v.settled) {
+              syncActors.delete(k)
+            }
+          }
+        }
+      }, 1000 * 60 * 30)
+    }).finally(() => {
+      this._status = undefined
+    })
+
+    // TODO: timeout
+    await readyDeferred.promise
+
+    return model.baseInfo
+  }
+
+  async call (type: any, payload: any = {}) {
+    // TODO: ?
+    this.emit(type, payload)
+    return this._call?.call(this, type, payload)
+  }
+
+  async callAsync (type: any, payload: any = {}) {
+    const actor = deferred(1000 * 10)
+    return this._call?.call(this, type, payload, actor)
+  }
+
+  async callUserModel (type: string, payload: any = {}) {
+    return this._callUserModel?.call(this, type, payload)
+  }
+
+  async _setupIframeSandbox () {
+    const pl = this._pluginLocal!
+
+    const handshake = new Postmate({
+      container: document.body,
+      url: pl.options.entry!,
+      classListArray: ['lsp-iframe-sandbox'],
+      model: { baseInfo: JSON.parse(JSON.stringify(pl.toJSON())) }
+    })
+
+    this._status = 'pending'
+
+    // timeout for handshake
+    let timer
+
+    return new Promise((resolve, reject) => {
+      timer = setTimeout(() => {
+        reject(new Error(`handshake Timeout`))
+      }, 3 * 1000) // 3secs
+
+      handshake.then(refChild => {
+        this._parent = refChild
+        this._connected = true
+        this.emit('connected')
+
+        refChild.frame.setAttribute('id', pl.id)
+        refChild.on(LSPMSGFn(pl.id), ({ type, payload }: any) => {
+          debug(`[call from plugin] `, type, payload)
+
+          this._pluginLocal?.emit(type, payload || {})
+        })
+
+        this._call = async (...args: any) => {
+          // parent all will get message
+          await refChild.call(LSPMSGFn(pl.id), { type: args[0], payload: args[1] || {} })
+        }
+
+        this._callUserModel = async (...args: any) => {
+          await refChild.call(args[0], args[1] || {})
+        }
+
+        resolve(null)
+      }).catch(e => {
+        reject(e)
+      }).finally(() => {
+        clearTimeout(timer)
+      })
+    }).catch(e => {
+      debug('iframe sandbox error', e)
+      throw e
+    }).finally(() => {
+      this._status = undefined
+    })
+  }
+
+  async _setupShadowSandbox () {
+    const pl = this._pluginLocal!
+    const shadow = this._shadow = new LSPluginShadowFrame(pl)
+
+    try {
+      this._status = 'pending'
+
+      await shadow.load()
+
+      this._connected = true
+      this.emit('connected')
+
+      this._call = async (type, payload = {}, actor) => {
+        actor && (payload.actor = actor)
+
+        // TODO: support sync call
+        // @ts-ignore Call in same thread
+        this._pluginLocal?.emit(type, payload)
+
+        return actor?.promise
+      }
+
+      this._callUserModel = async (...args: any) => {
+        const type = args[0]
+        const payload = args[1] || {}
+        const fn = this._userModel[type]
+
+        if (typeof fn === 'function') {
+          await fn.call(null, payload)
+        }
+      }
+    } catch (e) {
+      debug('shadow sandbox error', e)
+      throw e
+    } finally {
+      this._status = undefined
+    }
+  }
+
+  _extendUserModel (model: any) {
+    return Object.assign(this._userModel, model)
+  }
+
+  _getSandboxIframeContainer () {
+    return this._parent?.frame
+  }
+
+  _getSandboxShadowContainer () {
+    return this._shadow?.frame
+  }
+
+  async destroy () {
+    if (this._parent) {
+      await this._parent.destroy()
+    }
+
+    if (this._shadow) {
+      this._shadow.destroy()
+    }
+  }
+}
+
+export {
+  LSPluginCaller
+}

+ 1001 - 0
libs/src/LSPlugin.core.ts

@@ -0,0 +1,1001 @@
+import EventEmitter from 'eventemitter3'
+import {
+  deepMerge,
+  setupInjectedStyle,
+  genID,
+  setupInjectedTheme,
+  setupInjectedUI,
+  deferred,
+  invokeHostExportedApi,
+  isObject, withFileProtocol
+} from './helpers'
+import Debug from 'debug'
+import { LSPluginCaller, LSPMSG_READY, LSPMSG_SYNC, LSPMSG, LSPMSG_SETTINGS } from './LSPlugin.caller'
+import {
+  ILSPluginThemeManager,
+  LSPluginPkgConfig,
+  StyleOptions,
+  StyleString,
+  ThemeOptions,
+  UIOptions
+} from './LSPlugin'
+import { snakeCase } from 'snake-case'
+import DOMPurify from 'dompurify'
+import * as path from 'path'
+
+const debug = Debug('LSPlugin:core')
+
+declare global {
+  interface Window {
+    LSPluginCore: LSPluginCore
+  }
+}
+
+type DeferredActor = ReturnType<typeof deferred>
+type LSPluginCoreOptions = {
+  localUserConfigRoot: string
+}
+
+/**
+ * User settings
+ */
+class PluginSettings extends EventEmitter<'change'> {
+  private _settings: Record<string, any> = {
+    disabled: false
+  }
+
+  constructor (private _userPluginSettings: any) {
+    super()
+
+    Object.assign(this._settings, _userPluginSettings)
+  }
+
+  get<T = any> (k: string): T {
+    return this._settings[k]
+  }
+
+  set (k: string | Record<string, any>, v?: any) {
+    const o = deepMerge({}, this._settings)
+
+    if (typeof k === 'string') {
+      if (this._settings[k] == v) return
+      this._settings[k] = v
+    } else if (isObject(k)) {
+      deepMerge(this._settings, k)
+    } else {
+      return
+    }
+
+    this.emit('change',
+      Object.assign({}, this._settings), o)
+  }
+
+  toJSON () {
+    return this._settings
+  }
+}
+
+class PluginLogger extends EventEmitter<'change'> {
+  private _logs: Array<[type: string, payload: any]> = []
+
+  constructor (private _tag: string) {
+    super()
+  }
+
+  write (type: string, payload: any[]) {
+    let msg = payload.reduce((ac, it) => {
+      if (it && it instanceof Error) {
+        ac += `${it.message} ${it.stack}`
+      } else {
+        ac += it.toString()
+      }
+      return ac
+    }, `[${this._tag}][${new Date().toLocaleTimeString()}] `)
+
+    this._logs.push([type, msg])
+    this.emit('change')
+  }
+
+  clear () {
+    this._logs = []
+    this.emit('change')
+  }
+
+  info (...args: any[]) {
+    this.write('INFO', args)
+  }
+
+  error (...args: any[]) {
+    this.write('ERROR', args)
+  }
+
+  warn (...args: any[]) {
+    this.write('WARN', args)
+  }
+
+  toJSON () {
+    return this._logs
+  }
+}
+
+type UserPreferences = {
+  theme: ThemeOptions
+  externals: Array<string> // external plugin locations
+
+  [key: string]: any
+}
+
+type PluginLocalOptions = {
+  key?: string // Unique from Logseq Plugin Store
+  entry: string // Plugin main file
+  url: string // Plugin package fs location
+  name: string
+  version: string
+  mode: 'shadow' | 'iframe'
+  settings?: PluginSettings
+  logger?: PluginLogger
+
+  [key: string]: any
+}
+
+type PluginLocalUrl = Pick<PluginLocalOptions, 'url'> & { [key: string]: any }
+type RegisterPluginOpts = PluginLocalOptions | PluginLocalUrl
+
+type PluginLocalIdentity = string
+
+enum PluginLocalLoadStatus {
+  LOADING = 'loading',
+  UNLOADING = 'unloading',
+  LOADED = 'loaded',
+  UNLOADED = 'unload',
+  ERROR = 'error'
+}
+
+function initUserSettingsHandlers (pluginLocal: PluginLocal) {
+  const _ = (label: string): any => `settings:${label}`
+
+  pluginLocal.on(_('update'), (attrs) => {
+    if (!attrs) return
+    pluginLocal.settings?.set(attrs)
+  })
+}
+
+function initMainUIHandlers (pluginLocal: PluginLocal) {
+  const _ = (label: string): any => `main-ui:${label}`
+
+  pluginLocal.on(_('visible'), ({ visible, toggle }) => {
+    const el = pluginLocal.getMainUI()
+    el?.classList[toggle ? 'toggle' : (visible ? 'add' : 'remove')]('visible')
+    // pluginLocal.caller!.callUserModel(LSPMSG, { type: _('visible'), payload: visible })
+    // auto focus frame
+    if (!pluginLocal.shadow && el) {
+      (el as HTMLIFrameElement).contentWindow?.focus()
+    }
+  })
+
+  pluginLocal.on(_('attrs'), (attrs: Record<string, any>) => {
+    const el = pluginLocal.getMainUI()
+    Object.entries(attrs).forEach(([k, v]) => {
+      el?.setAttribute(k, v)
+    })
+  })
+
+  pluginLocal.on(_('style'), (style: Record<string, any>) => {
+    const el = pluginLocal.getMainUI()
+    Object.entries(style).forEach(([k, v]) => {
+      el!.style[k] = v
+    })
+  })
+}
+
+function initProviderHandlers (pluginLocal: PluginLocal) {
+  let _ = (label: string): any => `provider:${label}`
+  let themed = false
+
+  pluginLocal.on(_('theme'), (theme: ThemeOptions) => {
+    pluginLocal.themeMgr.registerTheme(
+      pluginLocal.id,
+      theme
+    )
+
+    if (!themed) {
+      pluginLocal._dispose(() => {
+        pluginLocal.themeMgr.unregisterTheme(pluginLocal.id)
+      })
+
+      themed = true
+    }
+  })
+
+  pluginLocal.on(_('style'), (style: StyleString | StyleOptions) => {
+    let key: string | undefined
+
+    if (typeof style !== 'string') {
+      key = style.key
+      style = style.style
+    }
+
+    if (!style || !style.trim()) return
+
+    pluginLocal._dispose(
+      setupInjectedStyle(style, {
+        'data-injected-style': key ? `${key}-${pluginLocal.id}` : '',
+        'data-ref': pluginLocal.id
+      })
+    )
+  })
+
+  pluginLocal.on(_('ui'), (ui: UIOptions) => {
+    pluginLocal._onHostMounted(() => {
+      // safe template
+      ui.template = DOMPurify.sanitize(ui.template)
+
+      pluginLocal._dispose(
+        setupInjectedUI.call(pluginLocal,
+          ui, {
+            'data-ref': pluginLocal.id
+          })
+      )
+    })
+  })
+}
+
+function initApiProxyHandlers (pluginLocal: PluginLocal) {
+  let _ = (label: string): any => `api:${label}`
+
+  pluginLocal.on(_('call'), (payload) => {
+    const rt = invokeHostExportedApi(payload.method, ...payload.args)
+    const { _sync } = payload
+
+    if (pluginLocal.shadow) {
+      if (payload.actor) {
+        payload.actor.resolve(rt)
+      }
+      return
+    }
+
+    if (_sync != null) {
+      const reply = (result: any) => {
+        pluginLocal.caller?.callUserModel(LSPMSG_SYNC, {
+          result, _sync
+        })
+      }
+
+      Promise.resolve(rt).then(reply, reply)
+    }
+  })
+}
+
+class IllegalPluginPackageError extends Error {
+  constructor (message: string) {
+    super(message)
+    this.name = IllegalPluginPackageError.name
+  }
+}
+
+class ExistedImportedPluginPackageError extends Error {
+  constructor (message: string) {
+    super(message)
+    this.name = ExistedImportedPluginPackageError.name
+  }
+}
+
+/**
+ * Host plugin for local
+ */
+class PluginLocal
+  extends EventEmitter<'loaded' | 'unloaded' | 'beforeunload' | 'error'> {
+
+  private _disposes: Array<() => Promise<any>> = []
+  private _id: PluginLocalIdentity
+  private _status: PluginLocalLoadStatus = PluginLocalLoadStatus.UNLOADED
+  private _loadErr?: Error
+  private _localRoot?: string
+  private _userSettingsFile?: string
+  private _caller?: LSPluginCaller
+
+  /**
+   * @param _options
+   * @param _themeMgr
+   * @param _ctx
+   */
+  constructor (
+    private _options: PluginLocalOptions,
+    private _themeMgr: ILSPluginThemeManager,
+    private _ctx: LSPluginCore
+  ) {
+    super()
+
+    this._id = _options.key || genID()
+
+    initUserSettingsHandlers(this)
+    initMainUIHandlers(this)
+    initProviderHandlers(this)
+    initApiProxyHandlers(this)
+  }
+
+  async _setupUserSettings () {
+    const { _options } = this
+    const key = _options.name.replace(/[^a-z0-9]/gi, '_').toLowerCase() + '_' + this.id
+    const logger = _options.logger = new PluginLogger('Loader')
+
+    try {
+      const [userSettingsFilePath, userSettings] = await invokeHostExportedApi('load_plugin_user_settings', key)
+      this._userSettingsFile = userSettingsFilePath
+
+      const settings = _options.settings = new PluginSettings(userSettings)
+
+      // observe settings
+      settings.on('change', (a, b) => {
+        debug('linked settings change', a)
+
+        if (!a.disabled && b.disabled) {
+          // Enable plugin
+          this.load()
+        }
+
+        if (a.disabled && !b.disabled) {
+          // Disable plugin
+          this.unload()
+        }
+
+        if (a) {
+          invokeHostExportedApi(`save_plugin_user_settings`, key, a)
+        }
+      })
+    } catch (e) {
+      debug('[load plugin user settings Error]', e)
+      logger?.error(e)
+    }
+  }
+
+  getMainUI (): HTMLElement | undefined {
+    if (this.shadow) {
+      return this.caller?._getSandboxShadowContainer()
+    }
+
+    return this.caller?._getSandboxIframeContainer()
+  }
+
+  async _preparePackageConfigs () {
+    const { url } = this._options
+    let pkg: any
+
+    try {
+      if (!url) {
+        throw new Error('Can not resolve package config location')
+      }
+
+      debug('prepare package root', url)
+
+      pkg = await invokeHostExportedApi('load_plugin_config', url)
+
+      if (!pkg || (pkg = JSON.parse(pkg), !pkg)) {
+        throw new Error(`Parse package config error #${url}/package.json`)
+      }
+    } catch (e) {
+      throw new IllegalPluginPackageError(e.message)
+    }
+
+    // Pick legal attrs
+    ['name', 'author', 'version', 'description'].forEach(k => {
+      this._options[k] = pkg[k]
+    })
+
+    // TODO: How with local protocol
+    const localRoot = this._localRoot = url
+    const logseq: Partial<LSPluginPkgConfig> = pkg.logseq || {}
+    const makeFullUrl = (loc, useFileProtocol = false) => {
+      if (!loc) return
+      const reg = /^(http|file|assets)/
+      if (!reg.test(loc)) {
+        const url = path.join(localRoot, loc)
+        loc = reg.test(url) ? url : ('file://' + url)
+      }
+      return useFileProtocol ? loc : loc.replace('file:', 'assets:')
+    }
+    const validateMain = (main) => main && /\.(js|html)$/.test(main)
+
+    // Entry from main
+    if (validateMain(pkg.main)) {
+      this._options.entry = makeFullUrl(pkg.main, true)
+
+      if (logseq.mode) {
+        this._options.mode = logseq.mode
+      }
+    }
+
+    const icon = logseq.icon || pkg.icon
+
+    if (icon) {
+      this._options.icon = makeFullUrl(icon)
+    }
+
+    // TODO: strategy for Logseq plugins center
+    if (logseq.id) {
+      this._id = logseq.id
+    } else {
+      logseq.id = this.id
+      try {
+        await invokeHostExportedApi('save_plugin_config', url, { ...pkg, logseq })
+      } catch (e) {
+        debug('[save plugin ID Error] ', e)
+      }
+    }
+
+    // Validate id
+    const { registeredPlugins, isRegistering } = this._ctx
+    if (isRegistering && registeredPlugins.has(logseq.id)) {
+      throw new ExistedImportedPluginPackageError('prepare package Error')
+    }
+
+    return async () => {
+      try {
+        // 0. Install Themes
+        let themes = logseq.themes
+
+        if (themes) {
+          await this._loadConfigThemes(
+            Array.isArray(themes) ? themes : [themes]
+          )
+        }
+      } catch (e) {
+        debug('[prepare package effect Error]', e)
+      }
+    }
+  }
+
+  async _tryToNormalizeEntry () {
+    let { entry, settings } = this.options
+    let devEntry = settings?.get('_devEntry')
+
+    if (devEntry) {
+      this._options.entry = devEntry
+      return
+    }
+
+    if (!entry.endsWith('.js')) return
+
+    let sdkPath = await invokeHostExportedApi('_callApplication', 'getAppPath')
+    let entryPath = await invokeHostExportedApi(
+      'write_user_tmp_file',
+      `${this._id}_index.html`,
+      `<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8">
+    <title>logseq plugin entry</title>
+    <script src="${sdkPath}/js/lsplugin.user.js"></script>
+  </head>
+  <body>
+  <div id="app"></div>
+  <script src="${entry}"></script>
+  </body>
+</html>`)
+
+    this._options.entry = withFileProtocol(entryPath)
+  }
+
+  async _loadConfigThemes (themes: Array<ThemeOptions>) {
+    themes.forEach((options) => {
+      if (!options.url) return
+
+      if (!options.url.startsWith('http') && this._localRoot) {
+        options.url = path.join(this._localRoot, options.url)
+        // file:// for native
+        if (!options.url.startsWith('file:')) {
+          options.url = 'assets://' + options.url
+        }
+      }
+
+      // @ts-ignore
+      this.emit('provider:theme', options)
+    })
+  }
+
+  async load (readyIndicator?: DeferredActor) {
+    if (this.pending) {
+      return
+    }
+
+    this._status = PluginLocalLoadStatus.LOADING
+    this._loadErr = undefined
+
+    try {
+      let installPackageThemes: () => Promise<void> = () => Promise.resolve()
+
+      if (!this.options.entry) { // Themes package no entry field
+        installPackageThemes = await this._preparePackageConfigs()
+      }
+
+      if (!this.settings) {
+        await this._setupUserSettings()
+      }
+
+      if (!this.disabled) {
+        await installPackageThemes.call(null)
+      }
+
+      if (this.disabled || !this.options.entry) {
+        return
+      }
+
+      await this._tryToNormalizeEntry()
+
+      this._caller = new LSPluginCaller(this)
+      await this._caller.connectToChild()
+
+      const readyFn = () => {
+        this._caller?.callUserModel(LSPMSG_READY)
+      }
+
+      if (readyIndicator) {
+        readyIndicator.promise.then(readyFn)
+      } else {
+        readyFn()
+      }
+
+      this._disposes.push(async () => {
+        await this._caller?.destroy()
+      })
+    } catch (e) {
+      debug('[Load Plugin Error] ', e)
+      this.logger?.error(e)
+
+      this._status = PluginLocalLoadStatus.ERROR
+      this._loadErr = e
+    } finally {
+      if (!this._loadErr) {
+        this._status = PluginLocalLoadStatus.LOADED
+      }
+    }
+  }
+
+  async reload () {
+    debug('TODO: reload plugin', this.id)
+  }
+
+  /**
+   * @param unregister If true delete plugin files
+   */
+  async unload (unregister: boolean = false) {
+    if (this.pending) {
+      return
+    }
+
+    if (unregister) {
+      await this.unload()
+
+      if (this.isInstalledInUserRoot) {
+        debug('TODO: remove plugin local files from user home root :)')
+      }
+
+      return
+    }
+
+    try {
+      this._status = PluginLocalLoadStatus.UNLOADING
+
+      const eventBeforeUnload = {}
+
+      // sync call
+      try {
+        this.emit('beforeunload', eventBeforeUnload)
+      } catch (e) {
+        console.error('[beforeunload Error]', e)
+      }
+
+      await this.dispose()
+
+      this.emit('unloaded')
+    } catch (e) {
+      debug('[plugin unload Error]', e)
+    } finally {
+      this._status = PluginLocalLoadStatus.UNLOADED
+    }
+  }
+
+  private async dispose () {
+    for (const fn of this._disposes) {
+      try {
+        fn && (await fn())
+      } catch (e) {
+        console.error(this.debugTag, 'dispose Error', e)
+      }
+    }
+
+    // clear
+    this._disposes = []
+  }
+
+  _dispose (fn: any) {
+    if (!fn) return
+    this._disposes.push(fn)
+  }
+
+  _onHostMounted (callback: () => void) {
+    const actor = this._ctx.hostMountedActor
+
+    if (!actor || actor.settled) {
+      callback()
+    } else {
+      actor?.promise.then(callback)
+    }
+  }
+
+  get isInstalledInUserRoot () {
+    const userRoot = this._ctx.options.localUserConfigRoot
+    const plugRoot = this._localRoot
+    return userRoot && plugRoot && plugRoot.startsWith(userRoot)
+  }
+
+  get loaded () {
+    return this._status === PluginLocalLoadStatus.LOADED
+  }
+
+  get pending () {
+    return [PluginLocalLoadStatus.LOADING, PluginLocalLoadStatus.UNLOADING]
+      .includes(this._status)
+  }
+
+  get status (): PluginLocalLoadStatus {
+    return this._status
+  }
+
+  get settings () {
+    return this.options.settings
+  }
+
+  get logger () {
+    return this.options.logger
+  }
+
+  get disabled () {
+    return this.settings?.get('disabled')
+  }
+
+  get caller () {
+    return this._caller
+  }
+
+  get id (): string {
+    return this._id
+  }
+
+  get shadow (): boolean {
+    return this.options.mode === 'shadow'
+  }
+
+  get options (): PluginLocalOptions {
+    return this._options
+  }
+
+  get themeMgr (): ILSPluginThemeManager {
+    return this._themeMgr
+  }
+
+  get debugTag () {
+    return `[${this._options?.name} #${this._id}]`
+  }
+
+  get localRoot (): string {
+    return this._localRoot || this._options.url
+  }
+
+  get loadErr (): Error | undefined {
+    return this._loadErr
+  }
+
+  get userSettingsFile (): string | undefined {
+    return this._userSettingsFile
+  }
+
+  toJSON () {
+    const json = { ...this.options } as any
+    json.id = this.id
+    json.err = this.loadErr
+    json.usf = this.userSettingsFile
+    return json
+  }
+}
+
+/**
+ * Host plugin core
+ */
+class LSPluginCore
+  extends EventEmitter<'beforeenable' | 'enabled' | 'beforedisable' | 'disabled' | 'registered' | 'error' | 'unregistered' |
+    'theme-changed' | 'theme-selected' | 'settings-changed'>
+  implements ILSPluginThemeManager {
+
+  private _isRegistering = false
+  private _readyIndicator?: DeferredActor
+  private _hostMountedActor: DeferredActor = deferred()
+  private _userPreferences: Partial<UserPreferences> = {}
+  private _registeredThemes = new Map<PluginLocalIdentity, Array<ThemeOptions>>()
+  private _registeredPlugins = new Map<PluginLocalIdentity, PluginLocal>()
+
+  /**
+   * @param _options
+   */
+  constructor (private _options: Partial<LSPluginCoreOptions>) {
+    super()
+  }
+
+  async loadUserPreferences () {
+    try {
+      const settings = await invokeHostExportedApi(`load_user_preferences`)
+
+      if (settings) {
+        Object.assign(this._userPreferences, settings)
+      }
+    } catch (e) {
+      debug('[load user preferences Error]', e)
+    }
+  }
+
+  async saveUserPreferences (settings: Partial<UserPreferences>) {
+    try {
+      if (settings) {
+        Object.assign(this._userPreferences, settings)
+      }
+
+      await invokeHostExportedApi(`save_user_preferences`, this._userPreferences)
+    } catch (e) {
+      debug('[save user preferences Error]', e)
+    }
+  }
+
+  async activateUserPreferences () {
+    const { theme } = this._userPreferences
+
+    // 0. theme
+    if (theme) {
+      await this.selectTheme(theme, false)
+    }
+  }
+
+  /**
+   * @param plugins
+   * @param initial
+   */
+  async register (
+    plugins: Array<RegisterPluginOpts> | RegisterPluginOpts,
+    initial = false
+  ) {
+    if (!Array.isArray(plugins)) {
+      await this.register([plugins])
+      return
+    }
+
+    try {
+      this._isRegistering = true
+
+      const userConfigRoot = this._options.localUserConfigRoot
+      const readyIndicator = this._readyIndicator = deferred()
+
+      await this.loadUserPreferences()
+
+      const externals = new Set(this._userPreferences.externals || [])
+
+      if (initial) {
+        plugins = plugins.concat([...externals].filter(url => {
+          return !plugins.length || (plugins as RegisterPluginOpts[]).every((p) => !p.entry && (p.url !== url))
+        }).map(url => ({ url })))
+      }
+
+      for (const pluginOptions of plugins) {
+        const { url } = pluginOptions as PluginLocalOptions
+        const pluginLocal = new PluginLocal(pluginOptions as PluginLocalOptions, this, this)
+
+        const timeLabel = `Load plugin #${pluginLocal.id}`
+        console.time(timeLabel)
+
+        await pluginLocal.load(readyIndicator)
+
+        const { loadErr } = pluginLocal
+
+        if (loadErr) {
+          debug(`Failed load plugin #`, pluginOptions)
+
+          this.emit('error', loadErr)
+
+          if (
+            loadErr instanceof IllegalPluginPackageError ||
+            loadErr instanceof ExistedImportedPluginPackageError) {
+            // TODO: notify global log system?
+            continue
+          }
+        }
+
+        console.timeEnd(timeLabel)
+
+        pluginLocal.settings?.on('change', (a) => {
+          this.emit('settings-changed', pluginLocal.id, a)
+          pluginLocal.caller?.callUserModel(LSPMSG_SETTINGS, { payload: a })
+        })
+
+        this._registeredPlugins.set(pluginLocal.id, pluginLocal)
+        this.emit('registered', pluginLocal)
+
+        // external plugins
+        if (!pluginLocal.isInstalledInUserRoot) {
+          externals.add(url)
+        }
+      }
+
+      await this.saveUserPreferences({ externals: Array.from(externals) })
+      await this.activateUserPreferences()
+
+      readyIndicator.resolve('ready')
+    } catch (e) {
+      console.error(e)
+    } finally {
+      this._isRegistering = false
+    }
+  }
+
+  async reload (plugins: Array<PluginLocalIdentity> | PluginLocalIdentity) {
+    if (!Array.isArray(plugins)) {
+      await this.reload([plugins])
+      return
+    }
+
+    for (const identity of plugins) {
+      const p = this.ensurePlugin(identity)
+      await p.reload()
+    }
+  }
+
+  async unregister (plugins: Array<PluginLocalIdentity> | PluginLocalIdentity) {
+    if (!Array.isArray(plugins)) {
+      await this.unregister([plugins])
+      return
+    }
+
+    const unregisteredExternals: Array<string> = []
+
+    for (const identity of plugins) {
+      const p = this.ensurePlugin(identity)
+
+      if (!p.isInstalledInUserRoot) {
+        unregisteredExternals.push(p.options.url)
+      }
+
+      await p.unload(true)
+
+      this._registeredPlugins.delete(identity)
+      this.emit('unregistered', identity)
+    }
+
+    let externals = this._userPreferences.externals || []
+    if (externals.length && unregisteredExternals.length) {
+      await this.saveUserPreferences({
+        externals: externals.filter((it) => {
+          return !unregisteredExternals.includes(it)
+        })
+      })
+    }
+  }
+
+  async enable (plugin: PluginLocalIdentity) {
+    const p = this.ensurePlugin(plugin)
+    if (p.pending) return
+
+    this.emit('beforeenable')
+    p.settings?.set('disabled', false)
+    // this.emit('enabled', p)
+  }
+
+  async disable (plugin: PluginLocalIdentity) {
+    const p = this.ensurePlugin(plugin)
+    if (p.pending) return
+
+    this.emit('beforedisable')
+    p.settings?.set('disabled', true)
+    // this.emit('disabled', p)
+  }
+
+  async _hook (ns: string, type: string, payload?: any, pid?: string) {
+    for (const [_, p] of this._registeredPlugins) {
+      if (!pid || pid === p.id) {
+        p.caller?.callUserModel(LSPMSG, {
+          ns, type: snakeCase(type), payload
+        })
+      }
+    }
+  }
+
+  hookApp (type: string, payload?: any, pid?: string) {
+    this._hook(`hook:app`, type, payload, pid)
+  }
+
+  hookEditor (type: string, payload?: any, pid?: string) {
+    this._hook(`hook:editor`, type, payload, pid)
+  }
+
+  _execDirective (tag: string, ...params: any[]) {
+
+  }
+
+  ensurePlugin (plugin: PluginLocalIdentity | PluginLocal) {
+    if (plugin instanceof PluginLocal) {
+      return plugin
+    }
+
+    const p = this._registeredPlugins.get(plugin)
+
+    if (!p) {
+      throw new Error(`plugin #${plugin} not existed.`)
+    }
+
+    return p
+  }
+
+  hostMounted () {
+    this._hostMountedActor.resolve()
+  }
+
+  get registeredPlugins (): Map<PluginLocalIdentity, PluginLocal> {
+    return this._registeredPlugins
+  }
+
+  get options () {
+    return this._options
+  }
+
+  get readyIndicator (): DeferredActor | undefined {
+    return this._readyIndicator
+  }
+
+  get hostMountedActor (): DeferredActor {
+    return this._hostMountedActor
+  }
+
+  get isRegistering (): boolean {
+    return this._isRegistering
+  }
+
+  get themes (): Map<PluginLocalIdentity, Array<ThemeOptions>> {
+    return this._registeredThemes
+  }
+
+  async registerTheme (id: PluginLocalIdentity, opt: ThemeOptions): Promise<void> {
+    debug('registered Theme #', id, opt)
+
+    if (!id) return
+    let themes: Array<ThemeOptions> = this._registeredThemes.get(id)!
+    if (!themes) {
+      this._registeredThemes.set(id, themes = [])
+    }
+
+    themes.push(opt)
+    this.emit('theme-changed', this.themes, { id, ...opt })
+  }
+
+  async selectTheme (opt?: ThemeOptions, effect = true): Promise<void> {
+    setupInjectedTheme(opt?.url)
+    this.emit('theme-selected', opt)
+    effect && this.saveUserPreferences({ theme: opt })
+  }
+
+  async unregisterTheme (id: PluginLocalIdentity): Promise<void> {
+    debug('unregistered Theme #', id)
+
+    if (!this._registeredThemes.has(id)) return
+    this._registeredThemes.delete(id)
+    this.emit('theme-changed', this.themes, { id })
+  }
+}
+
+function setupPluginCore (options: any) {
+  const pluginCore = new LSPluginCore(options)
+
+  debug('=== 🔗 Setup Logseq Plugin System 🔗 ===')
+
+  window.LSPluginCore = pluginCore
+}
+
+export {
+  PluginLocal,
+  setupPluginCore
+}

+ 196 - 0
libs/src/LSPlugin.d.ts

@@ -0,0 +1,196 @@
+import EventEmitter from 'eventemitter3'
+import { LSPluginCaller } from './LSPlugin.caller'
+import { LSPluginUser } from './LSPlugin.user'
+
+type PluginLocalIdentity = string
+
+type ThemeOptions = {
+  name: string
+  url: string
+  description?: string
+  mode?: 'dark' | 'light'
+
+  [key: string]: any
+}
+
+type StyleString = string
+type StyleOptions = {
+  key?: string
+  style: StyleString
+}
+
+type UIBaseOptions = {
+  key?: string
+  replace?: boolean
+  template: string
+}
+
+type UIPathIdentity = {
+  path: string // dom selector
+}
+
+type UISlotIdentity = {
+  slot: string // slot key
+}
+
+type UISlotOptions = UIBaseOptions & UISlotIdentity
+type UIPathOptions = UIBaseOptions & UIPathIdentity
+type UIOptions = UIPathOptions & UISlotOptions
+
+interface LSPluginPkgConfig {
+  id: PluginLocalIdentity
+  mode: 'shadow' | 'iframe'
+  themes: Array<ThemeOptions>
+  icon: string
+}
+
+interface LSPluginBaseInfo {
+  id: string // should be unique
+  mode: 'shadow' | 'iframe'
+
+  settings: {
+    disabled: boolean
+    [key: string]: any
+  },
+
+  [key: string]: any
+}
+
+type IHookEvent = {
+  [key: string]: any
+}
+
+type IUserHook = (callback: (e: IHookEvent) => void) => void
+type IUserSlotHook = (callback: (e: IHookEvent & UISlotIdentity) => void) => void
+
+interface BlockEntity {
+  uuid: string
+  content: string
+
+  [key: string]: any
+}
+
+type BlockIdentity = 'string' | Pick<BlockEntity, 'uuid'>
+type SlashCommandActionTag = 'editor/input' | 'editor/hook' | 'editor/clear-current-slash'
+type SlashCommandAction = [SlashCommandActionTag, ...args: any]
+
+interface IAppProxy {
+  pushState: (k: string, params?: {}) => void
+  replaceState: (k: string, params?: {}) => void
+  getUserState: () => Promise<any>
+  showMsg: (content: string, status?: 'success' | 'warning' | string) => void
+  setZoomFactor: (factor: number) => void
+  onThemeModeChanged: IUserHook
+  onPageFileMounted: IUserSlotHook
+  onBlockRendererMounted: IUserSlotHook
+}
+
+interface IEditorProxy {
+  registerSlashCommand: (this: LSPluginUser, tag: string, actions: Array<SlashCommandAction>) => boolean
+  registerBlockContextMenu: (this: LSPluginUser, tag: string, action: () => void) => boolean
+
+  // TODO: Block related APIs
+  getCurrentBlock: () => Promise<BlockIdentity>
+  getCurrentPageBlocksTree: <T = any> () => Promise<T>
+
+  insertBlock: (srcBlock: BlockIdentity, content: string, opts: Partial<{ before: boolean, sibling: boolean, props: {} }>) => Promise<BlockIdentity>
+  updateBlock: (srcBlock: BlockIdentity, content: string, opts: Partial<{ props: {} }>) => Promise<void>
+  removeBlock: (srcBlock: BlockIdentity, opts: Partial<{ includeChildren: boolean }>) => Promise<void>
+  touchBlock: (srcBlock: BlockIdentity) => Promise<BlockIdentity>
+  moveBlock: (srcBlock: BlockIdentity, targetBlock: BlockIdentity, opts: Partial<{ before: boolean, sibling: boolean }>) => Promise<void>
+
+  updateBlockProperty: (block: BlockIdentity, key: string, value: any) => Promise<void>
+  removeBlockProperty: (block: BlockIdentity) => Promise<void>
+}
+
+interface IDBProxy {
+  datascriptQuery: <T = any>(query: string) => Promise<T>
+}
+
+interface ILSPluginThemeManager extends EventEmitter {
+  themes: Map<PluginLocalIdentity, Array<ThemeOptions>>
+
+  registerTheme (id: PluginLocalIdentity, opt: ThemeOptions): Promise<void>
+
+  unregisterTheme (id: PluginLocalIdentity): Promise<void>
+
+  selectTheme (opt?: ThemeOptions): Promise<void>
+}
+
+type LSPluginUserEvents = 'ui:visible:changed' | 'settings:changed'
+
+interface ILSPluginUser extends EventEmitter<LSPluginUserEvents> {
+  /**
+   * Indicate connected with host
+   */
+  connected: boolean
+
+  /**
+   * Duplex message caller
+   */
+  caller: LSPluginCaller
+
+  /**
+   * Most from packages
+   */
+  baseInfo: LSPluginBaseInfo
+
+  /**
+   * Plugin user settings
+   */
+  settings?: LSPluginBaseInfo['settings']
+
+  /**
+   * Ready for host connected
+   */
+  ready (model?: Record<string, any>): Promise<any>
+
+  ready (callback?: (e: any) => void | {}): Promise<any>
+
+  ready (model?: Record<string, any>, callback?: (e: any) => void | {}): Promise<any>
+
+  /**
+   * @param model
+   */
+  provideModel (model: Record<string, any>): this
+
+  /**
+   * @param theme options
+   */
+  provideTheme (theme: ThemeOptions): this
+
+  /**
+   * @param style
+   */
+  provideStyle (style: StyleString | StyleOptions): this
+
+  /**
+   * @param ui options
+   */
+  provideUI (ui: UIOptions): this
+
+  /**
+   * @param attrs
+   */
+  updateSettings (attrs: Record<string, any>): void
+
+  /**
+   * MainUI for index.html
+   * @param attrs
+   */
+  setMainUIAttrs (attrs: Record<string, any>): void
+
+  setMainUIInlineStyle (style: CSSStyleDeclaration): void
+
+  showMainUI (): void
+
+  hideMainUI (): void
+
+  toggleMainUI (): void
+
+  isMainUIVisible: boolean
+
+  App: IAppProxy
+  Editor: IEditorProxy
+  DB: IDBProxy
+}

+ 116 - 0
libs/src/LSPlugin.shadow.ts

@@ -0,0 +1,116 @@
+import EventEmitter from 'eventemitter3'
+import { PluginLocal } from './LSPlugin.core'
+import { LSPluginUser } from './LSPlugin.user'
+
+// @ts-ignore
+const { importHTML, createSandboxContainer } = window.QSandbox || {}
+
+function userFetch (url, opts) {
+  if (!url.startsWith('http')) {
+    url = url.replace('file://', '')
+    return new Promise(async (resolve, reject) => {
+      try {
+        const content = await window.apis.doAction(['readFile', url])
+        resolve({
+          text () {
+            return content
+          }
+        })
+      } catch (e) {
+        console.error(e)
+        reject(e)
+      }
+    })
+  }
+
+  return fetch(url, opts)
+}
+
+class LSPluginShadowFrame extends EventEmitter<'mounted' | 'unmounted'> {
+  private _frame?: HTMLElement
+  private _root?: ShadowRoot
+  private _loaded = false
+  private _unmountFns: Array<() => Promise<void>> = []
+
+  constructor (
+    private _pluginLocal: PluginLocal
+  ) {
+    super()
+
+    _pluginLocal._dispose(() => {
+      this._unmount()
+    })
+  }
+
+  async load () {
+    const { name, entry } = this._pluginLocal.options
+
+    if (this.loaded || !entry) return
+
+    const { template, execScripts } = await importHTML(entry, { fetch: userFetch })
+
+    this._mount(template, document.body)
+
+    const sandbox = createSandboxContainer(
+      name, {
+        elementGetter: () => this._root?.firstChild,
+      }
+    )
+
+    const global = sandbox.instance.proxy as any
+
+    global.__shadow_mode__ = true
+    global.LSPluginLocal = this._pluginLocal
+    global.LSPluginShadow = this
+    global.LSPluginUser = global.logseq = new LSPluginUser(
+      this._pluginLocal.toJSON() as any,
+      this._pluginLocal.caller!
+    )
+
+    // TODO: {mount, unmount}
+    const execResult: any = await execScripts(global, true)
+
+    this._unmountFns.push(execResult.unmount)
+
+    this._loaded = true
+  }
+
+  _mount (content: string, container: HTMLElement) {
+    const frame = this._frame = document.createElement('div')
+    frame.classList.add('lsp-shadow-sandbox')
+    frame.id = this._pluginLocal.id
+
+    this._root = frame.attachShadow({ mode: 'open' })
+    this._root.innerHTML = `<div>${content}</div>`
+
+    container.appendChild(frame)
+
+    this.emit('mounted')
+  }
+
+  _unmount () {
+    for (const fn of this._unmountFns) {
+      fn && fn.call(null)
+    }
+  }
+
+  destroy () {
+    this.frame?.parentNode?.removeChild(this.frame)
+  }
+
+  get loaded (): boolean {
+    return this._loaded
+  }
+
+  get document () {
+    return this._root?.firstChild as HTMLElement
+  }
+
+  get frame (): HTMLElement {
+    return this._frame!
+  }
+}
+
+export {
+  LSPluginShadowFrame
+}

+ 303 - 0
libs/src/LSPlugin.user.ts

@@ -0,0 +1,303 @@
+import { deepMerge, invokeHostExportedApi } from './helpers'
+import { LSPluginCaller } from './LSPlugin.caller'
+import {
+  IAppProxy, IDBProxy,
+  IEditorProxy,
+  ILSPluginUser,
+  LSPluginBaseInfo, LSPluginUserEvents, SlashCommandAction,
+  StyleString,
+  ThemeOptions,
+  UIOptions
+} from './LSPlugin'
+import Debug from 'debug'
+import { snakeCase } from 'snake-case'
+import EventEmitter from 'eventemitter3'
+
+declare global {
+  interface Window {
+    __LSP__HOST__: boolean
+    logseq: ILSPluginUser
+  }
+}
+
+const debug = Debug('LSPlugin:user')
+
+const app: Partial<IAppProxy> = {}
+
+let registeredCmdUid = 0
+
+const editor: Partial<IEditorProxy> = {
+  registerSlashCommand (
+    this: LSPluginUser,
+    tag: string,
+    actions: Array<SlashCommandAction>
+  ) {
+    debug('Register slash command #', this.baseInfo.id, tag, actions)
+
+    actions = actions.map((it) => {
+      const [tag, ...args] = it
+
+      switch (tag) {
+        case 'editor/hook':
+          let key = args[0]
+          let fn = () => {
+            this.caller?.callUserModel(key)
+          }
+
+          if (typeof key === 'function') {
+            fn = key
+          }
+
+          const eventKey = `SlashCommandHook${tag}${++registeredCmdUid}`
+
+          it[1] = eventKey
+
+          // register command listener
+          this.Editor['on' + eventKey](fn)
+          break
+        default:
+      }
+
+      return it
+    })
+
+    this.caller?.call(`api:call`, {
+      method: 'register-plugin-slash-command',
+      args: [this.baseInfo.id, [tag, actions]]
+    })
+
+    return false
+  },
+
+  registerBlockContextMenu (
+    this: LSPluginUser,
+    tag: string,
+    action: () => void
+  ): boolean {
+    if (typeof action !== 'function') {
+      return false
+    }
+
+    const key = tag
+    const label = tag
+    const type = 'block-context-menu'
+    const eventKey = `SimpleCommandHook${tag}${++registeredCmdUid}`
+
+    this.Editor['on' + eventKey](action)
+
+    this.caller?.call(`api:call`, {
+      method: 'register-plugin-simple-command',
+      args: [this.baseInfo.id, [{ key, label, type }, ['editor/hook', eventKey]]]
+    })
+
+    return false
+  }
+}
+
+const db: Partial<IDBProxy> = {}
+
+type uiState = {
+  key?: number,
+  visible: boolean
+}
+
+const KEY_MAIN_UI = 0
+
+/**
+ * User plugin instance
+ */
+export class LSPluginUser extends EventEmitter<LSPluginUserEvents> implements ILSPluginUser {
+  /**
+   * Indicate connected with host
+   * @private
+   */
+  private _connected: boolean = false
+  private _ui = new Map<number, uiState>()
+
+  /**
+   * @param _baseInfo
+   * @param _caller
+   */
+  constructor (
+    private _baseInfo: LSPluginBaseInfo,
+    private _caller: LSPluginCaller
+  ) {
+    super()
+
+    _caller.on('settings:changed', (payload) => {
+      const b = Object.assign({}, this.settings)
+      const a = Object.assign(this._baseInfo.settings, payload)
+      this.emit('settings:changed', { ...a }, b)
+    })
+  }
+
+  async ready (
+    model?: any,
+    callback?: any
+  ) {
+    if (this._connected) return
+
+    try {
+
+      if (typeof model === 'function') {
+        callback = model
+        model = {}
+      }
+
+      let baseInfo = await this._caller.connectToParent(model)
+
+      baseInfo = deepMerge(this._baseInfo, baseInfo)
+
+      this._connected = true
+
+      callback && callback.call(this, baseInfo)
+    } catch (e) {
+      console.error('[LSPlugin Ready Error]', e)
+    }
+  }
+
+  provideModel (model: Record<string, any>) {
+    this.caller._extendUserModel(model)
+    return this
+  }
+
+  provideTheme (theme: ThemeOptions) {
+    this.caller.call('provider:theme', theme)
+    return this
+  }
+
+  provideStyle (style: StyleString) {
+    this.caller.call('provider:style', style)
+    return this
+  }
+
+  provideUI (ui: UIOptions) {
+    this.caller.call('provider:ui', ui)
+    return this
+  }
+
+  updateSettings (attrs: Record<string, any>) {
+    this.caller.call('settings:update', attrs)
+    // TODO: update associated baseInfo settings
+  }
+
+  setMainUIAttrs (attrs: Record<string, any>): void {
+    this.caller.call('main-ui:attrs', attrs)
+  }
+
+  setMainUIInlineStyle (style: CSSStyleDeclaration): void {
+    this.caller.call('main-ui:style', style)
+  }
+
+  hideMainUI (): void {
+    const payload = { key: KEY_MAIN_UI, visible: false }
+    this.caller.call('main-ui:visible', payload)
+    this.emit('ui:visible:changed', payload)
+    this._ui.set(payload.key, payload)
+  }
+
+  showMainUI (): void {
+    const payload = { key: KEY_MAIN_UI, visible: true }
+    this.caller.call('main-ui:visible', payload)
+    this.emit('ui:visible:changed', payload)
+    this._ui.set(payload.key, payload)
+  }
+
+  toggleMainUI (): void {
+    const payload = { key: KEY_MAIN_UI, toggle: true }
+    const state = this._ui.get(payload.key)
+    if (state && state.visible) {
+      this.hideMainUI()
+    } else {
+      this.showMainUI()
+    }
+  }
+
+  get isMainUIVisible (): boolean {
+    const state = this._ui.get(0)
+    return Boolean(state && state.visible)
+  }
+
+  get connected (): boolean {
+    return this._connected
+  }
+
+  get baseInfo (): LSPluginBaseInfo {
+    return this._baseInfo
+  }
+
+  get settings () {
+    return this.baseInfo?.settings
+  }
+
+  get caller (): LSPluginCaller {
+    return this._caller
+  }
+
+  _makeUserProxy (
+    target: any,
+    tag?: 'app' | 'editor' | 'db'
+  ) {
+    const that = this
+    const caller = this.caller
+
+    return new Proxy(target, {
+      get (target: any, propKey, receiver) {
+        const origMethod = target[propKey]
+
+        return function (this: any, ...args: any) {
+          if (origMethod) {
+            const ret = origMethod.apply(that, args)
+            if (ret === false) return
+          }
+
+          // Handle hook
+          if (tag) {
+            const hookMatcher = propKey.toString().match(/^(once|off|on)/i)
+
+            if (hookMatcher != null) {
+              const f = hookMatcher[0]
+              const s = hookMatcher.input!
+              const e = s.slice(f.length)
+
+              caller[f.toLowerCase()](`hook:${tag}:${snakeCase(e)}`, args[0])
+              return
+            }
+          }
+
+          // Call host
+          return caller.callAsync(`api:call`, {
+            method: propKey,
+            args: args
+          })
+        }
+      }
+    })
+  }
+
+  get App (): IAppProxy {
+    return this._makeUserProxy(app, 'app')
+  }
+
+  get Editor () {
+    return this._makeUserProxy(editor, 'editor')
+  }
+
+  get DB (): IDBProxy {
+    return this._makeUserProxy(db)
+  }
+}
+
+export function setupPluginUserInstance (
+  pluginBaseInfo: LSPluginBaseInfo,
+  pluginCaller: LSPluginCaller
+) {
+  return new LSPluginUser(pluginBaseInfo, pluginCaller)
+}
+
+if (window.__LSP__HOST__ == null) { // Entry of iframe mode
+  debug('Entry of iframe mode.')
+
+  const caller = new LSPluginCaller(null)
+  window.logseq = setupPluginUserInstance({} as any, caller)
+}

+ 279 - 0
libs/src/helpers.ts

@@ -0,0 +1,279 @@
+import { StyleString, UIOptions } from './LSPlugin'
+import { PluginLocal } from './LSPlugin.core'
+import { snakeCase } from 'snake-case'
+
+interface IObject {
+  [key: string]: any;
+}
+
+declare global {
+  interface Window {
+    api: any
+    apis: any
+  }
+}
+
+export function isObject (item: any) {
+  return (item === Object(item) && !Array.isArray(item))
+}
+
+export function deepMerge (
+  target: IObject,
+  ...sources: Array<IObject>
+) {
+  // return the target if no sources passed
+  if (!sources.length) {
+    return target
+  }
+
+  const result: IObject = target
+
+  if (isObject(result)) {
+    const len: number = sources.length
+
+    for (let i = 0; i < len; i += 1) {
+      const elm: any = sources[i]
+
+      if (isObject(elm)) {
+        for (const key in elm) {
+          if (elm.hasOwnProperty(key)) {
+            if (isObject(elm[key])) {
+              if (!result[key] || !isObject(result[key])) {
+                result[key] = {}
+              }
+              deepMerge(result[key], elm[key])
+            } else {
+              if (Array.isArray(result[key]) && Array.isArray(elm[key])) {
+                // concatenate the two arrays and remove any duplicate primitive values
+                result[key] = Array.from(new Set(result[key].concat(elm[key])))
+              } else {
+                result[key] = elm[key]
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+
+  return result
+}
+
+export function genID () {
+  // Math.random should be unique because of its seeding algorithm.
+  // Convert it to base 36 (numbers + letters), and grab the first 9 characters
+  // after the decimal.
+  return '_' + Math.random().toString(36).substr(2, 9)
+}
+
+export function ucFirst (str: string) {
+  return str.charAt(0).toUpperCase() + str.slice(1)
+}
+
+export function withFileProtocol (path: string) {
+  if (!path) return ''
+  const reg = /^(http|file|assets)/
+
+  if (!reg.test(path)) {
+    path = 'file://' + path
+  }
+
+  return path
+}
+
+/**
+ * @param timeout milliseconds
+ * @param tag string
+ */
+export function deferred<T = any> (timeout?: number, tag?: string) {
+  let resolve: any, reject: any
+  let settled = false
+  const timeFn = (r: Function) => {
+    return (v: T) => {
+      timeout && clearTimeout(timeout)
+      r(v)
+      settled = true
+    }
+  }
+
+  const promise = new Promise<T>((resolve1, reject1) => {
+    resolve = timeFn(resolve1)
+    reject = timeFn(reject1)
+
+    if (timeout) {
+      // @ts-ignore
+      timeout = setTimeout(() => reject(new Error(`[deferred timeout] ${tag}`)), timeout)
+    }
+  })
+
+  return {
+    created: Date.now(),
+    setTag: (t: string) => tag = t,
+    resolve, reject, promise,
+    get settled () {
+      return settled
+    }
+  }
+}
+
+export function invokeHostExportedApi (
+  method: string,
+  ...args: Array<any>
+) {
+  const method1 = snakeCase(method)
+  const fn = window.api[method1] || window.apis[method1] ||
+    window.api[method] || window.apis[method]
+
+  if (!fn) {
+    throw new Error(`Not existed method #${method}`)
+  }
+  return typeof fn !== 'function' ? fn : fn.apply(null, args)
+}
+
+export function setupIframeSandbox (
+  props: Record<string, any>,
+  target: HTMLElement
+) {
+  const iframe = document.createElement('iframe')
+
+  iframe.classList.add('lsp-iframe-sandbox')
+
+  Object.entries(props).forEach(([k, v]) => {
+    iframe.setAttribute(k, v)
+  })
+
+  target.appendChild(iframe)
+
+  return async () => {
+    target.removeChild(iframe)
+  }
+}
+
+export function setupInjectedStyle (
+  style: StyleString,
+  attrs: Record<string, any>
+) {
+  const key = attrs['data-injected-style']
+  let el = key && document.querySelector(`[data-injected-style=${key}]`)
+
+  if (el) {
+    el.textContent = style
+    return
+  }
+
+  el = document.createElement('style')
+  el.textContent = style
+
+  attrs && Object.entries(attrs).forEach(([k, v]) => {
+    el.setAttribute(k, v)
+  })
+
+  document.head.append(el)
+
+  return () => {
+    document.head.removeChild(el)
+  }
+}
+
+export function setupInjectedUI (
+  this: PluginLocal,
+  ui: UIOptions,
+  attrs: Record<string, any>
+) {
+  const pl = this
+  const selector = ui.path || `#${ui.slot}`
+
+  const target = selector && document.querySelector(selector)
+  if (!target) {
+    console.error(`${this.debugTag} can not resolve selector target ${selector}`)
+    return
+  }
+
+  const key = `${ui.key}-${pl.id}`
+
+  let el = document.querySelector(`div[data-injected-ui="${key}"]`) as HTMLElement
+
+  if (el) {
+    el.innerHTML = ui.template
+    return
+  }
+
+  el = document.createElement('div')
+  el.dataset.injectedUi = key || ''
+
+  // TODO: Support more
+  el.innerHTML = ui.template
+
+  attrs && Object.entries(attrs).forEach(([k, v]) => {
+    el.setAttribute(k, v)
+  })
+
+  target.appendChild(el);
+
+  // TODO: How handle events
+  ['click', 'focus', 'focusin', 'focusout', 'blur', 'dblclick',
+    'keyup', 'keypress', 'keydown', 'change', 'input'].forEach((type) => {
+    el.addEventListener(type, (e) => {
+      const target = e.target! as HTMLElement
+      const trigger = target.closest(`[data-on-${type}]`) as HTMLElement
+      if (!trigger) return
+
+      const msgType = trigger.dataset[`on${ucFirst(type)}`]
+      msgType && pl.caller?.callUserModel(msgType, transformableEvent(trigger, e))
+    }, false)
+  })
+
+  return () => {
+    target!.removeChild(el)
+  }
+}
+
+export function transformableEvent (target: HTMLElement, e: Event) {
+  const obj: any = {}
+
+  if (target) {
+    const ds = target.dataset
+    const FLAG_RECT = 'rect'
+
+    ;['value', 'id', 'className',
+      'dataset', FLAG_RECT
+    ].forEach((k) => {
+      let v: any
+
+      switch (k) {
+        case FLAG_RECT:
+          if (!ds.hasOwnProperty(FLAG_RECT)) return
+          v = target.getBoundingClientRect().toJSON()
+          break
+        default:
+          v = target[k]
+      }
+
+      if (typeof v === 'object') {
+        v = { ...v }
+      }
+
+      obj[k] = v
+    })
+  }
+
+  return obj
+}
+
+let injectedThemeEffect: any = null
+
+export function setupInjectedTheme (url?: string) {
+  injectedThemeEffect?.call()
+
+  if (!url) return
+
+  const link = document.createElement('link')
+  link.rel = 'stylesheet'
+  link.href = url
+  document.head.appendChild(link)
+
+  return (injectedThemeEffect = () => {
+    document.head.removeChild(link)
+    injectedThemeEffect = null
+  })
+}

+ 88 - 0
libs/tsconfig.json

@@ -0,0 +1,88 @@
+{
+  "compilerOptions": {
+    /* Visit https://aka.ms/tsconfig.json to read more about this file */
+
+    /* Basic Options */
+    // "incremental": true,                   /* Enable incremental compilation */
+    "target": "ESNext",
+    /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
+    "module": "ESNext",
+    /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
+    // "lib": [],                             /* Specify library files to be included in the compilation. */
+    "allowJs": true,
+    /* Allow javascript files to be compiled. */
+    // "checkJs": true,                       /* Report errors in .js files. */
+    "jsx": "react",
+    /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
+    "declaration": true,
+    /* Generates corresponding '.d.ts' file. */
+    // "declarationMap": true,                /* Generates a sourcemap for each corresponding '.d.ts' file. */
+    "sourceMap": false,
+    /* Generates corresponding '.map' file. */
+    // "outFile": "./",                       /* Concatenate and emit output to single file. */
+    "outDir": "dist",
+    /* Redirect output structure to the directory. */
+    "rootDir": "src",
+    /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
+    // "composite": true,                     /* Enable project compilation */
+    // "tsBuildInfoFile": "./",               /* Specify file to store incremental compilation information */
+    // "removeComments": true,                /* Do not emit comments to output. */
+    //    "noEmit": true,
+    /* Do not emit outputs. */
+    // "importHelpers": true,                 /* Import emit helpers from 'tslib'. */
+    // "downlevelIteration": true,            /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
+    // "isolatedModules": true,               /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
+
+    /* Strict Type-Checking Options */
+    // "strict": true,
+    /* Enable all strict type-checking options. */
+    // "noImplicitAny": true,                 /* Raise error on expressions and declarations with an implied 'any' type. */
+    "strictNullChecks": true,
+    /* Enable strict null checks. */
+    // "strictFunctionTypes": true,           /* Enable strict checking of function types. */
+    // "strictBindCallApply": true,           /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
+    "strictPropertyInitialization": true,
+    /* Enable strict checking of property initialization in classes. */
+    // "noImplicitThis": true,                /* Raise error on 'this' expressions with an implied 'any' type. */
+    // "alwaysStrict": true,                  /* Parse in strict mode and emit "use strict" for each source file. */
+
+    /* Additional Checks */
+    // "noUnusedLocals": true,                /* Report errors on unused locals. */
+    // "noUnusedParameters": true,            /* Report errors on unused parameters. */
+    // "noImplicitReturns": true,             /* Report error when not all code paths in function return a value. */
+    // "noFallthroughCasesInSwitch": true,    /* Report errors for fallthrough cases in switch statement. */
+
+    /* Module Resolution Options */
+    "moduleResolution": "node",
+    /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
+    // "baseUrl": "./",                       /* Base directory to resolve non-absolute module names. */
+    // "paths": {},                           /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
+    // "rootDirs": [],                        /* List of root folders whose combined content represents the structure of the project at runtime. */
+    // "typeRoots": [],                       /* List of folders to include type definitions from. */
+    // "types": [],                           /* Type declaration files to be included in compilation. */
+    // "allowSyntheticDefaultImports": true,  /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
+    "esModuleInterop": true,
+    /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
+    // "preserveSymlinks": true,              /* Do not resolve the real path of symlinks. */
+    // "allowUmdGlobalAccess": true,          /* Allow accessing UMD globals from modules. */
+
+    /* Source Map Options */
+    // "sourceRoot": "",                      /* Specify the location where debugger should locate TypeScript files instead of source locations. */
+    // "mapRoot": "",                         /* Specify the location where debugger should locate map files instead of generated locations. */
+    // "inlineSourceMap": true,               /* Emit a single file with source maps instead of having a separate file. */
+    // "inlineSources": true,                 /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
+
+    /* Experimental Options */
+    // "experimentalDecorators": true,        /* Enables experimental support for ES7 decorators. */
+    // "emitDecoratorMetadata": true,         /* Enables experimental support for emitting type metadata for decorators. */
+
+    /* Advanced Options */
+    "skipLibCheck": true,
+    /* Skip type checking of declaration files. */
+    "forceConsistentCasingInFileNames": true
+    /* Disallow inconsistently-cased references to the same file. */
+  },
+  "include": [
+    "src/**/*.ts"
+  ]
+}

+ 32 - 0
libs/webpack.config.core.js

@@ -0,0 +1,32 @@
+const webpack = require('webpack')
+const path = require('path')
+const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
+
+module.exports = {
+  entry: './src/LSPlugin.core.ts',
+  devtool: 'inline-source-map',
+  module: {
+    rules: [
+      {
+        test: /\.tsx?$/,
+        use: 'ts-loader',
+        exclude: /node_modules/,
+      },
+    ],
+  },
+  resolve: {
+    extensions: ['.tsx', '.ts', '.js'],
+  },
+  plugins: [
+    new webpack.ProvidePlugin({
+      process: 'process/browser',
+    }),
+    // new BundleAnalyzerPlugin()
+  ],
+  output: {
+    library: 'LSPlugin',
+    libraryTarget: 'umd',
+    filename: 'lsplugin.core.js',
+    path: path.resolve(__dirname, '../static/js'),
+  },
+}

+ 27 - 0
libs/webpack.config.js

@@ -0,0 +1,27 @@
+const path = require('path')
+const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
+
+module.exports = {
+  entry: './src/LSPlugin.user.ts',
+  module: {
+    rules: [
+      {
+        test: /\.tsx?$/,
+        use: 'ts-loader',
+        exclude: /node_modules/,
+      },
+    ],
+  },
+  resolve: {
+    extensions: ['.tsx', '.ts', '.js'],
+  },
+  plugins: [
+    // new BundleAnalyzerPlugin()
+  ],
+  output: {
+    library: "LSPluginEntry",
+    libraryTarget: "umd",
+    filename: 'lsplugin.user.js',
+    path: path.resolve(__dirname, 'dist')
+  },
+}

+ 1251 - 0
libs/yarn.lock

@@ -0,0 +1,1251 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@discoveryjs/json-ext@^0.5.0":
+  version "0.5.2"
+  resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752"
+  integrity sha512-HyYEUDeIj5rRQU2Hk5HTB2uHsbRQpF70nvMhVzi+VJR0X+xNEhjPui4/kBf3VeH/wqD28PT4sVOm8qqLjBrSZg==
+
+"@polka/url@^1.0.0-next.9":
+  version "1.0.0-next.11"
+  resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.11.tgz#aeb16f50649a91af79dbe36574b66d0f9e4d9f71"
+  integrity sha512-3NsZsJIA/22P3QUyrEDNA2D133H4j224twJrdipXN38dpnIOzAbUDtOwkcJ5pXmn75w7LSQDjA4tO9dm1XlqlA==
+
+"@types/debug@^4.1.5":
+  version "4.1.5"
+  resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.5.tgz#b14efa8852b7768d898906613c23f688713e02cd"
+  integrity sha512-Q1y515GcOdTHgagaVFhHnIFQ38ygs/kmxdNpvpou+raI9UO3YZcHDngBSYKQklcKlvA7iuQlmIKbzvmxcOE9CQ==
+
+"@types/dompurify@^2.2.1":
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/@types/dompurify/-/dompurify-2.2.1.tgz#eebf3af8afe2f577a53acab9d98a3a4cb04bbbe7"
+  integrity sha512-3JwbEeRVQ3n6+JgBW/hCdkydRk9/vWT+UEglcXEJqLJEcUganDH37zlfLznxPKTZZfDqA9K229l1qN458ubcOQ==
+  dependencies:
+    "@types/trusted-types" "*"
+
+"@types/eslint-scope@^3.7.0":
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.0.tgz#4792816e31119ebd506902a482caec4951fabd86"
+  integrity sha512-O/ql2+rrCUe2W2rs7wMR+GqPRcgB6UiqN5RhrR5xruFlY7l9YLMn0ZkDzjoHLeiFkR8MCQZVudUuuvQ2BLC9Qw==
+  dependencies:
+    "@types/eslint" "*"
+    "@types/estree" "*"
+
+"@types/eslint@*":
+  version "7.2.6"
+  resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-7.2.6.tgz#5e9aff555a975596c03a98b59ecd103decc70c3c"
+  integrity sha512-I+1sYH+NPQ3/tVqCeUSBwTE/0heyvtXqpIopUUArlBm0Kpocb8FbMa3AZ/ASKIFpN3rnEx932TTXDbt9OXsNDw==
+  dependencies:
+    "@types/estree" "*"
+    "@types/json-schema" "*"
+
+"@types/estree@*", "@types/estree@^0.0.46":
+  version "0.0.46"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.46.tgz#0fb6bfbbeabd7a30880504993369c4bf1deab1fe"
+  integrity sha512-laIjwTQaD+5DukBZaygQ79K1Z0jb1bPEMRrkXSLjtCcZm+abyp5YbrqpSLzD42FwWW6gK/aS4NYpJ804nG2brg==
+
+"@types/json-schema@*", "@types/json-schema@^7.0.6":
+  version "7.0.7"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad"
+  integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA==
+
+"@types/lodash-es@^4.17.4":
+  version "4.17.4"
+  resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.4.tgz#b2e440d2bf8a93584a9fd798452ec497986c9b97"
+  integrity sha512-BBz79DCJbD2CVYZH67MBeHZRX++HF+5p8Mo5MzjZi64Wac39S3diedJYHZtScbRVf4DjZyN6LzA0SB0zy+HSSQ==
+  dependencies:
+    "@types/lodash" "*"
+
+"@types/lodash@*":
+  version "4.14.168"
+  resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008"
+  integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q==
+
+"@types/node@*":
+  version "14.14.31"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-14.14.31.tgz#72286bd33d137aa0d152d47ec7c1762563d34055"
+  integrity sha512-vFHy/ezP5qI0rFgJ7aQnjDXwAMrG0KqqIH7tQG5PPv3BWBayOPIQNBjVc/P6hhdZfMx51REc6tfDNXHUio893g==
+
+"@types/postmate@^1.5.1":
+  version "1.5.1"
+  resolved "https://registry.yarnpkg.com/@types/postmate/-/postmate-1.5.1.tgz#56a506c371a7b4388beebacb4420838c2c3c4a2b"
+  integrity sha512-sFia2ycFNrxf1YZS59ShTDkMwhUlOYWAw7EslLePF52x5czUDrvdqB4f3eCXCiQp7yXpqHqwPCf4OBvCTqbaWw==
+
+"@types/trusted-types@*":
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.0.tgz#aee6e868fcef74f2b8c71614b6df81a601a42f17"
+  integrity sha512-I8MnZqNXsOLHsU111oHbn3khtvKMi5Bn4qVFsIWSJcCP1KKDiXX5AEw8UPk0nSopeC+Hvxt6yAy1/a5PailFqg==
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.0.tgz#a5aa679efdc9e51707a4207139da57920555961f"
+  integrity sha512-kX2W49LWsbthrmIRMbQZuQDhGtjyqXfEmmHyEi4XWnSZtPmxY0+3anPIzsnRb45VH/J55zlOfWvZuY47aJZTJg==
+  dependencies:
+    "@webassemblyjs/helper-numbers" "1.11.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.0.tgz#34d62052f453cd43101d72eab4966a022587947c"
+  integrity sha512-Q/aVYs/VnPDVYvsCBL/gSgwmfjeCb4LW8+TMrO3cSzJImgv8lxxEPM2JA5jMrivE7LSz3V+PFqtMbls3m1exDA==
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.0.tgz#aaea8fb3b923f4aaa9b512ff541b013ffb68d2d4"
+  integrity sha512-baT/va95eXiXb2QflSx95QGT5ClzWpGaa8L7JnJbgzoYeaA27FCvuBXU758l+KXWRndEmUXjP0Q5fibhavIn8w==
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.0.tgz#d026c25d175e388a7dbda9694e91e743cbe9b642"
+  integrity sha512-u9HPBEl4DS+vA8qLQdEQ6N/eJQ7gT7aNvMIo8AAWvAl/xMrcOSiI2M0MAnMCy3jIFke7bEee/JwdX1nUpCtdyA==
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.0.tgz#7ab04172d54e312cc6ea4286d7d9fa27c88cd4f9"
+  integrity sha512-DhRQKelIj01s5IgdsOJMKLppI+4zpmcMQ3XboFPLwCpSNH6Hqo1ritgHgD0nqHeSYqofA6aBN/NmXuGjM1jEfQ==
+  dependencies:
+    "@webassemblyjs/floating-point-hex-parser" "1.11.0"
+    "@webassemblyjs/helper-api-error" "1.11.0"
+    "@xtuc/long" "4.2.2"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.0.tgz#85fdcda4129902fe86f81abf7e7236953ec5a4e1"
+  integrity sha512-MbmhvxXExm542tWREgSFnOVo07fDpsBJg3sIl6fSp9xuu75eGz5lz31q7wTLffwL3Za7XNRCMZy210+tnsUSEA==
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.0.tgz#9ce2cc89300262509c801b4af113d1ca25c1a75b"
+  integrity sha512-3Eb88hcbfY/FCukrg6i3EH8H2UsD7x8Vy47iVJrP967A9JGqgBVL9aH71SETPx1JrGsOUVLo0c7vMCN22ytJew==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.0"
+    "@webassemblyjs/helper-buffer" "1.11.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.0"
+    "@webassemblyjs/wasm-gen" "1.11.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.0.tgz#46975d583f9828f5d094ac210e219441c4e6f5cf"
+  integrity sha512-KXzOqpcYQwAfeQ6WbF6HXo+0udBNmw0iXDmEK5sFlmQdmND+tr773Ti8/5T/M6Tl/413ArSJErATd8In3B+WBA==
+  dependencies:
+    "@xtuc/ieee754" "^1.2.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.0.tgz#f7353de1df38aa201cba9fb88b43f41f75ff403b"
+  integrity sha512-aqbsHa1mSQAbeeNcl38un6qVY++hh8OpCOzxhixSYgbRfNWcxJNJQwe2rezK9XEcssJbbWIkblaJRwGMS9zp+g==
+  dependencies:
+    "@xtuc/long" "4.2.2"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.0.tgz#86e48f959cf49e0e5091f069a709b862f5a2cadf"
+  integrity sha512-A/lclGxH6SpSLSyFowMzO/+aDEPU4hvEiooCMXQPcQFPPJaYcPQNKGOCLUySJsYJ4trbpr+Fs08n4jelkVTGVw==
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.0.tgz#ee4a5c9f677046a210542ae63897094c2027cb78"
+  integrity sha512-JHQ0damXy0G6J9ucyKVXO2j08JVJ2ntkdJlq1UTiUrIgfGMmA7Ik5VdC/L8hBK46kVJgujkBIoMtT8yVr+yVOQ==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.0"
+    "@webassemblyjs/helper-buffer" "1.11.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.0"
+    "@webassemblyjs/helper-wasm-section" "1.11.0"
+    "@webassemblyjs/wasm-gen" "1.11.0"
+    "@webassemblyjs/wasm-opt" "1.11.0"
+    "@webassemblyjs/wasm-parser" "1.11.0"
+    "@webassemblyjs/wast-printer" "1.11.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.0.tgz#3cdb35e70082d42a35166988dda64f24ceb97abe"
+  integrity sha512-BEUv1aj0WptCZ9kIS30th5ILASUnAPEvE3tVMTrItnZRT9tXCLW2LEXT8ezLw59rqPP9klh9LPmpU+WmRQmCPQ==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.0"
+    "@webassemblyjs/ieee754" "1.11.0"
+    "@webassemblyjs/leb128" "1.11.0"
+    "@webassemblyjs/utf8" "1.11.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.0.tgz#1638ae188137f4bb031f568a413cd24d32f92978"
+  integrity sha512-tHUSP5F4ywyh3hZ0+fDQuWxKx3mJiPeFufg+9gwTpYp324mPCQgnuVKwzLTZVqj0duRDovnPaZqDwoyhIO8kYg==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.0"
+    "@webassemblyjs/helper-buffer" "1.11.0"
+    "@webassemblyjs/wasm-gen" "1.11.0"
+    "@webassemblyjs/wasm-parser" "1.11.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.0.tgz#3e680b8830d5b13d1ec86cc42f38f3d4a7700754"
+  integrity sha512-6L285Sgu9gphrcpDXINvm0M9BskznnzJTE7gYkjDbxET28shDqp27wpruyx3C2S/dvEwiigBwLA1cz7lNUi0kw==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.0"
+    "@webassemblyjs/helper-api-error" "1.11.0"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.0"
+    "@webassemblyjs/ieee754" "1.11.0"
+    "@webassemblyjs/leb128" "1.11.0"
+    "@webassemblyjs/utf8" "1.11.0"
+
+"@webassemblyjs/[email protected]":
+  version "1.11.0"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.0.tgz#680d1f6a5365d6d401974a8e949e05474e1fab7e"
+  integrity sha512-Fg5OX46pRdTgB7rKIUojkh9vXaVN6sGYCnEiJN1GYkb0RPwShZXp6KTDqmoMdQPKhcroOXh3fEzmkWmCYaKYhQ==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.0"
+    "@xtuc/long" "4.2.2"
+
+"@webpack-cli/configtest@^1.0.1":
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.0.1.tgz#241aecfbdc715eee96bed447ed402e12ec171935"
+  integrity sha512-B+4uBUYhpzDXmwuo3V9yBH6cISwxEI4J+NO5ggDaGEEHb0osY/R7MzeKc0bHURXQuZjMM4qD+bSJCKIuI3eNBQ==
+
+"@webpack-cli/info@^1.2.2":
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-1.2.2.tgz#ef3c0cd947a1fa083e174a59cb74e0b6195c236c"
+  integrity sha512-5U9kUJHnwU+FhKH4PWGZuBC1hTEPYyxGSL5jjoBI96Gx8qcYJGOikpiIpFoTq8mmgX3im2zAo2wanv/alD74KQ==
+  dependencies:
+    envinfo "^7.7.3"
+
+"@webpack-cli/serve@^1.3.0":
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-1.3.0.tgz#2730c770f5f1f132767c63dcaaa4ec28f8c56a6c"
+  integrity sha512-k2p2VrONcYVX1wRRrf0f3X2VGltLWcv+JzXRBDmvCxGlCeESx4OXw91TsWeKOkp784uNoVQo313vxJFHXPPwfw==
+
+"@xtuc/ieee754@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
+  integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==
+
+"@xtuc/[email protected]":
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
+  integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
+
+acorn-walk@^8.0.0:
+  version "8.0.2"
+  resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.0.2.tgz#d4632bfc63fd93d0f15fd05ea0e984ffd3f5a8c3"
+  integrity sha512-+bpA9MJsHdZ4bgfDcpk0ozQyhhVct7rzOmO0s1IIr0AGGgKBljss8n2zp11rRP2wid5VGeh04CgeKzgat5/25A==
+
+acorn@^8.0.4:
+  version "8.0.5"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.0.5.tgz#a3bfb872a74a6a7f661bc81b9849d9cac12601b7"
+  integrity sha512-v+DieK/HJkJOpFBETDJioequtc3PfxsWMaxIdIwujtF7FEV/MAyDQLlm6/zPvr7Mix07mLh6ccVwIsloceodlg==
+
+ajv-keywords@^3.5.2:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
+  integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
+
+ajv@^6.12.5:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
+  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-colors@^4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
+  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
+
+ansi-styles@^4.1.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+big.js@^5.2.2:
+  version "5.2.2"
+  resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
+  integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
+
+braces@^3.0.1:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
+browserslist@^4.14.5:
+  version "4.16.3"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.16.3.tgz#340aa46940d7db878748567c5dea24a48ddf3717"
+  integrity sha512-vIyhWmIkULaq04Gt93txdh+j02yX/JzlyhLYbV3YQCn/zvES3JnY7TifHHvvr1w5hTDluNKMkV05cs4vy8Q7sw==
+  dependencies:
+    caniuse-lite "^1.0.30001181"
+    colorette "^1.2.1"
+    electron-to-chromium "^1.3.649"
+    escalade "^3.1.1"
+    node-releases "^1.1.70"
+
+buffer-from@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
+  integrity sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
+
+caniuse-lite@^1.0.30001181:
+  version "1.0.30001196"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001196.tgz#00518a2044b1abf3e0df31fadbe5ed90b63f4e64"
+  integrity sha512-CPvObjD3ovWrNBaXlAIGWmg2gQQuJ5YhuciUOjPRox6hIQttu8O+b51dx6VIpIY9ESd2d0Vac1RKpICdG4rGUg==
+
+chalk@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.0.tgz#4e14870a618d9e2edd97dd8345fd9d9dc315646a"
+  integrity sha512-qwx12AxXe2Q5xQ43Ac//I6v5aXTipYrSESdOgzrN+9XjgEpyjpKuvSGaN4qE93f7TQTlerQQ8S+EQ0EyDoVL1A==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+chrome-trace-event@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.2.tgz#234090ee97c7d4ad1a2c4beae27505deffc608a4"
+  integrity sha512-9e/zx1jw7B4CO+c/RXoCsfg/x1AfUBioy4owYH0bJprEYAx5hRFLRhWBqHAG57D0ZM4H7vxbP7bPe0VwhQRYDQ==
+  dependencies:
+    tslib "^1.9.0"
+
+clone-deep@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
+  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
+  dependencies:
+    is-plain-object "^2.0.4"
+    kind-of "^6.0.2"
+    shallow-clone "^3.0.0"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+colorette@^1.2.1:
+  version "1.2.2"
+  resolved "https://registry.yarnpkg.com/colorette/-/colorette-1.2.2.tgz#cbcc79d5e99caea2dbf10eb3a26fd8b3e6acfa94"
+  integrity sha512-MKGMzyfeuutC/ZJ1cba9NqcNpfeqMUcYmyF1ZFY6/Cn7CNSAKx6a+s48sqLqyAiZuaP2TcqMhoo+dlwFnVxT9w==
+
+commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+commander@^6.2.0:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c"
+  integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==
+
+commander@^7.0.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-7.1.0.tgz#f2eaecf131f10e36e07d894698226e36ae0eb5ff"
+  integrity sha512-pRxBna3MJe6HKnBGsDyMv8ETbptw3axEdYHoqNh7gu5oDcew8fs0xnivZGm06Ogk8zGAJ9VX+OPEr2GXEQK4dg==
+
+core-util-is@~1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
+  integrity sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=
+
+cross-spawn@^7.0.3:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
+debug@^4.3.1:
+  version "4.3.1"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.1.tgz#f0d229c505e0c6d8c49ac553d1b13dc183f6b2ee"
+  integrity sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==
+  dependencies:
+    ms "2.1.2"
+
+dompurify@^2.2.7:
+  version "2.2.7"
+  resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.2.7.tgz#a5f055a2a471638680e779bd08fc334962d11fd8"
+  integrity sha512-jdtDffdGNY+C76jvodNTu9jt5yYj59vuTUyx+wXdzcSwAGTYZDAQkQ7Iwx9zcGrA4ixC1syU4H3RZROqRxokxg==
+
+dot-case@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751"
+  integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==
+  dependencies:
+    no-case "^3.0.4"
+    tslib "^2.0.3"
+
+duplexer@^0.1.2:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6"
+  integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==
+
+electron-to-chromium@^1.3.649:
+  version "1.3.680"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.680.tgz#88cc44bd2a85b46cf7521f714db57dd74d0cd488"
+  integrity sha512-XBACJT9RdpdWtoMXQPR8Be3ZtmizWWbxfw8cY2b5feUwiDO3FUl8qo4W2jXoq/WnnA3xBRqafu1XbpczqyUvlA==
+
+emojis-list@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78"
+  integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==
+
+enhanced-resolve@^4.0.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.5.0.tgz#2f3cfd84dbe3b487f18f2db2ef1e064a571ca5ec"
+  integrity sha512-Nv9m36S/vxpsI+Hc4/ZGRs0n9mXqSWGGq49zxb/cJfPAQMbUtttJAlNPS4AQzaBdw/pKskw5bMbekT/Y7W/Wlg==
+  dependencies:
+    graceful-fs "^4.1.2"
+    memory-fs "^0.5.0"
+    tapable "^1.0.0"
+
+enhanced-resolve@^5.7.0:
+  version "5.7.0"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.7.0.tgz#525c5d856680fbd5052de453ac83e32049958b5c"
+  integrity sha512-6njwt/NsZFUKhM6j9U8hzVyD4E4r0x7NQzhTCbcWOJ0IQjNSAoalWmb0AE51Wn+fwan5qVESWi7t2ToBxs9vrw==
+  dependencies:
+    graceful-fs "^4.2.4"
+    tapable "^2.2.0"
+
+enquirer@^2.3.6:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.6.tgz#2a7fe5dd634a1e4125a975ec994ff5456dc3734d"
+  integrity sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==
+  dependencies:
+    ansi-colors "^4.1.1"
+
+envinfo@^7.7.3:
+  version "7.7.4"
+  resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.7.4.tgz#c6311cdd38a0e86808c1c9343f667e4267c4a320"
+  integrity sha512-TQXTYFVVwwluWSFis6K2XKxgrD22jEv0FTuLCQI+OjH7rn93+iY0fSSFM5lrSxFY+H1+B0/cvvlamr3UsBivdQ==
+
+errno@^0.1.3:
+  version "0.1.8"
+  resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.8.tgz#8bb3e9c7d463be4976ff888f76b4809ebc2e811f"
+  integrity sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==
+  dependencies:
+    prr "~1.0.1"
+
+es-module-lexer@^0.4.0:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.4.1.tgz#dda8c6a14d8f340a24e34331e0fab0cb50438e0e"
+  integrity sha512-ooYciCUtfw6/d2w56UVeqHPcoCFAiJdz5XOkYpv/Txl1HMUozpXjz/2RIQgqwKdXNDPSF1W7mJCFse3G+HDyAA==
+
+escalade@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40"
+  integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==
+
+eslint-scope@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^4.1.1"
+
+esrecurse@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+  integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
+  dependencies:
+    estraverse "^5.2.0"
+
+estraverse@^4.1.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
+
+estraverse@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.2.0.tgz#307df42547e6cc7324d3cf03c155d5cdb8c53880"
+  integrity sha512-BxbNGGNm0RyRYvUdHpIwv9IWzeM9XClbOxwoATuFdOE7ZE6wHL+HQ5T8hoPM+zHvmKzzsEqhgy0GrQ5X13afiQ==
+
+eventemitter3@^4.0.7:
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
+  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
+
+events@^3.2.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
+  integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
+
+execa@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/execa/-/execa-5.0.0.tgz#4029b0007998a841fbd1032e5f4de86a3c1e3376"
+  integrity sha512-ov6w/2LCiuyO4RLYGdpFGjkcs0wMTgGE8PrkTHikeUy5iJekXyPIKUjifk5CsE0pt7sMCrMZ3YNqoCj6idQOnQ==
+  dependencies:
+    cross-spawn "^7.0.3"
+    get-stream "^6.0.0"
+    human-signals "^2.1.0"
+    is-stream "^2.0.0"
+    merge-stream "^2.0.0"
+    npm-run-path "^4.0.1"
+    onetime "^5.1.2"
+    signal-exit "^3.0.3"
+    strip-final-newline "^2.0.0"
+
+fast-deep-equal@^3.1.1:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fastest-levenshtein@^1.0.12:
+  version "1.0.12"
+  resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.12.tgz#9990f7d3a88cc5a9ffd1f1745745251700d497e2"
+  integrity sha512-On2N+BpYJ15xIC974QNVuYGMOlEVt4s0EOI3wwMqOmK1fdDY+FN/zltPV8vosq4ad4c/gJ1KHScUn/6AWIgiow==
+
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
+function-bind@^1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d"
+  integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==
+
+get-stream@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.0.tgz#3e0012cb6827319da2706e601a1583e8629a6718"
+  integrity sha512-A1B3Bh1UmL0bidM/YX2NsCOTnGJePL9rO/M+Mw3m9f2gUpfokS0hi5Eah0WSUEWZdZhIZtMjkIYS7mDfOqNHbg==
+
+glob-to-regexp@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
+  integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
+
+graceful-fs@^4.1.2, graceful-fs@^4.2.4:
+  version "4.2.6"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.6.tgz#ff040b2b0853b23c3d31027523706f1885d76bee"
+  integrity sha512-nTnJ528pbqxYanhpDYsi4Rd8MAeaBA67+RZ10CM1m3bTAVFEDcd5AuA4a6W5YkGZ1iNXHzZz8T6TBKLeBuNriQ==
+
+gzip-size@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462"
+  integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==
+  dependencies:
+    duplexer "^0.1.2"
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796"
+  integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==
+  dependencies:
+    function-bind "^1.1.1"
+
+human-signals@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0"
+  integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==
+
+import-local@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.0.2.tgz#a8cfd0431d1de4a2199703d003e3e62364fa6db6"
+  integrity sha512-vjL3+w0oulAVZ0hBHnxa/Nm5TAurf9YLQJDhqRZyqb+VKGOB6LU8t9H1Nr5CIo16vh9XfJTOoHwU0B71S557gA==
+  dependencies:
+    pkg-dir "^4.2.0"
+    resolve-cwd "^3.0.0"
+
[email protected]:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
+  integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
+
+inherits@~2.0.3:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+interpret@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-2.2.0.tgz#1a78a0b5965c40a5416d007ad6f50ad27c417df9"
+  integrity sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==
+
+is-core-module@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.2.0.tgz#97037ef3d52224d85163f5597b2b63d9afed981a"
+  integrity sha512-XRAfAdyyY5F5cOXn7hYQDqh2Xmii+DEfIcQGxK/uNwMHhIkPWO0g8msXcbzLe+MpGoR951MlqM/2iIlU4vKDdQ==
+  dependencies:
+    has "^1.0.3"
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-plain-object@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
+  dependencies:
+    isobject "^3.0.1"
+
+is-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.0.tgz#bde9c32680d6fae04129d6ac9d921ce7815f78e3"
+  integrity sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw==
+
+isarray@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
+  integrity sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+
+isobject@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+  integrity sha1-TkMekrEalzFjaqH5yNHMvP2reN8=
+
+jest-worker@^26.6.2:
+  version "26.6.2"
+  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-26.6.2.tgz#7f72cbc4d643c365e27b9fd775f9d0eaa9c7a8ed"
+  integrity sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==
+  dependencies:
+    "@types/node" "*"
+    merge-stream "^2.0.0"
+    supports-color "^7.0.0"
+
+json-parse-better-errors@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
+  integrity sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json5@^2.1.2:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.0.tgz#2dfefe720c6ba525d9ebd909950f0515316c89a3"
+  integrity sha512-f+8cldu7X/y7RAJurMEJmdoKXGB/X550w2Nr3tTbezL6RwEE/iMcm+tZnXeoZtKuOq6ft8+CqzEkrIgx1fPoQA==
+  dependencies:
+    minimist "^1.2.5"
+
+kind-of@^6.0.2:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
+
+loader-runner@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.2.0.tgz#d7022380d66d14c5fb1d496b89864ebcfd478384"
+  integrity sha512-92+huvxMvYlMzMt0iIOukcwYBFpkYJdpl2xsZ7LrlayO7E8SOv+JJUEK17B/dJIHAOLMfh2dZZ/Y18WgmGtYNw==
+
+loader-utils@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.0.tgz#e4cace5b816d425a166b5f097e10cd12b36064b0"
+  integrity sha512-rP4F0h2RaWSvPEkD7BLDFQnvSf+nK+wr3ESUjNTyAGobqrijmW92zc+SO6d4p4B1wh7+B/Jg1mkQe5NYUEHtHQ==
+  dependencies:
+    big.js "^5.2.2"
+    emojis-list "^3.0.0"
+    json5 "^2.1.2"
+
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
+lodash@^4.17.20:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
+lower-case@^2.0.2:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28"
+  integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==
+  dependencies:
+    tslib "^2.0.3"
+
+lru-cache@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94"
+  integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==
+  dependencies:
+    yallist "^4.0.0"
+
+memory-fs@^0.5.0:
+  version "0.5.0"
+  resolved "https://registry.yarnpkg.com/memory-fs/-/memory-fs-0.5.0.tgz#324c01288b88652966d161db77838720845a8e3c"
+  integrity sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==
+  dependencies:
+    errno "^0.1.3"
+    readable-stream "^2.0.1"
+
+merge-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
+
+micromatch@^4.0.0:
+  version "4.0.2"
+  resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.2.tgz#4fcb0999bf9fbc2fcbdd212f6d629b9a56c39259"
+  integrity sha512-y7FpHSbMUMoyPbYUSzO6PaZ6FyRnQOpHuKwbo1G+Knck95XVU4QAiKdGEnj5wwoS7PlOgthX/09u5iFJ+aYf5Q==
+  dependencies:
+    braces "^3.0.1"
+    picomatch "^2.0.5"
+
[email protected]:
+  version "1.46.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.46.0.tgz#6267748a7f799594de3cbc8cde91def349661cee"
+  integrity sha512-svXaP8UQRZ5K7or+ZmfNhg2xX3yKDMUzqadsSqi4NCH/KomcH75MAMYAGVlvXn4+b/xOPhS3I2uHKRUzvjY7BQ==
+
+mime-types@^2.1.27:
+  version "2.1.29"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.29.tgz#1d4ab77da64b91f5f72489df29236563754bb1b2"
+  integrity sha512-Y/jMt/S5sR9OaqteJtslsFZKWOIIqMACsJSiHghlCAyhf7jfVYjKBmLiX8OgpWeW+fjJ2b+Az69aPFPkUOY6xQ==
+  dependencies:
+    mime-db "1.46.0"
+
+mime@^2.3.1:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.5.2.tgz#6e3dc6cc2b9510643830e5f19d5cb753da5eeabe"
+  integrity sha512-tqkh47FzKeCPD2PUiPB6pkbMzsCasjxAfC62/Wap5qrUWcb+sFasXUC5I3gYM5iBM8v/Qpn4UK0x+j0iHyFPDg==
+
+mimic-fn@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b"
+  integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==
+
+minimist@^1.2.5:
+  version "1.2.5"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
+  integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
+
[email protected]:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+neo-async@^2.6.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
+  integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
+
+no-case@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d"
+  integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==
+  dependencies:
+    lower-case "^2.0.2"
+    tslib "^2.0.3"
+
+node-releases@^1.1.70:
+  version "1.1.71"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.71.tgz#cb1334b179896b1c89ecfdd4b725fb7bbdfc7dbb"
+  integrity sha512-zR6HoT6LrLCRBwukmrVbHv0EpEQjksO6GmFcZQQuCAy139BEsoVKPYnf3jongYW83fAa1torLGYwxxky/p28sg==
+
+npm-run-path@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea"
+  integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==
+  dependencies:
+    path-key "^3.0.0"
+
+onetime@^5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e"
+  integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==
+  dependencies:
+    mimic-fn "^2.1.0"
+
+opener@^1.5.2:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598"
+  integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==
+
+p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  dependencies:
+    p-try "^2.0.0"
+
+p-limit@^3.1.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+  integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
+  dependencies:
+    yocto-queue "^0.1.0"
+
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
+p-try@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-key@^3.0.0, path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
+path-parse@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.6.tgz#d62dbb5679405d72c4737ec58600e9ddcf06d24c"
+  integrity sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
+
+path@^0.12.7:
+  version "0.12.7"
+  resolved "https://registry.yarnpkg.com/path/-/path-0.12.7.tgz#d4dc2a506c4ce2197eb481ebfcd5b36c0140b10f"
+  integrity sha1-1NwqUGxM4hl+tIHr/NWzbAFAsQ8=
+  dependencies:
+    process "^0.11.1"
+    util "^0.10.3"
+
+picomatch@^2.0.5:
+  version "2.2.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
+  integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
+
+pkg-dir@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+postmate@^1.5.2:
+  version "1.5.2"
+  resolved "https://registry.yarnpkg.com/postmate/-/postmate-1.5.2.tgz#d59a78b3780023c5d32225fd40633b364958bdb3"
+  integrity sha512-EHLlEmrUA/hALls49oBrtE7BzDXXjB9EiO4MZpsoO3R/jRuBmD+2WKQuYAbeuVEpTzrPpUTT79z2cz4qaFgPRg==
+
+process-nextick-args@~2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
+  integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
+
+process@^0.11.1:
+  version "0.11.10"
+  resolved "https://registry.yarnpkg.com/process/-/process-0.11.10.tgz#7332300e840161bda3e69a1d1d91a7d4bc16f182"
+  integrity sha1-czIwDoQBYb2j5podHZGn1LwW8YI=
+
+prr@~1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476"
+  integrity sha1-0/wRS6BplaRexok/SEzrHXj19HY=
+
+punycode@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+randombytes@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
+  integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
+  dependencies:
+    safe-buffer "^5.1.0"
+
+readable-stream@^2.0.1:
+  version "2.3.7"
+  resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
+  integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
+  dependencies:
+    core-util-is "~1.0.0"
+    inherits "~2.0.3"
+    isarray "~1.0.0"
+    process-nextick-args "~2.0.0"
+    safe-buffer "~5.1.1"
+    string_decoder "~1.1.1"
+    util-deprecate "~1.0.1"
+
+rechoir@^0.7.0:
+  version "0.7.0"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.7.0.tgz#32650fd52c21ab252aa5d65b19310441c7e03aca"
+  integrity sha512-ADsDEH2bvbjltXEP+hTIAmeFekTFK0V2BTxMkok6qILyAJEXV0AFfoWcAq4yfll5VdIMd/RVXq0lR+wQi5ZU3Q==
+  dependencies:
+    resolve "^1.9.0"
+
+resolve-cwd@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"
+  integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==
+  dependencies:
+    resolve-from "^5.0.0"
+
+resolve-from@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
+  integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
+
+resolve@^1.9.0:
+  version "1.20.0"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.20.0.tgz#629a013fb3f70755d6f0b7935cc1c2c5378b1975"
+  integrity sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
+  dependencies:
+    is-core-module "^2.2.0"
+    path-parse "^1.0.6"
+
+safe-buffer@^5.1.0:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+safe-buffer@~5.1.0, safe-buffer@~5.1.1:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d"
+  integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==
+
+schema-utils@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.0.0.tgz#67502f6aa2b66a2d4032b4279a2944978a0913ef"
+  integrity sha512-6D82/xSzO094ajanoOSbe4YvXWMfn2A//8Y1+MUqFAJul5Bs+yn36xbK9OtNDcRVSBJ9jjeoXftM6CfztsjOAA==
+  dependencies:
+    "@types/json-schema" "^7.0.6"
+    ajv "^6.12.5"
+    ajv-keywords "^3.5.2"
+
+semver@^7.3.4:
+  version "7.3.4"
+  resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.4.tgz#27aaa7d2e4ca76452f98d3add093a72c943edc97"
+  integrity sha512-tCfb2WLjqFAtXn4KEdxIhalnRtoKFN7nAwj0B3ZXCbQloV2tq5eDbcTmT68JJD3nRJq24/XgxtQKFIpQdtvmVw==
+  dependencies:
+    lru-cache "^6.0.0"
+
+serialize-javascript@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-5.0.1.tgz#7886ec848049a462467a97d3d918ebb2aaf934f4"
+  integrity sha512-SaaNal9imEO737H2c05Og0/8LUXG7EnsZyMa8MzkmuHoELfT6txuj0cMqRj6zfPKnmQ1yasR4PCJc8x+M4JSPA==
+  dependencies:
+    randombytes "^2.1.0"
+
+shallow-clone@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
+  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
+  dependencies:
+    kind-of "^6.0.2"
+
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+signal-exit@^3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
+  integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
+
+sirv@^1.0.7:
+  version "1.0.11"
+  resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.11.tgz#81c19a29202048507d6ec0d8ba8910fda52eb5a4"
+  integrity sha512-SR36i3/LSWja7AJNRBz4fF/Xjpn7lQFI30tZ434dIy+bitLYSP+ZEenHg36i23V2SGEz+kqjksg0uOGZ5LPiqg==
+  dependencies:
+    "@polka/url" "^1.0.0-next.9"
+    mime "^2.3.1"
+    totalist "^1.0.0"
+
+snake-case@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"
+  integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==
+  dependencies:
+    dot-case "^3.0.4"
+    tslib "^2.0.3"
+
+source-list-map@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/source-list-map/-/source-list-map-2.0.1.tgz#3993bd873bfc48479cca9ea3a547835c7c154b34"
+  integrity sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==
+
+source-map-support@~0.5.19:
+  version "0.5.19"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.19.tgz#a98b62f86dcaf4f67399648c085291ab9e8fed61"
+  integrity sha512-Wonm7zOCIJzBGQdB+thsPar0kYuCIzYvxZwlBa87yi/Mdjv7Tip2cyVbLj5o0cFPN4EVkuTwb3GDDyUx2DGnGw==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map@^0.6.0, source-map@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+source-map@~0.7.2:
+  version "0.7.3"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383"
+  integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ==
+
+string_decoder@~1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8"
+  integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==
+  dependencies:
+    safe-buffer "~5.1.0"
+
+strip-final-newline@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad"
+  integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==
+
+supports-color@^7.0.0, supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
+tapable@^1.0.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
+  integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
+
+tapable@^2.1.1, tapable@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.0.tgz#5c373d281d9c672848213d0e037d1c4165ab426b"
+  integrity sha512-FBk4IesMV1rBxX2tfiK8RAmogtWn53puLOQlvO8XuwlgxcYbP4mVPS9Ph4aeamSyyVjOl24aYWAuc8U5kCVwMw==
+
+terser-webpack-plugin@^5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.1.1.tgz#7effadee06f7ecfa093dbbd3e9ab23f5f3ed8673"
+  integrity sha512-5XNNXZiR8YO6X6KhSGXfY0QrGrCRlSwAEjIIrlRQR4W8nP69TaJUlh3bkuac6zzgspiGPfKEHcY295MMVExl5Q==
+  dependencies:
+    jest-worker "^26.6.2"
+    p-limit "^3.1.0"
+    schema-utils "^3.0.0"
+    serialize-javascript "^5.0.1"
+    source-map "^0.6.1"
+    terser "^5.5.1"
+
+terser@^5.5.1:
+  version "5.6.0"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.6.0.tgz#138cdf21c5e3100b1b3ddfddf720962f88badcd2"
+  integrity sha512-vyqLMoqadC1uR0vywqOZzriDYzgEkNJFK4q9GeyOBHIbiECHiWLKcWfbQWAUaPfxkjDhapSlZB9f7fkMrvkVjA==
+  dependencies:
+    commander "^2.20.0"
+    source-map "~0.7.2"
+    source-map-support "~0.5.19"
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+totalist@^1.0.0:
+  version "1.1.0"
+  resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df"
+  integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g==
+
+ts-loader@^8.0.17:
+  version "8.0.17"
+  resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-8.0.17.tgz#98f2ccff9130074f4079fd89b946b4c637b1f2fc"
+  integrity sha512-OeVfSshx6ot/TCxRwpBHQ/4lRzfgyTkvi7ghDVrLXOHzTbSK413ROgu/xNqM72i3AFeAIJgQy78FwSMKmOW68w==
+  dependencies:
+    chalk "^4.1.0"
+    enhanced-resolve "^4.0.0"
+    loader-utils "^2.0.0"
+    micromatch "^4.0.0"
+    semver "^7.3.4"
+
+tslib@^1.9.0:
+  version "1.14.1"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00"
+  integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==
+
+tslib@^2.0.3:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.1.0.tgz#da60860f1c2ecaa5703ab7d39bc05b6bf988b97a"
+  integrity sha512-hcVC3wYEziELGGmEEXue7D75zbwIIVUMWAVbHItGPx0ziyXxrOMQx4rQEVEV45Ut/1IotuEvwqPopzIOkDMf0A==
+
+typescript@^4.2.2:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.2.2.tgz#1450f020618f872db0ea17317d16d8da8ddb8c4c"
+  integrity sha512-tbb+NVrLfnsJy3M59lsDgrzWIflR4d4TIUjz+heUnHZwdF7YsrMTKoRERiIvI2lvBG95dfpLxB21WZhys1bgaQ==
+
+uri-js@^4.2.2:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
+  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
+  dependencies:
+    punycode "^2.1.0"
+
+util-deprecate@~1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"
+  integrity sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
+
+util@^0.10.3:
+  version "0.10.4"
+  resolved "https://registry.yarnpkg.com/util/-/util-0.10.4.tgz#3aa0125bfe668a4672de58857d3ace27ecb76901"
+  integrity sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==
+  dependencies:
+    inherits "2.0.3"
+
+v8-compile-cache@^2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.2.0.tgz#9471efa3ef9128d2f7c6a7ca39c4dd6b5055b132"
+  integrity sha512-gTpR5XQNKFwOd4clxfnhaqvfqMpqEwr4tOtCyz4MtYZX2JYhfr1JvBFKdS+7K/9rfpZR3VLX+YWBbKoxCgS43Q==
+
+watchpack@^2.0.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.1.1.tgz#e99630550fca07df9f90a06056987baa40a689c7"
+  integrity sha512-Oo7LXCmc1eE1AjyuSBmtC3+Wy4HcV8PxWh2kP6fOl8yTlNS7r0K9l1ao2lrrUza7V39Y3D/BbJgY8VeSlc5JKw==
+  dependencies:
+    glob-to-regexp "^0.4.1"
+    graceful-fs "^4.1.2"
+
+webpack-bundle-analyzer@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.4.0.tgz#74013106e7e2b07cbd64f3a5ae847f7e814802c7"
+  integrity sha512-9DhNa+aXpqdHk8LkLPTBU/dMfl84Y+WE2+KnfI6rSpNRNVKa0VGLjPd2pjFubDeqnWmulFggxmWBxhfJXZnR0g==
+  dependencies:
+    acorn "^8.0.4"
+    acorn-walk "^8.0.0"
+    chalk "^4.1.0"
+    commander "^6.2.0"
+    gzip-size "^6.0.0"
+    lodash "^4.17.20"
+    opener "^1.5.2"
+    sirv "^1.0.7"
+    ws "^7.3.1"
+
+webpack-cli@^4.5.0:
+  version "4.5.0"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-4.5.0.tgz#b5213b84adf6e1f5de6391334c9fa53a48850466"
+  integrity sha512-wXg/ef6Ibstl2f50mnkcHblRPN/P9J4Nlod5Hg9HGFgSeF8rsqDGHJeVe4aR26q9l62TUJi6vmvC2Qz96YJw1Q==
+  dependencies:
+    "@discoveryjs/json-ext" "^0.5.0"
+    "@webpack-cli/configtest" "^1.0.1"
+    "@webpack-cli/info" "^1.2.2"
+    "@webpack-cli/serve" "^1.3.0"
+    colorette "^1.2.1"
+    commander "^7.0.0"
+    enquirer "^2.3.6"
+    execa "^5.0.0"
+    fastest-levenshtein "^1.0.12"
+    import-local "^3.0.2"
+    interpret "^2.2.0"
+    rechoir "^0.7.0"
+    v8-compile-cache "^2.2.0"
+    webpack-merge "^5.7.3"
+
+webpack-merge@^5.7.3:
+  version "5.7.3"
+  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.7.3.tgz#2a0754e1877a25a8bbab3d2475ca70a052708213"
+  integrity sha512-6/JUQv0ELQ1igjGDzHkXbVDRxkfA57Zw7PfiupdLFJYrgFqY5ZP8xxbpp2lU3EPwYx89ht5Z/aDkD40hFCm5AA==
+  dependencies:
+    clone-deep "^4.0.1"
+    wildcard "^2.0.0"
+
+webpack-sources@^2.1.1:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-2.2.0.tgz#058926f39e3d443193b6c31547229806ffd02bac"
+  integrity sha512-bQsA24JLwcnWGArOKUxYKhX3Mz/nK1Xf6hxullKERyktjNMC4x8koOeaDNTA2fEJ09BdWLbM/iTW0ithREUP0w==
+  dependencies:
+    source-list-map "^2.0.1"
+    source-map "^0.6.1"
+
+webpack@^5.24.3:
+  version "5.24.3"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.24.3.tgz#6ec0f5059f8d7c7961075fa553cfce7b7928acb3"
+  integrity sha512-x7lrWZ7wlWAdyKdML6YPvfVZkhD1ICuIZGODE5SzKJjqI9A4SpqGTjGJTc6CwaHqn19gGaoOR3ONJ46nYsn9rw==
+  dependencies:
+    "@types/eslint-scope" "^3.7.0"
+    "@types/estree" "^0.0.46"
+    "@webassemblyjs/ast" "1.11.0"
+    "@webassemblyjs/wasm-edit" "1.11.0"
+    "@webassemblyjs/wasm-parser" "1.11.0"
+    acorn "^8.0.4"
+    browserslist "^4.14.5"
+    chrome-trace-event "^1.0.2"
+    enhanced-resolve "^5.7.0"
+    es-module-lexer "^0.4.0"
+    eslint-scope "^5.1.1"
+    events "^3.2.0"
+    glob-to-regexp "^0.4.1"
+    graceful-fs "^4.2.4"
+    json-parse-better-errors "^1.0.2"
+    loader-runner "^4.2.0"
+    mime-types "^2.1.27"
+    neo-async "^2.6.2"
+    schema-utils "^3.0.0"
+    tapable "^2.1.1"
+    terser-webpack-plugin "^5.1.1"
+    watchpack "^2.0.0"
+    webpack-sources "^2.1.1"
+
+which@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
+
+wildcard@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec"
+  integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw==
+
+ws@^7.3.1:
+  version "7.4.4"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-7.4.4.tgz#383bc9742cb202292c9077ceab6f6047b17f2d59"
+  integrity sha512-Qm8k8ojNQIMx7S+Zp8u/uHOx7Qazv3Yv4q68MiWWWOJhiwG5W3x7iqmRtJo8xxrciZUY4vRxUTJCKuRnF28ZZw==
+
+yallist@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72"
+  integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==
+
+yocto-queue@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+  integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==

+ 132 - 0
src/main/api.cljs

@@ -1,10 +1,137 @@
 (ns ^:no-doc api
   (:require [frontend.db :as db]
+            [frontend.db.model :as db-model]
+            [frontend.handler.block :as block-handler]
+            [frontend.util :as util]
+            [electron.ipc :as ipc]
+            [promesa.core :as p]
+            [camel-snake-kebab.core :as csk]
+            [cljs-bean.core :as bean]
             [frontend.state :as state]
+            [frontend.components.plugins :as plugins]
+            [frontend.handler.plugin :as plugin-handler]
+            [frontend.handler.notification :as notification]
             [datascript.core :as d]
+            [frontend.fs :as fs]
+            [clojure.string :as string]
+            [clojure.walk :as walk]
             [cljs.reader]
+            [reitit.frontend.easy :as rfe]
             [frontend.db.query-dsl :as query-dsl]))
 
+;; base
+(def ^:export show_themes
+  (fn []
+    (plugins/open-select-theme!)))
+
+(def ^:export set_theme_mode
+  (fn [mode]
+    (state/set-theme! (if (= mode "light") "white" "dark"))))
+
+(def ^:export load_plugin_config
+  (fn [path]
+    (fs/read-file "" (util/node-path.join path "package.json"))))
+
+(def ^:export load_plugin_readme
+  (fn [path]
+    (fs/read-file "" (util/node-path.join path "readme.md"))))
+
+(def ^:export save_plugin_config
+  (fn [path ^js data]
+    (let [repo ""
+          path (util/node-path.join path "package.json")]
+      (fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true}))))
+
+(def ^:export write_user_tmp_file
+  (fn [file content]
+    (p/let [repo ""
+            path (plugin-handler/get-ls-dotdir-root)
+            path (util/node-path.join path "tmp")
+            exist? (fs/file-exists? path "")
+            _ (when-not exist? (fs/mkdir! path))
+            path (util/node-path.join path file)
+            _ (fs/write-file! repo "" path content {:skip-mtime? true})]
+      path)))
+
+(def ^:export load_user_preferences
+  (fn []
+    (p/let [repo ""
+            path (plugin-handler/get-ls-dotdir-root)
+            path (util/node-path.join path "preferences.json")
+            _ (fs/create-if-not-exists repo "" path)
+            json (fs/read-file "" path)
+            json (if (string/blank? json) "{}" json)]
+      (js/JSON.parse json))))
+
+(def ^:export save_user_preferences
+  (fn [^js data]
+    (when data
+      (p/let [repo ""
+              path (plugin-handler/get-ls-dotdir-root)
+              path (util/node-path.join path "preferences.json")]
+        (fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true})))))
+
+(def ^:export load_plugin_user_settings
+  (fn [key]
+    (p/let [repo ""
+            path (plugin-handler/get-ls-dotdir-root)
+            exist? (fs/file-exists? path "settings")
+            _ (when-not exist? (fs/mkdir! (util/node-path.join path "settings")))
+            path (util/node-path.join path "settings" (str key ".json"))
+            _ (fs/create-if-not-exists repo "" path "{}")
+            json (fs/read-file "" path)]
+      [path (js/JSON.parse json)])))
+
+(def ^:export save_plugin_user_settings
+  (fn [key ^js data]
+    (p/let [repo ""
+            path (plugin-handler/get-ls-dotdir-root)
+            path (util/node-path.join path "settings" (str key ".json"))]
+      (fs/write-file! repo "" path (js/JSON.stringify data nil 2) {:skip-mtime? true}))))
+
+(def ^:export register_plugin_slash_command
+  (fn [pid ^js cmd-actions]
+    (when-let [[cmd actions] (bean/->clj cmd-actions)]
+      (plugin-handler/register-plugin-slash-command
+        pid [cmd (mapv #(into [(keyword (first %))]
+                              (rest %)) actions)]))))
+
+(def ^:export register_plugin_simple_command
+  (fn [pid ^js cmd-action]
+    (when-let [[cmd action] (bean/->clj cmd-action)]
+      (plugin-handler/register-plugin-simple-command
+        pid cmd (assoc action 0 (keyword (first action)))))))
+
+;; app
+(def ^:export push_state
+  (fn [^js k ^js params]
+    (rfe/push-state
+      (keyword k) (bean/->clj params))))
+
+(def ^:export replace_state
+  (fn [^js k ^js params]
+    (rfe/replace-state
+      (keyword k) (bean/->clj params))))
+
+;; editor
+(def ^:export get_current_page_blocks_tree
+  (fn []
+    (when-let [page (state/get-current-page)]
+      (let [blocks (db-model/get-page-blocks-no-cache page)
+            blocks (mapv #(-> %
+                              (dissoc :block/children)
+                              (assoc :block/uuid (str (:block/uuid %))))
+                         blocks)
+            blocks (block-handler/blocks->vec-tree blocks)
+            ;; clean key
+            blocks (walk/postwalk
+                     (fn [a]
+                       (if (keyword? a)
+                         (csk/->camelCase (name a))
+                         a)) blocks)]
+        (bean/->js blocks)))))
+
+;; db
 (defn ^:export q
   [query-string]
   (when-let [repo (state/get-current-repo)]
@@ -21,3 +148,8 @@
         (clj->js result)))))
 
 (def ^:export custom_query db/custom-query)
+
+;; helpers
+(defn ^:export show_msg
+  ([content] (show_msg content :success))
+  ([content status] (notification/show! content (keyword status))))

+ 154 - 0
src/main/frontend/components/plugins.cljs

@@ -0,0 +1,154 @@
+(ns frontend.components.plugins
+  (:require [rum.core :as rum]
+            [frontend.state :as state]
+            [cljs-bean.core :as bean]
+            [frontend.ui :as ui]
+            [frontend.util :as util]
+            [electron.ipc :as ipc]
+            [promesa.core :as p]
+            [frontend.components.svg :as svg]
+            [frontend.handler.notification :as notification]
+            [frontend.handler.plugin :as plugin-handler]))
+
+(rum/defc installed-themes
+  < rum/reactive
+  []
+  (let [themes (state/sub :plugin/installed-themes)
+        selected (state/sub :plugin/selected-theme)]
+    [:div.cp__themes-installed
+     [:h2.mb-4.text-xl "Installed Themes"]
+     (for [opt themes]
+       (let [current-selected (= selected (:url opt))]
+         [:div.it.flex.px-3.py-2.mb-2.rounded-sm.justify-between
+          {:key   (:url opt)
+           :class [(if current-selected "selected")]
+           :on-click #(do (js/LSPluginCore.selectTheme (if current-selected nil (clj->js opt)))
+                          (state/set-modal! nil))}
+          [:section
+           [:strong.block (:name opt)]
+           [:small.opacity-30 (:description opt)]]
+          [:small.flex-shrink-0.flex.items-center.opacity-10
+           (if current-selected "current")]]))]))
+
+(rum/defc unpacked-plugin-loader
+  [unpacked-pkg-path]
+  (rum/use-effect!
+   (fn []
+     (let [err-handle
+           (fn [^js e]
+             (case (keyword (aget e "name"))
+               :IllegalPluginPackageError
+               (notification/show! "Illegal Logseq plugin package." :error)
+               :ExistedImportedPluginPackageError
+               (notification/show! "Existed Imported plugin package." :error)
+               :default)
+             (plugin-handler/reset-unpacked-state))
+           reg-handle #(plugin-handler/reset-unpacked-state)]
+       (when unpacked-pkg-path
+         (doto js/LSPluginCore
+           (.once "error" err-handle)
+           (.once "registered" reg-handle)
+           (.register (bean/->js {:url unpacked-pkg-path}))))
+       #(doto js/LSPluginCore
+          (.off "error" err-handle)
+          (.off "registered" reg-handle))))
+   [unpacked-pkg-path])
+
+  (when unpacked-pkg-path
+    [:strong.inline-flex.px-3 "Loading ..."]))
+
+(rum/defc simple-markdown-display
+  < rum/reactive
+  []
+  (let [content (state/sub :plugin/active-readme)]
+    [:textarea.p-1.bg-transparent.border-none
+     {:style {:width "700px" :min-height "60vw"}}
+     content]))
+
+(rum/defc plugin-item-card
+  [{:keys [id name settings version url description author icon usf] :as item}]
+  (let [disabled (:disabled settings)]
+    [:div.cp__plugins-item-card
+     [:div.l.link-block
+      {:on-click #(plugin-handler/open-readme! url simple-markdown-display)}
+      (if icon
+        [:img.icon {:src icon}]
+        svg/folder)]
+     [:div.r
+      [:h3.head.text-xl.font-bold.pt-1.5
+       {:on-click #(plugin-handler/open-readme! url simple-markdown-display)}
+       [:span name]
+       [:sup.inline-block.px-1.text-xs.opacity-30 version]]
+      [:div.desc.text-xs.opacity-60
+       [:p description]
+       [:small (js/JSON.stringify (bean/->js settings))]]
+      [:div.flag
+       [:p.text-xs.text-gray-300.pr-2.flex.justify-between.dark:opacity-40
+        [:small author]
+        [:small (str "ID: " id)]]]
+
+      [:div.ctl
+       [:div.l
+        [:div.de
+         [:strong svg/settings-sm]
+         [:ul.menu-list
+          [:li {:on-click #(if usf (js/apis.openPath usf))} "Open settings"]
+          [:li {:on-click
+                #(let [confirm-fn
+                       (ui/make-confirm-modal
+                        {:title      (str "Are you sure uninstall plugin - " name "?")
+                         :on-confirm (fn [_ {:keys [close-fn]}]
+                                       (close-fn)
+                                       (plugin-handler/unregister-plugin id))})]
+                   (state/set-modal! confirm-fn))}
+           "Uninstall plugin"]]]]
+
+       [:div.flex.items-center
+        [:small.de (if disabled "Disabled" "Enabled")]
+        (ui/toggle (not disabled)
+                   (fn []
+                     (js-invoke js/LSPluginCore (if disabled "enable" "disable") id))
+                   true)]]]]))
+
+(rum/defc installed-page
+  < rum/reactive
+  []
+  (let [installed-plugins (state/sub :plugin/installed-plugins)
+        selected-unpacked-pkg (state/sub :plugin/selected-unpacked-pkg)]
+    [:div.cp__plugins-page-installed
+     [:h1 "Installed Plugins"]
+     [:div.mb-6.flex.items-center.justify-between
+      (ui/button
+       "Load unpacked plugin"
+       :intent "logseq"
+       :on-click plugin-handler/load-unpacked-plugin)
+      (unpacked-plugin-loader selected-unpacked-pkg)
+      (when (util/electron?)
+        (ui/button
+         [:span.flex.items-center
+           ;;svg/settings-sm
+          "Open plugin preferences file"]
+         :intent "logseq"
+         :on-click (fn []
+                     (p/let [root (plugin-handler/get-ls-dotdir-root)]
+                       (js/apis.openPath (str root "/preferences.json"))))))]
+
+     [:div.cp__plugins-item-lists.grid-cols-1.md:grid-cols-2.lg:grid-cols-3
+      (for [[_ item] installed-plugins]
+        (rum/with-key (plugin-item-card item) (:id item)))]]))
+
+(defn open-select-theme!
+  []
+  (state/set-modal! installed-themes))
+
+(rum/defc hook-ui-slot
+  ([type payload] (hook-ui-slot type payload nil))
+  ([type payload opts]
+   (let [id (str "slot__" (util/rand-str 8))]
+     (rum/use-effect!
+       (fn []
+         (plugin-handler/hook-plugin-app type {:slot id :payload payload} nil)
+         #())
+       [])
+     [:div.lsp-hook-ui-slot
+      (merge opts {:id id})])))

+ 185 - 0
src/main/frontend/components/plugins.css

@@ -0,0 +1,185 @@
+.cp__plugins {
+  &-page-installed {
+    min-height: 60vh;
+    padding-top: 20px;
+
+    > h1 {
+      padding: 20px 0;
+      font-size: 38px;
+    }
+  }
+
+  &-item-lists {
+    @apply w-full grid grid-flow-row gap-3 pt-1;
+  }
+
+  &-item-card {
+    @apply flex py-3 px-1 rounded-md;
+
+    background-color: var(--ls-secondary-background-color);
+    height: 180px;
+
+    svg, .icon {
+      width: 70px;
+      height: 70px;
+      opacity: .8;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+
+    .head {
+      max-height: 60px;
+      overflow: hidden;
+
+      cursor: pointer;
+
+      &:active {
+        opacity: .8;
+      }
+    }
+
+    .desc {
+      height: 60px;
+      overflow: hidden;
+    }
+
+    .flag {
+      position: absolute;
+      bottom: 20px;
+      left: 0;
+      width: 100%;
+    }
+
+    > .l {
+      padding: 8px;
+    }
+
+    > .r {
+      flex: 1;
+      position: relative;
+
+      p {
+        @apply py-1 m-0;
+      }
+
+      .ctl {
+        @apply flex pl-2 items-center justify-between absolute w-full;
+
+        bottom: -8px;
+        right: 8px;
+
+        .de {
+          font-size: 10px;
+          padding: 5px 0;
+          padding-right: 10px;
+          border-radius: 2px;
+          user-select: none;
+          transition: none;
+          opacity: .2;
+          position: relative;
+
+          .menu-list {
+            @apply shadow-md rounded-sm absolute hidden list-none overflow-hidden m-0 p-0;
+
+            background-color: var(--ls-primary-background-color);
+            top: 20px;
+            left: 0;
+            min-width: 100px;
+
+            > li {
+              margin: 0;
+              padding: 3px;
+              transition: background-color .2s;
+              user-select: none;
+              opacity: .8;
+
+              &:hover {
+                background-color: var(--ls-quaternary-background-color);
+
+                &:active {
+                  opacity: 1;
+                }
+              }
+            }
+          }
+
+          &.err {
+            @apply text-red-500 opacity-100;
+          }
+
+          &.log {
+            padding: 5px;
+          }
+
+          svg {
+            width: 13px;
+            height: 13px;
+          }
+        }
+
+        > .l {
+          @apply flex items-center;
+
+          margin-left: -80px;
+
+          .de {
+            &:hover {
+              opacity: .9;
+
+              .menu-list {
+                display: block;
+              }
+            }
+          }
+        }
+      }
+    }
+  }
+}
+
+.cp__themes {
+  &-installed {
+    min-width: 480px;
+
+    > .it {
+      user-select: none;
+      cursor: pointer;
+      background-color: var(--ls-secondary-background-color);
+      border: 1px solid transparent;
+      transition: background-color .3s;
+
+      &:hover, &.selected {
+        background-color: var(--ls-quaternary-background-color);
+      }
+    }
+  }
+}
+
+.lsp-iframe-sandbox, .lsp-shadow-sandbox {
+  position: absolute;
+  top: 0;
+  left: 0;
+  z-index: -1;
+  visibility: hidden;
+  height: 0;
+  width: 0;
+  padding: 0;
+  margin: 0;
+
+  &.visible {
+    z-index: 1;
+    width: 100vw;
+    height: 100vh;
+    visibility: visible;
+  }
+}
+
+body {
+  &[data-page=page] {
+    .lsp-hook-ui-slot {
+      @apply flex items-center px-1 opacity-70;
+    }
+  }
+}

+ 181 - 0
src/main/frontend/handler/plugin.cljs

@@ -0,0 +1,181 @@
+(ns frontend.handler.plugin
+  (:require [promesa.core :as p]
+            [rum.core :as rum]
+            [frontend.util :as util]
+            [frontend.fs :as fs]
+            [frontend.handler.notification :as notifications]
+            [camel-snake-kebab.core :as csk]
+            [frontend.state :as state]
+            [medley.core :as md]
+            [electron.ipc :as ipc]
+            [cljs-bean.core :as bean]
+            [clojure.string :as string]))
+
+(defonce lsp-enabled? (util/electron?))
+
+;; state handlers
+(defn register-plugin
+  [pl]
+  (swap! state/state update-in [:plugin/installed-plugins] assoc (keyword (:id pl)) pl))
+
+(defn unregister-plugin
+  [id]
+  (js/LSPluginCore.unregister id))
+
+(defn host-mounted!
+  []
+  (and lsp-enabled? (js/LSPluginCore.hostMounted)))
+
+(defn register-plugin-slash-command
+  [pid [cmd actions]]
+  (prn (if-let [pid (keyword pid)]
+         (when (contains? (:plugin/installed-plugins @state/state) pid)
+           (do (swap! state/state update-in [:plugin/installed-commands pid]
+                      (fnil merge {}) (hash-map cmd (mapv #(conj % {:pid pid}) actions)))
+               true)))))
+
+(defn unregister-plugin-slash-command
+  [pid]
+  (swap! state/state md/dissoc-in [:plugin/installed-commands (keyword pid)]))
+
+(defn register-plugin-simple-command
+  ;; action => [:action-key :event-key]
+  [pid {:keys [key label type] :as cmd}  action]
+  (if-let [pid (keyword pid)]
+    (when (contains? (:plugin/installed-plugins @state/state) pid)
+      (do (swap! state/state update-in [:plugin/simple-commands pid]
+                 (fnil conj []) [type cmd action pid])
+          true))))
+
+(defn unregister-plugin-simple-command
+  [pid]
+  (swap! state/state md/dissoc-in [:plugin/simple-commands (keyword pid)]))
+
+(defn update-plugin-settings
+  [id settings]
+  (swap! state/state update-in [:plugin/installed-plugins id] assoc :settings settings))
+
+(defn open-readme!
+  [url display]
+  (when url
+    (-> (p/let [content (js/api.load_plugin_readme url)]
+          (state/set-state! :plugin/active-readme content)
+          (state/set-modal! display))
+        (p/catch #(notifications/show! "No README file." :warn)))))
+
+(defn load-unpacked-plugin
+  []
+  (if util/electron?
+    (p/let [path (ipc/ipc "openDialogSync")]
+      (when-not (:plugin/selected-unpacked-pkg @state/state)
+        (state/set-state! :plugin/selected-unpacked-pkg path)))))
+
+(defn reset-unpacked-state
+  []
+  (state/set-state! :plugin/selected-unpacked-pkg nil))
+
+(defn hook-plugin
+  [tag type payload plugin-id]
+  (when lsp-enabled?
+    (js-invoke js/LSPluginCore
+               (str "hook" (string/capitalize (name tag)))
+               (name type)
+               (if (map? payload)
+                 (bean/->js (into {} (for [[k v] payload] [(csk/->camelCase k) (if (uuid? v) (str v) v)]))))
+               (if (keyword? plugin-id) (name plugin-id) plugin-id))))
+
+(defn hook-plugin-app
+  ([type payload] (hook-plugin-app type payload nil))
+  ([type payload plugin-id] (hook-plugin :app type payload plugin-id)))
+
+(defn hook-plugin-editor
+  ([type payload] (hook-plugin-editor type payload nil))
+  ([type payload plugin-id] (hook-plugin :editor type payload plugin-id)))
+
+(defn get-ls-dotdir-root
+  []
+  (ipc/ipc "getLogseqDotDirRoot"))
+
+(defn- get-user-default-plugins
+  []
+  (p/catch
+   (p/let [files ^js (ipc/ipc "getUserDefaultPlugins")
+           files (js->clj files)]
+     (map #(hash-map :url %) files))
+   (fn [e]
+     (js/console.error e))))
+
+;; components
+(rum/defc lsp-indicator < rum/reactive
+  []
+  (let [text (state/sub :plugin/indicator-text)]
+    (if (= text "END")
+      [:span]
+      [:div
+       {:style
+        {:width           "100%"
+         :height          "100vh"
+         :display         "flex"
+         :align-items     "center"
+         :justify-content "center"}}
+       [:span
+        {:style
+         {:color     "#aaa"
+          :font-size "38px"}} (or text "Loading ...")]])))
+
+(defn init-plugins
+  [callback]
+
+  (let [el (js/document.createElement "div")]
+    (.appendChild js/document.body el)
+    (rum/mount
+     (lsp-indicator) el))
+
+  (state/set-state! :plugin/indicator-text "Loading...")
+
+  (p/then
+   (p/let [root (get-ls-dotdir-root)
+           _ (.setupPluginCore js/LSPlugin (bean/->js {:localUserConfigRoot root}))
+           _ (doto js/LSPluginCore
+               (.on "registered"
+                    (fn [^js pl]
+                      (register-plugin
+                       (bean/->clj (.parse js/JSON (.stringify js/JSON pl))))))
+
+               (.on "unregistered" (fn [pid]
+                                     (let [pid (keyword pid)]
+                                        ;; plugins
+                                       (swap! state/state md/dissoc-in [:plugin/installed-plugins (keyword pid)])
+                                        ;; commands
+                                       (unregister-plugin-slash-command pid))))
+
+               (.on "theme-changed" (fn [^js themes]
+                                      (swap! state/state assoc :plugin/installed-themes
+                                             (vec (mapcat (fn [[_ vs]] (bean/->clj vs)) (bean/->clj themes))))))
+
+               (.on "theme-selected" (fn [^js opts]
+                                       (let [opts (bean/->clj opts)
+                                             url (:url opts)
+                                             mode (:mode opts)]
+                                         (when mode (state/set-theme! mode))
+                                         (state/set-state! :plugin/selected-theme url))))
+
+               (.on "settings-changed" (fn [id ^js settings]
+                                         (let [id (keyword id)]
+                                           (when (and settings
+                                                      (contains? (:plugin/installed-plugins @state/state) id))
+                                             (update-plugin-settings id (bean/->clj settings)))))))
+
+           default-plugins (get-user-default-plugins)
+
+           _ (.register js/LSPluginCore (bean/->js (if (seq default-plugins) default-plugins [])) true)])
+   #(do
+      (state/set-state! :plugin/indicator-text "END")
+      (callback))))
+
+(defn setup!
+  "setup plugin core handler"
+  [callback]
+  (if (not lsp-enabled?)
+    (callback)
+    (init-plugins callback)))