Kaynağa Gözat

Merge branch 'master' of https://github.com/weseek/growi into feat/119760

Shun Miyazawa 2 yıl önce
ebeveyn
işleme
d307eef22b
57 değiştirilmiş dosya ile 1912 ekleme ve 1067 silme
  1. 0 1
      .devcontainer/devcontainer.json
  2. 1 1
      .github/release-drafter.yml
  3. 22 11
      .vscode/launch.json
  4. 29 1
      CHANGELOG.md
  5. 0 6
      apps/app/config/migrate-mongo-config.spec.ts
  6. 19 11
      apps/app/package.json
  7. 6 2
      apps/app/public/static/locales/en_US/admin.json
  8. 6 2
      apps/app/public/static/locales/ja_JP/admin.json
  9. 6 2
      apps/app/public/static/locales/zh_CN/admin.json
  10. 0 75
      apps/app/src/components/Sidebar/InfiniteScroll.tsx
  11. 19 6
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  12. 68 0
      apps/app/src/features/growi-plugin/models/vo/github-url.spec.ts
  13. 51 0
      apps/app/src/features/growi-plugin/models/vo/github-url.ts
  14. 17 38
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  15. 0 2
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  16. 2 2
      apps/slackbot-proxy/package.json
  17. 2 9
      package.json
  18. 1 1
      packages/core/package.json
  19. 0 2
      packages/core/src/utils/page-path-utils/index.spec.ts
  20. 1 1
      packages/hackmd/package.json
  21. 2 2
      packages/presentation/package.json
  22. 1 1
      packages/preset-themes/package.json
  23. 4 4
      packages/remark-attachment-refs/package.json
  24. 1 1
      packages/remark-drawio/package.json
  25. 1 1
      packages/remark-growi-directive/package.json
  26. 8 5
      packages/remark-lsx/package.json
  27. 2 0
      packages/remark-lsx/src/@types/declaration.d.ts
  28. 30 0
      packages/remark-lsx/src/components/Lsx.module.scss
  29. 52 10
      packages/remark-lsx/src/components/Lsx.tsx
  30. 23 0
      packages/remark-lsx/src/interfaces/api.ts
  31. 5 5
      packages/remark-lsx/src/server/index.ts
  32. 49 0
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.spec.ts
  33. 38 0
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts
  34. 78 0
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts
  35. 31 0
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts
  36. 26 0
      packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts
  37. 24 0
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  38. 15 0
      packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts
  39. 153 0
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  40. 125 0
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  41. 0 264
      packages/remark-lsx/src/server/routes/lsx.ts
  42. 0 148
      packages/remark-lsx/src/stores/lsx.tsx
  43. 1 0
      packages/remark-lsx/src/stores/lsx/index.ts
  44. 77 0
      packages/remark-lsx/src/stores/lsx/lsx.ts
  45. 77 0
      packages/remark-lsx/src/stores/lsx/parse-num-option.spec.ts
  46. 36 0
      packages/remark-lsx/src/stores/lsx/parse-num-option.ts
  47. 13 0
      packages/remark-lsx/src/utils/depth-utils.spec.ts
  48. 8 0
      packages/remark-lsx/src/utils/depth-utils.ts
  49. 217 0
      packages/remark-lsx/src/utils/page-node.spec.ts
  50. 91 0
      packages/remark-lsx/src/utils/page-node.ts
  51. 6 4
      packages/remark-lsx/tsconfig.json
  52. 1 0
      packages/remark-lsx/vite.client.config.ts
  53. 3 1
      packages/remark-lsx/vite.server.config.ts
  54. 13 0
      packages/remark-lsx/vitest.config.ts
  55. 1 1
      packages/slack/package.json
  56. 2 2
      packages/ui/package.json
  57. 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'

+ 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

@@ -857,8 +857,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

@@ -865,8 +865,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

@@ -865,8 +865,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",

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

+ 19 - 6
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -5,6 +5,7 @@ import { useTranslation } from 'next-i18next';
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
+import type { IGrowiPluginOrigin } from '../../../interfaces';
 import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 export const PluginInstallerForm = (): JSX.Element => {
@@ -18,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,
     };
 
@@ -44,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>
 

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

+ 17 - 38
apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -16,14 +16,12 @@ 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][];
@@ -71,26 +69,16 @@ export class GrowiPluginService implements IGrowiPluginService {
           }
 
           // 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);
           }
@@ -114,39 +102,30 @@ export class GrowiPluginService implements IGrowiPluginService {
   * Install a plugin from URL and save it in the DB and file system.
   */
   async install(origin: IGrowiPluginOrigin): 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}`;
+    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: 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 GrowiPluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName);
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 

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

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

Dosya farkı çok büyük olduğundan ihmal edildi
+ 448 - 445
yarn.lock


Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor