Просмотр исходного кода

Merge pull request #3343 from weseek/master

release v4.2.6
Yuki Takei 5 лет назад
Родитель
Сommit
7137cdb9f9
67 измененных файлов с 1356 добавлено и 239 удалено
  1. 1 1
      .github/workflows/codeql-analysis.yml
  2. 18 0
      CHANGES.md
  3. 1 0
      config/env.dev.js
  4. 5 2
      package.json
  5. 13 0
      resource/locales/en_US/translation.json
  6. 13 0
      resource/locales/ja_JP/translation.json
  7. 13 0
      resource/locales/zh_CN/translation.json
  8. 0 2
      src/client/js/app.jsx
  9. 4 0
      src/client/js/boot.js
  10. 89 0
      src/client/js/components/ContentLinkButtons.jsx
  11. 17 1
      src/client/js/components/InstallerForm.jsx
  12. 3 0
      src/client/js/components/Page.jsx
  13. 2 2
      src/client/js/components/Page/DisplaySwitcher.jsx
  14. 2 2
      src/client/js/components/Page/TagEditModal.jsx
  15. 5 2
      src/client/js/components/Page/TagsInput.jsx
  16. 8 1
      src/client/js/components/PageCreateModal.jsx
  17. 2 1
      src/client/js/components/PageDuplicateModal.jsx
  18. 23 0
      src/client/js/components/PageEditor/CodeMirrorEditor.jsx
  19. 20 0
      src/client/js/components/PageEditor/EditorIcon.jsx
  20. 253 0
      src/client/js/components/PageEditor/GridEditModal.jsx
  21. 156 0
      src/client/js/components/PageEditor/GridEditorUtil.js
  22. 9 1
      src/client/js/components/PageEditor/HandsontableModal.jsx
  23. 9 10
      src/client/js/components/PageEditor/LinkEditModal.jsx
  24. 10 11
      src/client/js/components/PageEditor/MarkdownDrawioUtil.js
  25. 4 2
      src/client/js/components/PageEditor/MarkdownTableInterceptor.js
  26. 35 0
      src/client/js/components/PageEditor/OptionsSelector.jsx
  27. 4 1
      src/client/js/components/PagePathAutoComplete.jsx
  28. 2 1
      src/client/js/components/PageRenameModal.jsx
  29. 10 1
      src/client/js/components/StaffCredit/Contributor.js
  30. 0 54
      src/client/js/components/UserContentsLinks.jsx
  31. 34 0
      src/client/js/models/BootstrapGrid.js
  32. 2 0
      src/client/js/util/GrowiRenderer.js
  33. 10 0
      src/client/js/util/PreProcessor/EasyGrid.js
  34. 17 0
      src/client/js/util/old-ios.js
  35. 3 0
      src/client/styles/scss/_editor-navbar.scss
  36. 22 0
      src/client/styles/scss/_mixins.scss
  37. 9 0
      src/client/styles/scss/_old-ios.scss
  38. 50 4
      src/client/styles/scss/_on-edit.scss
  39. 5 5
      src/client/styles/scss/_toc.scss
  40. 1 4
      src/client/styles/scss/_wiki.scss
  41. 1 0
      src/client/styles/scss/style-app.scss
  42. 7 0
      src/client/styles/scss/theme/_apply-colors-dark.scss
  43. 8 0
      src/client/styles/scss/theme/_apply-colors-light.scss
  44. 34 0
      src/client/styles/scss/theme/_apply-colors.scss
  45. 1 0
      src/client/styles/scss/theme/antarctic.scss
  46. 1 11
      src/client/styles/scss/theme/christmas.scss
  47. 2 0
      src/client/styles/scss/theme/default.scss
  48. 1 0
      src/client/styles/scss/theme/future.scss
  49. 1 0
      src/client/styles/scss/theme/halloween.scss
  50. 1 6
      src/client/styles/scss/theme/island.scss
  51. 3 0
      src/client/styles/scss/theme/kibela.scss
  52. 2 0
      src/client/styles/scss/theme/mono-blue.scss
  53. 1 0
      src/client/styles/scss/theme/nature.scss
  54. 1 0
      src/client/styles/scss/theme/spring.scss
  55. 1 0
      src/client/styles/scss/theme/wood.scss
  56. 68 0
      src/migrations/20200827045151-remove-layout-setting.js
  57. 2 0
      src/server/crowi/express-init.js
  58. 10 6
      src/server/crowi/index.js
  59. 12 4
      src/server/middlewares/application-installed.js
  60. 2 2
      src/server/middlewares/application-not-installed.js
  61. 11 0
      src/server/middlewares/promster.js
  62. 34 33
      src/server/routes/index.js
  63. 1 1
      src/server/routes/installer.js
  64. 14 20
      src/server/routes/page.js
  65. 20 17
      src/server/service/app.js
  66. 12 0
      src/server/service/config-loader.js
  67. 221 31
      yarn.lock

+ 1 - 1
.github/workflows/codeql-analysis.yml

@@ -17,7 +17,7 @@ on:
   pull_request:
     # The branches below must be a subset of the branches above
     branches: [ master ]
-  # schedule:
+  schedule:
     - cron: '28 20 * * 6'
 
 jobs:

+ 18 - 0
CHANGES.md

@@ -1,5 +1,23 @@
 # CHANGES
 
+## v4.2.6-RC
+
+* Feature: Add a button to jump to Comments section
+* Feature: Paste Bootstrap4 Grid HTML with GUI
+* Feature: Disable auto formating table option
+* Improvement: Layout of Edit Link Modal
+* Improvement: Focus to the first input when modal is opened
+* Improvement: Preview layout in edit mode
+* Improvement: Install process under redundant environment
+* Improvement: Add contributors
+* Fix: Upgrading to v4.x failed when the user uses Kibela Layout
+    * Introduced by v4.2.0
+* Fix: diagrams.net (draw.io) errors
+* Fix: Navbar is not rendered on old iOS
+* Support: Expose metrics with Promster
+* Support: Upgrade libs
+    * axios
+
 ## v4.2.5-RC
 
 * Improvement: Invoke garbage collection when reindex all pages by elasticsearch

+ 1 - 0
config/env.dev.js

@@ -21,4 +21,5 @@ module.exports = {
   // USER_UPPER_LIMIT: 0,
   // DEV_HTTPS: true,
   // FORCE_WIKI_MODE: 'private', // 'public', 'private', undefined
+  // PROMSTER_ENABLED: true,
 };

+ 5 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "4.2.5-RC",
+  "version": "4.2.6-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -74,12 +74,14 @@
     ],
     "@google-cloud/storage": "^3.3.0",
     "@kobalab/socket.io-session": "^1.0.3",
+    "@promster/express": "^5.0.1",
+    "@promster/server": "^6.0.0",
     "JSONStream": "^1.3.5",
     "archiver": "^3.1.1",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "aws-sdk": "^2.88.0",
-    "axios": "^0.19.0",
+    "axios": "^0.21.1",
     "body-parser": "^1.18.2",
     "bunyan": "^1.8.12",
     "bunyan-format": "^0.2.1",
@@ -136,6 +138,7 @@
     "passport-local": "^1.0.0",
     "passport-saml": "^1.0.0",
     "passport-twitter": "^1.0.4",
+    "prom-client": "^13.0.0",
     "react-card-flip": "^1.0.10",
     "react-image-crop": "^8.3.0",
     "reconnecting-websocket": "^4.4.0",

+ 13 - 0
resource/locales/en_US/translation.json

@@ -6,6 +6,10 @@
   "delete_all": "Delete all",
   "Duplicate": "Duplicate",
   "Copy": "Copy",
+  "preview":"Preview",
+  "desktop":"Desktop",
+  "phone":"Smartphone",
+  "tablet":"Tablet",
   "Click to copy": "Click to copy",
   "Move/Rename": "Move/Rename",
   "Moved": "Moved",
@@ -304,6 +308,7 @@
   },
   "page_edit": {
     "Show active line": "Show active line",
+    "auto_format_table": "Auto format table",
     "overwrite_scopes": "{{operation}} and Overwrite scopes of all descendants",
     "notice": {
       "conflict": "Couldn't save the changes you made because someone else was editing this page. Please re-edit the affected section after reloading the page."
@@ -803,6 +808,14 @@
     "complete_to_install2":"Complete to Install GROWI ! Please check each settings on this page first.",
     "failed_to_create_admin_user":"Failed to create admin user. {{errMessage}}"
   },
+  "grid_edit":{
+    "create_bootstrap_4_grid":"Create Bootstrap 4 Grid",
+    "grid_settings": "Grid Settings",
+    "grid_pattern":"Grid Pattern",
+    "division":"Divisions",
+    "smart_no":"Smartphone / No Break",
+    "break_point":"Break point by display size"
+  },
   "validation":{
     "aws_region": "For the region, enter the AWS region name. ex):us-east-1",
     "aws_custom_endpoint":"For the custom endpoint, specify the URL that starts with http(s)://. Also, the trailing slash is not required.",

+ 13 - 0
resource/locales/ja_JP/translation.json

@@ -6,6 +6,10 @@
   "delete_all": "全て削除",
   "Duplicate": "複製",
   "Copy": "コピー",
+  "preview":"プレビュー",
+  "desktop":"パソコン",
+  "phone":"スマホ",
+  "tablet":"タブレット",
   "Click to copy": "クリックでコピー",
   "Move/Rename": "移動/名前変更",
   "Moved": "移動しました",
@@ -306,6 +310,7 @@
   },
   "page_edit": {
     "Show active line": "アクティブ行をハイライト",
+    "auto_format_table": "表の自動整形",
     "overwrite_scopes": "{{operation}}と同時に全ての配下ページのスコープを上書き",
     "notice": {
       "conflict": "すでに他の人がこのページを編集していたため保存できませんでした。ページを再読み込み後、自分の編集箇所のみ再度編集してください。"
@@ -796,6 +801,14 @@
     "complete_to_install2":"GROWI のインストールが完了しました!はじめに、このページで各種設定を確認してください。",
     "failed_to_create_admin_user":"管理ユーザーの作成に失敗しました。{{errMessage}}"
   },
+  "grid_edit":{
+    "create_bootstrap_4_grid":"Bootstrap 4 グリッドを作成",
+    "grid_settings": "グリッド設定",
+    "grid_pattern":"グリッド パターン",
+    "division":"分割",
+    "smart_no":"スマホ / 分割なし",
+    "break_point":"画面サイズより分割"
+  },
   "validation":{
     "aws_region": "リージョンには、AWSリージョン名を入力してください。例: ap-northeast-1",
     "aws_custom_endpoint": "カスタムエンドポイントは、http(s)://で始まるURLを指定してください。また、末尾の/は不要です。",

+ 13 - 0
resource/locales/zh_CN/translation.json

@@ -6,6 +6,10 @@
 	"delete_all": "删除所有",
 	"Duplicate": "复制",
 	"Copy": "复制",
+  "preview":"预览",
+  "desktop":"电脑",
+  "phone":"手机",
+  "tablet":"平板",
 	"Login": "登录",
 	"Click to copy": "点击复制",
 	"Move/Rename": "移动/重命名",
@@ -282,6 +286,7 @@
 	},
 	"page_edit": {
 		"Show active line": "显示活动行",
+		"auto_format_table": "自动格式化表格",
 		"overwrite_scopes": "{{operation}和覆盖所有子体的作用域",
 		"notice": {
 			"conflict": "无法保存您所做的更改,因为其他人正在编辑此页。请在重新加载页面后重新编辑受影响的部分。"
@@ -807,6 +812,14 @@
 		"complete_to_install1": "完成安装GROWI!请以管理员帐户登录。",
 		"complete_to_install2": "完成安装GROWI!请先检查此页上的每个设置。",
 		"failed_to_create_admin_user": "无法创建管理用户。{{errMessage}"
+	},
+  "grid_edit":{
+    "create_bootstrap_4_grid":"创建Bootstrap 4网格",
+    "grid_settings": "网格设置",
+    "grid_pattern": "网格样式",
+    "division":"分割",
+    "smart_no":"手机/不分割",
+    "break_point":"按画面大小分割"
   },
   "validation":{
     "aws_region": "关于地区,请输入AWS地区名,例如:ap-east-1",

+ 0 - 2
src/client/js/app.jsx

@@ -31,7 +31,6 @@ import BookmarkList from './components/PageList/BookmarkList';
 import LikerList from './components/User/LikerList';
 import Fab from './components/Fab';
 import PersonalSettings from './components/Me/PersonalSettings';
-import UserContentsLinks from './components/UserContentsLinks';
 import GrowiSubNavigation from './components/Navbar/GrowiSubNavigation';
 import GrowiSubNavigationSwitcher from './components/Navbar/GrowiSubNavigationSwitcher';
 
@@ -114,7 +113,6 @@ if (pageContainer.state.pageId != null) {
 
     'recent-created-icon': <RecentlyCreatedIcon />,
     'user-bookmark-icon': <BookmarkIcon />,
-    'grw-user-contents-links': <UserContentsLinks />,
   });
 }
 if (pageContainer.state.creator != null) {

+ 4 - 0
src/client/js/boot.js

@@ -1,5 +1,9 @@
 import {
   applyColorScheme,
 } from './util/color-scheme';
+import {
+  applyOldIos,
+} from './util/old-ios';
 
 applyColorScheme();
+applyOldIos();

+ 89 - 0
src/client/js/components/ContentLinkButtons.jsx

@@ -0,0 +1,89 @@
+import React, { useMemo } from 'react';
+import PropTypes from 'prop-types';
+
+import NavigationContainer from '../services/NavigationContainer';
+import PageContainer from '../services/PageContainer';
+
+import { withUnstatedContainers } from './UnstatedUtils';
+
+import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
+
+const WIKI_HEADER_LINK = 120;
+
+/**
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ */
+const ContentLinkButtons = (props) => {
+
+  const { navigationContainer, pageContainer } = props;
+  const { pageUser } = pageContainer.state;
+  const { isPageExist } = pageContainer.state;
+
+  // get element for smoothScroll
+  const getCommentListDom = useMemo(() => { return document.getElementById('page-comments-list') }, []);
+  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
+  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
+
+
+  const CommentLinkButton = () => {
+    return (
+      <div className="mt-3">
+        <button
+          type="button"
+          className="btn btn-outline-secondary btn-sm btn-block"
+          onClick={() => navigationContainer.smoothScrollIntoView(getCommentListDom, WIKI_HEADER_LINK)}
+        >
+          <i className="mr-2 icon-fw icon-bubbles"></i>
+          <span>Comments</span>
+        </button>
+      </div>
+    );
+  };
+
+  const BookMarkLinkButton = () => {
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-2"
+        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="mr-2 icon-star"></i>
+        <span>Bookmarks</span>
+      </button>
+
+    );
+  };
+
+  const RecentlyCreatedLinkButton = () => {
+    return (
+      <button
+        type="button"
+        className="btn btn-outline-secondary btn-sm px-3"
+        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
+      >
+        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
+        <span>Recently Created</span>
+      </button>
+
+    );
+  };
+
+  return (
+    <>
+      {isPageExist && <CommentLinkButton />}
+
+      <div className="mt-3 d-flex justify-content-between">
+        {pageUser && <><BookMarkLinkButton /><RecentlyCreatedLinkButton /></>}
+      </div>
+    </>
+  );
+
+};
+
+ContentLinkButtons.propTypes = {
+  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
+  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
+};
+
+export default withUnstatedContainers(ContentLinkButtons, [NavigationContainer, PageContainer]);

+ 17 - 1
src/client/js/components/InstallerForm.jsx

@@ -13,9 +13,12 @@ class InstallerForm extends React.Component {
 
     this.state = {
       isValidUserName: true,
+      isSubmitButtonDisabled: false,
       selectedLang: {},
     };
     // this.checkUserName = this.checkUserName.bind(this);
+
+    this.clickHandler = this.clickHandler.bind(this);
   }
 
   componentWillMount() {
@@ -39,6 +42,13 @@ class InstallerForm extends React.Component {
     this.setState({ selectedLang: meta });
   }
 
+  clickHandler() {
+    this.setState({ isSubmitButtonDisabled: true });
+    setTimeout(() => {
+      this.setState({ isSubmitButtonDisabled: false });
+    }, 3000);
+  }
+
   render() {
     const hasErrorClass = this.state.isValidUserName ? '' : ' has-error';
     const unavailableUserId = this.state.isValidUserName
@@ -149,7 +159,13 @@ class InstallerForm extends React.Component {
             <input type="hidden" name="_csrf" value={this.props.csrf} />
 
             <div className="input-group mt-4 mb-3 d-flex justify-content-center">
-              <button type="submit" className="btn-fill btn btn-register" id="register">
+              <button
+                type="submit"
+                className="btn-fill btn btn-register"
+                id="register"
+                disabled={this.state.isSubmitButtonDisabled}
+                onClick={this.clickHandler}
+              >
                 <div className="eff"></div>
                 <span className="btn-label"><i className="icon-user-follow" /></span>
                 <span className="btn-label-text">{ this.props.t('Create') }</span>

+ 3 - 0
src/client/js/components/Page.jsx

@@ -11,6 +11,7 @@ import MarkdownTable from '../models/MarkdownTable';
 
 import LinkEditModal from './PageEditor/LinkEditModal';
 import RevisionRenderer from './Page/RevisionRenderer';
+import GridEditModal from './PageEditor/GridEditModal';
 import HandsontableModal from './PageEditor/HandsontableModal';
 import DrawioModal from './PageEditor/DrawioModal';
 import mtu from './PageEditor/MarkdownTableUtil';
@@ -30,6 +31,7 @@ class Page extends React.Component {
 
     this.growiRenderer = this.props.appContainer.getRenderer('page');
 
+    this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
@@ -139,6 +141,7 @@ class Page extends React.Component {
 
         { isLoggedIn && (
           <>
+            <GridEditModal ref={this.gridEditModal} />
             <LinkEditModal ref={this.LinkEditModal} />
             <HandsontableModal ref={this.handsontableModal} onSave={this.saveHandlerForHandsontableModal} />
             <DrawioModal ref={this.drawioModal} onSave={this.saveHandlerForDrawioModal} />

+ 2 - 2
src/client/js/components/Page/DisplaySwitcher.jsx

@@ -8,7 +8,7 @@ import Editor from '../PageEditor';
 import Page from '../Page';
 import UserInfo from '../User/UserInfo';
 import TableOfContents from '../TableOfContents';
-import UserContentsLinks from '../UserContentsLinks';
+import ContentLinkButtons from '../ContentLinkButtons';
 import PageAccessories from '../PageAccessories';
 import PageEditorByHackmd from '../PageEditorByHackmd';
 import EditorNavbarBottom from '../PageEditor/EditorNavbarBottom';
@@ -37,7 +37,7 @@ const DisplaySwitcher = (props) => {
                   <div id="revision-toc" className="revision-toc">
                     <TableOfContents />
                   </div>
-                  {pageUser && <UserContentsLinks />}
+                  <ContentLinkButtons />
                 </div>
               </div>
             </div>

+ 2 - 2
src/client/js/components/Page/TagEditModal.jsx

@@ -35,12 +35,12 @@ function TagEditModal(props) {
   }
 
   return (
-    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal">
+    <Modal isOpen={props.isOpen} toggle={closeModalHandler} id="edit-tag-modal" autoFocus={false}>
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-primary text-light">
         Edit Tags
       </ModalHeader>
       <ModalBody>
-        <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} />
+        <TagsInput tags={tags} onTagsUpdated={onTagsUpdatedByTagsInput} autoFocus />
       </ModalBody>
       <ModalFooter>
         <button type="button" className="btn btn-primary" onClick={handleSubmit}>

+ 5 - 2
src/client/js/components/Page/TagsInput.jsx

@@ -82,6 +82,7 @@ class TagsInput extends React.Component {
           options={this.state.resultTags} // Search result (Some tag names)
           placeholder="tag name"
           selectHintOnEnter
+          autoFocus={this.props.autoFocus}
         />
       </div>
     );
@@ -95,13 +96,15 @@ class TagsInput extends React.Component {
 const TagsInputWrapper = withUnstatedContainers(TagsInput, [AppContainer]);
 
 TagsInput.propTypes = {
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  appContainer:  PropTypes.instanceOf(AppContainer).isRequired,
 
-  tags: PropTypes.array.isRequired,
+  tags:          PropTypes.array.isRequired,
   onTagsUpdated: PropTypes.func.isRequired,
+  autoFocus:     PropTypes.bool,
 };
 
 TagsInput.defaultProps = {
+  autoFocus:     false,
 };
 
 export default TagsInputWrapper;

+ 8 - 1
src/client/js/components/PageCreateModal.jsx

@@ -166,6 +166,7 @@ const PageCreateModal = (props) => {
                     addTrailingSlash
                     onSubmit={ppacSubmitHandler}
                     onInputChange={ppacInputChangeHandler}
+                    autoFocus
                   />
                 )
                 : (
@@ -243,7 +244,13 @@ const PageCreateModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={navigationContainer.state.isPageCreateModalShown} toggle={navigationContainer.closePageCreateModal} className="grw-create-page">
+    <Modal
+      size="lg"
+      isOpen={navigationContainer.state.isPageCreateModalShown}
+      toggle={navigationContainer.closePageCreateModal}
+      className="grw-create-page"
+      autoFocus={false}
+    >
       <ModalHeader tag="h4" toggle={navigationContainer.closePageCreateModal} className="bg-primary text-light">
         { t('New Page') }
       </ModalHeader>

+ 2 - 1
src/client/js/components/PageDuplicateModal.jsx

@@ -120,7 +120,7 @@ const PageDuplicateModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-duplicate-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} className="grw-duplicate-page" autoFocus={false}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         { t('modal_duplicate.label.Duplicate page') }
       </ModalHeader>
@@ -141,6 +141,7 @@ const PageDuplicateModal = (props) => {
                   initializedPath={path}
                   onSubmit={ppacSubmitHandler}
                   onInputChange={ppacInputChangeHandler}
+                  autoFocus
                 />
               )
               : (

+ 23 - 0
src/client/js/components/PageEditor/CodeMirrorEditor.jsx

@@ -19,6 +19,8 @@ import MarkdownTableInterceptor from './MarkdownTableInterceptor';
 import mlu from './MarkdownLinkUtil';
 import mtu from './MarkdownTableUtil';
 import mdu from './MarkdownDrawioUtil';
+import geu from './GridEditorUtil';
+import GridEditModal from './GridEditModal';
 import LinkEditModal from './LinkEditModal';
 import HandsontableModal from './HandsontableModal';
 import EditorIcon from './EditorIcon';
@@ -74,6 +76,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
       additionalClassSet: new Set(),
     };
 
+    this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
     this.drawioModal = React.createRef();
@@ -102,6 +105,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     this.renderCheatsheetModalButton = this.renderCheatsheetModalButton.bind(this);
 
     this.makeHeaderHandler = this.makeHeaderHandler.bind(this);
+    this.showGridEditorHandler = this.showGridEditorHandler.bind(this);
     this.showLinkEditHandler = this.showLinkEditHandler.bind(this);
     this.showHandsonTableHandler = this.showHandsonTableHandler.bind(this);
     this.showDrawioHandler = this.showDrawioHandler.bind(this);
@@ -437,6 +441,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
     const context = {
       handlers: [], // list of handlers which process enter key
       editor: this,
+      editorOptions: this.props.editorOptions,
     };
 
     const interceptorManager = this.interceptorManager;
@@ -666,6 +671,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
     cm.focus();
   }
 
+  showGridEditorHandler() {
+    this.gridEditModal.current.show(geu.getGridHtml(this.getCodeMirror()));
+  }
+
   showLinkEditHandler() {
     this.linkEditModal.current.show(mlu.getMarkdownLink(this.getCodeMirror()));
   }
@@ -779,6 +788,15 @@ export default class CodeMirrorEditor extends AbstractEditor {
       >
         <EditorIcon icon="Image" />
       </Button>,
+      <Button
+        key="nav-item-grid"
+        color={null}
+        size="sm"
+        title="Grid"
+        onClick={this.showGridEditorHandler}
+      >
+        <EditorIcon icon="Grid" />
+      </Button>,
       <Button
         key="nav-item-table"
         color={null}
@@ -870,6 +888,10 @@ export default class CodeMirrorEditor extends AbstractEditor {
 
         { this.renderCheatsheetOverlay() }
 
+        <GridEditModal
+          ref={this.gridEditModal}
+          onSave={(grid) => { return geu.replaceGridWithHtmlWithEditor(this.getCodeMirror(), grid) }}
+        />
         <LinkEditModal
           ref={this.linkEditModal}
           onSave={(linkText) => { return mlu.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
@@ -877,6 +899,7 @@ export default class CodeMirrorEditor extends AbstractEditor {
         <HandsontableModal
           ref={this.handsontableModal}
           onSave={(table) => { return mtu.replaceFocusedMarkdownTableWithEditor(this.getCodeMirror(), table) }}
+          ignoreAutoFormatting={this.props.editorOptions.ignoreMarkdownTableAutoFormatting}
         />
         <DrawioModal
           ref={this.drawioModal}

+ 20 - 0
src/client/js/components/PageEditor/EditorIcon.jsx

@@ -84,6 +84,26 @@ const EditorIcon = (props) => {
           <path d="M22,8H8A1,1,0,0,0,7,9V21a1,1,0,0,0,1,1H22a1,1,0,0,0,1-1V9A1,1,0,0,0,22,8Zm0,13H8V18l4.07-4.06,4.07,4a.41.41,0,0,0,.33.18.4.4,0,0,0,.32-.18l1.7-1.55,3.17,3.25L22,20Zm0-2.25-3.1-3.34a.89.89,0,0,0-.33-.17.89.89,0,0,0-.28.14l-1.83,1.49-4-3.9a.49.49,0,0,0-.32-.16.5.5,0,0,0-.41.16L8,16.75V9H22ZM19.5,12.5a1,1,0,1,1-1-1A1,1,0,0,1,19.5,12.5Z" />
         </svg>
       );
+    case 'Grid':
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" viewBox="0 0 30 30">
+          <rect width="30" height="30" fill="none" />
+          <g transform="translate(-375 -415)">
+            <g transform="translate(382 422)">
+              <path d="M5,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H5A.945.945,0,0,1,6,1V6A.945.945,0,0,1,5,7ZM1,1V6H5V1ZM1,.5V1H1Z" />
+            </g>
+            <g transform="translate(390 422)">
+              <path d="M7,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H7A.945.945,0,0,1,8,1V6A.945.945,0,0,1,7,7ZM1,1V6H7V1ZM1,.5V1H1Z" />
+            </g>
+            <g transform="translate(382 431)">
+              <path d="M9,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H9a.945.945,0,0,1,1,1V6A.945.945,0,0,1,9,7ZM1,1V6H9V1ZM1,.5V1H1Z" />
+            </g>
+            <g transform="translate(394 431)">
+              <path d="M3,7H1A.945.945,0,0,1,0,6V1A.945.945,0,0,1,1,0H3A.945.945,0,0,1,4,1V6A.945.945,0,0,1,3,7ZM1,1V6H3V1ZM1,.5V1H1Z" />
+            </g>
+          </g>
+        </svg>
+      );
     case 'Table':
       return (
         <svg xmlns="http://www.w3.org/2000/svg" height="30" viewBox="0 0 30 30">

+ 253 - 0
src/client/js/components/PageEditor/GridEditModal.jsx

@@ -0,0 +1,253 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+  Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+import { withTranslation } from 'react-i18next';
+import geu from './GridEditorUtil';
+import BootstrapGrid from '../../models/BootstrapGrid';
+
+const resSizes = BootstrapGrid.ResponsiveSize;
+const resSizeObj = {
+  [resSizes.XS_SIZE]: { displayText: 'grid_edit.smart_no' },
+  [resSizes.SM_SIZE]: { displayText: 'tablet' },
+  [resSizes.MD_SIZE]: { displayText: 'desktop' },
+};
+class GridEditModal extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      colsRatios: [6, 6],
+      responsiveSize: BootstrapGrid.ResponsiveSize.XS_SIZE,
+      show: false,
+      // use when re-edit grid
+      // gridHtml: '',
+    };
+
+    this.checkResposiveSize = this.checkResposiveSize.bind(this);
+    this.checkColsRatios = this.checkColsRatios.bind(this);
+    // use when re-edit grid
+    // this.init = this.init.bind(this);
+    this.show = this.show.bind(this);
+    this.hide = this.hide.bind(this);
+    this.cancel = this.cancel.bind(this);
+    this.pasteCodedGrid = this.pasteCodedGrid.bind(this);
+    this.renderSelectedGridPattern = this.renderSelectedGridPattern.bind(this);
+    this.renderBreakPointSetting = this.renderBreakPointSetting.bind(this);
+  }
+
+  async checkResposiveSize(rs) {
+    await this.setState({ responsiveSize: rs });
+  }
+
+  async checkColsRatios(cr) {
+    await this.setState({ colsRatios: cr });
+  }
+
+  // use when re-edit grid
+  // init(gridHtml) {
+  //   const initGridHtml = gridHtml;
+  //   this.setState({ gridHtml: initGridHtml });
+  // }
+
+  show(gridHtml) {
+    // use when re-edit grid
+    // this.init(gridHtml);
+    this.setState({ show: true });
+  }
+
+  hide() {
+    this.setState({ show: false });
+  }
+
+  cancel() {
+    this.hide();
+  }
+
+  pasteCodedGrid() {
+    const { colsRatios, responsiveSize } = this.state;
+    const convertedHTML = geu.convertRatiosAndSizeToHTML(colsRatios, responsiveSize);
+    const spaceTab = '    ';
+    const pastedGridData = `::: editable-row\n<div class="container">\n${spaceTab}<div class="row">\n${convertedHTML}\n${spaceTab}</div>\n</div>\n:::`;
+
+    if (this.props.onSave != null) {
+      this.props.onSave(pastedGridData);
+    }
+    this.cancel();
+  }
+
+  renderSelectedGridPattern() {
+    const colsRatios = this.state.colsRatios;
+    return colsRatios.join(' - ');
+  }
+
+  renderBreakPointSetting() {
+    const { t } = this.props;
+    const output = Object.entries(resSizeObj).map((responsiveSizeForMap) => {
+      return (
+        <div key={responsiveSizeForMap[0]} className="custom-control custom-radio custom-control-inline">
+          <input
+            type="radio"
+            className="custom-control-input"
+            id={responsiveSizeForMap[1].displayText}
+            value={responsiveSizeForMap[1].displayText}
+            checked={this.state.responsiveSize === responsiveSizeForMap[0]}
+            onChange={e => this.checkResposiveSize(responsiveSizeForMap[0])}
+          />
+          <label className="custom-control-label" htmlFor={responsiveSizeForMap[1].displayText}>
+            {t(responsiveSizeForMap[1].displayText)}
+          </label>
+        </div>
+      );
+    });
+    return output;
+  }
+
+  renderGridDivisionMenu() {
+    const gridDivisions = geu.mappingAllGridDivisionPatterns;
+    const { t } = this.props;
+    return (
+      <div className="container">
+        <div className="row">
+          {gridDivisions.map((gridDivision) => {
+            const numOfDivisions = gridDivision.numberOfGridDivisions;
+            return (
+              <div key={`${numOfDivisions}-divisions`} className="col-md-4 text-center">
+                <h6 className="dropdown-header">{numOfDivisions} {t('grid_edit.division')}</h6>
+                {gridDivision.mapping.map((gridOneDivision) => {
+                  const keyOfRow = `${numOfDivisions}-divisions-${gridOneDivision.join('-')}`;
+                  return (
+                    <button key={keyOfRow} className="dropdown-item" type="button" onClick={() => { this.checkColsRatios(gridOneDivision) }}>
+                      <div className="row">
+                        {gridOneDivision.map((god, i) => {
+                          const keyOfCol = `${keyOfRow}-${i}`;
+                          const className = `bg-info col-${god} border`;
+                          return <span key={keyOfCol} className={className}>{god}</span>;
+                        })}
+                      </div>
+                    </button>
+                  );
+                })}
+              </div>
+            );
+          })}
+        </div>
+      </div>
+    );
+  }
+
+  renderPreview() {
+    const { t } = this.props;
+    const isMdSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.MD_SIZE;
+    const isXsSelected = this.state.responsiveSize === BootstrapGrid.ResponsiveSize.XS_SIZE;
+    return (
+      <div className="row grw-grid-edit-preview border my-4 p-3">
+        <div className="col-lg-2">
+          <h4 className="d-block mt-2">{t('phone')}</h4>
+          <div className="mobile-preview d-block px-3 py-2">
+            {this.renderGridPreview(!isXsSelected)}
+          </div>
+        </div>
+        <div className="col-lg-3">
+          <h4 className="d-block mt-2">{t('tablet')}</h4>
+          <div className="tablet-preview d-block px-3 py-2">
+            {this.renderGridPreview(isMdSelected)}
+          </div>
+        </div>
+        <div className="col-lg-4">
+          <h4 className="d-block mt-2">{t('desktop')}</h4>
+          <div className="desktop-preview d-block px-3 py-2">
+            {this.renderGridPreview(false)}
+          </div>
+        </div>
+      </div>
+    );
+  }
+
+  renderGridPreview(isBreakEnabled) {
+    const { colsRatios } = this.state;
+
+    const convertedHTML = colsRatios.map((colsRatio, i) => {
+      const ratio = isBreakEnabled ? 12 : colsRatio;
+      const key = `grid-preview-col-${i}`;
+      const className = `col-${ratio} grid-edit-border-for-each-cols`;
+      return (
+        <div key={key} className={`${key} ${className}`}></div>
+      );
+    });
+    return (
+      <div className="row">{convertedHTML}</div>
+    );
+  }
+
+  render() {
+    const { t } = this.props;
+    return (
+      <Modal isOpen={this.state.show} toggle={this.cancel} size="xl" className="grw-grid-edit-modal">
+        <ModalHeader tag="h4" toggle={this.cancel} className="bg-primary text-light">
+          {t('grid_edit.create_bootstrap_4_grid')}
+        </ModalHeader>
+        <ModalBody className="container">
+          <div className="row">
+            <div className="col-12">
+              <h3 className="grw-modal-head">{t('grid_edit.grid_settings')}</h3>
+              <form className="form-group mb-0">
+                <div className="form-group row my-3">
+                  <label className="col-sm-3" htmlFor="gridPattern">
+                    {t('grid_edit.grid_pattern')}
+                  </label>
+                  <div className="col-sm-9">
+                    <button
+                      className="btn btn-outline-secondary dropdown-toggle"
+                      type="button"
+                      id="dropdownMenuButton"
+                      data-toggle="dropdown"
+                      aria-haspopup="true"
+                      aria-expanded="false"
+                    >
+                      {this.renderSelectedGridPattern()}
+                    </button>
+                    <div className="dropdown-menu grid-division-menu" aria-labelledby="dropdownMenuButton">
+                      {this.renderGridDivisionMenu()}
+                    </div>
+                  </div>
+                </div>
+                <div className="form-group row">
+                  <label className="col-sm-3" htmlFor="breakPoint">
+                    {t('grid_edit.break_point')}
+                  </label>
+                  <div className="col-sm-9">
+                    {this.renderBreakPointSetting()}
+                  </div>
+                </div>
+              </form>
+            </div>
+          </div>
+          <h3 className="grw-modal-head">{t('preview')}</h3>
+          <div className="col-12">
+            {this.renderPreview()}
+          </div>
+        </ModalBody>
+        <ModalFooter className="grw-modal-footer">
+          <div className="ml-auto">
+            <button type="button" className="mr-2 btn btn-secondary" onClick={this.cancel}>
+              Cancel
+            </button>
+            <button type="button" className="btn btn-primary" onClick={this.pasteCodedGrid}>
+              Done
+            </button>
+          </div>
+        </ModalFooter>
+      </Modal>
+    );
+  }
+
+}
+
+GridEditModal.propTypes = {
+  onSave: PropTypes.func,
+  t: PropTypes.func.isRequired,
+};
+export default withTranslation('translation', { withRef: true })(GridEditModal);

+ 156 - 0
src/client/js/components/PageEditor/GridEditorUtil.js

@@ -0,0 +1,156 @@
+/**
+ * Utility for grid editor
+ */
+class GridEditorUtil {
+
+  constructor() {
+    // https://regex101.com/r/7BN2fR/11
+    this.lineBeginPartOfGridRE = /^:::(\s.*)editable-row$/;
+    this.lineEndPartOfGridRE = /^:::$/;
+    this.mappingAllGridDivisionPatterns = [
+      {
+        numberOfGridDivisions: 2,
+        mapping: [[2, 10], [4, 8], [6, 6], [8, 4], [10, 2]],
+      },
+      {
+        numberOfGridDivisions: 3,
+        mapping: [[2, 5, 5], [5, 2, 5], [5, 5, 2], [4, 4, 4], [3, 3, 6], [3, 6, 3], [6, 3, 3]],
+      },
+      {
+        numberOfGridDivisions: 4,
+        mapping: [[2, 2, 4, 4], [4, 4, 2, 2], [2, 4, 2, 4], [4, 2, 4, 2], [3, 3, 3, 3], [2, 2, 2, 6], [6, 2, 2, 2]],
+      },
+    ];
+    this.isInGridBlock = this.isInGridBlock.bind(this);
+    this.replaceGridWithHtmlWithEditor = this.replaceGridWithHtmlWithEditor.bind(this);
+  }
+
+  /**
+   * return boolean value whether the cursor position is in a grid block
+   */
+  isInGridBlock(editor) {
+    const bog = this.getBog(editor);
+    const eog = this.getEog(editor);
+    if (bog === null || eog === null) {
+      return false;
+    }
+    return (JSON.stringify(bog) !== JSON.stringify(eog));
+  }
+
+  /**
+   * return grid html where the cursor is
+   */
+  getGridHtml(editor) {
+    const curPos = editor.getCursor();
+
+    if (this.isInGridBlock(editor)) {
+      const bog = this.getBog(editor);
+      const eog = this.getEog(editor);
+      // skip block begin sesion("::: editable-row")
+      bog.line++;
+      // skip block end sesion(":::")
+      eog.line--;
+      eog.ch = editor.getDoc().getLine(eog.line).length;
+      return editor.getDoc().getRange(bog, eog);
+    }
+    return editor.getDoc().getLine(curPos.line);
+  }
+
+  /**
+   * return the postion of the BOD(beginning of grid)
+   */
+  getBog(editor) {
+    const curPos = editor.getCursor();
+    const firstLine = editor.getDoc().firstLine();
+
+    if (this.lineBeginPartOfGridRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: 0 };
+    }
+
+    let line = curPos.line - 1;
+    let isFound = false;
+    for (; line >= firstLine; line--) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineBeginPartOfGridRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineEndPartOfGridRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return null;
+    }
+
+    const bodLine = Math.max(firstLine, line);
+    return { line: bodLine, ch: 0 };
+  }
+
+  /**
+   * return the postion of the EOD(end of grid)
+   */
+  getEog(editor) {
+    const curPos = editor.getCursor();
+    const lastLine = editor.getDoc().lastLine();
+
+    if (this.lineEndPartOfGridRE.test(editor.getDoc().getLine(curPos.line))) {
+      return { line: curPos.line, ch: editor.getDoc().getLine(curPos.line).length };
+    }
+
+    let line = curPos.line + 1;
+    let isFound = false;
+    for (; line <= lastLine; line++) {
+      const strLine = editor.getDoc().getLine(line);
+      if (this.lineEndPartOfGridRE.test(strLine)) {
+        isFound = true;
+        break;
+      }
+
+      if (this.lineBeginPartOfGridRE.test(strLine)) {
+        isFound = false;
+        break;
+      }
+    }
+
+    if (!isFound) {
+      return null;
+    }
+
+    const eodLine = Math.min(line, lastLine);
+    const lineLength = editor.getDoc().getLine(eodLine).length;
+    return { line: eodLine, ch: lineLength };
+  }
+
+  replaceGridWithHtmlWithEditor(editor, grid) {
+    const curPos = editor.getCursor();
+    let bog = this.getBog(editor);
+    let eog = this.getEog(editor);
+
+    if (bog === null || eog === null) {
+      bog = curPos;
+      eog = curPos;
+    }
+
+    editor.getDoc().replaceRange(grid.toString(), bog, eog);
+    editor.getDoc().setCursor(curPos.line + 1, 2);
+  }
+
+  convertRatiosAndSizeToHTML(ratioNumbers, responsiveSize) {
+    const cols = ratioNumbers.map((ratioNumber, i) => {
+      const spaceTab = '    ';
+      const className = `col${responsiveSize !== 'xs' ? `-${responsiveSize}` : ''}-${ratioNumber} bsGrid${i + 1}`;
+      return `${spaceTab}${spaceTab}<div class="${className}">Content</div>`;
+    });
+    return cols.join('\n');
+  }
+
+}
+
+// singleton pattern
+const instance = new GridEditorUtil();
+Object.freeze(instance);
+export default instance;

+ 9 - 1
src/client/js/components/PageEditor/HandsontableModal.jsx

@@ -158,7 +158,7 @@ export default class HandsontableModal extends React.PureComponent {
   save() {
     const markdownTable = new MarkdownTable(
       this.hotTable.hotInstance.getData(),
-      { align: [].concat(this.state.markdownTable.options.align) },
+      this.markdownTableOption,
     ).normalizeCells();
 
     if (this.props.onSave != null) {
@@ -398,6 +398,13 @@ export default class HandsontableModal extends React.PureComponent {
     }
   }
 
+  get markdownTableOption() {
+    return {
+      align: [].concat(this.state.markdownTable.options.align),
+      pad: this.props.ignoreAutoFormatting !== true,
+    };
+  }
+
   renderCloseButton() {
     return (
       <button type="button" className="close" onClick={this.cancel} aria-label="Close">
@@ -504,4 +511,5 @@ export default class HandsontableModal extends React.PureComponent {
 
 HandsontableModal.propTypes = {
   onSave: PropTypes.func,
+  ignoreAutoFormatting: PropTypes.bool,
 };

+ 9 - 10
src/client/js/components/PageEditor/LinkEditModal.jsx

@@ -5,6 +5,7 @@ import {
   Modal,
   ModalHeader,
   ModalBody,
+  ModalFooter,
   Popover,
   PopoverBody,
 } from 'reactstrap';
@@ -445,17 +446,15 @@ class LinkEditModal extends React.PureComponent {
               {this.renderLinkPreview()}
             </div>
           </div>
-          <div className="row">
-            <div className="col-12 text-center">
-              <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
-                {t('Cancel')}
-              </button>
-              <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
-                {t('Done')}
-              </button>
-            </div>
-          </div>
         </ModalBody>
+        <ModalFooter>
+          <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={this.hide}>
+            {t('Cancel')}
+          </button>
+          <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={this.save}>
+            {t('Done')}
+          </button>
+        </ModalFooter>
       </Modal>
     );
   }

+ 10 - 11
src/client/js/components/PageEditor/MarkdownDrawioUtil.js

@@ -10,7 +10,7 @@ class MarkdownDrawioUtil {
 
   /**
    * return the postion of the BOD(beginning of drawio)
-   * (If the cursor is not in a drawio block, return its position)
+   * (If the BOD is not found after the cursor or the EOD is found before the BOD, return null)
    */
   getBod(editor) {
     const curPos = editor.getCursor();
@@ -36,7 +36,7 @@ class MarkdownDrawioUtil {
     }
 
     if (!isFound) {
-      return { line: curPos.line, ch: curPos.ch };
+      return null;
     }
 
     const bodLine = Math.max(firstLine, line);
@@ -45,7 +45,7 @@ class MarkdownDrawioUtil {
 
   /**
    * return the postion of the EOD(end of drawio)
-   * (If the cursor is not in a drawio block, return its position)
+   * (If the EOD is not found after the cursor or the BOD is found before the EOD, return null)
    */
   getEod(editor) {
     const curPos = editor.getCursor();
@@ -71,7 +71,7 @@ class MarkdownDrawioUtil {
     }
 
     if (!isFound) {
-      return { line: curPos.line, ch: curPos.ch };
+      return null;
     }
 
     const eodLine = Math.min(line, lastLine);
@@ -85,17 +85,17 @@ class MarkdownDrawioUtil {
   isInDrawioBlock(editor) {
     const bod = this.getBod(editor);
     const eod = this.getEod(editor);
-
-    return (JSON.stringify(bod) !== JSON.stringify(eod));
+    if (bod === null || eod === null) {
+      return false;
+    }
+    return JSON.stringify(bod) !== JSON.stringify(eod);
   }
 
   /**
    * return drawioData instance where the cursor is
-   * (If the cursor is not in a drawio block, return current line)
+   * (If the cursor is not in a drawio block, return null)
    */
   getMarkdownDrawioMxfile(editor) {
-    const curPos = editor.getCursor();
-
     if (this.isInDrawioBlock(editor)) {
       const bod = this.getBod(editor);
       const eod = this.getEod(editor);
@@ -108,8 +108,7 @@ class MarkdownDrawioUtil {
 
       return editor.getDoc().getRange(bod, eod);
     }
-
-    return editor.getDoc().getLine(curPos.line);
+    return null;
   }
 
   replaceFocusedDrawioWithEditor(editor, drawioData) {

+ 4 - 2
src/client/js/components/PageEditor/MarkdownTableInterceptor.js

@@ -56,9 +56,11 @@ export default class MarkdownTableInterceptor extends BasicInterceptor {
   async process(contextName, ...args) {
     const context = Object.assign(args[0]); // clone
     const editor = context.editor; // AbstractEditor instance
+    // "ignoreMarkdownTableAutoFormatting" may be undefined, so it is compared to true and converted to bool.
+    const noIntercept = (context.editorOptions.ignoreMarkdownTableAutoFormatting === true);
 
-    // do nothing if editor is not a CodeMirrorEditor
-    if (editor == null || editor.getCodeMirror() == null) {
+    // do nothing if editor is not a CodeMirrorEditor or no intercept
+    if (editor == null || editor.getCodeMirror() == null || noIntercept) {
       return context;
     }
 

+ 35 - 0
src/client/js/components/PageEditor/OptionsSelector.jsx

@@ -49,6 +49,7 @@ class OptionsSelector extends React.Component {
     this.onChangeKeymapMode = this.onChangeKeymapMode.bind(this);
     this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
     this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
+    this.onClickMarkdownTableAutoFormatting = this.onClickMarkdownTableAutoFormatting.bind(this);
     this.onToggleConfigurationDropdown = this.onToggleConfigurationDropdown.bind(this);
   }
 
@@ -97,6 +98,17 @@ class OptionsSelector extends React.Component {
     editorContainer.saveOptsToLocalStorage();
   }
 
+  onClickMarkdownTableAutoFormatting(event) {
+    const { editorContainer } = this.props;
+
+    const newValue = !editorContainer.state.editorOptions.ignoreMarkdownTableAutoFormatting;
+    const newOpts = Object.assign(editorContainer.state.editorOptions, { ignoreMarkdownTableAutoFormatting: newValue });
+    editorContainer.setState({ editorOptions: newOpts });
+
+    // save to localStorage
+    editorContainer.saveOptsToLocalStorage();
+  }
+
   onToggleConfigurationDropdown(newValue) {
     this.setState({ isCddMenuOpened: !this.state.isCddMenuOpened });
   }
@@ -187,6 +199,7 @@ class OptionsSelector extends React.Component {
           <DropdownMenu>
             {this.renderActiveLineMenuItem()}
             {this.renderRealtimeMathJaxMenuItem()}
+            {this.renderMarkdownTableAutoFormattingMenuItem()}
             {/* <DropdownItem divider /> */}
           </DropdownMenu>
 
@@ -244,6 +257,28 @@ class OptionsSelector extends React.Component {
     );
   }
 
+  renderMarkdownTableAutoFormattingMenuItem() {
+    const { t, editorContainer } = this.props;
+    // Auto-formatting was enabled before optionalizing, so we made it a disabled option(ignoreMarkdownTableAutoFormatting).
+    const isActive = !editorContainer.state.editorOptions.ignoreMarkdownTableAutoFormatting;
+
+    const iconClasses = ['text-info'];
+    if (isActive) {
+      iconClasses.push('ti-check');
+    }
+    const iconClassName = iconClasses.join(' ');
+
+    return (
+      <DropdownItem toggle={false} onClick={this.onClickMarkdownTableAutoFormatting}>
+        <div className="d-flex justify-content-between">
+          <span className="icon-container"></span>
+          <span className="menuitem-label">{ t('page_edit.auto_format_table') }</span>
+          <span className="icon-container"><i className={iconClassName}></i></span>
+        </div>
+      </DropdownItem>
+    );
+  }
+
   render() {
     return (
       <div className="d-flex flex-row">

+ 4 - 1
src/client/js/components/PagePathAutoComplete.jsx

@@ -44,6 +44,7 @@ const PagePathAutoComplete = (props) => {
       emptyLabelExceptError={null}
       placeholder="Input page path"
       keywordOnInit={getKeywordOnInit(initializedPath)}
+      autoFocus={props.autoFocus}
     />
   );
 
@@ -55,10 +56,12 @@ PagePathAutoComplete.propTypes = {
 
   onSubmit:         PropTypes.func,
   onInputChange:    PropTypes.func,
+  autoFocus:        PropTypes.bool,
 };
 
 PagePathAutoComplete.defaultProps = {
-  initializedPath: '/',
+  initializedPath:  '/',
+  autoFocus:        false,
 };
 
 export default PagePathAutoComplete;

+ 2 - 1
src/client/js/components/PageRenameModal.jsx

@@ -134,7 +134,7 @@ const PageRenameModal = (props) => {
   }
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose}>
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.onClose} autoFocus={false}>
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         { t('modal_rename.label.Move/Rename page') }
       </ModalHeader>
@@ -156,6 +156,7 @@ const PageRenameModal = (props) => {
                 className="form-control"
                 onChange={e => inputChangeHandler(e.target.value)}
                 required
+                autoFocus
               />
             </form>
           </div>

+ 10 - 1
src/client/js/components/StaffCredit/Contributor.js

@@ -10,6 +10,7 @@ const contributors = [
           { position: 'Soncho 1st', name: 'mizozobu' },
           { position: 'Soncho 2nd', name: 'yusuketk' },
           { position: 'Paladin', name: 'itizawa' },
+          { position: 'Valkyrie', name: 'kaoritokashiki' },
         ],
       },
       {
@@ -35,7 +36,12 @@ const contributors = [
           { name: 'ryuichi-e' },
           { name: 'N1koge' },
           { name: 'Ertai87' },
-          { name: 'kaoritokashiki' },
+          { name: 'zahmis' },
+          { name: 'takeru0001' },
+          { name: 'Shu Katabe' },
+          { name: 'oshikishintaro' },
+          { name: 'makotoshiraishi' },
+          { name: 'yamagai' },
         ],
       },
     ],
@@ -53,6 +59,7 @@ const contributors = [
           { name: 'hitochan777' },
           { name: 'ttaka66' },
           { name: 'watagashi' },
+          { name: 'paichi81' },
           { name: 'nt-7' },
           { name: 'hideo54' },
           { name: 'wadahiro' },
@@ -79,6 +86,7 @@ const contributors = [
           { name: 'aximov' },
           { name: 'tats-u' },
           { name: 'yamatomo717' },
+          { name: 'tohutohu' },
         ],
       },
     ],
@@ -94,6 +102,7 @@ const contributors = [
           { name: 'Kanta Nishitani' },
           { position: 'The University of Tokyo', name: 'Takashi Yoneuchi' },
           { position: 'DeCurret', name: 'Yusuke Tanomogi' },
+          { position: 'Flatt Security', name: 'stypr' },
         ],
       },
     ],

+ 0 - 54
src/client/js/components/UserContentsLinks.jsx

@@ -1,54 +0,0 @@
-import React, { useMemo } from 'react';
-import PropTypes from 'prop-types';
-import loggerFactory from '@alias/logger';
-
-import NavigationContainer from '../services/NavigationContainer';
-
-import { withUnstatedContainers } from './UnstatedUtils';
-
-import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:cli:UserContentsLinks');
-const WIKI_HEADER_LINK = 120;
-
-/**
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- */
-const UserContentsLinks = (props) => {
-
-  const { navigationContainer } = props;
-
-  // get element for smoothScroll
-  const getBookMarkListHeaderDom = useMemo(() => { return document.getElementById('bookmarks-list') }, []);
-  const getRecentlyCreatedListHeaderDom = useMemo(() => { return document.getElementById('recently-created-list') }, []);
-
-  return (
-    <div className="mt-3 d-flex justify-content-around">
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm"
-        onClick={() => navigationContainer.smoothScrollIntoView(getBookMarkListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="mr-2 icon-star"></i>
-        <span>Bookmarks</span>
-      </button>
-      <button
-        type="button"
-        className="btn btn-outline-secondary btn-sm"
-        onClick={() => navigationContainer.smoothScrollIntoView(getRecentlyCreatedListHeaderDom, WIKI_HEADER_LINK)}
-      >
-        <i className="grw-icon-container-recently-created mr-2"><RecentlyCreatedIcon /></i>
-        <span>Recently Created</span>
-      </button>
-    </div>
-  );
-
-};
-
-UserContentsLinks.propTypes = {
-  navigationContainer: PropTypes.instanceOf(NavigationContainer).isRequired,
-};
-
-export default withUnstatedContainers(UserContentsLinks, [NavigationContainer]);

+ 34 - 0
src/client/js/models/BootstrapGrid.js

@@ -0,0 +1,34 @@
+export default class BootstrapGrid {
+
+  constructor(colsRatios, responsiveSize) {
+    this.colsRatios = BootstrapGrid.validateColsRatios(colsRatios);
+    this.responsiveSize = BootstrapGrid.validateResponsiveSize(responsiveSize);
+  }
+
+  static ResponsiveSize = {
+    XS_SIZE: 'xs', SM_SIZE: 'sm', MD_SIZE: 'md',
+  }
+
+  static validateColsRatios(colsRatios) {
+
+    if (colsRatios.length < 2 || colsRatios.length > 4) {
+      throw new Error('Incorrect array length of cols ratios');
+    }
+    const ratiosTotal = colsRatios.reduce((total, ratio) => { return total + ratio }, 0);
+    if (ratiosTotal !== 12) {
+      throw new Error('Incorrect cols ratios value');
+    }
+
+    return colsRatios;
+  }
+
+  static validateResponsiveSize(responsiveSize) {
+    if (responsiveSize === this.ResponsiveSize.XS_SIZE
+      || responsiveSize === this.ResponsiveSize.SM_SIZE
+      || responsiveSize === this.ResponsiveSize.MD_SIZE) {
+      return responsiveSize;
+    }
+    throw new Error('Incorrect responsive size');
+  }
+
+}

+ 2 - 0
src/client/js/util/GrowiRenderer.js

@@ -2,6 +2,7 @@ import MarkdownIt from 'markdown-it';
 
 import Linker from './PreProcessor/Linker';
 import CsvToTable from './PreProcessor/CsvToTable';
+import EasyGrid from './PreProcessor/EasyGrid';
 import XssFilter from './PreProcessor/XssFilter';
 
 import EmojiConfigurer from './markdown-it/emoji';
@@ -37,6 +38,7 @@ export default class GrowiRenderer {
     }
     else {
       this.preProcessors = [
+        new EasyGrid(appContainer),
         new Linker(appContainer),
         new CsvToTable(appContainer),
         new XssFilter(appContainer),

+ 10 - 0
src/client/js/util/PreProcessor/EasyGrid.js

@@ -0,0 +1,10 @@
+export default class EasyGrid {
+
+  process(markdown) {
+    // see: https://regex101.com/r/7NWvUU/2
+    return markdown.replace(/:::\s*editable-row[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group) => {
+      return group;
+    });
+  }
+
+}

+ 17 - 0
src/client/js/util/old-ios.js

@@ -0,0 +1,17 @@
+const userAgent = window.navigator.userAgent.toLowerCase();
+// https://youtrack.weseek.co.jp/issue/GW-4826
+const isOldIos = /(iphone|ipad|ipod) os (9|10|11|12)/.test(userAgent);
+
+/**
+ * Apply 'oldIos' attribute to <html></html>
+ */
+function applyOldIos() {
+  if (isOldIos) {
+    document.documentElement.setAttribute('old-ios', 'true');
+  }
+}
+
+export {
+  // eslint-disable-next-line import/prefer-default-export
+  applyOldIos,
+};

+ 3 - 0
src/client/styles/scss/_editor-navbar.scss

@@ -7,6 +7,9 @@
 
     li {
       display: inline-block;
+      i {
+        font-size: 16px;
+      }
     }
 
     button {

+ 22 - 0
src/client/styles/scss/_mixins.scss

@@ -253,3 +253,25 @@
     }
   }
 }
+@mixin highlighted($color) {
+  @keyframes fadeout {
+    100% {
+      opacity: 0;
+    }
+  }
+  position: relative;
+  z-index: 1;
+
+  &::after {
+    position: absolute;
+    top: 15%;
+    left: 0;
+    z-index: -1;
+    width: 100%;
+    height: 70%;
+    content: '';
+    background-color: $color;
+    border-radius: 2px;
+    animation: fadeout 1s ease-in 1.5s forwards;
+  }
+}

+ 9 - 0
src/client/styles/scss/_old-ios.scss

@@ -0,0 +1,9 @@
+html[old-ios] body:not(.on-edit) {
+  .grw-navbar {
+    position: initial !important;
+    top: 0 !important;
+  }
+  .grw-subnav-fixed-container {
+    top: 0 !important;
+  }
+}

+ 50 - 4
src/client/styles/scss/_on-edit.scss

@@ -232,13 +232,13 @@ body.on-edit {
     }
 
     .page-editor-preview-container {
+      overflow-y: scroll;
     }
 
     .page-editor-preview-body {
-      padding-top: 18px;
-      padding-right: 15px;
-      padding-left: 15px;
-      overflow-y: scroll;
+      max-width: 980px;
+      padding: 18px 15px 0;
+      margin: 0 auto;
     }
 
     .grw-editor-configuration-dropdown {
@@ -328,3 +328,49 @@ body.on-edit {
     border-bottom: 5px solid $gray-300;
   }
 }
+
+/*
+ Grid Edit Modal
+*/
+
+.grw-grid-edit-modal {
+  .desktop-preview,
+  .tablet-preview,
+  .mobile-preview {
+    .row {
+      height: 140px;
+      margin: 0px;
+    }
+  }
+  .desktop-preview {
+    .row {
+      div {
+        padding: 0px;
+      }
+    }
+  }
+
+  .tablet-preview {
+    .row {
+      div {
+        padding: 0px;
+      }
+    }
+  }
+
+  .mobile-preview {
+    width: 75%;
+    .row {
+      div {
+        padding: 0px;
+      }
+    }
+  }
+
+  .grid-division-menu {
+    width: 60vw;
+    @include media-breakpoint-down(lg) {
+      width: 80vw;
+    }
+  }
+}

+ 5 - 5
src/client/styles/scss/_toc.scss

@@ -25,11 +25,11 @@
       margin-left: 17px;
     }
   }
+}
 
-  .grw-icon-container-recently-created {
-    svg {
-      width: 14px;
-      height: 14px;
-    }
+.grw-icon-container-recently-created {
+  svg {
+    width: 14px;
+    height: 14px;
   }
 }

+ 1 - 4
src/client/styles/scss/_wiki.scss

@@ -34,6 +34,7 @@ div.body {
     border-bottom: solid 1px transparent;
   }
   h2 {
+    // padding-top: 0.5em;
     padding-bottom: 0.5em;
     font-size: 1.4em;
     font-weight: bold;
@@ -137,10 +138,6 @@ div.body {
     }
   }
 
-  .highlighted {
-    background-color: $warning;
-  }
-
   .revision-head {
     a {
       text-decoration: none;

+ 1 - 0
src/client/styles/scss/style-app.scss

@@ -46,6 +46,7 @@
 @import 'mirror_mode';
 @import 'modal';
 @import 'navbar';
+@import 'old-ios';
 @import 'on-edit';
 @import 'page-duplicate-modal';
 @import 'page_list';

+ 7 - 0
src/client/styles/scss/theme/_apply-colors-dark.scss

@@ -304,6 +304,13 @@ ul.pagination {
   }
 }
 
+/*
+ * GROWI Grid Edit Modal
+ */
+.grw-grid-edit-preview {
+  background: $gray-900;
+}
+
 /*
  * Slack
  */

+ 8 - 0
src/client/styles/scss/theme/_apply-colors-light.scss

@@ -211,6 +211,14 @@ $border-color: $border-color-global;
   }
 }
 
+/*
+ * GROWI Grid Edit Modal
+ */
+
+.grw-grid-edit-preview {
+  background: $gray-100;
+}
+
 /*
  * Slack
  */

+ 34 - 0
src/client/styles/scss/theme/_apply-colors.scss

@@ -389,6 +389,10 @@ ul.pagination {
  * GROWI wiki
  */
 .wiki {
+  .highlighted {
+    @include highlighted($bgcolor-highlighted);
+  }
+
   a {
     color: $color-link-wiki;
 
@@ -435,6 +439,36 @@ body.on-edit {
   }
 }
 
+/*
+ * GROWI Grid Edit Modal
+ */
+.grw-grid-edit-preview {
+  .desktop-preview,
+  .tablet-preview,
+  .mobile-preview {
+    background: $bgcolor-global;
+  }
+  .grid-edit-border-for-each-cols {
+    border: 2px solid $bgcolor-global;
+  }
+}
+
+.grid-preview-col-0 {
+  background: $growi-blue;
+}
+
+.grid-preview-col-1 {
+  background: $info;
+}
+
+.grid-preview-col-2 {
+  background: $success;
+}
+
+.grid-preview-col-3 {
+  background: $growi-green;
+}
+
 /*
  * GROWI comment
  */

+ 1 - 0
src/client/styles/scss/theme/antarctic.scss

@@ -51,6 +51,7 @@ html[dark] {
   $bgcolor-global: $themelight;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-50;
+  $bgcolor-highlighted: rgba($primary, 0.15);
 
   // Font colors
   $color-global: black;

+ 1 - 11
src/client/styles/scss/theme/christmas.scss

@@ -43,6 +43,7 @@ html[dark] {
   // Background colors
   $bgcolor-card: $gray-50;
   $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-highlighted: rgba($primary, 0.5);
 
   // Font colors
   $color-global: #112744;
@@ -106,17 +107,6 @@ html[dark] {
   @import 'apply-colors-light';
 
   // change color of highlighted header in wiki (default: orange)
-  .wiki {
-    .code-line.revision-head.highlighted {
-      color: $themelight;
-      background-color: lighten($themecolor, 20%);
-
-      .icon-note,
-      .icon-link {
-        color: $themelight;
-      }
-    }
-  }
 
   .sidebar {
     background: $themecolor;

+ 2 - 0
src/client/styles/scss/theme/default.scss

@@ -22,6 +22,7 @@ html[light] {
   $bgcolor-global: white;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-50;
+  $bgcolor-highlighted: rgba($primary, 0.1);
 
   // Font colors
   $color-global: #112744;
@@ -122,6 +123,7 @@ html[dark] {
   $bgcolor-global: #131418;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: darken($bgcolor-global, 5%);
+  $bgcolor-highlighted: rgba($primary, 0.4);
 
   // Font colors
   $color-global: $gray-400;

+ 1 - 0
src/client/styles/scss/theme/future.scss

@@ -11,6 +11,7 @@ html[dark] {
   $bgcolor-global: $themecolor;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: darken($themecolor, 5%);
+  $bgcolor-highlighted: rgba($primary, 0.4);
 
   // Font colors
   $color-global: #95abba;

+ 1 - 0
src/client/styles/scss/theme/halloween.scss

@@ -39,6 +39,7 @@ html[dark] {
   $bgcolor-global: #050000;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: $bgcolor-global;
+  $bgcolor-highlighted: rgba($primary, 0.4);
 
   // Font colors
   $color-global: #e9af2b;

+ 1 - 6
src/client/styles/scss/theme/island.scss

@@ -11,6 +11,7 @@ html[dark] {
   $bgcolor-card: $gray-50;
   $bgcolor-global: lighten($color-themelight, 10%);
   $bgcolor-inline-code: $gray-100; //optional
+  $bgcolor-highlighted: rgba($primary, 0.3);
 
   // Font colors
   $color-global: #112744;
@@ -84,12 +85,6 @@ html[dark] {
   @import 'apply-colors';
   @import 'apply-colors-light';
 
-  .wiki {
-    .highlighted {
-      background-color: lighten($primary, 20%);
-    }
-  }
-
   .rbt-menu {
     background: lighten($color-themelight, 5%);
   }

+ 3 - 0
src/client/styles/scss/theme/kibela.scss

@@ -45,6 +45,8 @@ html[dark] {
   $primary: $bgcolor-theme;
   $info: lighten($bgcolor-theme, 20%);
 
+  $bgcolor-highlighted: rgba($primary, 0.2);
+
   // List Group colors
   $color-list: $color-global;
   $bgcolor-list: $bgcolor-global;
@@ -98,6 +100,7 @@ html[dark] {
 
   @import 'apply-colors';
   @import 'apply-colors-light';
+
   //Button
   .grw-page-editor-mode-manager {
     .btn.btn-outline-primary {

+ 2 - 0
src/client/styles/scss/theme/mono-blue.scss

@@ -14,6 +14,7 @@ html[light] {
   $bgcolor-global: $themelight;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: darken($themelight, 5%);
+  $bgcolor-highlighted: rgba($primary, 0.1);
 
   // Font colors
   $color-global: $themecolor;
@@ -111,6 +112,7 @@ html[dark] {
   $bgcolor-navbar: #27343b;
   $bgcolor-inline-code: #1f1f22; //optional
   $bgcolor-card: darken($themedark, 5%);
+  $bgcolor-highlighted: rgba($primary, 0.5);
 
   // Font colors
   $color-global: #d3d4d4;

+ 1 - 0
src/client/styles/scss/theme/nature.scss

@@ -45,6 +45,7 @@ html[dark] {
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: #f1ffe4;
   $bgcolor-subnav: #fafafa;
+  $bgcolor-highlighted: rgba($primary, 0.1);
 
   // Font colors
   $color-global: #460039;

+ 1 - 0
src/client/styles/scss/theme/spring.scss

@@ -34,6 +34,7 @@ html[dark] {
   $bgcolor-global: white;
   $bgcolor-inline-code: $gray-100; //optional
   $bgcolor-card: $gray-50;
+  $bgcolor-highlighted: rgba($primary, 0.5);
 
   // Font colors
   $color-global: black;

+ 1 - 0
src/client/styles/scss/theme/wood.scss

@@ -43,6 +43,7 @@ html[dark] {
   // Background colors
   $bgcolor-global: white;
   $bgcolor-card: #ece8de;
+  $bgcolor-highlighted: rgba($primary, 0.3);
 
   // Font colors
   // $color-global: black;

+ 68 - 0
src/migrations/20200827045151-remove-layout-setting.js

@@ -0,0 +1,68 @@
+require('module-alias/register');
+const logger = require('@alias/logger')('growi:migrate:remove-layout-setting');
+
+const mongoose = require('mongoose');
+const config = require('@root/config/migrate');
+
+const { getModelSafely } = require('@commons/util/mongoose-utils');
+
+module.exports = {
+  async up(db, client) {
+    logger.info('Apply migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const layoutType = await Config.findOne({ key: 'customize:layout' });
+
+    if (layoutType == null) {
+      return;
+    }
+
+    const promise = [
+      // remove layout
+      Config.findOneAndDelete({ key: 'customize:layout' }),
+    ];
+
+    if (layoutType.value === '"kibela"') {
+      promise.push(
+        Config.update(
+          { key: 'customize:theme' },
+          { value: JSON.stringify('kibela') },
+        ),
+      );
+    }
+
+    await Promise.all(promise);
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down(db, client) {
+    logger.info('Rollback migration');
+    mongoose.connect(config.mongoUri, config.mongodb.options);
+
+    const Config = getModelSafely('Config') || require('@server/models/config')();
+
+    const theme = await Config.findOne({ key: 'customize:theme' });
+    const insertLayoutType = (theme.value === '"kibela"') ? 'kibela' : 'growi';
+
+    const insertConfig = new Config({
+      ns: 'crowi',
+      key: 'customize:layout',
+      value: JSON.stringify(insertLayoutType),
+    });
+
+    const promise = [
+      insertConfig.save(),
+      Config.update(
+        { key: 'customize:theme', value: JSON.stringify('kibela') },
+        { value: JSON.stringify('default') },
+      ),
+    ];
+
+    await Promise.all(promise);
+
+    logger.info('Migration has been successfully rollbacked');
+  },
+};

+ 2 - 0
src/server/crowi/express-init.js

@@ -18,6 +18,7 @@ module.exports = function(crowi, app) {
   const i18nSprintf = require('i18next-sprintf-postprocessor');
   const i18nMiddleware = require('i18next-express-middleware');
 
+  const promster = require('../middlewares/promster')(crowi, app);
   const registerSafeRedirect = require('../middlewares/safe-redirect')();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
@@ -116,6 +117,7 @@ module.exports = function(crowi, app) {
 
   app.use(flash());
 
+  app.use(promster);
   app.use(registerSafeRedirect);
   app.use(injectCurrentuserToLocalvars);
   app.use(autoReconnectToS2sMsgServer);

+ 10 - 6
src/server/crowi/index.js

@@ -404,7 +404,7 @@ Crowi.prototype.start = async function() {
   await this.init();
   await this.buildServer();
 
-  const { express } = this;
+  const { express, configManager } = this;
 
   // setup plugins
   this.pluginService = new PluginService(this, express);
@@ -419,6 +419,15 @@ Crowi.prototype.start = async function() {
       this.crowiDev.setupExpressAfterListening(express);
     }
   });
+  // listen for promster
+  if (configManager.getConfig('crowi', 'promster:isEnabled')) {
+    const { createServer } = require('@promster/server');
+    const promsterPort = configManager.getConfig('crowi', 'promster:port');
+
+    createServer({ port: promsterPort }).then(() => {
+      logger.info(`[${this.node_env}] Promster server is listening on port ${promsterPort}`);
+    });
+  }
 
   this.socketIoService.attachServer(serverListening);
 
@@ -460,11 +469,6 @@ Crowi.prototype.setupRoutesAtLast = function() {
   require('../routes')(this, this.express);
 };
 
-Crowi.prototype.setupAfterInstall = function() {
-  this.pluginService.autoDetectAndLoadPlugins();
-  this.setupRoutesAtLast();
-};
-
 /**
  * require API for plugins
  *

+ 12 - 4
src/server/middlewares/application-installed.js

@@ -2,12 +2,20 @@ module.exports = (crowi) => {
   const { appService } = crowi;
 
   return async(req, res, next) => {
-    const isInstalled = await appService.isDBInitialized();
+    const isDBInitialized = await appService.isDBInitialized();
 
-    if (!isInstalled) {
-      return res.redirect('/installer');
+    // when already installed
+    if (isDBInitialized) {
+      return next();
     }
 
-    return next();
+    // when other server have initialized DB
+    const isDBInitializedAfterForceReload = await appService.isDBInitialized(true);
+    if (isDBInitializedAfterForceReload) {
+      await appService.setupAfterInstall();
+      return res.safeRedirect(req.originalUrl);
+    }
+
+    return res.redirect('/installer');
   };
 };

+ 2 - 2
src/server/middlewares/application-not-installed.js

@@ -2,9 +2,9 @@ module.exports = (crowi) => {
   const { appService } = crowi;
 
   return async(req, res, next) => {
-    const isInstalled = await appService.isDBInitialized();
+    const isDBInitialized = await appService.isDBInitialized(true);
 
-    if (isInstalled) {
+    if (isDBInitialized) {
       req.flash('errorMessage', req.t('message.application_already_installed'));
       return res.redirect('admin');
     }

+ 11 - 0
src/server/middlewares/promster.js

@@ -0,0 +1,11 @@
+module.exports = (crowi, app) => {
+  const { configManager } = crowi;
+
+  // when disabled
+  if (!configManager.getConfig('crowi', 'promster:isEnabled')) {
+    return (req, res, next) => next();
+  }
+
+  const { createMiddleware } = require('@promster/express');
+  return createMiddleware({ app });
+};

+ 34 - 33
src/server/routes/index.js

@@ -33,37 +33,33 @@ module.exports = function(crowi, app) {
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  app.get('/'                        , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
-
   // API v3
   app.use('/api-docs', require('./apiv3/docs')(crowi));
   app.use('/_api/v3', require('./apiv3')(crowi));
 
+  app.get('/'                         , applicationInstalled, loginRequired , autoReconnectToSearch, page.showTopPage);
+
+  app.get('/login/error/:reason'      , applicationInstalled, login.error);
+  app.get('/login'                    , applicationInstalled, login.preLogin, login.login);
+  app.get('/login/invited'            , applicationInstalled, login.invited);
+  app.post('/login/activateInvited'   , applicationInstalled, form.invited                         , csrf, login.invited);
+  app.post('/login'                   , applicationInstalled, form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
+
+  app.post('/register'                , applicationInstalled, form.register                        , csrf, login.register);
+  app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
+  app.get('/logout'                   , applicationInstalled, logout.logout);
+
+  app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , admin.index);
+  app.get('/admin/app'                , applicationInstalled, loginRequiredStrictly , adminRequired , admin.app.index);
+
   // installer
   if (!isInstalled) {
     const installer = require('./installer')(crowi);
-    app.get('/installer'               , applicationNotInstalled , installer.index);
-    app.post('/installer'              , applicationNotInstalled , form.register , csrf, installer.install);
+    app.get('/installer'              , applicationNotInstalled , installer.index);
+    app.post('/installer'             , applicationNotInstalled , form.register , csrf, installer.install);
     return;
   }
 
-  app.get('/login/error/:reason'     , login.error);
-  app.get('/login'                   , applicationInstalled     , login.preLogin, login.login);
-  app.get('/login/invited'           , login.invited);
-  app.post('/login/activateInvited'  , form.invited                         , csrf, login.invited);
-  app.post('/login'                  , form.login                           , csrf, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
-  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
-
-  app.post('/register'               , form.register                        , csrf, login.register);
-  app.get('/register'                , applicationInstalled     , login.preLogin, login.register);
-  app.get('/logout'                  , logout.logout);
-
-  app.get('/admin'                          , loginRequiredStrictly , adminRequired , admin.index);
-  app.get('/admin/app'                      , loginRequiredStrictly , adminRequired , admin.app.index);
-
-  // security admin
-  app.get('/admin/security'                     , loginRequiredStrictly , adminRequired , admin.security.index);
-
   // OAuth
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailure);
   app.get('/passport/github'                      , loginPassport.loginWithGitHub, loginPassport.loginFailure);
@@ -77,29 +73,34 @@ module.exports = function(crowi, app) {
   app.get('/passport/oidc/callback'               , loginPassport.loginPassportOidcCallback     , loginPassport.loginFailure);
   app.post('/passport/saml/callback'              , loginPassport.loginPassportSamlCallback     , loginPassport.loginFailure);
 
+  app.post('/_api/login/testLdap'    , loginRequiredStrictly , form.login , loginPassport.testLdapCredentials);
+
+  // security admin
+  app.get('/admin/security'          , loginRequiredStrictly , adminRequired , admin.security.index);
+
   // markdown admin
-  app.get('/admin/markdown'                   , loginRequiredStrictly , adminRequired , admin.markdown.index);
+  app.get('/admin/markdown'          , loginRequiredStrictly , adminRequired , admin.markdown.index);
 
   // customize admin
-  app.get('/admin/customize'                , loginRequiredStrictly , adminRequired , admin.customize.index);
+  app.get('/admin/customize'         , loginRequiredStrictly , adminRequired , admin.customize.index);
 
   // search admin
-  app.get('/admin/search'              , loginRequiredStrictly , adminRequired , admin.search.index);
+  app.get('/admin/search'            , loginRequiredStrictly , adminRequired , admin.search.index);
 
   // notification admin
-  app.get('/admin/notification'              , loginRequiredStrictly , adminRequired , admin.notification.index);
-  app.get('/admin/notification/slackAuth'    , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
-  app.get('/admin/notification/slackSetting/disconnect', loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
-  app.get('/admin/global-notification/new'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
-  app.get('/admin/global-notification/:id'   , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
+  app.get('/admin/notification'                         , loginRequiredStrictly , adminRequired , admin.notification.index);
+  app.get('/admin/notification/slackAuth'               , loginRequiredStrictly , adminRequired , admin.notification.slackAuth);
+  app.get('/admin/notification/slackSetting/disconnect' , loginRequiredStrictly , adminRequired , admin.notification.disconnectFromSlack);
+  app.get('/admin/global-notification/new'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
+  app.get('/admin/global-notification/:id'              , loginRequiredStrictly , adminRequired , admin.globalNotification.detail);
 
-  app.get('/admin/users'                , loginRequiredStrictly , adminRequired , admin.user.index);
+  app.get('/admin/users'                                , loginRequiredStrictly , adminRequired , admin.user.index);
 
-  app.get('/admin/users/external-accounts'               , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
+  app.get('/admin/users/external-accounts'              , loginRequiredStrictly , adminRequired , admin.externalAccount.index);
 
   // user-groups admin
-  app.get('/admin/user-groups'             , loginRequiredStrictly, adminRequired, admin.userGroup.index);
-  app.get('/admin/user-group-detail/:id'   , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
+  app.get('/admin/user-groups'                          , loginRequiredStrictly, adminRequired, admin.userGroup.index);
+  app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
 
   // importer management for admin
   app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);

+ 1 - 1
src/server/routes/installer.js

@@ -85,7 +85,7 @@ module.exports = function(crowi) {
     // create initial pages
     await createInitialPages(adminUser, language);
 
-    crowi.setupAfterInstall();
+    appService.setupAfterInstall();
     appService.publishPostInstallationMessage();
 
     // login with passport

+ 14 - 20
src/server/routes/page.js

@@ -143,16 +143,16 @@ module.exports = function(crowi, app) {
   const ApiResponse = require('../util/apiResponse');
   const getToday = require('../util/getToday');
 
-  const { slackNotificationService, configManager } = crowi;
+  const { slackNotificationService, configManager, xssService } = crowi;
   const interceptorManager = crowi.getInterceptorManager();
   const globalNotificationService = crowi.getGlobalNotificationService();
 
   const XssOption = require('../../lib/service/xss/xssOption');
   const Xss = require('../../lib/service/xss/index');
   const initializedConfig = {
-    isEnabledXssPrevention: crowi.configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
-    tagWhiteList: crowi.xssService.getTagWhiteList(),
-    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    tagWhiteList: xssService.getTagWhiteList(),
+    attrWhiteList: xssService.getAttrWhiteList(),
   };
   const xssOption = new XssOption(initializedConfig);
   const xss = new Xss(xssOption);
@@ -330,9 +330,8 @@ module.exports = function(crowi, app) {
   async function showTopPage(req, res, next) {
     const portalPath = req.path;
     const revisionId = req.query.revision;
-    const layoutName = configManager.getConfig('crowi', 'customize:layout');
 
-    const view = `layout-${layoutName}/page_list`;
+    const view = 'layout-growi/page_list';
     const renderVars = { path: portalPath };
 
     let portalPage = await Page.findByPathAndViewer(portalPath, req.user);
@@ -364,7 +363,6 @@ module.exports = function(crowi, app) {
   async function showPageForGrowiBehavior(req, res, next) {
     const path = getPathFromRequest(req);
     const revisionId = req.query.revision;
-    const layoutName = configManager.getConfig('crowi', 'customize:layout');
 
     let page = await Page.findByPathAndViewer(path, req.user);
 
@@ -384,7 +382,7 @@ module.exports = function(crowi, app) {
     const offset = parseInt(req.query.offset) || 0;
     const renderVars = {};
 
-    let view = `layout-${layoutName}/page`;
+    let view = 'layout-growi/page';
 
     page.initLatestRevisionField(revisionId);
 
@@ -406,7 +404,7 @@ module.exports = function(crowi, app) {
 
     if (isUserPage(page.path)) {
       // change template
-      view = `layout-${layoutName}/user_page`;
+      view = 'layout-growi/user_page';
       await addRenderVarsForUserPage(renderVars, page, req.user);
     }
 
@@ -453,13 +451,11 @@ module.exports = function(crowi, app) {
     const { linkId } = req.params;
     const revisionId = req.query.revision;
 
-    const layoutName = configManager.getConfig('crowi', 'customize:layout');
-
     const shareLink = await ShareLink.findOne({ _id: linkId }).populate('relatedPage');
 
     if (shareLink == null || shareLink.relatedPage == null) {
       // page or sharelink are not found
-      return res.render(`layout-${layoutName}/not_found_shared_page`);
+      return res.render('layout-growi/not_found_shared_page');
     }
 
     const renderVars = {};
@@ -469,7 +465,7 @@ module.exports = function(crowi, app) {
     // check if share link is expired
     if (shareLink.isExpired()) {
       // page is not found
-      return res.render(`layout-${layoutName}/expired_shared_page`, renderVars);
+      return res.render('layout-growi/expired_shared_page', renderVars);
     }
 
     let page = shareLink.relatedPage;
@@ -491,7 +487,7 @@ module.exports = function(crowi, app) {
     addRenderVarsForScope(renderVars, page);
 
     await interceptorManager.process('beforeRenderPage', req, res, renderVars);
-    return res.render(`layout-${layoutName}/shared_page`, renderVars);
+    return res.render('layout-growi/shared_page', renderVars);
   };
 
   /**
@@ -528,19 +524,18 @@ module.exports = function(crowi, app) {
     const path = getPathFromRequest(req);
 
     const isCreatable = Page.isCreatableName(path);
-    const layoutName = configManager.getConfig('crowi', 'customize:layout');
 
     let view;
     const renderVars = { path };
 
     if (!isCreatable) {
-      view = `layout-${layoutName}/not_creatable`;
+      view = 'layout-growi/not_creatable';
     }
     else if (req.isForbidden) {
-      view = `layout-${layoutName}/forbidden`;
+      view = 'layout-growi/forbidden';
     }
     else {
-      view = `layout-${layoutName}/not_found`;
+      view = 'layout-growi/not_found';
 
       // retrieve templates
       if (req.user != null) {
@@ -571,7 +566,6 @@ module.exports = function(crowi, app) {
   actions.deletedPageListShow = async function(req, res) {
     // normalizePath makes '/trash/' -> '/trash'
     const path = pathUtils.normalizePath(`/trash${getPathFromRequest(req)}`);
-    const layoutName = configManager.getConfig('crowi', 'customize:layout');
 
     const limit = 50;
     const offset = parseInt(req.query.offset) || 0;
@@ -596,7 +590,7 @@ module.exports = function(crowi, app) {
 
     renderVars.pager = generatePager(result.offset, result.limit, result.totalCount);
     renderVars.pages = result.pages;
-    res.render(`layout-${layoutName}/page_list`, renderVars);
+    res.render('layout-growi/page_list', renderVars);
   };
 
   /**

+ 20 - 17
src/server/service/app.js

@@ -38,17 +38,9 @@ class AppService extends S2sMessageHandlable {
   async handleS2sMessage(s2sMessage) {
     logger.info('Invoke post installation process by pubsub notification');
 
-    const { crowi, configManager, s2sMessagingService } = this;
-
-    // load config and setup
-    await configManager.loadConfigs();
-
-    const isInstalled = this.crowi.configManager.getConfig('crowi', 'app:installed');
-    if (isInstalled) {
-      crowi.setupAfterInstall();
-
-      // remove message handler
-      s2sMessagingService.removeMessageHandler(this);
+    const isDBInitialized = await this.isDBInitialized(true);
+    if (isDBInitialized) {
+      this.setupAfterInstall();
     }
   }
 
@@ -64,9 +56,6 @@ class AppService extends S2sMessageHandlable {
       catch (e) {
         logger.error('Failed to publish post installation message with S2sMessagingService: ', e.message);
       }
-
-      // remove message handler
-      s2sMessagingService.removeMessageHandler(this);
     }
 
   }
@@ -113,9 +102,23 @@ class AppService extends S2sMessageHandlable {
     await this.configManager.updateConfigsInTheSameNamespace('crowi', initialConfig, true);
   }
 
-  async isDBInitialized() {
-    const appInstalled = await this.configManager.getConfigFromDB('crowi', 'app:installed');
-    return appInstalled;
+  async isDBInitialized(forceReload) {
+    if (forceReload) {
+      // load configs
+      await this.configManager.loadConfigs();
+    }
+    return this.configManager.getConfigFromDB('crowi', 'app:installed');
+  }
+
+  async setupAfterInstall() {
+    this.crowi.pluginService.autoDetectAndLoadPlugins();
+    this.crowi.setupRoutesAtLast();
+
+    // remove message handler
+    const { s2sMessagingService } = this;
+    if (s2sMessagingService != null) {
+      this.s2sMessagingService.removeMessageHandler(this);
+    }
   }
 
 }

+ 12 - 0
src/server/service/config-loader.js

@@ -380,6 +380,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    TYPES.NUMBER,
     default: 120,
   },
+  PROMSTER_ENABLED: {
+    ns:      'crowi',
+    key:     'promster:isEnabled',
+    type:    TYPES.BOOLEAN,
+    default: false,
+  },
+  PROMSTER_PORT: {
+    ns:      'crowi',
+    key:     'promster:port',
+    type:    TYPES.NUMBER,
+    default: 7788,
+  },
 };
 
 class ConfigLoader {

+ 221 - 31
yarn.lock

@@ -1761,6 +1761,39 @@
   dependencies:
     mkdirp "^1.0.4"
 
+"@promster/express@^5.0.1":
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/@promster/express/-/express-5.0.1.tgz#192a37cf04a1cf59c5aac6332d53508ddd6de5e5"
+  integrity sha512-o8Uk9K9ZAAT4vwr2LRKETJGzgjw02WbkhcBgtUp2Ep8qRXdkNo7/Hl5NDBw6v5bIod/La/vSq6gUdSPPVWO2dw==
+  dependencies:
+    "@promster/metrics" "^6.0.0"
+    merge-options "3.0.3"
+    tslib "2.0.3"
+
+"@promster/metrics@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/@promster/metrics/-/metrics-6.0.0.tgz#7aa33934f00e4c00dcc39de18772e98c2bd485ae"
+  integrity sha512-+TzrQE/nlKIBWjmBKilOSoR6vM0fYqZVEILRFo1EkrRKUWKeqP+DxOGF6hnxv3VZQP0oLYXSnHgIyKsB0EOpGA==
+  dependencies:
+    lodash.memoize "4.1.2"
+    lodash.once "4.1.1"
+    merge-options "3.0.3"
+    optional "0.1.4"
+    ts-essentials "7.0.1"
+    tslib "2.0.3"
+    url "0.11.0"
+    url-value-parser "2.0.1"
+  optionalDependencies:
+    gc-stats "1.4.0"
+
+"@promster/server@^6.0.0":
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/@promster/server/-/server-6.0.0.tgz#f3cbbb33ec72690bbe23bdd1088490f81a35e484"
+  integrity sha512-1pc3sy3Y9cOpTBZVEeXMDMTD1CvnztvLn2Y21SiIUSyy2PnABL/eIcYRM304xKdRvV8fDmTJkNY4fX+bfdlKHA==
+  dependencies:
+    "@promster/metrics" "^6.0.0"
+    tslib "2.0.3"
+
 "@sindresorhus/is@^0.7.0":
   version "0.7.0"
   resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.7.0.tgz#9a06f4f137ee84d7df0460c1fdb1135ffa6c50fd"
@@ -2762,13 +2795,12 @@ axios@0.17.1:
     follow-redirects "^1.2.5"
     is-buffer "^1.1.5"
 
-axios@^0.19.0:
-  version "0.19.0"
-  resolved "https://registry.yarnpkg.com/axios/-/axios-0.19.0.tgz#8e09bff3d9122e133f7b8101c8fbdd00ed3d2ab8"
-  integrity sha512-1uvKqKQta3KBxIz14F2v06AEHZ/dIoeKfbTRkK1E5oqjDnuEerLmYTgJB5AiQZHJcljpg1TuRzdjDR06qNk0DQ==
+axios@^0.21.1:
+  version "0.21.1"
+  resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
+  integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
   dependencies:
-    follow-redirects "1.5.10"
-    is-buffer "^2.0.2"
+    follow-redirects "^1.10.0"
 
 babel-eslint@^10.0.1:
   version "10.0.1"
@@ -3054,6 +3086,11 @@ bindings@^1.5.0:
   dependencies:
     file-uri-to-path "1.0.0"
 
+bintrees@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/bintrees/-/bintrees-1.0.1.tgz#0e655c9b9c2435eaab68bf4027226d2b55a34524"
+  integrity sha1-DmVcm5wkNeqraL9AJyJtK1WjRSQ=
+
 bl@^2.2.1:
   version "2.2.1"
   resolved "https://registry.yarnpkg.com/bl/-/bl-2.2.1.tgz#8c11a7b730655c5d56898cdc871224f40fd901d5"
@@ -4765,7 +4802,7 @@ debug@2, debug@2.6.9, debug@^2.0.0, debug@^2.2.0, debug@^2.3.3, debug@^2.6.8, de
   dependencies:
     ms "2.0.0"
 
-debug@3.1.0, debug@=3.1.0, debug@~3.1.0:
+debug@3.1.0, debug@~3.1.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/debug/-/debug-3.1.0.tgz#5bb5a0672628b64149566ba16819e61518c67261"
   integrity sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==
@@ -4901,6 +4938,11 @@ detect-indent@^5.0.0:
   resolved "https://registry.yarnpkg.com/detect-indent/-/detect-indent-5.0.0.tgz#3871cc0a6a002e8c3e5b3cf7f336264675f06b9d"
   integrity sha1-OHHMCmoALow+Wzz38zYmRnXwa50=
 
+detect-libc@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
+
 detect-newline@^3.0.0:
   version "3.1.0"
   resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651"
@@ -6359,12 +6401,10 @@ focus-trap@^2.4.5:
   dependencies:
     tabbable "^1.0.3"
 
-follow-redirects@1.5.10:
-  version "1.5.10"
-  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.5.10.tgz#7b7a9f9aea2fdff36786a94ff643ed07f4ff5e2a"
-  integrity sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==
-  dependencies:
-    debug "=3.1.0"
+follow-redirects@^1.10.0:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.13.1.tgz#5f69b813376cee4fd0474a3aba835df04ab763b7"
+  integrity sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg==
 
 follow-redirects@^1.2.5:
   version "1.7.0"
@@ -6482,6 +6522,13 @@ fs-extra@^9.0.1:
     jsonfile "^6.0.1"
     universalify "^1.0.0"
 
+fs-minipass@^1.2.5:
+  version "1.2.7"
+  resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.7.tgz#ccff8570841e7fe4265693da88936c55aed7f7c7"
+  integrity sha512-GWSSJGFy4e9GUeCcbIkED+bgAoFyj7XF1mV8rma3QW4NIqX9Kyx79N/PF61H5udOV3aY1IaMLs6pGbH71nlCTA==
+  dependencies:
+    minipass "^2.6.0"
+
 fs-minipass@^2.0.0:
   version "2.1.0"
   resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb"
@@ -6572,6 +6619,14 @@ gaze@^1.0.0:
   dependencies:
     globule "^1.0.0"
 
+gc-stats@1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/gc-stats/-/gc-stats-1.4.0.tgz#66cd194c5a8eae1138407300bc6cb42c2f6f3cd6"
+  integrity sha512-4FcCj9e8j8rCjvLkqRpGZBLgTC/xr9XEf5By3x77cDucWWB3pJK6FEwXZCTCbb4z8xdaOoi4owBNrvn3ciDdxA==
+  dependencies:
+    nan "^2.13.2"
+    node-pre-gyp "^0.13.0"
+
 gcp-metadata@^3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-3.0.0.tgz#6e43f899728d0e1bb7831631cf3f86cc9e4de3aa"
@@ -7335,7 +7390,7 @@ iconv-lite@0.4.23:
   dependencies:
     safer-buffer ">= 2.1.2 < 3"
 
-iconv-lite@0.4.24, iconv-lite@^0.4.24:
+iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4:
   version "0.4.24"
   resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
   dependencies:
@@ -7360,6 +7415,13 @@ iferr@^0.1.5:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
 
+ignore-walk@^3.0.1:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37"
+  integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==
+  dependencies:
+    minimatch "^3.0.4"
+
 ignore@^4.0.6:
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
@@ -7613,11 +7675,6 @@ is-buffer@^2.0.0, is-buffer@~2.0.4:
   resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.4.tgz#3e572f23c8411a5cfd9557c849e3665e0b290623"
   integrity sha512-Kq1rokWXOPXWuaMAqZiJW4XxsmD9zGx9q4aePabbn3qCRGedtH7Cm+zV8WETitMfu1wdh+Rvd6w5egwSngUX2A==
 
-is-buffer@^2.0.2:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.3.tgz#4ecf3fcf749cbd1e472689e109ac66261a25e725"
-  integrity sha512-U15Q7MXTuZlrbymiz95PJpZxu8IlipAp4dtS3wOdgPXx3mqBnslrWU14kxfHB+Py/+2PVKSr37dMAgM2A4uArw==
-
 is-callable@^1.1.1, is-callable@^1.1.3:
   version "1.1.3"
   resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.1.3.tgz#86eb75392805ddc33af71c92a0eedf74ee7604b2"
@@ -7827,6 +7884,11 @@ is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
 
+is-plain-obj@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+  integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
 is-plain-object@^2.0.1, is-plain-object@^2.0.3, 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"
@@ -8943,10 +9005,15 @@ lodash.isplainobject@^4.0.6:
   resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb"
   integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=
 
-lodash.memoize@^4.1.2:
+lodash.memoize@4.1.2, lodash.memoize@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
 
+lodash.once@4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
+  integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
+
 lodash.set@^4.3.2:
   version "4.3.2"
   resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@@ -9368,6 +9435,13 @@ merge-descriptors@1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61"
 
+merge-options@3.0.3:
+  version "3.0.3"
+  resolved "https://registry.yarnpkg.com/merge-options/-/merge-options-3.0.3.tgz#802b401f0de8dfae00d2a1e2dab9759b3dd98fe4"
+  integrity sha512-jytfjQxL5mVrtD9O24zOXU4neV3uVbQdn1F0o1pzSa1yH9LTEUOtfwpWSsyAxrrrXqAFTxaU4ynqkmekHLvYew==
+  dependencies:
+    is-plain-obj "^2.1.0"
+
 merge-stream@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
@@ -9624,6 +9698,14 @@ minipass-pipeline@^1.2.2:
   dependencies:
     minipass "^3.0.0"
 
+minipass@^2.6.0, minipass@^2.8.6, minipass@^2.9.0:
+  version "2.9.0"
+  resolved "https://registry.yarnpkg.com/minipass/-/minipass-2.9.0.tgz#e713762e7d3e32fed803115cf93e04bca9fcc9a6"
+  integrity sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==
+  dependencies:
+    safe-buffer "^5.1.2"
+    yallist "^3.0.0"
+
 minipass@^3.0.0, minipass@^3.1.1:
   version "3.1.3"
   resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
@@ -9631,6 +9713,13 @@ minipass@^3.0.0, minipass@^3.1.1:
   dependencies:
     yallist "^4.0.0"
 
+minizlib@^1.2.1:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-1.3.3.tgz#2290de96818a34c29551c8a8d301216bd65a861d"
+  integrity sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==
+  dependencies:
+    minipass "^2.9.0"
+
 minizlib@^2.1.1:
   version "2.1.2"
   resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931"
@@ -9896,6 +9985,15 @@ ncp@~2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3"
 
+needle@^2.2.1:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/needle/-/needle-2.6.0.tgz#24dbb55f2509e2324b4a99d61f413982013ccdbe"
+  integrity sha512-KKYdza4heMsEfSWD7VPUIz3zX2XDwOyX2d+geb4vrERZMT5RMU6ujjaD+I5Yr54uZxQ2w6XRTAhHBbSCyovZBg==
+  dependencies:
+    debug "^3.2.6"
+    iconv-lite "^0.4.4"
+    sax "^1.2.4"
+
 negotiator@0.6.1:
   version "0.6.1"
   resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.1.tgz#2b327184e8992101177b28563fb5e7102acd0ca9"
@@ -10059,6 +10157,22 @@ node-object-hash@^1.2.0:
   resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94"
   integrity sha512-UdS4swXs85fCGWWf6t6DMGgpN/vnlKeSGEQ7hJcrs7PBFoxoKLmibc3QRb7fwiYsjdL7PX8iI/TMSlZ90dgHhQ==
 
+node-pre-gyp@^0.13.0:
+  version "0.13.0"
+  resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.13.0.tgz#df9ab7b68dd6498137717838e4f92a33fc9daa42"
+  integrity sha512-Md1D3xnEne8b/HGVQkZZwV27WUi1ZRuZBij24TNaZwUPU3ZAFtvT6xxJGaUVillfmMKnn5oD1HoGsp2Ftik7SQ==
+  dependencies:
+    detect-libc "^1.0.2"
+    mkdirp "^0.5.1"
+    needle "^2.2.1"
+    nopt "^4.0.1"
+    npm-packlist "^1.1.6"
+    npmlog "^4.0.2"
+    rc "^1.2.7"
+    rimraf "^2.6.1"
+    semver "^5.3.0"
+    tar "^4"
+
 node-readfiles@^0.2.0:
   version "0.2.0"
   resolved "https://registry.yarnpkg.com/node-readfiles/-/node-readfiles-0.2.0.tgz#dbbd4af12134e2e635c245ef93ffcf6f60673a5d"
@@ -10137,6 +10251,14 @@ nopt@1.0.10:
   dependencies:
     abbrev "1"
 
+nopt@^4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48"
+  integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==
+  dependencies:
+    abbrev "1"
+    osenv "^0.1.4"
+
 normalize-package-data@^2.3.2, normalize-package-data@^2.3.4, normalize-package-data@^2.5.0:
   version "2.5.0"
   resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-2.5.0.tgz#e66db1838b200c1dfc233225d12cb36520e234a8"
@@ -10189,6 +10311,27 @@ normalize-url@^3.0.0:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.2.0.tgz#98d0948afc82829f374320f405fe9ca55a5f8567"
 
+npm-bundled@^1.0.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b"
+  integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==
+  dependencies:
+    npm-normalize-package-bin "^1.0.1"
+
+npm-normalize-package-bin@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2"
+  integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==
+
+npm-packlist@^1.1.6:
+  version "1.4.8"
+  resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e"
+  integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==
+  dependencies:
+    ignore-walk "^3.0.1"
+    npm-bundled "^1.0.1"
+    npm-normalize-package-bin "^1.0.1"
+
 npm-run-all@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.2.tgz#90d62d078792d20669139e718621186656cea056"
@@ -10216,7 +10359,7 @@ npm-run-path@^4.0.0:
   dependencies:
     path-key "^3.0.0"
 
-"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0:
+"npmlog@0 || 1 || 2 || 3 || 4", npmlog@^4.0.0, npmlog@^4.0.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
   integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
@@ -10557,6 +10700,11 @@ optimize-css-assets-webpack-plugin@^5.0.3:
     cssnano "^4.1.10"
     last-call-webpack-plugin "^3.0.0"
 
+optional@0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/optional/-/optional-0.1.4.tgz#cdb1a9bedc737d2025f690ceeb50e049444fd5b3"
+  integrity sha512-gtvrrCfkE08wKcgXaVwQVgwEQ8vel2dc5DDBn9RLQZ3YtmtkBss6A2HY6BnJH4N/4Ku97Ri/SF8sNWE2225WJw==
+
 optionator@^0.8.1, optionator@^0.8.2:
   version "0.8.2"
   resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.8.2.tgz#364c5e409d3f4d6301d6c0b4c05bba50180aeb64"
@@ -10596,7 +10744,7 @@ os-tmpdir@^1.0.0, os-tmpdir@~1.0.2:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
 
-osenv@0:
+osenv@0, osenv@^0.1.4:
   version "0.1.5"
   resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
   integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
@@ -11675,6 +11823,13 @@ progress@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.0.tgz#8a1be366bf8fc23db2bd23f10c6fe920b4389d1f"
 
+prom-client@^13.0.0:
+  version "13.0.0"
+  resolved "https://registry.yarnpkg.com/prom-client/-/prom-client-13.0.0.tgz#7e883e1c10c2d471258d3acf2d386197ffc819bd"
+  integrity sha512-M7ZNjIO6x+2R/vjSD13yjJPjpoZA8eEwH2Bp2Re0/PvzozD7azikv+SaBtZes4Q1ca/xHjZ4RSCuTag3YZLg1A==
+  dependencies:
+    tdigest "^0.1.1"
+
 promise-inflight@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3"
@@ -11939,7 +12094,7 @@ raw-body@^2.3.2:
     iconv-lite "0.4.23"
     unpipe "1.0.0"
 
-rc@>=1.2.8:
+rc@>=1.2.8, rc@^1.2.7:
   version "1.2.8"
   resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
   integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
@@ -12877,7 +13032,7 @@ right-align@^0.1.1:
   dependencies:
     align-text "^0.1.1"
 
-rimraf@2, rimraf@^2.6.2:
+rimraf@2, rimraf@^2.6.1, rimraf@^2.6.2:
   version "2.7.1"
   resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
   integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@@ -13060,7 +13215,7 @@ sax@1.2.1:
   version "1.2.1"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.1.tgz#7b8e656190b228e81a66aea748480d828cd2d37a"
 
-sax@>=0.6.0, sax@^1.2.1, sax@~1.2.4:
+sax@>=0.6.0, sax@^1.2.1, sax@^1.2.4, sax@~1.2.4:
   version "1.2.4"
   resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
   integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@@ -14436,6 +14591,19 @@ tar@^2.0.0:
     fstream "^1.0.12"
     inherits "2"
 
+tar@^4:
+  version "4.4.13"
+  resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525"
+  integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==
+  dependencies:
+    chownr "^1.1.1"
+    fs-minipass "^1.2.5"
+    minipass "^2.8.6"
+    minizlib "^1.2.1"
+    mkdirp "^0.5.0"
+    safe-buffer "^5.1.2"
+    yallist "^3.0.3"
+
 tar@^6.0.2:
   version "6.0.5"
   resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.5.tgz#bde815086e10b39f1dcd298e89d596e1535e200f"
@@ -14448,6 +14616,13 @@ tar@^6.0.2:
     mkdirp "^1.0.3"
     yallist "^4.0.0"
 
+tdigest@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/tdigest/-/tdigest-0.1.1.tgz#2e3cb2c39ea449e55d1e6cd91117accca4588021"
+  integrity sha1-Ljyyw56kSeVdHmzZEReszKRYgCE=
+  dependencies:
+    bintrees "1.0.1"
+
 teeny-request@^5.2.1:
   version "5.2.1"
   resolved "https://registry.yarnpkg.com/teeny-request/-/teeny-request-5.2.1.tgz#a6394db8359b87e64e47eeb2fbf34a65c9a751ff"
@@ -14725,6 +14900,16 @@ tryer@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
 
+ts-essentials@7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-7.0.1.tgz#d205508cae0cdadfb73c89503140cf2228389e2d"
+  integrity sha512-8lwh3QJtIc1UWhkQtr9XuksXu3O0YQdEE5g79guDfhCaU1FWTDIEDZ1ZSx4HTHUmlJZ8L812j3BZQ4a0aOUkSA==
+
+tslib@2.0.3, tslib@^2.0.3:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
+  integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
+
 tslib@^1.8.1:
   version "1.10.0"
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a"
@@ -14740,11 +14925,6 @@ tslib@^1.9.3:
   resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
   integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
 
-tslib@^2.0.3:
-  version "2.0.3"
-  resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.0.3.tgz#8e0741ac45fc0c226e58a17bfc3e64b9bc6ca61c"
-  integrity sha512-uZtkfKblCEQtZKBF6EBXVZeQNl82yqtDQdv+eck8u7tdPxjLu2/lp5/uPW+um2tpuxINHWy3GhiccY7QgEaVHQ==
-
 tsscmp@1.0.6:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/tsscmp/-/tsscmp-1.0.6.tgz#85b99583ac3589ec4bfef825b5000aa911d605eb"
@@ -15101,6 +15281,11 @@ url-to-options@^1.0.1:
   resolved "https://registry.yarnpkg.com/url-to-options/-/url-to-options-1.0.1.tgz#1505a03a289a48cbd7a434efbaeec5055f5633a9"
   integrity sha1-FQWgOiiaSMvXpDTvuu7FBV9WM6k=
 
+url-value-parser@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/url-value-parser/-/url-value-parser-2.0.1.tgz#c8179a095ab9ec1f5aa17ca36af5af396b4e95ed"
+  integrity sha512-bexECeREBIueboLGM3Y1WaAzQkIn+Tca/Xjmjmfd0S/hFHSCEoFkNh0/D0l9G4K74MkEP/lLFRlYnxX3d68Qgw==
+
 url@0.10.3:
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
@@ -15108,7 +15293,7 @@ url@0.10.3:
     punycode "1.3.2"
     querystring "0.2.0"
 
-url@^0.11.0:
+url@0.11.0, url@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
   dependencies:
@@ -15790,6 +15975,11 @@ yallist@^2.1.2:
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52"
   integrity sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=
 
+yallist@^3.0.0, yallist@^3.0.3:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd"
+  integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==
+
 yallist@^3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.0.2.tgz#8452b4bb7e83c7c188d8041c1a837c773d6d8bb9"