Browse Source

Merge branch 'support/apply-nextjs-2' into imprv/102140-create-new-pages

kaori 3 years ago
parent
commit
203b64cc3e
100 changed files with 2297 additions and 2166 deletions
  1. 4 0
      package.json
  2. 0 56
      packages/app/bin/generate-plugin-definitions-source.ts
  3. 2 1
      packages/app/jest.config.js
  4. 4 6
      packages/app/package.json
  5. 22 2
      packages/app/src/components/Admin/AuditLog/ActivityTable.tsx
  6. 61 19
      packages/app/src/components/Admin/AuditLogManagement.tsx
  7. 3 0
      packages/app/src/components/Layout/Admin.module.scss
  8. 23 0
      packages/app/src/components/PageComment.module.scss
  9. 67 51
      packages/app/src/components/PageComment.tsx
  10. 98 0
      packages/app/src/components/PageComment/Comment.module.scss
  11. 18 17
      packages/app/src/components/PageComment/Comment.tsx
  12. 11 11
      packages/app/src/components/PageComment/CommentControl.tsx
  13. 43 0
      packages/app/src/components/PageComment/CommentEditor.module.scss
  14. 32 72
      packages/app/src/components/PageComment/CommentEditor.tsx
  15. 28 14
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  16. 0 29
      packages/app/src/components/PageComment/CommentPreview.jsx
  17. 27 0
      packages/app/src/components/PageComment/CommentPreview.tsx
  18. 0 70
      packages/app/src/components/PageComment/DeleteCommentModal.jsx
  19. 10 0
      packages/app/src/components/PageComment/DeleteCommentModal.module.scss
  20. 93 0
      packages/app/src/components/PageComment/DeleteCommentModal.tsx
  21. 0 114
      packages/app/src/components/PageComment/ReplayComments.jsx
  22. 11 0
      packages/app/src/components/PageComment/ReplyComments.module.scss
  23. 101 0
      packages/app/src/components/PageComment/ReplyComments.tsx
  24. 34 0
      packages/app/src/components/PageComment/_comment-inheritance.scss
  25. 58 0
      packages/app/src/components/PageCommentSkelton.tsx
  26. 13 0
      packages/app/src/components/PageContentFooter.module.scss
  27. 18 19
      packages/app/src/components/PageContentFooter.tsx
  28. 1 1
      packages/app/src/components/PageEditor/Editor.tsx
  29. 0 482
      packages/app/src/components/PageEditorByHackmd.jsx
  30. 409 0
      packages/app/src/components/PageEditorByHackmd.tsx
  31. 1 1
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  32. 2 7
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  33. 7 3
      packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx
  34. 7 0
      packages/app/src/interfaces/editor-settings.ts
  35. 16 0
      packages/app/src/interfaces/hackmd.ts
  36. 1 1
      packages/app/src/interfaces/services/renderer.ts
  37. 23 13
      packages/app/src/pages/[[...path]].page.tsx
  38. 176 0
      packages/app/src/pages/_private-legacy-pages.page.tsx
  39. 10 0
      packages/app/src/pages/admin/[[...path]].page.tsx
  40. 1 1
      packages/app/src/server/crowi/express-init.js
  41. 6 5
      packages/app/src/server/crowi/index.js
  42. 0 40
      packages/app/src/server/plugins/plugin-utils-v2.js
  43. 0 38
      packages/app/src/server/plugins/plugin-utils-v4.ts
  44. 1 44
      packages/app/src/server/plugins/plugin-utils.js
  45. 0 72
      packages/app/src/server/plugins/plugin.service.js
  46. 4 4
      packages/app/src/server/routes/apiv3/export.js
  47. 7 7
      packages/app/src/server/routes/apiv3/import.js
  48. 10 8
      packages/app/src/server/routes/apiv3/slack-integration.js
  49. 2 3
      packages/app/src/server/routes/index.js
  50. 118 39
      packages/app/src/services/renderer/renderer.tsx
  51. 9 0
      packages/app/src/stores/context.tsx
  52. 5 43
      packages/app/src/styles/_comment.scss
  53. 2 121
      packages/app/src/styles/_comment_growi.scss
  54. 0 6
      packages/app/src/styles/_page-content-footer.scss
  55. 1 0
      packages/app/tsconfig.build.client.json
  56. 10 0
      packages/app/tsconfig.build.server-tsc-alias.json
  57. 2 1
      packages/app/tsconfig.build.server.json
  58. 1 0
      packages/app/tsconfig.json
  59. 2 3
      packages/core/src/index.ts
  60. 4 0
      packages/core/src/plugin/interfaces/option-parser.ts
  61. 0 11
      packages/core/src/plugin/interfaces/plugin-definition-v4.ts
  62. 8 4
      packages/core/src/plugin/model/tag-context.ts
  63. 2 4
      packages/core/src/plugin/util/args-parser.js
  64. 0 88
      packages/core/src/plugin/util/custom-tag-utils.js
  65. 5 0
      packages/core/src/plugin/util/custom-tag-utils.ts
  66. 4 10
      packages/core/src/plugin/util/option-parser.ts
  67. 2 2
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  68. 1 1
      packages/core/src/test/plugin/util/args-parser.test.js
  69. 50 48
      packages/core/src/test/plugin/util/custom-tag-utils.test.js
  70. 1 1
      packages/core/src/test/plugin/util/option-parser.test.js
  71. 1 1
      packages/core/src/test/service/localstorage-manager.test.js
  72. 18 0
      packages/plugin-lsx/.eslintrc.js
  73. 10 2
      packages/plugin-lsx/package.json
  74. 0 12
      packages/plugin-lsx/src/client-entry.js
  75. 0 20
      packages/plugin-lsx/src/client/css/index.css
  76. 0 246
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  77. 0 33
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxLogoutInterceptor.js
  78. 0 58
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  79. 0 68
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js
  80. 0 61
      packages/plugin-lsx/src/client/js/util/LsxContext.js
  81. 0 22
      packages/plugin-lsx/src/client/js/util/TagCacheManagerFactory.js
  82. 26 0
      packages/plugin-lsx/src/components/Lsx.module.scss
  83. 265 0
      packages/plugin-lsx/src/components/Lsx.tsx
  84. 1 1
      packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx
  85. 2 2
      packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx
  86. 0 0
      packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx
  87. 2 0
      packages/plugin-lsx/src/components/PageNode.js
  88. 1 0
      packages/plugin-lsx/src/components/index.ts
  89. 48 0
      packages/plugin-lsx/src/components/lsx-context.ts
  90. 21 0
      packages/plugin-lsx/src/components/tag-cache-manager.ts
  91. 0 11
      packages/plugin-lsx/src/index.js
  92. 6 0
      packages/plugin-lsx/src/index.ts
  93. 0 4
      packages/plugin-lsx/src/server-entry.js
  94. 18 20
      packages/plugin-lsx/src/server/routes/lsx.js
  95. 1 0
      packages/plugin-lsx/src/services/renderer/index.ts
  96. 108 0
      packages/plugin-lsx/src/services/renderer/lsx.ts
  97. 1 0
      packages/plugin-lsx/tsconfig.base.json
  98. 13 12
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js
  99. 12 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.d.ts
  100. 51 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.js

+ 4 - 0
package.json

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

+ 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 = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
-  '^@growi/(.+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)$': '<rootDir>/../$1/src',
+  '^@growi/([^/]+)/(.+)$': '<rootDir>/../$1/src/$2',
 };
 
 module.exports = {

+ 4 - 6
packages/app/package.json

@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "build:client": "yarn next build",
     "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",
     "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",
@@ -48,7 +48,7 @@
     "resources:dummy": "true",
     "// resources:plugin": "yarn ts-node bin/generate-plugin-definitions-source.ts",
     "// resources:dl-resources": "yarn ts-node bin/download-cdn-resources.ts",
-    "ts-node": "ts-node -r tsconfig-paths/register -r dotenv-flow/config --transpile-only"
+    "ts-node": "node -r ts-node/register -r tsconfig-paths/register -r dotenv-flow/config"
   },
   "// comments for dependencies": {
     "openid-client": "Node.js 12 or higher is required for openid-client@3 and above.",
@@ -186,8 +186,7 @@
   },
   "// comments for defDependencies": {
     "@handsontable/react": "v3 requires handsontable >= 7.0.0.",
-    "handsontable": "v7.0.0 or above is no loger MIT lisence.",
-    "ts-node": "v10 occurs 'SyntaxError: Cannot use import statement outside a module' when using migrate-mongo"
+    "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
     "@alienfast/i18next-loader": "^1.1.4",
@@ -251,7 +250,6 @@
     "swr": "^1.3.0",
     "throttle-debounce": "^3.0.1",
     "toastr": "^2.1.2",
-    "ts-node": "^9.1.1",
     "ts-node-dev": "^2.0.0",
     "tsc-alias": "^1.2.9",
     "unstated": "^2.1.1"

+ 22 - 2
packages/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -1,9 +1,11 @@
-import React, { FC } from 'react';
+import React, { FC, useState, useCallback } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui';
 import { format } from 'date-fns';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
 import { useTranslation } from 'react-i18next';
+import { Tooltip } from 'reactstrap';
 
 import { IActivityHasId } from '~/interfaces/activity';
 
@@ -17,6 +19,14 @@ const formatDate = (date) => {
 
 export const ActivityTable : FC<Props> = (props: Props) => {
   const { t } = useTranslation();
+  const [tooltopOpen, setTooltipOpen] = useState(false);
+
+  const showToolTip = useCallback(() => {
+    setTooltipOpen(true);
+    setTimeout(() => {
+      setTooltipOpen(false);
+    }, 1000);
+  }, [setTooltipOpen]);
 
   return (
     <div className="table-responsive text-nowrap h-100">
@@ -45,7 +55,17 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                 <td>{formatDate(activity.createdAt)}</td>
                 <td>{t(`admin:audit_log_action.${activity.action}`)}</td>
                 <td>{activity.ip}</td>
-                <td>{activity.endpoint}</td>
+                <td>
+                  {activity.endpoint}
+                  <CopyToClipboard text={activity.endpoint} onCopy={showToolTip}>
+                    <button type="button" className="btn btn-outline-secondary border-0 pull-right" id="tooltipTarget">
+                      <i className="fa fa-clipboard" aria-hidden="true"></i>
+                    </button>
+                  </CopyToClipboard>
+                  <Tooltip placement="top" isOpen={tooltopOpen} fade={false} target="tooltipTarget">
+                    copied!
+                  </Tooltip>
+                </td>
               </tr>
             );
           })}

+ 61 - 19
packages/app/src/components/Admin/AuditLogManagement.tsx

@@ -40,8 +40,9 @@ export const AuditLogManagement: FC = () => {
    * State
    */
   const [isSettingPage, setIsSettingPage] = useState<boolean>(false);
-  const [activePage, setActivePage] = useState<number>(1);
-  const offset = (activePage - 1) * PAGING_LIMIT;
+  const [activePageNumber, setActivePageNumber] = useState<number>(1);
+  const [jumpPageNumber, setJumpPageNumber] = useState<number>(1);
+  const offset = (activePageNumber - 1) * PAGING_LIMIT;
   const [startDate, setStartDate] = useState<Date | null>(null);
   const [endDate, setEndDate] = useState<Date | null>(null);
   const [selectedUsernames, setSelectedUsernames] = useState<string[]>([]);
@@ -59,6 +60,7 @@ export const AuditLogManagement: FC = () => {
   const { data: activityData, mutate: mutateActivity, error } = useSWRxActivity(PAGING_LIMIT, offset, searchFilter);
   const activityList = activityData?.docs != null ? activityData.docs : [];
   const totalActivityNum = activityData?.totalDocs != null ? activityData.totalDocs : 0;
+  const totalPagingPages = activityData?.totalPages != null ? activityData.totalPages : 0;
   const isLoading = activityData === undefined && error == null;
 
   if (error != null) {
@@ -71,34 +73,34 @@ export const AuditLogManagement: FC = () => {
    * Functions
    */
   const setActivePageHandler = useCallback((selectedPageNum: number) => {
-    setActivePage(selectedPageNum);
+    setActivePageNumber(selectedPageNum);
   }, []);
 
   const datePickerChangedHandler = useCallback((dateList: Date[] | null[]) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setStartDate(dateList[0]);
     setEndDate(dateList[1]);
   }, []);
 
   const actionCheckboxChangedHandler = useCallback((action: SupportedActionType) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     actionMap.set(action, !actionMap.get(action));
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
 
   const multipleActionCheckboxChangedHandler = useCallback((actions: SupportedActionType[], isChecked) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     actions.forEach(action => actionMap.set(action, isChecked));
     setActionMap(new Map(actionMap.entries()));
   }, [actionMap, setActionMap]);
 
   const setUsernamesHandler = useCallback((usernames: string[]) => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setSelectedUsernames(usernames);
   }, []);
 
   const clearButtonPushedHandler = useCallback(() => {
-    setActivePage(1);
+    setActivePageNumber(1);
     setStartDate(null);
     setEndDate(null);
     setSelectedUsernames([]);
@@ -107,15 +109,39 @@ export const AuditLogManagement: FC = () => {
     if (auditLogAvailableActionsData != null) {
       setActionMap(new Map<SupportedActionType, boolean>(auditLogAvailableActionsData.map(action => [action, true])));
     }
-  }, [setActivePage, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
+  }, [setActivePageNumber, setStartDate, setEndDate, setSelectedUsernames, setActionMap, auditLogAvailableActionsData]);
 
   const reloadButtonPushedHandler = useCallback(() => {
-    setActivePage(1);
+    setActivePageNumber(1);
     mutateActivity();
   }, [mutateActivity]);
 
+  const jumpPageInputChangeHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    const inputNumber = Number(e.target.value);
+    const isNan = Number.isNaN(inputNumber);
+
+    if (!isNan) {
+      // eslint-disable-next-line no-nested-ternary
+      const jumpPageNumber = inputNumber > totalPagingPages ? totalPagingPages : inputNumber <= 0 ? activePageNumber : inputNumber;
+      setJumpPageNumber(jumpPageNumber);
+    }
+    else {
+      setJumpPageNumber(activePageNumber);
+    }
+  }, [totalPagingPages, activePageNumber, setJumpPageNumber]);
+
+  const jumpPageInputKeyDownHandler = useCallback((e) => {
+    if (e.key === 'Enter') {
+      setActivePageNumber(jumpPageNumber);
+    }
+  }, [setActivePageNumber, jumpPageNumber]);
+
+  const jumpPageButtonPushedHandler = useCallback(() => {
+    setActivePageNumber(jumpPageNumber);
+  }, [jumpPageNumber]);
+
   // eslint-disable-next-line max-len
-  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePage) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
+  const activityCounter = `<b>${activityList.length === 0 ? 0 : offset + 1}</b> - <b>${(PAGING_LIMIT * activePageNumber) - (PAGING_LIMIT - activityList.length)}</b> of <b>${totalActivityNum}<b/>`;
 
   if (!auditLogEnabled) {
     return <AuditLogDisableMode />;
@@ -187,14 +213,30 @@ export const AuditLogManagement: FC = () => {
             )
           }
 
-          <PaginationWrapper
-            activePage={activePage}
-            changePage={setActivePageHandler}
-            totalItemsCount={totalActivityNum}
-            pagingLimit={PAGING_LIMIT}
-            align="center"
-            size="sm"
-          />
+          <div className="d-flex flex-row justify-content-center">
+            <PaginationWrapper
+              activePage={activePageNumber}
+              changePage={setActivePageHandler}
+              totalItemsCount={totalActivityNum}
+              pagingLimit={PAGING_LIMIT}
+              align="center"
+              size="sm"
+            />
+
+            <div className="admin-audit-log ml-3">
+              <label htmlFor="jumpPageInput" className="mr-1 text-secondary">Jump To Page</label>
+              <input
+                id="jumpPageInput"
+                type="text"
+                className="jump-page-input"
+                onChange={jumpPageInputChangeHandler}
+                onKeyDown={jumpPageInputKeyDownHandler}
+              />
+              <button className="btn btn-sm" type="button" onClick={jumpPageButtonPushedHandler}>
+                <b>Go</b>
+              </button>
+            </div>
+          </div>
         </>
       )}
     </div>

+ 3 - 0
packages/app/src/components/Layout/Admin.module.scss

@@ -230,6 +230,9 @@ $slack-work-space-name-card-border: #efc1f6;
     .date-range-picker {
       width: 188px;
     }
+    .jump-page-input {
+      width: 50px;
+    }
   }
 
   #layoutOptions {

+ 23 - 0
packages/app/src/components/PageComment.module.scss

@@ -0,0 +1,23 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.page-comment-styles :global {
+  .page-comments {
+    h4 {
+      margin-bottom: 1em;
+    }
+  }
+
+  // reply button
+  .btn-comment-reply {
+    margin-top: 0.5em;
+    border: none;
+  }
+
+  // TODO: Refacotr Soft-coding
+  .page-comment-button-skelton {
+    width: 70.0167px;
+    height: 26.3833px;
+    margin-top: 0.5em;
+    border: none;
+  }
+}

+ 67 - 51
packages/app/src/components/PageComment.tsx

@@ -2,7 +2,7 @@ import React, {
   FC, useEffect, useState, useMemo, memo, useCallback,
 } from 'react';
 
-import { Nullable } from '@growi/core';
+import dynamic from 'next/dynamic';
 import { Button } from 'reactstrap';
 
 import { toastError } from '~/client/util/apiNotification';
@@ -15,19 +15,29 @@ import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
 
 import { Comment } from './PageComment/Comment';
-import CommentEditor from './PageComment/CommentEditor';
-import DeleteCommentModal from './PageComment/DeleteCommentModal';
-import ReplayComments from './PageComment/ReplayComments';
+import { CommentEditorProps } from './PageComment/CommentEditor';
+import { CommentEditorLazyRenderer } from './PageComment/CommentEditorLazyRenderer';
+import { DeleteCommentModalProps } from './PageComment/DeleteCommentModal';
+import { ReplyComments } from './PageComment/ReplyComments';
+import { PageCommentSkelton } from './PageCommentSkelton';
 
-type Props = {
-  pageId?: Nullable<string>, // TODO: check pageId type
-  isReadOnly : boolean,
+import styles from './PageComment.module.scss';
+
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./PageComment/CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+const DeleteCommentModal = dynamic<DeleteCommentModalProps>(
+  () => import('./PageComment/DeleteCommentModal').then(mod => mod.DeleteCommentModal), { ssr: false },
+);
+
+
+type PageCommentProps = {
+  pageId?: string,
+  isReadOnly: boolean,
   titleAlign?: 'center' | 'left' | 'right',
-  highlightKeywords?:string[],
+  highlightKeywords?: string[],
   hideIfEmpty?: boolean,
 }
 
-export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
+export const PageComment: FC<PageCommentProps> = memo((props:PageCommentProps): JSX.Element => {
 
   const {
     pageId, highlightKeywords, isReadOnly, titleAlign, hideIfEmpty,
@@ -61,7 +71,6 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   }, [highlightKeywords]);
 
   useEffect(() => {
-
     if (comments != null) {
       const preprocessedCommentList: string[] = comments.map((comment) => {
         const highlightedComment: string = highlightComment(comment.comment);
@@ -72,7 +81,6 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
       });
       setFormatedComments(preprocessedComments);
     }
-
   }, [comments, highlightComment]);
 
   if (commentsFromOldest != null) {
@@ -110,16 +118,6 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     }
   }, [commentToBeDeleted, onDeleteCommentAfterOperation]);
 
-  const generateAllRepliesElement = (replyComments: ICommentHasIdList) => (
-    // TODO: need page props path
-    <ReplayComments
-      replyList={replyComments}
-      deleteBtnClicked={onClickDeleteButton}
-      rendererOptions={rendererOptions}
-      isReadOnly={isReadOnly}
-    />
-  );
-
   const removeShowEditorId = useCallback((commentId: string) => {
     setShowEditorIds((previousState) => {
       const previousShowEditorIds = new Set(...previousState);
@@ -128,37 +126,52 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     });
   }, []);
 
-
-  if (commentsFromOldest == null || commentsExceptReply == null) return <></>;
-
   if (hideIfEmpty && comments?.length === 0) {
     return <></>;
   }
-  if (rendererOptions == null || currentPagePath == null) {
-    return <></>;
+
+  let commentTitleClasses = 'border-bottom py-3 mb-3';
+  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
+
+  if (commentsFromOldest == null || commentsExceptReply == null || rendererOptions == null || currentPagePath == null || currentPage == null) {
+    if (hideIfEmpty && comments?.length === 0) {
+      return <></>;
+    }
+    return (
+      <PageCommentSkelton commentTitleClasses={commentTitleClasses}/>
+    );
   }
 
-  const generateCommentInnerElement = (comment: ICommentHasId) => (
-    currentPage != null && (
-      <Comment
-        rendererOptions={rendererOptions}
-        deleteBtnClicked={onClickDeleteButton}
-        comment={comment}
-        onComment={mutate}
-        isReadOnly={isReadOnly}
-        currentPagePath={currentPagePath}
-        currentRevisionId={currentPage.revision._id}
-        currentRevisionCreatedAt={currentPage.revision.createdAt}
-      />
-    )
+  const generateCommentElement = (comment: ICommentHasId) => (
+    <Comment
+      comment={comment}
+      isReadOnly={isReadOnly}
+      deleteBtnClicked={onClickDeleteButton}
+      onComment={mutate}
+      rendererOptions={rendererOptions}
+      currentPagePath={currentPagePath}
+      currentRevisionId={currentPage.revision._id}
+      currentRevisionCreatedAt={currentPage.revision.createdAt}
+    />
   );
 
-  let commentTitleClasses = 'border-bottom py-3 mb-3';
-  commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
+  const generateReplyCommentsElement = (replyComments: ICommentHasIdList) => (
+    <ReplyComments
+      isReadOnly={isReadOnly}
+      replyList={replyComments}
+      deleteBtnClicked={onClickDeleteButton}
+      onComment={mutate}
+      rendererOptions={rendererOptions}
+      currentPagePath={currentPagePath}
+      currentRevisionId={currentPage.revision._id}
+      currentRevisionCreatedAt={currentPage.revision.createdAt}
+    />
+  );
 
   return (
     <>
-      <div className="page-comments-row comment-list">
+      {/* TODO: Check the comment.html CSS */}
+      <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
         <div className="container-lg">
           <div className="page-comments">
             <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
@@ -173,11 +186,8 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 
                 return (
                   <div key={comment._id} className={commentThreadClasses}>
-                    {/* display comment */}
-                    {generateCommentInnerElement(comment)}
-                    {/* display reply comment */}
-                    {hasReply && generateAllRepliesElement(allReplies[comment._id])}
-                    {/* display reply button */}
+                    {generateCommentElement(comment)}
+                    {hasReply && generateReplyCommentsElement(allReplies[comment._id])}
                     {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                       <div className="text-right">
                         <Button
@@ -193,7 +203,6 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
                         </Button>
                       </div>
                     )}
-                    {/* display reply editor */}
                     {(!isReadOnly && showEditorIds.has(comment._id)) && (
                       <CommentEditor
                         rendererOptions={rendererOptions}
@@ -212,16 +221,23 @@ export const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 
               })}
             </div>
+            {/* TODO: Check if identical-page */}
+            {(!isReadOnly) && (
+              <CommentEditorLazyRenderer
+                pageId={pageId}
+                rendererOptions={rendererOptions}
+              />
+            )}
           </div>
         </div>
       </div>
-      {(!isReadOnly && commentToBeDeleted != null) && (
+      {!isReadOnly && (
         <DeleteCommentModal
           isShown={isDeleteConfirmModalShown}
           comment={commentToBeDeleted}
           errorMessage={errorMessageOnDelete}
-          cancel={onCancelDeleteComment}
-          confirmedToDelete={onDeleteComment}
+          cancelToDelete={onCancelDeleteComment}
+          confirmToDelete={onDeleteComment}
         />
       )}
     </>

+ 98 - 0
packages/app/src/components/PageComment/Comment.module.scss

@@ -0,0 +1,98 @@
+@use '../../styles/bootstrap/init' as bs;
+@use './_comment-inheritance';
+
+.comment-styles :global {
+  .page-comment-writer {
+    @include bs.media-breakpoint-down(xs) {
+      height: 3.5em;
+    }
+  }
+
+  .page-comment {
+    position: relative;
+    padding-top: 70px;
+    margin-top: -70px;
+    pointer-events: none;
+
+    // user name
+    .page-comment-creator {
+      margin-top: -0.5em;
+      margin-bottom: 0.5em;
+      font-weight: bold;
+    }
+
+    // user icon
+    .picture {
+      @extend %picture;
+    }
+
+    // comment section
+    .page-comment-main {
+      @extend %comment-section;
+      @include bs.media-breakpoint-up(sm) {
+        margin-left: 4.5em;
+      }
+      @include bs.media-breakpoint-down(xs) {
+        &:before {
+          content: none;
+        }
+      }
+
+      pointer-events: auto;
+
+      // delete button
+      .page-comment-control {
+        position: absolute;
+        top: 0;
+        right: 0;
+        visibility: hidden;
+      }
+
+      &:hover > .page-comment-control {
+        visibility: visible;
+      }
+    }
+
+    // comment body
+    .page-comment-body {
+      margin-bottom: 0.5em;
+      word-wrap: break-word;
+    }
+
+    // older comments
+    &.page-comment-older {
+    }
+    // newer comments
+    &.page-comment-newer {
+      opacity: 0.7;
+
+      &:hover {
+        opacity: 1;
+      }
+    }
+
+    .page-comment-meta {
+      display: flex;
+      justify-content: flex-end;
+
+      font-size: 0.9em;
+      color: bs.$gray-400;
+    }
+
+    .page-comment-revision svg {
+      width: 16px;
+      height: 16px;
+    }
+  }
+
+  // TODO: Refacotr Soft-coding
+  .page-comment-comment-body-skelton {
+    position: relative;
+    height: 66px;
+    padding: 1em;
+    margin-left: 4.5em;
+    @include bs.media-breakpoint-down(xs) {
+      margin-left: 3.5em;
+    }
+  }
+}

+ 18 - 17
packages/app/src/components/PageComment/Comment.tsx

@@ -1,10 +1,9 @@
 import React, { useEffect, useState } from 'react';
 
-
 import { UserPicture } from '@growi/ui';
-import { ConsoleFormattedStream } from 'browser-bunyan';
 import { format } from 'date-fns';
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { UncontrolledTooltip } from 'reactstrap';
 
 import { RendererOptions } from '~/services/renderer/renderer';
@@ -16,8 +15,13 @@ import HistoryIcon from '../Icons/HistoryIcon';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import Username from '../User/Username';
 
-import CommentControl from './CommentControl';
-import CommentEditor from './CommentEditor';
+import { CommentControl } from './CommentControl';
+import { CommentEditorProps } from './CommentEditor';
+
+import styles from './Comment.module.scss';
+
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor), { ssr: false });
+
 
 type CommentProps = {
   comment: ICommentHasId,
@@ -31,9 +35,12 @@ type CommentProps = {
 }
 
 export const Comment = (props: CommentProps): JSX.Element => {
+
   const {
-    comment, isReadOnly, deleteBtnClicked, onComment, rendererOptions, currentPagePath, currentRevisionId, currentRevisionCreatedAt,
+    comment, isReadOnly, deleteBtnClicked, onComment, rendererOptions,
+    currentPagePath, currentRevisionId, currentRevisionCreatedAt,
   } = props;
+
   const { t } = useTranslation();
   const { data: currentUser } = useCurrentUser();
 
@@ -66,7 +73,7 @@ export const Comment = (props: CommentProps): JSX.Element => {
     return creator.username === currentUser.username;
   };
 
-  const getRootClassName = (comment) => {
+  const getRootClassName = (comment: ICommentHasId) => {
     let className = 'page-comment flex-column';
 
     if (comment.revision === currentRevisionId) {
@@ -86,19 +93,14 @@ export const Comment = (props: CommentProps): JSX.Element => {
     return className;
   };
 
-  const deleteBtnClickedHandler = (comment) => {
+  const deleteBtnClickedHandler = () => {
     deleteBtnClicked(comment);
   };
 
-  const renderText = (comment) => {
+  const renderText = (comment: string) => {
     return <span style={{ whiteSpace: 'pre-wrap' }}>{comment}</span>;
   };
 
-  // TODO: Remove when update ReplayComments.jsx
-  if (currentPagePath == null) {
-    return <></>;
-  }
-
   const renderRevisionBody = () => {
     return (
       <RevisionRenderer
@@ -120,14 +122,13 @@ export const Comment = (props: CommentProps): JSX.Element => {
     : null;
 
   return (
-    <>
+    <div className={`${styles['comment-styles']}`}>
       {(isReEdit && !isReadOnly) ? (
         <CommentEditor
           rendererOptions={rendererOptions}
+          replyTo={undefined}
           currentCommentId={commentId}
           commentBody={comment.comment}
-          replyTo={undefined}
-          commentCreator={creator?.username}
           onCancelButtonClicked={() => setIsReEdit(false)}
           onCommentButtonClicked={() => {
             setIsReEdit(false);
@@ -173,6 +174,6 @@ export const Comment = (props: CommentProps): JSX.Element => {
         </div>
       )
       }
-    </>
+    </div>
   );
 };

+ 11 - 11
packages/app/src/components/PageComment/CommentControl.jsx → packages/app/src/components/PageComment/CommentControl.tsx

@@ -1,25 +1,25 @@
 import React from 'react';
 
-import PropTypes from 'prop-types';
 
+type CommentControlProps = {
+  onClickEditBtn: () => void,
+  onClickDeleteBtn: () => void,
+}
+
+export const CommentControl = (props: CommentControlProps): JSX.Element => {
+
+  const { onClickEditBtn, onClickDeleteBtn } = props;
 
-const CommentControl = (props) => {
   return (
+    // The page-comment-control class is imported from Comment.module.scss
     <div className="page-comment-control">
-      <button type="button" className="btn btn-link p-2" onClick={props.onClickEditBtn}>
+      <button type="button" className="btn btn-link p-2" onClick={onClickEditBtn}>
         <i className="ti ti-pencil"></i>
       </button>
-      <button type="button" className="btn btn-link p-2 mr-2" onClick={props.onClickDeleteBtn}>
+      <button type="button" className="btn btn-link p-2 mr-2" onClick={onClickDeleteBtn}>
         <i className="ti ti-close"></i>
       </button>
     </div>
   );
-};
 
-CommentControl.propTypes = {
-
-  onClickEditBtn: PropTypes.func.isRequired,
-  onClickDeleteBtn: PropTypes.func.isRequired,
 };
-
-export default CommentControl;

+ 43 - 0
packages/app/src/components/PageComment/CommentEditor.module.scss

@@ -0,0 +1,43 @@
+@use '~/styles/bootstrap/init' as bs;
+@use './_comment-inheritance';
+
+// display cheatsheet for comment form only
+.comment-editor-styles :global {
+  .comment-form {
+    position: relative;
+    margin-top: 1em;
+
+    // user icon
+    .picture {
+      @extend %picture;
+    }
+
+    // seciton
+    .comment-form-main {
+      @extend %comment-section;
+      margin-left: 4.5em;
+      @include bs.media-breakpoint-down(xs) {
+        margin-left: 3.5em;
+      }
+    }
+
+    // textarea
+    .comment-write {
+      margin-bottom: 0.5em;
+    }
+    .comment-form-preview {
+      padding-top: 0.5em;
+    }
+  }
+
+  // TODO: Refacotr Soft-coding
+  .page-comment-commenteditorlazyrenderer-body-skelton {
+    position: relative;
+    padding: 2.258rem 2rem;
+    margin-left: 4.5em;
+    line-height: 1.5;
+    @include bs.media-breakpoint-down(xs) {
+      margin-left: 3.5em;
+    }
+  }
+}

+ 32 - 72
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -4,29 +4,27 @@ import React, {
 
 import { UserPicture } from '@growi/ui';
 import {
-  Button,
-  TabContent, TabPane,
+  Button, TabContent, TabPane,
 } from 'reactstrap';
 import * as toastr from 'toastr';
 
 import { apiPostForm } from '~/client/util/apiv1-client';
-import { CustomWindow } from '~/interfaces/global';
-import { IInterceptorManager } from '~/interfaces/interceptor-manager';
 import { RendererOptions } from '~/services/renderer/renderer';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
-  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useRendererConfig,
+  useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId, useIsSlackConfigured,
+  useIsUploadableFile, useIsUploadableImage,
 } from '~/stores/context';
 import { useSWRxSlackChannels, useIsSlackEnabled } from '~/stores/editor';
-import { useIsMobile } from '~/stores/ui';
-
 
 import { CustomNavTab } from '../CustomNavigation/CustomNav';
 import NotAvailableForGuest from '../NotAvailableForGuest';
-// import Editor from '../PageEditor/Editor';
+import Editor from '../PageEditor/Editor';
 import { SlackNotification } from '../SlackNotification';
 
-import CommentPreview from './CommentPreview';
+import { CommentPreview } from './CommentPreview';
+
+import styles from './CommentEditor.module.scss';
 
 
 const navTabMapping = {
@@ -42,13 +40,12 @@ const navTabMapping = {
   },
 };
 
-type PropsType = {
+export type CommentEditorProps = {
   rendererOptions: RendererOptions,
   isForNewComment?: boolean,
   replyTo?: string,
   currentCommentId?: string,
   commentBody?: string,
-  commentCreator?: string,
   onCancelButtonClicked?: () => void,
   onCommentButtonClicked?: () => void,
 }
@@ -59,71 +56,35 @@ type EditorRef = {
   terminateUploadingState: () => void,
 }
 
-const CommentEditor = (props: PropsType): JSX.Element => {
+export const CommentEditor = (props: CommentEditorProps): JSX.Element => {
 
   const {
     rendererOptions, isForNewComment, replyTo,
-    currentCommentId, commentBody, commentCreator, onCancelButtonClicked, onCommentButtonClicked,
+    currentCommentId, commentBody, onCancelButtonClicked, onCommentButtonClicked,
   } = props;
+
   const { data: currentUser } = useCurrentUser();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: currentPageId } = useCurrentPageId();
   const { update: updateComment, post: postComment } = useSWRxPageComment(currentPageId);
   const { data: revisionId } = useRevisionId();
-  const { data: isMobile } = useIsMobile();
   const { data: isSlackEnabled, mutate: mutateIsSlackEnabled } = useIsSlackEnabled();
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-  const { data: config } = useRendererConfig();
-
-  // const isUploadable = config.upload.image || config.upload.file;
-  // const isUploadableFile = config.upload.file;
-  // const isSlackConfigured = config.isSlackConfigured;
+  const { data: isSlackConfigured } = useIsSlackConfigured();
+  const { data: isUploadableFile } = useIsUploadableFile();
+  const { data: isUploadableImage } = useIsUploadableImage();
 
   const [isReadyToUse, setIsReadyToUse] = useState(!isForNewComment);
   const [comment, setComment] = useState(commentBody ?? '');
-  const [html, setHtml] = useState('');
   const [activeTab, setActiveTab] = useState('comment_editor');
   const [error, setError] = useState();
   const [slackChannels, setSlackChannels] = useState(slackChannelsData?.toString());
 
   const editorRef = useRef<EditorRef>(null);
 
-  const renderHtml = useCallback((markdown: string) => {
-    const context = {
-      markdown,
-      parsedHTML: '',
-    };
-
-    // TODO: use ReactMarkdown
-
-    // const interceptorManager: IInterceptorManager = (window as CustomWindow).interceptorManager;
-    // interceptorManager.process('preRenderCommnetPreview', context)
-    //   .then(() => { return interceptorManager.process('prePreProcess', context) })
-    //   .then(() => {
-    //     context.markdown = rendererOptions.preProcess(context.markdown, context);
-    //   })
-    //   .then(() => { return interceptorManager.process('postPreProcess', context) })
-    //   .then(() => {
-    //     const parsedHTML = rendererOptions.process(context.markdown, context);
-    //     context.parsedHTML = parsedHTML;
-    //   })
-    //   .then(() => { return interceptorManager.process('prePostProcess', context) })
-    //   .then(() => {
-    //     context.parsedHTML = rendererOptions.postProcess(context.parsedHTML, context);
-    //   })
-    //   .then(() => { return interceptorManager.process('postPostProcess', context) })
-    //   .then(() => { return interceptorManager.process('preRenderCommentPreviewHtml', context) })
-    //   .then(() => {
-    //     setHtml(context.parsedHTML);
-    //   })
-    //   // process interceptors for post rendering
-    //   .then(() => { return interceptorManager.process('postRenderCommentPreviewHtml', context) });
-  }, [rendererOptions]);
-
   const handleSelect = useCallback((activeTab: string) => {
     setActiveTab(activeTab);
-    renderHtml(comment);
-  }, [comment, renderHtml]);
+  }, []);
 
   useEffect(() => {
     if (slackChannels === undefined) { return }
@@ -132,7 +93,6 @@ const CommentEditor = (props: PropsType): JSX.Element => {
 
   const initializeEditor = useCallback(() => {
     setComment('');
-    setHtml('');
     setActiveTab('comment_editor');
     setError(undefined);
     // reset value
@@ -210,7 +170,6 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   }, []);
 
   const uploadHandler = useCallback(async(file) => {
-
     if (editorRef.current == null) { return }
 
     const pagePath = currentPagePath;
@@ -220,6 +179,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
     formData.append('file', file);
     formData.append('path', pagePath ?? '');
     formData.append('page_id', pageId ?? '');
+
     try {
       // TODO: typescriptize res
       const res = await apiPostForm(endpoint, formData) as any;
@@ -242,12 +202,18 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   }, [apiErrorHandler, currentPageId, currentPagePath]);
 
   const getCommentHtml = useCallback(() => {
+    if (currentPagePath == null) {
+      return <></>;
+    }
+
     return (
       <CommentPreview
-        html={html}
+        rendererOptions={rendererOptions}
+        markdown={comment}
+        path={currentPagePath}
       />
     );
-  }, [html]);
+  }, [currentPagePath, comment, rendererOptions]);
 
   const renderBeforeReady = useCallback((): JSX.Element => {
     return (
@@ -266,7 +232,6 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   }, []);
 
   const renderReady = () => {
-
     const commentPreview = getCommentHtml();
 
     const errorMessage = <span className="text-danger text-right mr-2">{error}</span>;
@@ -286,8 +251,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
       </Button>
     );
 
-    // // TODO: typescriptize Editor
-    // const AnyEditor = Editor as any;
+    const isUploadable = isUploadableImage || isUploadableFile;
 
     return (
       <>
@@ -295,18 +259,16 @@ const CommentEditor = (props: PropsType): JSX.Element => {
           <CustomNavTab activeTab={activeTab} navTabMapping={navTabMapping} onNavSelected={handleSelect} hideBorderBottom />
           <TabContent activeTab={activeTab}>
             <TabPane tabId="comment_editor">
-              {/* <AnyEditor
+              <Editor
                 ref={editorRef}
                 value={comment}
-                lineNumbers={false}
-                isMobile={isMobile}
-                // isUploadable={isUploadable}
-                // isUploadableFile={isUploadableFile}
+                isUploadable={isUploadable}
+                isUploadableFile={isUploadableFile}
                 onChange={setComment}
                 onUpload={uploadHandler}
                 onCtrlEnter={ctrlEnterHandler}
                 isComment
-              /> */}
+              />
               {/*
                 Note: <OptionsSelector /> is not optimized for ComentEditor in terms of responsive design.
                 See a review comment in https://github.com/weseek/growi/pull/3473
@@ -325,7 +287,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
             <span className="flex-grow-1" />
             <span className="d-none d-sm-inline">{ errorMessage && errorMessage }</span>
 
-            {/* { isSlackConfigured
+            { isSlackConfigured
               && (
                 <div className="form-inline align-self-center mr-md-2">
                   <SlackNotification
@@ -337,7 +299,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
                   />
                 </div>
               )
-            } */}
+            }
             <div className="d-none d-sm-block">
               <span className="mr-2">{cancelButton}</span><span>{submitButton}</span>
             </div>
@@ -354,7 +316,7 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   };
 
   return (
-    <div className="form page-comment-form">
+    <div className={`${styles['comment-editor-styles']} form page-comment-form`}>
       <div className="comment-form">
         <div className="comment-form-user">
           <UserPicture user={currentUser} noLink noTooltip />
@@ -370,5 +332,3 @@ const CommentEditor = (props: PropsType): JSX.Element => {
   );
 
 };
-
-export default CommentEditor;

+ 28 - 14
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -1,33 +1,47 @@
-import React, { FC } from 'react';
+import React from 'react';
 
-import { useCommentPreviewOptions } from '~/stores/renderer';
+import dynamic from 'next/dynamic';
+
+import { RendererOptions } from '~/services/renderer/renderer';
 
 import { useSWRxPageComment } from '../../stores/comment';
+import { Skelton } from '../Skelton';
+
+import { CommentEditorProps } from './CommentEditor';
+
+import CommentEditorStyles from './CommentEditor.module.scss';
 
-import CommentEditor from './CommentEditor';
+const CommentEditor = dynamic<CommentEditorProps>(() => import('./CommentEditor').then(mod => mod.CommentEditor),
+  {
+    ssr: false,
+    loading: () => <div className={`${CommentEditorStyles['comment-editor-styles']} form page-comment-form`}>
+      <div className='comment-form'>
+        <div className='comment-form-user'>
+          <Skelton additionalClass='rounded-circle picture' roundedPill />
+        </div>
+        <Skelton additionalClass="page-comment-commenteditorlazyrenderer-body-skelton grw-skelton" />
+      </div>
+    </div>,
+  });
 
 type Props = {
-  pageId: string,
+  pageId?: string,
+  rendererOptions: RendererOptions,
 }
 
-const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
+export const CommentEditorLazyRenderer = (props: Props): JSX.Element => {
 
-  const { pageId } = props;
-  const { mutate } = useSWRxPageComment(pageId);
-  const { data: rendererOptions } = useCommentPreviewOptions();
+  const { pageId, rendererOptions } = props;
 
-  if (rendererOptions == null) {
-    return <></>;
-  }
+  const { mutate } = useSWRxPageComment(pageId);
 
   return (
     <CommentEditor
       rendererOptions={rendererOptions}
+      isForNewComment
       replyTo={undefined}
       onCommentButtonClicked={mutate}
-      isForNewComment
     />
   );
-};
 
-export default CommentEditorLazyRenderer;
+};

+ 0 - 29
packages/app/src/components/PageComment/CommentPreview.jsx

@@ -1,29 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-import RevisionBody from '../Page/RevisionBody';
-
-/**
- * Wrapper component for Page/RevisionBody
- */
-const CommentPreview = (props) => {
-
-  return (
-    <div className="page-comment-preview-body">
-      <RevisionBody
-        html={props.html}
-        additionalClassName="comment"
-        isMathJaxEnabled
-        renderMathJaxInRealtime
-      />
-    </div>
-  );
-
-};
-
-CommentPreview.propTypes = {
-  html: PropTypes.string,
-};
-
-export default CommentPreview;

+ 27 - 0
packages/app/src/components/PageComment/CommentPreview.tsx

@@ -0,0 +1,27 @@
+import { RendererOptions } from '~/services/renderer/renderer';
+
+import RevisionRenderer from '../Page/RevisionRenderer';
+
+
+type CommentPreviewPorps = {
+  rendererOptions: RendererOptions,
+  markdown: string,
+  path: string,
+}
+
+export const CommentPreview = (props: CommentPreviewPorps): JSX.Element => {
+
+  const { rendererOptions, markdown, path } = props;
+
+  return (
+    <div className="page-comment-preview-body">
+      <RevisionRenderer
+        rendererOptions={rendererOptions}
+        markdown={markdown}
+        additionalClassName="comment"
+        pagePath={path}
+      />
+    </div>
+  );
+
+};

+ 0 - 70
packages/app/src/components/PageComment/DeleteCommentModal.jsx

@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
-
-import { format } from 'date-fns';
-
-import { UserPicture } from '@growi/ui';
-import Username from '../User/Username';
-
-export default class DeleteCommentModal extends React.Component {
-
-  /*
-   * the threshold for omitting body
-   */
-  static get OMIT_BODY_THRES() { return 400 }
-
-  UNSAFE_componentWillMount() {
-  }
-
-  render() {
-    if (this.props.comment === undefined) {
-      return <div></div>;
-    }
-
-    const comment = this.props.comment;
-    const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
-
-    // generate body
-    let commentBody = comment.comment;
-    if (commentBody.length > DeleteCommentModal.OMIT_BODY_THRES) { // omit
-      commentBody = `${commentBody.substr(0, DeleteCommentModal.OMIT_BODY_THRES)}...`;
-    }
-    commentBody = <span style={{ whiteSpace: 'pre-wrap' }}>{commentBody}</span>;
-
-    return (
-      <Modal isOpen={this.props.isShown} toggle={this.props.cancel} className="page-comment-delete-modal">
-        <ModalHeader tag="h4" toggle={this.props.cancel} className="bg-danger text-light">
-          <span>
-            <i className="icon-fw icon-fire"></i>
-            Delete comment?
-          </span>
-        </ModalHeader>
-        <ModalBody>
-          <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
-          <p className="card well comment-body mt-2 p-2">{commentBody}</p>
-        </ModalBody>
-        <ModalFooter>
-          <span className="text-danger">{this.props.errorMessage}</span>&nbsp;
-          <Button onClick={this.props.cancel}>Cancel</Button>
-          <Button color="danger" onClick={this.props.confirmedToDelete}>
-            <i className="icon icon-fire"></i>
-            Delete
-          </Button>
-        </ModalFooter>
-      </Modal>
-    );
-  }
-
-}
-
-DeleteCommentModal.propTypes = {
-  isShown: PropTypes.bool.isRequired,
-  comment: PropTypes.object,
-  errorMessage: PropTypes.string,
-  cancel: PropTypes.func.isRequired, // for cancel evnet handling
-  confirmedToDelete: PropTypes.func.isRequired, // for confirmed event handling
-};

+ 10 - 0
packages/app/src/components/PageComment/DeleteCommentModal.module.scss

@@ -0,0 +1,10 @@
+// modal
+.page-comment-delete-modal :global {
+  .modal-content .modal-body {
+    .comment-body {
+      max-height: 13em;
+      // scrollable
+      overflow-y: auto;
+    }
+  }
+}

+ 93 - 0
packages/app/src/components/PageComment/DeleteCommentModal.tsx

@@ -0,0 +1,93 @@
+import React, { useMemo } from 'react';
+
+import { UserPicture } from '@growi/ui';
+import { format } from 'date-fns';
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { ICommentHasId } from '../../interfaces/comment';
+import Username from '../User/Username';
+
+import styles from './DeleteCommentModal.module.scss';
+
+
+export type DeleteCommentModalProps = {
+  isShown: boolean,
+  comment: ICommentHasId | null,
+  errorMessage: string,
+  cancelToDelete: () => void,
+  confirmToDelete: () => void,
+}
+
+export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element => {
+  const {
+    isShown, comment, errorMessage, cancelToDelete, confirmToDelete,
+  } = props;
+
+  const HeaderContent = useMemo(() => {
+    if (comment == null || isShown === false) {
+      return <></>;
+    }
+    return (
+      <span>
+        <i className="icon-fw icon-fire"></i>
+        Delete comment?
+      </span>
+    );
+  }, [comment, isShown]);
+
+  const BodyContent = useMemo(() => {
+    if (comment == null || isShown === false) {
+      return <></>;
+    }
+
+    // the threshold for omitting body
+    const OMIT_BODY_THRES = 400;
+
+    const commentDate = format(new Date(comment.createdAt), 'yyyy/MM/dd HH:mm');
+
+    let commentBody = comment.comment;
+    if (commentBody.length > OMIT_BODY_THRES) { // omit
+      commentBody = `${commentBody.substr(0, OMIT_BODY_THRES)}...`;
+    }
+    const commentBodyElement = <span style={{ whiteSpace: 'pre-wrap' }}>{commentBody}</span>;
+
+    return (
+      <>
+        <UserPicture user={comment.creator} size="xs" /> <strong><Username user={comment.creator}></Username></strong> wrote on {commentDate}:
+        <p className="card well comment-body mt-2 p-2">{commentBodyElement}</p>
+      </>
+    );
+  }, [comment, isShown]);
+
+  const FooterContent = useMemo(() => {
+    if (comment == null || isShown === false) {
+      return <></>;
+    }
+    return (
+      <>
+        <span className="text-danger">{errorMessage}</span>&nbsp;
+        <Button onClick={cancelToDelete}>Cancel</Button>
+        <Button color="danger" onClick={confirmToDelete}>
+          <i className="icon icon-fire"></i>
+          Delete
+        </Button>
+      </>
+    );
+  }, [cancelToDelete, comment, confirmToDelete, errorMessage, isShown]);
+
+  return (
+    <Modal isOpen={isShown} toggle={cancelToDelete} className={`${styles['page-comment-delete-modal']}`}>
+      <ModalHeader tag="h4" toggle={cancelToDelete} className="bg-danger text-light">
+        {HeaderContent}
+      </ModalHeader>
+      <ModalBody>
+        {BodyContent}
+      </ModalBody>
+      <ModalFooter>
+        {FooterContent}
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 0 - 114
packages/app/src/components/PageComment/ReplayComments.jsx

@@ -1,114 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-import { Collapse } from 'reactstrap';
-
-import { RendererOptions } from '~/services/renderer/renderer';
-import { useRendererConfig } from '~/stores/context';
-
-import { Comment } from './Comment';
-
-
-class ReplayComments extends React.PureComponent {
-
-  constructor() {
-    super();
-
-    this.state = {
-      isOlderRepliesShown: false,
-    };
-
-    this.toggleOlderReplies = this.toggleOlderReplies.bind(this);
-  }
-
-  toggleOlderReplies() {
-    this.setState({ isOlderRepliesShown: !this.state.isOlderRepliesShown });
-  }
-
-  renderReply(reply) {
-    return (
-      <div key={reply._id} className="page-comment-reply ml-4 ml-sm-5 mr-3">
-        <Comment
-          comment={reply}
-          deleteBtnClicked={this.props.deleteBtnClicked}
-          rendererOptions={this.props.rendererOptions}
-          isReadOnly={this.props.isReadOnly}
-        />
-      </div>
-    );
-  }
-
-  render() {
-    const { config } = this.props;
-
-    const isAllReplyShown = config.isAllReplyShown || false;
-    const replyList = this.props.replyList;
-
-    if (isAllReplyShown) {
-      return (
-        <React.Fragment>
-          {replyList.map((reply) => {
-            return this.renderReply(reply);
-          })}
-        </React.Fragment>
-      );
-    }
-
-    const areThereHiddenReplies = (replyList.length > 2);
-
-    const { isOlderRepliesShown } = this.state;
-    const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
-    const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
-    const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
-
-    const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
-    const hiddenReplies = replyList.slice(0, replyList.length - 2);
-
-    const hiddenElements = hiddenReplies.map((reply) => {
-      return this.renderReply(reply);
-    });
-
-    const shownElements = shownReplies.map((reply) => {
-      return this.renderReply(reply);
-    });
-
-    return (
-      <React.Fragment>
-        {areThereHiddenReplies && (
-          <div className="page-comments-hidden-replies">
-            <Collapse isOpen={this.state.isOlderRepliesShown}>
-              <div>{hiddenElements}</div>
-            </Collapse>
-            <div className="text-center">
-              <button
-                type="button"
-                className="btn btn-link"
-                onClick={this.toggleOlderReplies}
-              >
-                {toggleButtonIcon} {toggleButtonLabel}
-              </button>
-            </div>
-          </div>
-        )}
-        {shownElements}
-
-      </React.Fragment>
-    );
-  }
-
-}
-
-ReplayComments.propTypes = {
-  rendererOptions: PropTypes.instanceOf(RendererOptions).isRequired,
-  deleteBtnClicked: PropTypes.func.isRequired,
-  isReadOnly: PropTypes.bool.isRequired,
-  replyList: PropTypes.array,
-};
-
-const ReplayCommentsWrapperFC = (props) => {
-  const { data: config } = useRendererConfig();
-
-  return <ReplayComments config={config} {...props} />;
-};
-
-export default ReplayCommentsWrapperFC;

+ 11 - 0
packages/app/src/components/PageComment/ReplyComments.module.scss

@@ -0,0 +1,11 @@
+/*
+* reply
+*/
+.page-comment-reply :global {
+  margin-top: 1em;
+}
+
+// remove margin after hidden replies
+.page-comments-hidden-replies + .page-comment-reply :global {
+  margin-top: 0;
+}

+ 101 - 0
packages/app/src/components/PageComment/ReplyComments.tsx

@@ -0,0 +1,101 @@
+
+import React, { useState } from 'react';
+
+import { Collapse } from 'reactstrap';
+
+import { RendererOptions } from '~/services/renderer/renderer';
+
+import { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment';
+import { useIsAllReplyShown } from '../../stores/context';
+
+import { Comment } from './Comment';
+
+import styles from './ReplyComments.module.scss';
+
+
+type ReplycommentsProps = {
+  isReadOnly: boolean,
+  replyList: ICommentHasIdList,
+  deleteBtnClicked: (comment: ICommentHasId) => void,
+  onComment: () => void,
+  rendererOptions: RendererOptions,
+  currentPagePath: string,
+  currentRevisionId: string,
+  currentRevisionCreatedAt: Date,
+}
+
+export const ReplyComments = (props: ReplycommentsProps): JSX.Element => {
+
+  const {
+    isReadOnly, replyList, deleteBtnClicked, onComment, rendererOptions,
+    currentPagePath, currentRevisionId, currentRevisionCreatedAt,
+  } = props;
+
+  const { data: isAllReplyShown } = useIsAllReplyShown();
+
+  const [isOlderRepliesShown, setIsOlderRepliesShown] = useState(false);
+
+  const renderReply = (reply: ICommentHasId) => {
+    return (
+      <div key={reply._id} className={`${styles['page-comment-reply']} ml-4 ml-sm-5 mr-3`}>
+        <Comment
+          comment={reply}
+          isReadOnly={isReadOnly}
+          deleteBtnClicked={deleteBtnClicked}
+          onComment={onComment}
+          rendererOptions={rendererOptions}
+          currentPagePath={currentPagePath}
+          currentRevisionId={currentRevisionId}
+          currentRevisionCreatedAt={currentRevisionCreatedAt}
+        />
+      </div>
+    );
+  };
+
+  if (isAllReplyShown) {
+    return (
+      <>
+        {replyList.map((reply) => {
+          return renderReply(reply);
+        })}
+      </>
+    );
+  }
+
+  const areThereHiddenReplies = (replyList.length > 2);
+  const toggleButtonIconName = isOlderRepliesShown ? 'icon-arrow-up' : 'icon-options-vertical';
+  const toggleButtonIcon = <i className={`icon-fw ${toggleButtonIconName}`}></i>;
+  const toggleButtonLabel = isOlderRepliesShown ? '' : 'more';
+  const shownReplies = replyList.slice(replyList.length - 2, replyList.length);
+  const hiddenReplies = replyList.slice(0, replyList.length - 2);
+
+  const hiddenElements = hiddenReplies.map((reply) => {
+    return renderReply(reply);
+  });
+
+  const shownElements = shownReplies.map((reply) => {
+    return renderReply(reply);
+  });
+
+  return (
+    <>
+      {areThereHiddenReplies && (
+        <div className={`${styles['page-comments-hidden-replies']}`}>
+          <Collapse isOpen={isOlderRepliesShown}>
+            <div>{hiddenElements}</div>
+          </Collapse>
+          <div className="text-center">
+            <button
+              type="button"
+              className="btn btn-link"
+              onClick={() => setIsOlderRepliesShown(!isOlderRepliesShown)}
+            >
+              {toggleButtonIcon} {toggleButtonLabel}
+            </button>
+          </div>
+        </div>
+      )}
+      {shownElements}
+    </>
+  );
+};

+ 34 - 0
packages/app/src/components/PageComment/_comment-inheritance.scss

@@ -0,0 +1,34 @@
+@use '../../styles/bootstrap/init' as bs;
+
+%comment-section {
+  position: relative;
+  padding: 1em;
+
+  // speech balloon
+  &:before {
+    position: absolute;
+    top: 1.5em;
+    left: -1em;
+    display: block;
+    width: 0;
+    content: '';
+    border: 1em solid transparent;
+    border-left-width: 0;
+
+    @include bs.media-breakpoint-down(xs) {
+      top: 1em;
+    }
+  }
+}
+
+%picture {
+  float: left;
+  width: 3em;
+  height: 3em;
+  margin-top: 0.8em;
+
+  @include bs.media-breakpoint-down(xs) {
+    width: 2em;
+    height: 2em;
+  }
+}

+ 58 - 0
packages/app/src/components/PageCommentSkelton.tsx

@@ -0,0 +1,58 @@
+import React from 'react';
+
+import { Skelton } from './Skelton';
+
+import styles from './PageComment.module.scss';
+import CommentStyles from './PageComment/Comment.module.scss';
+import CommentEditorStyles from './PageComment/CommentEditor.module.scss';
+
+type PageCommentSkeltonProps = {
+  commentTitleClasses?: string,
+  roundedPill?: boolean,
+}
+
+export const PageCommentSkelton = (props: PageCommentSkeltonProps): JSX.Element => {
+  const {
+    commentTitleClasses,
+  } = props;
+
+  return (
+    <>
+      {/* TODO: Check the comment.html CSS */}
+      <div className={`${styles['page-comment-styles']} page-comments-row comment-list`}>
+        <div className="container-lg">
+          <div className="page-comments">
+            <h2 className={commentTitleClasses}><i className="icon-fw icon-bubbles"></i>Comments</h2>
+            <div className="page-comments-list" id="page-comments-list">
+              <div className={`${CommentStyles['comment-styles']} page-comment-thread pb-5  page-comment-thread-no-replies`}>
+                <div className='page-comment flex-column'>
+                  <div className='page-commnet-writer'>
+                    <Skelton additionalClass='rounded-circle picture' roundedPill />
+                  </div>
+                  <Skelton additionalClass="page-comment-comment-body-skelton grw-skelton" />
+                </div>
+                <div className='page-comment flex-column ml-4 ml-sm-5 mr-3'>
+                  <div className='page-commnet-writer mt-3'>
+                    <Skelton additionalClass='rounded-circle picture' roundedPill />
+                  </div>
+                  <Skelton additionalClass="page-comment-comment-body-skelton grw-skelton mt-3" />
+                </div>
+                <div className="text-right">
+                  <Skelton additionalClass="page-comment-button-skelton btn btn-outline-secondary btn-sm grw-skelton" />
+                </div>
+              </div>
+            </div>
+            <div className={`${CommentEditorStyles['comment-editor-styles']} form page-comment-form`}>
+              <div className='comment-form'>
+                <div className='comment-form-user'>
+                  <Skelton additionalClass='rounded-circle picture' roundedPill />
+                </div>
+                <Skelton additionalClass="page-comment-commenteditorlazyrenderer-body-skelton grw-skelton" />
+              </div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </>
+  );
+};

+ 13 - 0
packages/app/src/components/PageContentFooter.module.scss

@@ -0,0 +1,13 @@
+@use '~/styles/bootstrap/init' as bs;
+
+.page-content-footer :global {
+  border-top: solid 1px transparent;
+  .page-meta {
+    font-size: 0.95em;
+  }
+}
+// TODO: Should Soft Coding see: https://github.com/weseek/growi/pull/6404
+.page-content-footer-skelton :global {
+  width: 300px;
+  height: 20px;
+}

+ 18 - 19
packages/app/src/components/PageContentFooter.tsx

@@ -1,29 +1,30 @@
-import React, { FC, memo } from 'react';
+import React, { memo } from 'react';
 
-import { Ref } from '@growi/core';
+import dynamic from 'next/dynamic';
 
-import { IUser } from '../interfaces/user';
+import { useSWRxCurrentPage } from '~/stores/page';
 
-import AuthorInfo from './Navbar/AuthorInfo';
+import { Skelton } from './Skelton';
 
-type Props = {
-  createdAt: Date,
-  updatedAt: Date,
-  creator: any,
-  revisionAuthor: Ref<IUser>,
-}
+import styles from './PageContentFooter.module.scss';
 
-const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
-  const {
-    createdAt, updatedAt, creator, revisionAuthor,
-  } = props;
+const AuthorInfo = dynamic(() => import('./Navbar/AuthorInfo'),
+  { ssr: false, loading: () => <Skelton additionalClass={`${styles['page-content-footer-skelton']} mb-3`} /> });
+
+export const PageContentFooter = memo((): JSX.Element => {
+
+  const { data: page } = useSWRxCurrentPage();
+
+  if (page == null) {
+    return <></>;
+  }
 
   return (
-    <div className="page-content-footer py-4 d-edit-none d-print-none">
+    <div className={`${styles['page-content-footer']} page-content-footer py-4 d-edit-none d-print-none}`}>
       <div className="grw-container-convertible">
         <div className="page-meta">
-          <AuthorInfo user={creator as IUser} date={createdAt} mode="create" locate="footer" />
-          <AuthorInfo user={revisionAuthor as IUser} date={updatedAt} mode="update" locate="footer" />
+          <AuthorInfo user={page.creator} date={page.createdAt} mode="create" locate="footer" />
+          <AuthorInfo user={page.revision.author} date={page.updatedAt} mode="update" locate="footer" />
         </div>
       </div>
     </div>
@@ -31,5 +32,3 @@ const PageContentFooter:FC<Props> = memo((props:Props):JSX.Element => {
 });
 
 PageContentFooter.displayName = 'PageContentFooter';
-
-export default PageContentFooter;

+ 1 - 1
packages/app/src/components/PageEditor/Editor.tsx

@@ -20,7 +20,6 @@ import CodeMirrorEditor from './CodeMirrorEditor';
 import pasteHelper from './PasteHelper';
 import TextAreaEditor from './TextAreaEditor';
 
-
 type EditorPropsType = {
   value?: string,
   isGfmMode?: boolean,
@@ -36,6 +35,7 @@ type EditorPropsType = {
   onSave?: () => Promise<void>,
   onPasteFiles?: (event: Event) => void,
   onCtrlEnter?: (event: Event) => void,
+  isComment?: boolean,
 }
 
 type DropzoneRef = {

+ 0 - 482
packages/app/src/components/PageEditorByHackmd.jsx

@@ -1,482 +0,0 @@
-import React from 'react';
-
-import { useTranslation } from 'next-i18next';
-import PropTypes from 'prop-types';
-
-import AppContainer from '~/client/services/AppContainer';
-import EditorContainer from '~/client/services/EditorContainer';
-import PageContainer from '~/client/services/PageContainer';
-import { apiPost } from '~/client/util/apiv1-client';
-import { getOptionsToSave } from '~/client/util/editor';
-import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
-import {
-  useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
-} from '~/stores/editor';
-import {
-  useEditorMode, useSelectedGrant,
-} from '~/stores/ui';
-import loggerFactory from '~/utils/logger';
-
-import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
-import { withUnstatedContainers } from './UnstatedUtils';
-
-const logger = loggerFactory('growi:PageEditorByHackmd');
-
-class PageEditorByHackmd extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isInitialized: false,
-      isInitializing: false,
-      // for error
-      hasError: false,
-      errorMessage: '',
-      errorReason: '',
-    };
-
-    this.getHackmdUri = this.getHackmdUri.bind(this);
-    this.startToEdit = this.startToEdit.bind(this);
-    this.resumeToEdit = this.resumeToEdit.bind(this);
-    this.onSaveWithShortcut = this.onSaveWithShortcut.bind(this);
-    this.hackmdEditorChangeHandler = this.hackmdEditorChangeHandler.bind(this);
-    this.penpalErrorOccuredHandler = this.penpalErrorOccuredHandler.bind(this);
-  }
-
-  UNSAFE_componentWillMount() {
-    this.props.appContainer.registerComponentInstance('PageEditorByHackmd', this);
-  }
-
-  /**
-   * return markdown document of HackMD
-   * @return {Promise<string>}
-   */
-  getMarkdown() {
-    const { t } = this.props;
-    if (!this.state.isInitialized) {
-      return Promise.reject(new Error(t('hackmd.not_initialized')));
-    }
-
-    return this.hackmdEditor.getValue();
-  }
-
-  /**
-   * reset initialized status
-   */
-  reset() {
-    this.setState({ isInitialized: false });
-  }
-
-  getHackmdUri() {
-    const envVars = this.props.appContainer.getConfig().env;
-    return envVars.HACKMD_URI;
-  }
-
-  get isResume() {
-    const { pageContainer } = this.props;
-    const {
-      pageIdOnHackmd, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
-    } = pageContainer.state;
-
-    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
-    return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime;
-  }
-
-  /**
-   * Start integration with HackMD
-   */
-  async startToEdit() {
-    const { pageContainer } = this.props;
-    const hackmdUri = this.getHackmdUri();
-
-    if (hackmdUri == null) {
-      // do nothing
-      return;
-    }
-
-    this.setState({
-      isInitialized: false,
-      isInitializing: true,
-    });
-
-    const params = {
-      pageId: pageContainer.state.pageId,
-    };
-
-    try {
-      const res = await apiPost('/hackmd.integrate', params);
-
-      if (!res.ok) {
-        throw new Error(res.error);
-      }
-
-      await pageContainer.setState({
-        pageIdOnHackmd: res.pageIdOnHackmd,
-        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-      });
-    }
-    catch (err) {
-      pageContainer.showErrorToastr(err);
-
-      this.setState({
-        hasError: true,
-        errorMessage: 'GROWI server failed to connect to HackMD.',
-        errorReason: err.toString(),
-      });
-    }
-
-    this.setState({
-      isInitialized: true,
-      isInitializing: false,
-    });
-  }
-
-  /**
-   * Start to edit w/o any api request
-   */
-  resumeToEdit() {
-    this.setState({ isInitialized: true });
-  }
-
-  /**
-   * Reset draft
-   */
-  async discardChanges() {
-    const { pageContainer } = this.props;
-    const { pageId } = pageContainer.state;
-
-    try {
-      const res = await apiPost('/hackmd.discard', { pageId });
-
-      if (!res.ok) {
-        throw new Error(res.error);
-      }
-
-      this.props.pageContainer.setState({
-        isHackmdDraftUpdatingInRealtime: false,
-        hasDraftOnHackmd: false,
-        pageIdOnHackmd: res.pageIdOnHackmd,
-        remoteRevisionId: res.revisionIdHackmdSynced,
-        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
-      });
-    }
-    catch (err) {
-      logger.error(err);
-      pageContainer.showErrorToastr(err);
-    }
-  }
-
-  /**
-   * save and update state of containers
-   * @param {string} markdown
-   */
-  async onSaveWithShortcut(markdown) {
-    const {
-      isSlackEnabled, slackChannels, pageContainer, editorContainer, grant, grantGroupId, grantGroupName, pageTags, mutateIsEnabledUnsavedWarning,
-    } = this.props;
-    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant, grantGroupId, grantGroupName, pageTags);
-
-    try {
-      // disable unsaved warning
-      mutateIsEnabledUnsavedWarning(false);
-
-      // eslint-disable-next-line no-unused-vars
-      const { page, tags } = await pageContainer.save(markdown, this.props.editorMode, optionsToSave);
-      logger.debug('success to save');
-
-      pageContainer.showSuccessToastr();
-
-      // update state of EditorContainer
-      editorContainer.setState({ tags });
-    }
-    catch (error) {
-      logger.error('failed to save', error);
-      pageContainer.showErrorToastr(error);
-    }
-  }
-
-  /**
-   * onChange event of HackmdEditor handler
-   */
-  async hackmdEditorChangeHandler(body) {
-    const hackmdUri = this.getHackmdUri();
-    const { pageContainer, mutateIsEnabledUnsavedWarning } = this.props;
-
-    if (hackmdUri == null) {
-      // do nothing
-      return;
-    }
-
-    // do nothing if contents are same
-    if (pageContainer.state.markdown === body) {
-      return;
-    }
-
-    // enable unsaved warning
-    mutateIsEnabledUnsavedWarning(true);
-
-    const params = {
-      pageId: pageContainer.state.pageId,
-    };
-    try {
-      await apiPost('/hackmd.saveOnHackmd', params);
-    }
-    catch (err) {
-      logger.error(err);
-    }
-  }
-
-  penpalErrorOccuredHandler(error) {
-    const { pageContainer, t } = this.props;
-
-    pageContainer.showErrorToastr(error);
-
-    this.setState({
-      hasError: true,
-      errorMessage: t('hackmd.fail_to_connect'),
-      errorReason: error.toString(),
-    });
-  }
-
-  renderPreInitContent() {
-    const hackmdUri = this.getHackmdUri();
-    const { pageContainer, t } = this.props;
-    const {
-      revisionId, revisionIdHackmdSynced, remoteRevisionId, pageId,
-    } = pageContainer.state;
-    const isPageNotFound = pageId == null;
-
-    let content;
-
-    /*
-     * HackMD is not setup
-     */
-    if (hackmdUri == null) {
-      content = (
-        <div>
-          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> { t('hackmd.not_set_up')}</p>
-          {/* eslint-disable-next-line react/no-danger */}
-          <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_associate_with_growi_to_use_hackmd_refer_to_this') }} />
-        </div>
-      );
-    }
-
-    /*
-    * used HackMD from NotFound Page
-    */
-    else if (isPageNotFound) {
-      content = (
-        <div className="text-center">
-          <p className="hackmd-status-label">
-            <i className="fa fa-file-text mr-2" />
-            { t('hackmd.used_for_not_found') }
-          </p>
-          {/* eslint-disable-next-line react/no-danger */}
-          <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_make_page') }} />
-        </div>
-      );
-    }
-    /*
-     * Resume to edit or discard changes
-     */
-    else if (this.isResume) {
-      const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
-
-      content = (
-        <div>
-          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
-          <p className="text-center"><strong>{t('hackmd.unsaved_draft')}</strong></p>
-
-          { isHackmdDocumentOutdated && (
-            <div className="card border-warning">
-              <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
-              <div className="card-body text-center">
-                {t('hackmd.based_on_revision')}&nbsp;
-                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="badge badge-secondary">{revisionIdHackmdSynced.substr(-8)}</span></a>
-
-                <div className="text-center mt-3">
-                  <button
-                    className="btn btn-link btn-view-outdated-draft p-0"
-                    type="button"
-                    disabled={this.state.isInitializing}
-                    onClick={() => { return this.resumeToEdit() }}
-                  >
-                    {t('hackmd.view_outdated_draft')}
-                  </button>
-                </div>
-              </div>
-            </div>
-          ) }
-
-          { !isHackmdDocumentOutdated && (
-            <div className="text-center hackmd-resume-button-container mb-3">
-              <button
-                className="btn btn-success btn-lg"
-                type="button"
-                disabled={this.state.isInitializing}
-                onClick={() => { return this.resumeToEdit() }}
-              >
-                <span className="btn-label"><i className="icon-fw icon-control-end"></i></span>
-                <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
-              </button>
-            </div>
-          ) }
-
-          <div className="text-center hackmd-discard-button-container mb-3">
-            <button
-              className="btn btn-outline-secondary btn-lg"
-              type="button"
-              onClick={() => { return this.discardChanges() }}
-            >
-              <span className="btn-label"><i className="icon-fw icon-control-start"></i></span>
-              <span className="btn-text">{t('hackmd.discard_changes')}</span>
-            </button>
-          </div>
-
-        </div>
-      );
-    }
-    /*
-     * Start to edit
-     */
-    else {
-      const isRevisionOutdated = revisionId !== remoteRevisionId;
-
-      content = (
-        <div>
-          <p className="text-muted text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
-          <div className="text-center hackmd-start-button-container mb-3">
-            <button
-              className="btn btn-info btn-lg"
-              type="button"
-              disabled={isRevisionOutdated || this.state.isInitializing}
-              onClick={() => { return this.startToEdit() }}
-            >
-              <span className="btn-label"><i className="icon-fw icon-paper-plane"></i></span>
-              {t('hackmd.start_to_edit')}
-            </button>
-          </div>
-          <p className="text-center">{t('hackmd.clone_page_content')}</p>
-        </div>
-      );
-    }
-
-    return (
-      <div className="hackmd-preinit d-flex justify-content-center align-items-center">
-        {content}
-      </div>
-    );
-  }
-
-  render() {
-    const hackmdUri = this.getHackmdUri();
-    const { pageContainer, t } = this.props;
-    const {
-      markdown, pageIdOnHackmd,
-    } = pageContainer.state;
-
-
-    let content;
-
-    if (this.state.isInitialized) {
-      content = (
-        <HackmdEditor
-          ref={(c) => { this.hackmdEditor = c }}
-          hackmdUri={hackmdUri}
-          pageIdOnHackmd={pageIdOnHackmd}
-          initializationMarkdown={this.isResume ? null : markdown}
-          onChange={this.hackmdEditorChangeHandler}
-          onSaveWithShortcut={(document) => {
-            this.onSaveWithShortcut(document);
-          }}
-          onPenpalErrorOccured={this.penpalErrorOccuredHandler}
-        >
-        </HackmdEditor>
-      );
-    }
-    else {
-      content = this.renderPreInitContent();
-    }
-
-
-    return (
-      <div className="position-relative">
-
-        {content}
-
-        { this.state.hasError && (
-          <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
-            <div className="bg-box p-5 text-center">
-              <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> {t('hackmd.integration_failed')}</h2>
-              <h4>{this.state.errorMessage}</h4>
-              <p className="card well text-danger">
-                {this.state.errorReason}
-              </p>
-              {/* eslint-disable-next-line react/no-danger */}
-              <p dangerouslySetInnerHTML={{ __html: t('hackmd.check_configuration') }} />
-            </div>
-          </div>
-        ) }
-
-      </div>
-    );
-  }
-
-}
-
-PageEditorByHackmd.propTypes = {
-  t: PropTypes.func.isRequired, // i18next
-
-  appContainer: PropTypes.instanceOf(AppContainer).isRequired,
-  pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
-  editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
-
-  // TODO: remove this when omitting unstated is completed
-  editorMode: PropTypes.string.isRequired,
-  isSlackEnabled: PropTypes.bool.isRequired,
-  pageTags: PropTypes.arrayOf(PropTypes.string),
-  slackChannels: PropTypes.string.isRequired,
-  grant: PropTypes.number.isRequired,
-  grantGroupId: PropTypes.string,
-  grantGroupName: PropTypes.string,
-  mutateIsEnabledUnsavedWarning: PropTypes.func,
-};
-
-/**
- * Wrapper component for using unstated
- */
-const PageEditorByHackmdHOCWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer, EditorContainer]);
-
-const PageEditorByHackmdWrapper = (props) => {
-  const { t } = useTranslation();
-  const { data: editorMode } = useEditorMode();
-  const { data: currentPagePath } = useCurrentPagePath();
-  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
-  const { data: isSlackEnabled } = useIsSlackEnabled();
-  const { data: pageId } = useCurrentPageId();
-  const { data: pageTags } = usePageTagsForEditors(pageId);
-  const { data: grantData } = useSelectedGrant();
-  const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
-
-  if (editorMode == null) {
-    return null;
-  }
-
-  return (
-    <PageEditorByHackmdHOCWrapper
-      {...props}
-      t={t}
-      editorMode={editorMode}
-      isSlackEnabled={isSlackEnabled}
-      slackChannels={slackChannelsData.toString()}
-      pageTags={pageTags}
-      grant={grantData.grant}
-      grantGroupId={grantData.grantGroup?.id}
-      grantGroupName={grantData.grantedGroup?.name}
-      mutateIsEnabledUnsavedWarning={mutateIsEnabledUnsavedWarning}
-    />
-  );
-};
-
-export default PageEditorByHackmdWrapper;

+ 409 - 0
packages/app/src/components/PageEditorByHackmd.tsx

@@ -0,0 +1,409 @@
+import React, {
+  useCallback, useEffect, useRef, useState,
+} from 'react';
+
+import { useTranslation } from 'react-i18next';
+
+
+import AppContainer from '~/client/services/AppContainer';
+import PageContainer from '~/client/services/PageContainer';
+import { apiPost } from '~/client/util/apiv1-client';
+import { getOptionsToSave } from '~/client/util/editor';
+import { IResHackmdIntegrated, IResHackmdDiscard } from '~/interfaces/hackmd';
+import { useCurrentPagePath, useCurrentPageId } from '~/stores/context';
+import { useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors } from '~/stores/editor';
+import {
+  useEditorMode, useSelectedGrant,
+} from '~/stores/ui';
+import loggerFactory from '~/utils/logger';
+
+import HackmdEditor from './PageEditorByHackmd/HackmdEditor';
+import { withUnstatedContainers } from './UnstatedUtils';
+
+const logger = loggerFactory('growi:PageEditorByHackmd');
+
+type PageEditorByHackmdProps = {
+  appContainer: AppContainer,
+  pageContainer: PageContainer,
+};
+
+type HackEditorRef = {
+  getValue: () => string
+};
+
+const PageEditorByHackmd = (props: PageEditorByHackmdProps) => {
+  const { appContainer, pageContainer } = props; // wip
+
+  const { t } = useTranslation();
+  const { data: editorMode } = useEditorMode();
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: slackChannelsData } = useSWRxSlackChannels(currentPagePath);
+  const { data: isSlackEnabled } = useIsSlackEnabled();
+  const { data: pageId } = useCurrentPageId();
+  const { data: pageTags, mutate: updatePageTagsForEditors } = usePageTagsForEditors(pageId);
+  const { data: grant } = useSelectedGrant();
+
+  const slackChannels = slackChannelsData?.toString();
+
+  const [isInitialized, setIsInitialized] = useState(false);
+  const [isInitializing, setIsInitializing] = useState(false);
+  // for error
+  const [hasError, setHasError] = useState(false);
+  const [errorMessage, setErrorMessage] = useState('');
+  const [errorReason, setErrorReason] = useState('');
+
+  const hackmdEditorRef = useRef<HackEditorRef>(null);
+
+  useEffect(() => {
+    const pageEditorByHackmdInstance = {
+      getMarkdown: () => {
+        if (!isInitialized) {
+          return Promise.reject(new Error(t('hackmd.not_initialized')));
+        }
+
+        if (hackmdEditorRef.current == null) { return }
+
+        return hackmdEditorRef.current.getValue();
+      },
+      reset: () => {
+        setIsInitialized(false);
+      },
+    };
+    appContainer.registerComponentInstance('PageEditorByHackmd', pageEditorByHackmdInstance);
+  }, [appContainer, isInitialized, t]);
+
+  const getHackmdUri = useCallback(() => {
+    const envVars = appContainer.getConfig().env;
+    return envVars.HACKMD_URI;
+  }, [appContainer]);
+
+  const isResume = useCallback(() => {
+    const {
+      pageIdOnHackmd, hasDraftOnHackmd, isHackmdDraftUpdatingInRealtime,
+    } = pageContainer.state;
+    const isPageExistsOnHackmd = (pageIdOnHackmd != null);
+    return (isPageExistsOnHackmd && hasDraftOnHackmd) || isHackmdDraftUpdatingInRealtime;
+  }, [pageContainer.state]);
+
+  const startToEdit = useCallback(async() => {
+    const hackmdUri = getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    setIsInitialized(false);
+    setIsInitializing(true);
+
+    try {
+      const res = await apiPost<IResHackmdIntegrated>('/hackmd.integrate', { pageId });
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      await pageContainer.setState({
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      pageContainer.showErrorToastr(err);
+
+      setHasError(true);
+      setErrorMessage('GROWI server failed to connect to HackMD.');
+      setErrorReason(err.toString());
+    }
+
+    setIsInitialized(true);
+    setIsInitializing(false);
+  }, [getHackmdUri, pageContainer, pageId]);
+
+  /**
+   * Start to edit w/o any api request
+   */
+  const resumeToEdit = useCallback(() => {
+    setIsInitialized(true);
+  }, []);
+
+  const discardChanges = useCallback(async() => {
+    const { pageId } = pageContainer.state;
+
+    try {
+      const res = await apiPost<IResHackmdDiscard>('/hackmd.discard', { pageId });
+
+      if (!res.ok) {
+        throw new Error(res.error);
+      }
+
+      pageContainer.setState({
+        isHackmdDraftUpdatingInRealtime: false,
+        hasDraftOnHackmd: false,
+        pageIdOnHackmd: res.pageIdOnHackmd,
+        remoteRevisionId: res.revisionIdHackmdSynced,
+        revisionIdHackmdSynced: res.revisionIdHackmdSynced,
+      });
+    }
+    catch (err) {
+      logger.error(err);
+      pageContainer.showErrorToastr(err);
+    }
+  }, [pageContainer]);
+
+  /**
+   * save and update state of containers
+   * @param {string} markdown
+   */
+  const onSaveWithShortcut = useCallback(async(markdown) => {
+    if (isSlackEnabled == null || grant == null || slackChannels == null) { return }
+    const optionsToSave = getOptionsToSave(isSlackEnabled, slackChannels, grant.grant, grant.grantedGroup?.id, grant.grantedGroup?.name, pageTags ?? []);
+
+    try {
+      // disable unsaved warning
+      // editorContainer.disableUnsavedWarning(); commentout because disableUnsavedWarning doesn't exitst
+
+      // eslint-disable-next-line no-unused-vars
+      const { page, tags } = await pageContainer.save(markdown, editorMode, optionsToSave);
+      logger.debug('success to save');
+
+      pageContainer.showSuccessToastr();
+
+      updatePageTagsForEditors(tags);
+    }
+    catch (error) {
+      logger.error('failed to save', error);
+      pageContainer.showErrorToastr(error);
+    }
+  }, [editorMode, grant, isSlackEnabled, pageContainer, pageTags, slackChannels, updatePageTagsForEditors]);
+
+  /**
+   * onChange event of HackmdEditor handler
+   */
+  const hackmdEditorChangeHandler = useCallback(async(body) => {
+    const hackmdUri = getHackmdUri();
+
+    if (hackmdUri == null) {
+      // do nothing
+      return;
+    }
+
+    // do nothing if contents are same
+    if (pageContainer.state.markdown === body) {
+      return;
+    }
+
+    // enable unsaved warning
+    // editorContainer.enableUnsavedWarning(); commentout because enableUnsavedWarning doesn't exitst
+
+    const params = {
+      pageId: pageContainer.state.pageId,
+    };
+    try {
+      await apiPost('/hackmd.saveOnHackmd', params);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }, [getHackmdUri, pageContainer.state.markdown, pageContainer.state.pageId]);
+
+  const penpalErrorOccuredHandler = useCallback((error) => {
+    pageContainer.showErrorToastr(error);
+
+    setHasError(true);
+    setErrorMessage(t('hackmd.fail_to_connect'));
+    setErrorReason(error.toString());
+  }, [pageContainer, t]);
+
+  const renderPreInitContent = useCallback(() => {
+    const hackmdUri = getHackmdUri();
+    const {
+      revisionId, revisionIdHackmdSynced, remoteRevisionId, pageId,
+    } = pageContainer.state;
+    const isPageNotFound = pageId == null;
+
+    let content;
+
+    /*
+     * HackMD is not setup
+     */
+    if (hackmdUri == null) {
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> { t('hackmd.not_set_up')}</p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_associate_with_growi_to_use_hackmd_refer_to_this') }} />
+        </div>
+      );
+    }
+
+    /*
+    * used HackMD from NotFound Page
+    */
+    else if (isPageNotFound) {
+      content = (
+        <div className="text-center">
+          <p className="hackmd-status-label">
+            <i className="fa fa-file-text mr-2" />
+            { t('hackmd.used_for_not_found') }
+          </p>
+          {/* eslint-disable-next-line react/no-danger */}
+          <p dangerouslySetInnerHTML={{ __html: t('hackmd.need_to_make_page') }} />
+        </div>
+      );
+    }
+    /*
+     * Resume to edit or discard changes
+     */
+    else if (isResume()) {
+      const isHackmdDocumentOutdated = revisionIdHackmdSynced !== remoteRevisionId;
+
+      content = (
+        <div>
+          <p className="text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <p className="text-center"><strong>{t('hackmd.unsaved_draft')}</strong></p>
+
+          { isHackmdDocumentOutdated && (
+            <div className="card border-warning">
+              <div className="card-header bg-warning"><i className="icon-fw icon-info"></i> {t('hackmd.draft_outdated')}</div>
+              <div className="card-body text-center">
+                {t('hackmd.based_on_revision')}&nbsp;
+                <a href={`?revision=${revisionIdHackmdSynced}`}><span className="badge badge-secondary">{revisionIdHackmdSynced?.substr(-8)}</span></a>
+
+                <div className="text-center mt-3">
+                  <button
+                    className="btn btn-link btn-view-outdated-draft p-0"
+                    type="button"
+                    disabled={isInitializing}
+                    onClick={resumeToEdit}
+                  >
+                    {t('hackmd.view_outdated_draft')}
+                  </button>
+                </div>
+              </div>
+            </div>
+          ) }
+
+          { !isHackmdDocumentOutdated && (
+            <div className="text-center hackmd-resume-button-container mb-3">
+              <button
+                className="btn btn-success btn-lg waves-effect waves-light"
+                type="button"
+                disabled={isInitializing}
+                onClick={resumeToEdit}
+              >
+                <span className="btn-label"><i className="icon-fw icon-control-end"></i></span>
+                <span className="btn-text">{t('hackmd.resume_to_edit')}</span>
+              </button>
+            </div>
+          ) }
+
+          <div className="text-center hackmd-discard-button-container mb-3">
+            <button
+              className="btn btn-outline-secondary btn-lg waves-effect waves-light"
+              type="button"
+              onClick={discardChanges}
+            >
+              <span className="btn-label"><i className="icon-fw icon-control-start"></i></span>
+              <span className="btn-text">{t('hackmd.discard_changes')}</span>
+            </button>
+          </div>
+
+        </div>
+      );
+    }
+    /*
+     * Start to edit
+     */
+    else {
+      const isRevisionOutdated = revisionId !== remoteRevisionId;
+
+      content = (
+        <div>
+          <p className="text-muted text-center hackmd-status-label"><i className="fa fa-file-text"></i> HackMD is READY!</p>
+          <div className="text-center hackmd-start-button-container mb-3">
+            <button
+              className="btn btn-info btn-lg waves-effect waves-light"
+              type="button"
+              disabled={isRevisionOutdated || isInitializing}
+              onClick={startToEdit}
+            >
+              <span className="btn-label"><i className="icon-fw icon-paper-plane"></i></span>
+              {t('hackmd.start_to_edit')}
+            </button>
+          </div>
+          <p className="text-center">{t('hackmd.clone_page_content')}</p>
+        </div>
+      );
+    }
+
+    return (
+      <div className="hackmd-preinit d-flex justify-content-center align-items-center">
+        {content}
+      </div>
+    );
+  }, [discardChanges, getHackmdUri, isInitializing, isResume, pageContainer.state, resumeToEdit, startToEdit, t]);
+
+  if (editorMode == null) {
+    return null;
+  }
+
+  const hackmdUri = getHackmdUri();
+  const {
+    markdown, pageIdOnHackmd,
+  } = pageContainer.state;
+
+  let content;
+
+  // TODO: typescriptize
+  // using any because ref cann't used between FC and class conponent with type safe
+  const AnyEditor = HackmdEditor as any;
+
+  if (isInitialized) {
+    content = (
+      <AnyEditor
+        ref={hackmdEditorRef}
+        hackmdUri={hackmdUri}
+        pageIdOnHackmd={pageIdOnHackmd}
+        initializationMarkdown={isResume() ? null : markdown}
+        onChange={hackmdEditorChangeHandler}
+        onSaveWithShortcut={(document) => {
+          onSaveWithShortcut(document);
+        }}
+        onPenpalErrorOccured={penpalErrorOccuredHandler}
+      >
+      </AnyEditor>
+    );
+  }
+  else {
+    content = renderPreInitContent();
+  }
+
+
+  return (
+    <div className="position-relative">
+
+      {content}
+
+      { hasError && (
+        <div className="hackmd-error position-absolute d-flex flex-column justify-content-center align-items-center">
+          <div className="bg-box p-5 text-center">
+            <h2 className="text-warning"><i className="icon-fw icon-exclamation"></i> {t('hackmd.integration_failed')}</h2>
+            <h4>{errorMessage}</h4>
+            <p className="card well text-danger">
+              {errorReason}
+            </p>
+            {/* eslint-disable-next-line react/no-danger */}
+            <p dangerouslySetInnerHTML={{ __html: t('hackmd.check_configuration') }} />
+          </div>
+        </div>
+      ) }
+
+    </div>
+  );
+
+};
+
+const PageEditorByHackmdWrapper = withUnstatedContainers(PageEditorByHackmd, [AppContainer, PageContainer]);
+
+export default PageEditorByHackmdWrapper;

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

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

+ 2 - 7
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -81,7 +81,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const SubNavButtons = dynamic<SubNavButtonsProps>(() => import('../Navbar/SubNavButtons').then(mod => mod.SubNavButtons), { ssr: false });
   const RevisionLoader = dynamic(() => import('../Page/RevisionLoader'), { ssr: false });
   const PageComment = dynamic(() => import('../PageComment').then(mod => mod.PageComment), { ssr: false });
-  const PageContentFooter = dynamic(() => import('../PageContentFooter'), { ssr: false });
+  const PageContentFooter = dynamic(() => import('../PageContentFooter').then(mod => mod.PageContentFooter), { ssr: false });
 
   const scrollElementRef = useRef(null);
 
@@ -217,12 +217,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
           highlightKeywords={highlightKeywords}
         />
         <PageComment pageId={page._id} highlightKeywords={highlightKeywords} isReadOnly hideIfEmpty />
-        <PageContentFooter
-          createdAt={new Date(pageWithMeta.data.createdAt)}
-          updatedAt={new Date(pageWithMeta.data.updatedAt)}
-          creator={pageWithMeta.data.creator}
-          revisionAuthor={pageWithMeta.data.lastUpdateUser}
-        />
+        <PageContentFooter />
       </div>
     </div>
   );

+ 7 - 3
packages/app/src/components/Sidebar/PageTree/PrivateLegacyPagesLink.tsx

@@ -1,14 +1,18 @@
 import React, { FC, memo } from 'react';
 
+import Link from 'next/link';
+
 import { useTranslation } from 'next-i18next';
 
 export const PrivateLegacyPagesLink: FC = memo(() => {
   const { t } = useTranslation();
 
   return (
-    <a href="/_private-legacy-pages" className="h5 grw-private-legacy-pages-anchor text-decoration-none">
-      <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
-    </a>
+    <Link href="/_private-legacy-pages" prefetch={false}>
+      <a className="h5 grw-private-legacy-pages-anchor text-decoration-none">
+        <i className="icon-drawer mr-2"></i> {t('pagetree.private_legacy_pages')}
+      </a>
+    </Link>
   );
 });
 

+ 7 - 0
packages/app/src/interfaces/editor-settings.ts

@@ -30,6 +30,13 @@ export interface IEditorSettings {
   textlintSettings: undefined | ITextlintSettings;
 }
 
+export type EditorConfig = {
+  upload: {
+    isUploadableFile: boolean,
+    isUploadableImage: boolean,
+  }
+}
+
 export type OptionsToSave = {
   isSlackEnabled: boolean;
   slackChannels: string;

+ 16 - 0
packages/app/src/interfaces/hackmd.ts

@@ -0,0 +1,16 @@
+
+export interface IResHackmdIntegrated {
+  ok: boolean,
+  error?: any,
+  pageIdOnHackmd?: string,
+  revisionIdHackmdSynced?: string,
+  hasDraftOnHackmd?: string
+}
+
+export interface IResHackmdDiscard {
+  ok: boolean,
+  error?: any,
+  pageIdOnHackmd?: string,
+  revisionIdHackmdSynced?: string,
+  hasDraftOnHackmd?: string
+}

+ 1 - 1
packages/app/src/interfaces/services/renderer.ts

@@ -13,7 +13,7 @@ export type RendererConfig = {
   isEnabledLinebreaksInComments: boolean,
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
-  highlightJsStyleBorder: boolean
+  highlightJsStyleBorder: boolean,
 
   plantumlUri: string | null,
   blockdiagUri: string | null,

+ 23 - 13
packages/app/src/pages/[[...path]].page.tsx

@@ -20,12 +20,13 @@ import superjson from 'superjson';
 import { PageAlerts } from '~/components/PageAlert/PageAlerts';
 import { PageComment } from '~/components/PageComment';
 // import { useTranslation } from '~/i18n';
-// import CommentEditorLazyRenderer from '~/components/PageComment/CommentEditorLazyRenderer';
+import { PageContentFooter } from '~/components/PageContentFooter';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 // import { renderScriptTagByName, renderHighlightJsStyleTag } from '~/service/cdn-resources-loader';
 // import { useIndentSize } from '~/stores/editor';
 // import { useRendererSettings } from '~/stores/renderer';
 // import { EditorMode, useEditorMode, useIsMobile } from '~/stores/ui';
+import { EditorConfig } from '~/interfaces/editor-settings';
 import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { ISidebarConfig } from '~/interfaces/sidebar-config';
@@ -40,6 +41,7 @@ import {
 } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
+
 // import { isUserPage, isTrashPage, isSharedPage } from '~/utils/path-utils';
 
 // import GrowiSubNavigation from '../client/js/components/Navbar/GrowiSubNavigation';
@@ -60,6 +62,7 @@ import {
   useIsAclEnabled, useIsUserPage, useIsNotCreatable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPageId, useCurrentPathname,
   useIsSlackConfigured, useIsBlinkedHeaderAtBoot, useRendererConfig, useEditingMarkdown,
+  useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage,
 } from '../stores/context';
 
 import {
@@ -143,8 +146,9 @@ type Props = CommonProps & {
   // mathJax: string,
   // noCdn: string,
   // highlightJsStyle: string,
-  // isAllReplyShown: boolean,
-  // editorConfig: any,
+  isAllReplyShown: boolean,
+  // isContainerFluid: boolean,
+  editorConfig: EditorConfig,
   isEnabledStaleNotification: boolean,
   // isEnabledLinebreaks: boolean,
   // isEnabledLinebreaksInComments: boolean,
@@ -177,7 +181,7 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   }
 
   // commons
-  // useEditorConfig(props.editorConfig);
+  useEditorConfig(props.editorConfig);
   useCsrfToken(props.csrfToken);
 
   // UserUISettings
@@ -221,6 +225,10 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
   useRendererConfig(props.rendererConfig);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
+  useIsAllReplyShown(props.isAllReplyShown);
+
+  useIsUploadableFile(props.editorConfig.upload.isUploadableFile);
+  useIsUploadableImage(props.editorConfig.upload.isUploadableImage);
 
   // const { data: editorMode } = useEditorMode();
 
@@ -322,8 +330,9 @@ const GrowiPage: NextPage<Props> = (props: Props) => {
           </div>
         </div>
         <footer>
-          <PageComment pageId={useCurrentPageId().data} isReadOnly={false} titleAlign="left" />
-          {/* <CommentEditorLazyRenderer pageId={useCurrentPageId().data} /> */}
+          {/* <PageComments /> */}
+          <PageComment pageId={pageId} isReadOnly={false} titleAlign="left" />
+          <PageContentFooter />
         </footer>
 
         <UnsavedAlertDialog />
@@ -482,17 +491,18 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   // props.mathJax = configManager.getConfig('crowi', 'app:mathJax');
   // props.noCdn = configManager.getConfig('crowi', 'app:noCdn');
   // props.highlightJsStyle = configManager.getConfig('crowi', 'customize:highlightJsStyle');
-  // props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
+  props.isAllReplyShown = configManager.getConfig('crowi', 'customize:isAllReplyShown');
+  // props.isContainerFluid = configManager.getConfig('crowi', 'customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('crowi', 'customize:isEnabledStaleNotification');
   // props.isEnabledLinebreaks = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks');
   // props.isEnabledLinebreaksInComments = configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments');
   props.disableLinkSharing = configManager.getConfig('crowi', 'security:disableLinkSharing');
-  // props.editorConfig = {
-  //   upload: {
-  //     image: crowi.fileUploadService.getIsUploadable(),
-  //     file: crowi.fileUploadService.getFileUploadEnabled(),
-  //   },
-  // };
+  props.editorConfig = {
+    upload: {
+      isUploadableFile: crowi.fileUploadService.getFileUploadEnabled(),
+      isUploadableImage: crowi.fileUploadService.getIsUploadable(),
+    },
+  };
   // props.adminPreferredIndentSize = configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize');
   // props.isIndentSizeForced = configManager.getConfig('markdown', 'markdown:isIndentSizeForced');
 

+ 176 - 0
packages/app/src/pages/_private-legacy-pages.page.tsx

@@ -0,0 +1,176 @@
+import {
+  NextPage, GetServerSideProps, GetServerSidePropsContext,
+} from 'next';
+import { serverSideTranslations } from 'next-i18next/serverSideTranslations';
+import dynamic from 'next/dynamic';
+import Head from 'next/head';
+
+import { BasicLayout } from '~/components/Layout/BasicLayout';
+import { CrowiRequest } from '~/interfaces/crowi-request';
+import { RendererConfig } from '~/interfaces/services/renderer';
+import { ISidebarConfig } from '~/interfaces/sidebar-config';
+import { IUser, IUserHasId } from '~/interfaces/user';
+import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import UserUISettings from '~/server/models/user-ui-settings';
+import {
+  useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig,
+} from '~/stores/context';
+import {
+  usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
+  useCurrentSidebarContents, useCurrentProductNavWidth,
+} from '~/stores/ui';
+
+
+import {
+  CommonProps, getNextI18NextConfig, getServerSideCommonProps, useCustomTitle,
+} from './utils/commons';
+
+type Props = CommonProps & {
+  currentUser: IUser,
+
+  isSearchServiceConfigured: boolean,
+  isSearchServiceReachable: boolean,
+  isSearchScopeChildrenAsDefault: boolean,
+
+  // UI
+  userUISettings?: IUserUISettings
+  // Sidebar
+  sidebarConfig: ISidebarConfig,
+
+  // Render config
+  rendererConfig: RendererConfig,
+
+};
+
+const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
+  const { userUISettings } = props;
+
+  const PrivateLegacyPages = dynamic(() => import('~/components/PrivateLegacyPages'), { ssr: false });
+
+  // commons
+  useCsrfToken(props.csrfToken);
+
+  useCurrentUser(props.currentUser ?? null);
+
+  // Search
+  useIsSearchPage(true);
+  useIsSearchServiceConfigured(props.isSearchServiceConfigured);
+  useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+
+  // UserUISettings
+  usePreferDrawerModeByUser(userUISettings?.preferDrawerModeByUser ?? props.sidebarConfig.isSidebarDrawerMode);
+  usePreferDrawerModeOnEditByUser(userUISettings?.preferDrawerModeOnEditByUser);
+  useSidebarCollapsed(userUISettings?.isSidebarCollapsed ?? props.sidebarConfig.isSidebarClosedAtDockMode);
+  useCurrentSidebarContents(userUISettings?.currentSidebarContents);
+  useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
+
+  // render config
+  useRendererConfig(props.rendererConfig);
+
+  return (
+    <>
+      <Head>
+        {/*
+        {renderScriptTagByName('drawio-viewer')}
+        {renderScriptTagByName('highlight-addons')}
+        */}
+      </Head>
+      <div className="on-search">
+        <BasicLayout title={useCustomTitle(props, 'GROWI')}>
+
+          <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
+          <div id="main" className="main search-page mt-0">
+
+            <div id="private-regacy-pages">
+              <PrivateLegacyPages />
+            </div>
+
+          </div>
+        </BasicLayout>
+      </div>
+    </>
+  );
+};
+
+async function injectUserUISettings(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+
+  const userUISettings = user == null ? null : await UserUISettings.findOne({ user: user._id }).exec();
+  if (userUISettings != null) {
+    props.userUISettings = userUISettings.toObject();
+  }
+}
+
+async function injectServerConfigurations(context: GetServerSidePropsContext, props: Props): Promise<void> {
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const { crowi } = req;
+  const { configManager, searchService } = crowi;
+
+  props.isSearchServiceConfigured = searchService.isConfigured;
+  props.isSearchServiceReachable = searchService.isReachable;
+  props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+
+  props.sidebarConfig = {
+    isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
+    isSidebarClosedAtDockMode: configManager.getConfig('crowi', 'customize:isSidebarClosedAtDockMode'),
+  };
+
+  props.rendererConfig = {
+    isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
+    isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
+    isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
+
+    plantumlUri: process.env.PLANTUML_URI ?? null,
+    blockdiagUri: process.env.BLOCKDIAG_URI ?? null,
+
+    // XSS Options
+    isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),
+    attrWhiteList: crowi.xssService.getAttrWhiteList(),
+    tagWhiteList: crowi.xssService.getTagWhiteList(),
+    highlightJsStyleBorder: crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
+  };
+}
+
+/**
+ * for Server Side Translations
+ * @param context
+ * @param props
+ * @param namespacesRequired
+ */
+async function injectNextI18NextConfigurations(context: GetServerSidePropsContext, props: Props, namespacesRequired?: string[] | undefined): Promise<void> {
+  const nextI18NextConfig = await getNextI18NextConfig(serverSideTranslations, context, namespacesRequired);
+  props._nextI18Next = nextI18NextConfig._nextI18Next;
+}
+
+export const getServerSideProps: GetServerSideProps = async(context: GetServerSidePropsContext) => {
+  const req = context.req as CrowiRequest<IUserHasId & any>;
+  const { user } = req;
+
+  const result = await getServerSideCommonProps(context);
+
+  // check for presence
+  // see: https://github.com/vercel/next.js/issues/19271#issuecomment-730006862
+  if (!('props' in result)) {
+    throw new Error('invalid getSSP result');
+  }
+
+  const props: Props = result.props as Props;
+
+  if (user != null) {
+    props.currentUser = user.toObject();
+  }
+
+  await injectUserUISettings(context, props);
+  await injectServerConfigurations(context, props);
+  await injectNextI18NextConfigurations(context, props, ['translation']);
+
+  return {
+    props,
+  };
+};
+
+export default PrivateLegacyPage;

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

@@ -28,11 +28,13 @@ import AdminSamlSecurityContainer from '~/client/services/AdminSamlSecurityConta
 import AdminSlackIntegrationLegacyContainer from '~/client/services/AdminSlackIntegrationLegacyContainer';
 import AdminTwitterSecurityContainer from '~/client/services/AdminTwitterSecurityContainer';
 import AdminUsersContainer from '~/client/services/AdminUsersContainer';
+import { SupportedActionType } from '~/interfaces/activity';
 import { CrowiRequest } from '~/interfaces/crowi-request';
 import PluginUtils from '~/server/plugins/plugin-utils';
 import ConfigLoader from '~/server/service/config-loader';
 import {
   useCurrentUser, /* useSearchServiceConfigured, */ useIsAclEnabled, useIsMailerSetup, useIsSearchServiceReachable, useSiteUrl,
+  useAuditLogEnabled, useAuditLogAvailableActions,
 } from '~/stores/context';
 
 import {
@@ -75,6 +77,8 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isMailerSetup: boolean,
+  auditLogEnabled: boolean,
+  auditLogAvailableActions: SupportedActionType[],
 
   siteUrl: string,
 };
@@ -194,6 +198,9 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
 
   // useEnvVars(props.envVars);
 
+  useAuditLogEnabled(props.auditLogEnabled);
+  useAuditLogAvailableActions(props.auditLogAvailableActions);
+
   const injectableContainers: Container<any>[] = [];
 
   if (isClient()) {
@@ -318,6 +325,9 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
 
+  props.auditLogEnabled = crowi.configManager.getConfig('crowi', 'app:auditLogEnabled');
+  props.auditLogAvailableActions = crowi.activityService.getAvailableActions(false);
+
   return {
     props,
   };

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

@@ -150,7 +150,7 @@ module.exports = function(crowi, app) {
 
   // csurf should be initialized after express-session
   // default methods + PUT. See: https://expressjs.com/en/resources/middleware/csurf.html#ignoremethods
-  app.use(csrf({ ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST'], cookie: false }));
+  app.use(csrf({ ignoreMethods: ['GET', 'HEAD', 'OPTIONS', 'PUT', 'POST', 'DELETE'], cookie: false }));
 
   // passport
   debug('initialize Passport');

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

@@ -3,6 +3,7 @@ import http from 'http';
 import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
+import lsxRoutes from '@growi/plugin-lsx/server/routes';
 import mongoose from 'mongoose';
 import next from 'next';
 
@@ -33,7 +34,6 @@ import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/m
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
-const PluginService = require('../plugins/plugin.service');
 
 const sep = path.sep;
 
@@ -434,10 +434,6 @@ Crowi.prototype.start = async function() {
 
   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 httpServer = http.createServer(app);
@@ -465,6 +461,7 @@ Crowi.prototype.start = async function() {
   }
 
   // setup Express Routes
+  this.setupRoutesForPlugins();
   this.setupRoutesAtLast();
 
   // setup Global Error Handlers
@@ -515,6 +512,10 @@ Crowi.prototype.setupTerminus = function(server) {
   });
 };
 
+Crowi.prototype.setupRoutesForPlugins = function() {
+  lsxRoutes(this, this.express);
+};
+
 /**
  * setup Express Routes
  * !! 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 { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import { PluginUtilsV4 } from './plugin-utils-v4';
+// import { PluginUtilsV4 } from './plugin-utils-v4';
 
 const fs = require('graceful-fs');
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginUtilsV4 = new PluginUtilsV4();
-
 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
    *  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;

+ 4 - 4
packages/app/src/server/routes/apiv3/export.js

@@ -52,16 +52,16 @@ module.exports = (crowi) => {
   const { exportService, socketIoService } = crowi;
 
   const activityEvent = crowi.event('activity');
-  this.adminEvent = crowi.event('admin');
+  const adminEvent = crowi.event('admin');
 
   // setup event
-  this.adminEvent.on('onProgressForExport', (data) => {
+  adminEvent.on('onProgressForExport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onProgressForExport', data);
   });
-  this.adminEvent.on('onStartZippingForExport', (data) => {
+  adminEvent.on('onStartZippingForExport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onStartZippingForExport', data);
   });
-  this.adminEvent.on('onTerminateForExport', (data) => {
+  adminEvent.on('onTerminateForExport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onTerminateForExport', data);
   });
 

+ 7 - 7
packages/app/src/server/routes/apiv3/import.js

@@ -75,17 +75,17 @@ module.exports = (crowi) => {
   const adminRequired = require('../../middlewares/admin-required')(crowi);
   const addActivity = generateAddActivityMiddleware(crowi);
 
-  this.adminEvent = crowi.event('admin');
+  const adminEvent = crowi.event('admin');
   const activityEvent = crowi.event('activity');
 
   // setup event
-  this.adminEvent.on('onProgressForImport', (data) => {
+  adminEvent.on('onProgressForImport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onProgressForImport', data);
   });
-  this.adminEvent.on('onTerminateForImport', (data) => {
+  adminEvent.on('onTerminateForImport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onTerminateForImport', data);
   });
-  this.adminEvent.on('onErrorForImport', (data) => {
+  adminEvent.on('onErrorForImport', (data) => {
     socketIoService.getAdminSocket().emit('admin:onErrorForImport', data);
   });
 
@@ -256,7 +256,7 @@ module.exports = (crowi) => {
     }
     catch (err) {
       logger.error(err);
-      this.adminEvent.emit('onErrorForImport', { message: err.message });
+      adminEvent.emit('onErrorForImport', { message: err.message });
       return;
     }
 
@@ -268,7 +268,7 @@ module.exports = (crowi) => {
     }
     catch (err) {
       logger.error(err);
-      this.adminEvent.emit('onErrorForImport', { message: err.message });
+      adminEvent.emit('onErrorForImport', { message: err.message });
       return;
     }
 
@@ -298,7 +298,7 @@ module.exports = (crowi) => {
     }
     catch (err) {
       logger.error(err);
-      this.adminEvent.emit('onErrorForImport', { message: err.message });
+      adminEvent.emit('onErrorForImport', { message: err.message });
     }
   });
 

+ 10 - 8
packages/app/src/server/routes/apiv3/slack-integration.js

@@ -1,18 +1,21 @@
-import {
-  markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
-} from '@growi/slack';
 import createError from 'http-errors';
-import loggerFactory from '~/utils/logger';
+
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
+import loggerFactory from '~/utils/logger';
+
 import ErrorV3 from '../../models/vo/error-apiv3';
 
-const express = require('express');
-const mongoose = require('mongoose');
-const { body } = require('express-validator');
+import {
+  markdownSectionBlock, InvalidGrowiCommandError, generateRespondUtil, supportedGrowiCommands,
+} from '@growi/slack';
+
 
 const {
   verifySlackRequest, parseSlashCommand, InteractionPayloadAccessor, respond,
 } = require('@growi/slack');
+const express = require('express');
+const { body } = require('express-validator');
+const mongoose = require('mongoose');
 
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
@@ -22,7 +25,6 @@ const { handleError } = require('../../service/slack-command-handler/error-handl
 const { checkPermission } = require('../../util/slack-integration');
 
 module.exports = (crowi) => {
-  this.app = crowi.express;
 
   const { configManager, slackIntegrationService } = crowi;
 

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

@@ -144,7 +144,7 @@ module.exports = function(crowi, app) {
   // app.get('/admin/user-group-detail/:id'                , loginRequiredStrictly, adminRequired, admin.userGroup.detail);
 
   // auditLog admin
-  app.get('/admin/audit-log'                            , loginRequiredStrictly, adminRequired, admin.auditLog.index);
+  // app.get('/admin/audit-log'                            , loginRequiredStrictly, adminRequired, admin.auditLog.index);
 
   // importer management for admin
   // app.get('/admin/importer'                     , loginRequiredStrictly , adminRequired , admin.importer.index);
@@ -239,8 +239,7 @@ module.exports = function(crowi, app) {
     .get('/:token', injectResetOrderByTokenMiddleware, forgotPassword.resetPassword)
     .use(forgotPassword.handleErrosMiddleware));
 
-  app.use('/_private-legacy-pages', express.Router()
-    .get('/', injectUserUISettings, privateLegacyPages.renderPrivateLegacyPages));
+  app.get('/_private-legacy-pages', next.delegateToNext);
   app.use('/user-activation', express.Router()
     .get('/:token', applicationInstalled, injectUserRegistrationOrderByTokenMiddleware, userActivation.form)
     .use(userActivation.tokenErrorHandlerMiddeware));

+ 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 { 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 katex from 'rehype-katex';
 import raw from 'rehype-raw';
@@ -9,6 +17,8 @@ import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
+import deepmerge from 'ts-deepmerge';
+import { PluggableList, Pluggable, PluginTuple } from 'unified';
 
 
 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 => {
   return {
@@ -231,15 +290,6 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
-      [sanitize, {
-        ...sanitizeDefaultSchema,
-        attributes: {
-          ...sanitizeDefaultSchema.attributes,
-          '*': sanitizeDefaultSchema.attributes != null
-            ? sanitizeDefaultSchema.attributes['*'].concat('class', 'className')
-            : ['class', 'className'],
-        },
-      }],
       [addClass, {
         table: 'table table-bordered',
       }],
@@ -262,18 +312,19 @@ export const generateViewOptions = (
   const { remarkPlugins, rehypePlugins, components } = options;
 
   // 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,
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: (toc: HtmlElementNode) => {
@@ -296,17 +347,25 @@ export const generateViewOptions = (
 
         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
   if (components != null) {
     components.h1 = Header;
     components.h2 = Header;
     components.h3 = Header;
+    components.lsx = props => <Lsx {...props} forceToFetchData />;
   }
 
   // // Add configurers for viewer
@@ -321,6 +380,7 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  verifySanitizePlugin(options);
   return options;
 };
 
@@ -331,25 +391,27 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   const { remarkPlugins, rehypePlugins } = options;
 
   // 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'],
       customizeTOC: () => tocNode,
-    }]);
-  }
+    }],
+    [sanitize, commonSanitizeOption],
+  );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   // }]);
 
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
   // // Add configurers for preview
   // renderer.addConfigurers([
@@ -361,19 +423,23 @@ export const generatePreviewOptions = (config: RendererConfig): RendererOptions
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins } = options;
+  const { remarkPlugins, rehypePlugins } = options;
 
   // 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([
@@ -383,11 +449,18 @@ export const generateCommentPreviewOptions = (config: RendererConfig): RendererO
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
   // renderer.addConfigurers([
   //   new TableConfigurer(),
@@ -396,5 +469,11 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };

+ 9 - 0
packages/app/src/stores/context.tsx

@@ -4,6 +4,7 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { SupportedActionType } from '~/interfaces/activity';
+import { EditorConfig } from '~/interfaces/editor-settings';
 // import { CustomWindow } from '~/interfaces/global';
 import { RendererConfig } from '~/interfaces/services/renderer';
 import { GrowiThemes } from '~/interfaces/theme';
@@ -226,10 +227,18 @@ export const useIsLatestRevision = (initialData?: boolean): SWRResponse<boolean,
   return useStaticSWR('isLatestRevision', initialData);
 };
 
+export const useEditorConfig = (initialData?: EditorConfig): SWRResponse<EditorConfig, Error> => {
+  return useStaticSWR<EditorConfig, Error>('editorConfig', initialData);
+};
+
 export const useRendererConfig = (initialData?: RendererConfig): SWRResponse<RendererConfig, any> => {
   return useStaticSWR('growiRendererConfig', initialData);
 };
 
+export const useIsAllReplyShown = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useStaticSWR('isAllReplyShown', initialData);
+};
+
 export const useIsBlinkedHeaderAtBoot = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useStaticSWR('isBlinkedAtBoot', initialData);
 };

+ 5 - 43
packages/app/src/styles/_comment.scss

@@ -9,57 +9,19 @@
   }
 }
 
+
 .page-comments {
+  // TODO: Never use .page-comments-list-toggle-older class.
   .page-comments-list-toggle-older {
     display: inline-block;
     font-size: 0.9em;
   }
+  // TODO: "pointer-events: none;" moved to "Comment.module.scss" now.
+  // .page-comment was defined in _comment.scss and _comment_growi.scss
+  // Required if .page-comment is not under .growi but under .page-comments, or under .growi but not under .page-comments
   .page-comment {
     padding-top: 50px;
     margin-top: -50px;
     pointer-events: none;
   }
-
-  .page-comment {
-    // older comments
-    &.page-comment-older {
-    }
-    // newer comments
-    &.page-comment-newer {
-      opacity: 0.7;
-
-      &:hover {
-        opacity: 1;
-      }
-    }
-
-    .page-comment-meta {
-      display: flex;
-      justify-content: flex-end;
-
-      font-size: 0.9em;
-      color: $gray-400;
-    }
-
-    .page-comment-revision svg {
-      width: 16px;
-      height: 16px;
-    }
-  }
-
-  .page-comment-main {
-    pointer-events: auto;
-
-    // delete button
-    .page-comment-control {
-      position: absolute;
-      top: 0;
-      right: 0;
-      visibility: hidden;
-    }
-
-    &:hover > .page-comment-control {
-      visibility: visible;
-    }
-  }
 }

+ 2 - 121
packages/app/src/styles/_comment_growi.scss

@@ -1,129 +1,13 @@
 .growi {
-  %comment-section {
-    position: relative;
-    padding: 1em;
-
-    // speech balloon
-    &:before {
-      position: absolute;
-      top: 1.5em;
-      left: -1em;
-      display: block;
-      width: 0;
-      content: '';
-      border: 1em solid transparent;
-      border-left-width: 0;
-
-      @include media-breakpoint-down(xs) {
-        top: 1em;
-      }
-    }
-  }
-
-  %picture {
-    float: left;
-    width: 3em;
-    height: 3em;
-    margin-top: 0.8em;
-
-    @include media-breakpoint-down(xs) {
-      width: 2em;
-      height: 2em;
-    }
-  }
-
-  .page-comments {
-    h4 {
-      margin-bottom: 1em;
-    }
-  }
-
-  .page-comment-writer {
-    @include media-breakpoint-down(xs) {
-      height: 3.5em;
-    }
-  }
-
-  .page-comment {
-    position: relative;
-    padding-top: 70px;
-    margin-top: -70px;
-
-    // ユーザー名
-    .page-comment-creator {
-      margin-top: -0.5em;
-      margin-bottom: 0.5em;
-      font-weight: bold;
-    }
-
-    // ユーザーアイコン
-    .picture {
-      @extend %picture;
-    }
-
-    // コメントセクション
-    .page-comment-main {
-      @extend %comment-section;
-      @include media-breakpoint-up(sm) {
-        margin-left: 4.5em;
-      }
-      @include media-breakpoint-down(xs) {
-        &:before {
-          content: none;
-        }
-      }
-    }
-
-    // コメント本文
-    .page-comment-body {
-      margin-bottom: 0.5em;
-      word-wrap: break-word;
-    }
-  }
-
-  /*
-   * reply
-   */
-  .page-comment-reply {
-    margin-top: 1em;
-  }
-  // remove margin after hidden replies
-  .page-comments-hidden-replies + .page-comment-reply {
-    margin-top: 0;
-  }
-  // reply button
-  .btn.btn-comment-reply {
-    margin-top: 0.5em;
-    border: none;
-  }
-
   // display cheatsheet for comment form only
   .comment-form {
+    // TODO: Never use .editor-cheatsheet class.
     .editor-cheatsheet {
       display: none;
     }
 
-    position: relative;
-    margin-top: 1em;
-
-    // user icon
-    .picture {
-      @extend %picture;
-    }
-
-    // seciton
-    .comment-form-main {
-      @extend %comment-section;
-      margin-left: 4.5em;
-      @include media-breakpoint-down(xs) {
-        margin-left: 3.5em;
-      }
-    }
-
     // textarea
-    .comment-write {
-      margin-bottom: 0.5em;
-    }
+    // TODO: Never use .comment-form-comment class.
     .comment-form-comment {
       height: 80px;
       &:focus,
@@ -132,8 +16,5 @@
         transition: height 0.2s ease-out;
       }
     }
-    .comment-form-preview {
-      padding-top: 0.5em;
-    }
   }
 }

+ 0 - 6
packages/app/src/styles/_page-content-footer.scss

@@ -1,6 +0,0 @@
-.page-content-footer {
-  border-top: solid 1px transparent;
-  .page-meta {
-    font-size: 0.95em;
-  }
-}

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

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

+ 1 - 0
packages/app/tsconfig.json

@@ -5,6 +5,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "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';
 
 // export utils by *.js
 export const envUtils = _envUtils;
-export const customTagUtils = _customTagUtils;
 
 // export utils with namespace
+export * as customTagUtils from './plugin/util/custom-tag-utils';
 export * as templateChecker from './utils/template-checker';
 export * as objectIdUtils from './utils/objectid-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 all
+export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/has-object-id';
@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/user';
-export * from './plugin/interfaces/plugin-definition-v4';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';

+ 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
  */
-class TagContext {
+export class TagContext {
 
-  constructor(initArgs = {}) {
+  tagExpression: string | null;
+
+  method: string | null;
+
+  args: any;
+
+  constructor(initArgs: any = {}) {
     this.tagExpression = initArgs.tagExpression || null;
     this.method = initArgs.method || 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
  */
-class ArgsParser {
+export class ArgsParser {
 
   /**
    * @typedef ParseArgsResult
    * @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
    */
 
@@ -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
  */
-class OptionParser {
-
-  /**
-   * @typedef ParseRangeResult
-   * @property {number} start - start index
-   * @property {number} end - end index
-   */
+export class OptionParser {
 
   /**
    * Parse range expression
@@ -27,7 +23,7 @@ class OptionParser {
    * @param {string} str
    * @returns {ParseRangeResult}
    */
-  static parseRange(str) {
+  static parseRange(str: string): ParseRangeResult | null {
     if (str == 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';
 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 */
 
 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', () => {
 

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

@@ -1,8 +1,9 @@
 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', () => {
 
@@ -21,52 +22,53 @@ describe('customTagUtils', () => {
     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 OptionParser from '~/plugin/util/option-parser';
+import { OptionParser } from '~/plugin/util/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
 import 'jest-localstorage-mock';
 
-import * as LocalStorageManager from '~/service/localstorage-manager';
+import { LocalStorageManager } from '~/service/localstorage-manager';
 
 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",
   "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": [
     "dist"
   ],
@@ -18,11 +23,14 @@
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "clean": "npx -y shx rm -rf dist",
     "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:*",
     "test": ""
   },
-  "dependencies": {},
+  "dependencies": {
+    "@growi/core": "^5.1.3-RC.0",
+    "@growi/remark-growi-plugin": "^5.1.3-RC.0"
+  },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.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 { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
 
 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 PropTypes from 'prop-types';
 
-import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
 
 import { PagePathWrapper } from './PagePathWrapper';
 
@@ -33,7 +33,7 @@ export class LsxPage extends React.Component {
 
     // process depth option
     const optDepth = this.props.lsxContext.getOptDepth();
-    if (optDepth === undefined) {
+    if (optDepth == null) {
       this.setState({ isLinkable: true });
     }
     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) {
     this.pagePath = pagePath;
     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) {
     // init sort key
-    const optionsSort = optionsSortArg || 'path';
+    const optionsSort = optionsSortArg ?? 'path';
 
     // 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 = {};
@@ -162,8 +153,6 @@ class Lsx {
 
 module.exports = (crowi, app) => {
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const ApiResponse = crowi.require('../util/apiResponse');
   const actions = {};
 
   /**
@@ -203,8 +192,17 @@ module.exports = (crowi, app) => {
 
   actions.listPages = async(req, res) => {
     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);
 
@@ -221,7 +219,7 @@ module.exports = (crowi, app) => {
         : 1;
     }
     catch (error) {
-      return res.json(ApiResponse.error(error));
+      return res.status(500).send(error);
     }
 
     let query = builder.query;
@@ -244,10 +242,10 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages, toppageViewersCount }));
+      res.status(200).send({ pages, toppageViewersCount });
     }
     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",
   "compilerOptions": {
+    "jsx": "preserve",
   },
   "include": [
     "src"

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

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

Some files were not shown because too many files changed in this diff