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

Merge branch 'master' into imprv/integrate-customize-user-page-delete

ryoji-s 2 лет назад
Родитель
Сommit
1748f5580e
90 измененных файлов с 2360 добавлено и 1383 удалено
  1. 0 1
      .devcontainer/devcontainer.json
  2. 1 1
      .github/release-drafter.yml
  3. 1 1
      .github/workflows/reusable-app-prod.yml
  4. 22 11
      .vscode/launch.json
  5. 29 1
      CHANGELOG.md
  6. 0 6
      apps/app/config/migrate-mongo-config.spec.ts
  7. 19 11
      apps/app/package.json
  8. 6 2
      apps/app/public/static/locales/en_US/admin.json
  9. 6 2
      apps/app/public/static/locales/ja_JP/admin.json
  10. 6 2
      apps/app/public/static/locales/zh_CN/admin.json
  11. 1 0
      apps/app/src/components/PageDuplicateModal.tsx
  12. 10 4
      apps/app/src/components/PageEditor.tsx
  13. 1 1
      apps/app/src/components/SavePageControls/GrantSelector.tsx
  14. 0 75
      apps/app/src/components/Sidebar/InfiniteScroll.tsx
  15. 0 2
      apps/app/src/features/activate-plugin/index.ts
  16. 0 1
      apps/app/src/features/activate-plugin/utils/index.ts
  17. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  18. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  19. 21 7
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  20. 1 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  21. 1 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts
  22. 1 1
      apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx
  23. 0 0
      apps/app/src/features/growi-plugin/components/index.ts
  24. 6 6
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  25. 1 0
      apps/app/src/features/growi-plugin/interfaces/index.ts
  26. 16 15
      apps/app/src/features/growi-plugin/models/growi-plugin.ts
  27. 1 0
      apps/app/src/features/growi-plugin/models/index.ts
  28. 68 0
      apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts
  29. 51 0
      apps/app/src/features/growi-plugin/models/vo/github-url.ts
  30. 12 33
      apps/app/src/features/growi-plugin/routes/growi-plugins.ts
  31. 38 63
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  32. 1 0
      apps/app/src/features/growi-plugin/services/index.ts
  33. 3 2
      apps/app/src/features/growi-plugin/stores/growi-plugin.tsx
  34. 0 0
      apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts
  35. 1 1
      apps/app/src/pages/[[...path]].page.tsx
  36. 3 3
      apps/app/src/pages/_document.page.tsx
  37. 1 1
      apps/app/src/pages/admin/plugins.page.tsx
  38. 5 19
      apps/app/src/server/crowi/index.js
  39. 0 2
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  40. 6 8
      apps/app/src/server/routes/apiv3/customize-setting.js
  41. 1 1
      apps/app/src/server/routes/apiv3/index.js
  42. 2 5
      apps/app/src/server/service/customize.ts
  43. 1 1
      apps/app/src/stores/renderer.tsx
  44. 3 3
      apps/app/src/stores/template.tsx
  45. 0 181
      apps/app/test/cypress/integration/20-basic-features/20-basic-features--use-tools.spec.ts
  46. 194 0
      apps/app/test/cypress/integration/23-editor/23-editor--saving.spec.ts
  47. 151 0
      apps/app/test/cypress/integration/23-editor/23-editor--with-navigation.ts
  48. 1 0
      apps/app/test/cypress/integration/23-editor/assets/example.txt
  49. 2 2
      apps/slackbot-proxy/package.json
  50. 2 9
      package.json
  51. 1 1
      packages/core/package.json
  52. 0 2
      packages/core/src/utils/page-path-utils/index.spec.ts
  53. 1 1
      packages/hackmd/package.json
  54. 2 2
      packages/presentation/package.json
  55. 1 1
      packages/preset-themes/package.json
  56. 4 4
      packages/remark-attachment-refs/package.json
  57. 1 1
      packages/remark-drawio/package.json
  58. 1 1
      packages/remark-growi-directive/package.json
  59. 8 5
      packages/remark-lsx/package.json
  60. 2 0
      packages/remark-lsx/src/@types/declaration.d.ts
  61. 30 0
      packages/remark-lsx/src/components/Lsx.module.scss
  62. 52 10
      packages/remark-lsx/src/components/Lsx.tsx
  63. 23 0
      packages/remark-lsx/src/interfaces/api.ts
  64. 5 5
      packages/remark-lsx/src/server/index.ts
  65. 49 0
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.spec.ts
  66. 38 0
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts
  67. 78 0
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts
  68. 31 0
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts
  69. 26 0
      packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts
  70. 24 0
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  71. 15 0
      packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts
  72. 153 0
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  73. 125 0
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  74. 0 264
      packages/remark-lsx/src/server/routes/lsx.ts
  75. 0 148
      packages/remark-lsx/src/stores/lsx.tsx
  76. 1 0
      packages/remark-lsx/src/stores/lsx/index.ts
  77. 77 0
      packages/remark-lsx/src/stores/lsx/lsx.ts
  78. 77 0
      packages/remark-lsx/src/stores/lsx/parse-num-option.spec.ts
  79. 36 0
      packages/remark-lsx/src/stores/lsx/parse-num-option.ts
  80. 13 0
      packages/remark-lsx/src/utils/depth-utils.spec.ts
  81. 8 0
      packages/remark-lsx/src/utils/depth-utils.ts
  82. 217 0
      packages/remark-lsx/src/utils/page-node.spec.ts
  83. 91 0
      packages/remark-lsx/src/utils/page-node.ts
  84. 6 4
      packages/remark-lsx/tsconfig.json
  85. 1 0
      packages/remark-lsx/vite.client.config.ts
  86. 3 1
      packages/remark-lsx/vite.server.config.ts
  87. 13 0
      packages/remark-lsx/vitest.config.ts
  88. 1 1
      packages/slack/package.json
  89. 2 2
      packages/ui/package.json
  90. 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'

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -217,7 +217,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['10', '20', '21', '22', '30', '40', '50', '60']
+        spec-group: ['10', '20', '21', '22', '23', '30', '40', '50', '60']
 
     services:
       mongodb:

+ 22 - 11
.vscode/launch.json

@@ -2,17 +2,7 @@
     "version": "0.2.0",
     "configurations": [
       {
-        "type": "pwa-node",
-        "request": "attach",
-        "name": "Debug: Attach Debugger to Server",
-        "port": 9229,
-        "cwd": "${workspaceFolder}/apps/app",
-        "sourceMapPathOverrides": {
-          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
-        }
-      },
-      {
-        "type": "pwa-node",
+        "type": "node",
         "request": "launch",
         "name": "Debug: Current File",
         "skipFiles": [
@@ -26,6 +16,27 @@
           "${file}"
         ]
       },
+      {
+        "type": "node",
+        "request": "launch",
+        "name": "Debug: Current File with Vitest",
+        "autoAttachChildProcesses": true,
+        "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
+        "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
+        "args": ["run", "${relativeFile}"],
+        "smartStep": true,
+        "console": "integratedTerminal"
+      },
+      {
+        "type": "pwa-node",
+        "request": "attach",
+        "name": "Debug: Attach Debugger to Server",
+        "port": 9229,
+        "cwd": "${workspaceFolder}/apps/app",
+        "sourceMapPathOverrides": {
+          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
+        }
+      },
       {
         "type": "pwa-node",
         "request": "launch",

+ 29 - 1
CHANGELOG.md

@@ -1,9 +1,37 @@
 # Changelog
 
-## [Unreleased](https://github.com/weseek/growi/compare/v6.1.2...HEAD)
+## [Unreleased](https://github.com/weseek/growi/compare/v6.1.3...HEAD)
 
 *Please do not manually update this file. We've automated the process.*
 
+## [v6.1.3](https://github.com/weseek/growi/compare/v6.1.2...v6.1.3) - 2023-06-07
+
+### 💎 Features
+
+- feat(lsx):  Load more (#7774) @yuki-takei
+
+### 🚀 Improvement
+
+- imprv: Insert template (#7764) @yuki-takei
+- imprv: Update preset templates (#7762) @yuki-takei
+- imprv: Make migration script type safe (#7702) @miya
+- imprv: Update migration script docs (#7699) @miya
+
+### 🐛 Bug Fixes
+
+- fix(lsx): Parsing num/depth options (#7769) @yuki-takei
+- fix: When uploading an attachment and creating a new page, it does not inherit the grant of the parent page (#7768) @miya
+- fix: Unable to perform bookmark operations from bookmark item control (#7750) @miya
+- fix: Bookmarks status not updated on search result (#7667) @mudana-grune
+
+### 🧰 Maintenance
+
+- support: Refactor plugin related modules (#7765) @yuki-takei
+- support: Refactor AclService (#7754) @yuki-takei
+- support: typescriptize SlackLegacyUtil (#7751) @yuki-takei
+- support: Refactor ConfigManager (#7752) @yuki-takei
+- support: Convert unit tests by Jest to Vitest (#7749) @yuki-takei
+
 ## [v6.1.2](https://github.com/weseek/growi/compare/v6.1.1...v6.1.2) - 2023-05-25
 
 ### 🚀 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;

+ 19 - 11
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -63,18 +63,19 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.3-RC.0",
-    "@growi/hackmd": "^6.1.3-RC.0",
-    "@growi/preset-themes": "^6.1.3-RC.0",
-    "@growi/remark-attachment-refs": "^6.1.3-RC.0",
-    "@growi/remark-drawio": "^6.1.3-RC.0",
-    "@growi/remark-growi-directive": "^6.1.3-RC.0",
-    "@growi/remark-lsx": "^6.1.3-RC.0",
-    "@growi/slack": "^6.1.3-RC.0",
+    "@growi/core": "^6.1.4-RC.0",
+    "@growi/hackmd": "^6.1.4-RC.0",
+    "@growi/preset-themes": "^6.1.4-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.4-RC.0",
+    "@growi/remark-drawio": "^6.1.4-RC.0",
+    "@growi/remark-growi-directive": "^6.1.4-RC.0",
+    "@growi/remark-lsx": "^6.1.4-RC.0",
+    "@growi/slack": "^6.1.4-RC.0",
     "@promster/express": "^7.0.6",
     "@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",
@@ -207,12 +208,15 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.3-RC.0",
-    "@growi/ui": "^6.1.3-RC.0",
+    "@growi/presentation": "^6.1.4-RC.0",
+    "@growi/ui": "^6.1.4-RC.0",
     "@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",
@@ -224,10 +228,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",

+ 6 - 2
apps/app/public/static/locales/en_US/admin.json

@@ -862,8 +862,12 @@
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
-    "repository_url": "Repository URL",
-    "description": "You can install plugins by inputting the URL",
+    "form": {
+      "label_url": "Repository URL",
+      "desc_url": "You can install plugins by inputting the URL",
+      "label_branch": "Repository Branch Name",
+      "desc_branch": "You can specify the branch name to install. Default: `main`"
+    },
     "plugin_card": "Plugin Card",
     "plugin_is_not_installed": "Plugin is not installed",
     "install": "Install",

+ 6 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -870,8 +870,12 @@
   "plugins": {
     "plugins": "プラグイン",
     "plugin_installer": "プラグインインストーラー",
-    "repository_url": "URL",
-    "description": "リポジトリのURLの入力してください。",
+    "form": {
+      "label_url": "リポジトリURL",
+      "desc_url": "リポジトリのURLの入力してください。",
+      "label_branch": "ブランチの指定",
+      "desc_branch": "インストール対象のブランチを設定できます。デフォルト: `main`"
+    },
     "plugin_card": "プラグインカード",
     "plugin_is_not_installed": "プラグインがインストールされていません",
     "install": "インストール",

+ 6 - 2
apps/app/public/static/locales/zh_CN/admin.json

@@ -870,8 +870,12 @@
   "plugins": {
     "plugins": "Plugins",
     "plugin_installer": "Plugin Installer",
-    "repository_url": "Repository URL",
-    "description": "You can install plugins by inputting the URL",
+    "form": {
+      "label_url": "Repository URL",
+      "desc_url": "You can install plugins by inputting the URL",
+      "label_branch": "Repository Branch Name",
+      "desc_branch": "You can specify the branch name to install. Default: `main`"
+    },
     "plugin_card": "Plugin Card",
     "plugin_is_not_installed": "Plugin is not installed",
     "install": "Install",

+ 1 - 0
apps/app/src/components/PageDuplicateModal.tsx

@@ -252,6 +252,7 @@ const PageDuplicateModal = (): JSX.Element => {
         <button
           type="button"
           className="btn btn-primary"
+          data-testid="btn-duplicate"
           onClick={duplicate}
           disabled={!submitButtonEnabled}
         >

+ 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 <></>;

+ 1 - 1
apps/app/src/components/SavePageControls/GrantSelector.tsx

@@ -137,7 +137,7 @@ const GrantSelector = (props: Props): JSX.Element => {
     }
 
     return (
-      <div className="form-group grw-grant-selector mb-0">
+      <div className="form-group grw-grant-selector mb-0" data-testid="grw-grant-selector">
         <UncontrolledDropdown direction="up">
           <DropdownToggle color={dropdownToggleBtnColor} caret className="d-flex justify-content-between align-items-center" disabled={disabled}>
             {dropdownToggleLabelElm}

+ 0 - 75
apps/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -1,75 +0,0 @@
-import React, {
-  Ref, useEffect, useState,
-} from 'react';
-
-import type { SWRInfiniteResponse } from 'swr/infinite';
-
-type Props<T> = {
-  swrInifiniteResponse : SWRInfiniteResponse<T>
-  children: React.ReactNode,
-  loadingIndicator?: React.ReactNode
-  endingIndicator?: React.ReactNode
-  isReachingEnd?: boolean,
-  offset?: number
-}
-
-const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
-  const [intersecting, setIntersecting] = useState<boolean>(false);
-  const [element, setElement] = useState<HTMLElement>();
-  useEffect(() => {
-    if (element != null) {
-      const observer = new IntersectionObserver((entries) => {
-        setIntersecting(entries[0]?.isIntersecting);
-      });
-      observer.observe(element);
-      return () => observer.unobserve(element);
-    }
-    return;
-  }, [element]);
-  return [intersecting, el => el && setElement(el)];
-};
-
-const LoadingIndicator = (): React.ReactElement => {
-  return (
-    <div className="text-muted text-center">
-      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-    </div>
-  );
-};
-
-const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
-  const {
-    swrInifiniteResponse: {
-      setSize, isValidating,
-    },
-    children,
-    loadingIndicator,
-    endingIndicator,
-    isReachingEnd,
-    offset = 0,
-  } = props;
-
-  const [intersecting, ref] = useIntersection<HTMLDivElement>();
-
-  useEffect(() => {
-    if (intersecting && !isValidating && !isReachingEnd) {
-      setSize(size => size + 1);
-    }
-  }, [setSize, intersecting, isValidating, isReachingEnd]);
-
-  return (
-    <>
-      { children }
-
-      <div style={{ position: 'relative' }}>
-        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
-        {isReachingEnd
-          ? endingIndicator
-          : loadingIndicator || <LoadingIndicator />
-        }
-      </div>
-    </>
-  );
-};
-
-export default InfiniteScroll;

+ 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


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

@@ -4,7 +4,9 @@ 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 type { IGrowiPluginOrigin } from '../../../interfaces';
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxPlugins();
@@ -17,13 +19,13 @@ export const PluginInstallerForm = (): JSX.Element => {
 
     const {
       'pluginInstallerForm[url]': { value: url },
-      // 'pluginInstallerForm[ghBranch]': { value: ghBranch },
+      'pluginInstallerForm[ghBranch]': { value: ghBranch },
       // 'pluginInstallerForm[ghTag]': { value: ghTag },
     } = formData;
 
-    const pluginInstallerForm = {
+    const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
-      // ghBranch,
+      ghBranch,
       // ghTag,
     };
 
@@ -43,16 +45,28 @@ export const PluginInstallerForm = (): JSX.Element => {
   return (
     <form role="form" onSubmit={submitHandler}>
       <div className='form-group row'>
-        <label className="text-left text-md-right col-md-3 col-form-label">{t('plugins.repository_url')}</label>
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('plugins.form.label_url')}</label>
         <div className="col-md-6">
           <input
             className="form-control"
             type="text"
             name="pluginInstallerForm[url]"
-            placeholder="https://github.com/growi/plugins"
+            placeholder="https://github.com/weseek/growi-plugins-example"
             required
           />
-          <p className="form-text text-muted">{t('plugins.description')}</p>
+          <p className="form-text text-muted">{t('plugins.form.desc_url')}</p>
+        </div>
+      </div>
+      <div className='form-group row'>
+        <label className="text-left text-md-right col-md-3 col-form-label">{t('plugins.form.label_branch')}</label>
+        <div className="col-md-6">
+          <input
+            className="form-control col-md-3"
+            type="text"
+            name="pluginInstallerForm[ghBranch]"
+            placeholder="main"
+          />
+          <p className="form-text text-muted">{t('plugins.form.desc_branch')}</p>
         </div>
       </div>
 

+ 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';

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

@@ -0,0 +1,68 @@
+import { GitHubUrl } from './github-url';
+
+describe('GitHubUrl Constructor throws an error when the url string is', () => {
+
+  it.concurrent.each`
+    url
+    ${'//example.com/org/repos'}
+    ${'https://example.com'}
+    ${'https://github.com/org/repos/foo'}
+  `("'$url'", ({ url }) => {
+    // when
+    const caller = () => new GitHubUrl(url);
+
+    // then
+    expect(caller).toThrowError(`The specified URL is invalid. : url='${url}'`);
+  });
+
+});
+
+describe('The constructor is successfully processed', () => {
+
+  it('with http schemed url', () => {
+    // when
+    const githubUrl = new GitHubUrl('http://github.com/org/repos');
+
+    // then
+    expect(githubUrl).not.toBeNull();
+    expect(githubUrl.organizationName).toEqual('org');
+    expect(githubUrl.reposName).toEqual('repos');
+    expect(githubUrl.branchName).toEqual('main');
+  });
+
+  it('with https schemed url', () => {
+    // when
+    const githubUrl = new GitHubUrl('https://github.com/org/repos');
+
+    // then
+    expect(githubUrl).not.toBeNull();
+    expect(githubUrl.organizationName).toEqual('org');
+    expect(githubUrl.reposName).toEqual('repos');
+    expect(githubUrl.branchName).toEqual('main');
+  });
+
+  it('with branchName', () => {
+    // when
+    const githubUrl = new GitHubUrl('https://github.com/org/repos', 'fix/bug');
+
+    // then
+    expect(githubUrl).not.toBeNull();
+    expect(githubUrl.organizationName).toEqual('org');
+    expect(githubUrl.reposName).toEqual('repos');
+    expect(githubUrl.branchName).toEqual('fix/bug');
+  });
+
+});
+
+describe('archiveUrl()', () => {
+  it('returns zip url', () => {
+    // setup
+    const githubUrl = new GitHubUrl('https://github.com/org/repos', 'fix/bug');
+
+    // when
+    const { archiveUrl } = githubUrl;
+
+    // then
+    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix/bug.zip');
+  });
+});

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

@@ -0,0 +1,51 @@
+// https://regex101.com/r/fK2rV3/1
+const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
+
+export class GitHubUrl {
+
+  private _organizationName: string;
+
+  private _reposName: string;
+
+  private _branchName: string;
+
+  get organizationName(): string {
+    return this._organizationName;
+  }
+
+  get reposName(): string {
+    return this._reposName;
+  }
+
+  get branchName(): string {
+    return this._branchName;
+  }
+
+  get archiveUrl(): string {
+    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/heads/${this.branchName}.zip`, 'https://github.com');
+    return ghUrl.toString();
+  }
+
+  constructor(url: string, branchName = 'main') {
+
+    let matched;
+    try {
+      const ghUrl = new URL(url);
+
+      matched = ghUrl.pathname.match(githubReposIdPattern);
+
+      if (ghUrl.hostname !== 'github.com' || matched == null) {
+        throw new Error();
+      }
+    }
+    catch (err) {
+      throw new Error(`The specified URL is invalid. : url='${url}'`);
+    }
+
+    this._branchName = branchName;
+
+    this._organizationName = matched[1];
+    this._reposName = matched[2];
+  }
+
+}

+ 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) {

+ 38 - 63
apps/app/src/server/service/plugin.ts → apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -8,26 +8,25 @@ 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';
+import { GitHubUrl } from '../models/vo/github-url';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 const pluginStoringPath = resolveFromRoot('tmp/plugins');
 
-// https://regex101.com/r/fK2rV3/1
-const githubReposIdPattern = new RegExp(/^\/([^/]+)\/([^/]+)$/);
-
 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 +34,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 +54,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
@@ -71,26 +69,16 @@ export class PluginService implements IPluginService {
           }
 
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new URL(growiPlugin.origin.url);
-          const ghPathname = ghUrl.pathname;
-          // TODO: Branch names can be specified.
-          const ghBranch = 'main';
-          const match = ghPathname.match(githubReposIdPattern);
-          if (ghUrl.hostname !== 'github.com' || match == null) {
-            throw new Error('GitHub repository URL is invalid.');
-          }
-
-          const ghOrganizationName = match[1];
-          const ghReposName = match[2];
+          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.branchName);
+          const { reposName, branchName, archiveUrl } = ghUrl;
 
-          const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-          const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+          const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
           const unzippedPath = pluginStoringPath;
-          const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
+          const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
 
           try {
             // download github repository to local file system
-            await this.download(requestUrl, zipFilePath);
+            await this.download(archiveUrl, zipFilePath);
             await this.unzip(zipFilePath, unzippedPath);
             fs.renameSync(unzippedReposPath, pluginPath);
           }
@@ -113,40 +101,31 @@ 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> {
-    const ghUrl = new URL(origin.url);
-    const ghPathname = ghUrl.pathname;
-    // TODO: Branch names can be specified.
-    const ghBranch = 'main';
-
-    const match = ghPathname.match(githubReposIdPattern);
-    if (ghUrl.hostname !== 'github.com' || match == null) {
-      throw new Error('GitHub repository URL is invalid.');
-    }
-
-    const ghOrganizationName = match[1];
-    const ghReposName = match[2];
-    const installedPath = `${ghOrganizationName}/${ghReposName}`;
+  async install(origin: IGrowiPluginOrigin): Promise<string> {
+    const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
+    const {
+      organizationName, reposName, branchName, archiveUrl,
+    } = ghUrl;
+    const installedPath = `${organizationName}/${reposName}`;
 
-    const requestUrl = `https://github.com/${ghOrganizationName}/${ghReposName}/archive/refs/heads/${ghBranch}.zip`;
-    const zipFilePath = path.join(pluginStoringPath, `${ghBranch}.zip`);
+    const zipFilePath = path.join(pluginStoringPath, `${branchName}.zip`);
     const unzippedPath = pluginStoringPath;
-    const unzippedReposPath = path.join(pluginStoringPath, `${ghReposName}-${ghBranch}`);
-    const temporaryReposPath = path.join(pluginStoringPath, ghReposName);
+    const unzippedReposPath = path.join(pluginStoringPath, `${reposName}-${branchName}`);
+    const temporaryReposPath = path.join(pluginStoringPath, reposName);
     const reposStoringPath = path.join(pluginStoringPath, `${installedPath}`);
-    const organizationPath = path.join(pluginStoringPath, ghOrganizationName);
+    const organizationPath = path.join(pluginStoringPath, organizationName);
 
 
-    let plugins: GrowiPlugin<GrowiPluginMeta>[];
+    let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
-      await this.download(requestUrl, zipFilePath);
+      await this.download(archiveUrl, zipFilePath);
       await this.unzip(zipFilePath, unzippedPath);
       fs.renameSync(unzippedReposPath, temporaryReposPath);
 
       // detect plugins
-      plugins = await PluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName);
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
@@ -184,7 +163,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 +208,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 +256,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 +267,7 @@ export class PluginService implements IPluginService {
     return [plugin];
   }
 
-  async listPlugins(): Promise<GrowiPlugin[]> {
+  async listPlugins(): Promise<IGrowiPlugin[]> {
     return [];
   }
 
@@ -302,7 +279,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 +306,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 +347,6 @@ export class PluginService implements IPluginService {
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
 
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
     const entries: GrowiPluginResourceEntries = [];
 
     try {
@@ -409,3 +381,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


+ 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

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

@@ -9,130 +9,6 @@ context('Modal for page operation', () => {
     });
   });
 
-  it("PageCreateModal is shown and closed successfully", () => {
-    cy.visit('/');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}new-page-modal-opened`);
-      cy.get('button.close').click();
-    });
-
-    cy.collapseSidebar(true, true);
-    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
-  });
-
-  it("Successfully Create Today's page", () => {
-    const pageName = "Today's page";
-    cy.visit('/');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('.page-today-input2').type(pageName);
-      cy.screenshot(`${ssPrefix}today-add-page-name`);
-      cy.getByTestid('btn-create-memo').click();
-    });
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@save-page-btn').click();
-      // wait until
-      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
-    });
-    cy.get('.layout-root').should('not.have.class', 'editing');
-
-    cy.collapseSidebar(true);
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}create-today-page`);
-  });
-
-  it('Successfully create page under specific path', () => {
-    const pageName = 'child';
-
-    cy.visit('/foo/bar');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.get('body').within(() => {
-        return Cypress.$('[data-testid=page-create-modal]').is(':visible');
-      });
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('.rbt-input-main').should('have.value', '/foo/bar/');
-      cy.get('.rbt-input-main').type(pageName);
-      cy.screenshot(`${ssPrefix}under-path-add-page-name`);
-      cy.getByTestid('btn-create-page-under-below').click();
-    });
-
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@save-page-btn').click();
-      // wait until
-      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
-    });
-    cy.get('.layout-root').should('not.have.class', 'editing');
-
-    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
-  });
-
-  it('Trying to create template page under the root page fail', () => {
-    cy.visit('/');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('newPageBtn').click({force: true});
-      // wait until
-      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.getByTestid('grw-page-create-modal-path-name').should('have.text', '/');
-
-      cy.get('#template-type').click();
-      cy.get('#template-type').next().find('button:eq(0)').click({force: true});
-      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
-    });
-    cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-
-    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
-      cy.get('#template-type').click();
-      cy.get('#template-type').next().find('button:eq(1)').click({force: true});
-      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
-    });
-    cy.get('.Toastify__toast').should('be.visible');
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
-  });
-
   it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => {
     cy.visit('/Sandbox/Bootstrap4');
 
@@ -342,60 +218,3 @@ context('Tag Oprations', { scrollBehavior: false }, () =>{
   });
 
 });
-
-context('Shortcuts', () => {
-  const ssPrefix = 'shortcuts';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
-    const body1 = 'hello';
-    const body2 = ' world!';
-    const savePageShortcutKey = '{ctrl+s}';
-
-    cy.visit('/Sandbox/child');
-
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.waitUntil(() => {
-      // do
-      cy.get('@pageEditorModeManager').within(() => {
-        cy.get('button:nth-child(2)').click();
-      });
-      // until
-      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-    })
-
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
-
-    // 1st
-    cy.get('.CodeMirror').type(body1);
-    cy.get('.CodeMirror').contains(body1);
-    cy.get('.page-editor-preview-body').contains(body1);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-    cy.screenshot(`${ssPrefix}-update-page-1`);
-
-    cy.get('.Toastify').should('not.be.visible');
-
-    // 2nd
-    cy.get('.CodeMirror').type(body2);
-    cy.get('.CodeMirror').contains(body2);
-    cy.get('.page-editor-preview-body').contains(body2);
-    cy.get('.CodeMirror').type(savePageShortcutKey);
-
-    cy.get('.Toastify__toast').should('be.visible').within(() => {
-      cy.get('.Toastify__close-button').should('be.visible').click();
-      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
-    });
-    cy.screenshot(`${ssPrefix}-update-page-2`);
-  });
-});

+ 194 - 0
apps/app/test/cypress/integration/23-editor/23-editor--saving.spec.ts

@@ -0,0 +1,194 @@
+context('PageCreateModal', () => {
+
+  const ssPrefix = 'page-create-modal-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it("PageCreateModal is shown and closed successfully", () => {
+    cy.visit('/');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.screenshot(`${ssPrefix}new-page-modal-opened`);
+      cy.get('button.close').click();
+    });
+
+    cy.collapseSidebar(true, true);
+    cy.screenshot(`${ssPrefix}page-create-modal-closed`);
+  });
+
+  it("Successfully Create Today's page", () => {
+    const pageName = "Today's page";
+    cy.visit('/');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('.page-today-input2').type(pageName);
+      cy.screenshot(`${ssPrefix}today-add-page-name`);
+      cy.getByTestid('btn-create-memo').click();
+    });
+
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@save-page-btn').click();
+      // wait until
+      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
+    });
+    cy.get('.layout-root').should('not.have.class', 'editing');
+
+    cy.collapseSidebar(true);
+    cy.waitUntilSkeletonDisappear();
+    cy.screenshot(`${ssPrefix}create-today-page`);
+  });
+
+  it('Successfully create page under specific path', () => {
+    const pageName = 'child';
+
+    cy.visit('/foo/bar');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.get('body').within(() => {
+        return Cypress.$('[data-testid=page-create-modal]').is(':visible');
+      });
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').should('have.value', '/foo/bar/');
+      cy.get('.rbt-input-main').type(pageName);
+      cy.screenshot(`${ssPrefix}under-path-add-page-name`);
+      cy.getByTestid('btn-create-page-under-below').click();
+    });
+
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.getByTestid('save-page-btn').as('save-page-btn').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@save-page-btn').click();
+      // wait until
+      return cy.get('@save-page-btn').then($elem => $elem.is(':disabled'));
+    });
+    cy.get('.layout-root').should('not.have.class', 'editing');
+
+    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
+
+    cy.waitUntilSkeletonDisappear();
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}create-page-under-specific-page`);
+  });
+
+  it('Trying to create template page under the root page fail', () => {
+    cy.visit('/');
+
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('newPageBtn').click({force: true});
+      // wait until
+      return cy.getByTestid('page-create-modal').then($elem => $elem.is(':visible'));
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.getByTestid('grw-page-create-modal-path-name').should('have.text', '/');
+
+      cy.get('#template-type').click();
+      cy.get('#template-type').next().find('button:eq(0)').click({force: true});
+      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
+    });
+    cy.get('.Toastify__toast').should('be.visible');
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}create-template-for-children-error`);
+    cy.get('.Toastify__toast').should('be.visible').within(() => {
+      cy.get('.Toastify__close-button').should('be.visible').click();
+      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
+    });
+
+    cy.getByTestid('page-create-modal').should('be.visible').within(() => {
+      cy.get('#template-type').click();
+      cy.get('#template-type').next().find('button:eq(1)').click({force: true});
+      cy.getByTestid('grw-btn-edit-page').should('be.visible').click();
+    });
+    cy.get('.Toastify__toast').should('be.visible');
+    cy.collapseSidebar(true);
+    cy.screenshot(`${ssPrefix}create-template-for-descendants-error`);
+  });
+
+});
+
+
+context('Shortcuts', () => {
+  const ssPrefix = 'shortcuts';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Successfully updating a page using a shortcut on a previously created page', { scrollBehavior: false }, () => {
+    const body1 = 'hello';
+    const body2 = ' world!';
+    const savePageShortcutKey = '{ctrl+s}';
+
+    cy.visit('/Sandbox/child');
+
+    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+    cy.waitUntil(() => {
+      // do
+      cy.get('@pageEditorModeManager').within(() => {
+        cy.get('button:nth-child(2)').click();
+      });
+      // until
+      return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+    })
+
+    cy.get('.grw-editor-navbar-bottom').should('be.visible');
+
+    // 1st
+    cy.get('.CodeMirror').type(body1);
+    cy.get('.CodeMirror').contains(body1);
+    cy.get('.page-editor-preview-body').contains(body1);
+    cy.get('.CodeMirror').type(savePageShortcutKey);
+
+    cy.get('.Toastify__toast').should('be.visible').within(() => {
+      cy.get('.Toastify__close-button').should('be.visible').click();
+      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
+    });
+    cy.screenshot(`${ssPrefix}-update-page-1`);
+
+    cy.get('.Toastify').should('not.be.visible');
+
+    // 2nd
+    cy.get('.CodeMirror').type(body2);
+    cy.get('.CodeMirror').contains(body2);
+    cy.get('.page-editor-preview-body').contains(body2);
+    cy.get('.CodeMirror').type(savePageShortcutKey);
+
+    cy.get('.Toastify__toast').should('be.visible').within(() => {
+      cy.get('.Toastify__close-button').should('be.visible').click();
+      cy.get('.Toastify__progress-bar').invoke('attr', 'style', 'display: none')
+    });
+    cy.screenshot(`${ssPrefix}-update-page-2`);
+  });
+});

+ 151 - 0
apps/app/test/cypress/integration/23-editor/23-editor--with-navigation.ts

@@ -0,0 +1,151 @@
+function openEditor() {
+  cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
+  cy.waitUntil(() => {
+    // do
+    cy.get('@pageEditorModeManager').within(() => {
+      cy.get('button:nth-child(2)').click();
+    });
+    // until
+    return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
+  })
+  cy.get('.CodeMirror').should('be.visible');
+}
+
+context('Editor while uploading to a new page', () => {
+
+  const ssPrefix = 'editor-while-uploading-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  // for https://redmine.weseek.co.jp/issues/122040
+  it('should not be cleared and should prevent GrantSelector from modified', { scrollBehavior: false }, () => {
+    cy.visit('/Sandbox/for-122040');
+
+    openEditor();
+
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-1`);
+
+    // input the body
+    const body = 'Hello World!';
+    cy.get('.CodeMirror').type(body + '\n\n');
+    cy.get('.CodeMirror').should('contain.text', body);
+
+    // open GrantSelector
+    cy.waitUntil(() => {
+      // do
+      cy.getByTestid('grw-grant-selector').within(() => {
+        cy.get('button.dropdown-toggle').click({force: true});
+      });
+      // wait until
+      return cy.getByTestid('grw-grant-selector').within(() => {
+        return Cypress.$('.dropdown-menu.show').is(':visible');
+      });
+    });
+
+    // Select "Only me"
+    cy.getByTestid('grw-grant-selector').within(() => {
+      // click "Only me"
+      cy.get('.dropdown-menu.show').find('.dropdown-item').should('have.length', 4).then((menuItems) => {
+        menuItems[2].click();
+      });
+    });
+
+    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
+
+    // drag-drop a file
+    const filePath = 'test/cypress/integration/23-editor/assets/example.txt';
+    cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
+
+    // expect
+    cy.get('.CodeMirror').should('contain.text', body);
+    cy.get('.CodeMirror').should('contain.text', '[example.txt](/attachment/');
+    cy.getByTestid('grw-grant-selector').find('.dropdown-toggle').should('contain.text', 'Only me');
+    cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-3`);
+  });
+
+});
+
+context.skip('Editor while navigation', () => {
+
+  const ssPrefix = 'editor-while-navigation-';
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  // for https://redmine.weseek.co.jp/issues/115285
+  it('Successfully updating the page body', { scrollBehavior: false }, () => {
+    const page1Path = '/Sandbox/for-115285/page1';
+    const page2Path = '/Sandbox/for-115285/page2';
+
+    cy.visit(page1Path);
+
+    openEditor();
+
+    // page1
+    const bodyHello = 'hello';
+    cy.get('.CodeMirror').type(bodyHello);
+    cy.get('.CodeMirror').should('contain.text', bodyHello);
+    cy.get('.page-editor-preview-body').should('contain.text', bodyHello);
+    cy.getByTestid('page-editor').should('be.visible');
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page1`);
+
+    // save page1
+    cy.getByTestid('save-page-btn').click();
+
+    // open duplicate modal
+    cy.waitUntil(() => {
+      // do
+      cy.get('#grw-subnav-container').within(() => {
+        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
+      });
+      // wait until
+      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
+    });
+    cy.getByTestid('open-page-duplicate-modal-btn').filter(':visible').click({force: true});
+
+    // duplicate and navigate to page1
+    cy.getByTestid('page-duplicate-modal').should('be.visible').within(() => {
+      cy.get('input.form-control').clear();
+      cy.get('input.form-control').type(page2Path);
+      cy.getByTestid('btn-duplicate').click();
+    })
+
+    openEditor();
+
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page2`);
+
+    // type (without save)
+    const bodyWorld = ' world!!'
+    cy.get('.CodeMirror').type(`${bodyWorld}`);
+    cy.get('.CodeMirror').should('contain.text', `${bodyHello}${bodyWorld}`);
+    cy.get('.page-editor-preview-body').should('contain.text', `${bodyHello}${bodyWorld}`);
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page2-modified`);
+
+    // create a link to page1
+    cy.get('.CodeMirror').type('\n\n[page1](./page1)');
+
+    // go to page1
+    cy.get('.page-editor-preview-body').within(() => {
+      cy.get("a:contains('page1')").click();
+    });
+
+    openEditor();
+
+    cy.get('.CodeMirror').screenshot(`${ssPrefix}-editor-for-page1-returned`);
+
+    // expect
+    cy.get('.CodeMirror').should('contain.text', bodyHello);
+    cy.get('.CodeMirror').should('not.contain.text', bodyWorld); // text that added to page2
+    cy.get('.CodeMirror').should('not.contain.text', 'page1'); // text that added to page2
+  });
+});

+ 1 - 0
apps/app/test/cypress/integration/23-editor/assets/example.txt

@@ -0,0 +1 @@
+example.txt

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

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "6.1.3-slackbot-proxy.0",
+  "version": "6.1.4-slackbot-proxy.0",
   "license": "MIT",
   "scripts": {
     "build": "yarn tsc && tsc-alias -p tsconfig.build.json",
@@ -25,7 +25,7 @@
   },
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
-    "@growi/slack": "^6.1.3-RC.0",
+    "@growi/slack": "^6.1.4-RC.0",
     "@slack/oauth": "^2.0.1",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "^6.43.0",

+ 2 - 9
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -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": {

+ 1 - 1
packages/core/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/core",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "GROWI Core Libraries",
   "license": "MIT",
   "keywords": [

+ 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';

+ 1 - 1
packages/hackmd/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/hackmd",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "GROWI js and css files to use hackmd",
   "license": "MIT",
   "type": "module",

+ 2 - 2
packages/presentation/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/presentation",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "GROWI plugin for presentation",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -18,7 +18,7 @@
     "lint": "run-p lint:*"
   },
   "dependencies": {
-    "@growi/core": "^6.1.3-RC.0"
+    "@growi/core": "^6.1.4-RC.0"
   },
   "devDependencies": {
     "@marp-team/marp-core": "^3.6.0",

+ 1 - 1
packages/preset-themes/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@growi/preset-themes",
   "description": "GROWI preset themes",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "license": "MIT",
   "main": "dist/libs/preset-themes.umd.js",
   "module": "dist/libs/preset-themes.mjs",

+ 4 - 4
packages/remark-attachment-refs/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-attachment-refs",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "GROWI Plugin to add ref/refimg/refs/refsimg tags",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -26,9 +26,9 @@
   "dependencies": {
     "bunyan": "^1.8.15",
     "universal-bunyan": "^0.9.2",
-    "@growi/core": "^6.1.3-RC.0",
-    "@growi/remark-growi-directive": "^6.1.3-RC.0",
-    "@growi/ui": "^6.1.3-RC.0"
+    "@growi/core": "^6.1.4-RC.0",
+    "@growi/remark-growi-directive": "^6.1.4-RC.0",
+    "@growi/ui": "^6.1.4-RC.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

+ 1 - 1
packages/remark-drawio/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 1 - 1
packages/remark-growi-directive/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-growi-directive",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "remark plugin to support GROWI plugin (forked from remark-directive@2.0.1)",
   "license": "MIT",
   "keywords": [

+ 8 - 5
packages/remark-lsx/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-lsx",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
   "keywords": ["growi", "growi-plugin"],
@@ -19,16 +19,19 @@
     "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"
   },
   "dependencies": {
-    "@growi/core": "^6.1.3-RC.0",
-    "@growi/remark-growi-directive": "^6.1.3-RC.0",
-    "@growi/ui": "^6.1.3-RC.0",
+    "@growi/core": "^6.1.4-RC.0",
+    "@growi/remark-growi-directive": "^6.1.4-RC.0",
+    "@growi/ui": "^6.1.4-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';

+ 30 - 0
packages/remark-lsx/src/components/Lsx.module.scss

@@ -6,6 +6,36 @@
       animation: lsx-fadeIn 1s ease 0s infinite alternate;
     }
   }
+
+  .lsx-load-more-row {
+    opacity: .5;
+
+    .left-items-label {
+      display: none;
+    }
+    .btn-load-more {
+      border-right-style: hidden;
+      border-bottom-style: hidden;
+      border-left-style: hidden;
+      border-radius: 0;
+    }
+  }
+  .lsx-load-more-row:hover {
+    opacity: 1;
+
+    .left-items-label {
+      display: inline;
+    }
+    .btn-load-more {
+      border-style: solid;
+    }
+  }
+
+  .lsx-load-more-container {
+    max-width: 250px;
+    border-top: 1px black;
+  }
+
 }
 
 @keyframes lsx-fadeIn {

+ 52 - 10
packages/remark-lsx/src/components/Lsx.tsx

@@ -1,7 +1,8 @@
 import React, { useCallback, useMemo } from 'react';
 
 
-import { useSWRxNodeTree } from '../stores/lsx';
+import { useSWRxLsx } from '../stores/lsx';
+import { generatePageNodeTree } from '../utils/page-node';
 
 import { LsxListView } from './LsxPageList/LsxListView';
 import { LsxContext } from './lsx-context';
@@ -37,9 +38,10 @@ const LsxSubstance = React.memo(({
     return new LsxContext(prefix, options);
   }, [depth, filter, num, prefix, reverse, sort, except]);
 
-  const { data, error, isLoading: _isLoading } = useSWRxNodeTree(lsxContext, isImmutable);
+  const {
+    data, error, isLoading, setSize,
+  } = useSWRxLsx(lsxContext.pagePath, lsxContext.options, isImmutable);
 
-  const isLoading = _isLoading || data === undefined;
   const hasError = error != null;
   const errorMessage = error?.message;
 
@@ -49,10 +51,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]);
 
@@ -75,18 +79,56 @@ const LsxSubstance = React.memo(({
   }, [hasError, isLoading, lsxContext]);
 
   const contents = useMemo(() => {
-    if (isLoading) {
+    if (data == null) {
+      return <></>;
+    }
+
+    const depthRange = lsxContext.getOptDepth();
+
+    const nodeTree = generatePageNodeTree(prefix, data.flatMap(d => d.pages), depthRange);
+    const basisViewersCount = data.at(-1)?.toppageViewersCount;
+
+    return <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />;
+  }, [data, lsxContext, prefix]);
+
+
+  const LoadMore = useCallback(() => {
+    const lastResult = data?.at(-1);
+
+    if (lastResult == null) {
       return <></>;
     }
 
-    return <LsxListView nodeTree={data.nodeTree} lsxContext={lsxContext} basisViewersCount={data.toppageViewersCount} />;
-  }, [data?.nodeTree, data?.toppageViewersCount, isLoading, lsxContext]);
+    const { cursor, total } = lastResult;
+    const leftItemsNum = total - cursor;
+
+    if (leftItemsNum === 0) {
+      return <></>;
+    }
+
+    return (
+      <div className="row justify-content-center lsx-load-more-row">
+        <div className="col-12 col-sm-8 d-flex flex-column align-items-center lsx-load-more-container">
+          <button
+            type="button"
+            className="btn btn btn-block btn-outline-secondary btn-load-more"
+            onClick={() => setSize(size => size + 1)}
+          >
+            Load more<br />
+            <span className="text-muted small left-items-label">({leftItemsNum} pages left)</span>
+          </button>
+        </div>
+      </div>
+    );
+  }, [data, setSize]);
+
 
   return (
     <div className={`lsx ${styles.lsx}`}>
       <Error />
       <Loading />
       {contents}
+      <LoadMore />
     </div>
   );
 });

+ 23 - 0
packages/remark-lsx/src/interfaces/api.ts

@@ -0,0 +1,23 @@
+import { IPageHasId } from '@growi/core';
+
+export type LsxApiOptions = {
+  depth?: string,
+  filter?: string,
+  except?: string,
+  sort?: string,
+  reverse?: string,
+}
+
+export type LsxApiParams = {
+  pagePath: string,
+  offset?: number,
+  limit?: number,
+  options?: LsxApiOptions,
+}
+
+export type LsxApiResponseData = {
+  pages: IPageHasId[],
+  cursor: number,
+  total: number,
+  toppageViewersCount: number,
+}

+ 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;

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

@@ -0,0 +1,49 @@
+import type { ParseRangeResult } from '@growi/core';
+import { mock } from 'vitest-mock-extended';
+
+import { addDepthCondition } from './add-depth-condition';
+import type { PageQuery } from './generate-base-query';
+
+
+// mocking modules
+const mocks = vi.hoisted(() => {
+  return {
+    getDepthOfPathMock: vi.fn(),
+  };
+});
+
+vi.mock('../../../utils/depth-utils', () => ({ getDepthOfPath: mocks.getDepthOfPathMock }));
+
+
+describe('addDepthCondition()', () => {
+
+  it('returns query as-is', () => {
+    // setup
+    const query = mock<PageQuery>();
+
+    // when
+    const result = addDepthCondition(query, '/', null);
+
+    // then
+    expect(result).toEqual(query);
+  });
+
+  describe('throws http-errors instance', () => {
+
+    it('when the start is smaller than 1', () => {
+      // setup
+      const query = mock<PageQuery>();
+      const depthRange = mock<ParseRangeResult>();
+      depthRange.start = -1;
+      depthRange.end = 10;
+
+      // when
+      const caller = () => addDepthCondition(query, '/', depthRange);
+
+      // then
+      expect(caller).toThrowError(new Error("The specified option 'depth' is { start: -1, end: 10 } : the start must be larger or equal than 1"));
+      expect(mocks.getDepthOfPathMock).not.toHaveBeenCalled();
+    });
+
+  });
+});

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

@@ -0,0 +1,38 @@
+import type { ParseRangeResult } from '@growi/core';
+import createError from 'http-errors';
+
+import { getDepthOfPath } from '../../../utils/depth-utils';
+
+import type { PageQuery } from './generate-base-query';
+
+export const addDepthCondition = (query: PageQuery, pagePath: string, depthRange: ParseRangeResult | null): PageQuery => {
+
+  if (depthRange == null) {
+    return query;
+  }
+
+  const { start, end } = depthRange;
+
+  // check start
+  if (start < 1) {
+    throw createError(400, `The specified option 'depth' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`);
+  }
+  // check end
+  if (start > end && end > 0) {
+    throw createError(400, `The specified option 'depth' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`);
+  }
+
+  const depthOfPath = getDepthOfPath(pagePath);
+  const slashNumStart = depthOfPath + depthRange.start;
+  const slashNumEnd = depthOfPath + depthRange.end;
+
+  if (end < 0) {
+    return query.and([
+      { path: new RegExp(`^(\\/[^\\/]*){${slashNumStart},}$`) },
+    ]);
+  }
+
+  return query.and([
+    { path: new RegExp(`^(\\/[^\\/]*){${slashNumStart},${slashNumEnd}}$`) },
+  ]);
+};

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

@@ -0,0 +1,78 @@
+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() throws 400 http-errors instance ', () => {
+
+  it("when the param 'offset' is a negative value", () => {
+
+    // setup
+    const queryMock = mock<PageQuery>();
+
+    // when
+    const caller = () => addNumCondition(queryMock, -1, 10);
+
+    // then
+    expect(caller).toThrowError(createError(400, "The param 'offset' must be larger or equal than 0"));
+    expect(queryMock.skip).not.toHaveBeenCalledWith();
+    expect(queryMock.limit).not.toHaveBeenCalledWith();
+  });
+});
+
+
+describe('addNumCondition() set skip and limit with', () => {
+
+  it.concurrent.each`
+    offset        | limit           | expectedSkip   | expectedLimit
+    ${1}          | ${-1}           | ${1}           | ${null}
+    ${0}          | ${0}            | ${null}        | ${0}
+    ${0}          | ${10}           | ${null}        | ${10}
+    ${NaN}        | ${NaN}          | ${null}        | ${null}
+    ${undefined}  | ${undefined}    | ${null}        | ${50}
+  `("{ offset: $offset, limit: $limit }'", ({
+    offset, limit, expectedSkip, expectedLimit,
+  }) => {
+    // setup
+    const queryMock = mock<PageQuery>();
+
+    // result for q.skip()
+    const querySkipResultMock = mock<PageQuery>();
+    queryMock.skip.calledWith(expectedSkip).mockImplementation(() => querySkipResultMock);
+    // result for q.limit()
+    const queryLimitResultMock = mock<PageQuery>();
+    queryMock.limit.calledWith(expectedLimit).mockImplementation(() => queryLimitResultMock);
+    // result for q.skil().limit()
+    const querySkipAndLimitResultMock = mock<PageQuery>();
+    querySkipResultMock.limit.calledWith(expectedLimit).mockImplementation(() => querySkipAndLimitResultMock);
+
+    // when
+    const result = addNumCondition(queryMock, offset, limit);
+
+    // then
+    if (expectedSkip != null) {
+      expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
+      if (expectedLimit != null) {
+        expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
+        expect(result).toEqual(querySkipAndLimitResultMock); // q.skip().limit()
+      }
+      else {
+        expect(querySkipResultMock.limit).not.toHaveBeenCalled();
+        expect(result).toEqual(querySkipResultMock); // q.skil()
+      }
+    }
+    else {
+      expect(queryMock.skip).not.toHaveBeenCalled();
+      if (expectedLimit != null) {
+        expect(queryMock.limit).toHaveBeenCalledWith(expectedLimit);
+        expect(result).toEqual(queryLimitResultMock); // q.limit()
+      }
+      else {
+        expect(queryMock.limit).not.toHaveBeenCalled();
+        expect(result).toEqual(queryMock); // as-is
+      }
+    }
+  });
+
+});

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

@@ -0,0 +1,31 @@
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+
+const DEFAULT_PAGES_NUM = 50;
+
+/**
+ * add num condition that limit fetched pages
+ */
+export const addNumCondition = (query: PageQuery, offset = 0, limit = DEFAULT_PAGES_NUM): PageQuery => {
+
+  // check offset
+  if (offset < 0) {
+    throw createError(400, "The param 'offset' must be larger or equal than 0");
+  }
+  // check offset
+  if (offset < 0) {
+    throw createError(400, "The param 'offset' must be larger or equal than 0");
+  }
+
+  let q = query;
+  if (offset > 0) {
+    q = q.skip(offset);
+  }
+  if (limit >= 0) {
+    q = q.limit(limit);
+  }
+
+  return q;
+};

+ 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 type { IPageHasId, IUser } from '@growi/core';
+import { model } from 'mongoose';
+import type { Document, Query } from 'mongoose';
+
+export type PageQuery = Query<IPageHasId[], 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<IPageHasId>('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;
+};

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

@@ -0,0 +1,153 @@
+import { IPageHasId, IUser } from '@growi/core';
+import type { Request, Response } from 'express';
+import createError from 'http-errors';
+import { mock } from 'vitest-mock-extended';
+
+import type { LsxApiResponseData } from '../../../interfaces/api';
+
+import type { PageQuery, PageQueryBuilder } 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', () => {
+
+    const reqMock = mock<Request & { user: IUser }>();
+    reqMock.query = { pagePath: '/Sandbox' };
+
+    const builderMock = mock<PageQueryBuilder>();
+
+    mocks.generateBaseQueryMock.mockResolvedValue(builderMock);
+    mocks.getToppageViewersCountMock.mockImplementation(() => 99);
+
+    const queryMock = mock<PageQuery>();
+    builderMock.query = queryMock;
+
+    it('returns 200 HTTP response', async() => {
+      // setup query.clone().count()
+      const queryClonedMock = mock<PageQuery>();
+      queryMock.clone.mockImplementation(() => queryClonedMock);
+      queryClonedMock.count.mockResolvedValue(9);
+
+      // setup addNumCondition
+      mocks.addNumConditionMock.mockImplementation(() => queryMock);
+      // setup addSortCondition
+      mocks.addSortConditionMock.mockImplementation(() => queryMock);
+
+      // setup query.exec()
+      const pageMock = mock<IPageHasId>();
+      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();
+      const expectedResponseData: LsxApiResponseData = {
+        pages: [pageMock],
+        cursor: 1,
+        total: 9,
+        toppageViewersCount: 99,
+      };
+      expect(resStatusMock.send).toHaveBeenCalledWith(expectedResponseData);
+    });
+
+    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 error = new Error('error for test');
+      mocks.addNumConditionMock.mockImplementation(() => {
+        throw error;
+      });
+
+      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('error for test');
+    });
+
+    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 error = createError(400, 'error for test');
+      mocks.addNumConditionMock.mockImplementation(() => {
+        throw error;
+      });
+
+      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('error for test');
+    });
+
+  });
+});

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

@@ -0,0 +1,125 @@
+
+import { type IUser, OptionParser } 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 type { LsxApiParams, LsxApiResponseData } from '../../../interfaces/api';
+
+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 { 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 const listPages = async(req: Request & { user: IUser }, res: Response): Promise<Response> => {
+  const user = req.user;
+
+  // TODO: use express-validator
+  if (req.query.pagePath == null) {
+    return res.status(400).send("The 'pagePath' query must not be null.");
+  }
+
+  const params: LsxApiParams = {
+    pagePath: req.query.pagePath.toString(),
+    offset: req.query?.offset != null ? Number(req.query.offset) : undefined,
+    limit: req.query?.limit != null ? Number(req.query?.limit) : undefined,
+    options: req.query?.options != null ? JSON.parse(req.query.options.toString()) : {},
+  };
+
+  const {
+    pagePath, offset, limit, options,
+  } = params;
+  const builder = await generateBaseQuery(params.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, params.pagePath, OptionParser.parseRange(options.depth));
+    }
+    // filter
+    if (options?.filter != null) {
+      query = addFilterCondition(query, pagePath, options.filter);
+    }
+    if (options?.except != null) {
+      query = addExceptCondition(query, pagePath, options.except);
+    }
+
+    // get total num before adding num/sort conditions
+    const total = await query.clone().count();
+
+    // num
+    query = addNumCondition(query, offset, limit);
+    // sort
+    query = addSortCondition(query, options?.sort, options?.reverse);
+
+    const pages = await query.exec();
+    const cursor = (offset ?? 0) + pages.length;
+
+    const responseData: LsxApiResponseData = {
+      pages, cursor, total, toppageViewersCount,
+    };
+    return res.status(200).send(responseData);
+  }
+  catch (error) {
+    if (isHttpError(error)) {
+      return res.status(error.status).send(error.message);
+    }
+    return res.status(500).send(error.message);
+  }
+
+};

+ 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;
-};

+ 0 - 148
packages/remark-lsx/src/stores/lsx.tsx

@@ -1,148 +0,0 @@
-import * as url from 'url';
-
-import { IPageHasId, pathUtils } from '@growi/core';
-import axios from 'axios';
-import useSWR, { SWRResponse } from 'swr';
-
-import { LsxContext } from '../components/lsx-context';
-import type { PageNode } from '../interfaces/page-node';
-
-function isEquals(path1: string, path2: string) {
-  return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
-}
-
-function getParentPath(path: string) {
-  return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
-}
-
-/**
- * generate PageNode instances for target page and the ancestors
- *
- * @param {any} pathToNodeMap
- * @param {any} rootPagePath
- * @param {any} pagePath
- * @returns
- * @memberof Lsx
- */
-function generatePageNode(pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string): PageNode | null {
-  // exclude rootPagePath itself
-  if (isEquals(pagePath, rootPagePath)) {
-    return null;
-  }
-
-  // return when already registered
-  if (pathToNodeMap[pagePath] != null) {
-    return pathToNodeMap[pagePath];
-  }
-
-  // generate node
-  const node = { pagePath, children: [] };
-  pathToNodeMap[pagePath] = node;
-
-  /*
-    * process recursively for ancestors
-    */
-  // get or create parent node
-  const parentPath = getParentPath(pagePath);
-  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath);
-  // associate to patent
-  if (parentNode != null) {
-    parentNode.children.push(node);
-  }
-
-  return node;
-}
-
-function generatePageNodeTree(rootPagePath: string, pages: IPageHasId[]) {
-  const pathToNodeMap: Record<string, PageNode> = {};
-
-  pages.forEach((page) => {
-    // add slash ensure not to forward match to another page
-    // e.g. '/Java/' not to match to '/JavaScript'
-    const pagePath = pathUtils.addTrailingSlash(page.path);
-
-    const node = generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
-
-    // exclude rootPagePath itself
-    if (node == null) {
-      return;
-    }
-
-    // set the Page substance
-    node.page = page;
-  });
-
-  // return root objects
-  const rootNodes: PageNode[] = [];
-  Object.keys(pathToNodeMap).forEach((pagePath) => {
-    // exclude '/'
-    if (pagePath === '/') {
-      return;
-    }
-
-    const parentPath = getParentPath(pagePath);
-
-    // pick up what parent doesn't exist
-    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
-      rootNodes.push(pathToNodeMap[pagePath]);
-    }
-  });
-  return rootNodes;
-}
-
-type LsxResponse = {
-  pages: IPageHasId[],
-  toppageViewersCount: number,
-}
-
-const useSWRxLsxResponse = (
-    pagePath: string, options?: Record<string, string | undefined>, isImmutable?: boolean,
-): 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);
-    },
-    {
-      keepPreviousData: true,
-      revalidateIfStale: !isImmutable,
-      revalidateOnFocus: !isImmutable,
-      revalidateOnReconnect: !isImmutable,
-    },
-  );
-};
-
-type LsxNodeTree = {
-  nodeTree: PageNode[],
-  toppageViewersCount: number,
-}
-
-export const useSWRxNodeTree = (lsxContext: LsxContext, isImmutable?: boolean): SWRResponse<LsxNodeTree, Error> => {
-  const {
-    data, error, isLoading, isValidating,
-  } = useSWRxLsxResponse(lsxContext.pagePath, lsxContext.options, isImmutable);
-
-  return useSWR(
-    !isLoading && !isValidating ? ['lsxNodeTree', lsxContext.pagePath, lsxContext.options, isImmutable, data, error] : null,
-    ([, pagePath, , , data]) => {
-      if (data === undefined || error != null) {
-        throw error;
-      }
-      return {
-        nodeTree: generatePageNodeTree(pagePath, data?.pages),
-        toppageViewersCount: data.toppageViewersCount,
-      };
-    },
-    {
-      keepPreviousData: true,
-      revalidateIfStale: !isImmutable,
-      revalidateOnFocus: !isImmutable,
-      revalidateOnReconnect: !isImmutable,
-    },
-  );
-};

+ 1 - 0
packages/remark-lsx/src/stores/lsx/index.ts

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

+ 77 - 0
packages/remark-lsx/src/stores/lsx/lsx.ts

@@ -0,0 +1,77 @@
+import axios from 'axios';
+import useSWRInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
+
+import type { LsxApiOptions, LsxApiParams, LsxApiResponseData } from '../../interfaces/api';
+
+import { type ParseNumOptionResult, parseNumOption } from './parse-num-option';
+
+
+const LOADMORE_PAGES_NUM = 10;
+
+
+export const useSWRxLsx = (
+    pagePath: string, options?: Record<string, string|undefined>, isImmutable?: boolean,
+): SWRInfiniteResponse<LsxApiResponseData, Error> => {
+
+  return useSWRInfinite(
+    // key generator
+    (pageIndex, previousPageData) => {
+      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+
+      // parse num option
+      let initialOffsetAndLimit: ParseNumOptionResult | null = null;
+      let parseError: Error | undefined;
+      try {
+        initialOffsetAndLimit = options?.num != null
+          ? parseNumOption(options.num)
+          : null;
+      }
+      catch (err) {
+        parseError = err;
+      }
+
+      // the first loading
+      if (pageIndex === 0 || previousPageData == null) {
+        return ['/_api/lsx', pagePath, options, initialOffsetAndLimit?.offset, initialOffsetAndLimit?.limit, parseError?.message, isImmutable];
+      }
+
+      // loading more
+      return ['/_api/lsx', pagePath, options, previousPageData.cursor, LOADMORE_PAGES_NUM, parseError?.message, isImmutable];
+    },
+
+    // fetcher
+    async([endpoint, pagePath, options, offset, limit, parseErrorMessage]) => {
+      if (parseErrorMessage != null) {
+        throw new Error(parseErrorMessage);
+      }
+
+      const apiOptions = Object.assign({}, options, { num: undefined }) as LsxApiOptions;
+      const params: LsxApiParams = {
+        pagePath,
+        offset,
+        limit,
+        options: apiOptions,
+      };
+      try {
+        const res = await axios.get<LsxApiResponseData>(endpoint, { params });
+        return res.data;
+      }
+      catch (err) {
+        if (axios.isAxiosError(err)) {
+          throw new Error(err.response?.data.message);
+        }
+        throw err;
+      }
+    },
+
+    // options
+    {
+      keepPreviousData: true,
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+      revalidateFirstPage: false,
+      revalidateAll: false,
+    },
+  );
+};

+ 77 - 0
packages/remark-lsx/src/stores/lsx/parse-num-option.spec.ts

@@ -0,0 +1,77 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+
+import { parseNumOption } from './parse-num-option';
+
+describe('addNumCondition()', () => {
+
+  it('set limit with the specified number', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = parseNumOption('99');
+
+    // then
+    expect(result).toEqual({ limit: 99 });
+    expect(parseRangeSpy).not.toHaveBeenCalled();
+  });
+
+  it('returns null when the option value is invalid', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = parseNumOption('invalid string');
+
+    // then
+    expect(parseRangeSpy).toHaveBeenCalledWith('invalid string');
+    expect(result).toBeNull();
+  });
+
+  it('throws an error when the start value is smaller than 1', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const caller = () => parseNumOption('-1:10');
+
+    // then
+    expect(caller).toThrowError("The specified option 'num' is { start: -1, end: 10 } : the start must be larger or equal than 1");
+    expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
+  });
+
+  it('throws an error when the end value is smaller than the start value', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const caller = () => parseNumOption('3:2');
+
+    // then
+    expect(caller).toThrowError("The specified option 'num' is { start: 3, end: 2 } : the end must be larger or equal than the start");
+    expect(parseRangeSpy).toHaveBeenCalledWith('3:2');
+  });
+
+});
+
+
+describe('addNumCondition() set skip and limit with the range string', () => {
+
+  it.concurrent.each`
+    optionsNum    | expected
+    ${'1:10'}     | ${{ offset: 0, limit: 10 }}
+    ${'2:2'}      | ${{ offset: 1, limit: 1 }}
+    ${'3:'}       | ${{ offset: 2, limit: -1 }}
+  `("'$optionsNum", ({ optionsNum, expected }) => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = parseNumOption(optionsNum);
+
+    // then
+    expect(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
+    expect(result).toEqual(expected);
+  });
+
+});

+ 36 - 0
packages/remark-lsx/src/stores/lsx/parse-num-option.ts

@@ -0,0 +1,36 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+
+export type ParseNumOptionResult = { offset: number, limit?: number } | { offset?: number, limit: number };
+
+/**
+ * add num condition that limit fetched pages
+ */
+export const parseNumOption = (optionsNum: string): ParseNumOptionResult | null => {
+
+  if (Number.isInteger(Number(optionsNum))) {
+    return { limit: Number(optionsNum) };
+  }
+
+  const range = OptionParser.parseRange(optionsNum);
+
+  if (range == null) {
+    return null;
+  }
+
+  const start = range.start;
+  const end = range.end;
+
+  // check start
+  if (start < 1) {
+    throw new Error(`The specified option 'num' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`);
+  }
+  // check end
+  if (start > end && end > 0) {
+    throw new Error(`The specified option 'num' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`);
+  }
+
+  const offset = start - 1;
+  const limit = Math.max(-1, end - offset);
+
+  return { offset, limit };
+};

+ 13 - 0
packages/remark-lsx/src/utils/depth-utils.spec.ts

@@ -0,0 +1,13 @@
+import { getDepthOfPath } from './depth-utils';
+
+describe('getDepthOfPath()', () => {
+
+  it('returns 0 when the path does not include slash', () => {
+    // when
+    const result = getDepthOfPath('Sandbox');
+
+    // then
+    expect(result).toBe(0);
+  });
+
+});

+ 8 - 0
packages/remark-lsx/src/utils/depth-utils.ts

@@ -0,0 +1,8 @@
+import { isTopPage } from '@growi/core/dist/utils/page-path-utils';
+
+export const getDepthOfPath = (path: string): number => {
+  if (isTopPage(path)) {
+    return 0;
+  }
+  return (path.match(/\//g) ?? []).length;
+};

+ 217 - 0
packages/remark-lsx/src/utils/page-node.spec.ts

@@ -0,0 +1,217 @@
+import { IPageHasId, OptionParser } from '@growi/core';
+import { mock } from 'vitest-mock-extended';
+
+import { PageNode } from '../interfaces/page-node';
+
+import { generatePageNodeTree } from './page-node';
+
+
+function omitPageData(pageNode: PageNode): Omit<PageNode, 'page'> {
+  const obj = Object.assign({}, pageNode);
+  delete obj.page;
+
+  // omit data in children
+  obj.children = obj.children.map(child => omitPageData(child));
+
+  return obj;
+}
+
+describe('generatePageNodeTree()', () => {
+
+  it("returns when the rootPagePath is '/'", () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/',
+      '/Sandbox',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const result = generatePageNodeTree('/', pages);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/Sandbox',
+        children: [],
+      },
+    ]);
+  });
+
+  it('returns when the pages are not empty', () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/Sandbox',
+      '/Sandbox/level2',
+      '/Sandbox/level2/level3-1',
+      '/Sandbox/level2/level3-2',
+      '/Sandbox/level2/level3-3',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const result = generatePageNodeTree('/Sandbox', pages);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/Sandbox/level2',
+        children: [
+          {
+            pagePath: '/Sandbox/level2/level3-1',
+            children: [],
+          },
+          {
+            pagePath: '/Sandbox/level2/level3-2',
+            children: [],
+          },
+          {
+            pagePath: '/Sandbox/level2/level3-3',
+            children: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+  it('returns when the pages include some empty pages', () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/',
+      '/user/foo',
+      '/user/bar',
+      '/user/bar/memo/2023/06/01',
+      '/user/bar/memo/2023/06/02/memo-test',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const result = generatePageNodeTree('/', pages);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/user',
+        children: [
+          {
+            pagePath: '/user/foo',
+            children: [],
+          },
+          {
+            pagePath: '/user/bar',
+            children: [
+              {
+                pagePath: '/user/bar/memo',
+                children: [
+                  {
+                    pagePath: '/user/bar/memo/2023',
+                    children: [
+                      {
+                        pagePath: '/user/bar/memo/2023/06',
+                        children: [
+                          {
+                            pagePath: '/user/bar/memo/2023/06/01',
+                            children: [],
+                          },
+                          {
+                            pagePath: '/user/bar/memo/2023/06/02',
+                            children: [
+                              {
+                                pagePath: '/user/bar/memo/2023/06/02/memo-test',
+                                children: [],
+                              },
+                            ],
+                          },
+                        ],
+                      },
+                    ],
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+    ]);
+  });
+
+  it("returns with 'depth=1:2'", () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/Sandbox',
+      '/Sandbox/level2-1',
+      '/Sandbox/level2-2',
+      '/user',
+      '/user/foo',
+      '/user/bar',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const depthRange = OptionParser.parseRange('1:2');
+    const result = generatePageNodeTree('/', pages, depthRange);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/Sandbox',
+        children: [
+          {
+            pagePath: '/Sandbox/level2-1',
+            children: [],
+          },
+          {
+            pagePath: '/Sandbox/level2-2',
+            children: [],
+          },
+        ],
+      },
+      {
+        pagePath: '/user',
+        children: [
+          {
+            pagePath: '/user/foo',
+            children: [],
+          },
+          {
+            pagePath: '/user/bar',
+            children: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+  it("returns with 'depth=2:3'", () => {
+    // setup
+    const pages: IPageHasId[] = [
+      '/foo/level2',
+      '/foo/level2',
+      '/foo/level2/level3-1',
+      '/foo/level2/level3-2',
+    ].map(path => mock<IPageHasId>({ path }));
+
+    // when
+    const depthRange = OptionParser.parseRange('2:3');
+    const result = generatePageNodeTree('/', pages, depthRange);
+    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+
+    // then
+    expect(resultWithoutPageData).toStrictEqual([
+      {
+        pagePath: '/foo/level2',
+        children: [
+          {
+            pagePath: '/foo/level2/level3-1',
+            children: [],
+          },
+          {
+            pagePath: '/foo/level2/level3-2',
+            children: [],
+          },
+        ],
+      },
+    ]);
+  });
+
+});

+ 91 - 0
packages/remark-lsx/src/utils/page-node.ts

@@ -0,0 +1,91 @@
+import * as url from 'url';
+
+import type { IPageHasId, ParseRangeResult } from '@growi/core';
+import { removeTrailingSlash } from '@growi/core/dist/utils/path-utils';
+
+import type { PageNode } from '../interfaces/page-node';
+
+import { getDepthOfPath } from './depth-utils';
+
+
+function getParentPath(path: string) {
+  return removeTrailingSlash(decodeURIComponent(url.resolve(path, './')));
+}
+
+/**
+ * generate PageNode instances for target page and the ancestors
+ *
+ * @param {any} pathToNodeMap
+ * @param {any} rootPagePath
+ * @param {any} pagePath
+ * @returns
+ * @memberof Lsx
+ */
+function generatePageNode(
+    pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string, depthRange?: ParseRangeResult | null,
+): PageNode | null {
+
+  // exclude rootPagePath itself
+  if (pagePath === rootPagePath) {
+    return null;
+  }
+
+  const depthStartToProcess = getDepthOfPath(rootPagePath) + (depthRange?.start ?? 0); // at least 1
+  const currentPageDepth = getDepthOfPath(pagePath);
+
+  // return by the depth restriction
+  // '/' will also return null because the depth is 0
+  if (currentPageDepth < depthStartToProcess) {
+    return null;
+  }
+
+  // return when already registered
+  if (pathToNodeMap[pagePath] != null) {
+    return pathToNodeMap[pagePath];
+  }
+
+  // generate node
+  const node = { pagePath, children: [] };
+  pathToNodeMap[pagePath] = node;
+
+  /*
+    * process recursively for ancestors
+    */
+  // get or create parent node
+  const parentPath = getParentPath(pagePath);
+  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath, depthRange);
+  // associate to patent
+  if (parentNode != null) {
+    parentNode.children.push(node);
+  }
+
+  return node;
+}
+
+export function generatePageNodeTree(rootPagePath: string, pages: IPageHasId[], depthRange?: ParseRangeResult | null): PageNode[] {
+  const pathToNodeMap: Record<string, PageNode> = {};
+
+  pages.forEach((page) => {
+    const node = generatePageNode(pathToNodeMap, rootPagePath, page.path, depthRange); // this will not be null
+
+    // exclude rootPagePath itself
+    if (node == null) {
+      return;
+    }
+
+    // set the Page substance
+    node.page = page;
+  });
+
+  // return root objects
+  const rootNodes: PageNode[] = [];
+  Object.keys(pathToNodeMap).forEach((pagePath) => {
+    const parentPath = getParentPath(pagePath);
+
+    // pick up what parent doesn't exist
+    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
+      rootNodes.push(pathToNodeMap[pagePath]);
+    }
+  });
+  return rootNodes;
+}

+ 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"

+ 1 - 0
packages/remark-lsx/vite.client.config.ts

@@ -29,6 +29,7 @@ export default defineConfig({
         'next/link',
         'unified',
         'swr',
+        /^swr\/.*/,
         /^hast-.*/,
         /^unist-.*/,
         /^@growi\/.*/,

+ 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,
+  },
+});

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slack",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "license": "MIT",
   "main": "dist/index.js",
   "module": "dist/index.mjs",

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/ui",
-  "version": "6.1.3-RC.0",
+  "version": "6.1.4-RC.0",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "keywords": ["growi"],
@@ -16,7 +16,7 @@
     "lint": "npm-run-all -p lint:*"
   },
   "dependencies": {
-    "@growi/core": "^6.1.3-RC.0"
+    "@growi/core": "^6.1.4-RC.0"
   },
   "devDependencies": {
     "react": "^18.2.0"

Разница между файлами не показана из-за своего большого размера
+ 448 - 445
yarn.lock


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