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

Merge pull request #257 from weseek/imprv/replace-renderer

Imprv/replace renderer
Yuki Takei 8 лет назад
Родитель
Сommit
e770b83ba2
49 измененных файлов с 1187 добавлено и 620 удалено
  1. 12 4
      CHANGES.md
  2. 1 1
      config/env.dev.js
  3. 2 3
      config/webpack.common.js
  4. 17 13
      lib/crowi/index.js
  5. 3 1
      lib/views/crowi-plus/base/user_page_nosidebar.html
  6. 3 1
      lib/views/crowi-plus/page.html
  7. 3 1
      lib/views/crowi-plus/page_list.html
  8. 1 1
      lib/views/crowi-plus/widget/page_list_container.html
  9. 23 0
      lib/views/layout/layout.html
  10. 2 2
      lib/views/page.html
  11. 2 4
      lib/views/page_list.html
  12. 6 2
      lib/views/page_presentation.html
  13. 10 4
      package.json
  14. 1 1
      resource/css/_form.scss
  15. 22 69
      resource/css/_wiki.scss
  16. 1 1
      resource/css/crowi-reveal.scss
  17. 49 21
      resource/js/app.js
  18. 131 0
      resource/js/components/Page.js
  19. 0 70
      resource/js/components/Page/PageBody.js
  20. 62 0
      resource/js/components/Page/RevisionBody.js
  21. 39 18
      resource/js/components/PageEditor.js
  22. 0 99
      resource/js/components/PageEditor/EditorOptionsSelector.js
  23. 161 0
      resource/js/components/PageEditor/OptionsSelector.js
  24. 21 7
      resource/js/components/PageEditor/Preview.js
  25. 2 1
      resource/js/components/SearchPage.js
  26. 2 1
      resource/js/components/SearchPage/SearchResult.js
  27. 14 18
      resource/js/components/SearchPage/SearchResultList.js
  28. 2 2
      resource/js/legacy/crowi-form.js
  29. 0 1
      resource/js/legacy/crowi-presentation.js
  30. 58 108
      resource/js/legacy/crowi.js
  31. 16 6
      resource/js/util/Crowi.js
  32. 12 3
      resource/js/util/CrowiRenderer.js
  33. 166 0
      resource/js/util/GrowiRenderer.js
  34. 0 43
      resource/js/util/LangProcessor/PlantUML.js
  35. 12 4
      resource/js/util/LangProcessor/Template.js
  36. 0 85
      resource/js/util/LangProcessor/Tsv2Table.js
  37. 5 0
      resource/js/util/PostProcessor/Emoji.js
  38. 5 0
      resource/js/util/PostProcessor/Mathjax.js
  39. 26 0
      resource/js/util/PreProcessor/CsvToTable.js
  40. 15 0
      resource/js/util/markdown-it/common-plugins.js
  41. 17 0
      resource/js/util/markdown-it/emoji.js
  42. 44 0
      resource/js/util/markdown-it/header-line-number.js
  43. 38 0
      resource/js/util/markdown-it/header.js
  44. 16 0
      resource/js/util/markdown-it/mathjax.js
  45. 26 0
      resource/js/util/markdown-it/plantuml.js
  46. 13 0
      resource/js/util/markdown-it/table.js
  47. 42 0
      resource/js/util/markdown-it/toc-and-anchor.js
  48. 0 1
      resource/styles/index.js
  49. 84 24
      yarn.lock

+ 12 - 4
CHANGES.md

@@ -3,6 +3,14 @@ CHANGES
 
 ## 2.3.9-RC
 
+* Feature: Support Footnotes
+* Feature: Support Task lists
+* Feature: Support Table with CSV
+* Feature: Enable to render UML diagrams with public plantuml.com server
+* Feature: Enable to switch whether rendering MathJax in realtime or not
+* Improvement: Replace markdown parser with markdown-it
+* Improvement: Generate anchor of headers with header strings
+* Improvement: Update `#revision-body` tab contents after saving `Ctrl-/`
 * Fix: `Ctrl-/` doesn't work on Chrome
 * Fix: Close Shortcuts help with `Ctrl-/`, ESC key
 * Fix: Jump to last line wrongly when `.revision-head-edit-button` clicked
@@ -264,8 +272,8 @@ CHANGES
 
 ## 1.1.8
 
-* Fix: Depth of dropdown-menu when '.on-edit'
-* Fix: Error occured on saveing with Ctrl+S
+* Fix: Depth of dropdown-menu when `.on-edit`
+* Fix: Error occured on saveing with `Ctrl-S`
 * Fix: Guest users browsing
 
 ## 1.1.7
@@ -294,11 +302,11 @@ CHANGES
 ## 1.1.2
 
 * Imprv: Brushup fonts and styles
-* Fix: Ensure to specity revision id when saving with Ctrl+S
+* Fix: Ensure to specity revision id when saving with `Ctrl-S`
 
 ## 1.1.1
 
-* Feature: Save with Ctrl+S
+* Feature: Save with `Ctrl-S`
 * Imprv: Brushup fonts and styles
 
 ## 1.1.0

+ 1 - 1
config/env.dev.js

@@ -12,7 +12,7 @@ module.exports = {
   DEBUG: [
     // 'express:*',
     // 'crowi:*',
-    // 'crowi:crowi',
+    'crowi:crowi',
     'crowi:crowi:dev',
     'crowi:crowi:express-init',
     'crowi:models:external-account',

+ 2 - 3
config/webpack.common.js

@@ -34,6 +34,7 @@ module.exports = function (options) {
       //  on the global var jQuery
       "jquery": "jQuery",
       "emojione": "emojione",
+      "hljs": "hljs",
     },
     resolve: {
       extensions: ['.js', '.json'],
@@ -97,11 +98,9 @@ module.exports = function (options) {
         chunks: ['commons', 'plugin'],
       }),
 
-      new webpack.ProvidePlugin({
+      new webpack.ProvidePlugin({ // refs externals
         jQuery: "jquery",
         $: "jquery",
-        hljs: "reveal.js/plugin/highlight/highlight",
-        emojione: "emojione",
       }),
 
     ]

+ 17 - 13
lib/crowi/index.js

@@ -71,19 +71,7 @@ Crowi.prototype.init = function() {
   }).then(function() {
     return self.setupSessionConfig();
   }).then(function() {
-    return new Promise(function(resolve, reject) {
-      self.model('Config', require('../models/config')(self));
-      var Config = self.model('Config');
-      Config.loadAllConfig(function(err, doc) {
-        if (err) {
-          return reject();
-        }
-
-        self.setConfig(doc);
-
-        return resolve();
-      });
-    });
+    return self.setupAppConfig();
   }).then(function() {
     return self.scanRuntimeVersions();
   }).then(function() {
@@ -204,6 +192,22 @@ Crowi.prototype.setupSessionConfig = function() {
   });
 };
 
+Crowi.prototype.setupAppConfig = function() {
+  return new Promise((resolve, reject) => {
+    this.model('Config', require('../models/config')(this));
+    var Config = this.model('Config');
+    Config.loadAllConfig((err, doc) => {
+      if (err) {
+        return reject();
+      }
+
+      this.setConfig(doc);
+
+      return resolve();
+    });
+  });
+}
+
 Crowi.prototype.setupModels = function() {
   var self = this
     ;

+ 3 - 1
lib/views/crowi-plus/base/user_page_nosidebar.html

@@ -46,7 +46,9 @@
 
         {# relocate #revision-toc #}
         <div class="col-lg-2 col-md-3 visible-lg visible-md">
-          <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="50"></div>
+          <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="50">
+            <div id="revision-toc-content" class="revision-toc-content"></div>
+          </div>
         </div> {# /.col- #}
 
       </div>

+ 3 - 1
lib/views/crowi-plus/page.html

@@ -34,7 +34,9 @@
 
       {# relocate #revision-toc #}
       <div class="col-lg-2 col-md-3 visible-lg visible-md">
-        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100"></div>
+        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div> {# /.col- #}
 
     </div>

+ 3 - 1
lib/views/crowi-plus/page_list.html

@@ -38,7 +38,9 @@
 
       {# relocate #revision-toc #}
       <div class="col-lg-2 col-md-3 visible-lg visible-md">
-        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100"></div>
+        <div id="revision-toc" class="revision-toc" data-spy="affix" data-offset-top="100">
+          <div id="revision-toc-content" class="revision-toc-content"></div>
+        </div>
       </div> {# /.col- #}
 
     </div>

+ 1 - 1
lib/views/crowi-plus/widget/page_list_container.html

@@ -25,7 +25,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
         <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
         <div class="revision-body wiki"></div>
         <script type="text/template">{{ page.revision.body }}</script>

+ 23 - 0
lib/views/layout/layout.html

@@ -35,6 +35,27 @@
 
   <!-- jQuery, emojione -->
   <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+  <!-- highlight.js -->
+  <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
+  {% if local_config.env.MATHJAX %}
+    <!-- Mathjax -->
+    <script type="text/x-mathjax-config" async>
+      MathJax.Hub.Config({
+        skipStartupTypeset: true,
+        TeX: { equationNumbers: { autoNumber: "AMS" }},
+        extensions: ["tex2jax.js"],
+        jax: ["input/TeX", "output/SVG"],
+        tex2jax: {
+          processEscapes: true
+        },
+        showMathMenu: false,
+        showMathMenuMSIE: false,
+        showProcessingMessages: false,
+        messageStyle: "none"
+      });
+    </script>
+    <script src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.2/MathJax.js" async></script>
+  {% endif %}
 
   {% if env === 'development' %}
     <script src="/dll/vendor.dll.js"></script>
@@ -57,6 +78,8 @@
   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/font-awesome@4.7.0/css/font-awesome.min.css">
   <!-- emojione -->
   <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/emojione@3.1.2/extras/css/emojione.min.css">
+  <!-- highlight.js -->
+  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css">
 
   {% block html_additional_headers %}{% endblock %}
 

+ 2 - 2
lib/views/page.html

@@ -189,9 +189,9 @@
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
       <div class="revision-toc" id="revision-toc">
         <a data-toggle="collapse" data-parent="#revision-toc" href="#revision-toc-content" class="revision-toc-head">{{ t('Table of Contents') }}</a>
-
+        <div id="revision-toc-content" class="revision-toc-content collapse in"></div>
       </div>
-      <div class="wiki" id="revision-body-content"></div>
+      <div id="page"></div>
     </div>
 
     {# edit form #}

+ 2 - 4
lib/views/page_list.html

@@ -128,9 +128,7 @@
   </div>
   {% endif %}
     <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">
-      <div class="wiki" id="revision-body-content">
-        <i class="fa fa-spinner fa-pulse fa-fw"></i>
-      </div>
+      <div id="page"></div>
     </div>
 
     <script type="text/template" id="raw-text-original">{{ page.revision.body }}</script>
@@ -214,7 +212,7 @@
     {% if isEnabledTimeline() %}
     <div class="tab-pane" id="view-timeline" data-shown=0>
       {% for page in pages %}
-      <div class="timeline-body" id="id-{{ page.id }}">
+      <div class="timeline-body" id="id-{{ page.id }}" data-page-path="{{ page.path }}">
         <h3 class="revision-path"><a href="{{ page.path }}">{{ page.path }}</a></h3>
         <div class="revision-body wiki"></div>
         <script type="text/template">{{ page.revision.body }}</script>

+ 6 - 2
lib/views/page_presentation.html

@@ -17,8 +17,10 @@
       }
     </script>
 
-    <!-- jQuery -->
-    <script src="//cdn.jsdelivr.net/jquery/3.2.1/jquery.min.js"></script>
+    <!-- jQuery, emojione (expect to hit the cache) -->
+    <script src="https://cdn.jsdelivr.net/combine/npm/emojione@3.1.2,npm/jquery@3.3.1"></script>
+    <!-- highlight.js -->
+    <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@9.12.0/build/highlight.min.js"></script>
 
     {% if env === 'development' %}
       <script src="/dll/vendor.dll.js"></script>
@@ -34,6 +36,8 @@
 
     <!-- Google Fonts -->
     <link href='https://fonts.googleapis.com/css?family=Lato:400,700' rel='stylesheet' type='text/css'>
+    <!-- highlight.js -->
+    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@9.12.0/styles/github.css">
 
     <style>
       {{ customCss() }}

+ 10 - 4
package.json

@@ -68,6 +68,7 @@
     "crowi-pluginkit": "^1.1.0",
     "csrf": "~3.0.3",
     "css-loader": "^0.28.0",
+    "csv-to-markdown-table": "^0.4.0",
     "date-fns": "^1.29.0",
     "debug": "^3.1.0",
     "diff": "^3.3.0",
@@ -82,17 +83,22 @@
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
     "file-loader": "^1.1.0",
-    "get-line-from-pos": "^1.0.0",
     "googleapis": "^25.0.0",
     "graceful-fs": "^4.1.11",
-    "highlight.js": "^9.10.0",
     "i18next": "^10.0.1",
     "i18next-express-middleware": "^1.0.5",
     "i18next-node-fs-backend": "^1.0.0",
     "i18next-sprintf-postprocessor": "^0.2.2",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
-    "marked": "^0.3.12",
+    "markdown-it": "^8.4.0",
+    "markdown-it-emoji": "^1.4.0",
+    "markdown-it-footnote": "^3.0.1",
+    "markdown-it-mathjax": "^2.0.0",
+    "markdown-it-named-headers": "^0.0.4",
+    "markdown-it-plantuml": "^0.3.1",
+    "markdown-it-task-lists": "^2.1.0",
+    "markdown-it-toc-and-anchor": "^4.1.2",
     "md5": "^2.2.1",
     "method-override": "^2.3.10",
     "mkdirp": "~0.5.1",
@@ -109,7 +115,6 @@
     "passport-ldapauth": "^2.0.0",
     "passport-local": "^1.0.0",
     "pino-clf": "^1.0.2",
-    "plantuml-encoder": "^1.2.4",
     "react": "^16.0.0",
     "react-bootstrap": "^0.32.0",
     "react-bootstrap-typeahead": "^2.0.2",
@@ -128,6 +133,7 @@
     "throttle-debounce": "^1.0.1",
     "toastr": "^2.1.2",
     "uglifycss": "^0.0.27",
+    "url-join": "^3.0.0",
     "webpack": "^3.1.0",
     "webpack-bundle-analyzer": "^2.9.0",
     "webpack-merge": "~4.1.0",

+ 1 - 1
resource/css/_form.scss

@@ -214,7 +214,7 @@
       margin-right: 0.5em;
     }
 
-    .btn.btn-style-active-line {
+    .btn-style-active-line, .btn-render-mathjax-in-realtime {
       &:hover:not(.active), &:focus:not(.active) {
         background-color: inherit;
       }

+ 22 - 69
resource/css/_wiki.scss

@@ -135,14 +135,18 @@ div.body {
 
   img {
     margin: 5px 0;
+/* ensure to disable in crowi-plus system
     box-shadow: 0 0 5px 0px rgba(0,0,0,.2);
     border: solid 1px #ccc;
+*/
     max-width: 100%;
   }
+/* ensure to disable in crowi-plus system
   .noborder img, .img.noborder {
     box-shadow: none;
     border: none;
   }
+*/
 
   img.emojione {
     margin-top: -0.3em !important;
@@ -165,8 +169,21 @@ div.body {
     }
   }
 
-/* ensure to disable in crowi-plus
-  .wiki-code {
+  // borrowed from https://www.npmjs.com/package/github-markdown-css
+  .contains-task-list {
+    .task-list-item {
+      list-style-type: none;
+    }
+    .task-list-item+.task-list-item {
+      margin-top: 3px;
+    }
+    .task-list-item input {
+      margin: 0 0.2em 0.25em -1.6em;
+      vertical-align: middle;
+    }
+  }
+
+  pre.hljs {
     position: relative;
 
     cite {
@@ -176,11 +193,11 @@ div.body {
       padding: 0 4px;
       background: #ccc;
       color: #333;
-      font-size: .8em;
-
+      font-style: normal;
+      font-weight: bold;
+      opacity: 0.6;
     }
   };
-*/
 
   p code {  // only inline code blocks
     font-family: $font-family-monospace-not-strictly;
@@ -208,70 +225,6 @@ div.body {
     border-radius: 3px;
   }
 
-  // {{{ table (copied from bootstrap .table
-  table {
-    width: 100%;
-    margin-bottom: $line-height-computed;
-    // Cells
-    > thead,
-    > tbody,
-    > tfoot {
-      > tr {
-        > th,
-        > td {
-          padding: $table-cell-padding;
-          line-height: $line-height-base;
-          vertical-align: top;
-          border-top: 1px solid $table-border-color;
-        }
-      }
-    }
-    // Bottom align for column headings
-    > thead > tr > th {
-      vertical-align: bottom;
-      border-bottom: 2px solid $table-border-color;
-    }
-    // Remove top border from thead by default
-    > caption + thead,
-    > colgroup + thead,
-    > thead:first-child {
-      > tr:first-child {
-        > th,
-        > td {
-          border-top: 0;
-        }
-      }
-    }
-    // Account for multiple tbody instances
-    > tbody + tbody {
-      border-top: 2px solid $table-border-color;
-    }
-
-    // Nesting
-    table {
-      background-color: $body-bg;
-    }
-
-    // .table-bordered
-    border: 1px solid $table-border-color;
-    > thead,
-    > tbody,
-    > tfoot {
-      > tr {
-        > th,
-        > td {
-          border: 1px solid $table-border-color;
-        }
-      }
-    }
-    > thead > tr {
-      > th,
-      > td {
-        border-bottom-width: 2px;
-      }
-    }
-  }
-  // }}}
 }
 
 

+ 1 - 1
resource/css/crowi-reveal.scss

@@ -126,4 +126,4 @@
     // }}}
 
   }
-}
+}

+ 49 - 21
resource/js/app.js

@@ -2,12 +2,15 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 
 import Crowi from './util/Crowi';
-import CrowiRenderer from './util/CrowiRenderer';
+// import CrowiRenderer from './util/CrowiRenderer';
+import GrowiRenderer from './util/GrowiRenderer';
 
 import HeaderSearchBox  from './components/HeaderSearchBox';
 import SearchPage       from './components/SearchPage';
 import PageEditor       from './components/PageEditor';
-import EditorOptionsSelector from './components/PageEditor/EditorOptionsSelector';
+import OptionsSelector  from './components/PageEditor/OptionsSelector';
+import { EditorOptions, PreviewOptions } from './components/PageEditor/OptionsSelector';
+import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
@@ -36,6 +39,7 @@ let pageRevisionId = null;
 let pageRevisionCreatedAt = null;
 let pagePath;
 let pageContent = '';
+let markdown = '';
 if (mainContent !== null) {
   pageId = mainContent.getAttribute('data-page-id');
   pageRevisionId = mainContent.getAttribute('data-page-revision-id');
@@ -45,6 +49,7 @@ if (mainContent !== null) {
   if (rawText) {
     pageContent = rawText.innerHTML;
   }
+  markdown = entities.decodeHTML(pageContent);
 }
 const isLoggedin = document.querySelector('.main-container.nologin') == null;
 
@@ -59,7 +64,11 @@ if (isLoggedin) {
   crowi.fetchUsers();
 }
 
-const crowiRenderer = new CrowiRenderer(crowi);
+const crowiRenderer = new GrowiRenderer(crowi, null, {
+  mode: 'page',
+  isAutoSetup: false,                                     // manually setup because plugins may configure it
+  renderToc: crowi.getCrowiForJquery().renderTocContent,  // function for rendering Table Of Contents
+});
 window.crowiRenderer = crowiRenderer;
 
 // FIXME
@@ -69,10 +78,8 @@ if (isEnabledPlugins) {
   crowiPlugin.installAll(crowi, crowiRenderer);
 }
 
-// for PageEditor
-const onSaveSuccess = function(page) {
-  crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
-}
+// configure renderer
+crowiRenderer.setup(crowi.config);
 
 /**
  * define components
@@ -81,10 +88,8 @@ const onSaveSuccess = function(page) {
  */
 const componentMappings = {
   'search-top': <HeaderSearchBox crowi={crowi} />,
-  'search-page': <SearchPage crowi={crowi} />,
+  'search-page': <SearchPage crowi={crowi} crowiRenderer={crowiRenderer} />,
   'page-list-search': <PageListSearch crowi={crowi} />,
-  'page-comments-list': <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt= {pageRevisionCreatedAt} crowi={crowi} />,
-  'page-attachment': <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />,
 
   //'revision-history': <PageHistory pageId={pageId} />,
   'seen-user-list': <SeenUserList pageId={pageId} crowi={crowi} />,
@@ -93,13 +98,19 @@ const componentMappings = {
   'page-name-inputter': <NewPageNameInputter crowi={crowi} parentPageName={pagePath} />,
 
 };
-// additional definitions if pagePath exists
+// additional definitions if data exists
+if (pageId) {
+  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} />;
+  componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
+}
 if (pagePath) {
+  componentMappings['page'] = <Page crowi={crowi} crowiRenderer={crowiRenderer} markdown={markdown} pagePath={pagePath} showHeadEditButton={true} />;
   componentMappings['revision-path'] = <RevisionPath pagePath={pagePath} crowi={crowi} />;
   componentMappings['revision-url'] = <RevisionUrl pageId={pageId} pagePath={pagePath} />;
 }
 
 let componentInstances = {};
+
 Object.keys(componentMappings).forEach((key) => {
   const elem = document.getElementById(key);
   if (elem) {
@@ -117,28 +128,45 @@ if (elem) {
  * PageEditor
  */
 let pageEditor = null;
+const editorOptions = new EditorOptions(crowi.editorOptions);
+const previewOptions = new PreviewOptions(crowi.previewOptions);
 // render PageEditor
 const pageEditorElem = document.getElementById('page-editor');
 if (pageEditorElem) {
+  // create onSave event handler
+  const onSaveSuccess = function(page) {
+    // modify the revision id value to pass checking id when updating
+    crowi.getCrowiForJquery().updateCurrentRevision(page.revision._id);
+    // re-render Page component
+    componentInstances.page.setMarkdown(page.revision.body);
+  }
+
   pageEditor = ReactDOM.render(
-    <PageEditor crowi={crowi} pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
-        markdown={entities.decodeHTML(pageContent)} editorOptions={crowi.editorOptions}
+    <PageEditor crowi={crowi} crowiRenderer={crowiRenderer}
+        pageId={pageId} revisionId={pageRevisionId} pagePath={pagePath}
+        markdown={markdown}
+        editorOptions={editorOptions} previewOptions={previewOptions}
         onSaveSuccess={onSaveSuccess} />,
     pageEditorElem
   );
   // set refs for pageEditor
   crowi.setPageEditor(pageEditor);
 }
-// render EditorOptionsSelector
-const editorOptionSelectorElem = document.getElementById('page-editor-options-selector');
-if (editorOptionSelectorElem) {
+// render OptionsSelector
+const pageEditorOptionsSelectorElem = document.getElementById('page-editor-options-selector');
+if (pageEditorOptionsSelectorElem) {
   ReactDOM.render(
-    <EditorOptionsSelector options={crowi.editorOptions}
-        onChange={(opts) => { // set onChange event handler
-          pageEditor.setEditorOptions(opts);
-          crowi.saveEditorOptions(opts);
+    <OptionsSelector crowi={crowi}
+        editorOptions={editorOptions} previewOptions={previewOptions}
+        onChange={(newEditorOptions, newPreviewOptions) => { // set onChange event handler
+          // set options
+          pageEditor.setEditorOptions(newEditorOptions);
+          pageEditor.setPreviewOptions(newPreviewOptions);
+          // save
+          crowi.saveEditorOptions(newEditorOptions);
+          crowi.savePreviewOptions(newPreviewOptions);
         }} />,
-    editorOptionSelectorElem
+    pageEditorOptionsSelectorElem
   );
 }
 

+ 131 - 0
resource/js/components/Page.js

@@ -0,0 +1,131 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionBody from './Page/RevisionBody';
+
+export default class Page extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      html: '',
+    };
+
+    this.appendEditSectionButtons = this.appendEditSectionButtons.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.getHighlightedBody = this.getHighlightedBody.bind(this);
+  }
+
+  componentWillMount() {
+    this.renderHtml(this.props.markdown, this.props.highlightKeywords);
+  }
+
+  componentDidUpdate() {
+    this.appendEditSectionButtons();
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.markdown, nextProps.highlightKeywords);
+  }
+
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    this.renderHtml(markdown, this.props.highlightKeywords);
+  }
+
+  /**
+   * Add edit section buttons to headers
+   * This invoke `appendEditSectionButtons` method of `legacy/crowi.js`
+   *
+   * TODO: transplant `appendEditSectionButtons` to this class in the future
+   */
+  appendEditSectionButtons(parentElement) {
+    if (this.props.showHeadEditButton) {
+      const crowiForJquery = this.props.crowi.getCrowiForJquery();
+      crowiForJquery.appendEditSectionButtons(this.revisionBodyElement);
+    }
+  }
+
+  /**
+   * transplanted from legacy code -- Yuki Takei
+   * @param {string} body html strings
+   * @param {string} keywords
+   */
+  getHighlightedBody(body, keywords) {
+    let returnBody = body;
+
+    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
+      if (keyword === '') {
+        return;
+      }
+      const k = keyword
+            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
+            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
+      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
+      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
+    });
+
+    return returnBody;
+  }
+
+  renderHtml(markdown, highlightKeywords) {
+    var context = {
+      markdown,
+      dom: this.revisionBodyElement,
+      currentPagePath: this.props.pagePath,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRender', context)
+      .then(() => interceptorManager.process('prePreProcess', context))
+      .then(() => {
+        context.markdown = crowiRenderer.preProcess(context.markdown);
+      })
+      .then(() => interceptorManager.process('postPreProcess', context))
+      .then(() => {
+        var parsedHTML = crowiRenderer.process(context.markdown);
+        context['parsedHTML'] = parsedHTML;
+      })
+      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => {
+        context.parsedHTML = crowiRenderer.postProcess(context.parsedHTML, context.dom);
+
+        // highlight
+        if (highlightKeywords != null) {
+          context.parsedHTML = this.getHighlightedBody(context.parsedHTML, highlightKeywords);
+        }
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postRenderHtml', context));
+
+  }
+
+  render() {
+    const config = this.props.crowi.getConfig();
+    const isMathJaxEnabled = !!config.env.MATHJAX;
+
+    return (
+      <RevisionBody html={this.state.html}
+          inputRef={el => this.revisionBodyElement = el}
+          isMathJaxEnabled={isMathJaxEnabled}
+          renderMathJaxOnInit={true}
+      />
+    )
+  }
+}
+
+Page.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
+  markdown: PropTypes.string.isRequired,
+  pagePath: PropTypes.string.isRequired,
+  showHeadEditButton: PropTypes.bool,
+  highlightKeywords: PropTypes.string,
+};

+ 0 - 70
resource/js/components/Page/PageBody.js

@@ -1,70 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-export default class PageBody extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.crowiRenderer = window.crowiRenderer; // FIXME
-    this.getMarkupHTML = this.getMarkupHTML.bind(this);
-    this.getHighlightBody = this.getHighlightBody.bind(this);
-  }
-
-  getHighlightBody(body, keywords) {
-    let returnBody = body;
-
-    keywords.replace(/"/g, '').split(' ').forEach((keyword) => {
-      if (keyword === '') {
-        return;
-      }
-      const k = keyword
-            .replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
-            .replace(/(^"|"$)/g, ''); // for phrase (quoted) keyword
-      const keywordExp = new RegExp(`(${k}(?!(.*?")))`, 'ig');
-      returnBody = returnBody.replace(keywordExp, '<em class="highlighted">$&</em>');
-    });
-
-    return returnBody;
-  }
-
-  getMarkupHTML() {
-    let body = this.props.pageBody;
-    if (body === '') {
-      body = this.props.page.revision.body;
-    }
-
-    body = this.crowiRenderer.render(body, undefined, this.props.rendererOptions);
-
-    if (this.props.highlightKeywords) {
-      body = this.getHighlightBody(body, this.props.highlightKeywords);
-    }
-
-    return { __html: body };
-  }
-
-  render() {
-    let parsedBody = this.getMarkupHTML();
-
-    return (
-      <div
-        className="content"
-        dangerouslySetInnerHTML={parsedBody}
-        />
-    );
-  }
-}
-
-PageBody.propTypes = {
-  page: PropTypes.object.isRequired,
-  highlightKeywords: PropTypes.string,
-  pageBody: PropTypes.string,
-  rendererOptions: PropTypes.object,
-};
-
-PageBody.defaultProps = {
-  page: {},
-  pageBody: '',
-  rendererOptions: {},
-};
-

+ 62 - 0
resource/js/components/Page/RevisionBody.js

@@ -0,0 +1,62 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+export default class RevisionBody extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  componentDidMount() {
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
+      const intervalId = setInterval(() => {
+        if (MathJax.isReady) {
+          this.renderMathJax();
+          clearInterval(intervalId);
+        }
+      }, 100);
+    }
+  }
+
+  componentDidUpdate() {
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxInRealtime) {
+      this.renderMathJax();
+    }
+  }
+
+  componentWillReceiveProps(nextProps) {
+    if (MathJax != null && this.props.isMathJaxEnabled && this.props.renderMathJaxOnInit) {
+      this.renderMathJax();
+    }
+  }
+
+  renderMathJax() {
+    MathJax.Hub.Queue(["Typeset", MathJax.Hub, this.element]);
+  }
+
+  generateInnerHtml(html) {
+    return {__html: html};
+  }
+
+  render() {
+    return (
+      <div
+        ref={(elm) => {
+          this.element = elm;
+          if (this.props.inputRef != null) {
+            this.props.inputRef(elm);
+          }
+        }}
+        className="wiki" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+      </div>
+    )
+  }
+}
+
+RevisionBody.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func,  // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  renderMathJaxInRealtime: PropTypes.bool,
+};

+ 39 - 18
resource/js/components/PageEditor.js

@@ -4,6 +4,9 @@ import PropTypes from 'prop-types';
 import * as toastr from 'toastr';
 import { throttle, debounce } from 'throttle-debounce';
 
+import GrowiRenderer from '../util/GrowiRenderer';
+
+import { EditorOptions, PreviewOptions } from './PageEditor/OptionsSelector';
 import Editor from './PageEditor/Editor';
 import Preview from './PageEditor/Preview';
 
@@ -15,15 +18,20 @@ export default class PageEditor extends React.Component {
     const config = this.props.crowi.getConfig();
     const isUploadable = config.upload.image || config.upload.file;
     const isUploadableFile = config.upload.file;
+    const isMathJaxEnabled = !!config.env.MATHJAX;
 
     this.state = {
       revisionId: this.props.revisionId,
       markdown: this.props.markdown,
       isUploadable,
       isUploadableFile,
+      isMathJaxEnabled,
       editorOptions: this.props.editorOptions,
+      previewOptions: this.props.previewOptions,
     };
 
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, {mode: 'editor'});
+
     this.setCaretLine = this.setCaretLine.bind(this);
     this.focusToEditor = this.focusToEditor.bind(this);
     this.onMarkdownChanged = this.onMarkdownChanged.bind(this);
@@ -69,6 +77,14 @@ export default class PageEditor extends React.Component {
     this.setState({ editorOptions });
   }
 
+  /**
+   * set options (used from the outside)
+   * @param {object} previewOptions
+   */
+  setPreviewOptions(previewOptions) {
+    this.setState({ previewOptions });
+  }
+
   /**
    * the change event handler for `markdown` state
    * @param {string} value
@@ -244,14 +260,6 @@ export default class PageEditor extends React.Component {
 
     this.setState({ markdown: value });
 
-    // generate options obj
-    const rendererOptions = {
-      // see: https://www.npmjs.com/package/marked
-      marked: {
-        breaks: config.isEnabledLineBreaks,
-      }
-    };
-
     // render html
     var context = {
       markdown: this.state.markdown,
@@ -259,18 +267,24 @@ export default class PageEditor extends React.Component {
       currentPagePath: decodeURIComponent(location.pathname)
     };
 
-    this.props.crowi.interceptorManager.process('preRenderPreview', context)
-      .then(() => crowi.interceptorManager.process('prePreProcess', context))
+    const growiRenderer = this.growiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRenderPreview', context)
+      .then(() => interceptorManager.process('prePreProcess', context))
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+        context.markdown = growiRenderer.preProcess(context.markdown);
       })
-      .then(() => crowi.interceptorManager.process('postPreProcess', context))
+      .then(() => interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+        var parsedHTML = growiRenderer.process(context.markdown);
         context['parsedHTML'] = parsedHTML;
       })
-      .then(() => crowi.interceptorManager.process('postRenderPreview', context))
-      .then(() => crowi.interceptorManager.process('preRenderPreviewHtml', context))
+      .then(() => interceptorManager.process('prePostProcess', context))
+      .then(() => {
+        context.parsedHTML = growiRenderer.postProcess(context.parsedHTML, context.dom);
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderPreviewHtml', context))
       .then(() => {
         this.setState({ html: context.parsedHTML });
 
@@ -278,7 +292,7 @@ export default class PageEditor extends React.Component {
         $('#form-body').val(this.state.markdown);
       })
       // process interceptors for post rendering
-      .then(() => crowi.interceptorManager.process('postRenderPreviewHtml', context));
+      .then(() => interceptorManager.process('postRenderPreviewHtml', context));
 
   }
 
@@ -297,7 +311,12 @@ export default class PageEditor extends React.Component {
           />
         </div>
         <div className="col-md-6 hidden-sm hidden-xs page-editor-preview-container">
-          <Preview html={this.state.html} inputRef={el => this.previewElement = el} />
+          <Preview html={this.state.html}
+              inputRef={el => this.previewElement = el}
+              isMathJaxEnabled={this.state.isMathJaxEnabled}
+              renderMathJaxOnInit={false}
+              previewOptions={this.state.previewOptions}
+          />
         </div>
       </div>
     )
@@ -306,10 +325,12 @@ export default class PageEditor extends React.Component {
 
 PageEditor.propTypes = {
   crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
   markdown: PropTypes.string.isRequired,
   pageId: PropTypes.string,
   revisionId: PropTypes.string,
   pagePath: PropTypes.string,
   onSaveSuccess: PropTypes.func,
-  editorOptions: PropTypes.object,
+  editorOptions: PropTypes.instanceOf(EditorOptions),
+  previewOptions: PropTypes.instanceOf(PreviewOptions),
 };

+ 0 - 99
resource/js/components/PageEditor/EditorOptionsSelector.js

@@ -1,99 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import FormGroup from 'react-bootstrap/es/FormGroup';
-import FormControl from 'react-bootstrap/es/FormControl';
-import ControlLabel from 'react-bootstrap/es/ControlLabel';
-import Button from 'react-bootstrap/es/Button';
-
-export default class EditorOptionsSelector extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      options: this.props.options,
-    }
-
-    this.availableThemes = [
-      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
-    ]
-
-    this.onChangeTheme = this.onChangeTheme.bind(this);
-    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
-  }
-
-  componentDidMount() {
-    this.init();
-  }
-
-  init() {
-    this.themeSelectorInputEl.value = this.state.options.theme || this.availableThemes[0];
-  }
-
-  onChangeTheme() {
-    const newValue = this.themeSelectorInputEl.value;
-    const newOpts = Object.assign(this.state.options, {theme: newValue});
-    this.setState({options: newOpts});
-
-    // dispatch event
-    this.dispatchOnChange();
-  }
-
-  onClickStyleActiveLine(event) {
-    const newValue = !this.state.options.styleActiveLine;
-    console.log(newValue);
-    const newOpts = Object.assign(this.state.options, {styleActiveLine: newValue});
-    this.setState({options: newOpts});
-
-    // dispatch event
-    this.dispatchOnChange();
-  }
-
-  dispatchOnChange() {
-    if (this.props.onChange != null) {
-      this.props.onChange(this.state.options);
-    }
-  }
-
-  renderThemeSelector() {
-    const optionElems = this.availableThemes.map((theme) => {
-      return <option key={theme} value={theme}>{theme}</option>;
-    });
-
-    return (
-      <FormGroup controlId="formControlsSelect">
-        <ControlLabel>Theme:</ControlLabel>
-        <FormControl componentClass="select" placeholder="select"
-            onChange={this.onChangeTheme}
-            inputRef={ el => this.themeSelectorInputEl=el }>
-
-          {optionElems}
-
-        </FormControl>
-      </FormGroup>
-    )
-  }
-
-  renderStyleActiveLineSelector() {
-    const bool = this.state.options.styleActiveLine || false;
-    return (
-      <FormGroup controlId="formControlsSelect">
-        <Button active={bool} className="btn-style-active-line"
-            onClick={this.onClickStyleActiveLine}
-            ref="styleActiveLineButton">
-          Active Line
-        </Button>
-      </FormGroup>
-    )
-  }
-
-  render() {
-    return <span>{this.renderThemeSelector()} {this.renderStyleActiveLineSelector()}</span>
-  }
-}
-
-EditorOptionsSelector.propTypes = {
-  options: PropTypes.object,
-  onChange: PropTypes.func,
-};

+ 161 - 0
resource/js/components/PageEditor/OptionsSelector.js

@@ -0,0 +1,161 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import FormGroup from 'react-bootstrap/es/FormGroup';
+import FormControl from 'react-bootstrap/es/FormControl';
+import ControlLabel from 'react-bootstrap/es/ControlLabel';
+import Button from 'react-bootstrap/es/Button';
+
+import OverlayTrigger  from 'react-bootstrap/es/OverlayTrigger';
+import Tooltip from 'react-bootstrap/es/Tooltip';
+
+export default class OptionsSelector extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    const config = this.props.crowi.getConfig();
+    const isMathJaxEnabled = !!config.env.MATHJAX;
+
+    this.state = {
+      editorOptions: this.props.editorOptions || new EditorOptions(),
+      previewOptions: this.props.previewOptions || new PreviewOptions(),
+      isMathJaxEnabled,
+    }
+
+    this.availableThemes = [
+      'elegant', 'neo', 'mdn-like', 'material', 'monokai', 'twilight'
+    ]
+
+    this.onChangeTheme = this.onChangeTheme.bind(this);
+    this.onClickStyleActiveLine = this.onClickStyleActiveLine.bind(this);
+    this.onClickRenderMathJaxInRealtime = this.onClickRenderMathJaxInRealtime.bind(this);
+  }
+
+  componentDidMount() {
+    this.init();
+  }
+
+  init() {
+    this.themeSelectorInputEl.value = this.state.editorOptions.theme;
+  }
+
+  onChangeTheme() {
+    const newValue = this.themeSelectorInputEl.value;
+    const newOpts = Object.assign(this.state.editorOptions, {theme: newValue});
+    this.setState({editorOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  onClickStyleActiveLine(event) {
+    const newValue = !this.state.editorOptions.styleActiveLine;
+    const newOpts = Object.assign(this.state.editorOptions, {styleActiveLine: newValue});
+    this.setState({editorOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  onClickRenderMathJaxInRealtime(event) {
+    const newValue = !this.state.previewOptions.renderMathJaxInRealtime;
+    const newOpts = Object.assign(this.state.previewOptions, {renderMathJaxInRealtime: newValue});
+    this.setState({previewOptions: newOpts});
+
+    // dispatch event
+    this.dispatchOnChange();
+  }
+
+  /**
+   * dispatch onChange event
+   */
+  dispatchOnChange() {
+    if (this.props.onChange != null) {
+      this.props.onChange(this.state.editorOptions, this.state.previewOptions);
+    }
+  }
+
+  renderThemeSelector() {
+    const optionElems = this.availableThemes.map((theme) => {
+      return <option key={theme} value={theme}>{theme}</option>;
+    });
+
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <ControlLabel>Theme:</ControlLabel>
+        <FormControl componentClass="select" placeholder="select"
+            onChange={this.onChangeTheme}
+            inputRef={ el => this.themeSelectorInputEl=el }>
+
+          {optionElems}
+
+        </FormControl>
+      </FormGroup>
+    )
+  }
+
+  renderStyleActiveLineSelector() {
+    const bool = this.state.editorOptions.styleActiveLine;
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <Button active={bool} className="btn-style-active-line"
+            onClick={this.onClickStyleActiveLine}>
+          Active Line
+        </Button>
+      </FormGroup>
+    )
+  }
+
+  renderRealtimeMathJaxSelector() {
+    if (!this.state.isMathJaxEnabled) {
+      return;
+    }
+
+    // tooltip
+    const tooltip = (
+      <Tooltip id="tooltip-realtime-mathjax-rendering">Realtime MathJax Rendering</Tooltip>
+    );
+
+    const isEnabled = this.state.isMathJaxEnabled;
+    const isActive = isEnabled && this.state.previewOptions.renderMathJaxInRealtime;
+    return (
+      <FormGroup controlId="formControlsSelect">
+        <OverlayTrigger placement="top" overlay={tooltip}>
+          <Button active={isActive} className="btn-render-mathjax-in-realtime"
+              onClick={this.onClickRenderMathJaxInRealtime}>
+            <i className="fa fa-superscript" aria-hidden="true"></i>
+          </Button>
+        </OverlayTrigger>
+      </FormGroup>
+    )
+  }
+
+  render() {
+    return <span>{this.renderThemeSelector()} {this.renderStyleActiveLineSelector()} {this.renderRealtimeMathJaxSelector()}</span>
+  }
+}
+
+export class EditorOptions {
+  constructor(props) {
+    this.theme = 'elegant';
+    this.styleActiveLine = false;
+
+    Object.assign(this, props);
+  }
+}
+
+export class PreviewOptions {
+  constructor(props) {
+    this.renderMathJaxInRealtime = false;
+
+    Object.assign(this, props);
+  }
+}
+
+OptionsSelector.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  editorOptions: PropTypes.instanceOf(EditorOptions),
+  previewOptions: PropTypes.instanceOf(PreviewOptions),
+  onChange: PropTypes.func,
+};

+ 21 - 7
resource/js/components/PageEditor/Preview.js

@@ -1,21 +1,32 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import RevisionBody from '../Page/RevisionBody';
+
+import { PreviewOptions } from './OptionsSelector';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
 export default class Preview extends React.Component {
 
   constructor(props) {
     super(props);
   }
 
-  generateInnerHtml(html) {
-    return {__html: html};
-  }
-
   render() {
+    const renderMathJaxInRealtime = this.props.previewOptions.renderMathJaxInRealtime;
+
     return (
-      <div
-        ref={this.props.inputRef}
-        className="wiki page-editor-preview-body" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+      <div className="page-editor-preview-body"
+          ref={(elm) => {
+            this.props.inputRef(elm);
+          }}>
+
+        <RevisionBody
+          {...this.props}
+          renderMathJaxInRealtime={renderMathJaxInRealtime}
+        />
       </div>
     )
   }
@@ -24,4 +35,7 @@ export default class Preview extends React.Component {
 Preview.propTypes = {
   html: PropTypes.string,
   inputRef: PropTypes.func.isRequired,  // for getting div element
+  isMathJaxEnabled: PropTypes.bool,
+  renderMathJaxOnInit: PropTypes.bool,
+  previewOptions: PropTypes.instanceOf(PreviewOptions),
 };

+ 2 - 1
resource/js/components/SearchPage.js

@@ -101,10 +101,10 @@ export default class SearchPage extends React.Component {
         </div>
 
         <SearchResult
+          crowi={this.props.crowi}  crowiRenderer={this.props.crowiRenderer}
           pages={this.state.searchedPages}
           searchingKeyword={this.state.searchingKeyword}
           searchResultMeta={this.state.searchResultMeta}
-          crowi={this.props.crowi}
           />
       </div>
     );
@@ -114,6 +114,7 @@ export default class SearchPage extends React.Component {
 SearchPage.propTypes = {
   query: PropTypes.object,
   crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
 };
 SearchPage.defaultProps = {
   //pollInterval: 1000,

+ 2 - 1
resource/js/components/SearchPage/SearchResult.js

@@ -261,6 +261,7 @@ export default class SearchResult extends React.Component {
           </div>
           <div className="col-md-8 search-result-content" id="search-result-content">
             <SearchResultList
+              crowi={this.props.crowi}
               pages={this.props.pages}
               searchingKeyword={this.props.searchingKeyword}
               />
@@ -281,11 +282,11 @@ export default class SearchResult extends React.Component {
 }
 
 SearchResult.propTypes = {
+  crowi: PropTypes.object.isRequired,
   tree: PropTypes.string.isRequired,
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
   searchResultMeta: PropTypes.object.isRequired,
-  crowi: PropTypes.object.isRequired,
 };
 SearchResult.defaultProps = {
   tree: '',

+ 14 - 18
resource/js/components/SearchPage/SearchResultList.js

@@ -1,39 +1,33 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import PageBody from '../Page/PageBody.js';
+import GrowiRenderer from '../../util/GrowiRenderer';
+
+import Page from '../Page.js';
 
 export default class SearchResultList extends React.Component {
 
   constructor(props) {
     super(props);
+
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiRenderer, {mode: 'searchresult'});
   }
 
   render() {
     var isEnabledLineBreaks = $('#content-main').data('linebreaks-enabled');
 
-    // generate options obj
-    var rendererOptions = {
-      // see: https://www.npmjs.com/package/marked
-      marked: {
-        breaks: isEnabledLineBreaks
-      }
-    };
-
     const resultList = this.props.pages.map((page) => {
       const pageBody = page.revision.body;
       return (
         <div id={page._id} key={page._id} className="search-result-page">
           <h2><a href={page.path}>{page.path}</a></h2>
-          <div className="wiki">
-            <PageBody
-              className="hige"
-              page={page}
-              pageBody={pageBody}
-              highlightKeywords={this.props.searchingKeyword}
-              rendererOptions={rendererOptions}
-            />
-          </div>
+          <Page
+            crowi={this.props.crowi}
+            crowiRenderer={this.growiRenderer}
+            markdown={pageBody}
+            pagePath={page.path}
+            highlightKeywords={this.props.searchingKeyword}
+          />
         </div>
       );
     });
@@ -47,6 +41,8 @@ export default class SearchResultList extends React.Component {
 }
 
 SearchResultList.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
   pages: PropTypes.array.isRequired,
   searchingKeyword: PropTypes.string.isRequired,
 };

+ 2 - 2
resource/js/legacy/crowi-form.js

@@ -82,11 +82,11 @@ $(function() {
     crowi.interceptorManager.process('preRenderPreview', context)
       .then(() => crowi.interceptorManager.process('prePreProcess', context))
       .then(() => {
-        context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+        context.markdown = crowiRenderer.preProcess(context.markdown);
       })
       .then(() => crowi.interceptorManager.process('postPreProcess', context))
       .then(() => {
-        var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+        var parsedHTML = crowiRenderer.render(context.markdown, context.dom);
         context['parsedHTML'] = parsedHTML;
       })
       .then(() => crowi.interceptorManager.process('postRenderPreview', context))

+ 0 - 1
resource/js/legacy/crowi-presentation.js

@@ -35,7 +35,6 @@ require.ensure([], () => {
   require('reveal.js/lib/js/classList.js');
   require('reveal.js/plugin/markdown/marked.js');
   require('reveal.js/plugin/markdown/markdown.js');
-  require('reveal.js/plugin/highlight/highlight.js');
   require('reveal.js/plugin/zoom-js/zoom.js');
   require('reveal.js/plugin/notes/notes.js');
 

+ 58 - 108
resource/js/legacy/crowi.js

@@ -2,10 +2,15 @@
 /* Author: Sotaro KARASAWA <sotarok@crocos.co.jp>
 */
 
+import React from 'react';
+import ReactDOM from 'react-dom';
+
+import GrowiRenderer from '../util/GrowiRenderer';
+import Page from '../components/Page';
+
 const io = require('socket.io-client');
 const entities = require("entities");
 const escapeStringRegexp = require('escape-string-regexp');
-const getLineFromPos = require('get-line-from-pos');
 require('bootstrap-sass');
 require('jquery.cookie');
 
@@ -20,36 +25,20 @@ Crowi.createErrorView = function(msg) {
   $('#main').prepend($('<p class="alert-message error">' + msg + '</p>'));
 };
 
-Crowi.correctHeaders = function(contentId) {
-  // h1 ~ h6 の id 名を補正する
-  var $content = $(contentId || '#revision-body-content');
-  var i = 0;
-  $('h1,h2,h3,h4,h5,h6', $content).each(function(idx, elm) {
-    var id = 'head' + i++;
-    $(this).attr('id', id);
-    $(this).addClass('revision-head');
-    $(this).append('<span class="revision-head-link"><a href="#' + id +'"><i class="fa fa-link"></i></a></span>');
-  });
-};
+/**
+ * render Table Of Contents
+ * @param {string} tocHtml
+ */
+Crowi.renderTocContent = (tocHtml) => {
+  $('#revision-toc-content').html(tocHtml);
+}
 
 /**
  * append buttons to section headers
  */
-Crowi.appendEditSectionButtons = function(contentId, markdown) {
-  const $content = $(contentId || '#revision-body-content');
-  $('h1,h2,h3,h4,h5,h6', $content).each(function(idx, elm) {
-    // get header text string
-    const text = $(this).text();
-    const escapedText = escapeStringRegexp(text);
-
-    // search pos for '# ...'
-    // https://regex101.com/r/y5rpO5/1
-    const regexp = new RegExp(`[^\r\n]*#+[^\r\n]*${escapedText}[^\r\n]*`);
-    let position = markdown.search(regexp);
-    if (position < 0) { // if not found, search with header text only
-      position = markdown.search(text);
-    }
-    const line = getLineFromPos(markdown, position);
+Crowi.appendEditSectionButtons = function(parentElement) {
+  $('h1,h2,h3,h4,h5,h6', parentElement).each(function(idx, elm) {
+    const line = +elm.getAttribute('data-line');
 
     // add button
     $(this).append(`
@@ -98,52 +87,6 @@ Crowi.setCaretLineAndFocusToEditor = function() {
   crowi.focusToEditor();
 }
 
-Crowi.revisionToc = function(contentId, tocId) {
-  var $content = $(contentId || '#revision-body-content');
-  var $tocId = $(tocId || '#revision-toc');
-
-  var $tocContent = $('<div id="revision-toc-content" class="revision-toc-content collapse in"></div>');
-  $tocId.append($tocContent);
-
-  $('h1', $content).each(function(idx, elm) {
-    var id = $(this).attr('id');
-    var title = $(this).text();
-    var selector = '#' + id + ' ~ h2:not(#' + id + ' ~ h1 ~ h2)';
-
-    var $toc = $('<ul></ul>');
-    var $tocLi = $('<li><a href="#' + id +'">' + title + '</a></li>');
-
-
-    $tocContent.append($toc);
-    $toc.append($tocLi);
-
-    $(selector).each(function()
-    {
-      var id2 = $(this).attr('id');
-      var title2 = $(this).text();
-      var selector2 = '#' + id2 + ' ~ h3:not(#' + id2 + ' ~ h2 ~ h3)';
-
-      var $toc2 = $('<ul></ul>');
-      var $tocLi2 = $('<li><a href="#' + id2 +'">' + title2 + '</a></li>');
-
-      $tocLi.append($toc2);
-      $toc2.append($tocLi2);
-
-      $(selector2).each(function()
-      {
-        var id3 = $(this).attr('id');
-        var title3 = $(this).text();
-
-        var $toc3 = $('<ul></ul>');
-        var $tocLi3 = $('<li><a href="#' + id3 +'">' + title3 + '</a></li>');
-
-        $tocLi2.append($toc3);
-        $toc3.append($tocLi3);
-      });
-    });
-  });
-};
-
 // original: middleware.swigFilter
 Crowi.userPicture = function (user) {
   if (!user) {
@@ -167,7 +110,7 @@ Crowi.modifyScrollTop = function() {
   }
   var pageHeaderRect = pageHeader.getBoundingClientRect();
 
-  var sectionHeader = document.querySelector(hash);
+  var sectionHeader = Crowi.findSectionHeader(hash);
   if (sectionHeader === null) {
     return;
   }
@@ -227,14 +170,6 @@ $(function() {
   var pagePath= $('#content-main').data('path');
   var isSavedStatesOfTabChanges = config['isSavedStatesOfTabChanges'];
 
-  // generate options obj
-  var rendererOptions = {
-    // see: https://www.npmjs.com/package/marked
-    marked: {
-      breaks: config.isEnabledLineBreaks
-    }
-  };
-
   $('[data-toggle="popover"]').popover();
   $('[data-toggle="tooltip"]').tooltip();
   $('[data-tooltip-stay]').tooltip('show');
@@ -486,29 +421,26 @@ $(function() {
   });
 
   // for list page
+  let growiRendererForTimeline = null;
   $('a[data-toggle="tab"][href="#view-timeline"]').on('show.bs.tab', function() {
     var isShown = $('#view-timeline').data('shown');
+
+    if (growiRendererForTimeline == null) {
+      growiRendererForTimeline = new GrowiRenderer(crowi, crowiRenderer, {mode: 'timeline'});
+    }
+
     if (isShown == 0) {
       $('#view-timeline .timeline-body').each(function()
       {
         var id = $(this).attr('id');
         var contentId = '#' + id + ' > script';
         var revisionBody = '#' + id + ' .revision-body';
-        var $revisionBody = $(revisionBody);
+        var revisionBodyElem = document.querySelector(revisionBody);
         var revisionPath = '#' + id + ' .revision-path';
-
+        var pagePath = document.getElementById(id).getAttribute('data-page-path');
         var markdown = entities.decodeHTML($(contentId).html());
-        var parsedHTML = crowiRenderer.render(markdown, $revisionBody.get(0), rendererOptions);
-        $revisionBody.html(parsedHTML);
 
-        $('.template-create-button', revisionBody).on('click', function() {
-          var path = $(this).data('path');
-          var templateId = $(this).data('template');
-          var template = $('#' + templateId).html();
-
-          crowi.saveDraft(path, template);
-          top.location.href = path;
-        });
+        ReactDOM.render(<Page crowi={crowi} crowiRenderer={growiRendererForTimeline} markdown={markdown} pagePath={pagePath} />, revisionBodyElem);
       });
 
       $('#view-timeline').data('shown', 1);
@@ -540,6 +472,10 @@ $(function() {
 
   if (pageId) {
 
+    /*
+     * transplanted to React components -- 2018.02.04 Yuki Takei
+     *
+
     // if page exists
     var $rawTextOriginal = $('#raw-text-original');
     if ($rawTextOriginal.length > 0) {
@@ -556,12 +492,12 @@ $(function() {
       crowi.interceptorManager.process('preRender', context)
         .then(() => crowi.interceptorManager.process('prePreProcess', context))
         .then(() => {
-          context.markdown = crowiRenderer.preProcess(context.markdown, context.dom);
+          context.markdown = crowiRenderer.preProcess(context.markdown);
         })
         .then(() => crowi.interceptorManager.process('postPreProcess', context))
         .then(() => {
           var revisionBody = $('#revision-body-content');
-          var parsedHTML = crowiRenderer.render(context.markdown, context.dom, rendererOptions);
+          var parsedHTML = crowiRenderer.render(context.markdown, context.dom);
           context.parsedHTML = parsedHTML;
           Promise.resolve(context);
         })
@@ -580,9 +516,7 @@ $(function() {
             top.location.href = path;
           });
 
-          Crowi.correctHeaders('#revision-body-content');
           Crowi.appendEditSectionButtons('#revision-body-content', markdown);
-          Crowi.revisionToc('#revision-body-content', '#revision-toc');
 
           Promise.resolve($('#revision-body-content'));
         })
@@ -594,6 +528,7 @@ $(function() {
 
 
     }
+    */
 
     // header
     var $header = $('#page-header');
@@ -883,28 +818,43 @@ Crowi.findHashFromUrl = function(url)
 {
   var match;
   if (match = url.match(/#(.+)$/)) {
-    return '#' + match[1];
+    return `#${match[1]}`;
   }
 
   return "";
 }
 
+Crowi.findSectionHeader = function(hash) {
+  if (hash.length == 0) {
+    return;
+  }
+
+  // omit '#'
+  const id = hash.replace('#', '');
+  // don't use jQuery and document.querySelector
+  //  because hash may containe Base64 encoded strings
+  const elem = document.getElementById(id);
+  if (elem != null && elem.tagName.match(/h\d+/i)) {  // match h1, h2, h3...
+    return elem;
+  }
+
+  return null;
+}
+
 Crowi.unhighlightSelectedSection = function(hash)
 {
-  if (!hash || hash == "" || !hash.match(/^#head.+/)) {
-    // とりあえず head* だけ (検索結果ページで副作用出た
-    return true;
+  const elem = Crowi.findSectionHeader(hash);
+  if (elem != null) {
+    elem.classList.remove('highlighted');
   }
-  $(hash).removeClass('highlighted');
 }
 
 Crowi.highlightSelectedSection = function(hash)
 {
-  if (!hash || hash == "" || !hash.match(/^#head.+/)) {
-    // とりあえず head* だけ (検索結果ページで副作用出た
-    return true;
+  const elem = Crowi.findSectionHeader(hash);
+  if (elem != null) {
+    elem.classList.add('highlighted');
   }
-  $(hash).addClass('highlighted');
 }
 
 window.addEventListener('load', function(e) {
@@ -971,7 +921,7 @@ window.addEventListener('hashchange', function(e) {
       $('a[data-toggle="tab"][href="#revision-history"]').tab('show');
     }
   }
-  if (location.hash == '' || location.hash.match(/^#head.+/)) {
+  else {
     $('a[data-toggle="tab"][href="#revision-body"]').tab('show');
   }
 });

+ 16 - 6
resource/js/util/Crowi.js

@@ -4,10 +4,13 @@
 
 import axios from 'axios'
 import InterceptorManager from '../../../lib/util/interceptor-manager';
-import {
-  DetachCodeBlockInterceptor,
-  RestoreCodeBlockInterceptor,
-} from './interceptor/detach-code-blocks';
+
+//// disable Detach/Restore interceptors
+//// because markdown-it handles emoji and Linker in code blocks well -- 2018.02.01 Yuki Takei
+// import {
+//   DetachCodeBlockInterceptor,
+//   RestoreCodeBlockInterceptor,
+// } from './interceptor/detach-code-blocks';
 
 export default class Crowi {
   constructor(context, window) {
@@ -28,8 +31,10 @@ export default class Crowi {
 
     this.interceptorManager = new InterceptorManager();
     this.interceptorManager.addInterceptors([
-      new DetachCodeBlockInterceptor(this),
-      new RestoreCodeBlockInterceptor(this),
+      //// disable Detach/Restore interceptors
+      //// because markdown-it handles emoji and Linker in code blocks well -- 2018.02.01 Yuki Takei
+      // new DetachCodeBlockInterceptor(this),
+      // new RestoreCodeBlockInterceptor(this),
     ]);
 
     // FIXME
@@ -74,6 +79,7 @@ export default class Crowi {
       'users',
       'draft',
       'editorOptions',
+      'previewOptions',
     ];
 
     keys.forEach(key => {
@@ -153,6 +159,10 @@ export default class Crowi {
     this.localStorage.setItem('editorOptions', JSON.stringify(options));
   }
 
+  savePreviewOptions(options) {
+    this.localStorage.setItem('previewOptions', JSON.stringify(options));
+  }
+
   findUserById(userId) {
     if (this.userById && this.userById[userId]) {
       return this.userById[userId];

+ 12 - 3
resource/js/util/CrowiRenderer.js

@@ -1,3 +1,7 @@
+/**
+ * DEPRECATED
+ * replaced by GrowiRenderer -- 2018.01.29 Yuki Takei
+ *
 import marked from 'marked';
 import hljs from 'highlight.js';
 import * as entities from 'entities';
@@ -16,7 +20,6 @@ import PlantUML from './LangProcessor/PlantUML';
 
 export default class CrowiRenderer {
 
-
   constructor(crowi) {
     this.crowi = crowi;
 
@@ -134,6 +137,7 @@ export default class CrowiRenderer {
 
     return parsed;
   }
+  */
 
   /**
    * render
@@ -150,12 +154,17 @@ export default class CrowiRenderer {
    *
    * @memberOf CrowiRenderer
    */
-  render(markdown, dom, rendererOptions) {
+  /*
+   DEPRECATED
+   replaced by GrowiRenderer -- 2018.01.29 Yuki Takei
+
+  render(markdown, dom) {
     let html = '';
 
-    html = this.parseMarkdown(markdown, dom, rendererOptions.marked || {});
+    html = this.parseMarkdown(markdown, dom);
     html = this.postProcess(html, dom);
 
     return html;
   }
 }
+*/

+ 166 - 0
resource/js/util/GrowiRenderer.js

@@ -0,0 +1,166 @@
+import MarkdownIt from 'markdown-it';
+import * as entities from 'entities';
+
+import Linker        from './PreProcessor/Linker';
+import CsvToTable    from './PreProcessor/CsvToTable';
+import XssFilter     from './PreProcessor/XssFilter';
+
+import Template from './LangProcessor/Template';
+
+import CommonPluginsConfigurer from './markdown-it/common-plugins';
+import EmojiConfigurer from './markdown-it/emoji';
+import HeaderLineNumberConfigurer from './markdown-it/header-line-number';
+import HeaderConfigurer from './markdown-it/header';
+import MathJaxConfigurer from './markdown-it/mathjax';
+import PlantUMLConfigurer from './markdown-it/plantuml';
+import TableConfigurer from './markdown-it/table';
+import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
+
+export default class GrowiRenderer {
+
+  /**
+   *
+   * @param {Crowi} crowi
+   * @param {GrowiRenderer} originRenderer may be customized by plugins
+   * @param {object} options
+   */
+  constructor(crowi, originRenderer, options) {
+    this.crowi = crowi;
+    this.originRenderer = originRenderer || {};
+    this.options = Object.assign( // merge options
+      { isAutoSetup: true },      // default options
+      options || {});             // specified options
+
+    // initialize processors
+    //  that will be retrieved if originRenderer exists
+    this.preProcessors = this.originRenderer.preProcessors || [
+      new Linker(crowi),
+      new CsvToTable(crowi),
+      new XssFilter(crowi),
+    ];
+    this.postProcessors = this.originRenderer.postProcessors || [
+    ];
+
+    this.langProcessors = this.originRenderer.langProcessors || {
+      'template': new Template(crowi),
+    };
+
+    this.initMarkdownItConfigurers = this.initMarkdownItConfigurers.bind(this);
+    this.setup = this.setup.bind(this);
+    this.process = this.process.bind(this);
+    this.codeRenderer = this.codeRenderer.bind(this);
+
+    // init markdown-it
+    this.md = new MarkdownIt({
+      html: true,
+      linkify: true,
+      highlight: this.codeRenderer,
+    });
+    this.initMarkdownItConfigurers(options);
+
+    // auto setup
+    if (this.options.isAutoSetup) {
+      this.setup(crowi.getConfig());
+    }
+  }
+
+  initMarkdownItConfigurers(options) {
+    const crowi = this.crowi;
+
+    this.isMarkdownItConfigured = false;
+
+    this.markdownItConfigurers = [
+      new CommonPluginsConfigurer(crowi),
+      new HeaderConfigurer(crowi),
+      new TableConfigurer(crowi),
+      new EmojiConfigurer(crowi),
+      new MathJaxConfigurer(crowi),
+      new PlantUMLConfigurer(crowi),
+    ];
+
+    // add configurers according to mode
+    const mode = options.mode;
+    switch (mode) {
+      case 'page':
+        this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new TocAndAnchorConfigurer(crowi, options.renderToc),
+          new HeaderLineNumberConfigurer(crowi),
+        ]);
+        break;
+      case 'editor':
+        this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new HeaderLineNumberConfigurer(crowi)
+        ]);
+        break;
+      case 'timeline':
+        break;
+      case 'searchresult':
+        break;
+    }
+  }
+
+  /**
+   * setup with crowi config
+   * @param {any} config crowi config
+   */
+  setup(config) {
+    this.md.set({
+      breaks: config.isEnabledLineBreaks,
+    });
+
+    if (!this.isMarkdownItConfigured) {
+      this.markdownItConfigurers.forEach((configurer) => {
+        configurer.configure(this.md);
+      });
+    }
+  }
+
+  preProcess(markdown) {
+    for (let i = 0; i < this.preProcessors.length; i++) {
+      if (!this.preProcessors[i].process) {
+        continue;
+      }
+      markdown = this.preProcessors[i].process(markdown);
+    }
+
+    return markdown;
+  }
+
+  process(markdown) {
+    return this.md.render(markdown);
+  }
+
+  postProcess(html, dom) {
+    for (let i = 0; i < this.postProcessors.length; i++) {
+      if (!this.postProcessors[i].process) {
+        continue;
+      }
+      html = this.postProcessors[i].process(html, dom);
+    }
+
+    return html;
+  }
+
+  codeRenderer(code, langExt) {
+    if (langExt) {
+      const langAndFn = langExt.split(':');
+      const lang = langAndFn[0];
+      const langFn = langAndFn[1] || null;
+
+      // process langProcessors
+      if (this.langProcessors[lang] != null) {
+        return this.langProcessors[lang].process(code, langExt);
+      }
+
+      if (hljs.getLanguage(lang)) {
+        let citeTag = (langFn) ? `<cite>${langFn}</cite>` : '';
+        try {
+          return `<pre class="hljs">${citeTag}<code class="language-${lang}">${hljs.highlight(lang, code, true).value}</code></pre>`;
+        } catch (__) {}
+      }
+    }
+
+    return '';
+  }
+
+}

+ 0 - 43
resource/js/util/LangProcessor/PlantUML.js

@@ -1,43 +0,0 @@
-import plantuml from 'plantuml-encoder';
-import crypto from 'crypto';
-import * as entities from 'entities';
-
-export default class PlantUML {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-
-  }
-
-  generateId(token) {
-    const hasher = require('crypto').createHash('md5');
-    hasher.update(token);
-    return hasher.digest('hex');
-  }
-
-  process(code, lang) {
-    const config = crowi.getConfig();
-    if (!config.env.PLANTUML_URI) {
-      return `<pre class="wiki-code"><code>${entities.encodeHTML(code)}\n</code></pre>`;
-    }
-
-    let plantumlUri = config.env.PLANTUML_URI;
-    if (plantumlUri.substr(-1) !== '/') {
-      plantumlUri += '/';
-    }
-    const id = this.generateId(code + lang);
-    const encoded = plantuml.encode(`@startuml
-
-skinparam monochrome true
-
-${code}
-@enduml`);
-
-    return `
-      <div id="${id}" class="plantuml noborder">
-        <img src="${plantumlUri}svg/${encoded}">
-      </div>
-    `;
-  }
-}
-

+ 12 - 4
resource/js/util/LangProcessor/Template.js

@@ -56,9 +56,17 @@ export default class Template {
       pageName = this.parseTemplateString(lang.split(':')[1]);
     }
     code = this.parseTemplateString(code);
-    return `
-    <div class="page-template-builder">
-    <button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}"><i class="fa fa-pencil"></i> ${pageName}</button>
-      <pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre></div>\n`;
+
+    const content = `
+      <div class="page-template-builder">
+        <button class="template-create-button btn btn-default" data-template="${templateId}" data-path="${pageName}#edit-form">
+          <i class="fa fa-pencil"></i> ${pageName}
+        </button>
+        <pre><code id="${templateId}" class="lang-${lang}">${code}\n</code></pre>
+      </div>`;
+
+    // wrap with <pre-dummy>
+    //   to avoid to be wrapped with <pre><code> by markdown-it
+    return `<pre-dummy>${content}<pre-dummy>\n`;
   }
 }

+ 0 - 85
resource/js/util/LangProcessor/Tsv2Table.js

@@ -1,85 +0,0 @@
-import * as entities from 'entities';
-
-export default class Tsv2Table {
-
-  constructor(crowi, option) {
-    if (!option) {
-      option = {};
-    }
-    this.option = option;
-
-    this.option.header = this.option.header || false;
-  }
-  getCols(codeLines) {
-    let max = 0;
-
-    for (let i = 0; i < codeLines ; i++) {
-      if (max < codeLines.length) {
-        max = codeLines.length;
-      }
-    }
-
-    return max;
-  }
-
-  splitColums(line) {
-    // \t is replaced to '    ' by Lexer.lex(), so split by 4 spaces
-    return line.split(/\s{4}/g);
-  }
-
-  getTableHeader(codeLines, option) {
-    let headers = [];
-    let headLine = (codeLines[0] || '');
-
-    //console.log('head', headLine);
-    headers = this.splitColums(headLine).map(col => {
-      return `<th>${entities.encodeHTML(col)}</th>`;
-    });
-
-    if (headers.length < option.cols) {
-      headers.concat(new Array(option.cols - headers.length));
-    }
-
-    return `<tr>
-      ${headers.join('\n')}
-    </tr>`;
-  }
-
-  getTableBody(codeLines, option) {
-    let rows;
-
-    if (this.option.header) {
-      codeLines.shift();
-    }
-
-    rows = codeLines.map(row => {
-      const cols = this.splitColums(row).map(col => {
-        return `<td>${entities.encodeHTML(col)}</td>`;
-      }).join('');
-      return `<tr>${cols}</tr>`;
-    });
-
-    return rows.join('\n');
-  }
-
-  process(code) {
-    let option = {};
-    const codeLines = code.split(/\n|\r/);
-
-    option.cols = this.getCols(codeLines);
-
-    let header = '';
-    if (this.option.header) {
-      header = `<thead>
-        ${this.getTableHeader(codeLines, option)}
-      </thead>`;
-    }
-
-    return `<table>
-      ${header}
-      <tbody>
-        ${this.getTableBody(codeLines, option)}
-      </tbody>
-    </table>`;
-  }
-}

+ 5 - 0
resource/js/util/PostProcessor/Emoji.js

@@ -1,6 +1,11 @@
+/**
+ * DEPRECATED
+ * replaced by markdown-it-emoji -- 2018.01.30 Yuki Takei
+ *
 export default class Emoji {
 
   process(markdown) {
     return emojione.shortnameToImage(markdown);
   }
 }
+*/

+ 5 - 0
resource/js/util/PostProcessor/Mathjax.js

@@ -1,3 +1,7 @@
+/**
+ * DEPRECATED
+ * replaced by markdown-it-mathjax and rendering by PageEditor/Preview component -- 2018.01.30 Yuki Takei
+ *
 
 export default class Mathjax {
 
@@ -64,3 +68,4 @@ export default class Mathjax {
     return html;
   }
 }
+*/

+ 26 - 0
resource/js/util/PreProcessor/CsvToTable.js

@@ -0,0 +1,26 @@
+import csvToMarkdownTable from 'csv-to-markdown-table';
+
+export default class CsvToTable {
+  process(markdown) {
+
+    // see: https://regex101.com/r/WR6IvX/2
+    return markdown.replace(/```(\S+)[\r\n]((.|[\r\n])*?)[\r\n]```/gm, (all, group1, group2) => {
+      switch (group1) {
+        case 'tsv':
+          return csvToMarkdownTable(group2, '\t');
+          break;
+        case 'tsv-h':
+          return csvToMarkdownTable(group2, '\t', true);
+          break;
+        case 'csv':
+          return csvToMarkdownTable(group2, ',');
+          break;
+        case 'csv-h':
+          return csvToMarkdownTable(group2, ',', true);
+          break;
+        default:
+          return all;
+      }
+    });
+  }
+}

+ 15 - 0
resource/js/util/markdown-it/common-plugins.js

@@ -0,0 +1,15 @@
+export default class CommonPluginsConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-footnote'))
+      .use(require('markdown-it-task-lists'), {
+        enabled: true,
+      })
+      ;
+  }
+
+}

+ 17 - 0
resource/js/util/markdown-it/emoji.js

@@ -0,0 +1,17 @@
+export default class EmojiConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-emoji'));
+
+    // integrate markdown-it-emoji and emojione
+    md.renderer.rules.emoji = (token, idx) => {
+      const shortname = `:${token[idx].markup}:`;
+      return emojione.shortnameToImage(shortname);
+    };
+  }
+
+}

+ 44 - 0
resource/js/util/markdown-it/header-line-number.js

@@ -0,0 +1,44 @@
+export default class HeaderLineNumberConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    this.injectLineNumbers = this.injectLineNumbers.bind(this);
+    this.combineRules = this.combineRules.bind(this);
+  }
+
+  configure(md) {
+    const rules = md.renderer.rules;
+    const headingOpenOrg = rules.heading_open;
+    const paragraphOpenOrg = rules.paragraph_open;
+    // combine rule and set
+    rules.heading_open = this.combineRules(this.injectLineNumbers, headingOpenOrg);
+    rules.paragraph_open = this.combineRules(this.injectLineNumbers, paragraphOpenOrg);
+  }
+
+  /**
+   * Inject line numbers for sync scroll
+   * @see https://github.com/markdown-it/markdown-it/blob/e6f19eab4204122e85e4a342e0c1c8486ff40c2d/support/demo_template/index.js#L169
+   */
+  injectLineNumbers(tokens, idx, options, env, slf) {
+    var line;
+    if (tokens[idx].map && tokens[idx].level === 0) {
+      line = tokens[idx].map[0] + 1;    // add 1 to convert to line number
+      tokens[idx].attrJoin('class', 'line');
+      tokens[idx].attrSet('data-line', String(line));
+    }
+    return slf.renderToken(tokens, idx, options, env, slf);
+  }
+
+  combineRules(rule1, rule2) {
+    return (tokens, idx, options, env, slf) => {
+      if (rule1 != null) {
+        rule1(tokens, idx, options, env, slf);
+      }
+      if (rule2 != null) {
+        rule2(tokens, idx, options, env, slf);
+      }
+      return slf.renderToken(tokens, idx, options, env, slf);
+    }
+  }
+}

+ 38 - 0
resource/js/util/markdown-it/header.js

@@ -0,0 +1,38 @@
+export default class HeaderConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    this.injectRevisionHeadClass = this.injectRevisionHeadClass.bind(this);
+    this.combineRules = this.combineRules.bind(this);
+  }
+
+  configure(md) {
+    const rules = md.renderer.rules;
+    const headingOpenOrg = rules.heading_open;
+    // combine rule and set
+    rules.heading_open = this.combineRules(this.injectRevisionHeadClass, headingOpenOrg);
+  }
+
+  /**
+   * Inject 'revision-head' class
+   */
+  injectRevisionHeadClass(tokens, idx, options, env, slf) {
+    if (tokens[idx].map && tokens[idx].level === 0) {
+      tokens[idx].attrJoin('class', 'revision-head');
+    }
+    return slf.renderToken(tokens, idx, options, env, slf);
+  }
+
+  combineRules(rule1, rule2) {
+    return (tokens, idx, options, env, slf) => {
+      if (rule1 != null) {
+        rule1(tokens, idx, options, env, slf);
+      }
+      if (rule2 != null) {
+        rule2(tokens, idx, options, env, slf);
+      }
+      return slf.renderToken(tokens, idx, options, env, slf);
+    }
+  }
+}

+ 16 - 0
resource/js/util/markdown-it/mathjax.js

@@ -0,0 +1,16 @@
+export default class MathJaxConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+
+    const config = crowi.getConfig();
+    this.isEnabled = !!config.env.MATHJAX;  // convert to boolean
+  }
+
+  configure(md) {
+    if (this.isEnabled) {
+      md.use(require('markdown-it-mathjax')());
+    }
+  }
+
+}

+ 26 - 0
resource/js/util/markdown-it/plantuml.js

@@ -0,0 +1,26 @@
+import urljoin from 'url-join';
+
+export default class PlantUMLConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    const config = crowi.getConfig();
+
+    this.deflate = require('markdown-it-plantuml/lib/deflate.js');
+    this.serverUrl = config.env.PLANTUML_URI || 'http://plantuml.com';
+
+    this.generateSource = this.generateSource.bind(this);
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-plantuml'), 'name', {
+      generateSource: this.generateSource,
+    });
+  }
+
+  generateSource(umlCode) {
+    const zippedCode =
+      this.deflate.encode64(this.deflate.zip_deflate('@startuml\n' + umlCode + '\n@enduml', 9));
+    return urljoin(this.serverUrl, 'plantuml', 'svg' , zippedCode);
+  }
+}

+ 13 - 0
resource/js/util/markdown-it/table.js

@@ -0,0 +1,13 @@
+export default class TableConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.renderer.rules.table_open = (tokens, idx) => {
+      return '<table class="table table-bordered">';
+    };
+  }
+
+}

+ 42 - 0
resource/js/util/markdown-it/toc-and-anchor.js

@@ -0,0 +1,42 @@
+export default class TocAndAnchorConfigurer {
+
+  constructor(crowi, renderToc) {
+    this.crowi = crowi;
+    this.renderToc = renderToc;
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-toc-and-anchor').default, {
+        anchorLinkBefore: false,
+        anchorLinkSymbol: '',
+        anchorLinkSymbolClassName: 'fa fa-link',
+        anchorClassName: 'revision-head-link',
+      })
+      .use(require('markdown-it-named-headers'), {  // overwrite id defined by markdown-it-toc-and-anchor
+        slugify: this.customSlugify,
+      })
+      ;
+
+    // set toc render function
+    if (this.renderToc != null) {
+      md.set({
+        tocCallback: (tocMarkdown, tocArray, tocHtml) => {
+          this.renderToc(tocHtml);
+        },
+      });
+    }
+  }
+
+  /**
+   * create Base64 encoded id
+   * @see https://qiita.com/satokaz/items/64582da4640898c4bf42
+   * @param {string} header
+   */
+  customSlugify(header) {
+    return encodeURIComponent(header.trim()
+      .toLowerCase()
+      .replace(/[\]\[\!\"\#\$\%\&\'\(\)\*\+\,\.\/\:\;\<\=\>\?\@\\\^\_\{\|\}\~]/g, '')
+      .replace(/\s+/g, '-')) // Replace spaces with hyphens
+      .replace(/\-+$/, ''); // Replace trailing hyphen
+  }
+}

+ 0 - 1
resource/styles/index.js

@@ -1,2 +1 @@
 import '../css/crowi.scss';
-import 'highlight.js/styles/github.css';

+ 84 - 24
yarn.lock

@@ -1394,6 +1394,10 @@ clone@^1.0.2:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.3.tgz#298d7e2231660f40c003c2ed3140decf3f53085f"
 
+clone@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.1.tgz#d217d1e961118e3ac9a4b8bba3285553bf647cdb"
+
 co@^4.6.0:
   version "4.6.0"
   resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@@ -1753,6 +1757,10 @@ csso@~2.3.1:
     clap "^1.0.9"
     source-map "^0.5.3"
 
+csv-to-markdown-table@^0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/csv-to-markdown-table/-/csv-to-markdown-table-0.4.0.tgz#9040fd58a7bb963f515652f31e48ead8ca516fc7"
+
 currently-unhandled@^0.4.1:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea"
@@ -2069,7 +2077,7 @@ enhanced-resolve@^3.4.0:
     object-assign "^4.0.1"
     tapable "^0.2.7"
 
-entities@^1.1.1:
+entities@^1.1.1, entities@~1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0"
 
@@ -2575,10 +2583,6 @@ get-func-name@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/get-func-name/-/get-func-name-2.0.0.tgz#ead774abee72e20409433a066366023dd6887a41"
 
-get-line-from-pos@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/get-line-from-pos/-/get-line-from-pos-1.0.0.tgz#e3ca483035eef374ad40fff4d43df3fa2da328b3"
-
 get-stdin@^4.0.1:
   version "4.0.1"
   resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-4.0.1.tgz#b968c6b0a04384324902e8bf1a5df32579a450fe"
@@ -2811,10 +2815,6 @@ he@1.1.1:
   version "1.1.1"
   resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd"
 
-highlight.js@^9.10.0:
-  version "9.12.0"
-  resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e"
-
 hmac-drbg@^1.0.0:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"
@@ -3358,6 +3358,12 @@ ldapjs@^1.0.1:
   optionalDependencies:
     dtrace-provider "^0.7.0"
 
+linkify-it@^2.0.0:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.0.3.tgz#d94a4648f9b1c179d64fa97291268bdb6ce9434f"
+  dependencies:
+    uc.micro "^1.0.1"
+
 livereload-js@^2.2.2:
   version "2.2.2"
   resolved "https://registry.yarnpkg.com/livereload-js/-/livereload-js-2.2.2.tgz#6c87257e648ab475bc24ea257457edcc1f8d0bc2"
@@ -3661,6 +3667,49 @@ map-values@^1.0.1:
   version "1.0.1"
   resolved "https://registry.yarnpkg.com/map-values/-/map-values-1.0.1.tgz#768b8e79c009bf2b64fee806e22a7b1c4190c990"
 
+markdown-it-emoji@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz#9bee0e9a990a963ba96df6980c4fddb05dfb4dcc"
+
+markdown-it-footnote@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/markdown-it-footnote/-/markdown-it-footnote-3.0.1.tgz#7f3730747cacc86e2fe0bf8a17a710f34791517a"
+
+markdown-it-mathjax@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-mathjax/-/markdown-it-mathjax-2.0.0.tgz#ae2b4f4c5c719a03f9e475c664f7b2685231d9e9"
+
+markdown-it-named-headers@^0.0.4:
+  version "0.0.4"
+  resolved "https://registry.yarnpkg.com/markdown-it-named-headers/-/markdown-it-named-headers-0.0.4.tgz#82efc28324240a6b1e77b9aae501771d5f351c1f"
+  dependencies:
+    string "^3.0.1"
+
+markdown-it-plantuml@^0.3.1:
+  version "0.3.1"
+  resolved "https://registry.yarnpkg.com/markdown-it-plantuml/-/markdown-it-plantuml-0.3.1.tgz#f338df4d691a5561364e65809b6812bcb3d8b047"
+
+markdown-it-task-lists@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-task-lists/-/markdown-it-task-lists-2.1.0.tgz#4594f750f70df053d1dad68024388007c1d20783"
+
+markdown-it-toc-and-anchor@^4.1.2:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/markdown-it-toc-and-anchor/-/markdown-it-toc-and-anchor-4.1.2.tgz#b271f694a70bf719e6b728056d7bd931d364214d"
+  dependencies:
+    clone "^2.1.0"
+    uslug "^1.0.4"
+
+markdown-it@^8.4.0:
+  version "8.4.0"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-8.4.0.tgz#e2400881bf171f7018ed1bd9da441dac8af6306d"
+  dependencies:
+    argparse "^1.0.7"
+    entities "~1.1.1"
+    linkify-it "^2.0.0"
+    mdurl "^1.0.1"
+    uc.micro "^1.0.3"
+
 marked-terminal@^1.6.2:
   version "1.7.0"
   resolved "https://registry.yarnpkg.com/marked-terminal/-/marked-terminal-1.7.0.tgz#c8c460881c772c7604b64367007ee5f77f125904"
@@ -3671,7 +3720,7 @@ marked-terminal@^1.6.2:
     lodash.assign "^4.2.0"
     node-emoji "^1.4.1"
 
-marked@^0.3.12, marked@^0.3.6:
+marked@^0.3.6:
   version "0.3.12"
   resolved "https://registry.yarnpkg.com/marked/-/marked-0.3.12.tgz#7cf25ff2252632f3fe2406bde258e94eee927519"
 
@@ -3694,6 +3743,10 @@ md5@^2.2.1:
     crypt "~0.0.1"
     is-buffer "~1.1.1"
 
+mdurl@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e"
+
 media-typer@0.3.0:
   version "0.3.0"
   resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
@@ -4344,10 +4397,6 @@ p-try@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/p-try/-/p-try-1.0.0.tgz#cbc79cdbaf8fd4228e13f621f2b1a237c1b207b3"
 
-pako@1.0.3:
-  version "1.0.3"
-  resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.3.tgz#5f515b0c6722e1982920ae8005eacb0b7ca73ccf"
-
 pako@~1.0.5:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
@@ -4562,13 +4611,6 @@ pkginfo@^0.4.0:
   version "0.4.1"
   resolved "https://registry.yarnpkg.com/pkginfo/-/pkginfo-0.4.1.tgz#b5418ef0439de5425fc4995042dced14fb2a84ff"
 
-plantuml-encoder@^1.2.4:
-  version "1.2.4"
-  resolved "https://registry.yarnpkg.com/plantuml-encoder/-/plantuml-encoder-1.2.4.tgz#5f0056f7c04bd76aeef420bfcddef339e9f44081"
-  dependencies:
-    pako "1.0.3"
-    utf8-bytes "0.0.1"
-
 postcss-calc@^5.2.0:
   version "5.3.1"
   resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-5.3.1.tgz#77bae7ca928ad85716e2fda42f261bf7c1d65b5e"
@@ -5809,6 +5851,10 @@ string-width@^2.0.0:
     is-fullwidth-code-point "^2.0.0"
     strip-ansi "^4.0.0"
 
+string@^3.0.1:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/string/-/string-3.3.3.tgz#5ea211cd92d228e184294990a6cc97b366a77cb0"
+
 string_decoder@^1.0.0, string_decoder@~1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab"
@@ -6046,6 +6092,10 @@ ua-parser-js@^0.7.9:
   version "0.7.17"
   resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.17.tgz#e9ec5f9498b9ec910e7ae3ac626a805c4d09ecac"
 
+uc.micro@^1.0.1, uc.micro@^1.0.3:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/uc.micro/-/uc.micro-1.0.5.tgz#0c65f15f815aa08b560a61ce8b4db7ffc3f45376"
+
 uglify-js@2.6.0:
   version "2.6.0"
   resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.6.0.tgz#25eaa1cc3550e39410ceefafd1cfbb6b6d15f001"
@@ -6124,6 +6174,10 @@ uniqs@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/uniqs/-/uniqs-2.0.0.tgz#ffede4b36b25290696e6e165d4a59edb998e6b02"
 
+"unorm@>= 1.0.0":
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/unorm/-/unorm-1.4.1.tgz#364200d5f13646ca8bcd44490271335614792300"
+
 unpipe@1.0.0, unpipe@~1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
@@ -6132,6 +6186,10 @@ url-join@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8"
 
+url-join@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/url-join/-/url-join-3.0.0.tgz#26e8113ace195ea30d0fc38186e45400f9cea672"
+
 url@0.10.3:
   version "0.10.3"
   resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64"
@@ -6146,9 +6204,11 @@ url@^0.11.0:
     punycode "1.3.2"
     querystring "0.2.0"
 
-utf8-bytes@0.0.1:
-  version "0.0.1"
-  resolved "https://registry.yarnpkg.com/utf8-bytes/-/utf8-bytes-0.0.1.tgz#116b025448c9b500081cdfbf1f4d6c6c37d8837d"
+uslug@^1.0.4:
+  version "1.0.4"
+  resolved "https://registry.yarnpkg.com/uslug/-/uslug-1.0.4.tgz#b9a22f0914e0a86140633dacc302e5f4fa450677"
+  dependencies:
+    unorm ">= 1.0.0"
 
 util-deprecate@~1.0.1:
   version "1.0.2"