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

Merge remote-tracking branch 'origin/master' into support/apply-nextjs-2

Yuki Takei 3 лет назад
Родитель
Сommit
084ce0a7fe
52 измененных файлов с 693 добавлено и 348 удалено
  1. 1 2
      packages/app/src/client/plugin.js
  2. 8 30
      packages/app/src/client/services/AppContainer.js
  3. 39 1
      packages/app/src/client/services/ContextExtractor.tsx
  4. 2 2
      packages/app/src/client/services/PageContainer.js
  5. 0 208
      packages/app/src/client/util/GrowiRenderer.js
  6. 0 23
      packages/app/src/client/util/PreProcessor/XssFilter.js
  7. 4 7
      packages/app/src/client/util/reveal/plugins/growi-renderer.js
  8. 10 2
      packages/app/src/components/MyDraftList/Draft.jsx
  9. 7 4
      packages/app/src/components/Page.jsx
  10. 8 2
      packages/app/src/components/Page/RevisionLoader.jsx
  11. 1 1
      packages/app/src/components/Page/RevisionRenderer.jsx
  12. 9 3
      packages/app/src/components/PageComment.tsx
  13. 1 1
      packages/app/src/components/PageComment/CommentEditor.tsx
  14. 6 1
      packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx
  15. 8 0
      packages/app/src/components/PageEditor.tsx
  16. 5 5
      packages/app/src/components/PageEditor/Preview.tsx
  17. 12 3
      packages/app/src/components/PageTimeline.jsx
  18. 4 4
      packages/app/src/components/SearchPage/SearchResultContent.tsx
  19. 6 1
      packages/app/src/components/Sidebar/CustomSidebar.tsx
  20. 4 1
      packages/app/src/interfaces/global.ts
  21. 24 0
      packages/app/src/interfaces/services/renderer.ts
  22. 2 2
      packages/app/src/server/middlewares/login-form-validator.ts
  23. 1 1
      packages/app/src/server/routes/page.js
  24. 0 0
      packages/app/src/services/renderer/PostProcessor/.keep
  25. 0 0
      packages/app/src/services/renderer/PreProcessor/CsvToTable.js
  26. 0 0
      packages/app/src/services/renderer/PreProcessor/EasyGrid.js
  27. 0 0
      packages/app/src/services/renderer/PreProcessor/Linker.js
  28. 29 0
      packages/app/src/services/renderer/PreProcessor/XssFilter.ts
  29. 266 0
      packages/app/src/services/renderer/growi-renderer.ts
  30. 0 0
      packages/app/src/services/renderer/interceptor/detach-code-blocks.js
  31. 0 0
      packages/app/src/services/renderer/interceptor/drawio-interceptor.js
  32. 2 5
      packages/app/src/services/renderer/markdown-it/blockdiag.js
  33. 0 0
      packages/app/src/services/renderer/markdown-it/drawio-viewer.js
  34. 0 0
      packages/app/src/services/renderer/markdown-it/emoji-mart-data.ts
  35. 0 0
      packages/app/src/services/renderer/markdown-it/emoji.js
  36. 0 0
      packages/app/src/services/renderer/markdown-it/footernote.js
  37. 0 0
      packages/app/src/services/renderer/markdown-it/header-line-number.js
  38. 0 0
      packages/app/src/services/renderer/markdown-it/header-with-edit-link.js
  39. 0 0
      packages/app/src/services/renderer/markdown-it/header.js
  40. 5 9
      packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts
  41. 2 5
      packages/app/src/services/renderer/markdown-it/mathjax.js
  42. 2 5
      packages/app/src/services/renderer/markdown-it/plantuml.js
  43. 0 0
      packages/app/src/services/renderer/markdown-it/table-with-handsontable-button.js
  44. 0 0
      packages/app/src/services/renderer/markdown-it/table.js
  45. 0 4
      packages/app/src/services/renderer/markdown-it/task-lists.js
  46. 0 0
      packages/app/src/services/renderer/markdown-it/toc-and-anchor.js
  47. 0 13
      packages/app/src/services/xss/xssOption.js
  48. 24 0
      packages/app/src/services/xss/xssOption.ts
  49. 5 0
      packages/app/src/stores/context.tsx
  50. 76 0
      packages/app/src/stores/renderer.tsx
  51. 118 1
      packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts
  52. 2 2
      packages/plugin-pukiwiki-like-linker/src/client-entry.js

+ 1 - 2
packages/app/src/client/plugin.js

@@ -8,12 +8,11 @@ export default class GrowiPlugin {
    * process plugin entry
    *
    * @param {AppContainer} appContainer
-   * @param {GrowiRenderer} originRenderer The origin instance of GrowiRenderer
    *
    * @memberof CrowiPlugin
    */
   // eslint-disable-next-line @typescript-eslint/no-unused-vars
-  installAll(appContainer, originRenderer) {
+  installAll(appContainer) {
     // import plugin definitions
     let definitions = [];
     try {

+ 8 - 30
packages/app/src/client/services/AppContainer.js

@@ -1,7 +1,8 @@
 import { Container } from 'unstated';
 
 
-import GrowiRenderer from '../util/GrowiRenderer';
+import GrowiRenderer, { generatePreviewRenderer } from '~/services/renderer/growi-renderer';
+
 import { i18nFactory } from '../util/i18n';
 
 /**
@@ -26,7 +27,6 @@ export default class AppContainer extends Container {
 
     this.containerInstances = {};
     this.componentInstances = {};
-    this.rendererInstances = {};
   }
 
   /**
@@ -45,8 +45,6 @@ export default class AppContainer extends Container {
 
     this.isDocSaved = true;
 
-    this.originRenderer = new GrowiRenderer(this);
-
     const isPluginEnabled = body.dataset.pluginEnabled === 'true';
     if (isPluginEnabled) {
       this.initPlugins();
@@ -57,18 +55,20 @@ export default class AppContainer extends Container {
 
   initPlugins() {
     const growiPlugin = window.growiPlugin;
-    growiPlugin.installAll(this, this.originRenderer);
+    growiPlugin.installAll(this);
   }
 
   injectToWindow() {
     window.appContainer = this;
 
-    const originRenderer = this.getOriginRenderer();
-    window.growiRenderer = originRenderer;
+    const growiRenderer = new GrowiRenderer(this.getConfig());
+    growiRenderer.init();
+
+    window.growiRenderer = growiRenderer;
 
     // backward compatibility
     window.crowi = this;
-    window.crowiRenderer = originRenderer;
+    window.crowiRenderer = window.growiRenderer;
     window.crowiPlugin = window.growiPlugin;
   }
 
@@ -126,26 +126,4 @@ export default class AppContainer extends Container {
     return this.componentInstances[id];
   }
 
-  getOriginRenderer() {
-    return this.originRenderer;
-  }
-
-  /**
-   * factory method
-   */
-  getRenderer(mode) {
-    if (this.rendererInstances[mode] != null) {
-      return this.rendererInstances[mode];
-    }
-
-    const renderer = new GrowiRenderer(this, this.originRenderer);
-    // setup
-    renderer.initMarkdownItConfigurers(mode);
-    renderer.setup(mode);
-    // register
-    this.rendererInstances[mode] = renderer;
-
-    return renderer;
-  }
-
 }

+ 39 - 1
packages/app/src/client/services/ContextExtractor.tsx

@@ -3,7 +3,10 @@ import React, { FC, useEffect, useState } from 'react';
 
 import { pagePathUtils } from '@growi/core';
 
+import { CustomWindow } from '~/interfaces/global';
 import { IUserUISettings } from '~/interfaces/user-ui-settings';
+import { generatePreviewRenderer } from '~/services/renderer/growi-renderer';
+import { useRendererSettings } from '~/stores/renderer';
 import {
   useIsDeviceSmallerThanMd, useIsDeviceSmallerThanLg,
   usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed, useCurrentSidebarContents, useCurrentProductNavWidth,
@@ -20,7 +23,7 @@ import {
   useIsSearchPage, useIsForbidden, useIsIdenticalPath, useHasParent,
   useIsAclEnabled, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsEnabledAttachTitleHeader,
   useDefaultIndentSize, useIsIndentSizeForced, useCsrfToken, useGrowiVersion, useAuditLogEnabled,
-  useActivityExpirationSeconds, useAuditLogAvailableActions,
+  useActivityExpirationSeconds, useAuditLogAvailableActions, useGrowiRendererConfig,
 } from '../../stores/context';
 
 const { isTrashPage: _isTrashPage } = pagePathUtils;
@@ -117,6 +120,23 @@ const ContextExtractorOnce: FC = () => {
   useActivityExpirationSeconds(configByContextHydrate.activityExpirationSeconds);
   useAuditLogAvailableActions(configByContextHydrate.auditLogAvailableActions);
   useGrowiVersion(configByContextHydrate.crowi.version);
+  useRendererSettings({
+    isEnabledLinebreaks: configByContextHydrate.isEnabledLinebreaks,
+    isEnabledLinebreaksInComments: configByContextHydrate.isEnabledLinebreaksInComments,
+    adminPreferredIndentSize: configByContextHydrate.adminPreferredIndentSize,
+    isIndentSizeForced: configByContextHydrate.isIndentSizeForced,
+  });
+  useGrowiRendererConfig({
+    isEnabledXssPrevention: configByContextHydrate.isEnabledXssPrevention,
+    attrWhiteList: configByContextHydrate.attrWhiteList,
+    tagWhiteList: configByContextHydrate.tagWhiteList,
+    highlightJsStyleBorder: configByContextHydrate.highlightJsStyleBorder,
+    env: {
+      MATHJAX: configByContextHydrate.env.MATHJAX,
+      PLANTUML_URI: configByContextHydrate.env.PLANTUML_URI,
+      BLOCKDIAG_URI: configByContextHydrate.env.BLOCKDIAG_URI,
+    },
+  });
 
   // Page
   useDeleteUsername(deleteUsername);
@@ -166,6 +186,24 @@ const ContextExtractorOnce: FC = () => {
   const shouldInitAdminSock = !!currentUser?.isAdmin;
   useSetupGlobalAdminSocket(shouldInitAdminSock);
 
+  // TODO: Remove this code when reveal.js is omitted. see: https://github.com/weseek/growi/pull/6223
+  // Do not access this property from other than reveal.js plugins.
+  (window as CustomWindow).previewRenderer = generatePreviewRenderer(
+    {
+      isEnabledXssPrevention: configByContextHydrate.isEnabledXssPrevention,
+      attrWhiteList: configByContextHydrate.attrWhiteList,
+      tagWhiteList: configByContextHydrate.tagWhiteList,
+      highlightJsStyleBorder: configByContextHydrate.highlightJsStyleBorder,
+      env: {
+        MATHJAX: configByContextHydrate.env.MATHJAX,
+        PLANTUML_URI: configByContextHydrate.env.PLANTUML_URI,
+        BLOCKDIAG_URI: configByContextHydrate.env.BLOCKDIAG_URI,
+      },
+    },
+    null,
+    path,
+  );
+
   return null;
 };
 

+ 2 - 2
packages/app/src/client/services/PageContainer.js

@@ -13,10 +13,10 @@ import { apiv3Post } from '../util/apiv3-client';
 import {
   DetachCodeBlockInterceptor,
   RestoreCodeBlockInterceptor,
-} from '../util/interceptor/detach-code-blocks';
+} from '../../services/renderer/interceptor/detach-code-blocks';
 import {
   DrawioInterceptor,
-} from '../util/interceptor/drawio-interceptor';
+} from '../../services/renderer/interceptor/drawio-interceptor';
 
 const { isTrashPage } = pagePathUtils;
 

+ 0 - 208
packages/app/src/client/util/GrowiRenderer.js

@@ -1,208 +0,0 @@
-import MarkdownIt from 'markdown-it';
-
-import loggerFactory from '~/utils/logger';
-
-import CsvToTable from './PreProcessor/CsvToTable';
-import EasyGrid from './PreProcessor/EasyGrid';
-import Linker from './PreProcessor/Linker';
-import XssFilter from './PreProcessor/XssFilter';
-import BlockdiagConfigurer from './markdown-it/blockdiag';
-import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
-import EmojiConfigurer from './markdown-it/emoji';
-import FooternoteConfigurer from './markdown-it/footernote';
-import HeaderConfigurer from './markdown-it/header';
-import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
-import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
-import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
-import MathJaxConfigurer from './markdown-it/mathjax';
-import PlantUMLConfigurer from './markdown-it/plantuml';
-import TableConfigurer from './markdown-it/table';
-import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
-import TaskListsConfigurer from './markdown-it/task-lists';
-import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
-
-const logger = loggerFactory('growi:util:GrowiRenderer');
-
-export default class GrowiRenderer {
-
-  /**
-   *
-   * @param {AppContainer} appContainer
-   * @param {GrowiRenderer} originRenderer
-   * @param {string} mode
-   */
-  constructor(appContainer, originRenderer) {
-    this.appContainer = appContainer;
-
-    if (originRenderer != null) {
-      this.preProcessors = originRenderer.preProcessors;
-      this.postProcessors = originRenderer.postProcessors;
-    }
-    else {
-      this.preProcessors = [
-        new EasyGrid(),
-        new Linker(),
-        new CsvToTable(),
-        new XssFilter(appContainer),
-      ];
-      this.postProcessors = [
-      ];
-    }
-
-    this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
-    this.setup = this.setup.bind(this);
-    this.process = this.process.bind(this);
-    this.codeRenderer = this.codeRenderer.bind(this);
-  }
-
-  initMarkdownItConfigurers(mode) {
-    const appContainer = this.appContainer;
-
-    // init markdown-it
-    this.md = new MarkdownIt({
-      html: true,
-      linkify: true,
-      highlight: this.codeRenderer,
-    });
-
-    this.isMarkdownItConfigured = false;
-
-    this.markdownItConfigurers = [
-      new LinkerByRelativePathConfigurer(appContainer),
-      new TaskListsConfigurer(appContainer),
-      new HeaderConfigurer(),
-      new EmojiConfigurer(),
-      new MathJaxConfigurer(appContainer),
-      new DrawioViewerConfigurer(),
-      new PlantUMLConfigurer(appContainer),
-      new BlockdiagConfigurer(appContainer),
-    ];
-
-    // add configurers according to mode
-    switch (mode) {
-      case 'page': {
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(),
-          new TocAndAnchorConfigurer(),
-          new HeaderLineNumberConfigurer(),
-          new HeaderWithEditLinkConfigurer(),
-          new TableWithHandsontableButtonConfigurer(),
-        ]);
-        break;
-      }
-      case 'editor':
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new FooternoteConfigurer(),
-          new HeaderLineNumberConfigurer(),
-          new TableConfigurer(),
-        ]);
-        break;
-      // case 'comment':
-      //   break;
-      default:
-        this.markdownItConfigurers = this.markdownItConfigurers.concat([
-          new TableConfigurer(),
-        ]);
-        break;
-    }
-  }
-
-  /**
-   * setup with crowi config
-   */
-  setup(mode) {
-    const crowiConfig = this.appContainer.config;
-
-    let isEnabledLinebreaks;
-    switch (mode) {
-      case 'comment':
-        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaksInComments;
-        break;
-      default:
-        isEnabledLinebreaks = crowiConfig.isEnabledLinebreaks;
-        break;
-    }
-
-    this.md.set({
-      breaks: isEnabledLinebreaks,
-    });
-
-    if (!this.isMarkdownItConfigured) {
-      this.markdownItConfigurers.forEach((configurer) => {
-        configurer.configure(this.md);
-      });
-    }
-  }
-
-  preProcess(markdown, context) {
-    let processed = markdown;
-    for (let i = 0; i < this.preProcessors.length; i++) {
-      if (!this.preProcessors[i].process) {
-        continue;
-      }
-      processed = this.preProcessors[i].process(processed, context);
-    }
-
-    return processed;
-  }
-
-  process(markdown, context) {
-    return this.md.render(markdown, context);
-  }
-
-  postProcess(html, context) {
-    let processed = html;
-    for (let i = 0; i < this.postProcessors.length; i++) {
-      if (!this.postProcessors[i].process) {
-        continue;
-      }
-      processed = this.postProcessors[i].process(processed, context);
-    }
-
-    return processed;
-  }
-
-  codeRenderer(code, langExt) {
-    const config = this.appContainer.getConfig();
-    const noborder = (!config.highlightJsStyleBorder) ? 'hljs-no-border' : '';
-
-    let citeTag = '';
-    let hljsLang = 'plaintext';
-    let showLinenumbers = false;
-
-    if (langExt) {
-      // https://regex101.com/r/qGs7eZ/3
-      const match = langExt.match(/^([^:=\n]+)?(=([^:=\n]*))?(:([^:=\n]*))?(=([^:=\n]*))?$/);
-
-      const lang = match[1];
-      const fileName = match[5] || null;
-      showLinenumbers = (match[2] != null) || (match[6] != null);
-
-      if (fileName != null) {
-        citeTag = `<cite>${fileName}</cite>`;
-      }
-      if (hljs.getLanguage(lang)) {
-        hljsLang = lang;
-      }
-    }
-
-    let highlightCode = code;
-    try {
-      highlightCode = hljs.highlight(hljsLang, code, true).value;
-
-      // add line numbers
-      if (showLinenumbers) {
-        highlightCode = hljs.lineNumbersValue((highlightCode));
-      }
-    }
-    catch (err) {
-      logger.error(err);
-    }
-
-    return `<pre class="hljs ${noborder}">${citeTag}<code>${highlightCode}</code></pre>`;
-  }
-
-  highlightCode(code, lang) {
-  }
-
-}

+ 0 - 23
packages/app/src/client/util/PreProcessor/XssFilter.js

@@ -1,23 +0,0 @@
-import Xss from '~/services/xss';
-import XssOption from '~/services/xss/xssOption';
-
-export default class XssFilter {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    if (crowi.config.isEnabledXssPrevention) {
-      this.xssOption = new XssOption(crowi.config);
-      this.xss = new Xss(this.xssOption);
-    }
-  }
-
-  process(markdown) {
-    if (this.crowi.config.isEnabledXssPrevention) {
-      return this.xss.process(markdown);
-    }
-
-    return markdown;
-  }
-
-}

+ 4 - 7
packages/app/src/client/util/reveal/plugins/growi-renderer.js

@@ -2,19 +2,16 @@
  * reveal.js growi-renderer plugin.
  */
 (function(root, factory) {
-  // get AppContainer instance from parent window
-  const appContainer = window.parent.appContainer;
-
-  const growiRendererPlugin = factory(appContainer);
+  const growiRendererPlugin = factory();
   growiRendererPlugin.initialize();
-}(this, (appContainer) => {
+}(this, () => {
   /* eslint-disable no-useless-escape */
   const DEFAULT_SLIDE_SEPARATOR = '^\r?\n---\r?\n$';
   const DEFAULT_ELEMENT_ATTRIBUTES_SEPARATOR = '\\\.element\\\s*?(.+?)$';
   const DEFAULT_SLIDE_ATTRIBUTES_SEPARATOR = '\\\.slide:\\\s*?(\\\S.+?)$';
   /* eslint-enable no-useless-escape */
 
-  const growiRenderer = appContainer.getRenderer('editor');
+  const growiRenderer = window.parent.previewRenderer;
 
   let marked;
 
@@ -61,7 +58,7 @@
         section.setAttribute('data-markdown-parsed', 'true');
         const notes = section.querySelector('aside.notes');
         markdown = marked.getMarkdownFromSlide(section);
-        const context = { markdown };
+        const context = { markdown, currentPathname: decodeURIComponent(window.parent.location.pathname) };
 
         interceptorManager.process('preRender', context)
           .then(() => { return interceptorManager.process('prePreProcess', context) })

+ 10 - 2
packages/app/src/components/MyDraftList/Draft.jsx

@@ -9,6 +9,8 @@ import {
 } from 'reactstrap';
 
 import AppContainer from '~/client/services/AppContainer';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
+import { useDraftRenderer } from '~/stores/renderer';
 
 import RevisionBody from '../Page/RevisionBody';
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -25,7 +27,7 @@ class Draft extends React.Component {
       showCopiedMessage: false,
     };
 
-    this.growiRenderer = this.props.appContainer.getRenderer('draft');
+    this.growiRenderer = this.props.growiRenderer;
 
     this.changeToolTipLabel = this.changeToolTipLabel.bind(this);
     this.expandPanelHandler = this.expandPanelHandler.bind(this);
@@ -194,6 +196,7 @@ class Draft extends React.Component {
 Draft.propTypes = {
   t: PropTypes.func.isRequired,
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
 
   index: PropTypes.number.isRequired,
   path: PropTypes.string.isRequired,
@@ -204,7 +207,12 @@ Draft.propTypes = {
 
 const DraftWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <Draft t={t} {...props} />;
+  const { data: growiRenderer } = useDraftRenderer();
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
+  return <Draft t={t} growiRenderer={growiRenderer} {...props} />;
 };
 
 /**

+ 7 - 4
packages/app/src/components/Page.jsx

@@ -7,12 +7,14 @@ import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { getOptionsToSave } from '~/client/util/editor';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import {
   useCurrentPagePath, useIsGuestUser,
 } from '~/stores/context';
 import {
   useSWRxSlackChannels, useIsSlackEnabled, usePageTagsForEditors, useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
+import { useViewRenderer } from '~/stores/renderer';
 import {
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
 } from '~/stores/ui';
@@ -39,8 +41,6 @@ class Page extends React.Component {
       currentTargetDrawioArea: null,
     };
 
-    this.growiRenderer = this.props.appContainer.getRenderer('page');
-
     this.gridEditModal = React.createRef();
     this.linkEditModal = React.createRef();
     this.handsontableModal = React.createRef();
@@ -149,7 +149,7 @@ class Page extends React.Component {
       <div className={`mb-5 ${isMobile ? 'page-mobile' : ''}`}>
 
         { revisionId != null && (
-          <RevisionRenderer growiRenderer={this.growiRenderer} markdown={markdown} pagePath={pagePath} />
+          <RevisionRenderer growiRenderer={this.props.growiRenderer} markdown={markdown} pagePath={pagePath} />
         )}
 
         { !isGuestUser && (
@@ -171,6 +171,7 @@ Page.propTypes = {
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   editorContainer: PropTypes.instanceOf(EditorContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
 
   pagePath: PropTypes.string.isRequired,
   pageTags:  PropTypes.arrayOf(PropTypes.string),
@@ -195,6 +196,7 @@ const PageWrapper = (props) => {
   const { data: grant } = useSelectedGrant();
   const { data: grantGroupId } = useSelectedGrantGroupId();
   const { data: grantGroupName } = useSelectedGrantGroupName();
+  const { data: growiRenderer } = useViewRenderer();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
   const pageRef = useRef(null);
@@ -227,7 +229,7 @@ const PageWrapper = (props) => {
     };
   }, []);
 
-  if (currentPagePath == null || editorMode == null || isGuestUser == null) {
+  if (currentPagePath == null || editorMode == null || isGuestUser == null || growiRenderer == null) {
     return null;
   }
 
@@ -236,6 +238,7 @@ const PageWrapper = (props) => {
     <Page
       {...props}
       ref={pageRef}
+      growiRenderer={growiRenderer}
       pagePath={currentPagePath}
       editorMode={editorMode}
       isGuestUser={isGuestUser}

+ 8 - 2
packages/app/src/components/Page/RevisionLoader.jsx

@@ -4,8 +4,9 @@ import PropTypes from 'prop-types';
 import { useTranslation } from 'next-i18next';
 import { Waypoint } from 'react-waypoint';
 
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
+import { useViewRenderer } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './RevisionRenderer';
@@ -134,7 +135,12 @@ RevisionLoader.propTypes = {
 
 const RevisionLoaderWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <RevisionLoader t={t} {...props} />;
+  const { data: growiRenderer } = useViewRenderer();
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
+  return <RevisionLoader t={t} growiRenderer={growiRenderer} {...props} />;
 };
 
 export default RevisionLoaderWrapperFC;

+ 1 - 1
packages/app/src/components/Page/RevisionRenderer.jsx

@@ -3,9 +3,9 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import AppContainer from '~/client/services/AppContainer';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { blinkElem } from '~/client/util/blink-section-header';
 import { addSmoothScrollEvent } from '~/client/util/smooth-scroll';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useEditorSettings } from '~/stores/editor';
 
 import { withUnstatedContainers } from '../UnstatedUtils';

+ 9 - 3
packages/app/src/components/PageComment.tsx

@@ -8,6 +8,7 @@ import { Button } from 'reactstrap';
 import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 import { apiPost } from '~/client/util/apiv1-client';
+import { useCommentPreviewRenderer } from '~/stores/renderer';
 
 import { ICommentHasId, ICommentHasIdList } from '../interfaces/comment';
 import { useSWRxPageComment } from '../stores/comment';
@@ -35,6 +36,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
   } = props;
 
   const { data: comments, mutate } = useSWRxPageComment(pageId);
+  const { data: growiRenderer } = useCommentPreviewRenderer();
 
   const [commentToBeDeleted, setCommentToBeDeleted] = useState<ICommentHasId | null>(null);
   const [isDeleteConfirmModalShown, setIsDeleteConfirmModalShown] = useState<boolean>(false);
@@ -110,7 +112,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
 
   const generateCommentInnerElement = (comment: ICommentHasId) => (
     <Comment
-      growiRenderer={appContainer.getRenderer('comment')}
+      growiRenderer={growiRenderer}
       deleteBtnClicked={onClickDeleteButton}
       comment={comment}
       onComment={mutate}
@@ -122,7 +124,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     <ReplayComments
       replyList={replyComments}
       deleteBtnClicked={onClickDeleteButton}
-      growiRenderer={appContainer.getRenderer('comment')}
+      growiRenderer={growiRenderer}
       isReadOnly={isReadOnly}
     />
   );
@@ -142,6 +144,10 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
     return <></>;
   }
 
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
   let commentTitleClasses = 'border-bottom py-3 mb-3';
   commentTitleClasses = titleAlign != null ? `${commentTitleClasses} text-${titleAlign}` : `${commentTitleClasses} text-center`;
 
@@ -185,7 +191,7 @@ const PageComment:FC<Props> = memo((props:Props):JSX.Element => {
                     {/* display reply editor */}
                     {(!isReadOnly && showEditorIds.has(comment._id)) && (
                       <CommentEditor
-                        growiRenderer={appContainer.getRenderer('comment')}
+                        growiRenderer={growiRenderer}
                         replyTo={comment._id}
                         onCancelButtonClicked={() => {
                           removeShowEditorId(comment._id);

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.tsx

@@ -12,10 +12,10 @@ import * as toastr from 'toastr';
 import AppContainer from '~/client/services/AppContainer';
 import EditorContainer from '~/client/services/EditorContainer';
 import PageContainer from '~/client/services/PageContainer';
-import GrowiRenderer from '~/client/util/GrowiRenderer';
 import { apiPostForm } from '~/client/util/apiv1-client';
 import { CustomWindow } from '~/interfaces/global';
 import { IInterceptorManager } from '~/interfaces/interceptor-manager';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useSWRxPageComment } from '~/stores/comment';
 import {
   useCurrentPagePath, useCurrentPageId, useCurrentUser, useRevisionId,

+ 6 - 1
packages/app/src/components/PageComment/CommentEditorLazyRenderer.tsx

@@ -5,6 +5,7 @@ import { useSWRxPageComment } from '../../stores/comment';
 import AppContainer from '~/client/services/AppContainer';
 
 import CommentEditor from './CommentEditor';
+import { useCommentPreviewRenderer } from '~/stores/renderer';
 
 type Props = {
   appContainer: AppContainer,
@@ -15,9 +16,13 @@ const CommentEditorLazyRenderer:FC<Props> = (props:Props):JSX.Element => {
 
   const { pageId } = props;
   const { mutate } = useSWRxPageComment(pageId);
+  const { data: growiRenderer } = useCommentPreviewRenderer();
+
+  if (growiRenderer == null) {
+    return <></>;
+  }
 
   const { appContainer } = props;
-  const growiRenderer = appContainer.getRenderer('comment');
 
   return (
     <CommentEditor

+ 8 - 0
packages/app/src/components/PageEditor.tsx

@@ -20,6 +20,7 @@ import {
   useCurrentIndentSize, useSWRxSlackChannels, useIsSlackEnabled, useIsTextlintEnabled, usePageTagsForEditors,
   useIsEnabledUnsavedWarning,
 } from '~/stores/editor';
+import { usePreviewRenderer } from '~/stores/renderer';
 import {
   EditorMode,
   useEditorMode, useIsMobile, useSelectedGrant, useSelectedGrantGroupId, useSelectedGrantGroupName,
@@ -99,6 +100,8 @@ const PageEditor = (props: Props): JSX.Element => {
   const { data: indentSize, mutate: mutateCurrentIndentSize } = useCurrentIndentSize();
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
 
+  const { data: growiRenderer } = usePreviewRenderer();
+
   // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
   const [markdown, setMarkdown] = useState<string>(pageContainer.state.markdown!);
 
@@ -389,6 +392,10 @@ const PageEditor = (props: Props): JSX.Element => {
     return <></>;
   }
 
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
   const config = props.appContainer.getConfig();
   const isUploadable = config.upload.image || config.upload.file;
   const isUploadableFile = config.upload.file;
@@ -425,6 +432,7 @@ const PageEditor = (props: Props): JSX.Element => {
       <div className="d-none d-lg-block page-editor-preview-container flex-grow-1 flex-basis-0 mw-0">
         <Preview
           markdown={markdown}
+          growiRenderer={growiRenderer}
           ref={previewRef}
           isMathJaxEnabled={isMathJaxEnabled}
           renderMathJaxOnInit={false}

+ 5 - 5
packages/app/src/components/PageEditor/Preview.tsx

@@ -5,6 +5,7 @@ import React, {
 
 import AppContainer from '~/client/services/AppContainer';
 import InterceptorManager from '~/services/interceptor-manager';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import { useEditorSettings } from '~/stores/editor';
 
 import RevisionBody from '../Page/RevisionBody';
@@ -15,6 +16,7 @@ declare const interceptorManager: InterceptorManager;
 
 
 type Props = {
+  growiRenderer: GrowiRenderer,
   markdown?: string,
   pagePath?: string,
   isMathJaxEnabled?: boolean,
@@ -27,7 +29,7 @@ type UnstatedProps = Props & { appContainer: AppContainer };
 const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivElement>): JSX.Element => {
 
   const {
-    appContainer,
+    growiRenderer,
     markdown, pagePath,
   } = props;
 
@@ -35,8 +37,6 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
 
   const { data: editorSettings } = useEditorSettings();
 
-  const growiRenderer = appContainer.getRenderer('editor');
-
   const context = useMemo(() => {
     return {
       markdown,
@@ -61,7 +61,7 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
     }
 
     setHtml(context.parsedHTML ?? '');
-  }, [interceptorManager, context, growiRenderer]);
+  }, [context, growiRenderer]);
 
   useEffect(() => {
     if (markdown == null) {
@@ -82,7 +82,7 @@ const Preview = React.forwardRef((props: UnstatedProps, ref: RefObject<HTMLDivEl
         parsedHTML: html,
       });
     }
-  }, [context, html, interceptorManager]);
+  }, [context, html]);
 
   return (
     <div

+ 12 - 3
packages/app/src/components/PageTimeline.jsx

@@ -6,6 +6,8 @@ import { useTranslation } from 'next-i18next';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import { apiv3Get } from '~/client/util/apiv3-client';
+import GrowiRenderer from '~/services/renderer/growi-renderer';
+import { useTimelineRenderer } from '~/stores/renderer';
 
 import RevisionLoader from './Page/RevisionLoader';
 import PaginationWrapper from './PaginationWrapper';
@@ -48,9 +50,9 @@ class PageTimeline extends React.Component {
   }
 
   UNSAFE_componentWillMount() {
-    const { appContainer } = this.props;
+    const { growiRenderer } = this.props;
     // initialize GrowiRenderer
-    this.growiRenderer = appContainer.getRenderer('timeline');
+    this.growiRenderer = growiRenderer;
   }
 
   async componentDidMount() {
@@ -110,13 +112,20 @@ class PageTimeline extends React.Component {
 PageTimeline.propTypes = {
   t: PropTypes.func.isRequired, // i18next
   appContainer: PropTypes.instanceOf(AppContainer).isRequired,
+  growiRenderer: PropTypes.instanceOf(GrowiRenderer).isRequired,
   pageContainer: PropTypes.instanceOf(PageContainer).isRequired,
   pages: PropTypes.arrayOf(PropTypes.object),
 };
 
 const PageTimelineWrapperFC = (props) => {
   const { t } = useTranslation();
-  return <PageTimeline t={t} {...props} />;
+  const { data: growiRenderer } = useTimelineRenderer();
+
+  if (growiRenderer == null) {
+    return <></>;
+  }
+
+  return <PageTimeline t={t} growiRenderer={growiRenderer} {...props} />;
 };
 
 /**

+ 4 - 4
packages/app/src/components/SearchPage/SearchResultContent.tsx

@@ -15,6 +15,7 @@ import {
   usePageDuplicateModal, usePageRenameModal, usePageDeleteModal,
 } from '~/stores/modal';
 import { useDescendantsPageListForCurrentPathTermManager, usePageTreeTermManager } from '~/stores/page-listing';
+import { useSearchResultRenderer } from '~/stores/renderer';
 import { useFullTextSearchTermManager } from '~/stores/search';
 
 
@@ -119,8 +120,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
   const { open: openRenameModal } = usePageRenameModal();
   const { open: openDeleteModal } = usePageDeleteModal();
 
-  const growiRenderer = appContainer.getRenderer('searchresult');
-
+  const { data: growiRenderer } = useSearchResultRenderer();
 
   const duplicateItemClickedHandler = useCallback(async(pageToDuplicate) => {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -193,8 +193,8 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
     );
   }, [page, showPageControlDropdown, forceHideMenuItems, duplicateItemClickedHandler, renameItemClickedHandler, deleteItemClickedHandler]);
 
-  // return if page is null
-  if (page == null) return <></>;
+  // return if page or growiRenderer is null
+  if (page == null || growiRenderer == null) return <></>;
 
   return (
     <div key={page._id} data-testid="search-result-content" className="search-result-content grw-page-path-text-muted-container d-flex flex-column">

+ 6 - 1
packages/app/src/components/Sidebar/CustomSidebar.tsx

@@ -7,6 +7,7 @@ import { useSWRxPageByPath } from '~/stores/page';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import RevisionRenderer from '../Page/RevisionRenderer';
 import { IRevision } from '~/interfaces/revision';
+import { useCustomSidebarRenderer } from '~/stores/renderer';
 
 const logger = loggerFactory('growi:cli:CustomSidebar');
 
@@ -29,10 +30,14 @@ const CustomSidebar: FC<Props> = (props: Props) => {
 
   const { appContainer } = props;
 
-  const renderer = appContainer.getRenderer('sidebar');
+  const { data: renderer } = useCustomSidebarRenderer();
 
   const { data: page, error, mutate } = useSWRxPageByPath('/Sidebar');
 
+  if (renderer == null) {
+    return <></>;
+  }
+
   const isLoading = page === undefined && error == null;
   const markdown = (page?.revision as IRevision | undefined)?.body;
 

+ 4 - 1
packages/app/src/interfaces/global.ts

@@ -1,5 +1,6 @@
 import EventEmitter from 'events';
 
+import GrowiRenderer from '~/services/renderer/growi-renderer';
 import Xss from '~/services/xss';
 
 import { IGraphViewer } from './graph-viewer';
@@ -9,4 +10,6 @@ export type CustomWindow = Window
                          & typeof globalThis
                          & { interceptorManager: IInterceptorManager }
                          & { globalEmitter: EventEmitter }
-                         & { GraphViewer: IGraphViewer };
+                         & { GraphViewer: IGraphViewer }
+                         & { growiRenderer: GrowiRenderer }
+                         & { previewRenderer: GrowiRenderer }; // TODO: Remove this code when reveal.js is omitted. see: https://github.com/weseek/growi/pull/6223

+ 24 - 0
packages/app/src/interfaces/services/renderer.ts

@@ -0,0 +1,24 @@
+import { XssOptionConfig } from '~/services/xss/xssOption';
+
+export type RendererSettings = {
+  isEnabledLinebreaks: boolean,
+  isEnabledLinebreaksInComments: boolean,
+  adminPreferredIndentSize: number,
+  isIndentSizeForced: boolean,
+};
+
+export type GrowiHydratedEnv = {
+  PLANTUML_URI: string | null,
+  BLOCKDIAG_URI: string | null,
+  DRAWIO_URI: string | null,
+  HACKMD_URI: string | null,
+  MATHJAX: string | null,
+  NO_CDN: string | null,
+  GROWI_CLOUD_URI: string | null,
+  GROWI_APP_ID_FOR_GROWI_CLOUD: string | null,
+}
+
+export type GrowiRendererConfig = {
+  highlightJsStyleBorder: boolean
+  env: Pick<GrowiHydratedEnv, 'MATHJAX' | 'PLANTUML_URI' | 'BLOCKDIAG_URI'>
+} & XssOptionConfig;

+ 2 - 2
packages/app/src/server/middlewares/login-form-validator.ts

@@ -47,8 +47,8 @@ export const inviteValidation = (req, res, next) => {
 export const loginRules = () => {
   return [
     body('loginForm.username')
-      .matches(/^[\da-zA-Z\-_.@]+$/)
-      .withMessage('Username has invalid characters')
+      .matches(/^[\da-zA-Z\-_.+@]+$/)
+      .withMessage('Username or E-mail has invalid characters')
       .not()
       .isEmpty()
       .withMessage('Username field is required'),

+ 1 - 1
packages/app/src/server/routes/page.js

@@ -5,6 +5,7 @@ import urljoin from 'url-join';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
+import XssOption from '~/services/xss/xssOption';
 import loggerFactory from '~/utils/logger';
 
 import { PathAlreadyExistsError } from '../models/errors';
@@ -160,7 +161,6 @@ module.exports = function(crowi, app) {
 
   const activityEvent = crowi.event('activity');
 
-  const XssOption = require('~/services/xss/xssOption');
   const Xss = require('~/services/xss/index');
   const initializedConfig = {
     isEnabledXssPrevention: configManager.getConfig('markdown', 'markdown:xss:isEnabledPrevention'),

+ 0 - 0
packages/app/src/client/util/PostProcessor/.keep → packages/app/src/services/renderer/PostProcessor/.keep


+ 0 - 0
packages/app/src/client/util/PreProcessor/CsvToTable.js → packages/app/src/services/renderer/PreProcessor/CsvToTable.js


+ 0 - 0
packages/app/src/client/util/PreProcessor/EasyGrid.js → packages/app/src/services/renderer/PreProcessor/EasyGrid.js


+ 0 - 0
packages/app/src/client/util/PreProcessor/Linker.js → packages/app/src/services/renderer/PreProcessor/Linker.js


+ 29 - 0
packages/app/src/services/renderer/PreProcessor/XssFilter.ts

@@ -0,0 +1,29 @@
+import Xss from '~/services/xss';
+import XssOption, { XssOptionConfig } from '~/services/xss/xssOption';
+
+export default class XssFilter {
+
+  xssOption: XssOption
+
+  xss
+
+  config: XssOptionConfig
+
+  constructor(config: XssOptionConfig) {
+    this.config = config;
+
+    if (config.isEnabledXssPrevention) {
+      this.xssOption = new XssOption(config);
+      this.xss = new Xss(this.xssOption);
+    }
+  }
+
+  process(markdown) {
+    if (this.config.isEnabledXssPrevention) {
+      return this.xss.process(markdown);
+    }
+
+    return markdown;
+  }
+
+}

+ 266 - 0
packages/app/src/services/renderer/growi-renderer.ts

@@ -0,0 +1,266 @@
+import MarkdownIt from 'markdown-it';
+
+import { Nullable } from '~/interfaces/common'; // TODO: Remove this asap when the ContextExtractor is removed
+import { CustomWindow } from '~/interfaces/global';
+import { GrowiRendererConfig, RendererSettings } from '~/interfaces/services/renderer';
+import loggerFactory from '~/utils/logger';
+
+import CsvToTable from './PreProcessor/CsvToTable';
+import EasyGrid from './PreProcessor/EasyGrid';
+import Linker from './PreProcessor/Linker';
+import XssFilter from './PreProcessor/XssFilter';
+import BlockdiagConfigurer from './markdown-it/blockdiag';
+import DrawioViewerConfigurer from './markdown-it/drawio-viewer';
+import EmojiConfigurer from './markdown-it/emoji';
+import FooternoteConfigurer from './markdown-it/footernote';
+import HeaderConfigurer from './markdown-it/header';
+import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
+import HeaderWithEditLinkConfigurer from './markdown-it/header-with-edit-link';
+import LinkerByRelativePathConfigurer from './markdown-it/link-by-relative-path';
+import MathJaxConfigurer from './markdown-it/mathjax';
+import PlantUMLConfigurer from './markdown-it/plantuml';
+import TableConfigurer from './markdown-it/table';
+import TableWithHandsontableButtonConfigurer from './markdown-it/table-with-handsontable-button';
+import TaskListsConfigurer from './markdown-it/task-lists';
+import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
+
+
+const logger = loggerFactory('growi:util:GrowiRenderer');
+
+declare const hljs;
+
+type MarkdownSettings = {
+  breaks?: boolean,
+};
+
+export default class GrowiRenderer {
+
+  preProcessors: any[]
+
+  postProcessors: any[]
+
+  md: any
+
+  isMarkdownItConfigured: boolean
+
+  markdownItConfigurers: any[]
+
+  growiRendererConfig: GrowiRendererConfig
+
+  pagePath?: Nullable<string>
+
+  /**
+   *
+   * @param {string} mode
+   */
+  constructor(growiRendererConfig: GrowiRendererConfig, pagePath?: Nullable<string>) {
+    this.growiRendererConfig = growiRendererConfig;
+    this.pagePath = pagePath;
+
+    if ((window as CustomWindow).growiRenderer != null) {
+      this.preProcessors = (window as CustomWindow).growiRenderer.preProcessors;
+      this.postProcessors = (window as CustomWindow).growiRenderer.postProcessors;
+    }
+    else {
+      this.preProcessors = [
+        new EasyGrid(),
+        new Linker(),
+        new CsvToTable(),
+        new XssFilter({
+          isEnabledXssPrevention: this.growiRendererConfig.isEnabledXssPrevention,
+          tagWhiteList: this.growiRendererConfig.tagWhiteList,
+          attrWhiteList: this.growiRendererConfig.attrWhiteList,
+        }),
+      ];
+      this.postProcessors = [
+      ];
+    }
+
+    this.init = this.init.bind(this);
+    this.addConfigurers = this.addConfigurers.bind(this);
+    this.setMarkdownSettings = this.setMarkdownSettings.bind(this);
+    this.configure = this.configure.bind(this);
+    this.process = this.process.bind(this);
+    this.codeRenderer = this.codeRenderer.bind(this);
+  }
+
+  init() {
+    // init markdown-it
+    this.md = new MarkdownIt({
+      html: true,
+      linkify: true,
+      highlight: this.codeRenderer,
+    });
+
+    this.isMarkdownItConfigured = false;
+
+    this.markdownItConfigurers = [
+      new TaskListsConfigurer(),
+      new HeaderConfigurer(),
+      new EmojiConfigurer(),
+      new MathJaxConfigurer(this.growiRendererConfig),
+      new DrawioViewerConfigurer(),
+      new PlantUMLConfigurer(this.growiRendererConfig),
+      new BlockdiagConfigurer(this.growiRendererConfig),
+    ];
+
+    if (this.pagePath != null) {
+      this.markdownItConfigurers.push(
+        new LinkerByRelativePathConfigurer(this.pagePath),
+      );
+    }
+  }
+
+  addConfigurers(configurers: any[]): void {
+    this.markdownItConfigurers.push(...configurers);
+  }
+
+  setMarkdownSettings(settings: MarkdownSettings): void {
+    this.md.set(settings);
+  }
+
+  configure(): void {
+    if (!this.isMarkdownItConfigured) {
+      this.markdownItConfigurers.forEach((configurer) => {
+        configurer.configure(this.md);
+      });
+    }
+  }
+
+  preProcess(markdown, context) {
+    let processed = markdown;
+    for (let i = 0; i < this.preProcessors.length; i++) {
+      if (!this.preProcessors[i].process) {
+        continue;
+      }
+      processed = this.preProcessors[i].process(processed, context);
+    }
+
+    return processed;
+  }
+
+  process(markdown, context) {
+    return this.md.render(markdown, context);
+  }
+
+  postProcess(html, context) {
+    let processed = html;
+    for (let i = 0; i < this.postProcessors.length; i++) {
+      if (!this.postProcessors[i].process) {
+        continue;
+      }
+      processed = this.postProcessors[i].process(processed, context);
+    }
+
+    return processed;
+  }
+
+  codeRenderer(code, langExt) {
+    const noborder = (!this.growiRendererConfig.highlightJsStyleBorder) ? 'hljs-no-border' : '';
+
+    let citeTag = '';
+    let hljsLang = 'plaintext';
+    let showLinenumbers = false;
+
+    if (langExt) {
+      // https://regex101.com/r/qGs7eZ/3
+      const match = langExt.match(/^([^:=\n]+)?(=([^:=\n]*))?(:([^:=\n]*))?(=([^:=\n]*))?$/);
+
+      const lang = match[1];
+      const fileName = match[5] || null;
+      showLinenumbers = (match[2] != null) || (match[6] != null);
+
+      if (fileName != null) {
+        citeTag = `<cite>${fileName}</cite>`;
+      }
+      if (hljs.getLanguage(lang)) {
+        hljsLang = lang;
+      }
+    }
+
+    let highlightCode = code;
+    try {
+      highlightCode = hljs.highlight(hljsLang, code, true).value;
+
+      // add line numbers
+      if (showLinenumbers) {
+        highlightCode = hljs.lineNumbersValue((highlightCode));
+      }
+    }
+    catch (err) {
+      logger.error(err);
+    }
+
+    return `<pre class="hljs ${noborder}">${citeTag}<code>${highlightCode}</code></pre>`;
+  }
+
+}
+
+export interface RendererGenerator {
+  (growiRendererConfig: GrowiRendererConfig, rendererSettings: RendererSettings | null, pagePath?: Nullable<string>): GrowiRenderer
+}
+
+export const generateViewRenderer: RendererGenerator = (
+    growiRendererConfig: GrowiRendererConfig, rendererSettings: RendererSettings, pagePath?: Nullable<string>,
+): GrowiRenderer => {
+  const renderer = new GrowiRenderer(growiRendererConfig, pagePath);
+  renderer.init();
+
+  // Add configurers for viewer
+  renderer.addConfigurers([
+    new FooternoteConfigurer(),
+    new TocAndAnchorConfigurer(),
+    new HeaderLineNumberConfigurer(),
+    new HeaderWithEditLinkConfigurer(),
+    new TableWithHandsontableButtonConfigurer(),
+  ]);
+
+  renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
+  renderer.configure();
+
+  return renderer;
+};
+
+export const generatePreviewRenderer: RendererGenerator = (
+    growiRendererConfig: GrowiRendererConfig, rendererSettings: RendererSettings | null, pagePath?: Nullable<string>,
+): GrowiRenderer => {
+  const renderer = new GrowiRenderer(growiRendererConfig, pagePath);
+  renderer.init();
+
+  // Add configurers for preview
+  renderer.addConfigurers([
+    new FooternoteConfigurer(),
+    new HeaderLineNumberConfigurer(),
+    new TableConfigurer(),
+  ]);
+
+  renderer.configure();
+
+  return renderer;
+};
+
+const generateRendererWithTableConfigurer: RendererGenerator = (
+    growiRendererConfig: GrowiRendererConfig, rendererSettings: RendererSettings | null, pagePath?: Nullable<string>,
+): GrowiRenderer => {
+  const renderer = new GrowiRenderer(growiRendererConfig, pagePath);
+  renderer.init();
+
+  renderer.addConfigurers([
+    new TableConfigurer(),
+  ]);
+
+  renderer.configure();
+
+  return renderer;
+};
+
+export const generateCommentPreviewRenderer: RendererGenerator = (
+    growiRendererConfig: GrowiRendererConfig, rendererSettings: RendererSettings, pagePath?: Nullable<string>,
+): GrowiRenderer => {
+  const renderer = generateRendererWithTableConfigurer(growiRendererConfig, null, pagePath);
+
+  renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
+  renderer.configure();
+
+  return renderer;
+};

+ 0 - 0
packages/app/src/client/util/interceptor/detach-code-blocks.js → packages/app/src/services/renderer/interceptor/detach-code-blocks.js


+ 0 - 0
packages/app/src/client/util/interceptor/drawio-interceptor.js → packages/app/src/services/renderer/interceptor/drawio-interceptor.js


+ 2 - 5
packages/app/src/client/util/markdown-it/blockdiag.js → packages/app/src/services/renderer/markdown-it/blockdiag.js

@@ -1,10 +1,7 @@
 export default class BlockdiagConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-    const config = crowi.getConfig();
-
-    this.generateSourceUrl = config.env.BLOCKDIAG_URI || 'https://blockdiag-api.com/';
+  constructor(growiConfig) {
+    this.generateSourceUrl = growiConfig.env.BLOCKDIAG_URI || 'https://blockdiag-api.com/';
   }
 
   configure(md) {

+ 0 - 0
packages/app/src/client/util/markdown-it/drawio-viewer.js → packages/app/src/services/renderer/markdown-it/drawio-viewer.js


+ 0 - 0
packages/app/src/client/util/markdown-it/emoji-mart-data.ts → packages/app/src/services/renderer/markdown-it/emoji-mart-data.ts


+ 0 - 0
packages/app/src/client/util/markdown-it/emoji.js → packages/app/src/services/renderer/markdown-it/emoji.js


+ 0 - 0
packages/app/src/client/util/markdown-it/footernote.js → packages/app/src/services/renderer/markdown-it/footernote.js


+ 0 - 0
packages/app/src/client/util/markdown-it/header-line-number.js → packages/app/src/services/renderer/markdown-it/header-line-number.js


+ 0 - 0
packages/app/src/client/util/markdown-it/header-with-edit-link.js → packages/app/src/services/renderer/markdown-it/header-with-edit-link.js


+ 0 - 0
packages/app/src/client/util/markdown-it/header.js → packages/app/src/services/renderer/markdown-it/header.js


+ 5 - 9
packages/app/src/client/util/markdown-it/link-by-relative-path.ts → packages/app/src/services/renderer/markdown-it/link-by-relative-path.ts

@@ -5,18 +5,14 @@ const PATTERN_RELATIVE_PATH = new RegExp(/^(\.{1,2})(\/.*)?$/);
 
 export default class LinkerByRelativePathConfigurer {
 
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  appContainer: any;
+  pagePath: string
 
-  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  constructor(appContainer) {
-    this.appContainer = appContainer;
+  constructor(pagePath: string) {
+    this.pagePath = pagePath;
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-  configure(md): void {
-    const pageContainer = this.appContainer.getContainer('PageContainer');
-
+  configure(md, pagePath: string): void {
     // Remember old renderer, if overridden, or proxy to default renderer
     const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
       return self.renderToken(tokens, idx, options);
@@ -32,7 +28,7 @@ export default class LinkerByRelativePathConfigurer {
 
       if (hrefIndex != null && hrefIndex >= 0) {
         const href: string = tokens[idx].attrs[hrefIndex][1];
-        const currentPath: string | null = pageContainer?.state.path;
+        const currentPath: string | null = pagePath;
 
         // resolve relative path and replace
         if (PATTERN_RELATIVE_PATH.test(href) && currentPath != null) {

+ 2 - 5
packages/app/src/client/util/markdown-it/mathjax.js → packages/app/src/services/renderer/markdown-it/mathjax.js

@@ -1,10 +1,7 @@
 export default class MathJaxConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-
-    const config = crowi.getConfig();
-    this.isEnabled = !!config.env.MATHJAX; // convert to boolean
+  constructor(growiConfig) {
+    this.isEnabled = !!growiConfig.env.MATHJAX; // convert to boolean
   }
 
   configure(md) {

+ 2 - 5
packages/app/src/client/util/markdown-it/plantuml.js → packages/app/src/services/renderer/markdown-it/plantuml.js

@@ -3,12 +3,9 @@ import urljoin from 'url-join';
 
 export default class PlantUMLConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-    const config = crowi.getConfig();
-
+  constructor(growiConfig) {
     // Do NOT use HTTPS URL because plantuml.com refuse request except from members
-    this.serverUrl = config.env.PLANTUML_URI || 'http://plantuml.com/plantuml';
+    this.serverUrl = growiConfig.env.PLANTUML_URI || 'http://plantuml.com/plantuml';
 
     this.generateSource = this.generateSource.bind(this);
   }

+ 0 - 0
packages/app/src/client/util/markdown-it/table-with-handsontable-button.js → packages/app/src/services/renderer/markdown-it/table-with-handsontable-button.js


+ 0 - 0
packages/app/src/client/util/markdown-it/table.js → packages/app/src/services/renderer/markdown-it/table.js


+ 0 - 4
packages/app/src/client/util/markdown-it/task-lists.js → packages/app/src/services/renderer/markdown-it/task-lists.js

@@ -1,9 +1,5 @@
 export default class TaskListsConfigurer {
 
-  constructor(crowi) {
-    this.crowi = crowi;
-  }
-
   configure(md) {
     md.use(require('markdown-it-task-checkbox'), {
       disabled: true,

+ 0 - 0
packages/app/src/client/util/markdown-it/toc-and-anchor.js → packages/app/src/services/renderer/markdown-it/toc-and-anchor.js


+ 0 - 13
packages/app/src/services/xss/xssOption.js

@@ -1,13 +0,0 @@
-class XssOption {
-
-  constructor(config) {
-    const recommendedWhitelist = require('~/services/xss/recommended-whitelist');
-    const initializedConfig = (config != null) ? config : {};
-
-    this.isEnabledXssPrevention = initializedConfig.isEnabledXssPrevention || true;
-    this.tagWhiteList = initializedConfig.tagWhiteList || recommendedWhitelist.tags;
-    this.attrWhiteList = initializedConfig.attrWhiteList || recommendedWhitelist.attrs;
-  }
-
-}
-module.exports = XssOption;

+ 24 - 0
packages/app/src/services/xss/xssOption.ts

@@ -0,0 +1,24 @@
+export type XssOptionConfig = {
+  isEnabledXssPrevention: boolean,
+  tagWhiteList: any[],
+  attrWhiteList: any[],
+}
+
+export default class XssOption {
+
+  isEnabledXssPrevention: boolean
+
+  tagWhiteList: any[]
+
+  attrWhiteList: any[]
+
+  constructor(config: XssOptionConfig) {
+    const recommendedWhitelist = require('~/services/xss/recommended-whitelist');
+    const initializedConfig: Partial<XssOptionConfig> = (config != null) ? config : {};
+
+    this.isEnabledXssPrevention = initializedConfig.isEnabledXssPrevention || true;
+    this.tagWhiteList = initializedConfig.tagWhiteList || recommendedWhitelist.tags;
+    this.attrWhiteList = initializedConfig.attrWhiteList || recommendedWhitelist.attrs;
+  }
+
+}

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

@@ -4,6 +4,7 @@ import useSWRImmutable from 'swr/immutable';
 
 
 import { SupportedActionType } from '~/interfaces/activity';
+import { GrowiRendererConfig } from '~/interfaces/services/renderer';
 
 import { TargetAndAncestors } from '../interfaces/page-listing-results';
 import { IUser } from '../interfaces/user';
@@ -198,6 +199,10 @@ export const useIsLatestRevision = (initialData?: boolean): SWRResponse<boolean,
   return useStaticSWR('isLatestRevision', initialData);
 };
 
+export const useGrowiRendererConfig = (initialData?: GrowiRendererConfig): SWRResponse<GrowiRendererConfig, any> => {
+  return useStaticSWR('growiRendererConfig', initialData);
+};
+
 
 /** **********************************************************
  *                     Computed contexts

+ 76 - 0
packages/app/src/stores/renderer.tsx

@@ -0,0 +1,76 @@
+import { Key, SWRResponse } from 'swr';
+
+import { RendererSettings } from '~/interfaces/services/renderer';
+import GrowiRenderer, {
+  generateCommentPreviewRenderer, generatePreviewRenderer, generateViewRenderer, RendererGenerator,
+} from '~/services/renderer/growi-renderer';
+import { useStaticSWR } from '~/stores/use-static-swr';
+
+import { useCurrentPagePath, useGrowiRendererConfig } from './context';
+
+export const useRendererSettings = (initialData?: RendererSettings): SWRResponse<RendererSettings, any> => {
+  return useStaticSWR('rendererSettings', initialData);
+};
+
+// The base hook with common processes
+const _useRendererBase = (key: Key, generator: RendererGenerator): SWRResponse<GrowiRenderer, any> => {
+  let _key = key;
+
+  const { data: renderer, mutate: mutateRenderer } = useStaticSWR(key);
+  const { data: rendererSettings } = useRendererSettings();
+  const { data: currentPath } = useCurrentPagePath();
+  const { data: growiRendererConfig } = useGrowiRendererConfig();
+
+  if (rendererSettings == null || growiRendererConfig == null) {
+    _key = null;
+  }
+  // Initialize renderer
+  else if (renderer == null) {
+    const generated = generator(growiRendererConfig, rendererSettings, currentPath);
+    mutateRenderer(generated);
+  }
+
+  return useStaticSWR(_key);
+};
+
+export const useViewRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'viewRenderer';
+
+  return _useRendererBase(key, generateViewRenderer);
+};
+
+export const usePreviewRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'previewRenderer';
+
+  return _useRendererBase(key, generatePreviewRenderer);
+};
+
+export const useCommentPreviewRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'commentPreviewRenderer';
+
+  return _useRendererBase(key, generateCommentPreviewRenderer);
+};
+
+export const useSearchResultRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'searchResultRenderer';
+
+  return _useRendererBase(key, generateViewRenderer);
+};
+
+export const useTimelineRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'timelineRenderer';
+
+  return _useRendererBase(key, generateViewRenderer);
+};
+
+export const useDraftRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'draftRenderer';
+
+  return _useRendererBase(key, generateViewRenderer);
+};
+
+export const useCustomSidebarRenderer = (): SWRResponse<GrowiRenderer, any> => {
+  const key: Key = 'customSidebarRenderer';
+
+  return _useRendererBase(key, generateViewRenderer);
+};

+ 118 - 1
packages/app/test/cypress/integration/20-basic-features/use-tools.spec.ts

@@ -71,7 +71,7 @@ context('Modal for page operation', () => {
 
     cy.get('#grw-subnav-container').within(() => {
       cy.getByTestid('open-page-item-control-btn').click();
-      cy.getByTestid('open-page-move-rename-modal-btn').click();
+      cy.getByTestid('open-page-move-rename-modal-btn').click({force: true});
     });
 
     cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap4`);
@@ -141,3 +141,120 @@ context('Page Accessories Modal', () => {
   });
 
 });
+
+context('Tag Oprations', () =>{
+
+  beforeEach(() => {
+    // login
+    cy.fixture("user-admin.json").then(user => {
+      cy.login(user.username, user.password);
+    });
+  });
+
+  it('Successfully add new tag', () => {
+    const ssPrefix = 'tag-operations-add-new-tag-'
+    const tag = 'we';
+    cy.visit('/');
+
+    cy.get('#edit-tags-btn-wrapper-for-tooltip > a').click({force: true});
+    cy.get('#edit-tag-modal').should('be.visible').screenshot(`${ssPrefix}1-edit-tag-input`);
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('.rbt-input-main').type(tag, {force: true});
+      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
+      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
+      cy.screenshot(`${ssPrefix}2-type-tag-name`);
+    });
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
+      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
+      cy.get('a#tag-typeahead-asynctypeahead-item-0').click({force: true})
+      cy.screenshot(`${ssPrefix}3-insert-tag-name`, {capture: 'viewport'});
+    });
+
+    cy.get('#edit-tag-modal').within(() => {
+      cy.get('div.modal-footer > button').click();
+    });
+
+    cy.get('.grw-taglabels-container > form > a').contains(tag).should('exist');
+
+    cy.screenshot(`${ssPrefix}4-click-done`, {capture: 'viewport'});
+
+  });
+
+  it('Successfully duplicate page by generated tag', () => {
+    const ssPrefix = 'tag-operations-page-duplicate-';
+    const tag = 'we';
+    const newPageName = 'our';
+    cy.visit('/');
+    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+
+    cy.getByTestid('open-page-item-control-btn').first().click({force: true});
+    cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
+
+    cy.getByTestid('open-page-duplicate-modal-btn').first().click({force: true});
+    cy.getByTestid('page-duplicate-modal').should('be.visible');
+    cy.getByTestid('page-duplicate-modal').within(() => {
+      cy.get('.rbt-input-main').type(newPageName, {force: true});
+    }).screenshot(`${ssPrefix}3-duplicate-page`, {capture: 'viewport'});
+
+    cy.getByTestid('page-duplicate-modal').within(() => {
+      cy.get('.modal-footer > button.btn').click();
+    });
+    cy.visit(`/${newPageName}`);
+    cy.get('#wiki').should('not.be.empty');
+    cy.screenshot(`${ssPrefix}4-duplicated-page`, {capture: 'viewport'});
+  });
+
+  it('Successfully rename page from generated tag', () => {
+    const ssPrefix = 'tag-operations-page-rename-';
+    const tag = 'we';
+    const oldPageName = '/our';
+    const newPageName = '/ourus';
+
+    cy.visit('/');
+    cy.get('.grw-taglabels-container > form > a').contains(tag).click();
+    cy.getByTestid('search-result-base').should('be.visible');
+    cy.getByTestid('search-result-list').should('be.visible');
+    cy.getByTestid('search-result-content').should('be.visible');
+    cy.screenshot(`${ssPrefix}1-click-tag-name`, {capture: 'viewport'});
+
+    cy.getByTestid('search-result-list').within(() => {
+      cy.get('.list-group-item').each(($row) => {
+        if($row.find('a').text() === oldPageName){
+          cy.wrap($row).within(() => {
+            cy.getByTestid('open-page-item-control-btn').click();
+          });
+        }
+      });
+    });
+    cy.screenshot(`${ssPrefix}2-click-three-dots-menu`, {capture: 'viewport'});
+
+    cy.getByTestid('search-result-list').within(() => {
+      cy.get('.list-group-item').each(($row) => {
+        if($row.find('a').text() === oldPageName){
+          cy.wrap($row).within(() => {
+            cy.getByTestid('open-page-move-rename-modal-btn').click();
+          });
+        }
+      });
+    });
+
+    cy.getByTestid('page-rename-modal').should('be.visible').within(() => {
+      cy.get('.rbt-input-main').clear().type(newPageName,{force: true});
+    }).screenshot(`${ssPrefix}3-insert-new-page-name`);
+
+    cy.getByTestid('page-rename-modal').should('be.visible').within(() => {
+      cy.get('.modal-footer > button').click();
+    });
+
+    cy.visit(`/${newPageName}`);
+    cy.screenshot(`${ssPrefix}4-new-page-name-applied`, {capture: 'viewport'});
+  });
+
+});

+ 2 - 2
packages/plugin-pukiwiki-like-linker/src/client-entry.js

@@ -1,6 +1,6 @@
 import PukiwikiLikeLinker from './resource/js/util/PreProcessor/PukiwikiLikeLinker';
 
-export default (appContainer) => {
+export default () => {
   // add preprocessor to head of array
-  appContainer.originRenderer.preProcessors.unshift(new PukiwikiLikeLinker());
+  window.growiRenderer.preProcessors.unshift(new PukiwikiLikeLinker());
 };