Răsfoiți Sursa

Merge branch 'master' into imprv/115672-115674-presentation-preview

reiji-h 2 ani în urmă
părinte
comite
147f54b87c
71 a modificat fișierele cu 1344 adăugiri și 1097 ștergeri
  1. 0 1
      .devcontainer/devcontainer.json
  2. 1 1
      .github/release-drafter.yml
  3. 0 6
      apps/app/config/migrate-mongo-config.spec.ts
  4. 8 0
      apps/app/package.json
  5. 1 0
      apps/app/public/static/locales/en_US/commons.json
  6. 1 0
      apps/app/public/static/locales/en_US/translation.json
  7. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  8. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  9. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  10. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  11. 57 57
      apps/app/src/client/services/renderer/renderer.tsx
  12. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  13. 11 5
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  14. 19 13
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  15. 10 4
      apps/app/src/components/PageEditor.tsx
  16. 2 2
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  17. 130 0
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  18. 1 131
      apps/app/src/components/TemplateModal/index.tsx
  19. 0 2
      apps/app/src/features/activate-plugin/index.ts
  20. 0 1
      apps/app/src/features/activate-plugin/utils/index.ts
  21. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  22. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  23. 2 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  24. 1 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  25. 1 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts
  26. 1 1
      apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx
  27. 0 0
      apps/app/src/features/growi-plugin/components/index.ts
  28. 6 6
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  29. 1 0
      apps/app/src/features/growi-plugin/interfaces/index.ts
  30. 16 15
      apps/app/src/features/growi-plugin/models/growi-plugin.ts
  31. 1 0
      apps/app/src/features/growi-plugin/models/index.ts
  32. 12 33
      apps/app/src/features/growi-plugin/routes/growi-plugins.ts
  33. 22 26
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  34. 1 0
      apps/app/src/features/growi-plugin/services/index.ts
  35. 3 2
      apps/app/src/features/growi-plugin/stores/growi-plugin.tsx
  36. 0 0
      apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts
  37. 0 0
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  38. 0 0
      apps/app/src/features/mermaid/components/index.ts
  39. 0 0
      apps/app/src/features/mermaid/index.ts
  40. 0 0
      apps/app/src/features/mermaid/services/index.ts
  41. 0 0
      apps/app/src/features/mermaid/services/mermaid.ts
  42. 1 1
      apps/app/src/pages/[[...path]].page.tsx
  43. 3 3
      apps/app/src/pages/_document.page.tsx
  44. 1 1
      apps/app/src/pages/admin/plugins.page.tsx
  45. 5 19
      apps/app/src/server/crowi/index.js
  46. 0 2
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  47. 6 8
      apps/app/src/server/routes/apiv3/customize-setting.js
  48. 1 1
      apps/app/src/server/routes/apiv3/index.js
  49. 2 5
      apps/app/src/server/service/customize.ts
  50. 1 1
      apps/app/src/stores/renderer.tsx
  51. 3 3
      apps/app/src/stores/template.tsx
  52. 1 8
      package.json
  53. 0 2
      packages/core/src/utils/page-path-utils/index.spec.ts
  54. 4 1
      packages/remark-lsx/package.json
  55. 2 0
      packages/remark-lsx/src/@types/declaration.d.ts
  56. 6 4
      packages/remark-lsx/src/components/Lsx.tsx
  57. 5 5
      packages/remark-lsx/src/server/index.ts
  58. 41 0
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts
  59. 93 0
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts
  60. 38 0
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts
  61. 26 0
      packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts
  62. 24 0
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  63. 15 0
      packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts
  64. 134 0
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  65. 130 0
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  66. 0 264
      packages/remark-lsx/src/server/routes/lsx.ts
  67. 16 7
      packages/remark-lsx/src/stores/lsx.tsx
  68. 6 4
      packages/remark-lsx/tsconfig.json
  69. 3 1
      packages/remark-lsx/vite.server.config.ts
  70. 13 0
      packages/remark-lsx/vitest.config.ts
  71. 448 445
      yarn.lock

+ 0 - 1
.devcontainer/devcontainer.json

@@ -19,7 +19,6 @@
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
-    "firsttris.vscode-jest-runner",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",

+ 1 - 1
.github/release-drafter.yml

@@ -18,7 +18,7 @@ categories:
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
-  - label: 'feature'
+  - label: 'type/feature'
     branch:
       - '/^feat\/.+/'
   - label: 'type/improvement'

+ 0 - 6
apps/app/config/migrate-mongo-config.spec.ts

@@ -1,9 +1,3 @@
-import {
-  vi,
-  beforeEach,
-  describe, test, expect,
-} from 'vitest';
-
 import mockRequire from 'mock-require';
 
 const { reRequire } = mockRequire;

+ 8 - 0
apps/app/package.json

@@ -75,6 +75,7 @@
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@types/jest": "^29.5.2",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -213,7 +214,10 @@
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
+    "@swc-node/jest": "^1.6.2",
+    "@swc/jest": "^0.2.24",
     "@types/express": "^4.17.11",
+    "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
@@ -225,10 +229,14 @@
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.11.0",
+    "jest": "^29.5.0",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
     "jquery-slimscroll": "^1.3.8",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",

+ 1 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -2,6 +2,7 @@
   "Show": "Show",
   "Hide": "Hide",
   "Add": "Add",
+  "Insert": "Insert",
   "Reset": "Reset",
   "Sign out": "Logout",
   "New": "New",

+ 1 - 0
apps/app/public/static/locales/en_US/translation.json

@@ -454,6 +454,7 @@
   },
   "template": {
     "modal_label": {
+      "Select template": "Select template",
       "Create/Edit Template Page": "Create/Edit template page",
       "Create template under": "Create template page under this page"
     },

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

@@ -2,6 +2,7 @@
   "Show": "公開",
   "Hide": "非公開",
   "Add": "追加",
+  "Insert": "挿入",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",

+ 1 - 0
apps/app/public/static/locales/ja_JP/translation.json

@@ -487,6 +487,7 @@
   },
   "template": {
     "modal_label": {
+      "Select template": "テンプレートの選択",
       "Create/Edit Template Page": "テンプレートページの作成/編集",
       "Create template under": "配下にテンプレートページを作成"
     },

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

@@ -2,6 +2,7 @@
 	"Show": "显示",
 	"Hide": "隐藏",
   "Add": "添加",
+  "Insert": "插入",
   "Reset": "重启",
 	"Sign out": "退出",
   "New": "新建",

+ 1 - 0
apps/app/public/static/locales/zh_CN/translation.json

@@ -441,6 +441,7 @@
   },
 	"template": {
 		"modal_label": {
+      "Select template": "选择模板",
 			"Create/Edit Template Page": "创建/编辑模板页",
 			"Create template under": "在下面创建模板页"
 		},

+ 57 - 57
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,10 +1,10 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
-import * as refsGrowiPlugin from '@growi/remark-attachment-refs/dist/client/index.mjs';
-import * as drawioPlugin from '@growi/remark-drawio';
+import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client/index.mjs';
+import * as drawio from '@growi/remark-drawio';
 // eslint-disable-next-line import/extensions
-import * as lsxGrowiPlugin from '@growi/remark-lsx/dist/client/index.mjs';
+import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client/index.mjs';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
@@ -18,7 +18,7 @@ import type { Pluggable } from 'unified';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
-import * as mermaidPlugin from '~/features/mermaid-plugin';
+import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -58,11 +58,11 @@ export const generateViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -75,18 +75,18 @@ export const generateViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
     slug,
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -100,15 +100,15 @@ export const generateViewOptions = (
     components.h4 = Header;
     components.h5 = Header;
     components.h6 = Header;
-    components.lsx = lsxGrowiPlugin.Lsx;
-    components.ref = refsGrowiPlugin.Ref;
-    components.refs = refsGrowiPlugin.Refs;
-    components.refimg = refsGrowiPlugin.RefImg;
-    components.refsimg = refsGrowiPlugin.RefsImg;
-    components.gallery = refsGrowiPlugin.Gallery;
+    components.lsx = lsxGrowiDirective.Lsx;
+    components.ref = refsGrowiDirective.Ref;
+    components.refs = refsGrowiDirective.Refs;
+    components.refimg = refsGrowiDirective.RefImg;
+    components.refsimg = refsGrowiDirective.RefsImg;
+    components.gallery = refsGrowiDirective.Gallery;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -164,11 +164,11 @@ export const generateSimpleViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -185,17 +185,17 @@ export const generateSimpleViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     katex,
@@ -203,14 +203,14 @@ export const generateSimpleViewOptions = (
 
   // add components
   if (components != null) {
-    components.lsx = lsxGrowiPlugin.LsxImmutable;
-    components.ref = refsGrowiPlugin.RefImmutable;
-    components.refs = refsGrowiPlugin.RefsImmutable;
-    components.refimg = refsGrowiPlugin.RefImgImmutable;
-    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
-    components.gallery = refsGrowiPlugin.GalleryImmutable;
-    components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -241,11 +241,11 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -258,18 +258,18 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      drawioPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     katex,
@@ -277,14 +277,14 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
   // add components
   if (components != null) {
-    components.lsx = lsxGrowiPlugin.LsxImmutable;
-    components.ref = refsGrowiPlugin.RefImmutable;
-    components.refs = refsGrowiPlugin.RefsImmutable;
-    components.refimg = refsGrowiPlugin.RefImgImmutable;
-    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
-    components.gallery = refsGrowiPlugin.GalleryImmutable;
-    components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 4 - 4
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -30,7 +30,7 @@ type BookmarkFolderItemProps = {
   level: number
   root: string
   isUserHomePage?: boolean
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -39,7 +39,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
     isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
-    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
   } = props;
 
   const {
@@ -155,7 +155,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             level={level + 1}
             root={root}
             isUserHomePage={isUserHomePage}
-            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
         </div>
@@ -174,7 +174,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           level={level + 1}
           parentFolder={bookmarkFolder}
           canMoveToRoot={true}
-          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
         />
       );

+ 11 - 5
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -8,7 +8,7 @@ import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useSWRxUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useIsReadOnlyUser } from '~/stores/context';
+import { useIsReadOnlyUser, useCurrentUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 
@@ -35,20 +35,26 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
 
+  // In order to update the bookmark information in the sidebar when bookmarking or unbookmarking a page on someone else's user homepage
+  const { data: currentUser } = useCurrentUser();
+  const shouldMutateCurrentUserbookmarks = currentUser?._id !== userId;
+
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
   const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId);
+  const { mutate: mutateCurrentUserBookmarks } = useSWRxUserBookmarks(shouldMutateCurrentUserbookmarks ? currentUser?._id : undefined);
   const { open: openDeleteModal } = usePageDeleteModal();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
+    mutateCurrentUserBookmarks();
     mutateBookmarkInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
-  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
 
@@ -107,7 +113,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               root={bookmarkFolder._id}
               isUserHomePage={isUserHomePage}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           );
@@ -122,7 +128,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               parentFolder={null}
               canMoveToRoot={false}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           </div>

+ 19 - 13
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -6,7 +6,7 @@ import { DevidedPagePath, pathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-import { unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError } from '~/client/util/toastr';
@@ -28,7 +28,7 @@ type Props = {
   level: number,
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -39,14 +39,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
-  const { data: fetchedPageInfo } = useSWRxPageInfo(bookmarkedPage._id);
-
+  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage._id);
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -65,10 +64,16 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
 
-  const bookmarkMenuItemClickHandler = useCallback(async() => {
-    await unbookmark(bookmarkedPage._id);
+  const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
+    if (shouldBookmark) {
+      await bookmark(pageId);
+    }
+    else {
+      await unbookmark(pageId);
+    }
     bookmarkFolderTreeMutation();
-  }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
+    mutatePageInfo();
+  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
@@ -86,12 +91,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(false);
       await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
       bookmarkFolderTreeMutation();
+      mutatePageInfo();
     }
     catch (err) {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, bookmarkFolderTreeMutation]);
+  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -107,8 +113,8 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       meta: pageInfo,
     };
 
-    onClickDeleteBookmarkHandler(pageToDelete);
-  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
+    onClickDeleteMenuItemHandler(pageToDelete);
+  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
   return (
     <DragAndDropWrapper
@@ -137,12 +143,12 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             pageId={bookmarkedPage._id}
             isEnableActions
             isReadOnlyUser={isReadOnlyUser}
-            pageInfo={fetchedPageInfo}
+            pageInfo={pageInfo}
             forceHideMenuItems={[MenuItemType.DUPLICATE]}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
+            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
               ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
               : undefined}
           >

+ 10 - 4
apps/app/src/components/PageEditor.tsx

@@ -132,6 +132,8 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
+  const [isPageCreatedWithAttachmentUpload, setIsPageCreatedWithAttachmentUpload] = useState(false);
+
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -322,10 +324,11 @@ const PageEditor = React.memo((): JSX.Element => {
       editorRef.current.insertText(insertText);
 
       // when if created newly
+      // Not using 'mutateGrant' to inherit the grant of the parent page
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
+        setIsPageCreatedWithAttachmentUpload(true);
         globalEmitter.emit('resetInitializedHackMdStatus');
-        mutateGrant(res.page.grant);
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
@@ -338,7 +341,7 @@ const PageEditor = React.memo((): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateGrant, mutateIsLatestRevision, pageId]);
+  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -519,11 +522,14 @@ const PageEditor = React.memo((): JSX.Element => {
 
   // when transitioning to a different page, if the initialValue is the same,
   // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  // Also, if an attachment is uploaded and a new page is created,
+  // "useCurrentPagePath" changes, but no page transition is made, so nothing is done.
   useEffect(() => {
-    if (currentPagePath != null) {
+    if (currentPagePath != null && !isPageCreatedWithAttachmentUpload) {
       editorRef.current?.setValue(initialValue);
     }
-  }, [currentPagePath, initialValue]);
+    setIsPageCreatedWithAttachmentUpload(false);
+  }, [currentPagePath, initialValue, isPageCreatedWithAttachmentUpload]);
 
   if (!isEditable) {
     return <></>;

+ 2 - 2
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -848,8 +848,8 @@ class CodeMirrorEditor extends AbstractEditor {
   // }
 
   showTemplateModal() {
-    const onSubmit = templateText => this.setValue(templateText);
-    this.props.onClickTemplateBtn(onSubmit);
+    const onSubmit = templateText => this.insertText(templateText);
+    this.props.onClickTemplateBtn({ onSubmit });
   }
 
   showLinkEditModal() {

+ 130 - 0
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -0,0 +1,130 @@
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
+
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import { useTranslation } from 'next-i18next';
+import {
+  Modal,
+  ModalHeader,
+  ModalBody,
+  ModalFooter,
+} from 'reactstrap';
+
+import { useTemplateModal } from '~/stores/modal';
+import { usePreviewOptions } from '~/stores/renderer';
+import { useTemplates } from '~/stores/template';
+import loggerFactory from '~/utils/logger';
+
+import Preview from '../PageEditor/Preview';
+
+import { useFormatter } from './use-formatter';
+
+const logger = loggerFactory('growi:components:TemplateModal');
+
+
+type TemplateRadioButtonProps = {
+  template: ITemplate,
+  onChange: (selectedTemplate: ITemplate) => void,
+  isSelected?: boolean,
+}
+
+const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
+  const radioButtonId = `rb-${template.id}`;
+
+  return (
+    <div key={template.id} className="custom-control custom-radio mb-2">
+      <input
+        id={radioButtonId}
+        type="radio"
+        className="custom-control-input"
+        checked={isSelected}
+        onChange={() => onChange(template)}
+      />
+      <label className="custom-control-label" htmlFor={radioButtonId}>
+        {template.name}
+      </label>
+    </div>
+  );
+};
+
+export const TemplateModal = (): JSX.Element => {
+  const { t } = useTranslation(['translation', 'commons']);
+
+
+  const { data: templateModalStatus, close } = useTemplateModal();
+
+  const { data: rendererOptions } = usePreviewOptions();
+  const { data: templates } = useTemplates();
+
+  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
+
+  const { format } = useFormatter();
+
+  const submitHandler = useCallback((template?: ITemplate) => {
+    if (templateModalStatus == null || selectedTemplate == null) {
+      return;
+    }
+
+    if (templateModalStatus.onSubmit == null || template == null) {
+      close();
+      return;
+    }
+
+    templateModalStatus.onSubmit(format(selectedTemplate));
+    close();
+  }, [close, format, selectedTemplate, templateModalStatus]);
+
+  useEffect(() => {
+    if (!templateModalStatus?.isOpened) {
+      setSelectedTemplate(undefined);
+    }
+  }, [templateModalStatus?.isOpened]);
+
+  if (templates == null || templateModalStatus == 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}
+              />
+            )) }
+          </div>
+        </div>
+
+        <hr />
+
+        <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)}/>
+            ) }
+          </div>
+        </div>
+
+      </ModalBody>
+      <ModalFooter>
+        <button type="button" className="btn btn-sm 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}>
+          {t('commons:Insert')}
+        </button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 1 - 131
apps/app/src/components/TemplateModal/index.tsx

@@ -1,131 +1 @@
-import React, {
-  useCallback, useEffect, useState,
-} from 'react';
-
-import type { ITemplate } from '@growi/core/dist/interfaces/template';
-import { useTranslation } from 'next-i18next';
-import {
-  Modal,
-  ModalHeader,
-  ModalBody,
-  ModalFooter,
-} from 'reactstrap';
-
-import { useTemplateModal } from '~/stores/modal';
-import { usePreviewOptions } from '~/stores/renderer';
-import { useTemplates } from '~/stores/template';
-import loggerFactory from '~/utils/logger';
-
-import Preview from '../PageEditor/Preview';
-
-import { useFormatter } from './use-formatter';
-
-const logger = loggerFactory('growi:components:TemplateModal');
-
-
-type TemplateRadioButtonProps = {
-  template: ITemplate,
-  onChange: (selectedTemplate: ITemplate) => void,
-  isSelected?: boolean,
-}
-
-const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioButtonProps): JSX.Element => {
-  const radioButtonId = `rb-${template.id}`;
-
-  return (
-    <div key={template.id} className="custom-control custom-radio mb-2">
-      <input
-        id={radioButtonId}
-        type="radio"
-        className="custom-control-input"
-        checked={isSelected}
-        onChange={() => onChange(template)}
-      />
-      <label className="custom-control-label" htmlFor={radioButtonId}>
-        {template.name}
-      </label>
-    </div>
-  );
-};
-
-export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation();
-
-
-  const { data: templateModalStatus, close } = useTemplateModal();
-
-  const { data: rendererOptions } = usePreviewOptions();
-  const { data: templates } = useTemplates();
-
-  const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
-
-  const { format } = useFormatter();
-
-  const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null || selectedTemplate == null) {
-      return;
-    }
-
-    if (templateModalStatus.onSubmit == null || template == null) {
-      close();
-      return;
-    }
-
-    templateModalStatus.onSubmit(format(selectedTemplate));
-    close();
-  }, [close, format, selectedTemplate, templateModalStatus]);
-
-  useEffect(() => {
-    if (!templateModalStatus?.isOpened) {
-      setSelectedTemplate(undefined);
-    }
-  }, [templateModalStatus?.isOpened]);
-
-  if (templates == null || templateModalStatus == 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">
-        Template
-      </ModalHeader>
-
-      <ModalBody className="container">
-        <div className="row">
-          <div className="col-12">
-            { templates.map(template => (
-              <TemplateRadioButton
-                key={template.id}
-                template={template}
-                onChange={t => setSelectedTemplate(t)}
-                isSelected={template.id === selectedTemplate?.id}
-              />
-            )) }
-          </div>
-        </div>
-
-        { rendererOptions != null && selectedTemplate != null && (
-          <>
-            <hr />
-            <h3>Preview</h3>
-            <div className='card'>
-              <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
-                <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplate)}/>
-              </div>
-            </div>
-          </>
-        ) }
-
-      </ModalBody>
-      <ModalFooter>
-        <button type="button" className="btn btn-sm 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}>
-          {t('Update')}
-        </button>
-      </ModalFooter>
-    </Modal>
-  );
-};
+export * from './TemplateModal';

+ 0 - 2
apps/app/src/features/activate-plugin/index.ts

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

+ 0 - 1
apps/app/src/features/activate-plugin/utils/index.ts

@@ -1 +0,0 @@
-export { getGrowiFacade } from './growi-facade-utils';

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


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


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

@@ -4,7 +4,8 @@ import { useTranslation } from 'next-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxPlugins } from '~/stores/plugin';
+
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxPlugins();

+ 1 - 1
apps/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx → apps/app/src/features/growi-plugin/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/plugin';
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';

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

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

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

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

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


+ 6 - 6
apps/app/src/interfaces/plugin.ts → apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -8,29 +8,29 @@ export const GrowiPluginResourceType = {
 } as const;
 export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
 
-export type GrowiPluginOrigin = {
+export type IGrowiPluginOrigin = {
   url: string,
   ghBranch?: string,
   ghTag?: string,
 }
 
-export type GrowiPlugin<M extends GrowiPluginMeta = GrowiPluginMeta> = {
+export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
   isEnabled: boolean,
   installedPath: string,
   organizationName: string,
-  origin: GrowiPluginOrigin,
+  origin: IGrowiPluginOrigin,
   meta: M,
 }
 
-export type GrowiPluginMeta = {
+export type IGrowiPluginMeta = {
   name: string,
   types: GrowiPluginResourceType[],
   desc?: string,
   author?: string,
 }
 
-export type GrowiThemePluginMeta = GrowiPluginMeta & {
+export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
   themes: GrowiThemeMetadata[]
 }
 
-export type GrowiPluginHasId = GrowiPlugin & HasObjectId;
+export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

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

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

+ 16 - 15
apps/app/src/server/models/growi-plugin.ts → apps/app/src/features/growi-plugin/models/growi-plugin.ts

@@ -1,19 +1,20 @@
 import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import {
-  Schema, Model, Document, Types,
+  Schema, type Model, type Document, type Types,
 } from 'mongoose';
 
-import {
-  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
-} from '~/interfaces/plugin';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { GrowiPluginResourceType } from '../interfaces';
+import type {
+  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginOrigin, IGrowiThemePluginMeta,
+} from '../interfaces';
 
-export interface GrowiPluginDocument extends GrowiPlugin, Document {
+export interface IGrowiPluginDocument extends IGrowiPlugin, Document {
 }
-export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
-  findEnabledPlugins(): Promise<GrowiPlugin[]>
-  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
+export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
+  findEnabledPlugins(): Promise<IGrowiPlugin[]>
+  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]>
   activatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
@@ -32,7 +33,7 @@ const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
   accent: { type: String, required: true },
 });
 
-const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
+const growiPluginMetaSchema = new Schema<IGrowiPluginMeta|IGrowiThemePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
@@ -44,13 +45,13 @@ const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
   themes: [growiThemeMetadataSchema],
 });
 
-const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
+const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
   url: { type: String },
   ghBranch: { type: String },
   ghTag: { type: String },
 });
 
-const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
+const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
   isEnabled: { type: Boolean },
   installedPath: { type: String },
   organizationName: { type: String },
@@ -58,11 +59,11 @@ const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
   meta: growiPluginMetaSchema,
 });
 
-growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
   return this.find({ isEnabled: true });
 };
 
-growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]> {
   return this.find({
     isEnabled: true,
     'meta.types': { $in: types },
@@ -89,4 +90,4 @@ growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId):
   return pluginName;
 };
 
-export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);
+export const GrowiPlugin = getOrCreateModel<IGrowiPluginDocument, IGrowiPluginModel>('GrowiPlugin', growiPluginSchema);

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

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

+ 12 - 33
apps/app/src/server/routes/apiv3/plugins.ts → apps/app/src/features/growi-plugin/routes/growi-plugins.ts

@@ -2,10 +2,12 @@ import express, { Request, Router } from 'express';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import Crowi from '../../crowi';
-import type { GrowiPluginModel } from '../../models/growi-plugin';
+import Crowi from '~/server/crowi';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+import { GrowiPlugin } from '../models';
+import { growiPluginService } from '../services';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
 
 const ObjectID = mongoose.Types.ObjectId;
 
@@ -22,20 +24,14 @@ const validator = {
 };
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const router = express.Router();
-  const { pluginService } = crowi;
 
   router.get('/', loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const data = await GrowiPluginModel.find({});
+      const data = await GrowiPlugin.find({});
       return res.apiv3({ plugins: data });
     }
     catch (err) {
@@ -44,14 +40,10 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.post('/', loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { pluginInstallerForm: formValue } = req.body;
 
     try {
-      const pluginName = await pluginService.install(formValue);
+      const pluginName = await growiPluginService.install(formValue);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -60,15 +52,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.put('/:id/activate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const pluginName = await GrowiPluginModel.activatePlugin(pluginId);
+      const pluginName = await GrowiPlugin.activatePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -77,16 +65,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const pluginName = await GrowiPluginModel.deactivatePlugin(pluginId);
+      const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -95,15 +78,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const pluginName = await pluginService.deletePlugin(pluginId);
+      const pluginName = await growiPluginService.deletePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {

+ 22 - 26
apps/app/src/server/service/plugin.ts → apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -8,13 +8,14 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
-import {
-  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta, GrowiPluginMeta,
-} from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import type { GrowiPluginModel } from '../models/growi-plugin';
+import { GrowiPluginResourceType } from '../interfaces';
+import type {
+  IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
+} from '../interfaces';
+import { GrowiPlugin } from '../models';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
@@ -27,7 +28,7 @@ const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
 
 export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
+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);
@@ -35,19 +36,19 @@ function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
 
 
 type FindThemePluginResult = {
-  growiPlugin: GrowiPlugin,
+  growiPlugin: IGrowiPlugin,
   themeMetadata: GrowiThemeMetadata,
   themeHref: string,
 }
 
-export interface IPluginService {
-  install(origin: GrowiPluginOrigin): Promise<string>
+export interface IGrowiPluginService {
+  install(origin: IGrowiPluginOrigin): Promise<string>
   findThemePlugin(theme: string): Promise<FindThemePluginResult | null>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
   downloadNotExistPluginRepositories(): Promise<void>
 }
 
-export class PluginService implements IPluginService {
+export class GrowiPluginService implements IGrowiPluginService {
 
   /*
   * Downloading a non-existent repository to the file system
@@ -55,7 +56,6 @@ export class PluginService implements IPluginService {
   async downloadNotExistPluginRepositories(): Promise<void> {
     try {
       // find all growi plugin documents
-      const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
       const growiPlugins = await GrowiPlugin.find({});
 
       // if not exists repository in file system, download latest plugin repository
@@ -113,7 +113,7 @@ export class PluginService implements IPluginService {
   /*
   * Install a plugin from URL and save it in the DB and file system.
   */
-  async install(origin: GrowiPluginOrigin): Promise<string> {
+  async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new URL(origin.url);
     const ghPathname = ghUrl.pathname;
     // TODO: Branch names can be specified.
@@ -137,7 +137,7 @@ export class PluginService implements IPluginService {
     const organizationPath = path.join(pluginStoringPath, ghOrganizationName);
 
 
-    let plugins: GrowiPlugin<GrowiPluginMeta>[];
+    let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
@@ -146,7 +146,7 @@ export class PluginService implements IPluginService {
       fs.renameSync(unzippedReposPath, temporaryReposPath);
 
       // detect plugins
-      plugins = await PluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
@@ -184,7 +184,6 @@ export class PluginService implements IPluginService {
   }
 
   private async deleteOldPluginDocument(path: string): Promise<void> {
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     await GrowiPlugin.deleteMany({ installedPath: path });
   }
 
@@ -230,13 +229,12 @@ export class PluginService implements IPluginService {
     }
   }
 
-  private async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
-    const GrowiPlugin = mongoose.model('GrowiPlugin');
+  private async savePluginMetaData(plugins: IGrowiPlugin[]): Promise<void> {
     await GrowiPlugin.insertMany(plugins);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
-  private static async detectPlugins(origin: GrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+  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'));
 
@@ -279,7 +277,7 @@ export class PluginService implements IPluginService {
 
     // add theme metadata
     if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
-      (plugin as GrowiPlugin<GrowiThemePluginMeta>).meta = {
+      (plugin as IGrowiPlugin<IGrowiThemePluginMeta>).meta = {
         ...plugin.meta,
         themes: growiPlugin.themes,
       };
@@ -290,7 +288,7 @@ export class PluginService implements IPluginService {
     return [plugin];
   }
 
-  async listPlugins(): Promise<GrowiPlugin[]> {
+  async listPlugins(): Promise<IGrowiPlugin[]> {
     return [];
   }
 
@@ -302,7 +300,6 @@ export class PluginService implements IPluginService {
       return fs.promises.rm(path, { recursive: true });
     };
 
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     const growiPlugins = await GrowiPlugin.findById(pluginId);
 
     if (growiPlugins == null) {
@@ -330,14 +327,12 @@ export class PluginService implements IPluginService {
   }
 
   async findThemePlugin(theme: string): Promise<FindThemePluginResult | null> {
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
-    let matchedPlugin: GrowiPlugin | undefined;
+    let matchedPlugin: IGrowiPlugin | undefined;
     let matchedThemeMetadata: GrowiThemeMetadata | undefined;
 
     try {
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as GrowiPlugin<GrowiThemePluginMeta>[];
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as IGrowiPlugin<IGrowiThemePluginMeta>[];
 
       growiPlugins
         .forEach(async(growiPlugin) => {
@@ -373,8 +368,6 @@ export class PluginService implements IPluginService {
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
 
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
     const entries: GrowiPluginResourceEntries = [];
 
     try {
@@ -409,3 +402,6 @@ export class PluginService implements IPluginService {
   }
 
 }
+
+
+export const growiPluginService = new GrowiPluginService();

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

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

+ 3 - 2
apps/app/src/stores/plugin.tsx → apps/app/src/features/growi-plugin/stores/growi-plugin.tsx

@@ -1,10 +1,11 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { GrowiPluginHasId } from '~/interfaces/plugin';
+
+import type { IGrowiPluginHasId } from '../interfaces';
 
 type Plugins = {
-  plugins: GrowiPluginHasId[]
+  plugins: IGrowiPluginHasId[]
 }
 
 const pluginsFetcher = () => {

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


+ 0 - 0
apps/app/src/features/mermaid-plugin/components/MermaidViewer.tsx → apps/app/src/features/mermaid/components/MermaidViewer.tsx


+ 0 - 0
apps/app/src/features/mermaid-plugin/components/index.ts → apps/app/src/features/mermaid/components/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/index.ts → apps/app/src/features/mermaid/index.ts


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


+ 0 - 0
apps/app/src/features/mermaid-plugin/services/mermaid.ts → apps/app/src/features/mermaid/services/mermaid.ts


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

@@ -67,7 +67,7 @@ declare global {
 }
 
 
-const GrowiPluginsActivator = dynamic(() => import('~/features/activate-plugin').then(mod => mod.GrowiPluginsActivator), { ssr: false });
+const GrowiPluginsActivator = dynamic(() => import('~/features/growi-plugin/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')

+ 3 - 3
apps/app/src/pages/_document.page.tsx

@@ -6,8 +6,8 @@ import Document, {
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { growiPluginService, type GrowiPluginResourceEntries } from '~/features/growi-plugin/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { IPluginService, GrowiPluginResourceEntries } from '~/server/service/plugin';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
@@ -49,7 +49,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { customizeService, pluginService } = crowi;
+    const { customizeService } = crowi;
 
     const { themeHref } = customizeService;
     const customScript: string | null = customizeService.getCustomScript();
@@ -57,7 +57,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const customNoscript: string | null = customizeService.getCustomNoscript();
 
     // retrieve plugin manifests
-    const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
+    const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
     return {
       ...initialProps,

+ 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('~/components/Admin/PluginsExtension/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
+  () => import('~/features/growi-plugin/components/Admin/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
   { ssr: false },
 );
 

+ 5 - 19
apps/app/src/server/crowi/index.js

@@ -19,7 +19,6 @@ import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import Activity from '../models/activity';
-import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
@@ -32,8 +31,6 @@ import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
-// eslint-disable-next-line import/no-cycle
-import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
@@ -134,7 +131,6 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupSearcher(),
-    this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setupG2GTransferService(),
@@ -146,7 +142,7 @@ Crowi.prototype.init = async function() {
     this.setupUserGroupService(),
     this.setupExport(),
     this.setupImport(),
-    this.setupPluginService(),
+    this.setupGrowiPluginService(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
     this.setupActivityService(),
@@ -308,7 +304,6 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
-  allModels.growiPlugin = GrowiPlugin;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
@@ -394,13 +389,6 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
 };
 
-/**
- * setup PluginService
- */
-Crowi.prototype.setupPluginer = async function() {
-  this.pluginService = new PluginService(this);
-};
-
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   this.mailService = new MailService(this);
@@ -717,14 +705,12 @@ Crowi.prototype.setupImport = async function() {
   }
 };
 
-Crowi.prototype.setupPluginService = async function() {
-  const { PluginService } = require('../service/plugin');
-  if (this.pluginService == null) {
-    this.pluginService = new PluginService(this);
-  }
+Crowi.prototype.setupGrowiPluginService = async function() {
+  const { growiPluginService } = require('~/features/growi-plugin/services');
+
   // download plugin repositories, if document exists but there is no repository
   // TODO: Cannot download unless connected to the Internet at setup.
-  await this.pluginService.downloadNotExistPluginRepositories();
+  await growiPluginService.downloadNotExistPluginRepositories();
 };
 
 Crowi.prototype.setupPageService = async function() {

+ 0 - 2
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -1,5 +1,3 @@
-import { test } from 'vitest';
-
 import { RuleTester } from 'eslint';
 
 import noPopulate from '../no-populate';

+ 6 - 8
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -1,10 +1,14 @@
 /* eslint-disable no-unused-vars */
 
 import { 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 { SupportedAction } from '~/interfaces/activity';
-import { GrowiPluginResourceType } from '~/interfaces/plugin';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -14,13 +18,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
-const express = require('express');
-
 const router = express.Router();
 
-const { body, query } = require('express-validator');
-const multer = require('multer');
-
 
 /**
  * @swagger
@@ -276,8 +275,7 @@ module.exports = (crowi) => {
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
 
       // retrieve plugin manifests
-      const GrowiPluginModel = mongoose.model('GrowiPlugin');
-      const themePlugins = await GrowiPluginModel.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
+      const themePlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
 
       const pluginThemesMetadatas = themePlugins
         .map(themePlugin => themePlugin.meta.themes)

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

@@ -108,7 +108,7 @@ module.exports = (crowi, app) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
-  router.use('/plugins', require('./plugins')(crowi));
+  router.use('/plugins', require('~/features/growi-plugin/routes/growi-plugins')(crowi));
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 

+ 2 - 5
apps/app/src/server/service/customize.ts

@@ -3,12 +3,12 @@ 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 loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
 
 import type { ConfigManager } from './config-manager';
-import type { IPluginService } from './plugin';
 import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
@@ -28,8 +28,6 @@ class CustomizeService implements S2sMessageHandlable {
 
   xssService: any;
 
-  pluginService: IPluginService;
-
   lastLoadedAt?: Date;
 
   customCss?: string;
@@ -47,7 +45,6 @@ class CustomizeService implements S2sMessageHandlable {
     this.s2sMessagingService = crowi.s2sMessagingService;
     this.appService = crowi.appService;
     this.xssService = crowi.xssService;
-    this.pluginService = crowi.pluginService;
   }
 
   /**
@@ -155,7 +152,7 @@ class CustomizeService implements S2sMessageHandlable {
 
     this.theme = theme;
 
-    const resultForThemePlugin = await this.pluginService.findThemePlugin(theme);
+    const resultForThemePlugin = await growiPluginService.findThemePlugin(theme);
 
     if (resultForThemePlugin != null) {
       this.forcedColorScheme = getForcedColorScheme(resultForThemePlugin.themeMetadata.schemeType);

+ 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/activate-plugin';
+import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
 

+ 3 - 3
apps/app/src/stores/template.tsx

@@ -1,7 +1,7 @@
-import { ITemplate } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import type { ITemplate } from '@growi/core';
+import useSWR, { type SWRResponse } from 'swr';
 
-import { getGrowiFacade } from '~/features/activate-plugin';
+import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
 
 const presetTemplates: ITemplate[] = [
   // preset 1

+ 1 - 8
package.json

@@ -53,16 +53,13 @@
     "yargs": "^17.7.1"
   },
   "devDependencies": {
-    "@swc-node/jest": "^1.6.2",
     "@swc-node/register": "^1.6.2",
     "@swc/core": "^1.3.36",
     "@swc/helpers": "^0.4.14",
-    "@swc/jest": "^0.2.24",
     "@testing-library/cypress": "^8.0.2",
     "@types/css-modules": "^1.0.2",
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
-    "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
@@ -77,15 +74,11 @@
     "eslint-config-weseek": "^2.1.1",
     "eslint-import-resolver-typescript": "^3.2.5",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-rulesdir": "^0.2.2",
     "eslint-plugin-vitest": "^0.2.3",
     "glob": "^8.1.0",
-    "jest": "^28.1.3",
-    "jest-date-mock": "^1.0.8",
-    "jest-localstorage-mock": "^2.4.14",
     "mock-require": "^3.0.3",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
@@ -104,7 +97,7 @@
     "vite": "^4.3.8",
     "vite-plugin-dts": "^2.0.0-beta.0",
     "vite-tsconfig-paths": "^4.2.0",
-    "vitest": "^0.31.1",
+    "vitest": "^0.31.4",
     "vitest-mock-extended": "^1.1.3"
   },
   "engines": {

+ 0 - 2
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -1,5 +1,3 @@
-import { describe, test, expect } from 'vitest';
-
 import {
   isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
 } from './index';

+ 4 - 1
packages/remark-lsx/package.json

@@ -19,7 +19,8 @@
     "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}",
     "lint:styles": "stylelint --allow-empty-input src/**/*.scss src/**/*.css",
     "lint:typecheck": "tsc",
-    "lint": "run-p lint:*"
+    "lint": "run-p lint:*",
+    "test": "vitest run --coverage"
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM"
@@ -29,6 +30,8 @@
     "@growi/remark-growi-directive": "^6.1.3-RC.0",
     "@growi/ui": "^6.1.3-RC.0",
     "escape-string-regexp": "^4.0.0",
+    "express": "^4.16.1",
+    "mongoose": "^6.5.0",
     "swr": "^2.0.3"
   },
   "devDependencies": {

+ 2 - 0
packages/remark-lsx/src/@types/declaration.d.ts

@@ -0,0 +1,2 @@
+// prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
+declare module '*.scss';

+ 6 - 4
packages/remark-lsx/src/components/Lsx.tsx

@@ -49,10 +49,12 @@ const LsxSubstance = React.memo(({
     }
 
     return (
-      <div className="text-warning">
-        <i className="fa fa-exclamation-triangle fa-fw"></i>
-        {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
-      </div>
+      <details>
+        <summary className="text-warning">
+          <i className="fa fa-exclamation-triangle fa-fw"></i> {lsxContext.toString()}
+        </summary>
+        <small className="ml-3 text-muted">{errorMessage}</small>
+      </details>
     );
   }, [errorMessage, hasError, lsxContext]);
 

+ 5 - 5
packages/remark-lsx/src/server/index.ts

@@ -1,17 +1,17 @@
-import { routesFactory } from './routes/lsx';
+import type { Request, Response } from 'express';
 
-const loginRequiredFallback = (req, res) => {
+import { listPages } from './routes/list-pages';
+
+const loginRequiredFallback = (req: Request, res: Response) => {
   return res.status(403).send('login required');
 };
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 const middleware = (crowi: any, app: any): void => {
-  const lsx = routesFactory(crowi);
-
   const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
   const accessTokenParser = crowi.require('../middlewares/access-token-parser')(crowi);
 
-  app.get('/_api/lsx', accessTokenParser, loginRequired, lsx.listPages);
+  app.get('/_api/lsx', accessTokenParser, loginRequired, listPages);
 };
 
 export default middleware;

+ 41 - 0
packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts

@@ -0,0 +1,41 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+const { isTopPage } = pagePathUtils;
+
+export const addDepthCondition = (query: PageQuery, pagePath: string, optionsDepth: string): PageQuery => {
+
+  const range = OptionParser.parseRange(optionsDepth);
+
+  if (range == null) {
+    return query;
+  }
+
+  const start = range.start;
+  const end = range.end;
+
+  // check start
+  if (start < 1) {
+    throw createError(400, `specified depth is [${start}:${end}] : the start must be larger or equal than 1`);
+  }
+
+  // count slash
+  const slashNum = isTopPage(pagePath)
+    ? 1
+    : pagePath.split('/').length;
+  const depthStart = slashNum + start - 1;
+  const depthEnd = slashNum + end - 1;
+
+  if (end < 0) {
+    return query.and([
+      { path: new RegExp(`^(\\/[^\\/]*){${depthStart},}$`) },
+    ]);
+  }
+
+  return query.and([
+    { path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`) },
+  ]);
+};

+ 93 - 0
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts

@@ -0,0 +1,93 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+import createError from 'http-errors';
+import { mock } from 'vitest-mock-extended';
+
+import { addNumCondition } from './add-num-condition';
+import type { PageQuery } from './generate-base-query';
+
+describe('addNumCondition()', () => {
+
+  const queryMock = mock<PageQuery>();
+
+  it('set limit with the specified number', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    const queryLimitResultMock = mock<PageQuery>();
+    queryMock.limit.calledWith(99).mockImplementation(() => queryLimitResultMock);
+
+    // when
+    const result = addNumCondition(queryMock, 99);
+
+    // then
+    expect(queryMock.limit).toHaveBeenCalledWith(99);
+    expect(result).toEqual(queryLimitResultMock);
+    expect(parseRangeSpy).not.toHaveBeenCalled();
+  });
+
+  it('returns the specified qeury as-is when the option value is invalid', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = addNumCondition(queryMock, 'invalid string');
+
+    // then
+    expect(queryMock.limit).not.toHaveBeenCalled();
+    expect(parseRangeSpy).toHaveBeenCalledWith('invalid string');
+    expect(result).toEqual(queryMock);
+  });
+
+  it('throws 400 http-errors instance when the start value is smaller than 1', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const caller = () => addNumCondition(queryMock, '-1:10');
+
+    // then
+    expect(caller).toThrowError(createError(400, 'specified num is [-1:10] : the start must be larger or equal than 1'));
+    expect(queryMock.limit).not.toHaveBeenCalledWith();
+    expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
+  });
+
+});
+
+
+describe('addNumCondition() set skip and limit with the range string', () => {
+
+  it.concurrent.each`
+    optionsNum    | expectedSkip    | expectedLimit   | isExpectedToSetLimit
+    ${'1:10'}     | ${0}            | ${10}           | ${true}
+    ${'3:'}       | ${2}            | ${-1}           | ${false}
+  `("'$optionsNum", ({
+    optionsNum, expectedSkip, expectedLimit, isExpectedToSetLimit,
+  }) => {
+    // setup
+    const queryMock = mock<PageQuery>();
+
+    const querySkipResultMock = mock<PageQuery>();
+    queryMock.skip.calledWith(expectedSkip).mockImplementation(() => querySkipResultMock);
+
+    const queryLimitResultMock = mock<PageQuery>();
+    querySkipResultMock.limit.calledWith(expectedLimit).mockImplementation(() => queryLimitResultMock);
+
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = addNumCondition(queryMock, optionsNum);
+
+    // then
+    expect(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
+    expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
+    if (isExpectedToSetLimit) {
+      expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
+      expect(result).toEqual(queryLimitResultMock);
+    }
+    else {
+      expect(querySkipResultMock.limit).not.toHaveBeenCalled();
+      expect(result).toEqual(querySkipResultMock);
+    }
+  });
+
+});

+ 38 - 0
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts

@@ -0,0 +1,38 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+
+/**
+ * add num condition that limit fetched pages
+ */
+export const addNumCondition = (query: PageQuery, optionsNum: string | number): PageQuery => {
+
+  if (typeof optionsNum === 'number') {
+    return query.limit(optionsNum);
+  }
+
+  const range = OptionParser.parseRange(optionsNum);
+
+  if (range == null) {
+    return query;
+  }
+
+  const start = range.start;
+  const end = range.end;
+
+  // check start
+  if (start < 1) {
+    throw createError(400, `specified num is [${start}:${end}] : the start must be larger or equal than 1`);
+  }
+
+  const skip = start - 1;
+  const limit = end - skip;
+
+  if (limit < 0) {
+    return query.skip(skip);
+  }
+
+  return query.skip(skip).limit(limit);
+};

+ 26 - 0
packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts

@@ -0,0 +1,26 @@
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+/**
+ * add sort condition(sort key & sort order)
+ *
+ * If only the reverse option is specified, the sort key is 'path'.
+ * If only the sort key is specified, the sort order is the ascending order.
+ *
+ */
+export const addSortCondition = (query: PageQuery, optionsSortArg?: string, optionsReverse?: string): PageQuery => {
+  // init sort key
+  const optionsSort = optionsSortArg ?? 'path';
+
+  // the default sort order
+  const isReversed = optionsReverse === 'true';
+
+  if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
+    throw createError(400, `The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
+  }
+
+  const sortOption = {};
+  sortOption[optionsSort] = isReversed ? -1 : 1;
+  return query.sort(sortOption);
+};

+ 24 - 0
packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts

@@ -0,0 +1,24 @@
+import { IPage, IUser } from '@growi/core';
+import { model } from 'mongoose';
+import type { Document, Query } from 'mongoose';
+
+export type PageQuery = Query<IPage[], Document>;
+
+export type PageQueryBuilder = {
+  query: PageQuery,
+  addConditionToListOnlyDescendants: (pagePath: string) => PageQueryBuilder,
+  addConditionToFilteringByViewerForList: (builder: PageQueryBuilder, user: IUser) => PageQueryBuilder,
+};
+
+export const generateBaseQuery = async(pagePath: string, user: IUser): Promise<PageQueryBuilder> => {
+  const Page = model<IPage>('Page');
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const PageAny = Page as any;
+
+  const baseQuery = Page.find();
+
+  const builder: PageQueryBuilder = new PageAny.PageQueryBuilder(baseQuery);
+  builder.addConditionToListOnlyDescendants(pagePath);
+
+  return PageAny.addConditionToFilteringByViewerForList(builder, user);
+};

+ 15 - 0
packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts

@@ -0,0 +1,15 @@
+import { IPage } from '@growi/core';
+import { model } from 'mongoose';
+
+export const getToppageViewersCount = async(): Promise<number> => {
+  const Page = model<IPage>('Page');
+
+  const aggRes = await Page.aggregate<{ count: number }>([
+    { $match: { path: '/' } },
+    { $project: { count: { $size: '$seenUsers' } } },
+  ]);
+
+  return aggRes.length > 0
+    ? aggRes[0].count
+    : 1;
+};

+ 134 - 0
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -0,0 +1,134 @@
+import { IPage, IUser } from '@growi/core';
+import type { Request, Response } from 'express';
+import createError from 'http-errors';
+import { mock } from 'vitest-mock-extended';
+
+import type { PageQuery } from './generate-base-query';
+
+import { listPages } from '.';
+
+
+// mocking modules
+const mocks = vi.hoisted(() => {
+  return {
+    addNumConditionMock: vi.fn(),
+    addSortConditionMock: vi.fn(),
+    generateBaseQueryMock: vi.fn(),
+    getToppageViewersCountMock: vi.fn(),
+  };
+});
+
+vi.mock('./add-num-condition', () => ({ addNumCondition: mocks.addNumConditionMock }));
+vi.mock('./add-sort-condition', () => ({ addSortCondition: mocks.addSortConditionMock }));
+vi.mock('./generate-base-query', () => ({ generateBaseQuery: mocks.generateBaseQueryMock }));
+vi.mock('./get-toppage-viewers-count', () => ({ getToppageViewersCount: mocks.getToppageViewersCountMock }));
+
+
+describe('listPages', () => {
+
+  it("returns 400 HTTP response when the query 'pagePath' is undefined", async() => {
+    // setup
+    const reqMock = mock<Request & { user: IUser }>();
+    const resMock = mock<Response>();
+    const resStatusMock = mock<Response>();
+    resMock.status.calledWith(400).mockReturnValue(resStatusMock);
+
+    // when
+    await listPages(reqMock, resMock);
+
+    // then
+    expect(resMock.status).toHaveBeenCalledOnce();
+    expect(resStatusMock.send).toHaveBeenCalledOnce();
+    expect(mocks.generateBaseQueryMock).not.toHaveBeenCalled();
+  });
+
+  describe('with num option', () => {
+
+    mocks.generateBaseQueryMock.mockImplementation(() => vi.fn());
+    mocks.getToppageViewersCountMock.mockImplementation(() => 99);
+
+    it('returns 200 HTTP response', async() => {
+      // setup
+      const reqMock = mock<Request & { user: IUser }>();
+      reqMock.query = { pagePath: '/Sandbox' };
+
+      const pageMock = mock<IPage>();
+      const queryMock = mock<PageQuery>();
+      queryMock.exec.mockImplementation(async() => [pageMock]);
+      mocks.addSortConditionMock.mockImplementation(() => queryMock);
+
+      const resMock = mock<Response>();
+      const resStatusMock = mock<Response>();
+      resMock.status.calledWith(200).mockReturnValue(resStatusMock);
+
+      // when
+      await listPages(reqMock, resMock);
+
+      // then
+      expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
+      expect(mocks.getToppageViewersCountMock).toHaveBeenCalledOnce();
+      expect(mocks.addNumConditionMock).toHaveBeenCalledOnce();
+      expect(mocks.addSortConditionMock).toHaveBeenCalledOnce();
+      expect(resMock.status).toHaveBeenCalledOnce();
+      expect(resStatusMock.send).toHaveBeenCalledWith({
+        pages: [pageMock],
+        toppageViewersCount: 99,
+      });
+    });
+
+    it('returns 500 HTTP response when an unexpected error occured', async() => {
+      // setup
+      const reqMock = mock<Request & { user: IUser }>();
+      reqMock.query = { pagePath: '/Sandbox' };
+
+      // an Error instance will be thrown by addNumConditionMock
+      const expectedError = new Error('error for test');
+      mocks.addNumConditionMock.mockImplementation(() => {
+        throw expectedError;
+      });
+
+      const resMock = mock<Response>();
+      const resStatusMock = mock<Response>();
+      resMock.status.calledWith(500).mockReturnValue(resStatusMock);
+
+      // when
+      await listPages(reqMock, resMock);
+
+      // then
+      expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
+      expect(mocks.getToppageViewersCountMock).toHaveBeenCalledOnce();
+      expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
+      expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
+      expect(resMock.status).toHaveBeenCalledOnce();
+      expect(resStatusMock.send).toHaveBeenCalledWith(expectedError);
+    });
+
+    it('returns 400 HTTP response when the value is invalid', async() => {
+      // setup
+      const reqMock = mock<Request & { user: IUser }>();
+      reqMock.query = { pagePath: '/Sandbox' };
+
+      // an http-errors instance will be thrown by addNumConditionMock
+      const expectedError = createError(400, 'error for test');
+      mocks.addNumConditionMock.mockImplementation(() => {
+        throw expectedError;
+      });
+
+      const resMock = mock<Response>();
+      const resStatusMock = mock<Response>();
+      resMock.status.calledWith(400).mockReturnValue(resStatusMock);
+
+      // when
+      await listPages(reqMock, resMock);
+
+      // then
+      expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
+      expect(mocks.getToppageViewersCountMock).toHaveBeenCalledOnce();
+      expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
+      expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
+      expect(resMock.status).toHaveBeenCalledOnce();
+      expect(resStatusMock.send).toHaveBeenCalledWith(expectedError);
+    });
+
+  });
+});

+ 130 - 0
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -0,0 +1,130 @@
+
+import type { IUser } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import escapeStringRegexp from 'escape-string-regexp';
+import type { Request, Response } from 'express';
+import createError, { isHttpError } from 'http-errors';
+
+import { addDepthCondition } from './add-depth-condition';
+import { addNumCondition } from './add-num-condition';
+import { addSortCondition } from './add-sort-condition';
+import { generateBaseQuery, type PageQuery } from './generate-base-query';
+import { getToppageViewersCount } from './get-toppage-viewers-count';
+
+
+const DEFAULT_PAGES_NUM = 50;
+
+
+const { addTrailingSlash } = pathUtils;
+
+/**
+ * add filter condition that filter fetched pages
+ */
+function addFilterCondition(query, pagePath, optionsFilter, isExceptFilter = false): PageQuery {
+  // when option strings is 'filter=', the option value is true
+  if (optionsFilter == null || optionsFilter === true) {
+    throw createError(400, 'filter option require value in regular expression.');
+  }
+
+  const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
+
+  let filterPath;
+  try {
+    if (optionsFilter.charAt(0) === '^') {
+      // move '^' to the first of path
+      filterPath = new RegExp(`^${pagePathForRegexp}${optionsFilter.slice(1, optionsFilter.length)}`);
+    }
+    else {
+      filterPath = new RegExp(`^${pagePathForRegexp}.*${optionsFilter}`);
+    }
+  }
+  catch (err) {
+    throw createError(400, err);
+  }
+
+  if (isExceptFilter) {
+    return query.and({
+      path: { $not: filterPath },
+    });
+  }
+  return query.and({
+    path: filterPath,
+  });
+}
+
+function addExceptCondition(query, pagePath, optionsFilter): PageQuery {
+  return this.addFilterCondition(query, pagePath, optionsFilter, true);
+}
+
+
+export type ListPagesOptions = {
+  depth?: string,
+  num?: string,
+  filter?: string,
+  except?: string,
+  sort?: string,
+  reverse?: string,
+}
+
+export const listPages = async(req: Request & { user: IUser }, res: Response): Promise<Response> => {
+  const user = req.user;
+
+  let pagePath: string;
+  let options: ListPagesOptions | undefined;
+
+  try {
+    // TODO: use express-validator
+    if (req.query.pagePath == null) {
+      throw new Error("The 'pagePath' query must not be null.");
+    }
+
+    pagePath = req.query.pagePath?.toString();
+    if (req.query.options != null) {
+      options = JSON.parse(req.query.options.toString());
+    }
+  }
+  catch (error) {
+    return res.status(400).send(error);
+  }
+
+  const builder = await generateBaseQuery(pagePath, user);
+
+  // count viewers of `/`
+  let toppageViewersCount;
+  try {
+    toppageViewersCount = await getToppageViewersCount();
+  }
+  catch (error) {
+    return res.status(500).send(error);
+  }
+
+  let query = builder.query;
+  try {
+    // depth
+    if (options?.depth != null) {
+      query = addDepthCondition(query, pagePath, options.depth);
+    }
+    // filter
+    if (options?.filter != null) {
+      query = addFilterCondition(query, pagePath, options.filter);
+    }
+    if (options?.except != null) {
+      query = addExceptCondition(query, pagePath, options.except);
+    }
+    // num
+    const optionsNum = options?.num || DEFAULT_PAGES_NUM;
+    query = addNumCondition(query, optionsNum);
+    // sort
+    query = addSortCondition(query, options?.sort, options?.reverse);
+
+    const pages = await query.exec();
+    return res.status(200).send({ pages, toppageViewersCount });
+  }
+  catch (error) {
+    if (isHttpError(error)) {
+      return res.status(error.status).send(error);
+    }
+    return res.status(500).send(error);
+  }
+
+};

+ 0 - 264
packages/remark-lsx/src/server/routes/lsx.ts

@@ -1,264 +0,0 @@
-
-import { OptionParser } from '@growi/core/dist/plugin';
-import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
-import escapeStringRegexp from 'escape-string-regexp';
-import createError, { isHttpError } from 'http-errors';
-
-
-const DEFAULT_PAGES_NUM = 50;
-
-
-const { addTrailingSlash } = pathUtils;
-const { isTopPage } = pagePathUtils;
-
-class Lsx {
-
-  /**
-   * add depth condition that limit fetched pages
-   *
-   * @static
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {any} optionsDepth
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addDepthCondition(query, pagePath, optionsDepth) {
-    // when option strings is 'depth=', the option value is true
-    if (optionsDepth == null || optionsDepth === true) {
-      throw createError(400, 'The value of depth option is invalid.');
-    }
-
-    const range = OptionParser.parseRange(optionsDepth);
-
-    if (range == null) {
-      return query;
-    }
-
-    const start = range.start;
-    const end = range.end;
-
-    if (start < 1 || end < 1) {
-      throw createError(400, `specified depth is [${start}:${end}] : start and end are must be larger than 1`);
-    }
-
-    // count slash
-    const slashNum = isTopPage(pagePath)
-      ? 1
-      : pagePath.split('/').length;
-    const depthStart = slashNum; // start is not affect to fetch page
-    const depthEnd = slashNum + end - 1;
-
-    return query.and({
-      path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`),
-    });
-  }
-
-  /**
-   * add num condition that limit fetched pages
-   *
-   * @static
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {number|string} optionsNum
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addNumCondition(query, pagePath, optionsNum) {
-    // when option strings is 'num=', the option value is true
-    if (optionsNum == null || optionsNum === true) {
-      throw createError(400, 'The value of num option is invalid.');
-    }
-
-    if (typeof optionsNum === 'number') {
-      return query.limit(optionsNum);
-    }
-
-    const range = OptionParser.parseRange(optionsNum);
-
-    if (range == null) {
-      return query;
-    }
-
-    const start = range.start;
-    const end = range.end;
-
-    if (start < 1 || end < 1) {
-      throw createError(400, `specified num is [${start}:${end}] : start and end are must be larger than 1`);
-    }
-
-    const skip = start - 1;
-    const limit = end - skip;
-
-    return query.skip(skip).limit(limit);
-  }
-
-  /**
-   * add filter condition that filter fetched pages
-   *
-   * @static
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {any} optionsFilter
-   * @param {boolean} isExceptFilter
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addFilterCondition(query, pagePath, optionsFilter, isExceptFilter = false) {
-    // when option strings is 'filter=', the option value is true
-    if (optionsFilter == null || optionsFilter === true) {
-      throw createError(400, 'filter option require value in regular expression.');
-    }
-
-    const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
-
-    let filterPath;
-    try {
-      if (optionsFilter.charAt(0) === '^') {
-        // move '^' to the first of path
-        filterPath = new RegExp(`^${pagePathForRegexp}${optionsFilter.slice(1, optionsFilter.length)}`);
-      }
-      else {
-        filterPath = new RegExp(`^${pagePathForRegexp}.*${optionsFilter}`);
-      }
-    }
-    catch (err) {
-      throw createError(400, err);
-    }
-
-    if (isExceptFilter) {
-      return query.and({
-        path: { $not: filterPath },
-      });
-    }
-    return query.and({
-      path: filterPath,
-    });
-  }
-
-  static addExceptCondition(query, pagePath, optionsFilter) {
-    return this.addFilterCondition(query, pagePath, optionsFilter, true);
-  }
-
-  /**
-   * add sort condition(sort key & sort order)
-   *
-   * If only the reverse option is specified, the sort key is 'path'.
-   * If only the sort key is specified, the sort order is the ascending order.
-   *
-   * @static
-   * @param {any} query
-   * @param {string} pagePath
-   * @param {string} optionsSort
-   * @param {string} optionsReverse
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addSortCondition(query, pagePath, optionsSortArg, optionsReverse) {
-    // init sort key
-    const optionsSort = optionsSortArg ?? 'path';
-
-    // the default sort order
-    const isReversed = optionsReverse === 'true';
-
-    if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
-      throw createError(400, `The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
-    }
-
-    const sortOption = {};
-    sortOption[optionsSort] = isReversed ? -1 : 1;
-    return query.sort(sortOption);
-  }
-
-}
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
-export const routesFactory = (crowi): any => {
-  const Page = crowi.model('Page');
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const actions: any = {};
-
-  /**
-   *
-   * @param {*} pagePath
-   * @param {*} user
-   *
-   * @return {Promise<Query>} query
-   */
-  async function generateBaseQueryBuilder(pagePath, user) {
-    const baseQuery = Page.find();
-
-    const builder = new Page.PageQueryBuilder(baseQuery);
-    builder.addConditionToListOnlyDescendants(pagePath);
-
-    return Page.addConditionToFilteringByViewerForList(builder, user);
-  }
-
-  actions.listPages = async(req, res) => {
-    const user = req.user;
-
-    let pagePath;
-    let options;
-
-    try {
-      pagePath = req.query.pagePath;
-      options = JSON.parse(req.query.options);
-    }
-    catch (error) {
-      return res.status(400).send(error);
-    }
-
-    const builder = await generateBaseQueryBuilder(pagePath, user);
-
-    // count viewers of `/`
-    let toppageViewersCount;
-    try {
-      const aggRes = await Page.aggregate([
-        { $match: { path: '/' } },
-        { $project: { count: { $size: '$seenUsers' } } },
-      ]);
-
-      toppageViewersCount = aggRes.length > 0
-        ? aggRes[0].count
-        : 1;
-    }
-    catch (error) {
-      return res.status(500).send(error);
-    }
-
-    let query = builder.query;
-    try {
-      // depth
-      if (options.depth != null) {
-        query = Lsx.addDepthCondition(query, pagePath, options.depth);
-      }
-      // filter
-      if (options.filter != null) {
-        query = Lsx.addFilterCondition(query, pagePath, options.filter);
-      }
-      if (options.except != null) {
-        query = Lsx.addExceptCondition(query, pagePath, options.except);
-      }
-      // num
-      const optionsNum = options.num || DEFAULT_PAGES_NUM;
-      query = Lsx.addNumCondition(query, pagePath, optionsNum);
-      // sort
-      query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
-
-      const pages = await query.exec();
-      res.status(200).send({ pages, toppageViewersCount });
-    }
-    catch (error) {
-      if (isHttpError) {
-        return res.status(error.status).send(error);
-      }
-      return res.status(500).send(error);
-    }
-  };
-
-  return actions;
-};

+ 16 - 7
packages/remark-lsx/src/stores/lsx.tsx

@@ -100,13 +100,22 @@ const useSWRxLsxResponse = (
 ): SWRResponse<LsxResponse, Error> => {
   return useSWR(
     ['/_api/lsx', pagePath, options, isImmutable],
-    ([endpoint, pagePath, options]) => {
-      return axios.get(endpoint, {
-        params: {
-          pagePath,
-          options,
-        },
-      }).then(result => result.data as LsxResponse);
+    async([endpoint, pagePath, options]) => {
+      try {
+        const res = await axios.get<LsxResponse>(endpoint, {
+          params: {
+            pagePath,
+            options,
+          },
+        });
+        return res.data;
+      }
+      catch (err) {
+        if (axios.isAxiosError(err)) {
+          throw new Error(err.response?.data.message);
+        }
+        throw err;
+      }
     },
     {
       keepPreviousData: true,

+ 6 - 4
packages/remark-lsx/tsconfig.json

@@ -4,10 +4,12 @@
   "compilerOptions": {
     "jsx": "react-jsxdev",
 
-    "baseUrl": ".",
-    "paths": {
-      "~/*": ["./src/*"],
-    }
+    "plugins": [{ "name": "typescript-plugin-css-modules" }],
+
+    "typeRoots": ["./src/@types"],
+    "types": [
+      "vitest/globals"
+    ]
   },
   "include": [
     "src"

+ 3 - 1
packages/remark-lsx/vite.server.config.ts

@@ -22,11 +22,13 @@ export default defineConfig({
         preserveModulesRoot: 'src/server',
       },
       external: [
+        'react',
         'axios',
         'escape-string-regexp',
+        'express',
         'http-errors',
         'is-absolute-url',
-        'react',
+        'mongoose',
         'next/link',
         'unified',
         'swr',

+ 13 - 0
packages/remark-lsx/vitest.config.ts

@@ -0,0 +1,13 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+  },
+});

Fișier diff suprimat deoarece este prea mare
+ 448 - 445
yarn.lock


Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff