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

Merge branch 'support/apply-nextjs-2' into support/apply-nextjs-PageComment-integrate

jam411 3 лет назад
Родитель
Сommit
1eab34ec91
77 измененных файлов с 1380 добавлено и 1636 удалено
  1. 2 0
      package.json
  2. 3 3
      packages/app/_obsolete/src/client/admin.jsx
  3. 0 56
      packages/app/bin/generate-plugin-definitions-source.ts
  4. 2 1
      packages/app/jest.config.js
  5. 2 2
      packages/app/package.json
  6. 0 202
      packages/app/src/client/services/AdminUserGroupDetailContainer.js
  7. 0 4
      packages/app/src/client/services/AdminUsersContainer.js
  8. 48 31
      packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx
  9. 0 97
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx
  10. 69 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx
  11. 15 23
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  12. 0 95
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx
  13. 109 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx
  14. 0 129
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  15. 98 0
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  16. 1 1
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  17. 15 1
      packages/app/src/interfaces/user-group-response.ts
  18. 7 0
      packages/app/src/interfaces/user-group.ts
  19. 0 3
      packages/app/src/pages/admin/[[...path]].page.tsx
  20. 6 5
      packages/app/src/server/crowi/index.js
  21. 0 40
      packages/app/src/server/plugins/plugin-utils-v2.js
  22. 0 38
      packages/app/src/server/plugins/plugin-utils-v4.ts
  23. 1 44
      packages/app/src/server/plugins/plugin-utils.js
  24. 0 72
      packages/app/src/server/plugins/plugin.service.js
  25. 0 1
      packages/app/src/server/routes/apiv3/user-group.js
  26. 118 39
      packages/app/src/services/renderer/renderer.tsx
  27. 4 3
      packages/app/src/stores/user-group.tsx
  28. 1 0
      packages/app/tsconfig.build.client.json
  29. 10 0
      packages/app/tsconfig.build.server-tsc-alias.json
  30. 2 1
      packages/app/tsconfig.build.server.json
  31. 1 0
      packages/app/tsconfig.json
  32. 2 3
      packages/core/src/index.ts
  33. 2 0
      packages/core/src/interfaces/user.ts
  34. 4 0
      packages/core/src/plugin/interfaces/option-parser.ts
  35. 0 11
      packages/core/src/plugin/interfaces/plugin-definition-v4.ts
  36. 8 4
      packages/core/src/plugin/model/tag-context.ts
  37. 2 4
      packages/core/src/plugin/util/args-parser.js
  38. 0 88
      packages/core/src/plugin/util/custom-tag-utils.js
  39. 5 0
      packages/core/src/plugin/util/custom-tag-utils.ts
  40. 4 10
      packages/core/src/plugin/util/option-parser.ts
  41. 2 2
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  42. 1 1
      packages/core/src/test/plugin/util/args-parser.test.js
  43. 50 48
      packages/core/src/test/plugin/util/custom-tag-utils.test.js
  44. 1 1
      packages/core/src/test/plugin/util/option-parser.test.js
  45. 1 1
      packages/core/src/test/service/localstorage-manager.test.js
  46. 18 0
      packages/plugin-lsx/.eslintrc.js
  47. 10 2
      packages/plugin-lsx/package.json
  48. 0 12
      packages/plugin-lsx/src/client-entry.js
  49. 0 20
      packages/plugin-lsx/src/client/css/index.css
  50. 0 246
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  51. 0 33
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxLogoutInterceptor.js
  52. 0 58
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  53. 0 68
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js
  54. 0 61
      packages/plugin-lsx/src/client/js/util/LsxContext.js
  55. 0 22
      packages/plugin-lsx/src/client/js/util/TagCacheManagerFactory.js
  56. 26 0
      packages/plugin-lsx/src/components/Lsx.module.scss
  57. 265 0
      packages/plugin-lsx/src/components/Lsx.tsx
  58. 1 1
      packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx
  59. 2 2
      packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx
  60. 0 0
      packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx
  61. 2 0
      packages/plugin-lsx/src/components/PageNode.js
  62. 1 0
      packages/plugin-lsx/src/components/index.ts
  63. 48 0
      packages/plugin-lsx/src/components/lsx-context.ts
  64. 21 0
      packages/plugin-lsx/src/components/tag-cache-manager.ts
  65. 0 11
      packages/plugin-lsx/src/index.js
  66. 6 0
      packages/plugin-lsx/src/index.ts
  67. 0 4
      packages/plugin-lsx/src/server-entry.js
  68. 18 20
      packages/plugin-lsx/src/server/routes/lsx.js
  69. 1 0
      packages/plugin-lsx/src/services/renderer/index.ts
  70. 108 0
      packages/plugin-lsx/src/services/renderer/lsx.ts
  71. 1 0
      packages/plugin-lsx/tsconfig.base.json
  72. 13 12
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js
  73. 12 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.d.ts
  74. 51 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.js
  75. 142 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/readme.md
  76. 28 0
      packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js
  77. 10 0
      yarn.lock

+ 2 - 0
package.json

@@ -48,10 +48,12 @@
     "cross-env": "^7.0.0",
     "cross-env": "^7.0.0",
     "dotenv-flow": "^3.2.0",
     "dotenv-flow": "^3.2.0",
     "npm-run-all": "^4.1.5",
     "npm-run-all": "^4.1.5",
+    "ts-deepmerge": "^3.0.0",
     "tslib": "^2.3.1"
     "tslib": "^2.3.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@testing-library/cypress": "^8.0.2",
     "@testing-library/cypress": "^8.0.2",
+    "@types/css-modules": "^1.0.2",
     "@types/jest": "^26.0.22",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
     "@types/rewire": "^2.5.28",

+ 3 - 3
packages/app/_obsolete/src/client/admin.jsx

@@ -23,7 +23,7 @@ import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityConta
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminSocketIoContainer from '~/client/services/AdminSocketIoContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
+// import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import ContextExtractor from '~/client/services/ContextExtractor';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -67,7 +67,7 @@ const adminExternalAccountsContainer = new AdminExternalAccountsContainer(appCon
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminNotificationContainer = new AdminNotificationContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
 const adminMarkDownContainer = new AdminMarkDownContainer(appContainer);
-const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
+// const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer(appContainer);
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const socketIoContainer = appContainer.getContainer('SocketIoContainer');
 const injectableContainers = [
 const injectableContainers = [
   appContainer,
   appContainer,
@@ -81,7 +81,7 @@ const injectableContainers = [
   adminNotificationContainer,
   adminNotificationContainer,
   adminSlackIntegrationLegacyContainer,
   adminSlackIntegrationLegacyContainer,
   adminMarkDownContainer,
   adminMarkDownContainer,
-  adminUserGroupDetailContainer,
+  // adminUserGroupDetailContainer,
   socketIoContainer,
   socketIoContainer,
 ];
 ];
 
 

+ 0 - 56
packages/app/bin/generate-plugin-definitions-source.ts

@@ -1,56 +0,0 @@
-/**
- * the tool for genetion of plugin definitions source code
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- */
-import fs from 'graceful-fs';
-import normalize from 'normalize-path';
-import swig from 'swig-templates';
-
-import { PluginDefinitionV4 } from '@growi/core';
-
-import PluginUtils from '../src/server/plugins/plugin-utils';
-import loggerFactory from '../src/utils/logger';
-import { resolveFromRoot } from '../src/utils/project-dir-utils';
-
-const logger = loggerFactory('growi:bin:generate-plugin-definitions-source');
-
-
-const pluginUtils = new PluginUtils();
-
-const TEMPLATE = resolveFromRoot('bin/templates/plugin-definitions.js.swig');
-const OUT = resolveFromRoot('tmp/plugins/plugin-definitions.js');
-
-// list plugin names
-const pluginNames: string[] = pluginUtils.listPluginNames();
-logger.info('Detected plugins: ', pluginNames);
-
-async function main(): Promise<void> {
-
-  // get definitions
-  const definitions: PluginDefinitionV4[] = [];
-  for (const pluginName of pluginNames) {
-    // eslint-disable-next-line no-await-in-loop
-    const definition = await pluginUtils.generatePluginDefinition(pluginName, true);
-    if (definition != null) {
-      definitions.push(definition);
-    }
-  }
-
-  definitions.map((definition) => {
-    // convert backslash to slash
-    definition.entries = definition.entries.map((entryPath) => {
-      return normalize(entryPath);
-    });
-    return definition;
-  });
-
-  const compiledTemplate = swig.compileFile(TEMPLATE);
-  const code = compiledTemplate({ definitions });
-
-  // write
-  fs.writeFileSync(OUT, code);
-
-}
-
-main();

+ 2 - 1
packages/app/jest.config.js

@@ -5,7 +5,8 @@
 const MODULE_NAME_MAPPING = {
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
-  '^@growi/(.+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)/(.+)$': '<rootDir>/../$1/src/$2',
 };
 };
 
 
 module.exports = {
 module.exports = {

+ 2 - 2
packages/app/package.json

@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "start": "yarn next start",
     "build:client": "yarn next build",
     "build:client": "yarn next build",
     "prebuild:client": "tsc -p tsconfig.build.next.config.json",
     "prebuild:client": "tsc -p tsconfig.build.next.config.json",
-    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "postbuild:server": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
+    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server-tsc-alias.json",
+    "postbuild:server": "npx -y shx echo \"Listing files under transpiled\" && npx -y shx ls transpiled && npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",

+ 0 - 202
packages/app/src/client/services/AdminUserGroupDetailContainer.js

@@ -1,202 +0,0 @@
-/*
- * TODO 85062: AdminUserGroupDetailContainer is under transplantation to UserGroupDetailPage.tsx
- */
-
-import { isServer } from '@growi/core';
-import { Container } from 'unstated';
-
-import {
-  apiv3Get, apiv3Delete, apiv3Put, apiv3Post,
-} from '~/client/util/apiv3-client';
-import loggerFactory from '~/utils/logger';
-
-import { toastError } from '../util/apiNotification';
-
-
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
-
-/**
- * Service container for admin user group detail page (UserGroupDetailPage.jsx)
- * @extends {Container} unstated Container
- */
-export default class AdminUserGroupDetailContainer extends Container {
-
-  constructor(appContainer) {
-    super();
-
-    if (isServer()) {
-      return;
-    }
-
-    this.appContainer = appContainer;
-
-    const rootElem = document.getElementById('admin-user-group-detail');
-
-    if (rootElem == null) {
-      return;
-    }
-
-    this.state = {
-      // TODO: [SPA] get userGroup from props
-      userGroup: JSON.parse(rootElem.getAttribute('data-user-group')),
-      userGroupRelations: [], // For user list
-
-      // TODO 85062: /_api/v3/user-groups/children?include_grand_child=boolean
-      childUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-      grandChildUserGroups: [], // TODO 85062: fetch data on init (findChildGroupsByParentIds) For child group list
-
-      childUserGroupRelations: [], // TODO 85062: fetch data on init (findRelationsByGroupIds) For child group list users
-      relatedPages: [], // For page list
-      isUserGroupUserModalOpen: false,
-      searchType: 'partial',
-      isAlsoMailSearched: false,
-      isAlsoNameSearched: false,
-    };
-
-    this.init();
-
-    this.switchIsAlsoMailSearched = this.switchIsAlsoMailSearched.bind(this);
-    this.switchIsAlsoNameSearched = this.switchIsAlsoNameSearched.bind(this);
-    this.openUserGroupUserModal = this.openUserGroupUserModal.bind(this);
-    this.closeUserGroupUserModal = this.closeUserGroupUserModal.bind(this);
-    this.addUserByUsername = this.addUserByUsername.bind(this);
-    this.removeUserByUsername = this.removeUserByUsername.bind(this);
-  }
-
-  /**
-   * Workaround for the mangling in production build to break constructor.name
-   */
-  static getClassName() {
-    return 'AdminUserGroupDetailContainer';
-  }
-
-  /**
-   * retrieve user group data
-   */
-  async init() {
-    try {
-      const [
-        userGroupRelations,
-        relatedPages,
-      ] = await Promise.all([
-        apiv3Get(`/user-groups/${this.state.userGroup._id}/user-group-relations`).then((res) => { return res.data.userGroupRelations }),
-        apiv3Get(`/user-groups/${this.state.userGroup._id}/pages`).then((res) => { return res.data.pages }),
-      ]);
-
-      await this.setState({
-        userGroupRelations,
-        relatedPages,
-      });
-    }
-    catch (err) {
-      logger.error(err);
-      toastError(new Error('Failed to fetch data'));
-    }
-  }
-
-  /**
-   * switch isAlsoMailSearched
-   */
-  switchIsAlsoMailSearched() {
-    this.setState({ isAlsoMailSearched: !this.state.isAlsoMailSearched });
-  }
-
-  /**
-   * switch isAlsoNameSearched
-   */
-  switchIsAlsoNameSearched() {
-    this.setState({ isAlsoNameSearched: !this.state.isAlsoNameSearched });
-  }
-
-  /**
-   * switch searchType
-   */
-  switchSearchType(searchType) {
-    this.setState({ searchType });
-  }
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {object} param update param for user group
-   * @return {object} response object
-   */
-  async updateUserGroup(param) {
-    const res = await apiv3Put(`/user-groups/${this.state.userGroup._id}`, param);
-    const { userGroup } = res.data;
-
-    await this.setState({ userGroup });
-
-    return res;
-  }
-
-  /**
-   * open a modal
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   */
-  async openUserGroupUserModal() {
-    await this.setState({ isUserGroupUserModalOpen: true });
-  }
-
-  /**
-   * close a modal
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   */
-  async closeUserGroupUserModal() {
-    await this.setState({ isUserGroupUserModalOpen: false });
-  }
-
-  /**
-   * search user for invitation
-   * @param {string} username username of the user to be searched
-   */
-  async fetchApplicableUsers(searchWord) {
-    const res = await apiv3Get(`/user-groups/${this.state.userGroup._id}/unrelated-users`, {
-      searchWord,
-      searchType: this.state.searchType,
-      isAlsoMailSearched: this.state.isAlsoMailSearched,
-      isAlsoNameSearched: this.state.isAlsoNameSearched,
-    });
-
-    const { users } = res.data;
-
-    return users;
-  }
-
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {string} username username of the user to be added to the group
-   */
-  async addUserByUsername(username) {
-    const res = await apiv3Post(`/user-groups/${this.state.userGroup._id}/users/${username}`);
-
-    // do not add users for ducaplicate
-    if (res.data.userGroupRelation == null) { return }
-
-    this.init();
-  }
-
-  /**
-   * update user group
-   *
-   * @memberOf AdminUserGroupDetailContainer
-   * @param {string} username username of the user to be removed from the group
-   */
-  async removeUserByUsername(username) {
-    const res = await apiv3Delete(`/user-groups/${this.state.userGroup._id}/users/${username}`);
-
-    this.setState((prevState) => {
-      return {
-        userGroupRelations: prevState.userGroupRelations.filter((u) => { return u._id !== res.data.userGroupRelation._id }),
-      };
-    });
-  }
-
-}

+ 0 - 4
packages/app/src/client/services/AdminUsersContainer.js

@@ -2,14 +2,10 @@ import { isServer } from '@growi/core';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 import { Container } from 'unstated';
 import { Container } from 'unstated';
 
 
-import loggerFactory from '~/utils/logger';
-
 import {
 import {
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
   apiv3Delete, apiv3Get, apiv3Post, apiv3Put,
 } from '../util/apiv3-client';
 } from '../util/apiv3-client';
 
 
-// eslint-disable-next-line no-unused-vars
-const logger = loggerFactory('growi:services:AdminUserGroupDetailContainer');
 
 
 /**
 /**
  * Service container for admin users page (Users.jsx)
  * Service container for admin users page (Users.jsx)

+ 48 - 31
packages/app/src/components/Admin/UserGroupDetail/UserGroupDetailPage.tsx

@@ -1,7 +1,8 @@
 import React, {
 import React, {
-  FC, useState, useCallback, useEffect,
+  useState, useCallback, useEffect, useMemo,
 } from 'react';
 } from 'react';
 
 
+import { objectIdUtils } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -10,8 +11,9 @@ import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import {
 import {
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
   apiv3Get, apiv3Put, apiv3Delete, apiv3Post,
 } from '~/client/util/apiv3-client';
 } from '~/client/util/apiv3-client';
-import { IPageHasId } from '~/interfaces/page';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
 import { IUserGroup, IUserGroupHasId } from '~/interfaces/user';
+import { SearchTypes, SearchType } from '~/interfaces/user-group';
+import Xss from '~/services/xss';
 import { useIsAclEnabled } from '~/stores/context';
 import { useIsAclEnabled } from '~/stores/context';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import { useUpdateUserGroupConfirmModal } from '~/stores/modal';
 import {
 import {
@@ -19,7 +21,11 @@ import {
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
   useSWRxSelectableParentUserGroups, useSWRxSelectableChildUserGroups, useSWRxAncestorUserGroups,
 } from '~/stores/user-group';
 } from '~/stores/user-group';
 
 
-import { isValidObjectId } from '../../../../../core/src/utils/objectid-utils';
+
+const UserGroupPageList = dynamic(() => import('./UserGroupPageList'), { ssr: false });
+const UserGroupUserTable = dynamic(() => import('./UserGroupUserTable'), { ssr: false });
+
+const UserGroupUserModal = dynamic(() => import('./UserGroupUserModal'), { ssr: false });
 
 
 const UserGroupDeleteModal = dynamic(() => import('../UserGroup/UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDeleteModal = dynamic(() => import('../UserGroup/UserGroupDeleteModal').then(mod => mod.UserGroupDeleteModal), { ssr: false });
 const UserGroupDropdown = dynamic(() => import('../UserGroup/UserGroupDropdown').then(mod => mod.UserGroupDropdown), { ssr: false });
 const UserGroupDropdown = dynamic(() => import('../UserGroup/UserGroupDropdown').then(mod => mod.UserGroupDropdown), { ssr: false });
@@ -27,38 +33,33 @@ const UserGroupForm = dynamic(() => import('../UserGroup/UserGroupForm').then(mo
 const UserGroupModal = dynamic(() => import('../UserGroup/UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupModal = dynamic(() => import('../UserGroup/UserGroupModal').then(mod => mod.UserGroupModal), { ssr: false });
 const UserGroupTable = dynamic(() => import('../UserGroup/UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
 const UserGroupTable = dynamic(() => import('../UserGroup/UserGroupTable').then(mod => mod.UserGroupTable), { ssr: false });
 const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModal').then(mod => mod.UpdateParentConfirmModal), { ssr: false });
 const UpdateParentConfirmModal = dynamic(() => import('./UpdateParentConfirmModal').then(mod => mod.UpdateParentConfirmModal), { ssr: false });
-// import UserGroupPageList from './UserGroupPageList';
-// import UserGroupUserModal from './UserGroupUserModal';
-// import UserGroupUserTable from './UserGroupUserTable';
 
 
 
 
 type Props = {
 type Props = {
   userGroupId?: string,
   userGroupId?: string,
 }
 }
 
 
-const UserGroupDetailPage = (props: Props) => {
+const UserGroupDetailPage = (props: Props): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const router = useRouter();
   const router = useRouter();
+  const xss = useMemo(() => new Xss(), []);
   const { userGroupId: currentUserGroupId } = props;
   const { userGroupId: currentUserGroupId } = props;
 
 
-  /*
-   * State (from AdminUserGroupDetailContainer)
-   */
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
   const { data: currentUserGroup } = useSWRxUserGroup(currentUserGroupId);
-  const [relatedPages, setRelatedPages] = useState<IPageHasId[]>([]); // For page list
-  const [searchType, setSearchType] = useState<string>('partial');
+  const [searchType, setSearchType] = useState<SearchType>(SearchTypes.PARTIAL);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoMailSearched, setAlsoMailSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [isAlsoNameSearched, setAlsoNameSearched] = useState<boolean>(false);
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [selectedUserGroup, setSelectedUserGroup] = useState<IUserGroupHasId | undefined>(undefined); // not null but undefined (to use defaultProps in UserGroupDeleteModal)
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isCreateModalShown, setCreateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isUpdateModalShown, setUpdateModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
   const [isDeleteModalShown, setDeleteModalShown] = useState<boolean>(false);
+  const [isUserGroupUserModalShown, setIsUserGroupUserModalShown] = useState<boolean>(false);
 
 
   const isLoading = currentUserGroup === undefined;
   const isLoading = currentUserGroup === undefined;
   const notExistsUerGroup = !isLoading && currentUserGroup == null;
   const notExistsUerGroup = !isLoading && currentUserGroup == null;
 
 
   useEffect(() => {
   useEffect(() => {
-    if (!isValidObjectId(currentUserGroupId) || notExistsUerGroup) {
+    if (!objectIdUtils.isValidObjectId(currentUserGroupId) || notExistsUerGroup) {
       router.push('/admin/user-groups');
       router.push('/admin/user-groups');
     }
     }
   }, [currentUserGroup, currentUserGroupId, notExistsUerGroup, router]);
   }, [currentUserGroup, currentUserGroupId, notExistsUerGroup, router]);
@@ -87,21 +88,18 @@ const UserGroupDetailPage = (props: Props) => {
 
 
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
   const { open: openUpdateParentConfirmModal } = useUpdateUserGroupConfirmModal();
 
 
-
   /*
   /*
    * Function
    * Function
    */
    */
-  // TODO 85062: old name: switchIsAlsoMailSearched
   const toggleIsAlsoMailSearched = useCallback(() => {
   const toggleIsAlsoMailSearched = useCallback(() => {
     setAlsoMailSearched(prev => !prev);
     setAlsoMailSearched(prev => !prev);
   }, []);
   }, []);
 
 
-  // TODO 85062: old name: switchIsAlsoNameSearched
   const toggleAlsoNameSearched = useCallback(() => {
   const toggleAlsoNameSearched = useCallback(() => {
     setAlsoNameSearched(prev => !prev);
     setAlsoNameSearched(prev => !prev);
   }, []);
   }, []);
 
 
-  const switchSearchType = useCallback((searchType) => {
+  const switchSearchType = useCallback((searchType: SearchType) => {
     setSearchType(searchType);
     setSearchType(searchType);
   }, []);
   }, []);
 
 
@@ -161,7 +159,7 @@ const UserGroupDetailPage = (props: Props) => {
     }
     }
   }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
   }, [t, openUpdateParentConfirmModal, onSubmitUpdateGroup]);
 
 
-  const fetchApplicableUsers = useCallback(async(searchWord) => {
+  const fetchApplicableUsers = useCallback(async(searchWord: string) => {
     const res = await apiv3Get(`/user-groups/${currentUserGroupId}/unrelated-users`, {
     const res = await apiv3Get(`/user-groups/${currentUserGroupId}/unrelated-users`, {
       searchWord,
       searchWord,
       searchType,
       searchType,
@@ -174,16 +172,23 @@ const UserGroupDetailPage = (props: Props) => {
     return users;
     return users;
   }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
   }, [currentUserGroupId, searchType, isAlsoMailSearched, isAlsoNameSearched]);
 
 
-  // TODO 85062: will be used in UserGroupUserFormByInput
   const addUserByUsername = useCallback(async(username: string) => {
   const addUserByUsername = useCallback(async(username: string) => {
     await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
     await apiv3Post(`/user-groups/${currentUserGroupId}/users/${username}`);
+    setIsUserGroupUserModalShown(false);
     mutateUserGroupRelations();
     mutateUserGroupRelations();
   }, [currentUserGroupId, mutateUserGroupRelations]);
   }, [currentUserGroupId, mutateUserGroupRelations]);
 
 
+  // Fix: invalid csrf token => https://redmine.weseek.co.jp/issues/102704
   const removeUserByUsername = useCallback(async(username: string) => {
   const removeUserByUsername = useCallback(async(username: string) => {
-    await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
-    mutateUserGroupRelations();
-  }, [currentUserGroupId, mutateUserGroupRelations]);
+    try {
+      await apiv3Delete(`/user-groups/${currentUserGroupId}/users/${username}`);
+      toastSuccess(`Removed "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`);
+      mutateUserGroupRelations();
+    }
+    catch (err) {
+      toastError(new Error(`Unable to remove "${xss.process(username)}" from "${xss.process(currentUserGroup?.name)}"`));
+    }
+  }, [currentUserGroup?.name, currentUserGroupId, mutateUserGroupRelations, xss]);
 
 
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
   const showUpdateModal = useCallback((group: IUserGroupHasId) => {
     setUpdateModalShown(true);
     setUpdateModalShown(true);
@@ -309,7 +314,7 @@ const UserGroupDetailPage = (props: Props) => {
   /*
   /*
    * Dependencies
    * Dependencies
    */
    */
-  if (currentUserGroup == null) {
+  if (currentUserGroup == null || currentUserGroupId == null) {
     return <></>;
     return <></>;
   }
   }
 
 
@@ -344,11 +349,25 @@ const UserGroupDetailPage = (props: Props) => {
         />
         />
       </div>
       </div>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.user_list')}</h2>
-      {/* These compoents will be successfully shown in https://redmine.weseek.co.jp/issues/102159 */}
-      {/* <UserGroupUserTable /> */}
-      UserGroupUserTable
-      {/* <UserGroupUserModal /> */}
-      UserGroupUserModal
+      <UserGroupUserTable
+        userGroup={currentUserGroup}
+        userGroupRelations={childUserGroupRelations}
+        onClickPlusBtn={() => setIsUserGroupUserModalShown(true)}
+        onClickRemoveUserBtn={removeUserByUsername}
+      />
+      <UserGroupUserModal
+        isOpen={isUserGroupUserModalShown}
+        userGroup={currentUserGroup}
+        searchType={searchType}
+        isAlsoMailSearched={isAlsoMailSearched}
+        isAlsoNameSearched={isAlsoNameSearched}
+        onClickAddUserBtn={addUserByUsername}
+        onSearchApplicableUsers={fetchApplicableUsers}
+        onSwitchSearchType={switchSearchType}
+        onClose={() => setIsUserGroupUserModalShown(false)}
+        onToggleIsAlsoMailSearched={toggleIsAlsoMailSearched}
+        onToggleIsAlsoNameSearched={toggleAlsoNameSearched}
+      />
 
 
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <h2 className="admin-setting-header mt-4">{t('admin:user_group_management.child_group_list')}</h2>
       <UserGroupDropdown
       <UserGroupDropdown
@@ -394,9 +413,7 @@ const UserGroupDetailPage = (props: Props) => {
 
 
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <h2 className="admin-setting-header mt-4">{t('Page')}</h2>
       <div className="page-list">
       <div className="page-list">
-        {/* This compoent will be successfully shown in https://redmine.weseek.co.jp/issues/102159 */}
-        {/* <UserGroupPageList /> */}
-        UserGroupPageList
+        <UserGroupPageList userGroupId={currentUserGroupId} relatedPages={userGroupPages} />
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 0 - 97
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.jsx

@@ -1,97 +0,0 @@
-import React, { Fragment } from 'react';
-
-import PropTypes from 'prop-types';
-import { useTranslation } from 'next-i18next';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import AppContainer from '~/client/services/AppContainer';
-import { toastError } from '~/client/util/apiNotification';
-import { apiv3Get } from '~/client/util/apiv3-client';
-
-import PageListItemS from '../../PageList/PageListItemS';
-import PaginationWrapper from '../../PaginationWrapper';
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class UserGroupPageList extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      currentPages: [],
-      activePage: 1,
-      total: 0,
-      pagingLimit: 10,
-    };
-
-    this.handlePageChange = this.handlePageChange.bind(this);
-  }
-
-  async componentDidMount() {
-    await this.handlePageChange(this.state.activePage);
-  }
-
-  async handlePageChange(pageNum) {
-    const limit = this.state.pagingLimit;
-    const offset = (pageNum - 1) * limit;
-
-    try {
-      const res = await apiv3Get(`/user-groups/${this.props.adminUserGroupDetailContainer.state.userGroup._id}/pages`, {
-        limit,
-        offset,
-      });
-      const { total, pages } = res.data;
-
-      this.setState({
-        total,
-        activePage: pageNum,
-        currentPages: pages,
-      });
-    }
-    catch (err) {
-      toastError(err);
-    }
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-    const { relatedPages } = adminUserGroupDetailContainer.state;
-
-    return (
-      <Fragment>
-        <ul className="page-list-ul page-list-ul-flat mb-3">
-          {this.state.currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
-        </ul>
-        {relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
-          <PaginationWrapper
-            activePage={this.state.activePage}
-            changePage={this.handlePageChange}
-            totalItemsCount={this.state.total}
-            pagingLimit={this.state.pagingLimit}
-            align="center"
-            size="sm"
-          />
-        )}
-      </Fragment>
-    );
-  }
-
-}
-
-UserGroupPageList.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupPageListWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupPageList t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupPageListWrapper = withUnstatedContainers(UserGroupPageListWrapperFC, [AppContainer, AdminUserGroupDetailContainer]);
-
-export default UserGroupPageListWrapper;

+ 69 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupPageList.tsx

@@ -0,0 +1,69 @@
+import React, { useEffect, useState, useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { toastError } from '~/client/util/apiNotification';
+import { apiv3Get } from '~/client/util/apiv3-client';
+import { IPageHasId } from '~/interfaces/page';
+
+import PageListItemS from '../../PageList/PageListItemS';
+import PaginationWrapper from '../../PaginationWrapper';
+
+const pagingLimit = 10;
+
+type Props = {
+  userGroupId: string,
+  relatedPages?: IPageHasId[],
+}
+
+const UserGroupPageList = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const { userGroupId, relatedPages } = props;
+
+  const [currentPages, setCurrentPages] = useState<IPageHasId[]>([]);
+  const [activePage, setActivePage] = useState(1);
+  const [total, setTotal] = useState(0);
+
+  const handlePageChange = useCallback(async(pageNum) => {
+    const offset = (pageNum - 1) * pagingLimit;
+
+    try {
+      const res = await apiv3Get(`/user-groups/${userGroupId}/pages`, {
+        limit: pagingLimit,
+        offset,
+      });
+      const { total, pages } = res.data;
+
+      setTotal(total);
+      setActivePage(pageNum);
+      setCurrentPages(pages);
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [userGroupId]);
+
+  useEffect(() => {
+    handlePageChange(activePage);
+  }, [activePage, handlePageChange]);
+
+  return (
+    <>
+      <ul className="page-list-ul page-list-ul-flat mb-3">
+        {currentPages.map(page => <li key={page._id}><PageListItemS page={page} /></li>)}
+      </ul>
+      {relatedPages != null && relatedPages.length === 0 ? <p>{t('admin:user_group_management.no_pages')}</p> : (
+        <PaginationWrapper
+          activePage={activePage}
+          changePage={handlePageChange}
+          totalItemsCount={total}
+          pagingLimit={pagingLimit}
+          align="center"
+          size="sm"
+        />
+      )}
+    </>
+  );
+};
+
+export default UserGroupPageList;

+ 15 - 23
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -6,12 +6,9 @@ import PropTypes from 'prop-types';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { debounce } from 'throttle-debounce';
 import { debounce } from 'throttle-debounce';
 
 
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
 import Xss from '~/services/xss';
 import Xss from '~/services/xss';
 
 
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
 class UserGroupUserFormByInput extends React.Component {
 class UserGroupUserFormByInput extends React.Component {
 
 
   constructor(props) {
   constructor(props) {
@@ -38,16 +35,13 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   async addUserBySubmit() {
   async addUserBySubmit() {
-    const { adminUserGroupDetailContainer } = this.props;
-    const { userGroup } = adminUserGroupDetailContainer.state;
+    const { userGroup, onClickAddUserBtn } = this.props;
 
 
     if (this.state.inputUser.length === 0) { return }
     if (this.state.inputUser.length === 0) { return }
     const userName = this.state.inputUser[0].username;
     const userName = this.state.inputUser[0].username;
 
 
     try {
     try {
-      await adminUserGroupDetailContainer.addUserByUsername(userName);
-      await adminUserGroupDetailContainer.init();
-      await adminUserGroupDetailContainer.closeUserGroupUserModal();
+      await onClickAddUserBtn(userName);
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       toastSuccess(`Added "${this.xss.process(userName)}" to "${this.xss.process(userGroup.name)}"`);
       this.setState({ inputUser: '' });
       this.setState({ inputUser: '' });
     }
     }
@@ -63,10 +57,10 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   async searhApplicableUsers() {
   async searhApplicableUsers() {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { onSearchApplicableUsers } = this.props;
 
 
     try {
     try {
-      const users = await adminUserGroupDetailContainer.fetchApplicableUsers(this.state.keyword);
+      const users = await onSearchApplicableUsers(this.state.keyword);
       this.setState({ applicableUsers: users, isLoading: false });
       this.setState({ applicableUsers: users, isLoading: false });
     }
     }
     catch (err) {
     catch (err) {
@@ -83,7 +77,6 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   handleSearch(keyword) {
   handleSearch(keyword) {
-
     if (keyword === '') {
     if (keyword === '') {
       return;
       return;
     }
     }
@@ -100,15 +93,15 @@ class UserGroupUserFormByInput extends React.Component {
   }
   }
 
 
   renderMenuItemChildren(option) {
   renderMenuItemChildren(option) {
-    const { adminUserGroupDetailContainer } = this.props;
+    const { isAlsoNameSearched, isAlsoMailSearched } = this.props;
     const user = option;
     const user = option;
     return (
     return (
-      <React.Fragment>
+      <>
         <UserPicture user={user} size="sm" noLink noTooltip />
         <UserPicture user={user} size="sm" noLink noTooltip />
         <strong className="ml-2">{user.username}</strong>
         <strong className="ml-2">{user.username}</strong>
-        {adminUserGroupDetailContainer.state.isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
-        {adminUserGroupDetailContainer.state.isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
-      </React.Fragment>
+        {isAlsoNameSearched && <span className="ml-2">{user.name}</span>}
+        {isAlsoMailSearched && <span className="ml-2">{user.email}</span>}
+      </>
     );
     );
   }
   }
 
 
@@ -161,7 +154,11 @@ class UserGroupUserFormByInput extends React.Component {
 
 
 UserGroupUserFormByInput.propTypes = {
 UserGroupUserFormByInput.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   t: PropTypes.func.isRequired, // i18next
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
+  isAlsoMailSearched: PropTypes.bool.isRequired,
+  isAlsoNameSearched: PropTypes.bool.isRequired,
+  onClickAddUserBtn: PropTypes.func,
+  onSearchApplicableUsers: PropTypes.func,
+  userGroup: PropTypes.object,
 };
 };
 
 
 const UserGroupUserFormByInputWrapperFC = (props) => {
 const UserGroupUserFormByInputWrapperFC = (props) => {
@@ -169,9 +166,4 @@ const UserGroupUserFormByInputWrapperFC = (props) => {
   return <UserGroupUserFormByInput t={t} {...props} />;
   return <UserGroupUserFormByInput t={t} {...props} />;
 };
 };
 
 
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserFormByInputWrapper = withUnstatedContainers(UserGroupUserFormByInputWrapperFC, [AdminUserGroupDetailContainer]);
-
-export default UserGroupUserFormByInputWrapper;
+export default UserGroupUserFormByInputWrapperFC;

+ 0 - 95
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.jsx

@@ -1,95 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-import {
-  Modal, ModalHeader, ModalBody,
-} from 'reactstrap';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
-import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
-import UserGroupUserFormByInput from './UserGroupUserFormByInput';
-
-class UserGroupUserModal extends React.Component {
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <Modal isOpen={adminUserGroupDetailContainer.state.isUserGroupUserModalOpen} toggle={adminUserGroupDetailContainer.closeUserGroupUserModal}>
-        <ModalHeader tag="h4" toggle={adminUserGroupDetailContainer.closeUserGroupUserModal} className="bg-info text-light">
-          {t('admin:user_group_management.add_modal.add_user') }
-        </ModalHeader>
-        <ModalBody>
-          <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
-          <div className="p-3">
-            <UserGroupUserFormByInput />
-          </div>
-          <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
-          <div className="row mt-4">
-            <div className="col-6">
-              <div className="mb-5">
-                <CheckBoxForSerchUserOption
-                  option="mail"
-                  checked={adminUserGroupDetailContainer.state.isAlsoMailSearched}
-                  onChange={adminUserGroupDetailContainer.switchIsAlsoMailSearched}
-                />
-              </div>
-              <div className="mb-5">
-                <CheckBoxForSerchUserOption
-                  option="name"
-                  checked={adminUserGroupDetailContainer.state.isAlsoNameSearched}
-                  onChange={adminUserGroupDetailContainer.switchIsAlsoNameSearched}
-                />
-              </div>
-            </div>
-            <div className="col-6">
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="forward"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'forward'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('forward') }}
-                />
-              </div>
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="partial"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'partial'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('partial') }}
-                />
-              </div>
-              <div className="mb-5">
-                <RadioButtonForSerchUserOption
-                  searchType="backward"
-                  checked={adminUserGroupDetailContainer.state.searchType === 'backword'}
-                  onChange={() => { adminUserGroupDetailContainer.switchSearchType('backword') }}
-                />
-              </div>
-            </div>
-          </div>
-        </ModalBody>
-      </Modal>
-    );
-  }
-
-}
-
-UserGroupUserModal.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupUserModalWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserModal t={t} {...props} />;
-};
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserModalWrapper = withUnstatedContainers(UserGroupUserModalWrapperFC, [AdminUserGroupDetailContainer]);
-
-export default UserGroupUserModalWrapper;

+ 109 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserModal.tsx

@@ -0,0 +1,109 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+import {
+  Modal, ModalHeader, ModalBody,
+} from 'reactstrap';
+
+import { IUserGroupHasId } from '~/interfaces/user';
+import { SearchTypes, SearchType } from '~/interfaces/user-group';
+
+import CheckBoxForSerchUserOption from './CheckBoxForSerchUserOption';
+import RadioButtonForSerchUserOption from './RadioButtonForSerchUserOption';
+import UserGroupUserFormByInput from './UserGroupUserFormByInput';
+
+type Props = {
+  isOpen: boolean,
+  userGroup: IUserGroupHasId,
+  searchType: SearchType,
+  isAlsoMailSearched: boolean,
+  isAlsoNameSearched: boolean,
+  onClickAddUserBtn: (username: string) => Promise<void>,
+  onSearchApplicableUsers: (searchWord: string) => Promise<void>,
+  onSwitchSearchType: (searchType: SearchType) => void
+  onClose: () => void,
+  onToggleIsAlsoMailSearched: () => void,
+  onToggleIsAlsoNameSearched: () => void,
+}
+
+const UserGroupUserModal = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+  const {
+    isOpen,
+    userGroup,
+    searchType,
+    onClickAddUserBtn,
+    onSearchApplicableUsers,
+    onSwitchSearchType,
+    onClose,
+    isAlsoMailSearched,
+    isAlsoNameSearched,
+    onToggleIsAlsoMailSearched,
+    onToggleIsAlsoNameSearched,
+  } = props;
+
+  return (
+    <Modal isOpen={isOpen} toggle={onClose}>
+      <ModalHeader tag="h4" toggle={onClose} className="bg-info text-light">
+        {t('admin:user_group_management.add_modal.add_user') }
+      </ModalHeader>
+      <ModalBody>
+        <p className="card well">{t('admin:user_group_management.add_modal.description')}</p>
+        <div className="p-3">
+          <UserGroupUserFormByInput
+            userGroup={userGroup}
+            onClickAddUserBtn={onClickAddUserBtn}
+            onSearchApplicableUsers={onSearchApplicableUsers}
+            onClose={onClose}
+            isAlsoNameSearched={isAlsoNameSearched}
+            isAlsoMailSearched={isAlsoMailSearched}
+          />
+        </div>
+        <h2 className="border-bottom">{t('admin:user_group_management.add_modal.search_option')}</h2>
+        <div className="row mt-4">
+          <div className="col-6">
+            <div className="mb-5">
+              <CheckBoxForSerchUserOption
+                option="mail"
+                checked={isAlsoMailSearched}
+                onChange={onToggleIsAlsoMailSearched}
+              />
+            </div>
+            <div className="mb-5">
+              <CheckBoxForSerchUserOption
+                option="name"
+                checked={isAlsoNameSearched}
+                onChange={onToggleIsAlsoNameSearched}
+              />
+            </div>
+          </div>
+          <div className="col-6">
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="forward"
+                checked={searchType === SearchTypes.FORWARD}
+                onChange={() => onSwitchSearchType(SearchTypes.FORWARD)}
+              />
+            </div>
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="partial"
+                checked={searchType === SearchTypes.PARTIAL}
+                onChange={() => onSwitchSearchType(SearchTypes.PARTIAL)}
+              />
+            </div>
+            <div className="mb-5">
+              <RadioButtonForSerchUserOption
+                searchType="backward"
+                checked={searchType === SearchTypes.BACKWORD}
+                onChange={() => onSwitchSearchType(SearchTypes.BACKWORD)}
+              />
+            </div>
+          </div>
+        </div>
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default UserGroupUserModal;

+ 0 - 129
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -1,129 +0,0 @@
-import React from 'react';
-
-import { UserPicture } from '@growi/ui';
-import dateFnsFormat from 'date-fns/format';
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
-import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import Xss from '~/services/xss';
-
-import { withUnstatedContainers } from '../../UnstatedUtils';
-
-class UserGroupUserTable extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.xss = new Xss();
-
-    this.removeUser = this.removeUser.bind(this);
-  }
-
-  async removeUser(username) {
-    try {
-      await this.props.adminUserGroupDetailContainer.removeUserByUsername(username);
-      toastSuccess(`Removed "${this.xss.process(username)}" from "${this.xss.process(this.props.adminUserGroupDetailContainer.state.userGroup.name)}"`);
-    }
-    catch (err) {
-      // eslint-disable-next-line max-len
-      toastError(new Error(`Unable to remove "${this.xss.process(username)}" from "${this.xss.process(this.props.adminUserGroupDetailContainer.state.userGroup.name)}"`));
-    }
-  }
-
-  render() {
-    const { t, adminUserGroupDetailContainer } = this.props;
-
-    return (
-      <table className="table table-bordered table-user-list">
-        <thead>
-          <tr>
-            <th width="100px">#</th>
-            <th>
-              {t('username')}
-            </th>
-            <th>{t('Name')}</th>
-            <th width="100px">{t('Created')}</th>
-            <th width="160px">{t('Last_Login')}</th>
-            <th width="70px"></th>
-          </tr>
-        </thead>
-        <tbody>
-          {adminUserGroupDetailContainer.state.userGroupRelations.map((sRelation) => {
-            const { relatedUser } = sRelation;
-
-            return (
-              <tr key={sRelation._id}>
-                <td>
-                  <UserPicture user={relatedUser} className="picture rounded-circle" />
-                </td>
-                <td>
-                  <strong>{relatedUser.username}</strong>
-                </td>
-                <td>{relatedUser.name}</td>
-                <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
-                <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
-                <td>
-                  <div className="btn-group admin-user-menu">
-                    <button
-                      type="button"
-                      id={`admin-group-menu-button-${relatedUser._id}`}
-                      className="btn btn-outline-secondary btn-sm dropdown-toggle"
-                      data-toggle="dropdown"
-                    >
-                      <i className="icon-settings"></i>
-                    </button>
-                    <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
-                      <button
-                        className="dropdown-item"
-                        type="button"
-                        onClick={() => {
-                          return this.removeUser(relatedUser.username);
-                        }}
-                      >
-                        <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
-                      </button>
-                    </div>
-                  </div>
-                </td>
-              </tr>
-            );
-          })}
-
-          <tr>
-            <td></td>
-            <td className="text-center">
-              <button className="btn btn-outline-secondary" type="button" onClick={adminUserGroupDetailContainer.openUserGroupUserModal}>
-                <i className="ti ti-plus"></i>
-              </button>
-            </td>
-            <td></td>
-            <td></td>
-            <td></td>
-            <td></td>
-          </tr>
-
-        </tbody>
-      </table>
-    );
-  }
-
-}
-
-UserGroupUserTable.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-  adminUserGroupDetailContainer: PropTypes.instanceOf(AdminUserGroupDetailContainer).isRequired,
-};
-
-const UserGroupUserTableWrapperFC = (props) => {
-  const { t } = useTranslation();
-  return <UserGroupUserTable t={t} {...props} />;
-};
-
-/**
- * Wrapper component for using unstated
- */
-const UserGroupUserTableWrapper = withUnstatedContainers(UserGroupUserTableWrapperFC, [AdminUserGroupDetailContainer]);
-
-export default UserGroupUserTableWrapper;

+ 98 - 0
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -0,0 +1,98 @@
+import React from 'react';
+
+import { UserPicture } from '@growi/ui';
+import dateFnsFormat from 'date-fns/format';
+import { useTranslation } from 'next-i18next';
+
+import { IUserGroupHasId, IUserGroupRelation } from '~/interfaces/user';
+import { useSWRxUserGroupRelations } from '~/stores/user-group';
+
+type Props = {
+  userGroupRelations: IUserGroupRelation[],
+  userGroup: IUserGroupHasId,
+  onClickRemoveUserBtn: (username: string) => Promise<void>,
+  onClickPlusBtn: () => void,
+}
+
+export const UserGroupUserTable = (props: Props): JSX.Element => {
+  const { t } = useTranslation();
+
+  const {
+    userGroup, onClickRemoveUserBtn, onClickPlusBtn,
+  } = props;
+  const { data: userGroupRelations } = useSWRxUserGroupRelations(userGroup._id);
+
+
+  return (
+    <table className="table table-bordered table-user-list">
+      <thead>
+        <tr>
+          <th style={{ width: '100px' }}>#</th>
+          <th>
+            {t('username')}
+          </th>
+          <th>{t('Name')}</th>
+          <th style={{ width: '100px' }}>{t('Created')}</th>
+          <th style={{ width: '160px' }}>{t('Last_Login')}</th>
+          <th style={{ width: '70px' }}></th>
+        </tr>
+      </thead>
+      <tbody>
+        {userGroupRelations != null && userGroupRelations.map((relation) => {
+          const { relatedUser } = relation;
+
+          return (
+            <tr key={relation._id}>
+              <td>
+                <UserPicture user={relatedUser} className="picture rounded-circle" />
+              </td>
+              <td>
+                <strong>{relatedUser.username}</strong>
+              </td>
+              <td>{relatedUser.name}</td>
+              <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
+              <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
+              <td>
+                <div className="btn-group admin-user-menu">
+                  <button
+                    type="button"
+                    id={`admin-group-menu-button-${relatedUser._id}`}
+                    className="btn btn-outline-secondary btn-sm dropdown-toggle"
+                    data-toggle="dropdown"
+                  >
+                    <i className="icon-settings"></i>
+                  </button>
+                  <div className="dropdown-menu" role="menu" aria-labelledby={`admin-group-menu-button-${relatedUser._id}`}>
+                    <button
+                      className="dropdown-item"
+                      type="button"
+                      onClick={() => onClickRemoveUserBtn(relatedUser.username)}
+                    >
+                      <i className="icon-fw icon-user-unfollow"></i> {t('admin:user_group_management.remove_from_group')}
+                    </button>
+                  </div>
+                </div>
+              </td>
+            </tr>
+          );
+        })}
+
+        <tr>
+          <td></td>
+          <td className="text-center">
+            <button className="btn btn-outline-secondary" type="button" onClick={onClickPlusBtn}>
+              <i className="ti ti-plus"></i>
+            </button>
+          </td>
+          <td></td>
+          <td></td>
+          <td></td>
+          <td></td>
+        </tr>
+
+      </tbody>
+    </table>
+  );
+};
+
+export default UserGroupUserTable;

+ 1 - 1
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -17,7 +17,7 @@ type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   children: React.ReactNode,
   href?: string,
   href?: string,
   className?: string,
   className?: string,
-} ;
+};
 
 
 export const NextLink = ({
 export const NextLink = ({
   href, children, className, ...props
   href, children, className, ...props

+ 15 - 1
packages/app/src/interfaces/user-group-response.ts

@@ -1,5 +1,9 @@
-import { IUserGroupHasId, IUserGroupRelationHasId } from './user';
+import { HasObjectId, Ref } from '@growi/core';
+
 import { IPageHasId } from './page';
 import { IPageHasId } from './page';
+import {
+  IUser, IUserGroup, IUserGroupHasId, IUserGroupRelationHasId,
+} from './user';
 
 
 export type UserGroupResult = {
 export type UserGroupResult = {
   userGroup: IUserGroupHasId,
   userGroup: IUserGroupHasId,
@@ -18,6 +22,16 @@ export type UserGroupRelationListResult = {
   userGroupRelations: IUserGroupRelationHasId[],
   userGroupRelations: IUserGroupRelationHasId[],
 };
 };
 
 
+export type IUserGroupRelationHasIdPopulatedUser = {
+  relatedGroup: Ref<IUserGroup>,
+  relatedUser: IUser & HasObjectId,
+  createdAt: Date,
+} & HasObjectId;
+
+export type UserGroupRelationsResult = {
+  userGroupRelations: IUserGroupRelationHasIdPopulatedUser[],
+};
+
 export type UserGroupPagesResult = {
 export type UserGroupPagesResult = {
   pages: IPageHasId[],
   pages: IPageHasId[],
 }
 }

+ 7 - 0
packages/app/src/interfaces/user-group.ts

@@ -0,0 +1,7 @@
+export const SearchTypes = {
+  FORWARD: 'forward',
+  PARTIAL: 'partial',
+  BACKWORD: 'backword',
+} as const;
+
+export type SearchType = typeof SearchTypes[keyof typeof SearchTypes];

+ 0 - 3
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -27,7 +27,6 @@ import AdminOidcSecurityContainer from '~/client/services/AdminOidcSecurityConta
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
-import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import PluginUtils from '~/server/plugins/plugin-utils';
 import PluginUtils from '~/server/plugins/plugin-utils';
@@ -208,7 +207,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
     const adminNotificationContainer = new AdminNotificationContainer();
     const adminNotificationContainer = new AdminNotificationContainer();
     const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
     const adminSlackIntegrationLegacyContainer = new AdminSlackIntegrationLegacyContainer();
     const adminMarkDownContainer = new AdminMarkDownContainer();
     const adminMarkDownContainer = new AdminMarkDownContainer();
-    const adminUserGroupDetailContainer = new AdminUserGroupDetailContainer();
 
 
     injectableContainers.push(
     injectableContainers.push(
       adminAppContainer,
       adminAppContainer,
@@ -220,7 +218,6 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
       adminNotificationContainer,
       adminNotificationContainer,
       adminSlackIntegrationLegacyContainer,
       adminSlackIntegrationLegacyContainer,
       adminMarkDownContainer,
       adminMarkDownContainer,
-      adminUserGroupDetailContainer,
     );
     );
   }
   }
 
 

+ 6 - 5
packages/app/src/server/crowi/index.js

@@ -3,6 +3,7 @@ import http from 'http';
 import path from 'path';
 import path from 'path';
 
 
 import { createTerminus } from '@godaddy/terminus';
 import { createTerminus } from '@godaddy/terminus';
+import lsxRoutes from '@growi/plugin-lsx/server/routes';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import next from 'next';
 import next from 'next';
 
 
@@ -33,7 +34,6 @@ import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/m
 const logger = loggerFactory('growi:crowi');
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
 const models = require('../models');
-const PluginService = require('../plugins/plugin.service');
 
 
 const sep = path.sep;
 const sep = path.sep;
 
 
@@ -434,10 +434,6 @@ Crowi.prototype.start = async function() {
 
 
   const { express, configManager } = this;
   const { express, configManager } = this;
 
 
-  // setup plugins
-  this.pluginService = new PluginService(this, express);
-  await this.pluginService.autoDetectAndLoadPlugins();
-
   const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
   const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 
 
   const httpServer = http.createServer(app);
   const httpServer = http.createServer(app);
@@ -465,6 +461,7 @@ Crowi.prototype.start = async function() {
   }
   }
 
 
   // setup Express Routes
   // setup Express Routes
+  this.setupRoutesForPlugins();
   this.setupRoutesAtLast();
   this.setupRoutesAtLast();
 
 
   // setup Global Error Handlers
   // setup Global Error Handlers
@@ -515,6 +512,10 @@ Crowi.prototype.setupTerminus = function(server) {
   });
   });
 };
 };
 
 
+Crowi.prototype.setupRoutesForPlugins = function() {
+  lsxRoutes(this, this.express);
+};
+
 /**
 /**
  * setup Express Routes
  * setup Express Routes
  * !! this must be at last because it includes '/*' route !!
  * !! this must be at last because it includes '/*' route !!

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

@@ -1,40 +0,0 @@
-const path = require('path');
-
-class PluginUtilsV2 {
-
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'crowi-plugin-X',
-   *   meta: require('crowi-plugin-X'),
-   *   entries: [
-   *     'crowi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  generatePluginDefinition(name, isForClient = false) {
-    const meta = require(name);
-    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
-
-    entries = entries.map((entryPath) => {
-      const moduleRoot = path.resolve(require.resolve(`${name}/package.json`), '..');
-      const entryRelativePath = path.relative(moduleRoot, entryPath);
-      return path.join(name, entryRelativePath);
-    });
-
-    return {
-      name,
-      meta,
-      entries,
-    };
-  }
-
-}
-
-module.exports = PluginUtilsV2;

+ 0 - 38
packages/app/src/server/plugins/plugin-utils-v4.ts

@@ -1,38 +0,0 @@
-import path from 'path';
-
-import { PluginMetaV4, PluginDefinitionV4 } from '@growi/core';
-
-export class PluginUtilsV4 {
-
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'crowi-plugin-X',
-   *   meta: require('crowi-plugin-X'),
-   *   entries: [
-   *     'crowi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  async generatePluginDefinition(name: string, isForClient = false): Promise<PluginDefinitionV4> {
-    const meta: PluginMetaV4 = await import(name);
-    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
-
-    entries = entries.map((entryPath) => {
-      return path.join(name, entryPath);
-    });
-
-    return {
-      name,
-      meta,
-      entries,
-    };
-  }
-
-}

+ 1 - 44
packages/app/src/server/plugins/plugin-utils.js

@@ -1,57 +1,14 @@
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
 
-import { PluginUtilsV4 } from './plugin-utils-v4';
+// import { PluginUtilsV4 } from './plugin-utils-v4';
 
 
 const fs = require('graceful-fs');
 const fs = require('graceful-fs');
 
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 
-const pluginUtilsV4 = new PluginUtilsV4();
-
 class PluginUtils {
 class PluginUtils {
 
 
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'growi-plugin-X',
-   *   meta: require('growi-plugin-X'),
-   *   entries: [
-   *     'growi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  async generatePluginDefinition(name, isForClient = false) {
-    const meta = require(name);
-    let definition;
-
-    switch (meta.pluginSchemaVersion) {
-      // v1, v2 and v3 is deprecated
-      case 1:
-        logger.debug('pluginSchemaVersion 1 is deprecated');
-        break;
-      case 2:
-        logger.debug('pluginSchemaVersion 2 is deprecated');
-        break;
-      case 3:
-        logger.debug('pluginSchemaVersion 3 is deprecated');
-        break;
-      // v4 or above
-      case 4:
-        definition = await pluginUtilsV4.generatePluginDefinition(name, isForClient);
-        break;
-      default:
-        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
-    }
-
-    return definition;
-  }
-
   /**
   /**
    * list plugin module objects
    * list plugin module objects
    *  that starts with 'growi-plugin-' or 'crowi-plugin-'
    *  that starts with 'growi-plugin-' or 'crowi-plugin-'

+ 0 - 72
packages/app/src/server/plugins/plugin.service.js

@@ -1,72 +0,0 @@
-import loggerFactory from '~/utils/logger';
-
-const PluginUtils = require('./plugin-utils');
-
-const logger = loggerFactory('growi:plugins:PluginService');
-
-class PluginService {
-
-  constructor(crowi, app) {
-    this.crowi = crowi;
-    this.app = app;
-    this.pluginUtils = new PluginUtils();
-  }
-
-  async autoDetectAndLoadPlugins() {
-    const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
-
-    // import plugins
-    if (isEnabledPlugins) {
-      logger.debug('Plugins are enabled');
-      return this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
-    }
-
-  }
-
-  /**
-   * load plugins
-   *
-   * @memberOf PluginService
-   */
-  async loadPlugins(pluginNames) {
-    // get definitions
-    const definitions = [];
-    for (const pluginName of pluginNames) {
-      // eslint-disable-next-line no-await-in-loop
-      const definition = await this.pluginUtils.generatePluginDefinition(pluginName);
-      if (definition != null) {
-        this.loadPlugin(definition);
-      }
-    }
-  }
-
-  loadPlugin(definition) {
-    const meta = definition.meta;
-
-    switch (meta.pluginSchemaVersion) {
-      // v1, v2 and v3 is deprecated
-      case 1:
-        logger.warn('pluginSchemaVersion 1 is deprecated', definition);
-        break;
-      case 2:
-        logger.warn('pluginSchemaVersion 2 is deprecated', definition);
-        break;
-      case 3:
-        logger.warn('pluginSchemaVersion 3 is deprecated', definition);
-        break;
-      // v4 or above
-      case 4:
-        logger.info(`load plugin '${definition.name}'`);
-        definition.entries.forEach((entryPath) => {
-          const entry = require(entryPath);
-          entry(this.crowi, this.app);
-        });
-        break;
-      default:
-        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
-    }
-  }
-
-}
-
-module.exports = PluginService;

+ 0 - 1
packages/app/src/server/routes/apiv3/user-group.js

@@ -765,7 +765,6 @@ module.exports = (crowi) => {
     try {
     try {
       const userGroup = await UserGroup.findById(id);
       const userGroup = await UserGroup.findById(id);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
       const userGroupRelations = await UserGroupRelation.findAllRelationForUserGroup(userGroup);
-
       return res.apiv3({ userGroupRelations });
       return res.apiv3({ userGroupRelations });
     }
     }
     catch (err) {
     catch (err) {

+ 118 - 39
packages/app/src/services/renderer/renderer.ts → packages/app/src/services/renderer/renderer.tsx

@@ -1,4 +1,12 @@
+// allow only types to import from react
+import { ComponentType } from 'react';
+
+import { Lsx } from '@growi/plugin-lsx/components';
+import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
 import growiPlugin from '@growi/remark-growi-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
+import { NormalComponents } from 'react-markdown/lib/complex-types';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import katex from 'rehype-katex';
 import katex from 'rehype-katex';
 import raw from 'rehype-raw';
 import raw from 'rehype-raw';
@@ -9,6 +17,8 @@ import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
 import math from 'remark-math';
+import deepmerge from 'ts-deepmerge';
+import { PluggableList, Pluggable, PluginTuple } from 'unified';
 
 
 
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
@@ -216,8 +226,57 @@ const logger = loggerFactory('growi:util:GrowiRenderer');
 
 
 // }
 // }
 
 
-export type RendererOptions = Partial<ReactMarkdownOptions>;
+type SanitizePlugin = PluginTuple<[SanitizeOption]>;
+export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'> & {
+  remarkPlugins: PluggableList,
+  rehypePlugins: PluggableList,
+  components?:
+    | Partial<
+        Omit<NormalComponents, keyof SpecialComponents>
+        & SpecialComponents
+        & {
+          [elem: string]: ComponentType<any>,
+        }
+      >
+    | undefined
+};
+
+const commonSanitizeOption: SanitizeOption = deepmerge(
+  sanitizeDefaultSchema,
+  {
+    attributes: {
+      '*': ['class', 'className'],
+    },
+  },
+);
+
+const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
+  if (!Array.isArray(pluggable) || pluggable.length < 2) {
+    return false;
+  }
+  const sanitizeOption = pluggable[1];
+  return 'tagNames' in sanitizeOption && 'attributes' in sanitizeOption;
+};
+
+const hasSanitizePluginAtTheLast = (options: RendererOptions): boolean => {
+  const { rehypePlugins } = options;
+  if (rehypePlugins == null || rehypePlugins.length === 0) {
+    return false;
+  }
+
+  // get the last element
+  const lastPluggableElem = rehypePlugins.slice(-1)[0];
 
 
+  return isSanitizePlugin(lastPluggableElem);
+};
+
+const verifySanitizePlugin = (options: RendererOptions): void => {
+  if (hasSanitizePluginAtTheLast(options)) {
+    return;
+  }
+
+  throw new Error('The specified options does not have sanitize plugin in \'rehypePlugins\'');
+};
 
 
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
   return {
   return {
@@ -231,15 +290,6 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
       raw,
-      [sanitize, {
-        ...sanitizeDefaultSchema,
-        attributes: {
-          ...sanitizeDefaultSchema.attributes,
-          '*': sanitizeDefaultSchema.attributes != null
-            ? sanitizeDefaultSchema.attributes['*'].concat('class', 'className')
-            : ['class', 'className'],
-        },
-      }],
       [addClass, {
       [addClass, {
         table: 'table table-bordered',
         table: 'table table-bordered',
       }],
       }],
@@ -262,18 +312,19 @@ export const generateViewOptions = (
   const { remarkPlugins, rehypePlugins, components } = options;
   const { remarkPlugins, rehypePlugins, components } = options;
 
 
   // add remark plugins
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-    remarkPlugins.push(math);
-    if (config.isEnabledLinebreaks) {
-      remarkPlugins.push(breaks);
-    }
+  remarkPlugins.push(
+    emoji,
+    math,
+    lsxGrowiPlugin.remarkPlugin,
+  );
+  if (config.isEnabledLinebreaks) {
+    remarkPlugins.push(breaks);
   }
   }
 
 
-  // store toc node
-  if (rehypePlugins != null) {
-    rehypePlugins.push(katex);
-    rehypePlugins.push([toc, {
+  // add rehype plugins
+  rehypePlugins.push(
+    katex,
+    [toc, {
       nav: false,
       nav: false,
       headings: ['h1', 'h2', 'h3'],
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: (toc: HtmlElementNode) => {
       customizeTOC: (toc: HtmlElementNode) => {
@@ -296,17 +347,25 @@ export const generateViewOptions = (
 
 
         return false; // not show toc in body
         return false; // not show toc in body
       },
       },
-    }]);
-  }
-  // renderer.rehypePlugins.push([autoLinkHeadings, {
-  //   behavior: 'append',
-  // }]);
+    }],
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    // [autoLinkHeadings, {
+    //   behavior: 'append',
+    // }]
+  );
+
+  const sanitizeOption = deepmerge(
+    commonSanitizeOption,
+    lsxGrowiPlugin.sanitizeOption,
+  );
+  rehypePlugins.push([sanitize, sanitizeOption]);
 
 
   // add components
   // add components
   if (components != null) {
   if (components != null) {
     components.h1 = Header;
     components.h1 = Header;
     components.h2 = Header;
     components.h2 = Header;
     components.h3 = Header;
     components.h3 = Header;
+    components.lsx = props => <Lsx {...props} forceToFetchData />;
   }
   }
 
 
   // // Add configurers for viewer
   // // Add configurers for viewer
@@ -321,6 +380,7 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
   // renderer.configure();
 
 
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
@@ -331,25 +391,27 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   const { remarkPlugins, rehypePlugins } = options;
   const { remarkPlugins, rehypePlugins } = options;
 
 
   // add remark plugins
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-  }
-  // set toc node
-  if (rehypePlugins != null) {
-    rehypePlugins.push([toc, {
+  remarkPlugins.push(emoji);
+
+  // add rehype plugins
+  rehypePlugins.push(
+    [toc, {
       headings: ['h1', 'h2', 'h3'],
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: () => tocNode,
       customizeTOC: () => tocNode,
-    }]);
-  }
+    }],
+    [sanitize, commonSanitizeOption],
+  );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   //   behavior: 'append',
   // }]);
   // }]);
 
 
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
 
   // // Add configurers for preview
   // // Add configurers for preview
   // renderer.addConfigurers([
   // renderer.addConfigurers([
@@ -361,19 +423,23 @@ export const generatePreviewOptions = (config: RendererConfig): RendererOptions
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.configure();
   // renderer.configure();
 
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins } = options;
+  const { remarkPlugins, rehypePlugins } = options;
 
 
   // add remark plugins
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-    if (config.isEnabledLinebreaksInComments) {
-      remarkPlugins.push(breaks);
-    }
+  remarkPlugins.push(emoji);
+  if (config.isEnabledLinebreaksInComments) {
+    remarkPlugins.push(breaks);
   }
   }
 
 
   // renderer.addConfigurers([
   // renderer.addConfigurers([
@@ -383,11 +449,18 @@ export const generateCommentPreviewOptions = (config: RendererConfig): RendererO
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.configure();
   // renderer.configure();
 
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };
 
 
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
 
   // renderer.addConfigurers([
   // renderer.addConfigurers([
   //   new TableConfigurer(),
   //   new TableConfigurer(),
@@ -396,5 +469,11 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
   // renderer.configure();
 
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
   return options;
 };
 };

+ 4 - 3
packages/app/src/stores/user-group.tsx

@@ -6,7 +6,8 @@ import { apiv3Get } from '~/client/util/apiv3-client';
 import { IPageHasId } from '~/interfaces/page';
 import { IPageHasId } from '~/interfaces/page';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import { IUserGroupHasId, IUserGroupRelationHasId } from '~/interfaces/user';
 import {
 import {
-  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult,
+  IUserGroupRelationHasIdPopulatedUser,
+  UserGroupResult, UserGroupListResult, ChildUserGroupListResult, UserGroupRelationListResult, UserGroupRelationsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
   UserGroupPagesResult, SelectableParentUserGroupsResult, SelectableUserChildGroupsResult, AncestorUserGroupsResult,
 } from '~/interfaces/user-group-response';
 } from '~/interfaces/user-group-response';
 
 
@@ -51,10 +52,10 @@ export const useSWRxChildUserGroupList = (
   );
   );
 };
 };
 
 
-export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasId[], Error> => {
+export const useSWRxUserGroupRelations = (groupId: string): SWRResponse<IUserGroupRelationHasIdPopulatedUser[], Error> => {
   return useSWRImmutable(
   return useSWRImmutable(
     groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
     groupId != null ? [`/user-groups/${groupId}/user-group-relations`] : null,
-    endpoint => apiv3Get<UserGroupRelationListResult>(endpoint).then(result => result.data.userGroupRelations),
+    endpoint => apiv3Get<UserGroupRelationsResult>(endpoint).then(result => result.data.userGroupRelations),
   );
   );
 };
 };
 
 

+ 1 - 0
packages/app/tsconfig.build.client.json

@@ -8,6 +8,7 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
     }

+ 10 - 0
packages/app/tsconfig.build.server-tsc-alias.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.build.server.json",
+  "compilerOptions": {
+    "paths": {
+      "~/*": ["./src/*"],
+      "^/*": ["./*"],
+      "debug": ["./src/utils/logger/alias-for-debug"]
+    }
+  }
+}

+ 2 - 1
packages/app/tsconfig.build.server.json

@@ -11,6 +11,7 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/dist/cjs/*"],
       "debug": ["./src/utils/logger/alias-for-debug"]
       "debug": ["./src/utils/logger/alias-for-debug"]
     }
     }
   },
   },
@@ -21,6 +22,6 @@
     "src/linter-checker",
     "src/linter-checker",
     "src/stores",
     "src/stores",
     "src/styles",
     "src/styles",
-    "src/styles-hackmd",
+    "src/styles-hackmd"
   ]
   ]
 }
 }

+ 1 - 0
packages/app/tsconfig.json

@@ -5,6 +5,7 @@
     "paths": {
     "paths": {
       "~/*": ["./src/*"],
       "~/*": ["./src/*"],
       "^/*": ["./*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }
     }

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

@@ -1,11 +1,10 @@
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _envUtils from './utils/env-utils';
 import * as _envUtils from './utils/env-utils';
 
 
 // export utils by *.js
 // export utils by *.js
 export const envUtils = _envUtils;
 export const envUtils = _envUtils;
-export const customTagUtils = _customTagUtils;
 
 
 // export utils with namespace
 // export utils with namespace
+export * as customTagUtils from './plugin/util/custom-tag-utils';
 export * as templateChecker from './utils/template-checker';
 export * as templateChecker from './utils/template-checker';
 export * as objectIdUtils from './utils/objectid-utils';
 export * as objectIdUtils from './utils/objectid-utils';
 export * as pagePathUtils from './utils/page-path-utils';
 export * as pagePathUtils from './utils/page-path-utils';
@@ -13,6 +12,7 @@ export * as pathUtils from './utils/path-utils';
 export * as pageUtils from './utils/page-utils';
 export * as pageUtils from './utils/page-utils';
 
 
 // export all
 // export all
+export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/common';
 export * from './interfaces/has-object-id';
 export * from './interfaces/has-object-id';
@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/tag';
 export * from './interfaces/user';
 export * from './interfaces/user';
-export * from './plugin/interfaces/plugin-definition-v4';
 export * from './plugin/service/tag-cache-manager';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';
 export * from './service/localstorage-manager';

+ 2 - 0
packages/core/src/interfaces/user.ts

@@ -17,6 +17,8 @@ export type IUser = {
   isEmailPublished: boolean,
   isEmailPublished: boolean,
   lang: Lang,
   lang: Lang,
   slackMemberId?: string,
   slackMemberId?: string,
+  createdAt: Date,
+  lastLoginAt?: Date,
 }
 }
 
 
 export type IUserGroupRelation = {
 export type IUserGroupRelation = {

+ 4 - 0
packages/core/src/plugin/interfaces/option-parser.ts

@@ -0,0 +1,4 @@
+export type ParseRangeResult = {
+  start: number,
+  end: number,
+}

+ 0 - 11
packages/core/src/plugin/interfaces/plugin-definition-v4.ts

@@ -1,11 +0,0 @@
-export type PluginMetaV4 = {
-  pluginSchemaVersion: number,
-  serverEntries: string[],
-  clientEntries: string[],
-};
-
-export type PluginDefinitionV4 = {
-  name: string,
-  meta: PluginMetaV4,
-  entries: string[],
-};

+ 8 - 4
packages/core/src/plugin/model/tag-context.js → packages/core/src/plugin/model/tag-context.ts

@@ -1,14 +1,18 @@
 /**
 /**
  * Context class for custom-tag-utils#findTagAndReplace
  * Context class for custom-tag-utils#findTagAndReplace
  */
  */
-class TagContext {
+export class TagContext {
 
 
-  constructor(initArgs = {}) {
+  tagExpression: string | null;
+
+  method: string | null;
+
+  args: any;
+
+  constructor(initArgs: any = {}) {
     this.tagExpression = initArgs.tagExpression || null;
     this.tagExpression = initArgs.tagExpression || null;
     this.method = initArgs.method || null;
     this.method = initArgs.method || null;
     this.args = initArgs.args || null;
     this.args = initArgs.args || null;
   }
   }
 
 
 }
 }
-
-module.exports = TagContext;

+ 2 - 4
packages/core/src/plugin/util/args-parser.js

@@ -1,12 +1,12 @@
 /**
 /**
  * Arguments parser for custom tag
  * Arguments parser for custom tag
  */
  */
-class ArgsParser {
+export class ArgsParser {
 
 
   /**
   /**
    * @typedef ParseArgsResult
    * @typedef ParseArgsResult
    * @property {string} firstArgsKey - key of the first argument
    * @property {string} firstArgsKey - key of the first argument
-   * @property {string} firstArgsValue - value of the first argument
+   * @property {string|boolean} firstArgsValue - value of the first argument
    * @property {object} options - key of the first argument
    * @property {object} options - key of the first argument
    */
    */
 
 
@@ -55,5 +55,3 @@ class ArgsParser {
   }
   }
 
 
 }
 }
-
-module.exports = ArgsParser;

+ 0 - 88
packages/core/src/plugin/util/custom-tag-utils.js

@@ -1,88 +0,0 @@
-const TagContext = require('../model/tag-context');
-
-/**
- * @private
- *
- * create random strings
- * @see http://qiita.com/ryounagaoka/items/4736c225bdd86a74d59c
- *
- * @param {number} length
- * @return {string} random strings
- */
-function createRandomStr(length) {
-  const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
-  let generated = '';
-  for (let i = 0; i < length; i++) {
-    generated += bag[Math.floor(Math.random() * bag.length)];
-  }
-  return generated;
-}
-
-/**
- * @typedef FindTagAndReplaceResult
- * @property {string} html - HTML string
- * @property {Object} tagContextMap - Object.<string, [TagContext]{@link ../model/tag-context.html#TagContext}>
- *
- * @memberof customTagUtils
- */
-/**
- * @param {RegExp} tagPattern
- * @param {string} html
- * @param {function} replace replace function
- * @return {FindTagAndReplaceResult}
- *
- * @memberof customTagUtils
- */
-function findTagAndReplace(tagPattern, html, replace) {
-  let replacedHtml = html;
-  const tagContextMap = {};
-
-  if (tagPattern == null || html == null) {
-    return { html: replacedHtml, tagContextMap };
-  }
-
-  // see: https://regex101.com/r/NQq3s9/9
-  const pattern = new RegExp(`\\$(${tagPattern.source})\\((.*?)\\)(?=[<\\[\\s\\$])|\\$(${tagPattern.source})\\((.*)\\)(?![<\\[\\s\\$])`, 'g');
-
-  replacedHtml = html.replace(pattern, (all, group1, group2, group3, group4) => {
-    const tagExpression = all;
-    const method = (group1 || group3).trim();
-    const args = (group2 || group4 || '').trim();
-
-    // create contexts
-    const tagContext = new TagContext({ tagExpression, method, args });
-
-    if (replace != null) {
-      return replace(tagContext);
-    }
-
-    // replace with empty dom
-    const domId = `${method}-${createRandomStr(8)}`;
-    tagContextMap[domId] = tagContext;
-    return `<div id="${domId}"></div>`;
-  });
-
-  return { html: replacedHtml, tagContextMap };
-}
-
-/**
- * @namespace customTagUtils
- */
-module.exports = {
-  findTagAndReplace,
-  /**
-   * Context class used by findTagAndReplace
-   * @memberof customTagUtils
-   */
-  TagContext,
-  /**
-   * [ArgsParser]{@link ./args-parser#ArgsParser}
-   * @memberof customTagUtils
-   */
-  ArgsParser: require('./args-parser'),
-  /**
-   * [OptionParser]{@link ./option-parser#OptionParser}
-   * @memberof customTagUtils
-   */
-  OptionParser: require('./option-parser'),
-};

+ 5 - 0
packages/core/src/plugin/util/custom-tag-utils.ts

@@ -0,0 +1,5 @@
+export * from '../model/tag-context';
+
+export * from './args-parser';
+
+export * from './option-parser';

+ 4 - 10
packages/core/src/plugin/util/option-parser.js → packages/core/src/plugin/util/option-parser.ts

@@ -1,13 +1,9 @@
+import { ParseRangeResult } from '../interfaces/option-parser';
+
 /**
 /**
  * Options parser for custom tag
  * Options parser for custom tag
  */
  */
-class OptionParser {
-
-  /**
-   * @typedef ParseRangeResult
-   * @property {number} start - start index
-   * @property {number} end - end index
-   */
+export class OptionParser {
 
 
   /**
   /**
    * Parse range expression
    * Parse range expression
@@ -27,7 +23,7 @@ class OptionParser {
    * @param {string} str
    * @param {string} str
    * @returns {ParseRangeResult}
    * @returns {ParseRangeResult}
    */
    */
-  static parseRange(str) {
+  static parseRange(str: string): ParseRangeResult | null {
     if (str == null) {
     if (str == null) {
       return null;
       return null;
     }
     }
@@ -66,5 +62,3 @@ class OptionParser {
   }
   }
 
 
 }
 }
-
-module.exports = OptionParser;

+ 2 - 2
packages/core/src/test/plugin/service/tag-cache-manager.test.js

@@ -3,8 +3,8 @@
 // import each from 'jest-each';
 // import each from 'jest-each';
 jest.mock('~/service/localstorage-manager');
 jest.mock('~/service/localstorage-manager');
 
 
-import * as TagCacheManager from '~/plugin/service/tag-cache-manager';
-import * as LocalStorageManager from '~/service/localstorage-manager';
+import { TagCacheManager } from '~/plugin/service/tag-cache-manager';
+import { LocalStorageManager } from '~/service/localstorage-manager';
 /* eslint-enable import/first */
 /* eslint-enable import/first */
 
 
 describe('TagCacheManager.constructor', () => {
 describe('TagCacheManager.constructor', () => {

+ 1 - 1
packages/core/src/test/plugin/util/args-parser.test.js

@@ -1,4 +1,4 @@
-import ArgsParser from '~/plugin/util/args-parser';
+import { ArgsParser } from '~/plugin/util/args-parser';
 
 
 describe('args-parser', () => {
 describe('args-parser', () => {
 
 

+ 50 - 48
packages/core/src/test/plugin/util/custom-tag-utils.test.js

@@ -1,8 +1,9 @@
 import rewire from 'rewire';
 import rewire from 'rewire';
 
 
-import customTagUtils from '~/plugin/util/custom-tag-utils';
+import * as customTagUtils from '~/plugin/util/custom-tag-utils';
 
 
-const rewiredCustomTagUtils = rewire('../../../plugin/util/custom-tag-utils');
+// leave it commented out for rewire example -- 2022.08.18 Yuki Takei
+// const rewiredCustomTagUtils = rewire('../../../plugin/util/custom-tag-utils');
 
 
 describe('customTagUtils', () => {
 describe('customTagUtils', () => {
 
 
@@ -21,52 +22,53 @@ describe('customTagUtils', () => {
     expect(typeof customTagUtils.OptionParser).toBe('function');
     expect(typeof customTagUtils.OptionParser).toBe('function');
   });
   });
 
 
-  test('.createRandomStr(10) returns random string', () => {
-    // get private resource
-    const createRandomStr = rewiredCustomTagUtils.__get__('createRandomStr');
-    expect(createRandomStr(10)).toMatch(/^[a-z0-9]{10}$/);
-  });
-
-  test('.findTagAndReplace() returns default object when tagPattern is null', () => {
-    const htmlMock = jest.fn();
-    htmlMock.replace = jest.fn();
-
-    const result = customTagUtils.findTagAndReplace(null, '');
-
-    expect(result).toEqual({ html: '', tagContextMap: {} });
-    expect(htmlMock.replace).not.toHaveBeenCalled();
-  });
-
-  test('.findTagAndReplace() returns default object when html is null', () => {
-    const tagPatternMock = jest.fn();
-    tagPatternMock.source = jest.fn();
-
-    const result = customTagUtils.findTagAndReplace(tagPatternMock, null);
-
-    expect(result).toEqual({ html: null, tagContextMap: {} });
-    expect(tagPatternMock.source).not.toHaveBeenCalled();
-  });
-
-  test('.findTagAndReplace() works correctly', () => {
-    // setup mocks for private function
-    rewiredCustomTagUtils.__set__('createRandomStr', (length) => {
-      return 'dummyDomId';
-    });
-
-    const tagPattern = /ls|lsx/;
-    const html = '<section><h1>header</h1>\n$ls(/)</section>';
-
-    const result = rewiredCustomTagUtils.findTagAndReplace(tagPattern, html);
-
-    expect(result.html).toMatch(/<section><h1>header<\/h1>\n<div id="ls-dummyDomId"><\/div>/);
-    expect(result.tagContextMap).toEqual({
-      'ls-dummyDomId': {
-        tagExpression: '$ls(/)',
-        method: 'ls',
-        args: '/',
-      },
-    });
-  });
+  // leave it commented out for rewire example -- 2022.08.18 Yuki Takei
+  // test('.createRandomStr(10) returns random string', () => {
+  //   // get private resource
+  //   const createRandomStr = rewiredCustomTagUtils.__get__('createRandomStr');
+  //   expect(createRandomStr(10)).toMatch(/^[a-z0-9]{10}$/);
+  // });
+
+  // test('.findTagAndReplace() returns default object when tagPattern is null', () => {
+  //   const htmlMock = jest.fn();
+  //   htmlMock.replace = jest.fn();
+
+  //   const result = customTagUtils.findTagAndReplace(null, '');
+
+  //   expect(result).toEqual({ html: '', tagContextMap: {} });
+  //   expect(htmlMock.replace).not.toHaveBeenCalled();
+  // });
+
+  // test('.findTagAndReplace() returns default object when html is null', () => {
+  //   const tagPatternMock = jest.fn();
+  //   tagPatternMock.source = jest.fn();
+
+  //   const result = customTagUtils.findTagAndReplace(tagPatternMock, null);
+
+  //   expect(result).toEqual({ html: null, tagContextMap: {} });
+  //   expect(tagPatternMock.source).not.toHaveBeenCalled();
+  // });
+
+  // test('.findTagAndReplace() works correctly', () => {
+  //   // setup mocks for private function
+  //   rewiredCustomTagUtils.__set__('createRandomStr', (length) => {
+  //     return 'dummyDomId';
+  //   });
+
+  //   const tagPattern = /ls|lsx/;
+  //   const html = '<section><h1>header</h1>\n$ls(/)</section>';
+
+  //   const result = rewiredCustomTagUtils.findTagAndReplace(tagPattern, html);
+
+  //   expect(result.html).toMatch(/<section><h1>header<\/h1>\n<div id="ls-dummyDomId"><\/div>/);
+  //   expect(result.tagContextMap).toEqual({
+  //     'ls-dummyDomId': {
+  //       tagExpression: '$ls(/)',
+  //       method: 'ls',
+  //       args: '/',
+  //     },
+  //   });
+  // });
 
 
 
 
 });
 });

+ 1 - 1
packages/core/src/test/plugin/util/option-parser.test.js

@@ -1,6 +1,6 @@
 import each from 'jest-each';
 import each from 'jest-each';
 
 
-import OptionParser from '~/plugin/util/option-parser';
+import { OptionParser } from '~/plugin/util/option-parser';
 
 
 describe('option-parser', () => {
 describe('option-parser', () => {
 
 

+ 1 - 1
packages/core/src/test/service/localstorage-manager.test.js

@@ -1,7 +1,7 @@
 // eslint-disable-next-line import/no-unresolved
 // eslint-disable-next-line import/no-unresolved
 import 'jest-localstorage-mock';
 import 'jest-localstorage-mock';
 
 
-import * as LocalStorageManager from '~/service/localstorage-manager';
+import { LocalStorageManager } from '~/service/localstorage-manager';
 
 
 let localStorageManager = null;
 let localStorageManager = null;
 
 

+ 18 - 0
packages/plugin-lsx/.eslintrc.js

@@ -0,0 +1,18 @@
+module.exports = {
+  extends: [
+    'weseek/react',
+    'weseek/typescript',
+  ],
+  env: {
+  },
+  globals: {
+  },
+  settings: {
+    // resolve path aliases by eslint-import-resolver-typescript
+    'import/resolver': {
+      typescript: {},
+    },
+  },
+  rules: {
+  },
+};

+ 10 - 2
packages/plugin-lsx/package.json

@@ -9,6 +9,11 @@
   ],
   ],
   "main": "dist/cjs/index.js",
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
   "module": "dist/esm/index.js",
+  "exports": {
+    "./components": "./dist/cjs/components/index.js",
+    "./services/renderer": "./dist/cjs/services/renderer/index.js",
+    "./server/routes": "./dist/cjs/server/routes/index.js"
+  },
   "files": [
   "files": [
     "dist"
     "dist"
   ],
   ],
@@ -18,11 +23,14 @@
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "clean": "npx -y shx rm -rf dist",
     "clean": "npx -y shx rm -rf dist",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
+    "lint:styles": "stylelint --allow-empty-input src/**/*.scss src/**/*.css",
     "lint": "run-p lint:*",
     "lint": "run-p lint:*",
     "test": ""
     "test": ""
   },
   },
-  "dependencies": {},
+  "dependencies": {
+    "@growi/core": "^5.1.3-RC.0",
+    "@growi/remark-growi-plugin": "^5.1.3-RC.0"
+  },
   "devDependencies": {
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "eslint-plugin-regex": "^1.8.0",
     "react": "^18.2.0",
     "react": "^18.2.0",

+ 0 - 12
packages/plugin-lsx/src/client-entry.js

@@ -1,12 +0,0 @@
-import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInterceptor';
-import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
-import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
-
-export default () => {
-  // add interceptors
-  global.interceptorManager.addInterceptors([
-    new LsxLogoutInterceptor(),
-    new LsxPreRenderInterceptor(),
-    new LsxPostRenderInterceptor(),
-  ]);
-};

+ 0 - 20
packages/plugin-lsx/src/client/css/index.css

@@ -1,20 +0,0 @@
-.lsx .page-list-ul > li > a:not(:hover) {
-  text-decoration: none;
-}
-
-.lsx .lsx-page-not-exist {
-  opacity: 0.6;
-}
-
-.lsx .lsx-blink {
-  animation: lsx-fadeIn 1s ease 0s infinite alternate;
-}
-
-@keyframes lsx-fadeIn {
-  0% {
-    opacity: 0.2;
-  }
-  100% {
-    opacity: 0.9;
-  }
-}

+ 0 - 246
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -1,246 +0,0 @@
-
-import React from 'react';
-
-import * as url from 'url';
-
-import { pathUtils } from '@growi/core';
-import axios from 'axios';
-import PropTypes from 'prop-types';
-
-// eslint-disable-next-line no-unused-vars
-import { LsxContext } from '../util/LsxContext';
-import { TagCacheManagerFactory } from '../util/TagCacheManagerFactory';
-
-import { LsxListView } from './LsxPageList/LsxListView';
-import { PageNode } from './PageNode';
-
-import styles from '../../css/index.css';
-
-export class Lsx extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isLoading: false,
-      isError: false,
-      isCacheExists: false,
-      nodeTree: undefined,
-      basisViewersCount: undefined,
-      errorMessage: '',
-    };
-
-    this.tagCacheManager = TagCacheManagerFactory.getInstance();
-  }
-
-  async componentDidMount() {
-    const { lsxContext, forceToFetchData } = this.props;
-
-    // get state object cache
-    const stateCache = this.retrieveDataFromCache();
-
-    if (stateCache != null) {
-      this.setState({
-        isCacheExists: true,
-        nodeTree: stateCache.nodeTree,
-        isError: stateCache.isError,
-        errorMessage: stateCache.errorMessage,
-      });
-
-      // switch behavior by forceToFetchData
-      if (!forceToFetchData) {
-        return; // go to render()
-      }
-    }
-
-    lsxContext.parse();
-    this.setState({ isLoading: true });
-
-    // add slash ensure not to forward match to another page
-    // ex: '/Java/' not to match to '/JavaScript'
-    const pagePath = pathUtils.addTrailingSlash(lsxContext.pagePath);
-
-    try {
-      const res = await axios.get('/_api/plugins/lsx', {
-        params: {
-          pagePath,
-          options: lsxContext.options,
-        },
-      });
-
-      if (res.data.ok) {
-        const basisViewersCount = res.data.toppageViewersCount;
-        const nodeTree = this.generatePageNodeTree(pagePath, res.data.pages);
-        this.setState({ nodeTree, basisViewersCount });
-      }
-    }
-    catch (error) {
-      this.setState({ isError: true, errorMessage: error.message });
-    }
-    finally {
-      this.setState({ isLoading: false });
-
-      // store to sessionStorage
-      this.tagCacheManager.cacheState(lsxContext, this.state);
-    }
-  }
-
-  retrieveDataFromCache() {
-    const { lsxContext } = this.props;
-
-    // get state object cache
-    const stateCache = this.tagCacheManager.getStateCache(lsxContext);
-
-    // instanciate PageNode
-    if (stateCache != null && stateCache.nodeTree != null) {
-      stateCache.nodeTree = stateCache.nodeTree.map((obj) => {
-        return PageNode.instanciateFrom(obj);
-      });
-    }
-
-    return stateCache;
-  }
-
-  /**
-   * generate tree structure
-   *
-   * @param {string} rootPagePath
-   * @param {Page[]} pages Array of Page model
-   *
-   * @memberOf Lsx
-   */
-  generatePageNodeTree(rootPagePath, pages) {
-    const pathToNodeMap = {};
-
-    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);
-
-      // exclude rootPagePath itself
-      if (this.isEquals(pagePath, rootPagePath)) {
-        return;
-      }
-
-      const node = this.generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
-      // set the Page substance
-      node.page = page;
-    });
-
-    // return root objects
-    const rootNodes = [];
-    Object.keys(pathToNodeMap).forEach((pagePath) => {
-      // exclude '/'
-      if (pagePath === '/') {
-        return;
-      }
-
-      const parentPath = this.getParentPath(pagePath);
-
-      // pick up what parent doesn't exist
-      if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
-        rootNodes.push(pathToNodeMap[pagePath]);
-      }
-    });
-    return rootNodes;
-  }
-
-  /**
-   * generate PageNode instances for target page and the ancestors
-   *
-   * @param {any} pathToNodeMap
-   * @param {any} rootPagePath
-   * @param {any} pagePath
-   * @returns
-   * @memberof Lsx
-   */
-  generatePageNode(pathToNodeMap, rootPagePath, pagePath) {
-    // exclude rootPagePath itself
-    if (this.isEquals(pagePath, rootPagePath)) {
-      return null;
-    }
-
-    // return when already registered
-    if (pathToNodeMap[pagePath] != null) {
-      return pathToNodeMap[pagePath];
-    }
-
-    // generate node
-    const node = new PageNode(pagePath);
-    pathToNodeMap[pagePath] = node;
-
-    /*
-     * process recursively for ancestors
-     */
-    // get or create parent node
-    const parentPath = this.getParentPath(pagePath);
-    const parentNode = this.generatePageNode(pathToNodeMap, rootPagePath, parentPath);
-    // associate to patent
-    if (parentNode != null) {
-      parentNode.children.push(node);
-    }
-
-    return node;
-  }
-
-  /**
-   * compare whether path1 and path2 is the same
-   *
-   * @param {string} path1
-   * @param {string} path2
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  isEquals(path1, path2) {
-    return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
-  }
-
-  getParentPath(path) {
-    return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
-  }
-
-  renderContents() {
-    const lsxContext = this.props.lsxContext;
-    const {
-      isLoading, isError, isCacheExists, nodeTree,
-    } = this.state;
-
-    if (isError) {
-      return (
-        <div className="text-warning">
-          <i className="fa fa-exclamation-triangle fa-fw"></i>
-          {lsxContext.tagExpression} (-&gt; <small>{this.state.errorMessage}</small>)
-        </div>
-      );
-    }
-
-
-    return (
-      <div className={isLoading ? 'lsx-blink' : ''}>
-        { isLoading && (
-          <div className="text-muted">
-            <i className="fa fa-spinner fa-pulse mr-1"></i>
-            {lsxContext.tagExpression}
-            { isCacheExists && <small>&nbsp;(Showing cache..)</small> }
-          </div>
-        ) }
-        { nodeTree && (
-          <LsxListView nodeTree={this.state.nodeTree} lsxContext={this.props.lsxContext} basisViewersCount={this.state.basisViewersCount} />
-        ) }
-      </div>
-    );
-
-  }
-
-  render() {
-    return <div className="lsx">{this.renderContents()}</div>;
-  }
-
-}
-
-Lsx.propTypes = {
-  lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
-
-  forceToFetchData: PropTypes.bool,
-};

+ 0 - 33
packages/plugin-lsx/src/client/js/util/Interceptor/LsxLogoutInterceptor.js

@@ -1,33 +0,0 @@
-import { BasicInterceptor } from '@growi/core';
-
-import { TagCacheManagerFactory } from '../TagCacheManagerFactory';
-
-/**
- * The interceptor for lsx
- *
- *  replace lsx tag to a React target element
- */
-export class LsxLogoutInterceptor extends BasicInterceptor {
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'logout'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    TagCacheManagerFactory.getInstance().clearAllStateCaches();
-
-    // resolve
-    return context;
-  }
-
-}

+ 0 - 58
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,58 +0,0 @@
-import React from 'react';
-
-import { BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-
-import { Lsx } from '../../components/Lsx';
-import { LsxContext } from '../LsxContext';
-
-/**
- * The interceptor for lsx
- *
- *  render React DOM
- */
-export class LsxPostRenderInterceptor extends BasicInterceptor {
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'postRenderHtml'
-      || contextName === 'postRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    const isPreview = (contextName === 'postRenderPreviewHtml');
-
-    // forEach keys of lsxContextMap
-    Object.keys(context.lsxContextMap).forEach((domId) => {
-      const elem = document.getElementById(domId);
-
-      if (elem) {
-        // instanciate LsxContext from context
-        const lsxContext = new LsxContext(context.lsxContextMap[domId] || {});
-        lsxContext.fromPagePath = context.pagePath ?? context.currentPathname;
-
-        this.renderReactDOM(lsxContext, elem, isPreview);
-      }
-    });
-
-    return;
-  }
-
-  renderReactDOM(lsxContext, elem, isPreview) {
-    ReactDOM.render(
-      <Lsx lsxContext={lsxContext} forceToFetchData={!isPreview} />,
-      elem,
-    );
-  }
-
-}

+ 0 - 68
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js

@@ -1,68 +0,0 @@
-import { customTagUtils, BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-/**
- * The interceptor for lsx
- *
- *  replace lsx tag to a React target element
- */
-export class LsxPreRenderInterceptor extends BasicInterceptor {
-
-  constructor() {
-    super();
-
-    this.previousPreviewContext = null;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'preRenderHtml'
-      || contextName === 'preRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isProcessableParallel() {
-    return false;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-    const parsedHTML = context.parsedHTML;
-
-    const tagPattern = /ls|lsx/;
-    const result = customTagUtils.findTagAndReplace(tagPattern, parsedHTML);
-
-    context.parsedHTML = result.html;
-    context.lsxContextMap = result.tagContextMap;
-
-    // unmount
-    if (contextName === 'preRenderPreviewHtml') {
-      this.unmountPreviousReactDOMs(context);
-    }
-
-    // resolve
-    return context;
-  }
-
-  unmountPreviousReactDOMs(newContext) {
-    if (this.previousPreviewContext != null) {
-      // forEach keys of lsxContextMap
-      Object.keys(this.previousPreviewContext.lsxContextMap).forEach((domId) => {
-        const elem = document.getElementById(domId);
-        ReactDOM.unmountComponentAtNode(elem);
-      });
-    }
-
-    this.previousPreviewContext = newContext;
-  }
-
-}

+ 0 - 61
packages/plugin-lsx/src/client/js/util/LsxContext.js

@@ -1,61 +0,0 @@
-import * as url from 'url';
-
-import { customTagUtils, pathUtils } from '@growi/core';
-
-const { TagContext, ArgsParser, OptionParser } = customTagUtils;
-
-export class LsxContext extends TagContext {
-
-  /**
-   * @param {object|TagContext|LsxContext} initArgs
-   */
-  constructor(initArgs) {
-    super(initArgs);
-
-    this.fromPagePath = null;
-
-    // initialized after parse()
-    this.isParsed = null;
-    this.pagePath = null;
-    this.options = {};
-  }
-
-  parse() {
-    if (this.isParsed) {
-      return;
-    }
-
-    const parsedResult = ArgsParser.parse(this.args);
-    this.options = parsedResult.options;
-
-    // determine specifiedPath
-    // order:
-    //   1: lsx(prefix=..., ...)
-    //   2: lsx(firstArgs, ...)
-    //   3: fromPagePath
-    const specifiedPath = this.options.prefix
-        || ((parsedResult.firstArgsValue === true) ? parsedResult.firstArgsKey : undefined)
-        || this.fromPagePath;
-
-    // resolve pagePath
-    //   when `fromPagePath`=/hoge and `specifiedPath`=./fuga,
-    //        `pagePath` to be /hoge/fuga
-    //   when `fromPagePath`=/hoge and `specifiedPath`=/fuga,
-    //        `pagePath` to be /fuga
-    //   when `fromPagePath`=/hoge and `specifiedPath`=undefined,
-    //        `pagePath` to be /hoge
-    this.pagePath = (specifiedPath !== undefined)
-      ? decodeURIComponent(url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), specifiedPath))
-      : this.fromPagePath;
-
-    this.isParsed = true;
-  }
-
-  getOptDepth() {
-    if (this.options.depth === undefined) {
-      return undefined;
-    }
-    return OptionParser.parseRange(this.options.depth);
-  }
-
-}

+ 0 - 22
packages/plugin-lsx/src/client/js/util/TagCacheManagerFactory.js

@@ -1,22 +0,0 @@
-import { TagCacheManager } from '@growi/core';
-
-const LSX_STATE_CACHE_NS = 'lsx-state-cache';
-
-
-let _instance;
-export class TagCacheManagerFactory {
-
-  static getInstance() {
-    if (_instance == null) {
-      // create generateCacheKey implementation
-      const generateCacheKey = (lsxContext) => {
-        return `${lsxContext.fromPagePath}__${lsxContext.args}`;
-      };
-
-      _instance = new TagCacheManager(LSX_STATE_CACHE_NS, generateCacheKey);
-    }
-
-    return _instance;
-  }
-
-}

+ 26 - 0
packages/plugin-lsx/src/components/Lsx.module.scss

@@ -0,0 +1,26 @@
+.lsx :global {
+  page-list-ul > li > a:not(:hover) {
+    text-decoration: none;
+  }
+
+  .lsx-page-not-exist {
+    opacity: 0.6;
+  }
+
+  // workaround
+  // https://stackoverflow.com/a/57667536
+  .lsx-blink {
+    & :local {
+      animation: lsx-fadeIn 1s ease 0s infinite alternate;
+    }
+  }
+}
+
+@keyframes lsx-fadeIn {
+  0% {
+    opacity: 0.2;
+  }
+  100% {
+    opacity: 0.9;
+  }
+}

+ 265 - 0
packages/plugin-lsx/src/components/Lsx.tsx

@@ -0,0 +1,265 @@
+import React, {
+  useCallback, useEffect, useMemo, useState,
+} from 'react';
+
+import * as url from 'url';
+
+import { IPage, pathUtils } from '@growi/core';
+import axios from 'axios';
+
+import { LsxListView } from './LsxPageList/LsxListView';
+import { PageNode } from './PageNode';
+import { LsxContext } from './lsx-context';
+import { getInstance as getTagCacheManager } from './tag-cache-manager';
+
+import styles from './Lsx.module.scss';
+
+
+const tagCacheManager = getTagCacheManager();
+
+
+/**
+ * compare whether path1 and path2 is the same
+ *
+ * @param {string} path1
+ * @param {string} path2
+ * @returns
+ *
+ * @memberOf Lsx
+ */
+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 = new PageNode(pagePath);
+  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: IPage[]) {
+  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 Props = {
+  children: React.ReactNode,
+  className?: string,
+
+  prefix: string,
+  num?: string,
+  depth?: string,
+  sort?: string,
+  reverse?: string,
+  filter?: string,
+
+  forceToFetchData?: boolean,
+};
+
+type StateCache = {
+  isError: boolean,
+  errorMessage: string,
+  basisViewersCount?: number,
+  nodeTree?: PageNode[],
+}
+
+export const Lsx = ({
+  prefix,
+  num, depth, sort, reverse, filter,
+  ...props
+}: Props): JSX.Element => {
+
+  const [isLoading, setLoading] = useState(false);
+  const [isError, setError] = useState(false);
+  const [isCacheExists, setCacheExists] = useState(false);
+  const [nodeTree, setNodeTree] = useState<PageNode[]|undefined>();
+  const [basisViewersCount, setBasisViewersCount] = useState<number|undefined>();
+  const [errorMessage, setErrorMessage] = useState('');
+
+  const { forceToFetchData } = props;
+
+  const lsxContext = useMemo(() => {
+    const options = {
+      num, depth, sort, reverse, filter,
+    };
+    return new LsxContext(prefix, options);
+  }, [depth, filter, num, prefix, reverse, sort]);
+
+  const retrieveDataFromCache = useCallback(() => {
+    // get state object cache
+    const stateCache = tagCacheManager.getStateCache(lsxContext) as StateCache | null;
+
+    // instanciate PageNode
+    if (stateCache != null && stateCache.nodeTree != null) {
+      stateCache.nodeTree = stateCache.nodeTree.map((obj) => {
+        return PageNode.instanciateFrom(obj);
+      });
+    }
+
+    return stateCache;
+  }, [lsxContext]);
+
+  const loadData = useCallback(async() => {
+    setLoading(true);
+
+    // add slash ensure not to forward match to another page
+    // ex: '/Java/' not to match to '/JavaScript'
+    const pagePath = pathUtils.addTrailingSlash(lsxContext.pagePath);
+
+    let newNodeTree: PageNode[] = [];
+    try {
+      const result = await axios.get('/_api/plugins/lsx', {
+        params: {
+          pagePath,
+          options: lsxContext.options,
+        },
+      });
+
+      newNodeTree = generatePageNodeTree(pagePath, result.data.pages);
+      setNodeTree(newNodeTree);
+      setBasisViewersCount(result.data.toppageViewersCount);
+      setError(false);
+
+      // store to sessionStorage
+      tagCacheManager.cacheState(lsxContext, {
+        isError: false,
+        errorMessage: '',
+        basisViewersCount,
+        nodeTree: newNodeTree,
+      });
+    }
+    catch (error) {
+      setError(true);
+      setErrorMessage(error.message);
+
+      // store to sessionStorage
+      tagCacheManager.cacheState(lsxContext, {
+        isError: true,
+        errorMessage: error.message,
+      });
+    }
+    finally {
+      setLoading(false);
+    }
+  }, [basisViewersCount, lsxContext]);
+
+  useEffect(() => {
+    // get state object cache
+    const stateCache = retrieveDataFromCache();
+
+    if (stateCache != null) {
+      setCacheExists(true);
+      setNodeTree(stateCache.nodeTree);
+      setError(stateCache.isError);
+      setErrorMessage(stateCache.errorMessage);
+
+      // switch behavior by forceToFetchData
+      if (!forceToFetchData) {
+        return; // go to render()
+      }
+    }
+
+    loadData();
+  }, [forceToFetchData, loadData, retrieveDataFromCache]);
+
+  const renderContents = () => {
+    if (isError) {
+      return (
+        <div className="text-warning">
+          <i className="fa fa-exclamation-triangle fa-fw"></i>
+          {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
+        </div>
+      );
+    }
+
+    const showListView = nodeTree != null && (!isLoading || nodeTree.length > 0);
+
+    return (
+      <>
+        { isLoading && (
+          <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
+            <small>
+              <i className="fa fa-spinner fa-pulse mr-1"></i>
+              {lsxContext.toString()}
+              { isCacheExists && <>&nbsp;(Showing cache..)</> }
+            </small>
+          </div>
+        ) }
+        { showListView && (
+          <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />
+        ) }
+      </>
+    );
+  };
+
+  return <div className={`lsx ${styles.lsx}`}>{renderContents()}</div>;
+};

+ 1 - 1
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx → packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx

@@ -2,8 +2,8 @@ import React from 'react';
 
 
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
 import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
 
 
 import { LsxPage } from './LsxPage';
 import { LsxPage } from './LsxPage';
 
 

+ 2 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx → packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx

@@ -4,8 +4,8 @@ import { pathUtils } from '@growi/core';
 import { PageListMeta } from '@growi/ui';
 import { PageListMeta } from '@growi/ui';
 import PropTypes from 'prop-types';
 import PropTypes from 'prop-types';
 
 
-import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
 import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
 
 
 import { PagePathWrapper } from './PagePathWrapper';
 import { PagePathWrapper } from './PagePathWrapper';
 
 
@@ -33,7 +33,7 @@ export class LsxPage extends React.Component {
 
 
     // process depth option
     // process depth option
     const optDepth = this.props.lsxContext.getOptDepth();
     const optDepth = this.props.lsxContext.getOptDepth();
-    if (optDepth === undefined) {
+    if (optDepth == null) {
       this.setState({ isLinkable: true });
       this.setState({ isLinkable: true });
     }
     }
     else {
     else {

+ 0 - 0
packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx → packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx


+ 2 - 0
packages/plugin-lsx/src/client/js/components/PageNode.js → packages/plugin-lsx/src/components/PageNode.js

@@ -3,6 +3,8 @@ export class PageNode {
   constructor(pagePath) {
   constructor(pagePath) {
     this.pagePath = pagePath;
     this.pagePath = pagePath;
     this.children = [];
     this.children = [];
+
+    this.page = undefined;
   }
   }
 
 
   /**
   /**

+ 1 - 0
packages/plugin-lsx/src/components/index.ts

@@ -0,0 +1 @@
+export { Lsx } from './Lsx';

+ 48 - 0
packages/plugin-lsx/src/components/lsx-context.ts

@@ -0,0 +1,48 @@
+import { customTagUtils, ParseRangeResult } from '@growi/core';
+
+const { OptionParser } = customTagUtils;
+
+
+export class LsxContext {
+
+  pagePath: string;
+
+  options?: Record<string, string|undefined>;
+
+  constructor(pagePath: string, options: Record<string, string|undefined>) {
+    this.pagePath = pagePath;
+
+    // remove undefined keys
+    Object.keys(options).forEach(key => options[key] === undefined && delete options[key]);
+
+    this.options = options;
+  }
+
+  getOptDepth(): ParseRangeResult | null {
+    if (this.options?.depth == null) {
+      return null;
+    }
+    return OptionParser.parseRange(this.options.depth);
+  }
+
+  getStringifiedAttributes(separator = ', '): string {
+    const attributeStrs = [`prefix=${this.pagePath}`];
+    if (this.options != null) {
+      const optionEntries = Object.entries(this.options).sort();
+      attributeStrs.push(
+        ...optionEntries.map(([key, val]) => `${key}=${val || 'true'}`),
+      );
+    }
+
+    return attributeStrs.join(separator);
+  }
+
+  /**
+   * for printing errors
+   * @returns
+   */
+  toString(): string {
+    return `$lsx(${this.getStringifiedAttributes()})`;
+  }
+
+}

+ 21 - 0
packages/plugin-lsx/src/components/tag-cache-manager.ts

@@ -0,0 +1,21 @@
+import { TagCacheManager } from '@growi/core';
+
+import { LsxContext } from './lsx-context';
+
+const LSX_STATE_CACHE_NS = 'lsx-state-cache';
+
+
+let _instance;
+
+export function getInstance(): TagCacheManager {
+  if (_instance == null) {
+    // create generateCacheKey implementation
+    const generateCacheKey = (lsxContext: LsxContext) => {
+      return `${lsxContext.pagePath}__${lsxContext.getStringifiedAttributes('_')}`;
+    };
+
+    _instance = new TagCacheManager(LSX_STATE_CACHE_NS, generateCacheKey);
+  }
+
+  return _instance;
+}

+ 0 - 11
packages/plugin-lsx/src/index.js

@@ -1,11 +0,0 @@
-const isProd = process.env.NODE_ENV === 'production';
-
-module.exports = {
-  pluginSchemaVersion: 4,
-  serverEntries: [
-    isProd ? 'dist/cjs/server-entry.js' : 'src/server-entry.js',
-  ],
-  clientEntries: [
-    'src/client-entry.js',
-  ],
-};

+ 6 - 0
packages/plugin-lsx/src/index.ts

@@ -0,0 +1,6 @@
+import * as _serverRoutes from './server/routes';
+
+export const serverRoutes = _serverRoutes;
+
+export * from './components';
+export * from './services/renderer';

+ 0 - 4
packages/plugin-lsx/src/server-entry.js

@@ -1,4 +0,0 @@
-module.exports = (crowi, app) => {
-  // add routes
-  require('./server/routes')(crowi, app);
-};

+ 18 - 20
packages/plugin-lsx/src/server/routes/lsx.js

@@ -135,22 +135,13 @@ class Lsx {
    */
    */
   static addSortCondition(query, pagePath, optionsSortArg, optionsReverse) {
   static addSortCondition(query, pagePath, optionsSortArg, optionsReverse) {
     // init sort key
     // init sort key
-    const optionsSort = optionsSortArg || 'path';
+    const optionsSort = optionsSortArg ?? 'path';
 
 
     // the default sort order
     // the default sort order
-    let isReversed = false;
+    const isReversed = optionsReverse === 'true';
 
 
-    if (optionsSort != null) {
-      if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
-        throw new Error(`The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
-      }
-    }
-
-    if (optionsReverse != null) {
-      if (optionsReverse !== 'true' && optionsReverse !== 'false') {
-        throw new Error(`The specified value '${optionsReverse}' for the reverse option is invalid. It must be 'true' or 'false'.`);
-      }
-      isReversed = (optionsReverse === 'true');
+    if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
+      throw new Error(`The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
     }
     }
 
 
     const sortOption = {};
     const sortOption = {};
@@ -162,8 +153,6 @@ class Lsx {
 
 
 module.exports = (crowi, app) => {
 module.exports = (crowi, app) => {
   const Page = crowi.model('Page');
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const ApiResponse = crowi.require('../util/apiResponse');
   const actions = {};
   const actions = {};
 
 
   /**
   /**
@@ -203,8 +192,17 @@ module.exports = (crowi, app) => {
 
 
   actions.listPages = async(req, res) => {
   actions.listPages = async(req, res) => {
     const user = req.user;
     const user = req.user;
-    const pagePath = req.query.pagePath;
-    const options = JSON.parse(req.query.options);
+
+    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);
     const builder = await generateBaseQueryBuilder(pagePath, user);
 
 
@@ -221,7 +219,7 @@ module.exports = (crowi, app) => {
         : 1;
         : 1;
     }
     }
     catch (error) {
     catch (error) {
-      return res.json(ApiResponse.error(error));
+      return res.status(500).send(error);
     }
     }
 
 
     let query = builder.query;
     let query = builder.query;
@@ -244,10 +242,10 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
 
       const pages = await query.exec();
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages, toppageViewersCount }));
+      res.status(200).send({ pages, toppageViewersCount });
     }
     }
     catch (error) {
     catch (error) {
-      return res.json(ApiResponse.error(error));
+      return res.status(500).send(error);
     }
     }
   };
   };
 
 

+ 1 - 0
packages/plugin-lsx/src/services/renderer/index.ts

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

+ 108 - 0
packages/plugin-lsx/src/services/renderer/lsx.ts

@@ -0,0 +1,108 @@
+import assert from 'assert';
+
+import { pathUtils } from '@growi/core';
+import { RemarkGrowiPluginType } from '@growi/remark-growi-plugin';
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { selectAll, HastNode } from 'hast-util-select';
+import { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+const NODE_NAME_PATTERN = new RegExp(/ls|lsx/);
+const SUPPORTED_ATTRIBUTES = ['prefix', 'num', 'depth', 'sort', 'reverse', 'filter'];
+
+const { hasHeadingSlash } = pathUtils;
+
+type DirectiveAttributes = Record<string, string>
+
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === RemarkGrowiPluginType.Text || node.type === RemarkGrowiPluginType.Leaf) {
+        if (typeof node.name !== 'string') {
+          return;
+        }
+        if (!NODE_NAME_PATTERN.test(node.name)) {
+          return;
+        }
+
+        const data = node.data ?? (node.data = {});
+        const attributes = node.attributes as DirectiveAttributes || {};
+
+        // set 'prefix' attribute if the first attribute is only value
+        // e.g.
+        //   case 1: lsx(prefix=/path..., ...)    => prefix="/path"
+        //   case 2: lsx(/path, ...)              => prefix="/path"
+        //   case 3: lsx(/foo, prefix=/bar ...)   => prefix="/bar"
+        if (attributes.prefix == null) {
+          const attrEntries = Object.entries(attributes);
+
+          if (attrEntries.length > 0) {
+            const [firstAttrKey, firstAttrValue] = attrEntries[0];
+
+            if (firstAttrValue === '' && !SUPPORTED_ATTRIBUTES.includes(firstAttrValue)) {
+              attributes.prefix = firstAttrKey;
+            }
+          }
+        }
+
+        data.hName = 'lsx';
+        data.hProperties = attributes;
+      }
+    });
+  };
+};
+
+export type LsxRehypePluginParams = {
+  pagePath?: string,
+}
+
+const pathResolver = (relativeHref: string, basePath: string): string => {
+  // generate relative pathname
+  const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
+  const relativeUrl = new URL(relativeHref, baseUrl);
+
+  return relativeUrl.pathname;
+};
+
+export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
+  assert.notStrictEqual(options.pagePath, null, 'lsx rehype plugin requires \'pagePath\' option');
+
+  return (tree) => {
+    if (options.pagePath == null) {
+      return;
+    }
+
+    const basePagePath = options.pagePath;
+    const elements = selectAll('lsx', tree as HastNode);
+
+    elements.forEach((lsxElem) => {
+      if (lsxElem.properties == null) {
+        return;
+      }
+
+      const prefix = lsxElem.properties.prefix;
+
+      // set basePagePath when prefix is undefined or invalid
+      if (prefix == null || typeof prefix !== 'string') {
+        lsxElem.properties.prefix = basePagePath;
+        return;
+      }
+
+      // return when prefix is already determined and aboslute path
+      if (hasHeadingSlash(prefix)) {
+        return;
+      }
+
+      // resolve relative path
+      lsxElem.properties.prefix = pathResolver(prefix, basePagePath);
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['lsx'],
+  attributes: {
+    lsx: SUPPORTED_ATTRIBUTES,
+  },
+};

+ 1 - 0
packages/plugin-lsx/tsconfig.base.json

@@ -1,6 +1,7 @@
 {
 {
   "extends": "../../tsconfig.base.json",
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
   "compilerOptions": {
+    "jsx": "preserve",
   },
   },
   "include": [
   "include": [
     "src"
     "src"

+ 13 - 12
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js

@@ -10,13 +10,14 @@ import {
   asciiAlpha,
   asciiAlpha,
   asciiAlphanumeric,
   asciiAlphanumeric,
   markdownLineEnding,
   markdownLineEnding,
-  markdownLineEndingOrSpace,
   markdownSpace,
   markdownSpace,
 } from 'micromark-util-character';
 } from 'micromark-util-character';
 import { codes } from 'micromark-util-symbol/codes.js';
 import { codes } from 'micromark-util-symbol/codes.js';
 import { types } from 'micromark-util-symbol/types.js';
 import { types } from 'micromark-util-symbol/types.js';
 import { ok as assert } from 'uvu/assert';
 import { ok as assert } from 'uvu/assert';
 
 
+import { markdownLineEndingOrSpaceOrComma, factoryAttributesDevider } from '../../micromark-factory-attributes-devider/index.js';
+
 /**
 /**
  * @param {Effects} effects
  * @param {Effects} effects
  * @param {State} ok
  * @param {State} ok
@@ -81,7 +82,7 @@ export function factoryAttributes(
       return shortcutStart(code);
       return shortcutStart(code);
     }
     }
 
 
-    if (code === codes.colon || code === codes.underscore || asciiAlpha(code)) {
+    if (code === codes.colon || code === codes.underscore || code === codes.slash || asciiAlpha(code)) {
       effects.enter(attributeType);
       effects.enter(attributeType);
       effects.enter(attributeNameType);
       effects.enter(attributeNameType);
       effects.consume(code);
       effects.consume(code);
@@ -92,8 +93,8 @@ export function factoryAttributes(
       return factorySpace(effects, between, types.whitespace)(code);
       return factorySpace(effects, between, types.whitespace)(code);
     }
     }
 
 
-    if (!disallowEol && markdownLineEndingOrSpace(code)) {
-      return factoryWhitespace(effects, between)(code);
+    if (!disallowEol && (markdownLineEndingOrSpaceOrComma(code))) {
+      return factoryAttributesDevider(effects, between)(code);
     }
     }
 
 
     return end(code);
     return end(code);
@@ -122,7 +123,7 @@ export function factoryAttributes(
       || code === codes.greaterThan
       || code === codes.greaterThan
       || code === codes.graveAccent
       || code === codes.graveAccent
       || code === codes.rightParenthesis
       || code === codes.rightParenthesis
-      || markdownLineEndingOrSpace(code)
+      || markdownLineEndingOrSpaceOrComma(code)
     ) {
     ) {
       return nok(code);
       return nok(code);
     }
     }
@@ -150,7 +151,7 @@ export function factoryAttributes(
       code === codes.numberSign
       code === codes.numberSign
       || code === codes.dot
       || code === codes.dot
       || code === codes.rightParenthesis
       || code === codes.rightParenthesis
-      || markdownLineEndingOrSpace(code)
+      || markdownLineEndingOrSpaceOrComma(code)
     ) {
     ) {
       effects.exit(`${type}Value`);
       effects.exit(`${type}Value`);
       effects.exit(type);
       effects.exit(type);
@@ -181,8 +182,8 @@ export function factoryAttributes(
       return factorySpace(effects, nameAfter, types.whitespace)(code);
       return factorySpace(effects, nameAfter, types.whitespace)(code);
     }
     }
 
 
-    if (!disallowEol && markdownLineEndingOrSpace(code)) {
-      return factoryWhitespace(effects, nameAfter)(code);
+    if (!disallowEol && markdownLineEndingOrSpaceOrComma(code)) {
+      return factoryAttributesDevider(effects, nameAfter)(code);
     }
     }
 
 
     return nameAfter(code);
     return nameAfter(code);
@@ -229,8 +230,8 @@ export function factoryAttributes(
       return factorySpace(effects, valueBefore, types.whitespace)(code);
       return factorySpace(effects, valueBefore, types.whitespace)(code);
     }
     }
 
 
-    if (!disallowEol && markdownLineEndingOrSpace(code)) {
-      return factoryWhitespace(effects, valueBefore)(code);
+    if (!disallowEol && markdownLineEndingOrSpaceOrComma(code)) {
+      return factoryAttributesDevider(effects, valueBefore)(code);
     }
     }
 
 
     effects.enter(attributeValueType);
     effects.enter(attributeValueType);
@@ -254,7 +255,7 @@ export function factoryAttributes(
       return nok(code);
       return nok(code);
     }
     }
 
 
-    if (code === codes.rightParenthesis || markdownLineEndingOrSpace(code)) {
+    if (code === codes.rightParenthesis || markdownLineEndingOrSpaceOrComma(code)) {
       effects.exit(attributeValueData);
       effects.exit(attributeValueData);
       effects.exit(attributeValueType);
       effects.exit(attributeValueType);
       effects.exit(attributeType);
       effects.exit(attributeType);
@@ -316,7 +317,7 @@ export function factoryAttributes(
 
 
   /** @type {State} */
   /** @type {State} */
   function valueQuotedAfter(code) {
   function valueQuotedAfter(code) {
-    return code === codes.rightParenthesis || markdownLineEndingOrSpace(code)
+    return code === codes.rightParenthesis || markdownLineEndingOrSpaceOrComma(code)
       ? between(code)
       ? between(code)
       : end(code);
       : end(code);
   }
   }

+ 12 - 0
packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.d.ts

@@ -0,0 +1,12 @@
+/**
+ * @param {Effects} effects
+ * @param {State} ok
+ */
+export function factoryAttributesDevider(
+  effects: Effects,
+  ok: State
+): (
+  code: import('micromark-util-types').Code
+) => void | import('micromark-util-types').State
+export type Effects = import('micromark-util-types').Effects
+export type State = import('micromark-util-types').State

+ 51 - 0
packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.js

@@ -0,0 +1,51 @@
+/**
+ * @typedef {import('micromark-util-types').Effects} Effects
+ * @typedef {import('micromark-util-types').State} State
+ */
+import { factorySpace } from 'micromark-factory-space';
+import { markdownLineEnding, markdownSpace } from 'micromark-util-character';
+import { codes } from 'micromark-util-symbol/codes.js';
+
+export function markdownLineEndingOrSpaceOrComma(code) {
+  return code !== null && (code < codes.nul || code === codes.space || code === codes.comma);
+}
+
+/**
+ * @param {Effects} effects
+ * @param {State} ok
+ */
+export function factoryAttributesDevider(effects, ok) {
+  /** @type {boolean} */
+  let seen;
+  return start;
+  /** @type {State} */
+
+  function start(code) {
+    if (markdownLineEnding(code)) {
+      effects.enter('lineEnding');
+      effects.consume(code);
+      effects.exit('lineEnding');
+      seen = true;
+      return start;
+    }
+
+    // consume comma
+    if (code === codes.comma) {
+      effects.enter('attributeDevider');
+      effects.consume(code);
+      effects.exit('attributeDevider');
+      seen = true;
+      return start;
+    }
+
+    if (markdownSpace(code)) {
+      return factorySpace(
+        effects,
+        start,
+        seen ? 'linePrefix' : 'lineSuffix',
+      )(code);
+    }
+
+    return ok(code);
+  }
+}

+ 142 - 0
packages/remark-growi-plugin/src/micromark-factory-attributes-devider/readme.md

@@ -0,0 +1,142 @@
+# micromark-factory-whitespace
+
+[![Build][build-badge]][build]
+[![Coverage][coverage-badge]][coverage]
+[![Downloads][downloads-badge]][downloads]
+[![Size][bundle-size-badge]][bundle-size]
+[![Sponsors][sponsors-badge]][opencollective]
+[![Backers][backers-badge]][opencollective]
+[![Chat][chat-badge]][chat]
+
+micromark factory to parse [markdown line endings or spaces][ws] (found in lots
+of places).
+
+## Contents
+
+*   [Install](#install)
+*   [Use](#use)
+*   [API](#api)
+    *   [`factoryWhitespace(…)`](#factorywhitespace)
+*   [Security](#security)
+*   [Contribute](#contribute)
+*   [License](#license)
+
+## Install
+
+[npm][]:
+
+```sh
+npm install micromark-factory-whitespace
+```
+
+## Use
+
+```js
+import {factoryWhitespace} from 'micromark-factory-whitespace'
+import {codes} from 'micromark-util-symbol/codes'
+import {types} from 'micromark-util-symbol/types'
+
+// A micromark tokenizer that uses the factory:
+/** @type {Tokenizer} */
+function tokenizeTitle(effects, ok, nok) {
+  return start
+
+  /** @type {State} */
+  function start(code) {
+    return markdownLineEndingOrSpace(code)
+      ? factoryWhitespace(effects, before)(code)
+      : nok(code)
+  }
+
+  // …
+}
+```
+
+## API
+
+This module exports the following identifiers: `factoryWhitespace`.
+There is no default export.
+
+### `factoryWhitespace(…)`
+
+Note that there is no `nok` parameter:
+
+*   line endings or spaces in markdown are often optional, in which case this
+    factory can be used and `ok` will be switched to whether spaces were found
+    or not,
+*   One line ending or space can be detected with
+    [markdownLineEndingOrSpace(code)][ws] right before using `factoryWhitespace`
+
+###### Parameters
+
+*   `effects` (`Effects`) — Context
+*   `ok` (`State`) — State switched to when successful
+
+###### Returns
+
+`State`.
+
+## Security
+
+See [`security.md`][securitymd] in [`micromark/.github`][health] for how to
+submit a security report.
+
+## Contribute
+
+See [`contributing.md`][contributing] in [`micromark/.github`][health] for ways
+to get started.
+See [`support.md`][support] for ways to get help.
+
+This project has a [code of conduct][coc].
+By interacting with this repository, organisation, or community you agree to
+abide by its terms.
+
+## License
+
+[MIT][license] © [Titus Wormer][author]
+
+<!-- Definitions -->
+
+[build-badge]: https://github.com/micromark/micromark/workflows/main/badge.svg
+
+[build]: https://github.com/micromark/micromark/actions
+
+[coverage-badge]: https://img.shields.io/codecov/c/github/micromark/micromark.svg
+
+[coverage]: https://codecov.io/github/micromark/micromark
+
+[downloads-badge]: https://img.shields.io/npm/dm/micromark-factory-whitespace.svg
+
+[downloads]: https://www.npmjs.com/package/micromark-factory-whitespace
+
+[bundle-size-badge]: https://img.shields.io/bundlephobia/minzip/micromark-factory-whitespace.svg
+
+[bundle-size]: https://bundlephobia.com/result?p=micromark-factory-whitespace
+
+[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg
+
+[backers-badge]: https://opencollective.com/unified/backers/badge.svg
+
+[opencollective]: https://opencollective.com/unified
+
+[npm]: https://docs.npmjs.com/cli/install
+
+[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg
+
+[chat]: https://github.com/micromark/micromark/discussions
+
+[license]: https://github.com/micromark/micromark/blob/main/license
+
+[author]: https://wooorm.com
+
+[health]: https://github.com/micromark/.github
+
+[securitymd]: https://github.com/micromark/.github/blob/HEAD/security.md
+
+[contributing]: https://github.com/micromark/.github/blob/HEAD/contributing.md
+
+[support]: https://github.com/micromark/.github/blob/HEAD/support.md
+
+[coc]: https://github.com/micromark/.github/blob/HEAD/code-of-conduct.md
+
+[ws]: https://github.com/micromark/micromark/tree/main/packages/micromark-util-character#markdownlineendingorspacecode

+ 28 - 0
packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js

@@ -969,6 +969,34 @@ test('content', (t) => {
     'should support `class` shortcuts after `class` attributes',
     'should support `class` shortcuts after `class` attributes',
   );
   );
 
 
+  t.test('spec for growi plugin', (t) => {
+    t.equal(
+      micromark('a $lsx(/Sandbox)', options()),
+      '<p>a </p>',
+      'should support name with slash',
+    );
+
+    t.equal(
+      micromark('a $lsx(key=value, reverse)', options()),
+      '<p>a </p>',
+      'should support name=value and an attribute w/o value',
+    );
+
+    t.equal(
+      micromark('a $lsx(key=value, reverse, reverse2)', options()),
+      '<p>a </p>',
+      'should support consecutive attributes w/o value',
+    );
+
+    t.equal(
+      micromark('a $lsx(/Sandbox, key=value, reverse)', options()),
+      '<p>a </p>',
+      'should support name=value after an empty value attribute',
+    );
+
+    t.end();
+  });
+
   t.end();
   t.end();
 });
 });
 
 

+ 10 - 0
yarn.lock

@@ -3957,6 +3957,11 @@
   resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
   resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
   integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
   integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
 
 
+"@types/css-modules@^1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/css-modules/-/css-modules-1.0.2.tgz#8884135f9be3e204b42ef7ad7fce2474e8d74cb6"
+  integrity sha512-tyqlt2GtEBdsxJylh78zSxI/kOJK5Iz8Ta4Fxr8KLTP8mD/IgMa84D8EKPS/AWCp+MDoctgJyikrVWY28GKmcg==
+
 "@types/debug@^0.0.30":
 "@types/debug@^0.0.30":
   version "0.0.30"
   version "0.0.30"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.30.tgz#dc1e40f7af3b9c815013a7860e6252f6352a84df"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.30.tgz#dc1e40f7af3b9c815013a7860e6252f6352a84df"
@@ -22991,6 +22996,11 @@ try-resolve@^1.0.1:
   resolved "https://registry.yarnpkg.com/try-resolve/-/try-resolve-1.0.1.tgz#cfde6fabd72d63e5797cfaab873abbe8e700e912"
   resolved "https://registry.yarnpkg.com/try-resolve/-/try-resolve-1.0.1.tgz#cfde6fabd72d63e5797cfaab873abbe8e700e912"
   integrity sha1-z95vq9ctY+V5fPqrhzq76OcA6RI=
   integrity sha1-z95vq9ctY+V5fPqrhzq76OcA6RI=
 
 
+ts-deepmerge@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ts-deepmerge/-/ts-deepmerge-3.0.0.tgz#231c48901606eb104ab51a74cb447af0e9e669e4"
+  integrity sha512-gpjFrde/nE3jp7l5cJTDpyhdbGdAIO/AkNjaz4V9odnopdLd9NVrQcBDEBiE/ucMV9dmNOcdgzOVwS7U6SsBhA==
+
 ts-essentials@9.0.0:
 ts-essentials@9.0.0:
   version "9.0.0"
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.0.0.tgz#6196b7f390926429256c70951c8edd260e8e5097"
   resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.0.0.tgz#6196b7f390926429256c70951c8edd260e8e5097"