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

Merge branch 'master' into imprv/110234-show-spinner-while-loading

ryoji-s 2 лет назад
Родитель
Сommit
b73e184748
100 измененных файлов с 1303 добавлено и 806 удалено
  1. 2 3
      .github/workflows/release-slackbot-proxy.yml
  2. 3 2
      .github/workflows/release.yml
  3. 17 1
      CHANGELOG.md
  4. 4 1
      apps/app/package.json
  5. 3 0
      apps/app/public/static/locales/ja_JP/commons.json
  6. 3 0
      apps/app/public/static/locales/zh_CN/commons.json
  7. 4 1
      apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx
  8. 103 10
      apps/app/src/components/Admin/Users/PasswordResetModal.jsx
  9. 10 11
      apps/app/src/components/Page/TagEditModal.tsx
  10. 3 2
      apps/app/src/components/Sidebar/SidebarNav.tsx
  11. 197 56
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  12. 9 15
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  13. 5 8
      apps/app/src/components/TemplateModal/use-formatter.tsx
  14. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  15. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  16. 4 4
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  17. 2 2
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  18. 0 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/index.ts
  19. 1 0
      apps/app/src/features/growi-plugin/client/components/Admin/index.ts
  20. 1 1
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  21. 1 0
      apps/app/src/features/growi-plugin/client/components/index.ts
  22. 24 0
      apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx
  23. 0 0
      apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts
  24. 0 1
      apps/app/src/features/growi-plugin/components/index.ts
  25. 14 11
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  26. 5 0
      apps/app/src/features/growi-plugin/server/consts/index.ts
  27. 157 0
      apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts
  28. 17 28
      apps/app/src/features/growi-plugin/server/models/growi-plugin.ts
  29. 0 0
      apps/app/src/features/growi-plugin/server/models/index.ts
  30. 0 0
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  31. 4 2
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  32. 2 2
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  33. 11 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts
  34. 12 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts
  35. 94 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts
  36. 90 72
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  37. 0 0
      apps/app/src/features/growi-plugin/server/services/growi-plugin/index.ts
  38. 1 0
      apps/app/src/features/growi-plugin/server/services/index.ts
  39. 0 27
      apps/app/src/features/growi-plugin/stores/growi-plugin.tsx
  40. 95 0
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  41. 1 0
      apps/app/src/features/templates/stores/index.ts
  42. 30 0
      apps/app/src/features/templates/stores/template.tsx
  43. 32 23
      apps/app/src/pages/[[...path]].page.tsx
  44. 2 1
      apps/app/src/pages/_document.page.tsx
  45. 2 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  46. 2 1
      apps/app/src/pages/_search.page.tsx
  47. 1 1
      apps/app/src/pages/admin/plugins.page.tsx
  48. 2 1
      apps/app/src/pages/me/[[...path]].page.tsx
  49. 2 1
      apps/app/src/pages/tags.page.tsx
  50. 3 1
      apps/app/src/pages/trash.page.tsx
  51. 2 0
      apps/app/src/pages/utils/commons.ts
  52. 2 1
      apps/app/src/server/crowi/express-init.js
  53. 1 1
      apps/app/src/server/crowi/index.js
  54. 1 1
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  55. 3 5
      apps/app/src/server/routes/apiv3/customize-setting.js
  56. 3 2
      apps/app/src/server/routes/apiv3/index.js
  57. 65 1
      apps/app/src/server/routes/apiv3/users.js
  58. 1 1
      apps/app/src/server/service/customize.ts
  59. 1 1
      apps/app/src/server/util/mongoose-utils.ts
  60. 1 1
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  61. 1 1
      apps/app/src/stores/modal.tsx
  62. 1 1
      apps/app/src/stores/renderer.tsx
  63. 0 141
      apps/app/src/stores/template.tsx
  64. 6 0
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  65. 1 1
      apps/app/tsconfig.json
  66. 0 11
      apps/slackbot-proxy/bump-versions.config.js
  67. 3 2
      apps/slackbot-proxy/package.json
  68. 1 5
      package.json
  69. 1 1
      packages/core/package.json
  70. 4 3
      packages/core/src/consts/growi-plugin.ts
  71. 1 0
      packages/core/src/consts/index.ts
  72. 2 14
      packages/core/src/index.ts
  73. 0 5
      packages/core/src/interfaces/growi-facade.ts
  74. 13 0
      packages/core/src/interfaces/index.ts
  75. 0 5
      packages/core/src/interfaces/template.ts
  76. 1 1
      packages/hackmd/package.json
  77. 1 1
      packages/pluginkit/package.json
  78. 0 1
      packages/pluginkit/src/consts/index.ts
  79. 0 1
      packages/pluginkit/src/index.ts
  80. 12 0
      packages/pluginkit/src/model/growi-plugin-package-data.ts
  81. 5 2
      packages/pluginkit/src/model/growi-plugin-validation-data.ts
  82. 1 1
      packages/pluginkit/src/model/growi-plugin-validation-error.ts
  83. 1 0
      packages/pluginkit/src/model/index.ts
  84. 0 2
      packages/pluginkit/src/server/utils/v4/index.ts
  85. 0 11
      packages/pluginkit/src/server/utils/v4/package-json/import.spec.ts
  86. 0 6
      packages/pluginkit/src/server/utils/v4/package-json/import.ts
  87. 0 2
      packages/pluginkit/src/server/utils/v4/package-json/index.ts
  88. 0 116
      packages/pluginkit/src/server/utils/v4/package-json/validate.spec.ts
  89. 0 162
      packages/pluginkit/src/server/utils/v4/template.ts
  90. 2 0
      packages/pluginkit/src/v4/index.ts
  91. 1 0
      packages/pluginkit/src/v4/interfaces/index.ts
  92. 25 0
      packages/pluginkit/src/v4/interfaces/template.ts
  93. 1 0
      packages/pluginkit/src/v4/server/index.ts
  94. 11 0
      packages/pluginkit/src/v4/server/utils/common/import-package-json.spec.ts
  95. 9 0
      packages/pluginkit/src/v4/server/utils/common/import-package-json.ts
  96. 2 0
      packages/pluginkit/src/v4/server/utils/common/index.ts
  97. 117 0
      packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.spec.ts
  98. 7 6
      packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.ts
  99. 2 0
      packages/pluginkit/src/v4/server/utils/index.ts
  100. 16 0
      packages/pluginkit/src/v4/server/utils/template/get-markdown.ts

+ 2 - 3
.github/workflows/release-slackbot-proxy.yml

@@ -112,9 +112,8 @@ jobs:
         yarn --frozen-lockfile
 
     - name: Bump versions for next RC
-      working-directory: ./apps/slackbot-proxy
       run: |
-        yarn version --no-git-tag-version --prepatch --preid=slackbot-proxy
+        turbo run version --filter=@growi/slackbot-proxy -- --prerelease
 
     - name: Retrieve information from package.json
       uses: myrotvorets/info-from-package-json-action@1.2.0
@@ -136,6 +135,6 @@ jobs:
         source_branch: support/prepare-v${{ steps.package-json.outputs.packageVersion }}
         destination_branch: master
         pr_title: Prepare v${{ steps.package-json.outputs.packageVersion }}
-        pr_label: flag/exclude-from-changelog
+        pr_label: flag/exclude-from-changelog,type/prepare-next-version
         pr_body: "An automated PR generated by ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
         github_token: ${{ secrets.GITHUB_TOKEN }}

+ 3 - 2
.github/workflows/release.yml

@@ -35,7 +35,7 @@ jobs:
 
     - name: Bump versions
       run: |
-        turbo run bump-versions:patch
+        turbo run version --filter=@growi/app -- --patch
         yarn upgrade --scope=@growi
         sh ./apps/app/bin/github-actions/update-readme.sh
 
@@ -98,7 +98,8 @@ jobs:
 
     - name: Bump versions for next RC
       run: |
-        yarn bump-versions:rc
+        turbo run version --filter=@growi/app -- --prepatch
+        turbo run version --filter=@growi/slackbot-proxy -- --prepatch
         yarn upgrade --scope=@growi
 
     - name: Retrieve information from package.json

+ 17 - 1
CHANGELOG.md

@@ -1,9 +1,25 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.3...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.4...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.4](https://github.com/weseek/growi/compare/v6.1.3...v6.1.4) - 2023-06-12
+
+### 💎 Features
+
+- feat(plugin): Specify repository branch name (#7783) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Suppress unnecessary bookmark API requests (#7798) @yuki-takei
+
+### 🐛 Bug Fixes
+
+- fix: Bookmarks mutation for the current user (#7797) @yuki-takei
+- fix: Slack channels data for User Triggered Notification is not loaded (#7794) @yuki-takei
+- fix: The input of the editor is cleared when an attachment is added when a new page editing (#7788) @miya
+
 ## [v6.1.3](https://github.com/weseek/growi/compare/v6.1.2...v6.1.3) - 2023-06-07
 
 ### 💎 Features

+ 4 - 1
apps/app/package.json

@@ -47,7 +47,7 @@
     "openapi:v1": "yarn cross-env API_VERSION=1 yarn swagger-jsdoc -- \"src/server/*/*.js\" \"src/server/models/**/*.js\"",
     "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config",
     "ts-node-dev": "ts-node-dev -r tsconfig-paths/register -r dotenv-flow/config",
-    "version": "yarn version --no-git-tag-version"
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM",
@@ -66,6 +66,8 @@
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "link:../../packages/core",
     "@growi/hackmd": "link:../../packages/hackmd",
+    "@growi/pluginkit": "link:../../packages/pluginkit",
+    "@growi/preset-templates": "link:../../packages/preset-templates",
     "@growi/preset-themes": "link:../../packages/preset-themes",
     "@growi/remark-attachment-refs": "link:../../packages/remark-attachment-refs",
     "@growi/remark-drawio": "link:../../packages/remark-drawio",
@@ -186,6 +188,7 @@
     "remark-math": "^5.1.1",
     "remark-toc": "^8.0.1",
     "remark-wiki-link": "^1.0.4",
+    "sanitize-filename": "^1.6.3",
     "socket.io": "^4.2.0",
     "stream-to-promise": "^3.0.0",
     "string-width": "=4.2.2",

+ 3 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -6,6 +6,9 @@
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",
+  "Send": "送信",
+  "Close": "閉じる",
+  "Done": "完了",
   "Delete": "削除",
   "meta": {
     "display_name": "日本語"

+ 3 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -6,6 +6,9 @@
   "Reset": "重启",
 	"Sign out": "退出",
   "New": "新建",
+  "Send": "发送",
+  "Close": "关闭",
+  "Done": "完成",
   "Delete": "删除",
 
   "meta": {

+ 4 - 1
apps/app/src/components/Admin/UserGroup/UserGroupForm.tsx

@@ -45,6 +45,9 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
 
   const isSelectableParentUserGroups = selectableParentUserGroups != null && selectableParentUserGroups.length > 0;
 
+  const isChildUserGroup = parentUserGroup !== undefined;
+  const messageAtReleaseParentGroup = isChildUserGroup ? t('user_group_management.release_parent_group') : t('user_group_management.select_parent_group');
+
   return (
     <form onSubmit={(e) => {
       e.preventDefault();
@@ -107,7 +110,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
                 btn btn-outline-secondary dropdown-toggle mb-3 ${isSelectableParentUserGroups ? '' : 'disabled'}
               `}
             >
-              {selectedParent?.name ?? t('user_group_management.select_parent_group')}
+              {selectedParent?.name ?? messageAtReleaseParentGroup}
             </button>
             <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
               {

+ 103 - 10
apps/app/src/components/Admin/Users/PasswordResetModal.jsx

@@ -2,13 +2,15 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 import PropTypes from 'prop-types';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import {
-  Modal, ModalHeader, ModalBody, ModalFooter,
+  Modal, ModalHeader, ModalBody, ModalFooter, Tooltip,
 } from 'reactstrap';
 
+import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastError } from '~/client/util/toastr';
-
+import { useIsMailerSetup } from '~/stores/context';
 
 class PasswordResetModal extends React.Component {
 
@@ -16,11 +18,15 @@ class PasswordResetModal extends React.Component {
     super(props);
 
     this.state = {
-      temporaryPassword: [],
+      temporaryPassword: '',
       isPasswordResetDone: false,
+      isEmailSent: false,
+      isEmailSending: false,
+      showTooltip: false,
     };
 
     this.resetPassword = this.resetPassword.bind(this);
+    this.onClickSendNewPasswordButton = this.onClickSendNewPasswordButton.bind(this);
   }
 
   async resetPassword() {
@@ -35,6 +41,44 @@ class PasswordResetModal extends React.Component {
     }
   }
 
+  renderButtons() {
+    const { t, isMailerSetup } = this.props;
+    const { isEmailSent, isEmailSending } = this.state;
+
+    return (
+      <>
+        <button type="submit" className={`btn ${isEmailSent ? 'btn-secondary' : 'btn-primary'}`}
+          onClick={this.onClickSendNewPasswordButton} disabled={!isMailerSetup || isEmailSending || isEmailSent}>
+          {isEmailSending && <i className='fa fa-spinner fa-pulse mx-2' />}
+          {!isEmailSending && (isEmailSent ? t('commons:Done') : t('commons:Send'))}
+        </button>
+        <button type="submit" className="btn btn-danger" onClick={this.props.onClose}>
+          {t('commons:Close')}
+        </button>
+      </>
+    );
+  }
+
+  renderAddress() {
+    const { t, isMailerSetup, userForPasswordResetModal } = this.props;
+
+    return (
+      <div className="d-flex col text-left ml-1 pl-0">
+        {!isMailerSetup ? (
+          <label className="form-text text-muted" dangerouslySetInnerHTML={{ __html: t('admin:mailer_setup_required') }} />
+        ) : (
+          <>
+            <p className="mr-2">To:</p>
+            <div>
+              <p className="mb-0">{userForPasswordResetModal.username}</p>
+              <p className="mb-0">{userForPasswordResetModal.email}</p>
+            </div>
+          </>
+        )}
+      </div>
+    );
+  }
+
   renderModalBodyBeforeReset() {
     const { t, userForPasswordResetModal } = this.props;
 
@@ -53,6 +97,11 @@ class PasswordResetModal extends React.Component {
 
   returnModalBodyAfterReset() {
     const { t, userForPasswordResetModal } = this.props;
+    const { temporaryPassword, showPassword, showTooltip } = this.state;
+
+    const maskedPassword = showPassword
+      ? temporaryPassword
+      : '•'.repeat(temporaryPassword.length);
 
     return (
       <>
@@ -61,7 +110,28 @@ class PasswordResetModal extends React.Component {
           {t('user_management.reset_password_modal.target_user')}: <code>{userForPasswordResetModal.email}</code>
         </p>
         <p>
-          {t('user_management.reset_password_modal.new_password')}: <code>{this.state.temporaryPassword}</code>
+          {t('user_management.reset_password_modal.new_password')}:{' '}
+          <code>
+            <span
+              onMouseEnter={() => this.setState({ showPassword: true })}
+              onMouseLeave={() => this.setState({ showPassword: false })}
+            >
+              {showPassword ? temporaryPassword : maskedPassword}
+            </span>
+          </code>
+          <CopyToClipboard text={ temporaryPassword } onCopy={() => this.setState({ showTooltip: true })}>
+            <button id="copy-tooltip" type="button" className="btn btn-outline-secondary border-0">
+              <i className="fa fa-clone" aria-hidden="true"></i>
+            </button>
+          </CopyToClipboard>
+          <Tooltip
+            placement="right"
+            isOpen={showTooltip}
+            target="copy-tooltip"
+            toggle={() => this.setState({ showTooltip: false })}
+          >
+            {t('Copied!')}
+          </Tooltip>
         </p>
       </>
     );
@@ -77,15 +147,35 @@ class PasswordResetModal extends React.Component {
   }
 
   returnModalFooterAfterReset() {
-    const { t } = this.props;
-
     return (
-      <button type="submit" className="btn btn-primary" onClick={this.props.onClose}>
-        {t('Close')}
-      </button>
+      <>
+        {this.renderAddress()}
+        {this.renderButtons()}
+      </>
     );
   }
 
+  async onClickSendNewPasswordButton() {
+
+    const {
+      userForPasswordResetModal,
+    } = this.props;
+
+    this.setState({ isEmailSending: true });
+
+    try {
+      await apiv3Put('/users/reset-password-email', { id: userForPasswordResetModal._id, newPassword: this.state.temporaryPassword });
+      this.setState({ isEmailSent: true });
+    }
+    catch (err) {
+      this.setState({ isEmailSent: false });
+      toastError(err);
+    }
+    finally {
+      this.setState({ isEmailSending: false });
+    }
+  }
+
 
   render() {
     const { t } = this.props;
@@ -109,7 +199,8 @@ class PasswordResetModal extends React.Component {
 
 const PasswordResetModalWrapperFC = (props) => {
   const { t } = useTranslation('admin');
-  return <PasswordResetModal t={t} {...props} />;
+  const { data: isMailerSetup } = useIsMailerSetup();
+  return <PasswordResetModal t={t} isMailerSetup={isMailerSetup ?? false} {...props} />;
 };
 
 /**
@@ -122,6 +213,8 @@ PasswordResetModal.propTypes = {
   isOpen: PropTypes.bool.isRequired,
   onClose: PropTypes.func.isRequired,
   userForPasswordResetModal: PropTypes.object,
+  onSuccessfullySentNewPasswordEmail: PropTypes.func.isRequired,
+  adminUsersContainer: PropTypes.instanceOf(AdminUsersContainer).isRequired,
 
 };
 

+ 10 - 11
apps/app/src/components/Page/TagEditModal.jsx → apps/app/src/components/Page/TagEditModal.tsx

@@ -1,18 +1,24 @@
 import React, { useState, useEffect } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
 import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
 import TagsInput from './TagsInput';
 
-function TagEditModal(props) {
-  const [tags, setTags] = useState([]);
+type Props = {
+  tags: string[],
+  isOpen: boolean,
+  onClose?: () => void,
+  onTagsUpdated?: (tags: string[]) => Promise<void> | void,
+};
+
+function TagEditModal(props: Props): JSX.Element {
+  const [tags, setTags] = useState<string[]>([]);
   const { t } = useTranslation();
 
-  function onTagsUpdatedByTagsInput(tags) {
+  function onTagsUpdatedByTagsInput(tags: string[]) {
     setTags(tags);
   }
 
@@ -54,11 +60,4 @@ function TagEditModal(props) {
 
 }
 
-TagEditModal.propTypes = {
-  tags: PropTypes.array,
-  isOpen: PropTypes.bool.isRequired,
-  onClose: PropTypes.func,
-  onTagsUpdated: PropTypes.func,
-};
-
 export default TagEditModal;

+ 3 - 2
apps/app/src/components/Sidebar/SidebarNav.tsx

@@ -6,7 +6,7 @@ import Link from 'next/link';
 
 import { useUserUISettings } from '~/client/services/user-ui-settings';
 import { SidebarContentsType } from '~/interfaces/ui';
-import { useCurrentUser } from '~/stores/context';
+import { useCurrentUser, useGrowiCloudUri } from '~/stores/context';
 import { useCurrentSidebarContents } from '~/stores/ui';
 
 import styles from './SidebarNav.module.scss';
@@ -84,6 +84,7 @@ type Props = {
 export const SidebarNav: FC<Props> = (props: Props) => {
 
   const { data: currentUser } = useCurrentUser();
+  const { data: growiCloudUri } = useGrowiCloudUri();
 
   const [isAdmin, setAdmin] = useState(false);
 
@@ -110,7 +111,7 @@ export const SidebarNav: FC<Props> = (props: Props) => {
       <div className="grw-sidebar-nav-secondary-container">
         {isAdmin && <SecondaryItem label="Admin" iconName="settings" href="/admin" />}
         {/* <SecondaryItem label="Draft" iconName="file_copy" href="/me/drafts" /> */}
-        <SecondaryItem label="Help" iconName="help" href="https://docs.growi.org" isBlank />
+        <SecondaryItem label="Help" iconName="help" href={ growiCloudUri != null ? 'https://growi.cloud/help/' : 'https://docs.growi.org' } isBlank />
         <SecondaryItem label="Trash" iconName="delete" href="/trash" />
       </div>
     </div>

+ 197 - 56
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -2,18 +2,28 @@ import React, {
   useCallback, useEffect, useState,
 } from 'react';
 
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import assert from 'assert';
+
+import { Lang } from '@growi/core';
+import {
+  extractSupportedLocales, getLocalizedTemplate, type TemplateSummary,
+} from '@growi/pluginkit/dist/v4';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
   ModalHeader,
   ModalBody,
   ModalFooter,
+  UncontrolledDropdown,
+  DropdownToggle,
+  DropdownMenu,
+  DropdownItem,
 } from 'reactstrap';
 
-import { useTemplateModal } from '~/stores/modal';
+import { useSWRxTemplate, useSWRxTemplates } from '~/features/templates/stores';
+import { useTemplateModal, type TemplateModalStatus } from '~/stores/modal';
+import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
-import { useTemplates } from '~/stores/template';
 import loggerFactory from '~/utils/logger';
 
 import Preview from '../PageEditor/Preview';
@@ -23,108 +33,239 @@ import { useFormatter } from './use-formatter';
 const logger = loggerFactory('growi:components:TemplateModal');
 
 
-type TemplateRadioButtonProps = {
-  template: ITemplate,
-  onChange: (selectedTemplate: ITemplate) => void,
+function constructTemplateId(templateSummary: TemplateSummary): string {
+  const defaultTemplate = templateSummary.default;
+
+  return `${defaultTemplate.pluginId ?? ''}_${defaultTemplate.id}`;
+}
+
+type TemplateItemProps = {
+  templateSummary: TemplateSummary,
+  selectedLocale?: string,
+  onClick?: () => void,
   isSelected?: boolean,
+  usersDefaultLang?: Lang,
 }
 
-const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
-  const radioButtonId = `rb-${template.id}`;
+const TemplateItem: React.FC<TemplateItemProps> = ({
+  templateSummary,
+  onClick,
+  isSelected,
+  usersDefaultLang,
+}) => {
+  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+  const templateLocales = extractSupportedLocales(templateSummary);
+
+  assert(localizedTemplate?.isValid);
 
   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>
+    <a
+      className={`list-group-item list-group-item-action ${isSelected ? 'active' : ''}`}
+      onClick={onClick}
+      aria-current="true"
+    >
+      <h4 className="mb-1">{localizedTemplate.title}</h4>
+      <p className="mb-2">{localizedTemplate.desc}</p>
+      { templateLocales != null && Array.from(templateLocales).map(locale => (
+        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+      ))}
+    </a>
   );
 };
 
-export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation(['translation', 'commons']);
+type TemplateModalSubstanceProps = {
+  templateModalStatus: TemplateModalStatus,
+  close: () => void,
+}
 
+const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element => {
+  const { templateModalStatus, close } = props;
 
-  const { data: templateModalStatus, close } = useTemplateModal();
+  const { t } = useTranslation(['translation', 'commons']);
 
+  const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: rendererOptions } = usePreviewOptions();
-  const { data: templates } = useTemplates();
+  const { data: templateSummaries } = useSWRxTemplates();
+
+  const [selectedTemplateSummary, setSelectedTemplateSummary] = useState<TemplateSummary>();
+  const [selectedTemplateLocale, setSelectedTemplateLocale] = useState<string>();
 
-  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+  const { data: selectedTemplateMarkdown } = useSWRxTemplate(selectedTemplateSummary, selectedTemplateLocale);
 
   const { format } = useFormatter();
 
-  const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null || selectedTemplate == null) {
+  const usersDefaultLang = personalSettingsInfo?.lang;
+  const selectedLocalizedTemplate = getLocalizedTemplate(selectedTemplateSummary, usersDefaultLang);
+  const selectedTemplateLocales = extractSupportedLocales(selectedTemplateSummary);
+
+  const submitHandler = useCallback((markdown?: string) => {
+    if (markdown == null) {
       return;
     }
 
-    if (templateModalStatus.onSubmit == null || template == null) {
+    if (templateModalStatus.onSubmit == null) {
       close();
       return;
     }
 
-    templateModalStatus.onSubmit(format(selectedTemplate));
+    templateModalStatus.onSubmit(format(selectedTemplateMarkdown));
     close();
-  }, [close, format, selectedTemplate, templateModalStatus]);
+  }, [close, format, selectedTemplateMarkdown, templateModalStatus]);
+
+  const onClickHandler = useCallback((
+      templateSummary: TemplateSummary,
+  ) => {
+    let localeToSet: string | Lang | undefined;
+
+    if (selectedTemplateLocale != null && selectedTemplateLocale in templateSummary) {
+      localeToSet = selectedTemplateLocale;
+    }
+    else if (usersDefaultLang != null && usersDefaultLang in templateSummary) {
+      localeToSet = usersDefaultLang;
+    }
+    else {
+      localeToSet = undefined;
+    }
+
+    setSelectedTemplateLocale(localeToSet);
+    setSelectedTemplateSummary(templateSummary);
+  }, [selectedTemplateLocale, usersDefaultLang]);
 
   useEffect(() => {
-    if (!templateModalStatus?.isOpened) {
-      setSelectedTemplate(undefined);
+    if (!templateModalStatus.isOpened) {
+      setSelectedTemplateSummary(undefined);
+      setSelectedTemplateLocale(undefined);
     }
-  }, [templateModalStatus?.isOpened]);
+  }, [templateModalStatus.isOpened]);
 
-  if (templates == null || templateModalStatus == null) {
+  if (templateSummaries == null) {
     return <></>;
   }
 
   return (
-    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
+    <>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
       </ModalHeader>
-
       <ModalBody className="container">
         <div className="row">
-          <div className="col-12">
-            { templates.map(template => (
-              <TemplateRadioButton
-                key={template.id}
-                template={template}
-                onChange={selected => setSelectedTemplate(selected)}
-                isSelected={template.id === selectedTemplate?.id}
-              />
-            )) }
+          {/* List Group */}
+          <div className="d-none d-lg-block col-lg-4">
+            <div className="list-group">
+              {templateSummaries.map((templateSummary) => {
+                const templateId = constructTemplateId(templateSummary);
+                const isSelected = selectedTemplateSummary != null && constructTemplateId(selectedTemplateSummary) === templateId;
+
+                return (
+                  <TemplateItem
+                    key={templateId}
+                    templateSummary={templateSummary}
+                    onClick={() => onClickHandler(templateSummary)}
+                    isSelected={isSelected}
+                    usersDefaultLang={usersDefaultLang}
+                  />
+                );
+              })}
+            </div>
           </div>
-        </div>
+          {/* Dropdown */}
+          <div className='d-lg-none col mb-3'>
+            <UncontrolledDropdown>
+              <DropdownToggle caret type="button" outline className='w-100 text-right'>
+                <span className="float-left">
+                  {selectedLocalizedTemplate != null && selectedLocalizedTemplate.isValid
+                    ? selectedLocalizedTemplate.title
+                    : t('Select template')}
+                </span>
+              </DropdownToggle>
+              <DropdownMenu role="menu" className='p-0'>
+                {templateSummaries.map((templateSummary, index) => {
+                  const templateId = constructTemplateId(templateSummary);
+                  const localizedTemplate = getLocalizedTemplate(templateSummary, usersDefaultLang);
+                  const templateLocales = extractSupportedLocales(templateSummary);
 
-        <hr />
+                  assert(localizedTemplate?.isValid);
 
-        <h3>{t('Preview')}</h3>
-        <div className='card'>
-          <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
-            { rendererOptions != null && selectedTemplate != null && (
-              <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplate)}/>
-            ) }
+                  return (
+                    <DropdownItem
+                      key={templateId}
+                      onClick={() => onClickHandler(templateSummary)}
+                      className={`px-4 py-3 ${index === 0 ? '' : 'border-top'}`}
+                    >
+                      <h4 className="mb-1 text-wrap">{localizedTemplate.title}</h4>
+                      <p className="mb-1 text-wrap">{localizedTemplate.desc}</p>
+                      { templateLocales != null && Array.from(templateLocales).map(locale => (
+                        <span key={locale} className="badge border rounded-pill text-muted mr-1">{locale}</span>
+                      ))}
+                    </DropdownItem>
+                  );
+                })}
+              </DropdownMenu>
+            </UncontrolledDropdown>
+          </div>
+          <div className="col-12 col-lg-8">
+            <div className='row mb-2 mb-lg-0'>
+              <div className="col-6">
+                <h3>{t('preview')}</h3>
+              </div>
+              <div className="col-6 d-flex justify-content-end">
+                <UncontrolledDropdown>
+                  <DropdownToggle caret type="button" outline className='float-right' disabled={selectedTemplateSummary == null}>
+                    <span className="float-left">{selectedTemplateLocale != null ? selectedTemplateLocale : t('Language')}</span>
+                  </DropdownToggle>
+                  <DropdownMenu className="dropdown-menu" role="menu">
+                    { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
+                      return (
+                        <DropdownItem
+                          key={locale}
+                          onClick={() => setSelectedTemplateLocale(locale)}>
+                          <span>{locale}</span>
+                        </DropdownItem>
+                      );
+                    }) }
+                  </DropdownMenu>
+                </UncontrolledDropdown>
+              </div>
+            </div>
+            <div className='card'>
+              <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
+                { rendererOptions != null && selectedTemplateSummary != null && (
+                  <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplateMarkdown)}/>
+                ) }
+              </div>
+            </div>
           </div>
         </div>
-
       </ModalBody>
       <ModalFooter>
-        <button type="button" className="btn btn-sm btn-outline-secondary mx-1" onClick={close}>
+        <button type="button" className="btn btn-outline-secondary mx-1" onClick={close}>
           {t('Cancel')}
         </button>
-        <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
+        <button
+          type="submit"
+          className="btn btn-primary mx-1"
+          onClick={() => submitHandler(selectedTemplateMarkdown)}
+          disabled={selectedTemplateSummary == null}>
           {t('commons:Insert')}
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+
+export const TemplateModal = (): JSX.Element => {
+  const { data: templateModalStatus, close } = useTemplateModal();
+
+  if (templateModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="xl" autoFocus={false}>
+      { templateModalStatus.isOpened && (
+        <TemplateModalSubstance templateModalStatus={templateModalStatus} close={close} />
+      ) }
     </Modal>
   );
 };

+ 9 - 15
apps/app/src/components/TemplateModal/use-formatter.spec.tsx

@@ -1,6 +1,3 @@
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
-import { mock } from 'vitest-mock-extended';
-
 import { useFormatter } from './use-formatter';
 
 
@@ -47,26 +44,24 @@ describe('useFormatter', () => {
 
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = 'markdown body';
-    const markdown = format(template);
+    const markdown = 'markdown body';
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe('markdown body');
+    expect(formatted).toBe('markdown body');
   });
 
   it('returns markdown formatted when currentPagePath is undefined', () => {
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}{{^title}}(empty){{/title}}
 path: {{{path}}}
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: (empty)
 path: /
 `);
@@ -82,16 +77,15 @@ path: /
 
     // when
     const { format } = useFormatter();
-    const template = mock<ITemplate>();
-    template.markdown = `
+    const markdown = `
 title: {{{title}}}
 path: {{{path}}}
 date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
 `;
-    const markdown = format(template);
+    const formatted = format(markdown);
 
     // then
-    expect(markdown).toBe(`
+    expect(formatted).toBe(`
 title: Sandbox
 path: /Sandbox
 date: 2023/05/31 15:01

+ 5 - 8
apps/app/src/components/TemplateModal/use-formatter.tsx

@@ -1,6 +1,5 @@
 import path from 'path';
 
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
 import dateFnsFormat from 'date-fns/format';
 import mustache from 'mustache';
 
@@ -10,7 +9,7 @@ import loggerFactory from '~/utils/logger';
 const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
 
 
-type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatMethod = (markdown?: string) => string;
 type FormatterData = {
   format: FormatMethod,
 }
@@ -18,16 +17,15 @@ type FormatterData = {
 export const useFormatter = (): FormatterData => {
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const format: FormatMethod = (selectedTemplate) => {
-    if (selectedTemplate == null) {
+  const format: FormatMethod = (markdown) => {
+    if (markdown == null) {
       return '';
     }
 
     // replace placeholder
-    let markdown = selectedTemplate.markdown;
     const now = new Date();
     try {
-      markdown = mustache.render(selectedTemplate.markdown, {
+      return mustache.render(markdown, {
         title: path.basename(currentPagePath ?? '/'),
         path: currentPagePath ?? '/',
         yyyy: dateFnsFormat(now, 'yyyy'),
@@ -39,9 +37,8 @@ export const useFormatter = (): FormatterData => {
     }
     catch (err) {
       logger.warn('An error occured while ejs processing.', err);
+      return markdown;
     }
-
-    return markdown;
   };
 
   return { format };

+ 0 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss


+ 0 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx


+ 4 - 4
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -5,11 +5,11 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
-import type { IGrowiPluginOrigin } from '../../../interfaces';
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import type { IGrowiPluginOrigin } from '../../../../interfaces';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 export const PluginInstallerForm = (): JSX.Element => {
-  const { mutate } = useSWRxPlugins();
+  const { mutate } = useSWRxAdminPlugins();
   const { t } = useTranslation('admin');
 
   const submitHandler = useCallback(async(e) => {
@@ -25,7 +25,7 @@ export const PluginInstallerForm = (): JSX.Element => {
 
     const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
-      ghBranch,
+      ghBranch: ghBranch || 'main',
       // ghTag,
     };
 

+ 2 - 2
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { Spinner } from 'reactstrap';
 
-import { useSWRxPlugins } from '../../../stores/growi-plugin';
+import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
@@ -19,7 +19,7 @@ const Loading = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
 
-  const { data, mutate } = useSWRxPlugins();
+  const { data, mutate } = useSWRxAdminPlugins();
 
   return (
     <div>

+ 0 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts → apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/index.ts


+ 1 - 0
apps/app/src/features/growi-plugin/client/components/Admin/index.ts

@@ -0,0 +1 @@
+export * from './PluginsExtensionPageContents';

+ 1 - 1
apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx → apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react';
 
-import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils.client';
+import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var

+ 1 - 0
apps/app/src/features/growi-plugin/client/components/index.ts

@@ -0,0 +1 @@
+export * from './GrowiPluginsActivator';

+ 24 - 0
apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx

@@ -0,0 +1,24 @@
+import useSWR, { SWRResponse } from 'swr';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+import type { IGrowiPluginHasId } from '../../interfaces';
+
+type Plugins = {
+  plugins: IGrowiPluginHasId[]
+}
+
+export const useSWRxAdminPlugins = (): SWRResponse<Plugins, Error> => {
+  return useSWR(
+    '/plugins',
+    async(endpoint) => {
+      try {
+        const res = await apiv3Get<Plugins>(endpoint);
+        return res.data;
+      }
+      catch (err) {
+        throw new Error(err);
+      }
+    },
+  );
+};

+ 0 - 0
apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts → apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts


+ 0 - 1
apps/app/src/features/growi-plugin/components/index.ts

@@ -1 +0,0 @@
-export * from './GrowiPluginsActivator.client';

+ 14 - 11
apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -1,12 +1,5 @@
-import { GrowiThemeMetadata, HasObjectId } from '@growi/core';
-
-export const GrowiPluginResourceType = {
-  Template: 'template',
-  Style: 'style',
-  Theme: 'theme',
-  Script: 'script',
-} as const;
-export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
+import type { GrowiPluginType, GrowiThemeMetadata, HasObjectId } from '@growi/core';
+import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
 
 export type IGrowiPluginOrigin = {
   url: string,
@@ -24,13 +17,23 @@ export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
 
 export type IGrowiPluginMeta = {
   name: string,
-  types: GrowiPluginResourceType[],
+  types: GrowiPluginType[],
   desc?: string,
   author?: string,
 }
 
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
-  themes: GrowiThemeMetadata[]
+  themes: GrowiThemeMetadata[],
+}
+
+export type IGrowiTemplatePluginMeta = IGrowiPluginMeta & {
+  templateSummaries: TemplateSummary[],
 }
 
+export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> = T extends 'theme'
+  ? IGrowiThemePluginMeta
+  : T extends 'template'
+    ? IGrowiTemplatePluginMeta
+    : IGrowiPluginMeta;
+
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 5 - 0
apps/app/src/features/growi-plugin/server/consts/index.ts

@@ -0,0 +1,5 @@
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+export const PLUGIN_STORING_PATH = resolveFromRoot('tmp/plugins');
+
+export const PLUGIN_EXPRESS_STATIC_DIR = '/static/plugins';

+ 157 - 0
apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts

@@ -0,0 +1,157 @@
+import { GrowiPluginType } from '@growi/core';
+
+import { GrowiPlugin } from './growi-plugin';
+
+describe('GrowiPlugin find methods', () => {
+
+  beforeAll(async() => {
+    await GrowiPlugin.insertMany([
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-unenabled1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-unenabled1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-unenabled1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-unenabled2',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-unenabled2',
+        },
+        meta: {
+          name: '@growi/growi-plugin-unenabled2',
+          types: [GrowiPluginType.Template],
+        },
+      },
+      {
+        isEnabled: true,
+        installedPath: 'weseek/growi-plugin-example1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+      {
+        isEnabled: true,
+        installedPath: 'weseek/growi-plugin-example2',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example2',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example2',
+          types: [GrowiPluginType.Template],
+        },
+      },
+    ]);
+  });
+
+  afterAll(async() => {
+    await GrowiPlugin.deleteMany({});
+  });
+
+  describe.concurrent('.findEnabledPlugins', () => {
+    it('shoud returns documents which isEnabled is true', async() => {
+      // when
+      const results = await GrowiPlugin.findEnabledPlugins();
+
+      const pluginNames = results.map(p => p.meta.name);
+
+      // then
+      expect(results.length === 2).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example1')).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
+    });
+  });
+
+  describe.concurrent('.findEnabledPluginsByType', () => {
+    it("shoud returns documents which type is 'template'", async() => {
+      // when
+      const results = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+
+      const pluginNames = results.map(p => p.meta.name);
+
+      // then
+      expect(results.length === 1).toBeTruthy();
+      expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
+    });
+  });
+
+});
+
+
+describe('GrowiPlugin activate/deactivate', () => {
+
+  beforeAll(async() => {
+    await GrowiPlugin.insertMany([
+      {
+        isEnabled: false,
+        installedPath: 'weseek/growi-plugin-example1',
+        organizationName: 'weseek',
+        origin: {
+          url: 'https://github.com/weseek/growi-plugin-example1',
+        },
+        meta: {
+          name: '@growi/growi-plugin-example1',
+          types: [GrowiPluginType.Script],
+        },
+      },
+    ]);
+  });
+
+  afterAll(async() => {
+    await GrowiPlugin.deleteMany({});
+  });
+
+  describe('.activatePlugin', () => {
+    it('shoud update the property "isEnabled" to true', async() => {
+      // setup
+      const plugin = await GrowiPlugin.findOne({});
+      assert(plugin != null);
+
+      expect(plugin.isEnabled).toBeFalsy(); // isEnabled: false
+
+      // when
+      const result = await GrowiPlugin.activatePlugin(plugin._id);
+      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+
+      // then
+      expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
+      expect(pluginAfterActivated).not.toBeNull();
+      assert(pluginAfterActivated != null);
+      expect(pluginAfterActivated.isEnabled).toBeTruthy(); // isEnabled: true
+    });
+  });
+
+  describe('.deactivatePlugin', () => {
+    it('shoud update the property "isEnabled" to true', async() => {
+      // setup
+      const plugin = await GrowiPlugin.findOne({});
+      assert(plugin != null);
+
+      expect(plugin.isEnabled).toBeTruthy(); // isEnabled: true
+
+      // when
+      const result = await GrowiPlugin.deactivatePlugin(plugin._id);
+      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+
+      // then
+      expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
+      expect(pluginAfterActivated).not.toBeNull();
+      assert(pluginAfterActivated != null);
+      expect(pluginAfterActivated.isEnabled).toBeFalsy(); // isEnabled: false
+    });
+  });
+
+});

+ 17 - 28
apps/app/src/features/growi-plugin/models/growi-plugin.ts → apps/app/src/features/growi-plugin/server/models/growi-plugin.ts

@@ -1,48 +1,35 @@
-import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
+import { GrowiPluginType } from '@growi/core';
 import {
   Schema, type Model, type Document, type Types,
 } from 'mongoose';
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { GrowiPluginResourceType } from '../interfaces';
 import type {
-  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginOrigin, IGrowiThemePluginMeta,
-} from '../interfaces';
+  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginMetaByType, IGrowiPluginOrigin, IGrowiTemplatePluginMeta, IGrowiThemePluginMeta,
+} from '../../interfaces';
 
-export interface IGrowiPluginDocument extends IGrowiPlugin, Document {
+export interface IGrowiPluginDocument<M extends IGrowiPluginMeta = IGrowiPluginMeta> extends IGrowiPlugin<M>, Document {
+  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta,
 }
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
-  findEnabledPlugins(): Promise<IGrowiPlugin[]>
-  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]>
+  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>
+  findEnabledPluginsByType<T extends GrowiPluginType>(type: T): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>
   activatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
 
-const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
-  name: { type: String, required: true },
-  manifestKey: { type: String, required: true },
-  schemeType: {
-    type: String,
-    enum: GrowiThemeSchemeType,
-    require: true,
-  },
-  bg: { type: String, required: true },
-  topbar: { type: String, required: true },
-  sidebar: { type: String, required: true },
-  accent: { type: String, required: true },
-});
-
-const growiPluginMetaSchema = new Schema<IGrowiPluginMeta|IGrowiThemePluginMeta>({
+const growiPluginMetaSchema = new Schema<IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
-    enum: GrowiPluginResourceType,
+    enum: GrowiPluginType,
     require: true,
   },
   desc: { type: String },
   author: { type: String },
-  themes: [growiThemeMetadataSchema],
+  themes: [Map],
+  templateSummaries: [Map],
 });
 
 const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
@@ -60,14 +47,16 @@ const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
 });
 
 growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
-  return this.find({ isEnabled: true });
+  return this.find({ isEnabled: true }).lean();
 };
 
-growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPluginsByType = async function<T extends GrowiPluginType>(
+    type: T,
+): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
   return this.find({
     isEnabled: true,
-    'meta.types': { $in: types },
-  });
+    'meta.types': { $in: type },
+  }).lean();
 };
 
 growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {

+ 0 - 0
apps/app/src/features/growi-plugin/models/index.ts → apps/app/src/features/growi-plugin/server/models/index.ts


+ 0 - 0
apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts → apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts


+ 4 - 2
apps/app/src/features/growi-plugin/models/vo/github-url.ts → apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -1,3 +1,5 @@
+import sanitize from 'sanitize-filename';
+
 // https://regex101.com/r/fK2rV3/1
 const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
 
@@ -44,8 +46,8 @@ export class GitHubUrl {
 
     this._branchName = branchName;
 
-    this._organizationName = matched[1];
-    this._reposName = matched[2];
+    this._organizationName = sanitize(matched[1]);
+    this._reposName = sanitize(matched[2]);
   }
 
 }

+ 2 - 2
apps/app/src/features/growi-plugin/routes/growi-plugins.ts → apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -5,8 +5,8 @@ import mongoose from 'mongoose';
 import Crowi from '~/server/crowi';
 import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 
-import { GrowiPlugin } from '../models';
-import { growiPluginService } from '../services';
+import { GrowiPlugin } from '../../../models';
+import { growiPluginService } from '../../../services';
 
 
 const ObjectID = mongoose.Types.ObjectId;

+ 11 - 0
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts

@@ -0,0 +1,11 @@
+import type { GrowiPluginValidationData } from '@growi/pluginkit';
+import { scanAllTemplates } from '@growi/pluginkit/dist/v4/server';
+
+import type { IGrowiPlugin, IGrowiTemplatePluginMeta } from '../../../interfaces';
+
+export const generateTemplatePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiTemplatePluginMeta> => {
+  return {
+    ...plugin.meta,
+    templateSummaries: await scanAllTemplates(validationData.projectDirRoot, { pluginId: plugin.installedPath }),
+  };
+};

+ 12 - 0
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts

@@ -0,0 +1,12 @@
+import type { GrowiPluginValidationData } from '@growi/pluginkit';
+
+import type { IGrowiPlugin, IGrowiThemePluginMeta } from '../../../interfaces';
+
+export const generateThemePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiThemePluginMeta> => {
+  // TODO: validate as a theme plugin
+
+  return {
+    ...plugin.meta,
+    themes: validationData.growiPlugin.themes,
+  };
+};

+ 94 - 0
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts

@@ -0,0 +1,94 @@
+import fs from 'fs';
+import path from 'path';
+
+import { PLUGIN_STORING_PATH } from '../../consts';
+import { GrowiPlugin } from '../../models';
+
+import { growiPluginService } from './growi-plugin';
+
+describe('Installing a GROWI template plugin', () => {
+
+  it('install() should success', async() => {
+    // when
+    const result = await growiPluginService.install({
+      url: 'https://github.com/weseek/growi-plugin-templates-for-office',
+    });
+    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+
+    // expect
+    expect(result).toEqual('growi-plugin-templates-for-office');
+    expect(count).toBe(1);
+    expect(fs.existsSync(path.join(
+      PLUGIN_STORING_PATH,
+      'weseek',
+      'growi-plugin-templates-for-office',
+    ))).toBeTruthy();
+  });
+
+  it('install() should success (re-install)', async() => {
+    // confirm
+    const count1 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    expect(count1).toBe(1);
+
+    // setup
+    const dummyFilePath = path.join(
+      PLUGIN_STORING_PATH,
+      'weseek',
+      'growi-plugin-templates-for-office',
+      'dummy.txt',
+    );
+    fs.appendFileSync(dummyFilePath, '');
+    expect(fs.existsSync(dummyFilePath)).toBeTruthy();
+
+    // when
+    const result = await growiPluginService.install({
+      url: 'https://github.com/weseek/growi-plugin-templates-for-office',
+    });
+    const count2 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+
+    // expect
+    expect(result).toEqual('growi-plugin-templates-for-office');
+    expect(count2).toBe(1);
+    expect(fs.existsSync(dummyFilePath)).toBeFalsy(); // the dummy file should be removed
+  });
+
+});
+
+describe('Installing a GROWI theme plugin', () => {
+
+  it('install() should success', async() => {
+    // when
+    const result = await growiPluginService.install({
+      url: 'https://github.com/weseek/growi-plugin-theme-welcome-to-fumiya-room',
+    });
+    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-welcome-to-fumiya-room' });
+
+    // expect
+    expect(result).toEqual('growi-plugin-theme-welcome-to-fumiya-room');
+    expect(count).toBe(1);
+    expect(fs.existsSync(path.join(
+      PLUGIN_STORING_PATH,
+      'weseek',
+      'growi-plugin-theme-welcome-to-fumiya-room',
+    ))).toBeTruthy();
+  });
+
+  it('findThemePlugin() should return data with metadata and manifest', async() => {
+    // confirm
+    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-welcome-to-fumiya-room' });
+    expect(count).toBe(1);
+
+    // when
+    const results = await growiPluginService.findThemePlugin('welcome-to-fumiya-room');
+
+    // expect
+    expect(results).not.toBeNull();
+    assert(results != null);
+    expect(results.growiPlugin).not.toBeNull();
+    expect(results.themeMetadata).not.toBeNull();
+    expect(results.themeHref).not.toBeNull();
+    expect(results.themeHref
+      .startsWith('/static/plugins/weseek/growi-plugin-theme-welcome-to-fumiya-room/dist/assets/style.')).toBeTruthy();
+  });
+
+});

+ 90 - 72
apps/app/src/features/growi-plugin/services/growi-plugin.ts → apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -1,33 +1,39 @@
 import fs, { readFileSync } from 'fs';
 import path from 'path';
 
-import { GrowiThemeMetadata, ViteManifest } from '@growi/core';
+import { GrowiPluginType, type GrowiThemeMetadata, type ViteManifest } from '@growi/core';
+import type { GrowiPluginPackageData } from '@growi/pluginkit';
+import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist/v4/server';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
+import sanitize from 'sanitize-filename';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
 import loggerFactory from '~/utils/logger';
-import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import { GrowiPluginResourceType } from '../interfaces';
 import type {
-  IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
-} from '../interfaces';
-import { GrowiPlugin } from '../models';
-import { GitHubUrl } from '../models/vo/github-url';
+  IGrowiPlugin, IGrowiPluginOrigin, IGrowiPluginMeta,
+} from '../../../interfaces';
+import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '../../consts';
+import { GrowiPlugin } from '../../models';
+import { GitHubUrl } from '../../models/vo/github-url';
+
+import { generateTemplatePluginMeta } from './generate-template-plugin-meta';
+import { generateThemePluginMeta } from './generate-theme-plugin-meta';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginStoringPath = resolveFromRoot('tmp/plugins');
+export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
+function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest | undefined {
+  const manifestPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath, 'dist/manifest.json');
 
-export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
+  if (!fs.existsSync(manifestPath)) {
+    return;
+  }
 
-function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest {
-  const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
   const manifestStr: string = readFileSync(manifestPath, 'utf-8');
   return JSON.parse(manifestStr);
 }
@@ -58,8 +64,8 @@ export class GrowiPluginService implements IGrowiPluginService {
 
       // if not exists repository in file system, download latest plugin repository
       for await (const growiPlugin of growiPlugins) {
-        const pluginPath = path.join(pluginStoringPath, growiPlugin.installedPath);
-        const organizationName = path.join(pluginStoringPath, growiPlugin.organizationName);
+        const pluginPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath);
+        const organizationName = path.join(PLUGIN_STORING_PATH, growiPlugin.organizationName);
         if (fs.existsSync(pluginPath)) {
           continue;
         }
@@ -69,12 +75,12 @@ export class GrowiPluginService implements IGrowiPluginService {
           }
 
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.branchName);
+          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
           const { reposName, branchName, archiveUrl } = ghUrl;
 
-          const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
-          const unzippedPath = pluginStoringPath;
-          const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
+          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${branchName}.zip`);
+          const unzippedPath = PLUGIN_STORING_PATH;
+          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${branchName}`);
 
           try {
             // download github repository to local file system
@@ -106,42 +112,42 @@ export class GrowiPluginService implements IGrowiPluginService {
     const {
       organizationName, reposName, branchName, archiveUrl,
     } = ghUrl;
+
+    const sanitizedBranchName = sanitize(branchName);
+
     const installedPath = `${organizationName}/${reposName}`;
 
-    const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
-    const unzippedPath = pluginStoringPath;
-    const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
-    const temporaryReposPath = path.join(pluginStoringPath, reposName);
-    const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
-    const organizationPath = path.join(pluginStoringPath, organizationName);
+    const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
+    const zipFilePath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}.zip`);
+    const temporaryReposPath = path.join(organizationPath, `${reposName}-${sanitizedBranchName}`);
+    const reposPath = path.join(organizationPath, reposName);
 
+    if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
     let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
       await this.download(archiveUrl, zipFilePath);
-      await this.unzip(zipFilePath, unzippedPath);
-      fs.renameSync(unzippedReposPath, temporaryReposPath);
+      await this.unzip(zipFilePath, organizationPath);
 
       // detect plugins
-      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName);
-
-      if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
+      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName, { packageRootPath: temporaryReposPath });
 
       // remove the old repository from the storing path
-      if (fs.existsSync(reposStoringPath)) await fs.promises.rm(reposStoringPath, { recursive: true });
+      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
 
       // move new repository from temporary path to storing path.
-      fs.renameSync(temporaryReposPath, reposStoringPath);
+      fs.renameSync(temporaryReposPath, reposPath);
     }
     catch (err) {
+      logger.error(err);
+      throw err;
+    }
+    finally {
       // clean up
       if (fs.existsSync(zipFilePath)) await fs.promises.rm(zipFilePath);
-      if (fs.existsSync(unzippedReposPath)) await fs.promises.rm(unzippedReposPath, { recursive: true });
       if (fs.existsSync(temporaryReposPath)) await fs.promises.rm(temporaryReposPath, { recursive: true });
-      logger.error(err);
-      throw err;
     }
 
     try {
@@ -154,9 +160,10 @@ export class GrowiPluginService implements IGrowiPluginService {
       return plugins[0].meta.name;
     }
     catch (err) {
-      // clean up
-      if (fs.existsSync(reposStoringPath)) await fs.promises.rm(reposStoringPath, { recursive: true });
+      // uninstall
+      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
       await this.deleteOldPluginDocument(installedPath);
+
       logger.error(err);
       throw err;
     }
@@ -189,22 +196,21 @@ export class GrowiPluginService implements IGrowiPluginService {
         }).catch((err) => {
           logger.error(err);
           // eslint-disable-next-line prefer-promise-reject-errors
-          rejects('Filed to download file.');
+          rejects('Failed to download file.');
         });
     });
   }
 
-  private async unzip(zipFilePath: fs.PathLike, unzippedPath: fs.PathLike): Promise<void> {
+  private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
     try {
       const stream = fs.createReadStream(zipFilePath);
-      const unzipStream = stream.pipe(unzipper.Extract({ path: unzippedPath }));
+      const unzipStream = stream.pipe(unzipper.Extract({ path: destPath }));
 
       await streamToPromise(unzipStream);
-      await fs.promises.rm(zipFilePath);
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to unzip.');
+      throw new Error('Failed to unzip.');
     }
   }
 
@@ -213,35 +219,39 @@ export class GrowiPluginService implements IGrowiPluginService {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
-  private static async detectPlugins(origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<IGrowiPlugin[]> {
-    const packageJsonPath = path.resolve(pluginStoringPath, ghReposName, 'package.json');
-    const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
+  private static async detectPlugins(
+      origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string,
+      opts?: {
+        packageRootPath?: string,
+        parentPackageData?: GrowiPluginPackageData,
+      },
+  ): Promise<IGrowiPlugin[]> {
+    const packageRootPath = opts?.packageRootPath ?? path.resolve(PLUGIN_STORING_PATH, ghOrganizationName, ghReposName);
 
-    const { growiPlugin } = packageJson;
-    const {
-      name: packageName, description: packageDesc, author: packageAuthor,
-    } = parentPackageJson ?? packageJson;
+    // validate
+    const validationData = await validateGrowiDirective(packageRootPath);
 
+    const packageData = opts?.parentPackageData ?? importPackageJson(packageRootPath);
 
-    if (growiPlugin == null) {
-      throw new Error('This package does not include \'growiPlugin\' section.');
-    }
+    const { growiPlugin } = validationData;
+    const {
+      name: packageName, description: packageDesc, author: packageAuthor,
+    } = packageData;
 
     // 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(ghReposName, subPackagePath);
-          return this.detectPlugins(origin, subPackageInstalledPath, packageJson);
+          return this.detectPlugins(origin, ghOrganizationName, ghReposName, {
+            packageRootPath: path.join(packageRootPath, subPackagePath),
+            parentPackageData: packageData,
+          });
         }),
       );
       return plugins.flat();
     }
 
-    if (growiPlugin.types == null) {
-      throw new Error('\'growiPlugin\' section must have a \'types\' property.');
-    }
-    const plugin = {
+    const plugin: IGrowiPlugin = {
       isEnabled: true,
       installedPath: `${ghOrganizationName}/${ghReposName}`,
       organizationName: ghOrganizationName,
@@ -255,11 +265,12 @@ export class GrowiPluginService implements IGrowiPluginService {
     };
 
     // add theme metadata
-    if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
-      (plugin as IGrowiPlugin<IGrowiThemePluginMeta>).meta = {
-        ...plugin.meta,
-        themes: growiPlugin.themes,
-      };
+    if (growiPlugin.types.includes(GrowiPluginType.Theme)) {
+      plugin.meta = await generateThemePluginMeta(plugin, validationData);
+    }
+    // add template metadata
+    if (growiPlugin.types.includes(GrowiPluginType.Template)) {
+      plugin.meta = await generateTemplatePluginMeta(plugin, validationData);
     }
 
     logger.info('Plugin detected => ', plugin);
@@ -286,12 +297,12 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
 
     try {
-      const growiPluginsPath = path.join(pluginStoringPath, growiPlugins.installedPath);
+      const growiPluginsPath = path.join(PLUGIN_STORING_PATH, growiPlugins.installedPath);
       await deleteFolder(growiPluginsPath);
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to delete plugin repository.');
+      throw new Error('Failed to delete plugin repository.');
     }
 
     try {
@@ -299,7 +310,7 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to delete plugin from GrowiPlugin documents.');
+      throw new Error('Failed to delete plugin from GrowiPlugin documents.');
     }
 
     return growiPlugins.meta.name;
@@ -311,10 +322,10 @@ export class GrowiPluginService implements IGrowiPluginService {
 
     try {
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as IGrowiPlugin<IGrowiThemePluginMeta>[];
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
 
       growiPlugins
-        .forEach(async(growiPlugin) => {
+        .forEach((growiPlugin) => {
           const themeMetadatas = growiPlugin.meta.themes;
           const themeMetadata = themeMetadatas.find(t => t.name === theme);
 
@@ -336,7 +347,10 @@ export class GrowiPluginService implements IGrowiPluginService {
     let themeHref;
     try {
       const manifest = retrievePluginManifest(matchedPlugin);
-      themeHref = `${PLUGINS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
+      if (manifest == null) {
+        throw new Error('The manifest file does not exists');
+      }
+      themeHref = `${PLUGIN_EXPRESS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
     }
     catch (e) {
       logger.error(`Could not read manifest file for the theme '${theme}'`, e);
@@ -357,14 +371,18 @@ export class GrowiPluginService implements IGrowiPluginService {
           const { types } = growiPlugin.meta;
           const manifest = await retrievePluginManifest(growiPlugin);
 
+          if (manifest == null) {
+            return;
+          }
+
           // add script
-          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Template)) {
-            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`;
+          if (types.includes(GrowiPluginType.Script)) {
+            const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`;
             entries.push([growiPlugin.installedPath, href]);
           }
           // add link
-          if (types.includes(GrowiPluginResourceType.Script) || types.includes(GrowiPluginResourceType.Style)) {
-            const href = `${PLUGINS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
+          if (types.includes(GrowiPluginType.Script) || types.includes(GrowiPluginType.Style)) {
+            const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
             entries.push([growiPlugin.installedPath, href]);
           }
         }

+ 0 - 0
apps/app/src/features/growi-plugin/services/index.ts → apps/app/src/features/growi-plugin/server/services/growi-plugin/index.ts


+ 1 - 0
apps/app/src/features/growi-plugin/server/services/index.ts

@@ -0,0 +1 @@
+export * from './growi-plugin';

+ 0 - 27
apps/app/src/features/growi-plugin/stores/growi-plugin.tsx

@@ -1,27 +0,0 @@
-import useSWR, { SWRResponse } from 'swr';
-
-import { apiv3Get } from '~/client/util/apiv3-client';
-
-import type { IGrowiPluginHasId } from '../interfaces';
-
-type Plugins = {
-  plugins: IGrowiPluginHasId[]
-}
-
-const pluginsFetcher = () => {
-  return async() => {
-    const reqUrl = '/plugins';
-
-    try {
-      const res = await apiv3Get(reqUrl);
-      return res.data;
-    }
-    catch (err) {
-      throw new Error(err);
-    }
-  };
-};
-
-export const useSWRxPlugins = (): SWRResponse<Plugins, Error> => {
-  return useSWR('/plugins', pluginsFetcher());
-};

+ 95 - 0
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -0,0 +1,95 @@
+import path from 'path';
+
+import { GrowiPluginType } from '@growi/core';
+import { TemplateSummary } from '@growi/pluginkit/dist/v4';
+import { scanAllTemplates, getMarkdown } from '@growi/pluginkit/dist/v4/server';
+import express from 'express';
+import { param, query } from 'express-validator';
+
+import { PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
+import { GrowiPlugin } from '~/features/growi-plugin/server/models';
+import { apiV3FormValidator } from '~/server/middlewares/apiv3-form-validator';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+import loggerFactory from '~/utils/logger';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+const logger = loggerFactory('growi:routes:apiv3:templates');
+
+const router = express.Router();
+
+const validator = {
+  list: [
+    query('includeInvalidTemplates').optional().isBoolean(),
+  ],
+  get: [
+    param('templateId').isString(),
+    param('locale').isString(),
+  ],
+};
+
+
+// cache object
+let presetTemplateSummaries: TemplateSummary[];
+
+
+module.exports = (crowi) => {
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+
+  router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
+    const { includeInvalidTemplates } = req.query;
+
+    // scan preset templates
+    if (presetTemplateSummaries == null) {
+      const presetTemplatesRoot = resolveFromRoot('../../node_modules/@growi/preset-templates');
+      presetTemplateSummaries = await scanAllTemplates(presetTemplatesRoot, {
+        returnsInvalidTemplates: includeInvalidTemplates,
+      });
+    }
+
+    // load plugin templates
+    const plugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+
+    return res.apiv3({
+      summaries: [
+        ...presetTemplateSummaries,
+        ...plugins.flatMap(p => p.meta.templateSummaries),
+      ],
+    });
+  });
+
+  router.get('/preset-templates/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(req, res: ApiV3Response) => {
+    const {
+      templateId, locale,
+    } = req.params;
+
+    const presetTemplatesRoot = resolveFromRoot('../../node_modules/@growi/preset-templates');
+
+    try {
+      const markdown = await getMarkdown(presetTemplatesRoot, templateId, locale);
+      return res.apiv3({ markdown });
+    }
+    catch (err) {
+      res.apiv3Err(err);
+    }
+  });
+
+  router.get('/plugin-templates/:organizationId/:reposId/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(
+      req, res: ApiV3Response,
+  ) => {
+    const {
+      organizationId, reposId, templateId, locale,
+    } = req.params;
+
+    const pluginRoot = path.join(PLUGIN_STORING_PATH, `${organizationId}/${reposId}`);
+
+    try {
+      const markdown = await getMarkdown(pluginRoot, templateId, locale);
+      return res.apiv3({ markdown });
+    }
+    catch (err) {
+      res.apiv3Err(err);
+    }
+  });
+
+  return router;
+};

+ 1 - 0
apps/app/src/features/templates/stores/index.ts

@@ -0,0 +1 @@
+export * from './template';

+ 30 - 0
apps/app/src/features/templates/stores/template.tsx

@@ -0,0 +1,30 @@
+import { getLocalizedTemplate, type TemplateSummary } from '@growi/pluginkit/dist/v4';
+import type { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
+
+import { apiv3Get } from '~/client/util/apiv3-client';
+
+export const useSWRxTemplates = (): SWRResponse<TemplateSummary[], Error> => {
+  return useSWRImmutable(
+    '/templates',
+    endpoint => apiv3Get<{ summaries: TemplateSummary[] }>(endpoint).then(res => res.data.summaries),
+  );
+};
+
+export const useSWRxTemplate = (summary: TemplateSummary | undefined, locale?: string): SWRResponse<string, Error> => {
+  const pluginId = summary?.default.pluginId;
+  const targetTemplate = getLocalizedTemplate(summary, locale);
+
+  return useSWRImmutable(
+    () => {
+      if (targetTemplate == null) {
+        return null;
+      }
+
+      return pluginId == null
+        ? `/templates/preset-templates/${targetTemplate.id}/${targetTemplate.locale}`
+        : `/templates/plugin-templates/${pluginId}/${targetTemplate.id}/${targetTemplate.locale}`;
+    },
+    endpoint => apiv3Get<{ markdown: string }>(endpoint).then(res => res.data.markdown),
+  );
+};

+ 32 - 23
apps/app/src/pages/[[...path]].page.tsx

@@ -35,7 +35,7 @@ import {
   useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPathname,
-  useIsSlackConfigured, useRendererConfig,
+  useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useIsContainerFluid, useIsNotCreatable,
 } from '~/stores/context';
 import { useEditingMarkdown } from '~/stores/editor';
@@ -68,7 +68,7 @@ declare global {
 }
 
 
-const GrowiPluginsActivator = dynamic(() => import('~/features/growi-plugin/components').then(mod => mod.GrowiPluginsActivator), { ssr: false });
+const GrowiPluginsActivator = dynamic(() => import('~/features/growi-plugin/client/components').then(mod => mod.GrowiPluginsActivator), { ssr: false });
 const DescendantsPageListModal = dynamic(() => import('../components/DescendantsPageListModal').then(mod => mod.DescendantsPageListModal), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
 const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() => import('../components/Navbar/GrowiSubNavigationSwitcher')
@@ -193,6 +193,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // commons
   useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   // page
   useIsContainerFluid(props.isContainerFluid);
@@ -237,15 +238,39 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useHasDraftOnHackmd(pageWithMeta?.data.hasDraftOnHackmd ?? false);
   useCurrentPathname(props.currentPathname);
 
-  useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
-
+  const { mutate: mutateInitialPage } = useSWRxCurrentPage();
   const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
+  const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
 
-  const { mutate: mutateIsNotFound } = useIsNotFound();
+  // Store initial data
+  useEffect(() => {
+    if (!props.skipSSR) {
+      mutateInitialPage(pageWithMeta?.data ?? null);
+    }
+  }, [mutateInitialPage, pageWithMeta, props.skipSSR]);
 
-  const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  // Store initial data (When revisionBody is not SSR)
+  useEffect(() => {
+    if (!props.skipSSR) {
+      return;
+    }
+
+    if (currentPageId != null && !props.isNotFound) {
+      const mutatePageData = async() => {
+        const pageData = await mutateCurrentPage();
+        mutateEditingMarkdown(pageData?.revision.body);
+      };
+
+      // If skipSSR is true, use the API to retrieve page data.
+      // Because pageWIthMeta does not contain revision.body
+      mutatePageData();
+    }
+  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
+
+
+  const { mutate: mutateIsNotFound } = useIsNotFound();
 
-  const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
 
   const { data: grantData } = useSWRxIsGrantNormalized(pageId);
@@ -266,22 +291,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     ? _isTrashPage(pageWithMeta.data.path)
     : false;
 
-
-  useEffect(() => {
-    if (!props.skipSSR) {
-      return;
-    }
-
-    if (currentPageId != null && !props.isNotFound) {
-      const mutatePageData = async() => {
-        const pageData = await mutateCurrentPage();
-        mutateEditingMarkdown(pageData?.revision.body);
-      };
-
-      mutatePageData();
-    }
-  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
-
   // sync grant data
   useEffect(() => {
     const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;

+ 2 - 1
apps/app/src/pages/_document.page.tsx

@@ -6,7 +6,7 @@ import Document, {
   Html, Head, Main, NextScript,
 } from 'next/document';
 
-import { growiPluginService, type GrowiPluginResourceEntries } from '~/features/growi-plugin/services';
+import type { GrowiPluginResourceEntries } from '~/features/growi-plugin/server/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import loggerFactory from '~/utils/logger';
 
@@ -57,6 +57,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const customNoscript: string | null = customizeService.getCustomNoscript();
 
     // retrieve plugin manifests
+    const growiPluginService = await import('~/features/growi-plugin/server/services').then(mod => mod.growiPluginService);
     const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
     return {

+ 2 - 1
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -12,7 +12,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import {
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri,
 } from '~/stores/context';
 
 import type { CommonProps } from './utils/commons';
@@ -41,6 +41,7 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
 
   // commons
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   useCurrentUser(props.currentUser ?? null);
 

+ 2 - 1
apps/app/src/pages/_search.page.tsx

@@ -13,7 +13,7 @@ import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { IUser, IUserHasId } from '~/interfaces/user';
 import {
   useCsrfToken, useCurrentUser, useIsContainerFluid, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useShowPageLimitationL, useGrowiCloudUri,
 } from '~/stores/context';
 
 import { SearchPage } from '../components/SearchPage';
@@ -47,6 +47,7 @@ const SearchResultPage: NextPageWithLayout<Props> = (props: Props) => {
 
   // commons
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   useCurrentUser(props.currentUser ?? null);
 

+ 1 - 1
apps/app/src/pages/admin/plugins.page.tsx

@@ -18,7 +18,7 @@ import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
 const PluginsExtensionPageContents = dynamic(
-  () => import('~/features/growi-plugin/components/Admin/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
+  () => import('~/features/growi-plugin/client/components/Admin').then(mod => mod.PluginsExtensionPageContents),
   { ssr: false },
 );
 

+ 2 - 1
apps/app/src/pages/me/[[...path]].page.tsx

@@ -14,7 +14,7 @@ import { BasicLayout } from '~/components/Layout/BasicLayout';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
-  useCurrentUser, useIsSearchPage,
+  useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
   useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig,
@@ -88,6 +88,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
 
   // commons
   useCsrfToken(props.csrfToken);
+  useGrowiCloudUri(props.growiCloudUri);
 
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);

+ 2 - 1
apps/app/src/pages/tags.page.tsx

@@ -16,7 +16,7 @@ import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
   useCurrentUser, useIsSearchPage,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
-  useIsSearchScopeChildrenAsDefault,
+  useIsSearchScopeChildrenAsDefault, useGrowiCloudUri,
 } from '../stores/context';
 
 import { NextPageWithLayout } from './_app.page';
@@ -55,6 +55,7 @@ const TagPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   const totalCount: number = tagDataList?.totalCount || 0;
   const isLoading = tagDataList === undefined && error == null;
 
+  useGrowiCloudUri(props.growiCloudUri);
 
   useIsSearchPage(false);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);

+ 3 - 1
apps/app/src/pages/trash.page.tsx

@@ -14,7 +14,7 @@ import { useDrawerMode } from '~/stores/ui';
 
 import { BasicLayout } from '../components/Layout/BasicLayout';
 import {
-  useCurrentUser, useCurrentPathname,
+  useCurrentUser, useCurrentPathname, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useIsSearchScopeChildrenAsDefault, useIsSearchPage, useShowPageLimitationXL, useIsGuestUser, useIsReadOnlyUser,
 } from '../stores/context';
@@ -42,6 +42,8 @@ type Props = CommonProps & {
 const TrashPage: NextPageWithLayout<CommonProps> = (props: Props) => {
   useCurrentUser(props.currentUser ?? null);
 
+  useGrowiCloudUri(props.growiCloudUri);
+
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);

+ 2 - 0
apps/app/src/pages/utils/commons.ts

@@ -30,6 +30,7 @@ export type CommonProps = {
   isMaintenanceMode: boolean,
   redirectDestination: string | null,
   isDefaultLogo: boolean,
+  growiCloudUri: string,
   currentUser?: IUserHasId,
   forcedColorScheme?: ColorScheme,
   sidebarConfig: ISidebarConfig,
@@ -96,6 +97,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     currentUser,
     isDefaultLogo,
     forcedColorScheme,
+    growiCloudUri: configManager.getConfig('crowi', 'app:growiCloudUri'),
     sidebarConfig: {
       isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
       isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),

+ 2 - 1
apps/app/src/server/crowi/express-init.js

@@ -2,6 +2,7 @@ import { manifestPath as presetThemesManifestPath } from '@growi/preset-themes';
 import csrf from 'csurf';
 import qs from 'qs';
 
+import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '~/features/growi-plugin/server/consts';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
@@ -88,7 +89,7 @@ module.exports = function(crowi, app) {
   app.use('/static/preset-themes', express.static(
     resolveFromRoot(`../../node_modules/@growi/preset-themes/${path.dirname(presetThemesManifestPath)}`),
   ));
-  app.use('/static/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
+  app.use(PLUGIN_EXPRESS_STATIC_DIR, express.static(PLUGIN_STORING_PATH));
 
   app.use(methodOverride());
 

+ 1 - 1
apps/app/src/server/crowi/index.js

@@ -706,7 +706,7 @@ Crowi.prototype.setupImport = async function() {
 };
 
 Crowi.prototype.setupGrowiPluginService = async function() {
-  const { growiPluginService } = require('~/features/growi-plugin/services');
+  const growiPluginService = await import('~/features/growi-plugin/server/services').then(mod => mod.growiPluginService);
 
   // download plugin repositories, if document exists but there is no repository
   // TODO: Cannot download unless connected to the Internet at setup.

+ 1 - 1
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -85,7 +85,7 @@ module.exports = (crowi) => {
               model: 'User',
             },
           },
-        }).exec();
+        }).exec() as never as BookmarkFolderItems[];
 
       const returnValue: BookmarkFolderItems[] = [];
 

+ 3 - 5
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -1,13 +1,11 @@
 /* eslint-disable no-unused-vars */
 
-import { ErrorV3 } from '@growi/core';
+import { GrowiPluginType, ErrorV3 } from '@growi/core';
 import express from 'express';
 import { body } from 'express-validator';
-import mongoose from 'mongoose';
 import multer from 'multer';
 
-import { GrowiPluginResourceType } from '~/features/growi-plugin/interfaces';
-import { GrowiPlugin } from '~/features/growi-plugin/models';
+import { GrowiPlugin } from '~/features/growi-plugin/server/models';
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
@@ -275,7 +273,7 @@ module.exports = (crowi) => {
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
 
       // retrieve plugin manifests
-      const themePlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
+      const themePlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
 
       const pluginThemesMetadatas = themePlugins
         .map(themePlugin => themePlugin.meta.themes)

+ 3 - 2
apps/app/src/server/routes/apiv3/index.js

@@ -1,3 +1,4 @@
+import growiPlugin from '~/features/growi-plugin/server/routes/apiv3/admin';
 import loggerFactory from '~/utils/logger';
 
 import { generateAddActivityMiddleware } from '../../middlewares/add-activity';
@@ -43,6 +44,7 @@ module.exports = (crowi, app) => {
   routerForAdmin.use('/slack-integration-legacy-settings', require('./slack-integration-legacy-settings')(crowi));
   routerForAdmin.use('/activity', require('./activity')(crowi));
   routerForAdmin.use('/g2g-transfer', g2gTransfer(crowi));
+  routerForAdmin.use('/plugins', growiPlugin(crowi));
 
   // auth
   const applicationInstalled = require('../../middlewares/application-installed')(crowi);
@@ -108,12 +110,11 @@ module.exports = (crowi, app) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
-  router.use('/plugins', require('~/features/growi-plugin/routes/growi-plugins')(crowi));
-
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 
   router.use('/bookmark-folder', require('./bookmark-folder')(crowi));
   router.use('/questionnaire', require('~/features/questionnaire/server/routes/apiv3/questionnaire')(crowi));
+  router.use('/templates', require('~/features/templates/server/routes/apiv3')(crowi));
 
   return [router, routerForAdmin, routerForAuth];
 };

+ 65 - 1
apps/app/src/server/routes/apiv3/users.js

@@ -182,6 +182,23 @@ module.exports = (crowi) => {
     return { failedToSendEmailList };
   };
 
+  const sendEmailByUser = async(user) => {
+    const { appService, mailService } = crowi;
+    const appTitle = appService.getAppTitle();
+
+    await mailService.send({
+      to: user.email,
+      subject: `New password for ${appTitle}`,
+      template: path.join(crowi.localeDir, 'en_US/admin/userResetPassword.ejs'),
+      vars: {
+        email: user.email,
+        password: user.password,
+        url: crowi.appService.getSiteUrl(),
+        appTitle,
+      },
+    });
+  };
+
   /**
    * @swagger
    *
@@ -948,7 +965,7 @@ module.exports = (crowi) => {
    *                    description: user id for reset password
    *        responses:
    *          200:
-   *            description: success resrt password
+   *            description: success reset password
    */
   router.put('/reset-password', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
     const { id } = req.body;
@@ -967,6 +984,53 @@ module.exports = (crowi) => {
     }
   });
 
+  /**
+   * @swagger
+   *
+   *  paths:
+   *    /users/reset-password-email:
+   *      put:
+   *        tags: [Users]
+   *        operationId: resetPasswordEmail
+   *        summary: /users/reset-password-email
+   *        description: send new password email
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  newPassword:
+   *                    type: string
+   *                  user:
+   *                    type: string
+   *                    description: user id for send new password email
+   *        responses:
+   *          200:
+   *            description: success send new password email
+   */
+  router.put('/reset-password-email', loginRequiredStrictly, adminRequired, addActivity, async(req, res) => {
+    const { id } = req.body;
+
+    try {
+      const user = await User.findById(id);
+      if (user == null) {
+        throw new Error('User not found');
+      }
+      const userInfo = {
+        email: user.email,
+        password: req.body.newPassword,
+      };
+
+      await sendEmailByUser(userInfo);
+      return res.apiv3();
+    }
+    catch (err) {
+      const msg = err.message;
+      logger.error('Error', err);
+      return res.apiv3Err(new ErrorV3(msg));
+    }
+  });
+
   /**
    * @swagger
    *

+ 1 - 1
apps/app/src/server/service/customize.ts

@@ -3,7 +3,7 @@ import { ColorScheme, DevidedPagePath, getForcedColorScheme } from '@growi/core'
 import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import uglifycss from 'uglifycss';
 
-import { growiPluginService } from '~/features/growi-plugin/services';
+import { growiPluginService } from '~/features/growi-plugin/server/services';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';

+ 1 - 1
apps/app/src/server/util/mongoose-utils.ts

@@ -26,7 +26,7 @@ export const getModelSafely = <T>(modelName: string): Model<T & Document> | null
 };
 
 // TODO: Do not use any type
-export const getOrCreateModel = <Interface, Method>(modelName: string, schema: any): any => {
+export const getOrCreateModel = <Interface, Method>(modelName: string, schema: any): Method & Model<Interface & Document> => {
   if (mongoose.modelNames().includes(modelName)) {
     return mongoose.model<Interface & Document, Method & Model<Interface & Document>>(modelName);
   }

+ 1 - 1
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -10,7 +10,7 @@ const SUPPORTED_ATTRIBUTES = ['attachmentId', 'url', 'attachmentName'];
 const isAttachmentLink = (url: string) => {
   // https://regex101.com/r/9qZhiK/1
   const attachmentUrlFormat = new RegExp(/^\/(attachment)\/([^/^\n]+)$/);
-  return url.match(attachmentUrlFormat);
+  return attachmentUrlFormat.test(url);
 };
 
 const rewriteNode = (node: Node) => {

+ 1 - 1
apps/app/src/stores/modal.tsx

@@ -633,7 +633,7 @@ type TemplateSelectedCallback = (templateText: string) => void;
 type TemplateModalOptions = {
   onSubmit?: TemplateSelectedCallback,
 }
-type TemplateModalStatus = TemplateModalOptions & {
+export type TemplateModalStatus = TemplateModalOptions & {
   isOpened: boolean,
 }
 

+ 1 - 1
apps/app/src/stores/renderer.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import type { HtmlElementNode } from 'rehype-toc';
 import useSWR, { type SWRResponse } from 'swr';
 
-import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
+import { getGrowiFacade } from '~/features/growi-plugin/client/utils/growi-facade-utils';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
 

+ 0 - 141
apps/app/src/stores/template.tsx

@@ -1,141 +0,0 @@
-import type { ITemplate } from '@growi/core';
-import useSWR, { type SWRResponse } from 'swr';
-
-import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
-
-const presetTemplates: ITemplate[] = [
-  // preset 1
-  {
-    id: '__preset1__',
-    name: '日報',
-    markdown: `# {{yyyy}}/{{MM}}/{{dd}} 日報
-
-## 今日の目標
-- 目標1
-    - 〇〇の完了
-- 目標2
-    - 〇〇を〇件達成
-
-
-## 内容
-- 10:00 ~ 10:20 今日のタスク確認
-- 10:20 ~ 11:00 全体会議
-
-
-## 進捗
-- 目標1
-    - 完了
-- 目標2
-    - 〇〇件達成
-
-
-## メモ
-- 改善できることの振り返り
-
-
-## 翌営業日の目標
-- 目標1
-    - 〇〇の完了
-- 目標2
-    - 〇〇を〇件達成
-`,
-  },
-
-  // preset 2
-  {
-    id: '__preset2__',
-    name: '議事録',
-    markdown: `# {{{title}}}{{^title}}<会議名>{{/title}}
-
-## 日時
-{{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}〜hh:mm
-
-
-## 参加者
--
-
-## 議題
-1.
-2.
-
-
-## 1.
-### 内容
-
-
-### 決定事項
-
-
-### Next Action
-
-
-## 2.
-### 内容
-
-
-### 決定事項
-
-
-### Next Action
-
-
-## 次回会議
-- 会議内容
-- 会議時間
-    - {{yyyy}}/{{MM}}/dd
-`,
-  },
-
-  // preset 3
-  {
-    id: '__preset3__',
-    name: '企画書',
-    markdown: `# {{{title}}}{{^title}}<企画タイトル>{{/title}}
-
-## 目的
-
-
-## 現状の課題
-
-
-## 概要
-#### 企画の内容
-
-#### スケジュール
-
-
-## 効果
-#### メリット
-
-#### 数値目標
-
-
-## 参考資料
-
-`,
-  },
-
-  // preset 4
-  {
-    id: '__preset4__',
-    name: '関連ページの一覧表示',
-    markdown: `# 関連ページ
-
-## 子ページ一覧
-$lsx(depth=1)
-`,
-  },
-];
-
-export const useTemplates = (): SWRResponse<ITemplate[], Error> => {
-  return useSWR(
-    'templates',
-    () => [
-      ...presetTemplates,
-      ...Object.values<ITemplate>(getGrowiFacade().customTemplates ?? {}),
-    ],
-    {
-      fallbackData: presetTemplates,
-    },
-  );
-};

+ 6 - 0
apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -65,10 +65,16 @@ context('Editor while uploading to a new page', () => {
     cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
 
     // drag-drop a file
+    cy.intercept('POST', '/_api/attachments.add').as('attachmentsAdd');
     const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
     cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
+    cy.wait('@attachmentsAdd')
+
+    // Update page using shortcut keys
+    cy.get('.CodeMirror').type('{ctrl+s}');
 
     // expect
+    cy.get('.Toastify__toast').should('contain.text', 'Saved successfully');
     cy.get('.CodeMirror').should('contain.text', body);
     cy.get('.CodeMirror').should('contain.text', '[example.txt](/attachment/');
     cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');

+ 1 - 1
apps/app/tsconfig.json

@@ -20,7 +20,7 @@
   "include": [
     "next-env.d.ts",
     "config",
-    "src",
+    "src"
   ],
   "ts-node": {
     "transpileOnly": true,

+ 0 - 11
apps/slackbot-proxy/bump-versions.config.js

@@ -1,11 +0,0 @@
-/*
- * Reference: https://community.algolia.com/shipjs/
- */
-module.exports = {
-  monorepo: {
-    mainVersionFile: 'package.json',
-    packagesToBump: [
-      './',
-    ],
-  },
-};

+ 3 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.4-slackbot-proxy.0",
+  "version": "6.1.5-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -18,7 +18,8 @@
     "postbuild": "yarn cp:public && yarn cp:views && yarn cp:bootstrap",
     "predev": "yarn cp:bootstrap:dev",
     "lint": "yarn eslint src --ext .ts",
-    "lint:fix": "yarn eslint src --ext .ts --fix"
+    "lint:fix": "yarn eslint src --ext .ts --fix",
+    "version": "yarn version --no-git-tag-version --preid=slackbot-proxy"
   },
   "// comments for dependencies": {
     "read-pkg-up": "v8 doesn't support CommonJS anymore. https://github.com/sindresorhus/read-pkg-up/issues/17"

+ 1 - 5
package.json

@@ -38,9 +38,7 @@
     "app:server": "cd apps/app && yarn server",
     "slackbot-proxy:build": "turbo run build --filter @growi/slackbot-proxy",
     "slackbot-proxy:server": "cd apps/slackbot-proxy && yarn start:prod",
-    "bump-versions:patch": "turbo run version -- --patch",
-    "bump-versions:rc": "turbo run version -- --prepatch --preid=RC",
-    "version": "yarn version --no-git-tag-version"
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "dependencies": {
     "cross-env": "^7.0.0",
@@ -60,7 +58,6 @@
     "@types/estree": "^1.0.1",
     "@types/node": "^17.0.43",
     "@types/path-browserify": "^1.0.0",
-    "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
     "@vitejs/plugin-react": "^3.1.0",
@@ -92,7 +89,6 @@
     "ts-node-dev": "^2.0.0",
     "tsconfig-paths": "^3.9.0",
     "typescript": "~4.9",
-    "unplugin-swc": "^1.3.2",
     "vite": "^4.3.8",
     "vite-plugin-dts": "^2.0.0-beta.0",
     "vite-tsconfig-paths": "^4.2.0",

+ 1 - 1
packages/core/package.json

@@ -21,7 +21,7 @@
     "lint:typecheck": "tsc",
     "lint": "npm-run-all -p lint:*",
     "test": "vitest run --coverage",
-    "version": "yarn version --no-git-tag-version"
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM"

+ 4 - 3
packages/pluginkit/src/consts/types.ts → packages/core/src/consts/growi-plugin.ts

@@ -1,6 +1,7 @@
 export const GrowiPluginType = {
-  SCRIPT: 'script',
-  TEMPLATE: 'template',
-  THEME: 'theme',
+  Template: 'template',
+  Style: 'style',
+  Theme: 'theme',
+  Script: 'script',
 } as const;
 export type GrowiPluginType = typeof GrowiPluginType[keyof typeof GrowiPluginType];

+ 1 - 0
packages/core/src/consts/index.ts

@@ -0,0 +1 @@
+export * from './growi-plugin';

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

@@ -1,17 +1,5 @@
-export * from './interfaces/attachment';
-export * from './interfaces/color-scheme';
-export * from './interfaces/common';
-export * from './interfaces/growi-facade';
-export * from './interfaces/growi-theme-metadata';
-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 './interfaces/vite';
+export * from './consts';
+export * from './interfaces';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';
 export * from './plugin';

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

@@ -1,5 +1,3 @@
-import type { ITemplate } from './template';
-
 export type GrowiFacade = {
   markdownRenderer?: {
     optionsGenerators?: {
@@ -10,7 +8,4 @@ export type GrowiFacade = {
     },
     optionsMutators?: any,
   },
-  customTemplates?: {
-    [pluginName: string]: ITemplate,
-  }
 };

+ 13 - 0
packages/core/src/interfaces/index.ts

@@ -0,0 +1,13 @@
+export * from './attachment';
+export * from './color-scheme';
+export * from './common';
+export * from './growi-facade';
+export * from './growi-theme-metadata';
+export * from './has-object-id';
+export * from './lang';
+export * from './page';
+export * from './revision';
+export * from './subscription';
+export * from './tag';
+export * from './user';
+export * from './vite';

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

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

+ 1 - 1
packages/hackmd/package.json

@@ -15,7 +15,7 @@
     "lint:js": "yarn eslint **/*.{js,ts}",
     "lint:typecheck": "tsc",
     "lint": "npm-run-all -p lint:*",
-    "version": "yarn version --no-git-tag-version"
+    "version": "yarn version --no-git-tag-version --preid=RC"
   },
   "dependencies": {},
   "devDependencies": {

+ 1 - 1
packages/pluginkit/package.json

@@ -16,7 +16,7 @@
     "test": "vitest run --coverage"
   },
   "dependencies": {
-    "@growi/core": "^6.1.5-RC",
+    "@growi/core": "link:../core",
     "extensible-custom-error": "^0.0.7"
   },
   "devDependencies": {

+ 0 - 1
packages/pluginkit/src/consts/index.ts

@@ -1 +0,0 @@
-export * from './types';

+ 0 - 1
packages/pluginkit/src/index.ts

@@ -1,2 +1 @@
-export * from './consts';
 export * from './model';

+ 12 - 0
packages/pluginkit/src/model/growi-plugin-package-data.ts

@@ -0,0 +1,12 @@
+import { GrowiPluginType } from '@growi/core';
+
+export type GrowiPluginDirective = {
+  [key: string]: any,
+  schemaVersion: number,
+  types: GrowiPluginType[],
+}
+
+export type GrowiPluginPackageData = {
+  [key: string]: any,
+  growiPlugin: GrowiPluginDirective,
+}

+ 5 - 2
packages/pluginkit/src/model/growi-plugin-validation-data.ts

@@ -1,8 +1,11 @@
-import { GrowiPluginType } from '../consts/types';
+import { GrowiPluginType } from '@growi/core/dist/consts';
+
+import { GrowiPluginDirective } from './growi-plugin-package-data';
 
 export type GrowiPluginValidationData = {
   projectDirRoot: string,
-  schemaVersion?: number,
+  growiPlugin: GrowiPluginDirective,
+  schemaVersion: number,
   expectedPluginType?: GrowiPluginType,
   actualPluginTypes?: GrowiPluginType[],
 };

+ 1 - 1
packages/pluginkit/src/model/growi-plugin-validation-error.ts

@@ -3,7 +3,7 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import type { GrowiPluginValidationData } from './growi-plugin-validation-data';
 
 
-export class GrowiPluginValidationError<E extends GrowiPluginValidationData = GrowiPluginValidationData> extends ExtensibleCustomError {
+export class GrowiPluginValidationError<E extends Partial<GrowiPluginValidationData> = Partial<GrowiPluginValidationData>> extends ExtensibleCustomError {
 
   data?: E;
 

+ 1 - 0
packages/pluginkit/src/model/index.ts

@@ -1,2 +1,3 @@
+export * from './growi-plugin-package-data';
 export * from './growi-plugin-validation-data';
 export * from './growi-plugin-validation-error';

+ 0 - 2
packages/pluginkit/src/server/utils/v4/index.ts

@@ -1,2 +0,0 @@
-export * from './package-json';
-export * from './template';

+ 0 - 11
packages/pluginkit/src/server/utils/v4/package-json/import.spec.ts

@@ -1,11 +0,0 @@
-import path from 'path';
-
-import { importPackageJson } from './import';
-
-it('importPackageJson() returns an object', async() => {
-  // when
-  const pkg = await importPackageJson(path.resolve(__dirname, '../../../../../test/fixtures/example-package/template1'));
-
-  // then
-  expect(pkg).not.toBeNull();
-});

+ 0 - 6
packages/pluginkit/src/server/utils/v4/package-json/import.ts

@@ -1,6 +0,0 @@
-import path from 'path';
-
-export const importPackageJson = async(projectDirRoot: string): Promise<any> => {
-  const packageJsonUrl = path.resolve(projectDirRoot, 'package.json');
-  return import(packageJsonUrl);
-};

+ 0 - 2
packages/pluginkit/src/server/utils/v4/package-json/index.ts

@@ -1,2 +0,0 @@
-export * from './import';
-export * from './validate';

+ 0 - 116
packages/pluginkit/src/server/utils/v4/package-json/validate.spec.ts

@@ -1,116 +0,0 @@
-import examplePkg from '^/test/fixtures/example-package/template1/package.json';
-
-import { GrowiPluginType } from '~/consts';
-
-import { validatePackageJson } from './validate';
-
-const mocks = vi.hoisted(() => {
-  return {
-    importPackageJsonMock: vi.fn(),
-  };
-});
-
-vi.mock('./import', () => {
-  return { importPackageJson: mocks.importPackageJsonMock };
-});
-
-describe('validatePackageJson()', () => {
-
-  it('returns a data object', async() => {
-    // setup
-    mocks.importPackageJsonMock.mockResolvedValue(examplePkg);
-
-    // when
-    const data = await validatePackageJson('package.json');
-
-    // then
-    expect(data).not.toBeNull();
-  });
-
-  it("with the 'expectedPluginType' argument returns a data object", async() => {
-    // setup
-    mocks.importPackageJsonMock.mockResolvedValue(examplePkg);
-
-    // when
-    const data = await validatePackageJson('package.json', GrowiPluginType.TEMPLATE);
-
-    // then
-    expect(data).not.toBeNull();
-  });
-
-  describe('should throw an GrowiPluginValidationError', () => {
-
-    it("when the pkg does not have 'growiPlugin' directive", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({});
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The package.json does not have 'growiPlugin' directive.");
-    });
-
-    it("when the 'schemaVersion' is NaN", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 'foo',
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
-    });
-
-    it("when the 'schemaVersion' is less than 4", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 3,
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
-    });
-
-    it("when the 'types' directive does not exist", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 4,
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive does not have 'types' directive.");
-    });
-
-    it("when the 'types' directive does not have expected plugin type", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 4,
-          types: [GrowiPluginType.TEMPLATE],
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json', GrowiPluginType.SCRIPT) };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive does not have expected plugin type in 'types' directive.");
-    });
-  });
-
-});

+ 0 - 162
packages/pluginkit/src/server/utils/v4/template.ts

@@ -1,162 +0,0 @@
-import assert from 'assert';
-import fs from 'fs';
-import path from 'path';
-import { promisify } from 'util';
-
-import { GrowiPluginType } from '~/consts';
-import type { GrowiPluginValidationData, GrowiTemplatePluginValidationData } from '~/model';
-import { GrowiPluginValidationError } from '~/model';
-
-import { importPackageJson, validatePackageJson } from './package-json';
-
-
-const statAsync = promisify(fs.stat);
-
-
-/**
- * An utility for template plugin which wrap 'validatePackageJson' of './package-json.ts' module
- * @param projectDirRoot
- */
-export const validateTemplatePluginPackageJson = async(projectDirRoot: string): Promise<GrowiTemplatePluginValidationData> => {
-  const data = await validatePackageJson(projectDirRoot, GrowiPluginType.TEMPLATE);
-
-  const pkg = await importPackageJson(projectDirRoot);
-
-  // check supporting locales
-  const supportingLocales: string[] | undefined = pkg.growiPlugin.locales;
-  if (supportingLocales == null || supportingLocales.length === 0) {
-    throw new GrowiPluginValidationError<GrowiPluginValidationData & { supportingLocales?: string[] }>(
-      "Template plugin must have 'supportingLocales' and that must have one or more locales",
-      {
-        ...data,
-        supportingLocales,
-      },
-    );
-  }
-
-  return {
-    ...data,
-    supportingLocales,
-  };
-};
-
-export type TemplateStatus = {
-  id: string,
-  locale: string,
-  isValid: boolean,
-  invalidReason?: string,
-}
-
-type TemplateDirStatus = {
-  isTemplateExists: boolean,
-  isMetaDataFileExists: boolean,
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  meta?: any,
-}
-
-async function getStats(tplDir: string): Promise<TemplateDirStatus> {
-  const markdownPath = path.resolve(tplDir, 'template.md');
-  const statForMarkdown = await statAsync(markdownPath);
-  const isTemplateExists = statForMarkdown.isFile();
-
-  const metaDataPath = path.resolve(tplDir, 'meta.json');
-  const statForMetaDataFile = await statAsync(metaDataPath);
-  const isMetaDataFileExists = statForMetaDataFile.isFile();
-
-  const result: TemplateDirStatus = {
-    isTemplateExists,
-    isMetaDataFileExists,
-  };
-
-  if (isMetaDataFileExists) {
-    result.meta = await import(metaDataPath);
-  }
-
-  return result;
-}
-
-export const scanTemplateStatus = async(projectDirRoot: string, templateId: string, data: GrowiTemplatePluginValidationData): Promise<TemplateStatus[]> => {
-  const status: TemplateStatus[] = [];
-
-  const tplRootDirPath = path.resolve(projectDirRoot, 'dist', templateId);
-
-  for await (const locale of data.supportingLocales) {
-    const tplDir = path.resolve(tplRootDirPath, locale);
-
-    try {
-      const {
-        isTemplateExists, isMetaDataFileExists, meta,
-      } = await getStats(tplDir);
-
-      if (!isTemplateExists) throw new Error("'template.md does not exist.");
-      if (!isMetaDataFileExists) throw new Error("'meta.md does not exist.");
-      if (meta?.title == null) throw new Error("'meta.md does not contain the title.");
-
-      status.push({ id: templateId, locale, isValid: true });
-    }
-    catch (err) {
-      status.push({
-        id: templateId,
-        locale,
-        isValid: false,
-        invalidReason: err.message,
-      });
-    }
-  }
-
-  // eslint-disable-next-line no-console
-  console.debug({ status });
-
-  return status;
-};
-
-export const scanAllTemplateStatus = async(projectDirRoot: string, data: GrowiTemplatePluginValidationData): Promise<TemplateStatus[]> => {
-  const status: TemplateStatus[] = [];
-
-  const distDirPath = path.resolve(projectDirRoot, 'dist');
-  const distDirFiles = fs.readdirSync(distDirPath);
-
-  for await (const templateId of distDirFiles) {
-    status.push(...await scanTemplateStatus(projectDirRoot, templateId, data));
-  }
-
-  return status;
-};
-
-
-export const validateTemplatePlugin = async(projectDirRoot: string): Promise<boolean> => {
-  const data = await validateTemplatePluginPackageJson(projectDirRoot);
-
-  const results = await scanAllTemplateStatus(projectDirRoot, data);
-
-  if (results.length === 0) {
-    throw new Error('This plugin does not have any templates');
-  }
-
-  // construct map
-  // key: id
-  // value: isValid properties
-  const idValidMap: { [id: string]: boolean[] } = {};
-  results.forEach((status) => {
-    const validMap = idValidMap[status.id] ?? [];
-    validMap.push(status.isValid);
-    idValidMap[status.id] = validMap;
-  });
-
-  for (const [id, validMap] of Object.entries(idValidMap)) {
-    assert(validMap.length === data.supportingLocales.length);
-
-    // warn
-    if (!validMap.every(bool => bool)) {
-      // eslint-disable-next-line no-console
-      console.warn(`[WARN] Template '${id}' has invalid status`);
-    }
-
-    // This means the template directory does not have any valid template
-    if (!validMap.some(bool => bool)) {
-      return false;
-    }
-  }
-
-  return true;
-};

+ 2 - 0
packages/pluginkit/src/v4/index.ts

@@ -0,0 +1,2 @@
+export * from './interfaces';
+export * from './utils';

+ 1 - 0
packages/pluginkit/src/v4/interfaces/index.ts

@@ -0,0 +1 @@
+export * from './template';

+ 25 - 0
packages/pluginkit/src/v4/interfaces/template.ts

@@ -0,0 +1,25 @@
+export type TemplateStatusBasis = {
+  id: string,
+  locale: string,
+  pluginId?: string,
+}
+export type TemplateStatusValid = TemplateStatusBasis & {
+  isValid: true,
+  isDefault: boolean,
+  title: string,
+  desc?: string,
+}
+export type TemplateStatusInvalid = TemplateStatusBasis & {
+  isValid: false,
+  invalidReason: string,
+}
+export type TemplateStatus = TemplateStatusValid | TemplateStatusInvalid;
+
+export function isTemplateStatusValid(status: TemplateStatus): status is TemplateStatusValid {
+  return status.isValid;
+}
+
+export type TemplateSummary = {
+  default: TemplateStatusValid,
+  [locale: string]: TemplateStatus,
+}

+ 1 - 0
packages/pluginkit/src/v4/server/index.ts

@@ -0,0 +1 @@
+export * from './utils';

+ 11 - 0
packages/pluginkit/src/v4/server/utils/common/import-package-json.spec.ts

@@ -0,0 +1,11 @@
+import path from 'path';
+
+import { importPackageJson } from './import-package-json';
+
+it('importPackageJson() returns an object', async() => {
+  // when
+  const pkg = importPackageJson(path.resolve(__dirname, '../../../../../test/fixtures/example-package/template1'));
+
+  // then
+  expect(pkg).not.toBeNull();
+});

+ 9 - 0
packages/pluginkit/src/v4/server/utils/common/import-package-json.ts

@@ -0,0 +1,9 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import type { GrowiPluginPackageData } from '../../../../model';
+
+export const importPackageJson = (projectDirRoot: string): GrowiPluginPackageData => {
+  const packageJsonUrl = path.resolve(projectDirRoot, 'package.json');
+  return JSON.parse(readFileSync(packageJsonUrl, 'utf-8'));
+};

+ 2 - 0
packages/pluginkit/src/v4/server/utils/common/index.ts

@@ -0,0 +1,2 @@
+export * from './import-package-json';
+export * from './validate-growi-plugin-directive';

+ 117 - 0
packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.spec.ts

@@ -0,0 +1,117 @@
+import { GrowiPluginType } from '@growi/core/dist/consts';
+
+import examplePkg from '../../../../../test/fixtures/example-package/template1/package.json';
+
+
+import { validateGrowiDirective } from './validate-growi-plugin-directive';
+
+const mocks = vi.hoisted(() => {
+  return {
+    importPackageJsonMock: vi.fn(),
+  };
+});
+
+vi.mock('./import-package-json', () => {
+  return { importPackageJson: mocks.importPackageJsonMock };
+});
+
+describe('validateGrowiDirective()', () => {
+
+  it('returns a data object', async() => {
+    // setup
+    mocks.importPackageJsonMock.mockReturnValue(examplePkg);
+
+    // when
+    const data = validateGrowiDirective('package.json');
+
+    // then
+    expect(data).not.toBeNull();
+  });
+
+  it("with the 'expectedPluginType' argument returns a data object", async() => {
+    // setup
+    mocks.importPackageJsonMock.mockReturnValue(examplePkg);
+
+    // when
+    const data = validateGrowiDirective('package.json', GrowiPluginType.Template);
+
+    // then
+    expect(data).not.toBeNull();
+  });
+
+  describe('should throw an GrowiPluginValidationError', () => {
+
+    it("when the pkg does not have 'growiPlugin' directive", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({});
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The package.json does not have 'growiPlugin' directive.");
+    });
+
+    it("when the 'schemaVersion' is NaN", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 'foo',
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
+    });
+
+    it("when the 'schemaVersion' is less than 4", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 3,
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
+    });
+
+    it("when the 'types' directive does not exist", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 4,
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive does not have 'types' directive.");
+    });
+
+    it("when the 'types' directive does not have expected plugin type", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 4,
+          types: [GrowiPluginType.Template],
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json', GrowiPluginType.Script) };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive does not have expected plugin type in 'types' directive.");
+    });
+  });
+
+});

+ 7 - 6
packages/pluginkit/src/server/utils/v4/package-json/validate.ts → packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.ts

@@ -1,16 +1,17 @@
-import { GrowiPluginType } from '~/consts';
-import { type GrowiPluginValidationData, GrowiPluginValidationError } from '~/model';
+import { GrowiPluginType } from '@growi/core/dist/consts';
 
-import { importPackageJson } from './import';
+import { type GrowiPluginValidationData, GrowiPluginValidationError } from '../../../../model';
 
+import { importPackageJson } from './import-package-json';
 
-export const validatePackageJson = async(projectDirRoot: string, expectedPluginType?: GrowiPluginType): Promise<GrowiPluginValidationData> => {
-  const pkg = await importPackageJson(projectDirRoot);
 
-  const data: GrowiPluginValidationData = { projectDirRoot };
+export const validateGrowiDirective = (projectDirRoot: string, expectedPluginType?: GrowiPluginType): GrowiPluginValidationData => {
+  const pkg = importPackageJson(projectDirRoot);
 
   const { growiPlugin } = pkg;
 
+  const data: GrowiPluginValidationData = { projectDirRoot, schemaVersion: NaN, growiPlugin };
+
   if (growiPlugin == null) {
     throw new GrowiPluginValidationError("The package.json does not have 'growiPlugin' directive.", data);
   }

+ 2 - 0
packages/pluginkit/src/v4/server/utils/index.ts

@@ -0,0 +1,2 @@
+export * from './common';
+export * from './template';

+ 16 - 0
packages/pluginkit/src/v4/server/utils/template/get-markdown.ts

@@ -0,0 +1,16 @@
+import fs from 'fs';
+import path from 'path';
+
+import { getStatus } from './get-status';
+
+
+export const getMarkdown = async(projectDirRoot: string, templateId: string, locale: string): Promise<string> => {
+  const tplDir = path.resolve(projectDirRoot, 'dist', templateId, locale);
+
+  const { isTemplateExists } = await getStatus(tplDir);
+
+  if (!isTemplateExists) throw new Error("'template.md does not exist.");
+
+  const markdownPath = path.resolve(tplDir, 'template.md');
+  return fs.readFileSync(markdownPath, { encoding: 'utf-8' });
+};

Некоторые файлы не были показаны из-за большого количества измененных файлов