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

Merge branch 'master' into support/107699-next-PageStatusAlert

Yuken Tezuka 3 лет назад
Родитель
Сommit
239361a0d8
67 измененных файлов с 1328 добавлено и 229 удалено
  1. 0 17
      packages/app/bin/templates/plugin-definitions.js.swig
  2. 0 1
      packages/app/package.json
  3. 0 1
      packages/app/public/static/locales/en_US/admin.json
  4. 0 1
      packages/app/public/static/locales/ja_JP/admin.json
  5. 0 1
      packages/app/public/static/locales/zh_CN/admin.json
  6. 0 2
      packages/app/src/client/services/AdminHomeContainer.js
  7. 51 0
      packages/app/src/client/services/activate-plugin.ts
  8. 0 8
      packages/app/src/components/Admin/AdminHome/AdminHome.jsx
  9. 0 55
      packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx
  10. 21 0
      packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx
  11. 3 0
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  12. 13 0
      packages/app/src/components/Admin/PluginsExtension/Loading.js
  13. 68 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss
  14. 85 0
      packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx
  15. 91 0
      packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx
  16. 58 0
      packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx
  17. 2 2
      packages/app/src/components/CreateTemplateModal.jsx
  18. 1 1
      packages/app/src/components/EmptyTrashModal.tsx
  19. 1 1
      packages/app/src/components/Layout/AdminLayout.tsx
  20. 8 2
      packages/app/src/components/Navbar/GrowiNavbar.tsx
  21. 14 1
      packages/app/src/components/Page.tsx
  22. 1 1
      packages/app/src/components/PageAlert/FixPageGrantAlert.tsx
  23. 3 2
      packages/app/src/components/PageCreateModal.jsx
  24. 1 1
      packages/app/src/components/PageCreateModal.module.scss
  25. 1 1
      packages/app/src/components/PageDeleteModal.tsx
  26. 18 6
      packages/app/src/components/PageEditor.tsx
  27. 22 0
      packages/app/src/components/PageEditor/CodeMirrorEditor.jsx
  28. 8 0
      packages/app/src/components/PageEditor/EditorIcon.jsx
  29. 6 2
      packages/app/src/components/PageList/PageListItemL.tsx
  30. 1 1
      packages/app/src/components/PrivateLegacyPages.tsx
  31. 1 1
      packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx
  32. 1 1
      packages/app/src/components/PutbackPageModal.jsx
  33. 1 1
      packages/app/src/components/Sidebar/RecentChanges.tsx
  34. 117 0
      packages/app/src/components/TemplateModal.tsx
  35. 30 0
      packages/app/src/components/TemplateTab.tsx
  36. 2 2
      packages/app/src/components/Theme/ThemeDefault.global.scss
  37. 1 1
      packages/app/src/components/Theme/ThemeDefault.tsx
  38. 12 5
      packages/app/src/components/Theme/utils/ThemeInjector.tsx
  39. 21 0
      packages/app/src/interfaces/github-api.ts
  40. 26 0
      packages/app/src/interfaces/plugin.ts
  41. 5 0
      packages/app/src/pages/_app.page.tsx
  42. 49 6
      packages/app/src/pages/_document.page.tsx
  43. 0 3
      packages/app/src/pages/admin/index.page.tsx
  44. 53 0
      packages/app/src/pages/admin/plugins.page.tsx
  45. 23 3
      packages/app/src/pages/me/[[...path]].page.tsx
  46. 22 1
      packages/app/src/pages/tags.page.tsx
  47. 22 1
      packages/app/src/pages/trash.page.tsx
  48. 3 0
      packages/app/src/server/crowi/express-init.js
  49. 21 0
      packages/app/src/server/crowi/index.js
  50. 1 2
      packages/app/src/server/middlewares/application-not-installed.js
  51. 40 0
      packages/app/src/server/models/growi-plugin.ts
  52. 0 76
      packages/app/src/server/plugins/plugin-utils.js
  53. 0 4
      packages/app/src/server/routes/apiv3/admin-home.js
  54. 4 1
      packages/app/src/server/routes/apiv3/index.js
  55. 29 0
      packages/app/src/server/routes/apiv3/plugins-extention.ts
  56. 2 8
      packages/app/src/server/routes/index.js
  57. 139 0
      packages/app/src/server/service/plugin.ts
  58. 15 0
      packages/app/src/services/renderer/renderer.tsx
  59. 14 5
      packages/app/src/stores/renderer.tsx
  60. 61 0
      packages/app/src/stores/template.tsx
  61. 27 0
      packages/app/src/stores/useInstalledPlugins.ts
  62. 39 0
      packages/app/src/utils/growi-facade.ts
  63. 47 0
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  64. 0 1
      packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts
  65. 2 0
      packages/core/src/index.ts
  66. 16 0
      packages/core/src/interfaces/growi-facade.ts
  67. 5 0
      packages/core/src/interfaces/template.ts

+ 0 - 17
packages/app/bin/templates/plugin-definitions.js.swig

@@ -1,17 +0,0 @@
-/*
- * !! don't commit this file !!
- * !!      just revert       !!
- */
-module.exports = [
-  {% for definition in definitions %}{
-    name: '{{ definition.name }}',
-    meta: require('{{ definition.name }}'),
-    entries: [
-      {% for entryPath in definition.entries %}
-      require('{{ entryPath }}').default,
-      {% endfor %}
-    ]
-  },
-  {% endfor %}
-
-]

+ 0 - 1
packages/app/package.json

@@ -47,7 +47,6 @@
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "resources:hackmd": "yarn lerna run build --scope=@growi/hackmd",
     "resources:dummy": "true",
-    "// resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },

+ 0 - 1
packages/app/public/static/locales/en_US/admin.json

@@ -285,7 +285,6 @@
     "system_information": "System information",
     "wiki_administrator": "Only wiki administrator can access this page",
     "assign_administrator": "You can assign the selected user to be a wiki administrator on the User Management page using the 'Give admin access' button",
-    "list_of_installed_plugins": "List of installed plugins",
     "package_name": "Package name",
     "specified_version": "Specified version",
     "installed_version": "Installed version",

+ 0 - 1
packages/app/public/static/locales/ja_JP/admin.json

@@ -311,7 +311,6 @@
     "system_information": "システム情報",
     "wiki_administrator": "この画面はWiki管理者のみがアクセスできる画面です。",
     "assign_administrator": "「ユーザー管理」から「管理者にする」ボタンを使ってユーザーをWiki管理者に任命することができます。",
-    "list_of_installed_plugins": "インストールされているプラグイン一覧",
     "package_name": "パッケージ名",
     "specified_version": "指定バージョン",
     "installed_version": "インストールされているバージョン",

+ 0 - 1
packages/app/public/static/locales/zh_CN/admin.json

@@ -316,7 +316,6 @@
     "system_information": "系统信息",
     "wiki_administrator": "只有wiki管理员可以访问此页",
     "assign_administrator": "您可以使用“授予管理员访问权限”按钮在“用户管理”页上将所选用户指定为wiki管理员",
-    "list_of_installed_plugins": "已安装插件列表",
     "package_name": "包名称",
     "specified_version": "指定版本",
     "installed_version": "已安装版本",

+ 0 - 2
packages/app/src/client/services/AdminHomeContainer.js

@@ -3,7 +3,6 @@ import { Container } from 'unstated';
 
 import loggerFactory from '~/utils/logger';
 
-import { toastError } from '../util/apiNotification';
 import { apiv3Get } from '../util/apiv3-client';
 
 // eslint-disable-next-line no-unused-vars
@@ -66,7 +65,6 @@ export default class AdminHomeContainer extends Container {
         nodeVersion: adminHomeParams.nodeVersion,
         npmVersion: adminHomeParams.npmVersion,
         yarnVersion: adminHomeParams.yarnVersion,
-        installedPlugins: adminHomeParams.installedPlugins,
         envVars: adminHomeParams.envVars,
         isV5Compatible: adminHomeParams.isV5Compatible,
         isMaintenanceMode: adminHomeParams.isMaintenanceMode,

+ 51 - 0
packages/app/src/client/services/activate-plugin.ts

@@ -0,0 +1,51 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { GrowiPlugin } from '~/interfaces/plugin';
+import { initializeGrowiFacade } from '~/utils/growi-facade';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var pluginActivators: {
+    [key: string]: {
+      activate: () => void,
+      deactivate: () => void,
+    },
+  };
+}
+
+
+export type GrowiPluginManifestEntries = [growiPlugin: GrowiPlugin, manifest: any][];
+
+
+export class ActivatePluginService {
+
+  static async retrievePluginManifests(growiPlugins: GrowiPlugin[]): Promise<GrowiPluginManifestEntries> {
+    const entries: GrowiPluginManifestEntries = [];
+
+    growiPlugins.forEach(async(growiPlugin) => {
+      const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
+      const customManifestStr: string = await readFileSync(manifestPath, 'utf-8');
+      entries.push([growiPlugin, JSON.parse(customManifestStr)]);
+    });
+
+    return entries;
+  }
+
+  static activateAll(): void {
+    initializeGrowiFacade();
+
+    const { pluginActivators } = window;
+
+    if (pluginActivators == null) {
+      return;
+    }
+
+    Object.entries(pluginActivators).forEach(([, activator]) => {
+      activator.activate();
+    });
+  }
+
+}

+ 0 - 8
packages/app/src/components/Admin/AdminHome/AdminHome.jsx

@@ -15,7 +15,6 @@ import { withUnstatedContainers } from '../../UnstatedUtils';
 
 
 import EnvVarsTable from './EnvVarsTable';
-import InstalledPluginTable from './InstalledPluginTable';
 import SystemInfomationTable from './SystemInfomationTable';
 
 const logger = loggerFactory('growi:admin');
@@ -85,13 +84,6 @@ const AdminHome = (props) => {
         </div>
       </div>
 
-      <div className="row mb-5">
-        <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('admin:admin_top.list_of_installed_plugins')}</h2>
-          <InstalledPluginTable />
-        </div>
-      </div>
-
       <div className="row mb-5">
         <div className="col-md-12">
           <h2 className="admin-setting-header">{t('admin:admin_top.list_of_env_vars')}</h2>

+ 0 - 55
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -1,55 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminHomeContainer from '~/client/services/AdminHomeContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-const InstalledPluginTable = (props) => {
-  const { t } = useTranslation();
-  const { adminHomeContainer } = props;
-
-  const { installedPlugins } = adminHomeContainer.state;
-
-  if (installedPlugins == null) {
-    return <></>;
-  }
-
-  return (
-    <table data-testid="admin-installed-plugin-table" className="table table-bordered">
-      <thead>
-        <tr>
-          <th className="text-center">{t('admin:admin_top.package_name')}</th>
-          <th className="text-center">{t('admin:admin_top.specified_version')}</th>
-          <th className="text-center">{t('admin:admin_top.installed_version')}</th>
-        </tr>
-      </thead>
-      <tbody>
-        {adminHomeContainer.state.installedPlugins.map((plugin) => {
-          return (
-            <tr key={plugin.name}>
-              <td>{plugin.name}</td>
-              <td data-hide-in-vrt className="text-center">{plugin.requiredVersion}</td>
-              <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
-            </tr>
-          );
-        })}
-      </tbody>
-    </table>
-  );
-
-};
-
-InstalledPluginTable.propTypes = {
-  adminHomeContainer: PropTypes.instanceOf(AdminHomeContainer).isRequired,
-};
-
-
-/**
- * Wrapper component for using unstated
- */
-const InstalledPluginTableWrapper = withUnstatedContainers(InstalledPluginTable, [AdminHomeContainer]);
-
-export default InstalledPluginTableWrapper;

+ 21 - 0
packages/app/src/components/Admin/Common/AdminInstallButtonRow.tsx

@@ -0,0 +1,21 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+type Props = {
+  onClick: () => void,
+  disabled: boolean,
+
+}
+
+export const AdminInstallButtonRow = (props: Props): JSX.Element => {
+  // TODO: const { t } = useTranslation('admin');
+
+  return (
+    <div className="row my-3">
+      <div className="mx-auto">
+        <button type="button" className="btn btn-primary" onClick={props.onClick} disabled={props.disabled}>Install</button>
+      </div>
+    </div>
+  );
+};

+ 3 - 0
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -36,6 +36,7 @@ const AdminNavigation = (props) => {
       case 'user-groups':              return <><i className="mr-1 icon-fw icon-people"></i>{          t('user_group_management.user_group_management') }</>;
       case 'search':                   return <><i className="mr-1 icon-fw icon-magnifier"></i>{       t('full_text_search_management.full_text_search_management') }</>;
       case 'audit-log':                return <><i className="mr-1 icon-fw icon-feed"></i>{            t('audit_log_management.audit_log')}</>;
+      case 'plugins':                  return <><i className="mr-1 icon-fw icon-puzzle"></i>{           'Plugins Extention'}</>;
       case 'cloud':                    return <><i className="mr-1 icon-fw icon-share-alt"></i>{       t('cloud_setting_management.to_cloud_settings')} </>;
       default:                         return <><i className="mr-1 icon-fw icon-home"></i>{            t('wiki_management_home_page') }</>;
       /* eslint-enable no-multi-spaces, max-len */
@@ -91,6 +92,7 @@ const AdminNavigation = (props) => {
         <MenuLink menu="users"        isListGroupItems isActive={isActiveMenu('/users')} />
         <MenuLink menu="user-groups"  isListGroupItems isActive={isActiveMenu('/user-groups')} />
         <MenuLink menu="audit-log"    isListGroupItems isActive={isActiveMenu('/audit-log')} />
+        <MenuLink menu="plugins"      isListGroupItems isActive={isActiveMenu('/plugins')} />
         <MenuLink menu="search"       isListGroupItems isActive={isActiveMenu('/search')} />
         {growiCloudUri != null && growiAppIdForGrowiCloud != null
           && (
@@ -140,6 +142,7 @@ const AdminNavigation = (props) => {
             {isActiveMenu('/user-groups') &&       <MenuLabel menu="user-groups" />}
             {isActiveMenu('/search') &&            <MenuLabel menu="search" />}
             {isActiveMenu('/audit-log') &&         <MenuLabel menu="audit-log" />}
+            {isActiveMenu('/plugins') &&           <MenuLabel menu="plugins" />}
             {/* eslint-enable no-multi-spaces */}
           </span>
         </button>

+ 13 - 0
packages/app/src/components/Admin/PluginsExtension/Loading.js

@@ -0,0 +1,13 @@
+import {
+  Spinner,
+} from 'reactstrap';
+
+const Loading = () => {
+  return (
+    <Spinner className='d-flex justify-content-center aligh-items-center'>
+      Loading...
+    </Spinner>
+  );
+};
+
+export default Loading;

+ 68 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.module.scss

@@ -0,0 +1,68 @@
+// TODO: Rewrite according to guidelines
+.plugin_card :global {
+
+  .switch__label {
+    position: relative;
+    display: inline-block;
+    width: 50px;
+  }
+  .switch__content {
+    position: relative;
+    display: block;
+    height: 31px;
+    overflow: hidden;
+    cursor: pointer;
+    border-radius: 30px;
+  }
+  .switch__content:before {
+    position: absolute;
+    top: 0;
+    left: 0;
+    display: block;
+    width: calc(100% - 3px);
+    height: calc(100% - 3px);
+    content: '';
+    background-color: #fff;
+    border: 1.5px solid #E5E5EA;
+    border-radius: 30px;
+  }
+  .switch__content:after {
+    position: absolute;
+    top: 50%;
+    left: 50%;
+    display: block;
+    width: 0;
+    height: 0;
+    content: '';
+    background-color: transparent;
+    border-radius: 30px;
+    transition: all .5s;
+  }
+  .switch__input {
+    display: none;
+  }
+
+  .switch__circle {
+    position: absolute;
+    top: 2px;
+    left: 2px;
+    display: block;
+    width: 27px;
+    height: 27px;
+    background-color: #fff;
+    border-radius: 20px;
+    box-shadow: 0 2px 6px #999;
+    transition: all .5s;
+  }
+  .switch__input:checked ~ .switch__circle {
+    left: 21px;
+  }
+
+  .switch__input:checked ~ .switch__content:after {
+    top: 0;
+    left: 0;
+    width: 100%;
+    height: 100%;
+    background-color: #0078D7;
+  }
+}

+ 85 - 0
packages/app/src/components/Admin/PluginsExtension/PluginCard.tsx

@@ -0,0 +1,85 @@
+// import { faCircleArrowDown, faCircleCheck } from '@fortawesome/free-solid-svg-icons';
+// import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+
+import Link from 'next/link';
+
+import styles from './PluginCard.module.scss';
+
+
+type Props = {
+  name: string,
+  url: string,
+  description: string,
+}
+
+export const PluginCard = (props: Props): JSX.Element => {
+  const {
+    name, url, description,
+  } = props;
+  // const [isEnabled, setIsEnabled] = useState(true);
+
+  // const checkboxHandler = useCallback(() => {
+  //   setIsEnabled(false);
+  // }, []);
+
+  return (
+    <div className="card shadow border-0" key={name}>
+      <div className="card-body px-5 py-4 mt-3">
+        <div className="row mb-3">
+          <div className="col-9">
+            <h2 className="card-title h3 border-bottom pb-2 mb-3">
+              <Link href={`${url}`}>{name}</Link>
+            </h2>
+            <p className="card-text text-muted">{description}</p>
+          </div>
+          <div className='col-3'>
+            <div className={`${styles.plugin_card}`}>
+              <div className="switch">
+                <label className="switch__label">
+                  <input type="checkbox" className="switch__input" checked/>
+                  <span className="switch__content"></span>
+                  <span className="switch__circle"></span>
+                </label>
+              </div>
+            </div>
+            {/* <div className="custom-control custom-switch custom-switch-lg custom-switch-slack">
+              <input
+                type="checkbox"
+                className="custom-control-input border-0"
+                checked={isEnabled}
+                onChange={checkboxHandler}
+              />
+              <label className="custom-control-label align-center"></label>
+            </div> */}
+            {/* <Image className="mx-auto" alt="GitHub avator image" src={owner.avatar_url} width={250} height={250} /> */}
+          </div>
+        </div>
+        <div className="row">
+          <div className="col-12 d-flex flex-wrap gap-2">
+            {/* {topics?.map((topic: string) => {
+              return (
+                <span key={`${name}-${topic}`} className="badge rounded-1 mp-bg-light-blue text-dark fw-normal">
+                  {topic}
+                </span>
+              );
+            })} */}
+          </div>
+        </div>
+      </div>
+      <div className="card-footer px-5 border-top-0 mp-bg-light-blue">
+        <p className="d-flex justify-content-between align-self-center mb-0">
+          <span>
+            {/* {owner.login === 'weseek' ? <FontAwesomeIcon icon={faCircleCheck} className="me-1 text-primary" /> : <></>}
+
+            <a href={owner.html_url} target="_blank" rel="noreferrer">
+              {owner.login}
+            </a> */}
+          </span>
+          {/* <span>
+            <FontAwesomeIcon icon={faCircleArrowDown} className="me-1" /> {stargazersCount}
+          </span> */}
+        </p>
+      </div>
+    </div>
+  );
+};

+ 91 - 0
packages/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx

@@ -0,0 +1,91 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+import { toastSuccess, toastError } from '~/client/util/apiNotification';
+import { apiv3Post } from '~/client/util/apiv3-client';
+
+import AdminInstallButtonRow from '../Common/AdminUpdateButtonRow';
+// TODO: error notification (toast, loggerFactory)
+// TODO: i18n
+
+export const PluginInstallerForm = (): JSX.Element => {
+  // const { t } = useTranslation('admin');
+
+  const submitHandler = useCallback(async(e) => {
+    e.preventDefault();
+
+    const formData = e.target.elements;
+
+    const {
+      'pluginInstallerForm[url]': { value: url },
+      // 'pluginInstallerForm[ghBranch]': { value: ghBranch },
+      // 'pluginInstallerForm[ghTag]': { value: ghTag },
+    } = formData;
+
+    const pluginInstallerForm = {
+      url,
+      // ghBranch,
+      // ghTag,
+    };
+
+    try {
+      await apiv3Post('/plugins-extention', { pluginInstallerForm });
+      toastSuccess('Plugin Install Successed!');
+    }
+    catch (err) {
+      toastError(err);
+      // logger.error(err);
+    }
+  }, []);
+
+  return (
+    <form role="form" onSubmit={submitHandler}>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">GitHub Repository URL</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            // defaultValue={adminAppContainer.state.title || ''}
+            name="pluginInstallerForm[url]"
+            placeholder="https://github.com/weseek/growi-plugin-lsx"
+            required
+          />
+          <p className="form-text text-muted">You can install plugins by inputting the GitHub URL.</p>
+          {/* <p className="form-text text-muted">{t('admin:app_setting.sitename_change')}</p> */}
+        </div>
+      </div>
+      {/* <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">branch</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghBranch]"
+            placeholder="main"
+          />
+          <p className="form-text text-muted">branch name</p>
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">tag</label>
+        <div className="col-md-6">
+          <input
+            className="form-control"
+            type="text"
+            name="pluginInstallerForm[ghTag]"
+            placeholder="tags"
+          />
+          <p className="form-text text-muted">tag name</p>
+        </div>
+      </div> */}
+
+      <div className="row my-3">
+        <div className="mx-auto">
+          <button type="submit" className="btn btn-primary">Install</button>
+        </div>
+      </div>
+    </form>
+  );
+};

+ 58 - 0
packages/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import type { SearchResultItem } from '~/interfaces/github-api';
+import { useInstalledPlugins } from '~/stores/useInstalledPlugins';
+
+import Loading from './Loading';
+import { PluginCard } from './PluginCard';
+import { PluginInstallerForm } from './PluginInstallerForm';
+
+
+// TODO: i18n
+
+export const PluginsExtensionPageContents = (): JSX.Element => {
+  // const { data, error } = useInstalledPlugins();
+
+  // if (data == null) {
+  //   return <Loading />;
+  // }
+
+  return (
+    <div>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugin Installer</h2>
+          <PluginInstallerForm />
+        </div>
+      </div>
+
+      <div className="row mb-5">
+        <div className="col-lg-12">
+          <h2 className="admin-setting-header">Plugins</h2>
+          <div className="d-grid gap-5">
+            <PluginCard
+              name={'growi-plugin-templates-for-office'}
+              url={'https://github.com/weseek/growi-plugin-templates-for-office'}
+              description={'GROWI markdown templates for office.'}
+            />
+            {/* <PluginCard
+              name={'growi-plugin-theme-welcome-to-fumiya-room'}
+              url={'https://github.com/weseek/growi-plugin-theme-welcome-to-fumiya-room'}
+              description={'Welcome to fumiya\'s room! This is very very "latest" design...'}
+            /> */}
+            <PluginCard
+              name={'growi-plugin-copy-code-to-clipboard'}
+              url={'https://github.com/weseek/growi-plugin-copy-code-to-clipboard'}
+              description={'Add copy button on code blocks.'}
+            />
+            {/* {data?.items.map((item: SearchResultItem) => {
+              return <PluginCard key={item.name} {...item} />;
+            })} */}
+          </div>
+        </div>
+      </div>
+
+    </div>
+  );
+};

+ 2 - 2
packages/app/src/components/CreateTemplateModal.jsx

@@ -1,8 +1,8 @@
 import React from 'react';
 
 import { pathUtils } from '@growi/core';
-import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
+import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import urljoin from 'url-join';
 
@@ -42,7 +42,7 @@ const CreateTemplateModal = (props) => {
   }
 
   return (
-    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal" className="grw-create-page">
+    <Modal isOpen={props.isOpen} toggle={props.onClose} data-testid="page-template-modal">
       <ModalHeader tag="h4" toggle={props.onClose} className="bg-primary text-light">
         {t('template.modal_label.Create/Edit Template Page')}
       </ModalHeader>

+ 1 - 1
packages/app/src/components/EmptyTrashModal.tsx

@@ -59,7 +59,7 @@ const EmptyTrashModal: FC = () => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal" className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeEmptyTrashModal} data-testid="page-delete-modal">
       <ModalHeader tag="h4" toggle={closeEmptyTrashModal} className="bg-danger text-light">
         <i className="icon-fw icon-fire"></i>
         {t('modal_empty.empty_the_trash')}

+ 1 - 1
packages/app/src/components/Layout/AdminLayout.tsx

@@ -28,7 +28,7 @@ const AdminLayout = ({
   return (
     <RawLayout title={title}>
       <div className={`admin-page ${styles['admin-page']}`}>
-        <GrowiNavbar />
+        <GrowiNavbar isGlobalSearchHidden={true} />
 
         <header className="py-0 container-fluid">
           <h1 className="title px-3">{componentTitle}</h1>

+ 8 - 2
packages/app/src/components/Navbar/GrowiNavbar.tsx

@@ -136,7 +136,13 @@ const GrowiNavbarLogo: FC<NavbarLogoProps> = memo((props: NavbarLogoProps) => {
 
 GrowiNavbarLogo.displayName = 'GrowiNavbarLogo';
 
-export const GrowiNavbar = (): JSX.Element => {
+type Props = {
+  isGlobalSearchHidden?: boolean
+}
+
+export const GrowiNavbar = (props: Props): JSX.Element => {
+
+  const { isGlobalSearchHidden } = props;
 
   const GlobalSearch = dynamic<GlobalSearchProps>(() => import('./GlobalSearch').then(mod => mod.GlobalSearch), { ssr: false });
 
@@ -169,7 +175,7 @@ export const GrowiNavbar = (): JSX.Element => {
       </ul>
 
       <div className="grw-global-search-container position-absolute">
-        { isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
+        { !isGlobalSearchHidden && isSearchServiceConfigured && !isDeviceSmallerThanMd && !isSearchPage && (
           <GlobalSearch />
         ) }
       </div>

+ 14 - 1
packages/app/src/components/Page.tsx

@@ -25,6 +25,7 @@ import {
   useCurrentPageTocNode,
   useIsMobile,
 } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
@@ -43,6 +44,7 @@ declare global {
 const GridEditModal = dynamic(() => import('./PageEditor/GridEditModal'), { ssr: false });
 const LinkEditModal = dynamic(() => import('./PageEditor/LinkEditModal'), { ssr: false });
 
+
 const logger = loggerFactory('growi:Page');
 
 
@@ -63,7 +65,7 @@ export const Page = (props) => {
   const { data: tagsInfo } = useSWRxTagsInfo(currentPage?._id);
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isMobile } = useIsMobile();
-  const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
   const { open: openDrawioModal } = useDrawioModal();
   const { open: openHandsontableModal } = useHandsontableModal();
@@ -71,6 +73,17 @@ export const Page = (props) => {
   const saveOrUpdate = useSaveOrUpdate();
 
 
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
   useEffect(() => {
     mutateCurrentPageTocNode(tocRef.current);
   // eslint-disable-next-line react-hooks/exhaustive-deps

+ 1 - 1
packages/app/src/components/PageAlert/FixPageGrantAlert.tsx

@@ -224,7 +224,7 @@ const FixPageGrantModal = (props: ModalProps): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpen} toggle={close} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpen} toggle={close}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         { t('fix_page_grant.modal.title') }
       </ModalHeader>

+ 3 - 2
packages/app/src/components/PageCreateModal.jsx

@@ -11,7 +11,6 @@ import {
 } from 'reactstrap';
 import { debounce } from 'throttle-debounce';
 
-
 import { toastError } from '~/client/util/apiNotification';
 import { useCurrentUser, useIsSearchServiceReachable } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
@@ -19,6 +18,8 @@ import { EditorMode, useEditorMode } from '~/stores/ui';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
+import styles from './PageCreateModal.module.scss';
+
 const {
   userPageRoot, isCreatablePage, generateEditorPath, isUsersHomePage,
 } = pagePathUtils;
@@ -315,7 +316,7 @@ const PageCreateModal = () => {
       isOpen={isOpened}
       toggle={() => closeCreateModal()}
       data-testid="page-create-modal"
-      className="grw-create-page"
+      className={`grw-create-page ${styles['grw-create-page']}`}
       autoFocus={false}
     >
       <ModalHeader tag="h4" toggle={() => closeCreateModal()} className="bg-primary text-light">

+ 1 - 1
packages/app/src/styles/_create-page.scss → packages/app/src/components/PageCreateModal.module.scss

@@ -1,4 +1,4 @@
-.grw-create-page {
+.grw-create-page :global {
   .page-today-input1 {
     width: 60px;
   }

+ 1 - 1
packages/app/src/components/PageDeleteModal.tsx

@@ -281,7 +281,7 @@ const PageDeleteModal: FC = () => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal" className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={closeDeleteModal} data-testid="page-delete-modal">
       <ModalHeader tag="h4" toggle={closeDeleteModal} className={`bg-${deleteIconAndKey[deleteMode].color} text-light`}>
         {headerContent()}
       </ModalHeader>

+ 18 - 6
packages/app/src/components/PageEditor.tsx

@@ -35,6 +35,7 @@ import {
   useEditorMode, useSelectedGrant,
 } from '~/stores/ui';
 import { useGlobalSocket } from '~/stores/websocket';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 
@@ -83,7 +84,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
-  const { data: rendererOptions } = usePreviewOptions();
+  const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const saveOrUpdate = useSaveOrUpdate();
 
@@ -155,6 +156,17 @@ const PageEditor = React.memo((): JSX.Element => {
   //   );
   //   return optionsToSave;
   // }, [grantData, isSlackEnabled, pageTags, slackChannelsData]);
+  // register to facade
+  useEffect(() => {
+    // for markdownRenderer
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          previewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
 
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
@@ -169,7 +181,6 @@ const PageEditor = React.memo((): JSX.Element => {
     setMarkdownWithDebounce(value, isClean);
   }, [setMarkdownWithDebounce]);
 
-  // return true if the save succeeds, otherwise false.
   const save = useCallback(async(opts?: {overwriteScopesOfDescendants: boolean}): Promise<IPageHasId | null> => {
     if (grantData == null || isSlackEnabled == null || currentPathname == null) {
       logger.error('Some materials to save are invalid', { grantData, isSlackEnabled, currentPathname });
@@ -240,12 +251,13 @@ const PageEditor = React.memo((): JSX.Element => {
       return;
     }
 
-    const isSuccess = await save();
-    if (isSuccess) {
+    const page = await save();
+    if (page != null) {
       toastSuccess(t('toaster.save_succeeded'));
+      await mutateCurrentPageId(page._id);
+      await mutateCurrentPage();
     }
-
-  }, [editorMode, save, t]);
+  }, [editorMode, mutateCurrentPage, mutateCurrentPageId, save, t]);
 
 
   /**

+ 22 - 0
packages/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -14,6 +14,7 @@ import InterceptorManager from '~/services/interceptor-manager';
 import { useHandsontableModal, useDrawioModal } from '~/stores/modal';
 import loggerFactory from '~/utils/logger';
 
+import { TemplateModal } from '../TemplateModal';
 import { UncontrolledCodeMirror } from '../UncontrolledCodeMirror';
 
 import AbstractEditor from './AbstractEditor';
@@ -110,6 +111,7 @@ class CodeMirrorEditor extends AbstractEditor {
       emojiSearchText: '',
       startPosWithEmojiPickerModeTurnedOn: null,
       isEmojiPickerMode: false,
+      isTemplateModalOpened: false,
     };
 
     this.cm = React.createRef();
@@ -161,6 +163,8 @@ class CodeMirrorEditor extends AbstractEditor {
     this.clickDrawioIconHandler = this.clickDrawioIconHandler.bind(this);
     this.clickTableIconHandler = this.clickTableIconHandler.bind(this);
 
+    this.showTemplateModal = this.showTemplateModal.bind(this);
+
   }
 
   init() {
@@ -869,6 +873,10 @@ class CodeMirrorEditor extends AbstractEditor {
     this.linkEditModal.current.show(markdownLinkUtil.getMarkdownLink(this.getCodeMirror()));
   }
 
+  showTemplateModal() {
+    this.setState({ isTemplateModalOpened: true });
+  }
+
   // fold draw.io section (``` drawio ~ ```)
   foldDrawioSection() {
     const editor = this.getCodeMirror();
@@ -1049,6 +1057,15 @@ class CodeMirrorEditor extends AbstractEditor {
       >
         <EditorIcon icon="Emoji" />
       </Button>,
+      <Button
+        key="nav-item-template"
+        color={null}
+        bssize="small"
+        title="Template"
+        onClick={() => this.showTemplateModal()}
+      >
+        <EditorIcon icon="Template" />
+      </Button>,
     ];
   }
 
@@ -1142,6 +1159,11 @@ class CodeMirrorEditor extends AbstractEditor {
           ref={this.linkEditModal}
           onSave={(linkText) => { return markdownLinkUtil.replaceFocusedMarkdownLinkWithEditor(this.getCodeMirror(), linkText) }}
         />
+        <TemplateModal
+          isOpen={this.state.isTemplateModalOpened}
+          onClose={() => this.setState({ isTemplateModalOpened: false })}
+          onSubmit={templateText => this.setValue(templateText) }
+        />
       </div>
     );
   }

+ 8 - 0
packages/app/src/components/PageEditor/EditorIcon.jsx

@@ -1,5 +1,6 @@
 /* eslint-disable max-len */
 import React from 'react';
+
 import PropTypes from 'prop-types';
 
 const EditorIcon = (props) => {
@@ -139,6 +140,13 @@ const EditorIcon = (props) => {
           </g>
         </svg>
       );
+    case 'Template':
+      // TODO: fix
+      return (
+        <svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" className="bi bi-filetype-md" viewBox="-2 -3 28 21">
+          <path fillRule="evenodd" d="M14 4.5V14a2 2 0 0 1-2 2H9v-1h3a1 1 0 0 0 1-1V4.5h-2A1.5 1.5 0 0 1 9.5 3V1H4a1 1 0 0 0-1 1v9H2V2a2 2 0 0 1 2-2h5.5L14 4.5ZM.706 13.189v2.66H0V11.85h.806l1.14 2.596h.026l1.14-2.596h.8v3.999h-.716v-2.66h-.038l-.946 2.159h-.516l-.952-2.16H.706Zm3.919 2.66V11.85h1.459c.406 0 .741.078 1.005.234.263.157.46.383.589.68.13.297.196.655.196 1.075 0 .422-.066.784-.196 1.084-.131.301-.33.53-.595.689-.264.158-.597.237-1 .237H4.626Zm1.353-3.354h-.562v2.707h.562c.186 0 .347-.028.484-.082a.8.8 0 0 0 .334-.252 1.14 1.14 0 0 0 .196-.422c.045-.168.067-.365.067-.592a2.1 2.1 0 0 0-.117-.753.89.89 0 0 0-.354-.454c-.159-.102-.362-.152-.61-.152Z"/>
+        </svg>
+      );
   }
 
 

+ 6 - 2
packages/app/src/components/PageList/PageListItemL.tsx

@@ -162,6 +162,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const canRenderESSnippet = elasticSearchResult != null && elasticSearchResult.snippet != null;
   const canRenderRevisionSnippet = revisionShortBody != null;
 
+  const hasBrowsingRights = canRenderESSnippet || canRenderRevisionSnippet;
+
   return (
     <li
       key={pageData._id}
@@ -228,7 +230,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
               </div>
 
               {/* doropdown icon includes page control buttons */}
-              <div className="ml-auto">
+              {hasBrowsingRights
+              && <div className="ml-auto">
                 <PageItemControl
                   alignRight
                   pageId={pageData._id}
@@ -242,6 +245,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   onClickRevertMenuItem={revertMenuItemClickHandler}
                 />
               </div>
+              }
             </div>
             <div className="page-list-snippet py-1">
               <Clamp lines={2}>
@@ -253,7 +257,7 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
                   <div data-testid="revision-short-body-in-page-list-item-L">{revisionShortBody}</div>
                 ) }
                 {
-                  !canRenderESSnippet && !canRenderRevisionSnippet && (
+                  !hasBrowsingRights && (
                     <>
                       <i className="icon-exclamation p-1"></i>
                       {t('not_allowed_to_see_this_page')}

+ 1 - 1
packages/app/src/components/PrivateLegacyPages.tsx

@@ -148,7 +148,7 @@ const ConvertByPathModal = React.memo((props: ConvertByPathModalProps): JSX.Elem
   }, [props.isOpen]);
 
   return (
-    <Modal size="lg" isOpen={props.isOpen} toggle={props.close} className="grw-create-page">
+    <Modal size="lg" isOpen={props.isOpen} toggle={props.close}>
       <ModalHeader tag="h4" toggle={props.close} className="bg-primary text-light">
         { t('private_legacy_pages.by_path_modal.title') }
       </ModalHeader>

+ 1 - 1
packages/app/src/components/PrivateLegacyPagesMigrationModal.tsx

@@ -73,7 +73,7 @@ export const PrivateLegacyPagesMigrationModal = (): JSX.Element => {
   };
 
   return (
-    <Modal size="lg" isOpen={isOpened} toggle={close} className="grw-create-page">
+    <Modal size="lg" isOpen={isOpened} toggle={close}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         { t('private_legacy_pages.modal.title') }
       </ModalHeader>

+ 1 - 1
packages/app/src/components/PutbackPageModal.jsx

@@ -116,7 +116,7 @@ const PutBackPageModal = () => {
   }, [closePutBackPageModal]);
 
   return (
-    <Modal isOpen={isOpened} toggle={closeModalHandler} className="grw-create-page">
+    <Modal isOpen={isOpened} toggle={closeModalHandler}>
       <ModalHeader tag="h4" toggle={closeModalHandler} className="bg-info text-light">
         <HeaderContent/>
       </ModalHeader>

+ 1 - 1
packages/app/src/components/Sidebar/RecentChanges.tsx

@@ -112,7 +112,7 @@ const SmallPageItem = memo(({ page }: PageItemProps): JSX.Element => {
   }
 
   return (
-    <li className="list-group-item py-2 px-0">
+    <li className={`list-group-item ${styles['list-group-item']} py-2 px-0`}>
       <div className="d-flex w-100">
         <UserPicture user={page.lastUpdateUser} size="md" noTooltip />
         <div className="flex-grow-1 ml-2">

+ 117 - 0
packages/app/src/components/TemplateModal.tsx

@@ -0,0 +1,117 @@
+import React, { useCallback, useState } from 'react';
+
+import { ITemplate } from '@growi/core';
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { usePreviewOptions } from '~/stores/renderer';
+import { useTemplates } from '~/stores/template';
+
+import Preview from './PageEditor/Preview';
+
+
+type TemplateRadioButtonProps = {
+  template: ITemplate,
+  onChange: (selectedTemplate: ITemplate) => void,
+  isSelected?: boolean,
+}
+
+const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
+  const radioButtonId = `rb-${template.id}`;
+
+  return (
+    <div key={template.id} className="custom-control custom-radio mb-2">
+      <input
+        id={radioButtonId}
+        type="radio"
+        className="custom-control-input"
+        checked={isSelected}
+        onChange={() => onChange(template)}
+      />
+      <label className="custom-control-label" htmlFor={radioButtonId}>
+        {template.name}
+      </label>
+    </div>
+  );
+};
+
+
+type Props = {
+  isOpen: boolean,
+  onClose: () => void,
+  onSubmit?: (markdown: string) => void,
+}
+
+export const TemplateModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { isOpen, onClose, onSubmit } = props;
+
+  const { data: rendererOptions } = usePreviewOptions();
+  const { data: templates } = useTemplates();
+
+  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+
+  const submitHandler = useCallback((template?: ITemplate) => {
+    if (onSubmit == null || template == null) {
+      onClose();
+      return;
+    }
+
+    onSubmit(template.markdown);
+    onClose();
+  }, [onClose, onSubmit]);
+
+  if (templates == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={isOpen} toggle={onClose} size="lg" autoFocus={false}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-primary text-light">
+        Template
+      </ModalHeader>
+
+      <ModalBody className="container">
+        <div className="row">
+          <div className="col-12">
+            { templates.map(template => (
+              <TemplateRadioButton
+                key={template.id}
+                template={template}
+                onChange={t => setSelectedTemplate(t)}
+                isSelected={template.id === selectedTemplate?.id}
+              />
+            )) }
+          </div>
+        </div>
+
+        { rendererOptions != null && (
+          <>
+            <hr />
+            <h3>Preview</h3>
+            <div className='card'>
+              <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
+                <Preview rendererOptions={rendererOptions} markdown={selectedTemplate?.markdown}/>
+              </div>
+            </div>
+          </>
+        ) }
+
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={onClose}>
+          {t('Cancel')}
+        </button>
+        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
+          {t('Update')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 30 - 0
packages/app/src/components/TemplateTab.tsx

@@ -0,0 +1,30 @@
+import React from 'react';
+
+type Props = {
+  template: any,
+  onChangeHandler: any,
+}
+
+// const onChangeHandler = () => {
+
+// }
+
+export const TemplateTab = (props: Props): JSX.Element => {
+  const { template, onChangeHandler } = props;
+
+  return (
+    <div key={template.name} className="custom-control custom-radio">
+      <input
+        type="radio"
+        className="custom-control-input"
+        id="string"
+        value={template.value}
+        // checked={this.state.linkerType === template.value}
+        onChange={onChangeHandler}
+      />
+      <label className="custom-control-label" htmlFor="string">
+        {template.name}
+      </label>
+    </div>
+  );
+};

+ 2 - 2
packages/app/src/components/Theme/ThemeDefault.global.scss

@@ -16,7 +16,7 @@
 
 //== Light Mode
 //
-:root[data-theme='light'] .theme-default {
+:root[data-theme='light'] {
   $primary: #122c55;
   $accent: #209fd8;
 
@@ -116,7 +116,7 @@
 
 //== Dark Mode
 //
-:root[data-theme='dark'] .theme-default {
+:root[data-theme='dark'] {
   $primary: #115cd3;
   $accent: #db00c2;
 

+ 1 - 1
packages/app/src/components/Theme/ThemeDefault.tsx

@@ -3,6 +3,6 @@ import { ThemeInjector } from './utils/ThemeInjector';
 // import styles from './ThemeDefault.module.scss';
 
 const ThemeDefault = ({ children }: { children: JSX.Element }): JSX.Element => {
-  return <ThemeInjector className="theme-default">{children}</ThemeInjector>;
+  return <ThemeInjector>{children}</ThemeInjector>;
 };
 export default ThemeDefault;

+ 12 - 5
packages/app/src/components/Theme/utils/ThemeInjector.tsx

@@ -5,20 +5,27 @@ import { useIsomorphicLayoutEffect } from 'usehooks-ts';
 
 type Props = {
   children: JSX.Element,
-  className: string,
+  bodyTagClassName?: string,
+  className?: string,
   bgImageNode?: React.ReactNode,
 }
 
-export const ThemeInjector = ({ children, className: themeClassName, bgImageNode }: Props): JSX.Element => {
-  const className = `${children.props.className ?? ''} ${themeClassName}`;
+export const ThemeInjector = ({
+  children, bodyTagClassName, className: childrenClassName, bgImageNode,
+}: Props): JSX.Element => {
+  const className = `${children.props.className ?? ''} ${childrenClassName ?? ''}`;
 
   // add class name to <body>
   useIsomorphicLayoutEffect(() => {
-    document.body.classList.add(themeClassName);
+    if (bodyTagClassName != null) {
+      document.body.classList.add(bodyTagClassName);
+    }
 
     // clean up
     return () => {
-      document.body.classList.remove(themeClassName);
+      if (bodyTagClassName != null) {
+        document.body.classList.remove(bodyTagClassName);
+      }
     };
   });
 

+ 21 - 0
packages/app/src/interfaces/github-api.ts

@@ -0,0 +1,21 @@
+export type SearchResult = {
+  total_count: number,
+  imcomplete_results: boolean,
+  items: SearchResultItem[];
+}
+
+export type SearchResultItem = {
+  id: number,
+  name: string,
+  owner: {
+    login: string,
+    html_url: string,
+    avatar_url: string,
+  },
+  fullName: string,
+  htmlUrl: string,
+  description: string,
+  topics: string[],
+  homepage: string,
+  stargazersCount: number,
+}

+ 26 - 0
packages/app/src/interfaces/plugin.ts

@@ -0,0 +1,26 @@
+export const GrowiPluginResourceType = {
+  Template: 'template',
+  Style: 'style',
+  Script: 'script',
+} as const;
+export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
+
+export type GrowiPluginOrigin = {
+  url: string,
+  ghBranch?: string,
+  ghTag?: string,
+}
+
+export type GrowiPlugin = {
+  isEnabled: boolean,
+  installedPath: string,
+  origin: GrowiPluginOrigin,
+  meta: GrowiPluginMeta,
+}
+
+export type GrowiPluginMeta = {
+  name: string,
+  types: GrowiPluginResourceType[],
+  desc?: string,
+  author?: string,
+}

+ 5 - 0
packages/app/src/pages/_app.page.tsx

@@ -7,6 +7,7 @@ import { SWRConfig } from 'swr';
 
 import * as nextI18nConfig from '^/config/next-i18next.config';
 
+import { ActivatePluginService } from '~/client/services/activate-plugin';
 import { useI18nextHMR } from '~/services/i18next-hmr';
 import {
   useAppTitle, useConfidential, useGrowiTheme, useGrowiVersion, useSiteUrl, useCustomizedLogoSrc,
@@ -45,6 +46,10 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
     import('bootstrap/dist/js/bootstrap');
   }, []);
 
+  useEffect(() => {
+    ActivatePluginService.activateAll();
+  }, []);
+
 
   const commonPageProps = pageProps as CommonProps;
   // useInterceptorManager(new InterceptorManager());

+ 49 - 6
packages/app/src/pages/_document.page.tsx

@@ -1,18 +1,57 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
+import mongoose from 'mongoose';
 import Document, {
   DocumentContext, DocumentInitialProps,
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
 import { CrowiRequest } from '~/interfaces/crowi-request';
+import { GrowiPlugin, GrowiPluginResourceType } from '~/interfaces/plugin';
 
+type HeadersForGrowiPluginProps = {
+  pluginManifestEntries: GrowiPluginManifestEntries;
+}
+
+const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element => {
+  const { pluginManifestEntries } = props;
+
+  return (
+    <>
+      { pluginManifestEntries.map(([growiPlugin, manifest]) => {
+        const { types } = growiPlugin.meta;
+
+        const elements: JSX.Element[] = [];
 
-// type GrowiDocumentProps = {};
-// declare type GrowiDocumentInitialProps = GrowiDocumentProps & DocumentInitialProps;
-declare type GrowiDocumentInitialProps = DocumentInitialProps & { customCss: string };
+        // add script
+        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
+          elements.push(<>
+            {/* eslint-disable-next-line @next/next/no-sync-scripts */ }
+            <script type="module" key={`script_${growiPlugin.installedPath}`}
+              src={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
+          </>);
+        }
+        // add link
+        if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
+          elements.push(<>
+            <link rel="stylesheet" key={`link_${growiPlugin.installedPath}`}
+              href={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
+          </>);
+        }
 
+        return elements;
+      }) }
+    </>
+  );
+};
+
+interface GrowiDocumentProps {
+  customCss: string;
+  pluginManifestEntries: GrowiPluginManifestEntries;
+}
+declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
 class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
@@ -22,12 +61,15 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const { customizeService } = crowi;
     const customCss: string = customizeService.getCustomCss();
 
-    const props = { ...initialProps, customCss };
-    return props;
+    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
+    const growiPlugins = await GrowiPlugin.find({ isEnabled: true });
+    const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests(growiPlugins);
+
+    return { ...initialProps, customCss, pluginManifestEntries };
   }
 
   override render(): JSX.Element {
-    const { customCss } = this.props;
+    const { customCss, pluginManifestEntries } = this.props;
 
     return (
       <Html>
@@ -45,6 +87,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
           <link rel='preload' href="/static/fonts/Lato-Regular-latin-ext.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin.woff2" as="font" type="font/woff2" />
           <link rel='preload' href="/static/fonts/Lato-Bold-latin-ext.woff2" as="font" type="font/woff2" />
+          <HeadersForGrowiPlugin pluginManifestEntries={pluginManifestEntries} />
         </Head>
         <body>
           <Main />

+ 0 - 3
packages/app/src/pages/admin/index.page.tsx

@@ -9,7 +9,6 @@ import { Container, Provider } from 'unstated';
 import AdminHomeContainer from '~/client/services/AdminHomeContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
-import PluginUtils from '~/server/plugins/plugin-utils';
 import { useCurrentUser, useGrowiCloudUri, useGrowiAppIdForGrowiCloud } from '~/stores/context';
 
 import { retrieveServerSideProps } from '../../utils/admin-page-util';
@@ -63,12 +62,10 @@ const AdminHomePage: NextPage<Props> = (props) => {
 const injectServerConfigurations = async(context: GetServerSidePropsContext, props: Props): Promise<void> => {
   const req: CrowiRequest = context.req as CrowiRequest;
   const { crowi } = req;
-  const pluginUtils = new PluginUtils();
 
   props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
-  props.installedPlugins = pluginUtils.listPlugins();
   props.growiCloudUri = await crowi.configManager.getConfig('crowi', 'app:growiCloudUri');
   props.growiAppIdForGrowiCloud = await crowi.configManager.getConfig('crowi', 'app:growiAppIdForCloud');
 };

+ 53 - 0
packages/app/src/pages/admin/plugins.page.tsx

@@ -0,0 +1,53 @@
+import { isClient } from '@growi/core';
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
+import { Container, Provider } from 'unstated';
+
+
+import AdminAppContainer from '~/client/services/AdminAppContainer';
+import { CommonProps, useCustomTitle } from '~/pages/utils/commons';
+import { useCurrentUser } from '~/stores/context';
+import { useIsMaintenanceMode } from '~/stores/maintenanceMode';
+
+import { retrieveServerSideProps } from '../../utils/admin-page-util';
+
+
+const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
+const PluginsExtensionPageContents = dynamic(
+  () => import('~/components/Admin/PluginsExtension/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
+  { ssr: false },
+);
+
+
+const AdminAppPage: NextPage<CommonProps> = (props) => {
+  const { t } = useTranslation('commons');
+  useIsMaintenanceMode(props.isMaintenanceMode);
+  useCurrentUser(props.currentUser ?? null);
+
+  const title = 'Plugins Extention';
+  const injectableContainers: Container<any>[] = [];
+
+  if (isClient()) {
+    const adminAppContainer = new AdminAppContainer();
+    injectableContainers.push(adminAppContainer);
+  }
+
+  return (
+    <Provider inject={[...injectableContainers]}>
+      <AdminLayout title={useCustomTitle(props, title)} componentTitle={title} >
+        <PluginsExtensionPageContents />
+      </AdminLayout>
+    </Provider>
+  );
+};
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const props = await retrieveServerSideProps(context);
+  return props;
+};
+
+
+export default AdminAppPage;

+ 23 - 3
packages/app/src/pages/me/[[...path]].page.tsx

@@ -12,6 +12,7 @@ import { useRouter } from 'next/router';
 
 import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
 import { UserUISettingsModel } from '~/server/models/user-ui-settings';
@@ -19,7 +20,7 @@ import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhiteList, useShowPageLimitationXL,
+  useRegistrationWhiteList, useShowPageLimitationXL, useRendererConfig,
 } from '~/stores/context';
 import {
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -39,6 +40,7 @@ type Props = CommonProps & {
   isSearchScopeChildrenAsDefault: boolean,
   userUISettings?: IUserUISettings
   sidebarConfig: ISidebarConfig,
+  rendererConfig: RendererConfig,
   showPageLimitationXL: number,
 
   // config
@@ -93,18 +95,20 @@ const MePage: NextPage<Props> = (props: Props) => {
   // commons
   useCsrfToken(props.csrfToken);
 
-  // // UserUISettings
+  // UserUISettings
   usePreferDrawerModeByUser(props.userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
   usePreferDrawerModeOnEditByUser(props.userUISettings?.preferDrawerModeOnEditByUser);
   useSidebarCollapsed(props.userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
-  // // page
+  // page
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
+  useRendererConfig(props.rendererConfig);
+
   return (
     <>
       <BasicLayout title={useCustomTitle(props, 'GROWI')}>
@@ -159,6 +163,22 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
 }
 
 // /**

+ 22 - 1
packages/app/src/pages/tags.page.tsx

@@ -8,6 +8,7 @@ import dynamic from 'next/dynamic';
 import Head from 'next/head';
 
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IDataTagCount } from '~/interfaces/tag';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
@@ -21,7 +22,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault,
+  useIsSearchScopeChildrenAsDefault, useRendererConfig,
 } from '../stores/context';
 
 import {
@@ -41,6 +42,8 @@ type Props = CommonProps & {
 
   // sidebar
   sidebarConfig: ISidebarConfig,
+
+  rendererConfig: RendererConfig,
 };
 
 const TagList = dynamic(() => import('~/components/TagList'), { ssr: false });
@@ -74,6 +77,8 @@ const TagPage: NextPage<CommonProps> = (props: Props) => {
   useCurrentSidebarContents(props.userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(props.userUISettings?.currentProductNavWidth);
 
+  useRendererConfig(props.rendererConfig);
+
   return (
     <>
       <Head>
@@ -138,6 +143,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
 }
 
 /**

+ 22 - 1
packages/app/src/pages/trash.page.tsx

@@ -7,6 +7,7 @@ import dynamic from 'next/dynamic';
 
 import { GrowiSubNavigation } from '~/components/Navbar/GrowiSubNavigation';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import type { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
 import type { UserUISettingsModel } from '~/server/models/user-ui-settings';
@@ -18,7 +19,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useCurrentPageId, useCurrentPathname,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser,
+  useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useRendererConfig,
 } from '../stores/context';
 
 import {
@@ -40,6 +41,8 @@ type Props = CommonProps & {
   userUISettings?: IUserUISettings
   // Sidebar
   sidebarConfig: ISidebarConfig,
+
+  rendererConfig: RendererConfig,
 };
 
 const TrashPage: NextPage<CommonProps> = (props: Props) => {
@@ -62,6 +65,8 @@ const TrashPage: NextPage<CommonProps> = (props: Props) => {
 
   useShowPageLimitationXL(props.showPageLimitationXL);
 
+  useRendererConfig(props.rendererConfig);
+
   const { data: isDrawerMode } = useDrawerMode();
   const { data: isGuestUser } = useIsGuestUser();
 
@@ -121,6 +126,22 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
     isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
   };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
 }
 
 /**

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -112,8 +112,11 @@ module.exports = function(crowi, app) {
   });
 
   app.set('port', crowi.port);
+
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   app.use(express.static(crowi.publicDir, staticOption));
+  app.use('/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
+
   app.engine('html', swig.renderFile);
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   app.set('view engine', 'html');

+ 21 - 0
packages/app/src/server/crowi/index.js

@@ -15,6 +15,7 @@ import loggerFactory from '~/utils/logger';
 import { projectRoot } from '~/utils/project-dir-utils';
 
 import Activity from '../models/activity';
+import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
@@ -26,6 +27,8 @@ import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
+// eslint-disable-next-line import/no-cycle
+import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -65,6 +68,7 @@ function Crowi() {
   this.growiBridgeService = null;
   this.exportService = null;
   this.importService = null;
+  this.pluginService = null;
   this.searchService = null;
   this.socketIoService = null;
   this.pageService = null;
@@ -119,6 +123,7 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupSearcher(),
+    this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setUpFileUpload(),
@@ -130,6 +135,7 @@ Crowi.prototype.init = async function() {
     this.setupUserGroupService(),
     this.setupExport(),
     this.setupImport(),
+    this.setupPluginService(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
     this.setupActivityService(),
@@ -291,6 +297,7 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
+  allModels.growiPlugin = GrowiPlugin;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
@@ -368,6 +375,13 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
 };
 
+/**
+ * setup PluginService
+ */
+Crowi.prototype.setupPluginer = async function() {
+  this.pluginService = new PluginService(this);
+};
+
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   this.mailService = new MailService(this);
@@ -684,6 +698,13 @@ Crowi.prototype.setupImport = async function() {
   }
 };
 
+Crowi.prototype.setupPluginService = async function() {
+  const { PluginService } = require('../service/plugin');
+  if (this.pluginService == null) {
+    this.pluginService = new PluginService(this);
+  }
+};
+
 Crowi.prototype.setupPageService = async function() {
   if (this.pageService == null) {
     this.pageService = new PageService(this);

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

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

+ 40 - 0
packages/app/src/server/models/growi-plugin.ts

@@ -0,0 +1,40 @@
+import {
+  Schema, Model, Document,
+} from 'mongoose';
+
+import {
+  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType,
+} from '~/interfaces/plugin';
+
+import { getOrCreateModel } from '../util/mongoose-utils';
+
+export interface GrowiPluginDocument extends GrowiPlugin, Document {
+}
+export type GrowiPluginModel = Model<GrowiPluginDocument>
+
+const growiPluginMetaSchema = new Schema<GrowiPluginMeta>({
+  name: { type: String, required: true },
+  types: {
+    type: [String],
+    enum: GrowiPluginResourceType,
+    require: true,
+  },
+  desc: { type: String },
+  author: { type: String },
+});
+
+const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
+  url: { type: String },
+  ghBranch: { type: String },
+  ghTag: { type: String },
+});
+
+const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
+  isEnabled: { type: Boolean },
+  installedPath: { type: String },
+  origin: growiPluginOriginSchema,
+  meta: growiPluginMetaSchema,
+});
+
+
+export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 0 - 76
packages/app/src/server/plugins/plugin-utils.js

@@ -1,76 +0,0 @@
-import loggerFactory from '~/utils/logger';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
-
-// import { PluginUtilsV4 } from './plugin-utils-v4';
-
-const fs = require('graceful-fs');
-
-const logger = loggerFactory('growi:plugins:plugin-utils');
-
-class PluginUtils {
-
-  /**
-   * list plugin module objects
-   *  that starts with 'growi-plugin-' or 'crowi-plugin-'
-   * borrowing from: https://github.com/hexojs/hexo/blob/d1db459c92a4765620343b95789361cbbc6414c5/lib/hexo/load_plugins.js#L17
-   *
-   * @returns array of objects
-   *   [
-   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
-   *     { name: 'growi-plugin-...', requiredVersion: '^1.0.0', installedVersion: '1.0.0' },
-   *     ...
-   *   ]
-   *
-   * @memberOf PluginService
-   */
-  listPlugins() {
-    const packagePath = resolveFromRoot('package.json');
-
-    // Make sure package.json exists
-    if (!fs.existsSync(packagePath)) {
-      return [];
-    }
-
-    // Read package.json and find dependencies
-    const content = fs.readFileSync(packagePath);
-    const json = JSON.parse(content);
-    const deps = json.dependencies || {};
-
-    const pluginNames = Object.keys(deps).filter((name) => {
-      return /^@growi\/plugin-/.test(name);
-    });
-
-    return pluginNames.map((name) => {
-      return {
-        name,
-        requiredVersion: deps[name],
-        installedVersion: this.getVersion(name),
-      };
-    });
-  }
-
-  /**
-   * list plugin module names that starts with 'crowi-plugin-'
-   *
-   * @returns array of plugin names
-   *
-   * @memberOf PluginService
-   */
-  listPluginNames() {
-    const plugins = this.listPlugins();
-    return plugins.map((plugin) => { return plugin.name });
-  }
-
-  getVersion(packageName) {
-    const packagePath = resolveFromRoot(`../../node_modules/${packageName}/package.json`);
-
-    // Read package.json and find version
-    const content = fs.readFileSync(packagePath);
-    const json = JSON.parse(content);
-    return json.version || '';
-  }
-
-}
-
-module.exports = PluginUtils;
-export default PluginUtils;

+ 0 - 4
packages/app/src/server/routes/apiv3/admin-home.js

@@ -1,9 +1,6 @@
 import ConfigLoader from '../../service/config-loader';
 
 const express = require('express');
-const PluginUtils = require('../../plugins/plugin-utils');
-
-const pluginUtils = new PluginUtils();
 
 const router = express.Router();
 
@@ -71,7 +68,6 @@ module.exports = (crowi) => {
       nodeVersion: crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : '-',
       npmVersion: crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : '-',
       yarnVersion: crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : '-',
-      installedPlugins: pluginUtils.listPlugins(crowi.rootDir),
       envVars: await ConfigLoader.getEnvVarsForDisplay(true),
       isV5Compatible: crowi.configManager.getConfig('crowi', 'app:isV5Compatible'),
       isMaintenanceMode: crowi.configManager.getConfig('crowi', 'app:isMaintenanceMode'),

+ 4 - 1
packages/app/src/server/routes/apiv3/index.js

@@ -16,7 +16,8 @@ const router = express.Router();
 const routerForAdmin = express.Router();
 const routerForAuth = express.Router();
 
-module.exports = (crowi, app, isInstalled) => {
+module.exports = (crowi, app) => {
+  const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
   // add custom functions to express response
   require('./response')(express, crowi);
@@ -103,6 +104,8 @@ module.exports = (crowi, app, isInstalled) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
+  router.use('/plugins-extention', require('./plugins-extention')(crowi));
+
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
 

+ 29 - 0
packages/app/src/server/routes/apiv3/plugins-extention.ts

@@ -0,0 +1,29 @@
+import express, { Request } from 'express';
+
+import Crowi from '../../crowi';
+
+import { ApiV3Response } from './interfaces/apiv3-response';
+
+type PluginInstallerFormRequest = Request & { form: any };
+
+module.exports = (crowi: Crowi) => {
+  const router = express.Router();
+  const { pluginService } = crowi;
+
+  router.post('/', async(req: PluginInstallerFormRequest, res: ApiV3Response) => {
+    if (pluginService == null) {
+      return res.apiv3Err(400);
+    }
+
+    try {
+      await pluginService.install(crowi, req.body.pluginInstallerForm);
+      return res.apiv3({});
+    }
+    catch (err) {
+      // TODO: error handling
+      return res.apiv3Err(err, 400);
+    }
+  });
+
+  return router;
+};

+ 2 - 8
packages/app/src/server/routes/index.js

@@ -56,11 +56,10 @@ module.exports = function(crowi, app) {
   const unavailableWhenMaintenanceMode = generateUnavailableWhenMaintenanceModeMiddleware(crowi);
   const unavailableWhenMaintenanceModeForApi = generateUnavailableWhenMaintenanceModeMiddlewareForApi(crowi);
 
-  const isInstalled = crowi.configManager.getConfig('crowi', 'app:installed');
 
   /* eslint-disable max-len, comma-spacing, no-multi-spaces */
 
-  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app, isInstalled);
+  const [apiV3Router, apiV3AdminRouter, apiV3AuthRouter] = require('./apiv3')(crowi, app);
 
   app.use('/api-docs', require('./apiv3/docs')(crowi, app));
 
@@ -82,8 +81,6 @@ module.exports = function(crowi, app) {
   app.get('/invited'                  , applicationInstalled, next.delegateToNext);
   // app.post('/login'                   , applicationInstalled, loginFormValidator.loginRules(), loginFormValidator.loginValidation, csrfProtection,  addActivity, loginPassport.loginWithLocal, loginPassport.loginWithLdap, loginPassport.loginFailure);
 
-  app.get('/register'                 , applicationInstalled, login.preLogin, login.register);
-
   // NOTE: get method "/admin/export/:fileName" should be loaded before "/admin/*"
   app.get('/admin/export/:fileName'   , loginRequiredStrictly , adminRequired ,admin.export.api.validators.export.download(), admin.export.download);
 
@@ -91,10 +88,7 @@ module.exports = function(crowi, app) {
   app.get('/admin'                    , applicationInstalled, loginRequiredStrictly , adminRequired , next.delegateToNext);
 
   // installer
-  if (!isInstalled) {
-    app.get('/installer'              , applicationNotInstalled, next.delegateToNext);
-    return;
-  }
+  app.get('/installer'                , applicationNotInstalled, next.delegateToNext);
 
   // OAuth
   app.get('/passport/google'                      , loginPassport.loginWithGoogle, loginPassport.loginFailureForExternalAccount);

+ 139 - 0
packages/app/src/server/service/plugin.ts

@@ -0,0 +1,139 @@
+import { execSync } from 'child_process';
+import fs from 'fs';
+import path from 'path';
+
+import mongoose from 'mongoose';
+
+import type { GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin } from '~/interfaces/plugin';
+import loggerFactory from '~/utils/logger';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+// eslint-disable-next-line import/no-cycle
+import Crowi from '../crowi';
+
+const logger = loggerFactory('growi:plugins:plugin-utils');
+
+const pluginStoringPath = resolveFromRoot('tmp/plugins');
+
+// https://regex101.com/r/fK2rV3/1
+const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
+
+
+export class PluginService {
+
+  crowi: any;
+
+  growiBridgeService: any;
+
+  baseDir: any;
+
+  getFile:any;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    this.growiBridgeService = crowi.growiBridgeService;
+    this.baseDir = path.join(crowi.tmpDir, 'plugins');
+    this.getFile = this.growiBridgeService.getFile.bind(this);
+  }
+
+  async install(crowi: Crowi, origin: GrowiPluginOrigin): Promise<void> {
+    // download
+    const ghUrl = new URL(origin.url);
+    const ghPathname = ghUrl.pathname;
+
+    const match = ghPathname.match(githubReposIdPattern);
+    if (ghUrl.hostname !== 'github.com' || match == null) {
+      throw new Error('The GitHub Repository URL is invalid.');
+    }
+
+    const ghOrganizationName = match[1];
+    const ghReposName = match[2];
+
+    try {
+      await this.downloadZipFile(`${ghUrl.href}/archive/refs/heads/main.zip`, ghOrganizationName, ghReposName);
+    }
+    catch (err) {
+      console.log('downloadZipFile error', err);
+    }
+
+    // save plugin metadata
+    const installedPath = `${ghOrganizationName}/${ghReposName}`;
+    const plugins = await PluginService.detectPlugins(origin, installedPath);
+    await this.savePluginMetaData(plugins);
+
+    return;
+  }
+
+  async downloadZipFile(url: string, ghOrganizationName: string, ghReposName: string): Promise<void> {
+
+    const downloadTargetPath = pluginStoringPath;
+    const zipFilePath = path.join(downloadTargetPath, 'main.zip');
+    const unzipTargetPath = path.join(pluginStoringPath, ghOrganizationName);
+
+    const stdout1 = execSync(`wget ${url} -O ${zipFilePath}`);
+    const stdout2 = execSync(`mkdir -p ${ghOrganizationName}`);
+    const stdout3 = execSync(`rm -rf ${ghOrganizationName}/${ghReposName}`);
+    const stdout4 = execSync(`unzip ${zipFilePath} -d ${unzipTargetPath}`);
+    const stdout5 = execSync(`mv ${unzipTargetPath}/${ghReposName}-main ${unzipTargetPath}/${ghReposName}`);
+    const stdout6 = execSync(`rm ${zipFilePath}`);
+
+    return;
+  }
+
+  async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
+    const GrowiPlugin = mongoose.model('GrowiPlugin');
+    await GrowiPlugin.insertMany(plugins);
+  }
+
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+  static async detectPlugins(origin: GrowiPluginOrigin, installedPath: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+    const packageJsonPath = path.resolve(pluginStoringPath, installedPath, 'package.json');
+    const packageJson = await import(packageJsonPath);
+
+    const { growiPlugin } = packageJson;
+    const {
+      name: packageName, description: packageDesc, author: packageAuthor,
+    } = parentPackageJson ?? packageJson;
+
+
+    if (growiPlugin == null) {
+      throw new Error('This package does not include \'growiPlugin\' section.');
+    }
+
+    // detect sub plugins for monorepo
+    if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
+      const plugins = await Promise.all(
+        growiPlugin.packages.map(async(subPackagePath) => {
+          const subPackageInstalledPath = path.join(installedPath, subPackagePath);
+          return this.detectPlugins(origin, subPackageInstalledPath, packageJson);
+        }),
+      );
+      return plugins.flat();
+    }
+
+    if (growiPlugin.types == null) {
+      throw new Error('\'growiPlugin\' section must have a \'types\' property.');
+    }
+    const plugin = {
+      isEnabled: true,
+      installedPath,
+      origin,
+      meta: {
+        name: growiPlugin.name ?? packageName,
+        desc: growiPlugin.desc ?? packageDesc,
+        author: growiPlugin.author ?? packageAuthor,
+        types: growiPlugin.types,
+      },
+    };
+
+    logger.info('Plugin detected => ', plugin);
+
+    return [plugin];
+  }
+
+  async listPlugins(): Promise<GrowiPlugin[]> {
+    return [];
+  }
+
+
+}

+ 15 - 0
packages/app/src/services/renderer/renderer.tsx

@@ -1,6 +1,7 @@
 // allow only types to import from react
 import { ComponentType } from 'react';
 
+import { isClient } from '@growi/core';
 import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
@@ -29,6 +30,7 @@ import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { Table } from '~/components/ReactMarkdownComponents/Table';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import { RendererConfig } from '~/interfaces/services/renderer';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 import * as addClass from './rehype-plugins/add-class';
@@ -499,3 +501,16 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   verifySanitizePlugin(options);
   return options;
 };
+
+
+// register to facade
+if (isClient()) {
+  registerGrowiFacade({
+    markdownRenderer: {
+      optionsGenerators: {
+        generateViewOptions,
+        generatePreviewOptions,
+      },
+    },
+  });
+}

+ 14 - 5
packages/app/src/stores/renderer.tsx

@@ -1,5 +1,5 @@
 import { HtmlElementNode } from 'rehype-toc';
-import { Key, SWRResponse } from 'swr';
+import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -8,6 +8,7 @@ import {
   generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
+import { getGrowiFacade } from '~/utils/growi-facade';
 
 
 import {
@@ -52,9 +53,13 @@ export const useViewOptions = (storeTocNodeHandler: (toc: HtmlElementNode) => vo
     ? ['viewOptions', currentPagePath, rendererConfig]
     : null;
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
+    (rendererId, currentPagePath, rendererConfig) => {
+      // determine options generator
+      const optionsGenerator = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGenerateViewOptions ?? generateViewOptions;
+      return optionsGenerator(currentPagePath, rendererConfig, storeTocNodeHandler);
+    },
     {
       fallbackData: isAllDataValid ? generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler) : undefined,
     },
@@ -88,9 +93,13 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
     ? ['previewOptions', rendererConfig, currentPagePath]
     : null;
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     key,
-    (rendererId, rendererConfig, currentPagePath) => generatePreviewOptions(rendererConfig, currentPagePath),
+    (rendererId, rendererConfig, pagePath, highlightKeywords) => {
+      // determine options generator
+      const optionsGenerator = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGeneratePreviewOptions ?? generatePreviewOptions;
+      return optionsGenerator(rendererConfig, pagePath, highlightKeywords);
+    },
     {
       fallbackData: isAllDataValid ? generatePreviewOptions(rendererConfig, currentPagePath) : undefined,
     },

+ 61 - 0
packages/app/src/stores/template.tsx

@@ -0,0 +1,61 @@
+import { ITemplate } from '@growi/core';
+import useSWR, { SWRResponse } from 'swr';
+
+import { getGrowiFacade } from '~/utils/growi-facade';
+
+const presetTemplates: ITemplate[] = [
+  // preset 1
+  {
+    id: '__preset1__',
+    name: '[Preset] WESEEK Inner Wiki Style',
+    markdown: `# 関連ページ
+
+$lsx()
+
+# `,
+  },
+
+  // preset 2
+  {
+    id: '__preset2__',
+    name: '[Preset] Qiita Style',
+    markdown: `# <会議体名>
+## 日時
+yyyy/mm/dd hh:mm〜hh:mm
+
+## 場所
+
+## 出席者
+-
+
+## 議題
+1. [議題1](#link)
+2.
+3.
+
+## 議事内容
+### <a name="link"></a>議題1
+
+## 決定事項
+- 決定事項1
+
+## アクション事項
+- [ ] アクション
+
+## 次回
+yyyy/mm/dd (予定、時間は追って連絡)`,
+  },
+];
+
+export const useTemplates = (): SWRResponse<ITemplate[], Error> => {
+  return useSWR<ITemplate[], Error>(
+    'templates',
+    () => [
+      ...presetTemplates,
+      ...Object.values(getGrowiFacade().customTemplates ?? {}),
+    ],
+    {
+      fallbackData: presetTemplates,
+    },
+  );
+};

+ 27 - 0
packages/app/src/stores/useInstalledPlugins.ts

@@ -0,0 +1,27 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import type { SearchResult, SearchResultItem } from '../interfaces/github-api';
+
+const pluginFetcher = (owner: string, repo: string) => {
+  return async() => {
+    const reqUrl = `/api/fetch_repository?owner=${owner}&repo=${repo}`;
+    const data = await fetch(reqUrl).then(res => res.json());
+    return data.searchResultItem;
+  };
+};
+
+export const useInstalledPlugin = (owner: string, repo: string): SWRResponse<SearchResultItem | null, Error> => {
+  return useSWR(`${owner}/{repo}`, pluginFetcher(owner, repo));
+};
+
+const pluginsFetcher = () => {
+  return async() => {
+    const reqUrl = '/api/fetch_repositories';
+    const data = await fetch(reqUrl).then(res => res.json());
+    return data.searchResult;
+  };
+};
+
+export const useInstalledPlugins = (): SWRResponse<SearchResult | null, Error> => {
+  return useSWR('/api/fetch_repositories', pluginsFetcher());
+};

+ 39 - 0
packages/app/src/utils/growi-facade.ts

@@ -0,0 +1,39 @@
+import { GrowiFacade, isServer } from '@growi/core';
+import deepmerge from 'ts-deepmerge';
+
+declare global {
+  // eslint-disable-next-line vars-on-top, no-var
+  var growiFacade: GrowiFacade;
+}
+
+
+export const initializeGrowiFacade = (): void => {
+  if (isServer()) {
+    return;
+  }
+
+  if (window.growiFacade == null) {
+    window.growiFacade = {};
+  }
+};
+
+export const getGrowiFacade = (): GrowiFacade => {
+  if (isServer()) {
+    return {};
+  }
+
+  initializeGrowiFacade();
+
+  return window.growiFacade;
+};
+
+export const registerGrowiFacade = (addedFacade: GrowiFacade): void => {
+  if (isServer()) {
+    throw new Error('This method is available only in client.');
+  }
+
+  window.growiFacade = deepmerge(
+    getGrowiFacade(),
+    addedFacade,
+  );
+};

+ 47 - 0
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -428,3 +428,50 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
     cy.screenshot(`${ssPrefix}4-new-page-name-applied`);
   });
 });
+
+context('Shortcuts', () => {
+  const ssPrefix = 'shortcuts';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Successfully updating a page using a shortcut on a previously created page', () => {
+    const body1 = 'hello';
+    const body2 = 'world';
+    const savePageShortcutKey = '{ctrl+s}'
+
+    cy.visit('/Sandbox/child');
+    cy.waitUntilSkeletonDisappear();
+
+    cy.get('#grw-subnav-container').within(() => {
+      cy.getByTestid('editor-button').should('be.visible').click();
+    })
+
+    cy.get('.layout-root').should('have.class', 'editing');
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+
+    // 1st
+    cy.get('.CodeMirror').type(body1);
+    cy.get('.CodeMirror').contains(body1);
+    cy.get('.page-editor-preview-body').contains(body1);
+    cy.get('.CodeMirror').type(savePageShortcutKey);
+
+    cy.get('.toast').should('be.visible').trigger('mouseover');
+    cy.screenshot(`${ssPrefix}-update-page-1`);
+    cy.get('.toast-close-button').click();
+    cy.get('.toast').should('not.exist');
+
+    // 2nd
+    cy.get('.CodeMirror').type(body2);
+    cy.get('.CodeMirror').contains(body2);
+    cy.get('.page-editor-preview-body').contains(body2);
+    cy.get('.CodeMirror').type(savePageShortcutKey);
+
+    cy.get('.toast').should('be.visible').trigger('mouseover');
+    cy.screenshot(`${ssPrefix}-update-page-2`);
+  });
+});

+ 0 - 1
packages/app/test/cypress/integration/40-admin/access-to-admin-page.spec.ts

@@ -26,7 +26,6 @@ context('Access to Admin page', () => {
     cy.visit('/admin');
     cy.getByTestid('admin-home').should('be.visible');
     cy.getByTestid('admin-system-information-table').should('be.visible');
-    cy.getByTestid('admin-installed-plugin-table').should('be.visible');
     cy.screenshot(`${ssPrefix}-admin`);
   });
 

+ 2 - 0
packages/core/src/index.ts

@@ -15,12 +15,14 @@ export * as pageUtils from './utils/page-utils';
 export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
+export * from './interfaces/growi-facade';
 export * from './interfaces/has-object-id';
 export * from './interfaces/lang';
 export * from './interfaces/page';
 export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
+export * from './interfaces/template';
 export * from './interfaces/user';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';

+ 16 - 0
packages/core/src/interfaces/growi-facade.ts

@@ -0,0 +1,16 @@
+import { ITemplate } from './template';
+
+export type GrowiFacade = {
+  markdownRenderer?: {
+    optionsGenerators?: {
+      generateViewOptions?: any;
+      customGenerateViewOptions?: any;
+      generatePreviewOptions?: any;
+      customGeneratePreviewOptions?: any;
+    },
+    optionsMutators?: any,
+  },
+  customTemplates?: {
+    [pluginName: string]: ITemplate,
+  }
+};

+ 5 - 0
packages/core/src/interfaces/template.ts

@@ -0,0 +1,5 @@
+export type ITemplate = {
+  id: string,
+  name: string,
+  markdown: string,
+}