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

Merge branch 'master' into imprv/display-side-scrollbar

# Conflicts:
#	lib/views/layout-growi/base/layout.html
Yuki Takei 7 лет назад
Родитель
Сommit
68562511ce
57 измененных файлов с 900 добавлено и 376 удалено
  1. 36 2
      CHANGES.md
  2. 3 1
      README.md
  3. 3 0
      lib/crowi/express-init.js
  4. 0 1
      lib/crowi/index.js
  5. 1 1
      lib/form/admin/securityPassportLdap.js
  6. 2 1
      lib/form/comment.js
  7. 4 3
      lib/locales/en-US/translation.json
  8. 4 3
      lib/locales/ja/translation.json
  9. 4 2
      lib/models/comment.js
  10. 2 0
      lib/models/config.js
  11. 5 2
      lib/models/external-account.js
  12. 28 22
      lib/models/page.js
  13. 4 3
      lib/routes/comment.js
  14. 1 0
      lib/routes/index.js
  15. 3 1
      lib/routes/login-passport.js
  16. 58 36
      lib/routes/page.js
  17. 10 0
      lib/service/passport.js
  18. 12 0
      lib/util/getToday.js
  19. 7 2
      lib/util/middlewares.js
  20. 13 0
      lib/util/templateChecker.js
  21. 1 1
      lib/util/xss.js
  22. 18 0
      lib/views/admin/widget/passport/ldap.html
  23. 1 26
      lib/views/layout-growi/widget/comments.html
  24. 33 20
      lib/views/modal/create_page.html
  25. 15 19
      lib/views/modal/create_template.html
  26. 1 1
      lib/views/widget/page_content.html
  27. 4 0
      lib/views/widget/page_list.html
  28. 1 1
      lib/views/widget/page_list_and_timeline.html
  29. 12 9
      lib/views/widget/page_tabs.html
  30. 5 4
      package.json
  31. 14 6
      resource/js/app.js
  32. 2 1
      resource/js/components/Page/RevisionBody.js
  33. 69 1
      resource/js/components/PageComment/Comment.js
  34. 190 0
      resource/js/components/PageComment/CommentForm.js
  35. 35 0
      resource/js/components/PageComment/CommentPreview.js
  36. 0 65
      resource/js/components/PageCommentFormBehavior.js
  37. 23 12
      resource/js/components/PageComments.js
  38. 8 0
      resource/js/components/PageList/PageListMeta.js
  39. 20 15
      resource/js/util/GrowiRenderer.js
  40. 0 72
      resource/js/util/LangProcessor/Template.js
  41. 82 0
      resource/js/util/PostProcessor/CrowiTemplate.js
  42. 0 9
      resource/js/util/PreProcessor/ImageExpander.js
  43. 3 2
      resource/js/util/interceptor/detach-code-blocks.js
  44. 17 0
      resource/js/util/markdown-it/blockdiag.js
  45. 0 15
      resource/js/util/markdown-it/common-plugins.js
  46. 11 0
      resource/js/util/markdown-it/footernote.js
  47. 13 0
      resource/js/util/markdown-it/task-lists.js
  48. 9 0
      resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss
  49. 7 0
      resource/styles/agile-admin/inverse/colors/_apply-colors.scss
  50. 0 5
      resource/styles/agile-admin/inverse/colors/blue-night.scss
  51. 3 0
      resource/styles/scss/_comment_growi.scss
  52. 8 0
      resource/styles/scss/_create-template.scss
  53. 0 1
      resource/styles/scss/_search.scss
  54. 55 0
      resource/styles/scss/_wiki.scss
  55. 1 0
      resource/styles/scss/style.scss
  56. 3 1
      test/models/page.test.js
  57. 36 10
      yarn.lock

+ 36 - 2
CHANGES.md

@@ -1,13 +1,47 @@
 CHANGES
 ========
 
-## 3.1.2-RC
+## 3.1.4-RC
 
-* Improvement: Add 'future' theme
+* Feature: Support [blockdiag](http://blockdiag.com)
+* Feature: Add `BLOCKDIAG_URI` environment variable
+* Fix: sanitize `#raw-text-original` content with 'entities'
+* Fix: page.rename api doesn't work
+* Support: Upgrade libs
+    * markdown-it-toc-and-anchor-with-slugid
+
+
+## 3.1.3
+
+* Feature: Write comment with Markdown
+* Improvement: Support some placeholders for template page
+* Improvement: Omit unnecessary response header
+* Improvement: Support LDAP attribute mappings for user's full name
+* Fix: HTML escaped characters in markdown are unescaped unexpectedly after page is saved
 * Fix: Posting to Slack doesn't work
     * Introduced by 3.1.0
+
+## 3.1.2
+
+* Feature: Template page
+* Improvement: Add 'future' theme
+* Improvement: Modify syntax for Crowi compatible template feature
+    * *before*
+        ~~~
+        ``` template:/page/name
+        page contents
+        ```
+        ~~~
+    * *after*
+        ~~~
+        ::: template:/page/name
+        page contents
+        :::
+        ~~~
+* Improvement: Escape iframe tag in block codes
 * Support: Upgrade libs
     * assets-webpack-plugin
+    * googleapis
     * react-clipboard.js
     * xss
 

+ 3 - 1
README.md

@@ -159,10 +159,12 @@ Environment Variables
     * NODE_ENV: `production` OR `development`.
     * PORT: Server port. default: `3000`
     * REDIS_URL: URI to connect to Redis (to session store).
-    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * ELASTICSEARCH_URI: URI to connect to Elasticearch.
+    * PLANTUML_URI: URI to connect to [PlantUML](http://plantuml.com/) server.
+    * BLOCKDIAG_URI: URI to connect to [blockdiag](http://http://blockdiag.com/) server.
     * PASSWORD_SEED: A password seed used by password hash generator.
     * SECRET_TOKEN: A secret key for verifying the integrity of signed cookies.
+    * SESSION_NAME: The name of the session ID cookie to set in the response by Express. default: `connect.sid`
     * FILE_UPLOAD: `aws` (default), `local`, `none`
 
 

+ 3 - 0
lib/crowi/express-init.js

@@ -47,6 +47,9 @@ module.exports = function(crowi, app) {
       overloadTranslationOptionHandler: i18nSprintf.overloadTranslationOptionHandler
     });
 
+  // omit unnecessary header
+  app.disable('x-powered-by');
+
   app.use(function(req, res, next) {
     var now = new Date()
       , baseUrl

+ 0 - 1
lib/crowi/index.js

@@ -389,7 +389,6 @@ Crowi.prototype.buildServer = function() {
   var express = require('express')()
     , env = this.node_env
     ;
-
   require('./express-init')(this, express);
 
   // import plugins

+ 1 - 1
lib/form/admin/securityPassportLdap.js

@@ -16,9 +16,9 @@ module.exports = form(
   field('settingForm[security:passport-ldap:bindDNPassword]'),
   field('settingForm[security:passport-ldap:searchFilter]'),
   field('settingForm[security:passport-ldap:attrMapUsername]'),
+  field('settingForm[security:passport-ldap:attrMapName]'),
   field('settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]').trim().toBooleanStrict(),
   field('settingForm[security:passport-ldap:groupSearchBase]'),
   field('settingForm[security:passport-ldap:groupSearchFilter]'),
   field('settingForm[security:passport-ldap:groupDnProperty]')
 );
-

+ 2 - 1
lib/form/comment.js

@@ -7,5 +7,6 @@ module.exports = form(
   field('commentForm.page_id').trim().required(),
   field('commentForm.revision_id').trim().required(),
   field('commentForm.comment').trim().required(),
-  field('commentForm.comment_position').trim().toInt()
+  field('commentForm.comment_position').trim().toInt(),
+  field('commentForm.is_markdown').trim().toBooleanStrict()
 );

+ 4 - 3
lib/locales/en-US/translation.json

@@ -231,11 +231,11 @@
       "create/edit": "Create/Edit Template page..",
       "select": "Select template page type"
     },
-    "local": {
+    "children": {
       "label": "Template for children",
       "desc": "Applies only to the same level pages which the template exists"
     },
-    "global": {
+    "decendants": {
       "label": "Template for descendants",
       "desc": "Applies to all decendant pages"
     }
@@ -351,7 +351,8 @@
       "search_filter_detail3": "If empty, the filter <code>(uid=&#123;&#123;username&#125;&#125;)</code> is used.",
       "search_filter_example1": "Match with 'uid' or 'mail'",
       "search_filter_example2": "Match with 'sAMAccountName' for Active Directory",
-      "username_detail": "Specification of mappings when creating new users",
+      "username_detail": "Specification of mappings for <code>username</code> when creating new users",
+      "name_detail": "Specification of mappings for <code>name</code> when creating new users",
       "Treat username matching as identical": "Automatically bind external accounts newly logged in to local accounts when <code>username</code> match",
   		"Treat username matching as identical_warn": "WARNING: Be aware of security because the system treats the same user as a match of <code>username</code>.",
       "group_search_base_DN": "Group Search Base DN",

+ 4 - 3
lib/locales/ja/translation.json

@@ -246,11 +246,11 @@
       "select": "テンプレートタイプを選択してください",
       "create/edit": "テンプレートページの作成/編集.."
     },
-    "local": {
+    "children": {
       "label": "同一階層テンプレート",
       "desc": "テンプレートページが存在する階層にのみ適応されます"
     },
-    "global": {
+    "decendants": {
       "label": "下位層テンプレート",
       "desc": "テンプレートページが存在する下位層のすべてのページに適応されます"
     }
@@ -368,7 +368,8 @@
       "search_filter_detail3": "空欄の場合 <code>(uid=&#123;&#123;username&#125;&#125;)</code> が使用されます。",
       "search_filter_example1": "'uid' または 'mail' に一致",
       "search_filter_example2": "'sAMAccountName' に一致 (Active Directory)",
-      "username_detail": "新規ユーザーの関連付けを設定",
+      "username_detail": "新規ユーザーのアカウント名(<code>username</code>)に関連付ける属性",
+      "name_detail": "新規ユーザーの表示名(<code>name</code>)に関連付ける属性",
       "Treat username matching as identical": "新規ログイン時、<code>username</code> が一致したローカルアカウントが存在した場合は自動的に紐付ける",
       "Treat username matching as identical_warn": "WARNING: <code>username</code> の一致を以て同一ユーザーであるとみなすので、セキュリティに注意してください",
       "group_search_base_DN": "グループ検索ベース DN",

+ 4 - 2
lib/models/comment.js

@@ -12,10 +12,11 @@ module.exports = function(crowi) {
     revision: { type: ObjectId, ref: 'Revision', index: true },
     comment: { type: String, required: true },
     commentPosition: { type: Number, default: -1 },
-    createdAt: { type: Date, default: Date.now }
+    createdAt: { type: Date, default: Date.now },
+    isMarkdown: { type: Boolean, default: false}
   });
 
-  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position) {
+  commentSchema.statics.create = function(pageId, creatorId, revisionId, comment, position, isMarkdown) {
     var Comment = this,
       commentPosition = position || -1;
 
@@ -28,6 +29,7 @@ module.exports = function(crowi) {
       newComment.revision = revisionId;
       newComment.comment = comment;
       newComment.commentPosition = position;
+      newComment.isMarkdown = isMarkdown || false;
 
       newComment.save(function(err, data) {
         if (err) {

+ 2 - 0
lib/models/config.js

@@ -59,6 +59,7 @@ module.exports = function(crowi) {
       'security:passport-ldap:bindDNPassword' : undefined,
       'security:passport-ldap:searchFilter' : undefined,
       'security:passport-ldap:attrMapUsername' : undefined,
+      'security:passport-ldap:attrMapName' : undefined,
       'security:passport-ldap:groupSearchBase' : undefined,
       'security:passport-ldap:groupSearchFilter' : undefined,
       'security:passport-ldap:groupDnProperty' : undefined,
@@ -464,6 +465,7 @@ module.exports = function(crowi) {
       isSavedStatesOfTabChanges: Config.isSavedStatesOfTabChanges(config),
       env: {
         PLANTUML_URI: env.PLANTUML_URI || null,
+        BLOCKDIAG_URI: env.BLOCKDIAG_URI || null,
         MATHJAX: env.MATHJAX || null,
       },
     };

+ 5 - 2
lib/models/external-account.js

@@ -68,7 +68,7 @@ class ExternalAccount {
    * @returns {Promise<ExternalAccount>}
    * @memberof ExternalAccount
    */
-  static findOrRegister(providerType, accountId, usernameToBeRegistered) {
+  static findOrRegister(providerType, accountId, usernameToBeRegistered, nameToBeRegistered) {
 
     return this.findOne({ providerType, accountId })
       .then(account => {
@@ -86,10 +86,13 @@ class ExternalAccount {
             if (user != null) {
               throw new DuplicatedUsernameException(`User '${usernameToBeRegistered}' already exists`, user);
             }
+            if (nameToBeRegistered == null) {
+              nameToBeRegistered = '';
+            }
 
             // create a new User with STATUS_ACTIVE
             debug(`ExternalAccount '${accountId}' is not found, it is going to be registered.`);
-            return User.createUser('', usernameToBeRegistered, undefined, undefined, undefined, User.STATUS_ACTIVE);
+            return User.createUser(nameToBeRegistered, usernameToBeRegistered, undefined, undefined, undefined, User.STATUS_ACTIVE);
           })
           .then(newUser => {
             return this.associate(providerType, accountId, newUser);

+ 28 - 22
lib/models/page.js

@@ -2,6 +2,7 @@ module.exports = function(crowi) {
   var debug = require('debug')('growi:models:page')
     , mongoose = require('mongoose')
     , escapeStringRegexp = require('escape-string-regexp')
+    , templateChecker = require('../util/templateChecker')
     , ObjectId = mongoose.Schema.Types.ObjectId
     , GRANT_PUBLIC = 1
     , GRANT_RESTRICTED = 2
@@ -96,6 +97,10 @@ module.exports = function(crowi) {
     return isPortalPath(this.path);
   };
 
+  pageSchema.methods.isTemplate = function() {
+    return templateChecker(this.path);
+  };
+
   pageSchema.methods.isCreator = function(userData) {
     // ゲスト閲覧の場合は userData に false が入る
     if (!userData) {
@@ -392,10 +397,10 @@ module.exports = function(crowi) {
 
   pageSchema.statics.isCreatableName = function(name) {
     var forbiddenPages = [
-      /\^|\$|\*|\+|\#/,
-      /^\/_.*/, // /_api/* and so on
-      /^\/\-\/.*/,
+      /\^|\$|\*|\+|#/,
+      /^\/-\/.*/,
       /^\/_r\/.*/,
+      /^\/_apix?(\/.*)?/,
       /^\/?https?:\/\/.+$/, // avoid miss in renaming
       /\/{2,}/,             // avoid miss in renaming
       /\s+\/\s+/,           // avoid miss in renaming
@@ -527,21 +532,21 @@ module.exports = function(crowi) {
     });
   };
 
-  // check if a given page has a local and global tempalte
+  // check if a given page has a children and decendants tempalte
   pageSchema.statics.checkIfTemplatesExist = function(path) {
     const Page = this;
     const pathList = generatePathsOnTree(path, []);
     const regexpList = pathList.map(path => new RegExp(`${path}/_{1,2}template`));
     let templateInfo = {
-      localTemplateExists: false,
-      globalTemplateExists: false,
+      childrenTemplateExists: false,
+      decendantsTemplateExists: false,
     };
 
     return Page
       .find({path: {$in: regexpList}})
       .then(templates => {
-        templateInfo.localTemplateExists = (assignTemplateByType(templates, path, '__') ? true : false);
-        templateInfo.globalTemplateExists = (assignGlobalTemplate(templates, path) ? true : false);
+        templateInfo.childrenTemplateExists = (assignTemplateByType(templates, path, '_') ? true : false);
+        templateInfo.decendantsTemplateExists = (assignTemplateByType(templates, path, '__') ? true : false);
 
         return templateInfo;
       });
@@ -570,11 +575,12 @@ module.exports = function(crowi) {
   };
 
   const generatePathsOnTree = (path, pathList) => {
+    pathList.push(path);
+
     if (path === '') {
       return pathList;
     }
 
-    pathList.push(path);
     const newPath = cutOffLastSlash(path);
 
     return generatePathsOnTree(newPath, pathList);
@@ -588,10 +594,10 @@ module.exports = function(crowi) {
     }
   };
 
-  const assignGlobalTemplate = (globalTemplates, path) => {
-    const globalTemplate = assignTemplateByType(globalTemplates, path, '_');
-    if (globalTemplate) {
-      return globalTemplate;
+  const assignDecendantsTemplate = (decendantsTemplates, path) => {
+    const decendantsTemplate = assignTemplateByType(decendantsTemplates, path, '__');
+    if (decendantsTemplate) {
+      return decendantsTemplate;
     }
 
     if (path === '') {
@@ -599,28 +605,28 @@ module.exports = function(crowi) {
     }
 
     const newPath = cutOffLastSlash(path);
-    return assignGlobalTemplate(globalTemplates, newPath);
+    return assignDecendantsTemplate(decendantsTemplates, newPath);
   };
 
   const fetchTemplate = (templates, templatePath) => {
     let templateBody;
     /**
-     * get local template
+     * get children template
      * __tempate: applicable only to immediate decendants
      */
-    const localTemplate = assignTemplateByType(templates, templatePath, '__');
+    const childrenTemplate = assignTemplateByType(templates, templatePath, '_');
 
     /**
-     * get global templates
+     * get decendants templates
      * _tempate: applicable to all pages under
      */
-    const globalTemplate = assignGlobalTemplate(templates, templatePath);
+    const decendantsTemplate = assignDecendantsTemplate(templates, templatePath);
 
-    if (localTemplate) {
-      templateBody =  localTemplate.revision.body;
+    if (childrenTemplate) {
+      templateBody =  childrenTemplate.revision.body;
     }
-    else if (globalTemplate) {
-      templateBody = globalTemplate.revision.body;
+    else if (decendantsTemplate) {
+      templateBody = decendantsTemplate.revision.body;
     }
 
     return templateBody;

+ 4 - 3
lib/routes/comment.js

@@ -62,8 +62,9 @@ module.exports = function(crowi, app) {
     var revisionId = form.revision_id;
     var comment = form.comment;
     var position = form.comment_position || -1;
+    var isMarkdown = form.is_markdown;
 
-    return Comment.create(pageId, req.user._id, revisionId, comment, position)
+    return Comment.create(pageId, req.user._id, revisionId, comment, position, isMarkdown)
       .then(function(createdComment) {
         createdComment.creator = req.user;
         return res.json(ApiResponse.success({comment: createdComment}));
@@ -92,11 +93,11 @@ module.exports = function(crowi, app) {
           return Page.updateCommentCount(comment.page);
         })
         .then(function() {
-          return res.json(ApiResponse.success({})); 
+          return res.json(ApiResponse.success({}));
         });
       })
       .catch(function(err) {
-        return res.json(ApiResponse.error(err)); 
+        return res.json(ApiResponse.error(err));
       });
 
   };

+ 1 - 0
lib/routes/index.js

@@ -167,6 +167,7 @@ module.exports = function(crowi, app) {
   app.post('/_api/pages.revertRemove' , loginRequired(crowi, app) , csrf, page.api.revertRemove); // (Avoid from API Token)
   app.post('/_api/pages.unlink'       , loginRequired(crowi, app) , csrf, page.api.unlink); // (Avoid from API Token)
   app.post('/_api/pages.duplicate'    , accessTokenParser, loginRequired(crowi, app), csrf, page.api.duplicate);
+  app.get('/_api/pages.templates'   , accessTokenParser , loginRequired(crowi, app, false) , page.api.templates);
   app.get('/_api/comments.get'        , accessTokenParser , loginRequired(crowi, app, false) , comment.api.get);
   app.post('/_api/comments.add'       , form.comment, accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.add);
   app.post('/_api/comments.remove'    , accessTokenParser , loginRequired(crowi, app) , csrf, comment.api.remove);

+ 3 - 1
lib/routes/login-passport.js

@@ -108,10 +108,12 @@ module.exports = function(crowi, app) {
       const ldapAccountId = passportService.getLdapAccountIdFromReq(req);
 
       const attrMapUsername = passportService.getLdapAttrNameMappedToUsername();
+      const attrMapName = passportService.getLdapAttrNameMappedToName();
       const usernameToBeRegistered = ldapAccountInfo[attrMapUsername];
+      const nameToBeRegistered = ldapAccountInfo[attrMapName];
 
       // find or register(create) user
-      ExternalAccount.findOrRegister('ldap', ldapAccountId, usernameToBeRegistered)
+      ExternalAccount.findOrRegister('ldap', ldapAccountId, usernameToBeRegistered, nameToBeRegistered)
         .catch((err) => {
           if (err.name === 'DuplicatedUsernameException') {
             // get option

+ 58 - 36
lib/routes/page.js

@@ -13,6 +13,8 @@ module.exports = function(crowi, app) {
     , ApiResponse = require('../util/apiResponse')
     , interceptorManager = crowi.getInterceptorManager()
     , pagePathUtil = require('../util/pagePathUtil')
+    , swig = require('swig-templates')
+    , getToday = require('../util/getToday')
 
     , actions = {};
 
@@ -252,14 +254,11 @@ module.exports = function(crowi, app) {
       tree: [],
       pageRelatedGroup: null,
       template: null,
-      localTemplateExists: false,
-      globalTemplateExists: false,
     };
 
     var pageTeamplate = 'customlayout-selector/page';
 
     var isRedirect = false;
-    var originalPath = path;
     Page.findPage(path, req.user, req.query.revision)
     .then(function(page) {
       debug('Page found', page._id, page.path);
@@ -283,11 +282,7 @@ module.exports = function(crowi, app) {
           renderVars.tree = tree;
         })
         .then(function() {
-          return Page.checkIfTemplatesExist(originalPath);
-        })
-        .then(function(templateInfo) {
-          renderVars.localTemplateExists = templateInfo.localTemplateExists;
-          renderVars.globalTemplateExists = templateInfo.globalTemplateExists;
+          return Page.checkIfTemplatesExist(path);
         })
         .then(() => {
           return PageGroupRelation.findByPage(renderVars.page);
@@ -333,8 +328,12 @@ module.exports = function(crowi, app) {
     .catch(function(err) {
       pageTeamplate = 'customlayout-selector/not_found';
 
-      return Page.findTemplate(originalPath)
+      return Page.findTemplate(path)
         .then(template => {
+          if (template) {
+            template = replacePlaceholders(template, req);
+          }
+
           renderVars.template = template;
         });
     })
@@ -371,6 +370,17 @@ module.exports = function(crowi, app) {
     });
   };
 
+  const replacePlaceholders = (template, req) => {
+    const definitions = {
+      pagepath: getPathFromRequest(req),
+      username: req.user.name,
+      today: getToday(),
+    };
+    const compiledTemplate = swig.compile(template);
+
+    return compiledTemplate(definitions);
+  };
+
   actions.deletedPageListShow = function(req, res) {
     var path = '/trash' + getPathFromRequest(req);
     var limit = 50;
@@ -443,16 +453,22 @@ module.exports = function(crowi, app) {
   function renderPage(pageData, req, res) {
     // create page
     if (!pageData) {
-      Page.findTemplate(getPathFromRequest(req))
-      .then((template) => {
-        return res.render('customlayout-selector/not_found', {
-          author: {},
-          page: false,
-          template: template,
+      const path = getPathFromRequest(req);
+      return Page.findTemplate(path)
+        .then(template => {
+          if (template) {
+            template = replacePlaceholders(template, req);
+          }
+
+          return res.render('customlayout-selector/not_found', {
+            author: {},
+            page: false,
+            template,
+          });
         });
-      });
     }
 
+
     if (pageData.redirectTo) {
       return res.redirect(encodeURI(pageData.redirectTo + '?redirectFrom=' + pagePathUtil.encodePagePath(pageData.path)));
     }
@@ -462,8 +478,6 @@ module.exports = function(crowi, app) {
       page: pageData,
       revision: pageData.revision || {},
       author: pageData.revision.author || false,
-      localTemplateExists: false,
-      globalTemplateExists: false,
     };
     var userPage = isUserPage(pageData.path);
     var userData = null;
@@ -508,12 +522,6 @@ module.exports = function(crowi, app) {
       }
     }).then(function() {
       return interceptorManager.process('beforeRenderPage', req, res, renderVars);
-    }).then(function() {
-      return Page.checkIfTemplatesExist(pageData.path)
-        .then(function(templateInfo) {
-          renderVars.localTemplateExists = templateInfo.localTemplateExists;
-          renderVars.globalTemplateExists = templateInfo.globalTemplateExists;
-        });
     }).then(function() {
       var defaultPageTeamplate = 'customlayout-selector/page';
       if (userData) {
@@ -669,13 +677,6 @@ module.exports = function(crowi, app) {
         }
       }
 
-      return res.redirect(redirectPath);
-    }).catch(function(err) {
-      debug('Page create or edit error.', err);
-      if (pageData && !req.form.isValid) {
-        return renderPage(pageData, req, res);
-      }
-
       return res.redirect(redirectPath);
     });
   };
@@ -1105,9 +1106,10 @@ module.exports = function(crowi, app) {
 
     Page.findPageByPath(newPagePath)
     .then(function(page) {
-      // if page found, cannot cannot rename to that path
-      return res.json(ApiResponse.error(`このページ名は作成できません (${newPagePath})。ページが存在します。`));
-    }).catch(function(err) {
+      if (page != null) {
+        // if page found, cannot cannot rename to that path
+        return res.json(ApiResponse.error(`このページ名は作成できません (${newPagePath})。ページが存在します。`));
+      }
 
       Page.findPageById(pageId)
       .then(function(pageData) {
@@ -1123,12 +1125,14 @@ module.exports = function(crowi, app) {
           return Page.rename(pageData, newPagePath, req.user, options);
         }
 
-      }).then(function() {
+      })
+      .then(function() {
         var result = {};
         result.page = page;
 
         return res.json(ApiResponse.success(result));
-      }).catch(function(err) {
+      })
+      .catch(function(err) {
         return res.json(ApiResponse.error('Failed to update page.'));
       });
     });
@@ -1185,5 +1189,23 @@ module.exports = function(crowi, app) {
     });
   };
 
+  /**
+   * @api {get} /pages.templates Check if templates exist for page
+   * @apiName FindTemplates
+   * @apiGroup Page
+   *
+   * @apiParam {String} path
+   */
+  api.templates = function(req, res) {
+    const pagePath = req.query.path;
+    const templateFinder = Page.checkIfTemplatesExist(pagePath);
+
+    templateFinder.then(function(templateInfo) {
+      return res.json(ApiResponse.success(templateInfo));
+    }).catch(function(err) {
+      return res.json(ApiResponse.error(err));
+    });
+  };
+
   return actions;
 };

+ 10 - 0
lib/service/passport.js

@@ -137,6 +137,16 @@ class PassportService {
     const config = this.crowi.config;
     return config.crowi['security:passport-ldap:attrMapUsername'] || 'uid';
   }
+  /**
+   * return attribute name for mapping to name of Crowi DB
+   *
+   * @returns
+   * @memberof PassportService
+   */
+  getLdapAttrNameMappedToName() {
+    const config = this.crowi.config;
+    return config.crowi['security:passport-ldap:attrMapName'] || '';
+  }
 
   /**
    * CAUTION: this method is capable to use only when `req.body.loginForm` is not null

+ 12 - 0
lib/util/getToday.js

@@ -0,0 +1,12 @@
+/**
+ * getToday
+ */
+
+module.exports = function() {
+  const today = new Date();
+  const month = ('0' + (today.getMonth() + 1)).slice(-2);
+  const day = ('0' + today.getDate()).slice(-2);
+  const dateString = today.getFullYear() + '/' + month + '/' + day;
+
+  return dateString;
+};

+ 7 - 2
lib/util/middlewares.js

@@ -1,5 +1,6 @@
-var debug = require('debug')('growi:lib:middlewares');
-var md5 = require('md5');
+const debug = require('debug')('growi:lib:middlewares');
+const md5 = require('md5');
+const entities = require('entities');
 
 exports.csrfKeyGenerator = function(crowi, app) {
   return function(req, res, next) {
@@ -178,6 +179,10 @@ exports.swigFilters = function(app, swig) {
       }
     });
 
+    swig.setFilter('sanitize', function(string) {
+      return entities.encodeHTML(string);
+    });
+
     next();
   };
 };

+ 13 - 0
lib/util/templateChecker.js

@@ -0,0 +1,13 @@
+/**
+ * templateChecker
+ */
+
+module.exports = function(path) {
+  'use strict';
+
+  if (path.match(/.*\/_{1,2}template$/)) {
+    return true;
+  }
+
+  return false;
+};

+ 1 - 1
lib/util/xss.js

@@ -7,7 +7,7 @@ class Xss {
     let option = {
       stripIgnoreTag: true,
       css: false,
-      escapeHtml: (html) => html,
+      escapeHtml: (html) => html,   // resolve https://github.com/weseek/growi/issues/221
     };
     if (isAllowAllAttrs) {
       // allow all attributes

+ 18 - 0
lib/views/admin/widget/passport/ldap.html

@@ -120,6 +120,7 @@
       <h4>Attribute Mapping ({{ t("security_setting.optional") }})</h4>
 
       <div class="form-group">
+        <div class="row">
         <label for="settingForm[security:passport-ldap:attrMapUsername]" class="col-xs-3 control-label">username</label>
         <div class="col-xs-6">
           <input class="form-control" type="text" placeholder="Default: uid"
@@ -129,7 +130,11 @@
               {{ t("security_setting.ldap.username_detail") }}
             </small>
           </p>
+        </div>
+        </div>
 
+        <div class="row">
+        <div class="col-xs-6 col-xs-offset-3">
           <div class="checkbox checkbox-info">
             <input type="checkbox" id="cbSameUsernameTreatedAsIdenticalUser" name="settingForm[security:passport-ldap:isSameUsernameTreatedAsIdenticalUser]" value="1"
                 {% if settingForm['security:passport-ldap:isSameUsernameTreatedAsIdenticalUser'] %}checked{% endif %} />
@@ -143,8 +148,21 @@
             </p>
           </div>
         </div>
+        </div>
       </div>
 
+      <div class="row">
+        <label for="settingForm[security:passport-ldap:attrMapName]" class="col-xs-3 control-label">name</label>
+        <div class="col-xs-6">
+          <input class="form-control" type="text"
+              name="settingForm[security:passport-ldap:attrMapName]" value="{{ settingForm['security:passport-ldap:attrMapName'] || '' }}">
+          <p class="help-block">
+            <small>
+              {{ t("security_setting.ldap.name_detail") }}
+            </small>
+          </p>
+        </div>
+        </div>
 
       <h4>{{ t("security_setting.ldap.group_search_filter") }} ({{ t("security_setting.optional") }})</h4>
 

+ 1 - 26
lib/views/layout-growi/widget/comments.html

@@ -19,32 +19,7 @@
     </div>
 
     {% if page and not page.isDeleted() %}
-    <form class="form page-comment-form" id="page-comment-form" onsubmit="return false;">
-      <div class="comment-form">
-        <div class="comment-form-user">
-            <img src="{{ user|picture }}" class="picture img-circle" width="25" alt="{{ user.name }}" title="{{ user.name }}" />
-        </div>
-        <div class="comment-form-main">
-          <div class="comment-write" id="comment-write">
-            <textarea class="comment-form-comment form-control" id="comment-form-comment" name="commentForm[comment]"
-                required placeholder="Write comments here..." {% if not user %}disabled{% endif %}></textarea>
-          </div>
-          <div class="comment-submit">
-            <input type="hidden" name="_csrf" value="{{ csrf() }}">
-            <input type="hidden" name="commentForm[page_id]" value="{{ page._id.toString() }}">
-            <input type="hidden" name="commentForm[revision_id]" value="{{ revision._id.toString() }}">
-            <div class="pull-right">
-              <span class="text-danger" id="comment-form-message"></span>
-              <button type="submit" id="comment-form-button" class="fcbtn btn btn-sm btn-outline btn-rounded btn-primary btn-1b" {% if not user %}disabled{% endif %}>
-                Comment
-              </button>
-            </div>
-            <div class="clearfix"></div>
-          </div>
-        </div>
-      </div>
-    </form>
-    <div id="page-comment-form-behavior"></div>
+    <div id="page-comment-write"></div>
     {% endif %}
 
   </div>

+ 33 - 20
lib/views/modal/create_page.html

@@ -44,19 +44,21 @@
           </fieldset>
         </form>
 
-        <div id = "template-form" class="row form-horizontal m-t-15">
+        <div id="template-form" class="row form-horizontal m-t-15">
           <fieldset class="col-xs-12">
             <legend>{{ t('template.modal_label.Create template under', parentPath(path)) }}</legend>
             <div class="d-flex create-page-input-container">
               <div class="create-page-input-row d-flex align-items-center">
-                <select id="template-type" class="page-name-input form-control">
-                  <option value="" disabled selected>{{ t('template.option_label.select') }}</option>
-                  <option value="local">{{ t('template.local.label') }}(__template) - {{ t('template.local.desc') }}</option>
-                  <option value="global">{{ t('template.global.label') }}(_template) - {{ t('template.global.desc') }}</option>
+                <select id="template-type" class="form-control selectpicker" title="{{ t('template.option_label.select') }}">
+                  <option value="children" data-subtext="- {{ t('template.children.desc') }}">{{ t('template.children.label') }}(_template)</option>
+                  <option value="decentants" data-subtext="- {{ t('template.decendants.desc') }}">{{ t('template.decendants.label') }}(__template)</option>
                 </select>
               </div>
               <div class="create-page-button-container">
-                  <a id="link-to-template" href="{{ page.path || path }}"><button class="fcbtn btn btn-outline btn-rounded btn-primary btn-1b"><i class="icon-fw icon-doc"></i>{{ t('Create') }}</button></a>
+                <a id="link-to-template" href="{{ page.path || path }}" class="fcbtn btn btn-outline btn-rounded btn-primary btn-1b disabled">
+                  <i class="icon-fw icon-doc"></i>
+                  <span id="create-template-button-link">{{ t('Create') }}/{{ t('Edit') }}</span>
+                </a>
               </div>
             </div>
           </fieldset>
@@ -68,22 +70,33 @@
   </div><!-- /.modal-dialog -->
 </div><!-- /.modal -->
 <script>
-  if($("#create-page")) {
-    let pagePath = $("#link-to-template").attr("href");
+  let buttonTextChildren;
+  let buttonTextDecendants;
+  let pagePath = $("#link-to-template").attr("href");
 
-    if (pagePath.endsWith("/")) {
+  if (pagePath.endsWith("/")) {
       pagePath = pagePath.slice(0, -1);
-    };
+  };
 
-    $("#template-type").on("change", () => {
-      if ($("#template-type").val() === "local") {
-        href = pagePath + "/__template#edit-form";
-        $("#link-to-template").attr("href", href);
-      }
-      else if ($("#template-type").val() === "global") {
-        href = pagePath + "/_template#edit-form";
-        $("#link-to-template").attr("href", href);
-      };
+  $.get(`/_api/pages.templates?path=${pagePath}`)
+    .then(templateInfo => {
+      buttonTextChildren = templateInfo.childrenTemplateExists ? `{{ t('Edit') }}` : `{{ t('Create') }}`;
+      buttonTextDecendants = templateInfo.decendantsTemplateExists ? `{{ t('Edit') }}` : `{{ t('Create') }}`;
     });
-  };
+
+  $("#template-type").on("change", () => {
+    // enable button
+    $('#link-to-template').removeClass("disabled");
+
+    if ($("#template-type").val() === "children") {
+      href = pagePath + "/_template#edit-form";
+      $("#link-to-template").attr("href", href);
+      $('#create-template-button-link').text(buttonTextChildren);
+    }
+    else if ($("#template-type").val() === "decentants") {
+      href = pagePath + "/__template#edit-form";
+      $("#link-to-template").attr("href", href);
+      $('#create-template-button-link').text(buttonTextDecendants);
+    };
+  });
 </script>

+ 15 - 19
lib/views/modal/create_template.html

@@ -1,4 +1,4 @@
-<div class="modal" id="create-template">
+<div class="modal create-template" id="create-template">
   <div class="modal-dialog">
     <div class="modal-content">
 
@@ -11,34 +11,30 @@
           <label class="mb-4">{{ t('template.modal_label.Create template under', page.path ) }}</label>
           <div class="row">
             <div class="col-sm-6">
-              <div class="panel panel-default">
-                <div class="panel-heading">{{ t('template.local.label') }}</div>
+              <div class="panel panel-default panel-select-template">
+                <div class="panel-heading">{{ t('template.children.label') }}</div>
                 <div class="panel-body">
-                  <p class="text-center"><code>__template</code></p>
-                  <p class="help-block text-center"><small>{{ t('template.local.desc') }}</small></p>
+                  <p class="text-center"><code>_template</code></p>
+                  <p class="help-block text-center"><small>{{ t('template.children.desc') }}</small></p>
                 </div>
                 <div class="panel-footer text-center">
-                  {% if localTemplateExists %}
-                  <a href="{{ page.path }}/__template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Edit') }}</button></a>
-                  {% else %}
-                  <a href="{{ page.path }}/__template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Create') }}</button></a>
-                  {% endif %}
+                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}_template#edit-form"
+                      class="btn btn-sm btn-primary" id="template-button-children">
+                  </a>
                 </div>
               </div>
             </div>
             <div class="col-sm-6">
-              <div class="panel panel-default">
-                <div class="panel-heading">{{ t('template.global.label') }}</div>
+              <div class="panel panel-default panel-select-template">
+                <div class="panel-heading">{{ t('template.decendants.label') }}</div>
                 <div class="panel-body">
-                  <p class="text-center"><code>_template</code></p>
-                  <p class="help-block text-center"><small>{{ t('template.global.desc') }}</small></p>
+                  <p class="text-center"><code>__template</code></p>
+                  <p class="help-block text-center"><small>{{ t('template.decendants.desc') }}</small></p>
                 </div>
                 <div class="panel-footer text-center">
-                  {% if globalTemplateExists %}
-                  <a href="{{ page.path }}/_template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Edit') }}</button></a>
-                  {% else %}
-                  <a href="{{ page.path }}/_template#edit-form"><button class="btn btn-sm btn-primary">{{ t('Create') }}</button></a>
-                  {% endif %}
+                  <a href="{% if page.path.endsWith('/') %}{{ page.path }}{% else %}{{ page.path}}/{% endif %}__template#edit-form"
+                      class="btn btn-sm btn-primary" id="template-button-decendants">
+                  </a>
                 </div>
               </div>
             </div>

+ 1 - 1
lib/views/widget/page_content.html

@@ -15,7 +15,7 @@
   <div class="tab-content">
 
     {% if page %}
-      <script type="text/template" id="raw-text-original">{{ revision.body }}</script>
+      <script type="text/template" id="raw-text-original">{{ revision.body.toString() | sanitize }}</script>
 
       {# formatted text #}
       <div class="tab-pane {% if not req.body.pageForm %}active{% endif %}" id="revision-body">

+ 4 - 0
lib/views/widget/page_list.html

@@ -19,6 +19,10 @@
       <span class="label label-info">PORTAL</span>
     {% endif  %}
 
+    {% if page.isTemplate() %}
+      <span class="label label-info">TMPL</span>
+    {% endif  %}
+
     {% if page.commentCount > 0 %}
     <span>
       <i class="icon-bubble"></i>{{ page.commentCount }}

+ 1 - 1
lib/views/widget/page_list_and_timeline.html

@@ -33,7 +33,7 @@
             <div class="revision-body wiki"></div>
           </div>
         </div>
-        <script type="text/template">{{ page.revision.body }}</script>
+        <script type="text/template">{{ page.revision.body.toString() | sanitize }}</script>
       </div>
       <hr>
       {% endfor %}

+ 12 - 9
lib/views/widget/page_tabs.html

@@ -22,32 +22,35 @@
     Right Tabs
   #}
   {% if !isTrashPage() %}
-    {% if not isPortal %}
+    {% if isPortal %}
     <li class="nav-main-right-tab dropdown pull-right">
       <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
-        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
-        <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
-        <li class="divider"></li>
         <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>
-        {% if isDeletablePage() %}
+        {% if ('/' !== path) %}
         <li class="divider"></li>
-        <li><a href="#" data-target="#deletePage" data-toggle="modal"><i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}</a></li>
+        <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> {{ t('Unportalize') }}</a></li>
         {% endif %}
       </ul>
     </li>
-    {% elseif ('/' !== path) %}
+    {% else %}
     <li class="nav-main-right-tab dropdown pull-right">
       <a class="dropdown-toggle {% if not user %}dropdown-disabled{% endif %}" {% if user %}data-toggle="dropdown" href="#"{% endif %}>
         <i class="icon-options-vertical"></i>
       </a>
       <ul class="dropdown-menu">
-        <li><a href="#" data-target="#unportalize" data-toggle="modal"><i class="fa fa-share"></i> {{ t('Unportalize') }}</a></li>
+        <li><a href="#" data-target="#renamePage" data-toggle="modal"><i class="icon-fw icon-action-redo"></i> {{ t('Move') }}</a></li>
+        <li><a href="#" data-target="#duplicatePage" data-toggle="modal"><i class="icon-fw icon-docs"></i> {{ t('Duplicate') }}</a></li>
+        <li class="divider"></li>
+        <li><a href="#" data-target="#create-template" data-toggle="modal"><i class="icon-fw icon-magic-wand"></i> {{ t('template.option_label.create/edit') }}</a></li>
+        {% if isDeletablePage() %}
+        <li class="divider"></li>
+        <li><a href="#" data-target="#deletePage" data-toggle="modal"><i class="icon-fw icon-fire text-danger"></i> {{ t('Delete') }}</a></li>
+        {% endif %}
       </ul>
     </li>
-    {% endif %}
   {% endif %}
 
   <li class="nav-main-right-tab pull-right">

+ 5 - 4
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "3.1.2-RC",
+  "version": "3.1.4-RC",
   "description": "Team collaboration software using markdown",
   "tags": [
     "wiki",
@@ -65,6 +65,7 @@
     "cookie-parser": "^1.4.3",
     "cross-env": "^5.0.5",
     "csrf": "~3.0.3",
+    "diff": "^3.5.0",
     "elasticsearch": "^15.0.0",
     "entities": "^1.1.1",
     "env-cmd": "^8.0.1",
@@ -75,7 +76,7 @@
     "express-sanitizer": "^1.0.4",
     "express-session": "~1.15.0",
     "express-webpack-assets": "^0.1.0",
-    "googleapis": "^30.0.0",
+    "googleapis": "^31.0.2",
     "graceful-fs": "^4.1.11",
     "growi-pluginkit": "^1.1.0",
     "i18next": "^11.1.1",
@@ -130,7 +131,6 @@
     "css-loader": "^0.28.0",
     "csv-to-markdown-table": "^0.4.0",
     "date-fns": "^1.29.0",
-    "diff": "^3.3.0",
     "diff2html": "^2.3.3",
     "eazy-logger": "^3.0.2",
     "eslint": "^4.19.1",
@@ -143,13 +143,14 @@
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "markdown-it": "^8.4.0",
+    "markdown-it-blockdiag": "^1.0.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": "^1.0.0",
     "markdown-it-task-lists": "^2.1.0",
-    "markdown-it-toc-and-anchor-with-slugid": "^1.1.3",
+    "markdown-it-toc-and-anchor-with-slugid": "^1.1.4",
     "markdown-table": "^1.1.1",
     "metismenu": "^2.7.4",
     "mocha": "^5.0.0",

+ 14 - 6
resource/js/app.js

@@ -18,7 +18,7 @@ import Page             from './components/Page';
 import PageListSearch   from './components/PageListSearch';
 import PageHistory      from './components/PageHistory';
 import PageComments     from './components/PageComments';
-import PageCommentFormBehavior from './components/PageCommentFormBehavior';
+import CommentForm from './components/PageComment/CommentForm';
 import PageAttachment   from './components/PageAttachment';
 import SeenUserList     from './components/SeenUserList';
 import RevisionPath     from './components/Page/RevisionPath';
@@ -116,7 +116,7 @@ const componentMappings = {
 };
 // additional definitions if data exists
 if (pageId) {
-  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} />;
+  componentMappings['page-comments-list'] = <PageComments pageId={pageId} revisionId={pageRevisionId} revisionCreatedAt={pageRevisionCreatedAt} crowi={crowi} crowiOriginRenderer={crowiRenderer} />;
   componentMappings['page-attachment'] = <PageAttachment pageId={pageId} pageContent={pageContent} crowi={crowi} />;
 }
 if (pagePath) {
@@ -134,10 +134,18 @@ Object.keys(componentMappings).forEach((key) => {
   }
 });
 
-// render components with refs to another component
-const elem = document.getElementById('page-comment-form-behavior');
-if (elem) {
-  ReactDOM.render(<PageCommentFormBehavior crowi={crowi} pageComments={componentInstances['page-comments-list']} />, elem);
+// render comment form
+const writeCommentElem = document.getElementById('page-comment-write');
+if (writeCommentElem) {
+  const pageCommentsElem = componentInstances['page-comments-list'];
+  const postCompleteHandler = (comment) => {
+    if (pageCommentsElem != null) {
+      pageCommentsElem.retrieveData();
+    }
+  };
+  ReactDOM.render(
+    <CommentForm crowi={crowi} pageId={pageId} revisionId={pageRevisionId} onPostComplete={postCompleteHandler} crowiRenderer={crowiRenderer}/>,
+    writeCommentElem);
 }
 
 /*

+ 2 - 1
resource/js/components/Page/RevisionBody.js

@@ -51,7 +51,7 @@ export default class RevisionBody extends React.Component {
             this.props.inputRef(elm);
           }
         }}
-        className="wiki" dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
+        className={'wiki ' + this.props.additionalClassName} dangerouslySetInnerHTML={this.generateInnerHtml(this.props.html)}>
       </div>
     );
   }
@@ -63,4 +63,5 @@ RevisionBody.propTypes = {
   isMathJaxEnabled: PropTypes.bool,
   renderMathJaxOnInit: PropTypes.bool,
   renderMathJaxInRealtime: PropTypes.bool,
+  additionalClassName: PropTypes.string,
 };

+ 69 - 1
resource/js/components/PageComment/Comment.js

@@ -3,6 +3,8 @@ import PropTypes from 'prop-types';
 
 import dateFnsFormat from 'date-fns/format';
 
+import RevisionBody from '../Page/RevisionBody';
+
 import ReactUtils from '../ReactUtils';
 import UserPicture from '../User/UserPicture';
 
@@ -19,11 +21,30 @@ export default class Comment extends React.Component {
   constructor(props) {
     super(props);
 
+    this.state = {
+      html: '',
+    };
+
     this.isCurrentUserIsAuthor = this.isCurrentUserEqualsToAuthor.bind(this);
     this.isCurrentRevision = this.isCurrentRevision.bind(this);
     this.getRootClassName = this.getRootClassName.bind(this);
     this.getRevisionLabelClassName = this.getRevisionLabelClassName.bind(this);
     this.deleteBtnClickedHandler = this.deleteBtnClickedHandler.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+  }
+
+  componentWillMount() {
+    this.renderHtml(this.props.comment.comment);
+  }
+
+  componentWillReceiveProps(nextProps) {
+    this.renderHtml(nextProps.comment.comment);
+  }
+
+  //not used
+  setMarkdown(markdown) {
+    this.setState({ markdown });
+    this.renderHtml(markdown);
   }
 
   isCurrentUserEqualsToAuthor() {
@@ -48,13 +69,58 @@ export default class Comment extends React.Component {
     this.props.deleteBtnClicked(this.props.comment);
   }
 
+  renderRevisionBody() {
+    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}
+          additionalClassName="comment" />
+    );
+  }
+
+  renderHtml(markdown) {
+    var context = {
+      markdown,
+      dom: this.revisionBodyElement,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRenderComment', 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);
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderCommentHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postRenderCommentHtml', context));
+
+  }
+
   render() {
     const comment = this.props.comment;
     const creator = comment.creator;
+    const isMarkdown = comment.isMarkdown;
 
     const rootClassName = this.getRootClassName();
     const commentDate = dateFnsFormat(comment.createdAt, 'YYYY/MM/DD HH:mm');
-    const commentBody = ReactUtils.nl2br(comment.comment);
+    const commentBody = isMarkdown ? this.renderRevisionBody(): ReactUtils.nl2br(comment.comment);
     const creatorsPage = `/user/${creator.username}`;
     const revHref = `?revision=${comment.revision}`;
     const revFirst8Letters = comment.revision.substr(-8);
@@ -90,4 +156,6 @@ Comment.propTypes = {
   currentRevisionId: PropTypes.string.isRequired,
   currentUserId: PropTypes.string.isRequired,
   deleteBtnClicked: PropTypes.func.isRequired,
+  crowi: PropTypes.object.isRequired,
+  crowiRenderer: PropTypes.object.isRequired,
 };

+ 190 - 0
resource/js/components/PageComment/CommentForm.js

@@ -0,0 +1,190 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ReactUtils from '../ReactUtils';
+
+import CommentPreview from '../PageComment/CommentPreview';
+
+import Button from 'react-bootstrap/es/Button';
+import Tab from 'react-bootstrap/es/Tab';
+import Tabs from 'react-bootstrap/es/Tabs';
+import UserPicture from '../User/UserPicture';
+
+/**
+ *
+ * @author Yuki Takei <yuki@weseek.co.jp>
+ *
+ * @export
+ * @class Comment
+ * @extends {React.Component}
+ */
+
+export default class CommentForm extends React.Component {
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      comment: '',
+      isMarkdown: true,
+      html: '',
+      key: 1,
+    };
+
+    this.updateState = this.updateState.bind(this);
+    this.postComment = this.postComment.bind(this);
+    this.renderHtml = this.renderHtml.bind(this);
+    this.handleSelect = this.handleSelect.bind(this);
+  }
+
+  updateState(event) {
+    const target = event.target;
+    const value = target.type === 'checkbox' ? target.checked : target.value;
+    const name = target.name;
+
+    this.setState({
+      [name]: value
+    });
+  }
+
+  handleSelect(key) {
+    this.setState({ key });
+    this.renderHtml(this.state.comment);
+  }
+
+  /**
+   * Load data of comments and rerender <PageComments />
+   */
+  postComment(event) {
+    event.preventDefault();
+    this.props.crowi.apiPost('/comments.add', {
+      commentForm: {
+        comment: this.state.comment,
+        _csrf: this.props.crowi.csrfToken,
+        page_id: this.props.pageId,
+        revision_id: this.props.revisionId,
+        is_markdown: this.state.isMarkdown,
+      }
+    })
+      .then((res) => {
+        if (this.props.onPostComplete != null) {
+          this.props.onPostComplete(res.comment);
+        }
+        this.setState({
+          comment: '',
+          isMarkdown: true,
+          html: '',
+          key: 1,
+        });
+      });
+  }
+
+  getCommentHtml() {
+    return (
+      <CommentPreview
+        html={this.state.html}
+        inputRef={el => this.previewElement = el}/>
+    );
+  }
+
+  renderHtml(markdown) {
+    var context = {
+      markdown,
+      dom: this.previewElement,
+    };
+
+    const crowiRenderer = this.props.crowiRenderer;
+    const interceptorManager = this.props.crowi.interceptorManager;
+    interceptorManager.process('preRenderCommnetPreview', 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);
+      })
+      .then(() => interceptorManager.process('postPostProcess', context))
+      .then(() => interceptorManager.process('preRenderCommentPreviewHtml', context))
+      .then(() => {
+        this.setState({ html: context.parsedHTML });
+      })
+      // process interceptors for post rendering
+      .then(() => interceptorManager.process('postRenderCommentPreviewHtml', context));
+  }
+
+  generateInnerHtml(html) {
+    return {__html: html};
+  }
+
+
+  render() {
+    const crowi = this.props.crowi;
+    const username = crowi.me;
+    const user = crowi.findUser(username);
+    const creatorsPage = `/user/${username}`;
+    const comment = this.state.comment;
+    const commentPreview = this.state.isMarkdown ? this.getCommentHtml(): ReactUtils.nl2br(comment);
+
+    return (
+      <div>
+        <form className="form page-comment-form" id="page-comment-form" onSubmit={this.postComment}>
+          { username &&
+            <div className="comment-form">
+              <div className="comment-form-user">
+                <a href={creatorsPage}>
+                  <UserPicture user={user} />
+                </a>
+              </div>
+              <div className="comment-form-main">
+                <div className="comment-write">
+                  <Tabs activeKey={this.state.key} id="comment-form-tabs" onSelect={this.handleSelect} animation={false}>
+                    <Tab eventKey={1} title="Write">
+                      <textarea className="comment-form-comment form-control" id="comment-form-comment" name="comment" required placeholder="Write comments here..." value={this.state.comment} onChange={this.updateState} >
+                      </textarea>
+                    </Tab>
+                    { this.state.isMarkdown == true &&
+                    <Tab eventKey={2} title="Preview">
+                      <div className="comment-form-preview">
+                       {commentPreview}
+                      </div>
+                    </Tab>
+                    }
+                  </Tabs>
+                </div>
+                <div className="comment-submit">
+                  <div className="pull-left">
+                  { this.state.key == 1 &&
+                    <label>
+                      <input type="checkbox" id="comment-form-is-markdown" name="isMarkdown" checked={this.state.isMarkdown} value="1" onChange={this.updateState} /> Markdown
+                    </label>
+                  }
+                  </div>
+                  <div className="pull-right">
+                    <Button type="submit" value="Submit" bsStyle="primary" className="fcbtn btn btn-sm btn-primary btn-outline btn-rounded btn-1b">
+                        Comment
+                    </Button>
+                  </div>
+                  <div className="clearfix">
+                  </div>
+                </div>
+              </div>
+            </div>
+          }
+        </form>
+      </div>
+    );
+  }
+}
+
+CommentForm.propTypes = {
+  crowi: PropTypes.object.isRequired,
+  onPostComplete: PropTypes.func,
+  pageId: PropTypes.string,
+  revisionId: PropTypes.string,
+  crowiRenderer:  PropTypes.object.isRequired,
+};

+ 35 - 0
resource/js/components/PageComment/CommentPreview.js

@@ -0,0 +1,35 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+
+import RevisionBody from '../Page/RevisionBody';
+
+/**
+ * Wrapper component for Page/RevisionBody
+ */
+export default class CommentPreview extends React.Component {
+
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    return (
+      <div className="page-comment-preview-body"
+          ref={(elm) => {
+            this.previewElement = elm;
+            this.props.inputRef(elm);
+          }}>
+
+        <RevisionBody
+          {...this.props}
+          additionalClassName="comment"
+        />
+      </div>
+    );
+  }
+}
+
+CommentPreview.propTypes = {
+  html: PropTypes.string,
+  inputRef: PropTypes.func.isRequired,  // for getting div element
+};

+ 0 - 65
resource/js/components/PageCommentFormBehavior.js

@@ -1,65 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PageComments from './PageComments';
-
-/**
- * Set the behavior that post comments to #page-comment-form
- *
- * This is transplanted from legacy/crowi.js -- 2017.06.03 Yuki Takei
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- *
- * @export
- * @class PageCommentFormBehavior
- * @extends {React.Component}
- */
-export default class PageCommentFormBehavior extends React.Component {
-
-  constructor(props) {
-    super(props);
-  }
-
-  componentWillMount() {
-    const pageComments = this.props.pageComments;
-
-    if (pageComments === undefined) {
-      return;
-    }
-
-    $('#page-comment-form').on('submit', function() {
-      var $button = $('#comment-form-button');
-      $button.attr('disabled', 'disabled');
-      $.post('/_api/comments.add', $(this).serialize(), function(data) {
-        $button.prop('disabled', false);
-        if (data.ok) {
-
-          // reload comments
-          pageComments.init();
-
-          $('#comment-form-comment').val('');
-          $('#comment-form-message').text('');
-        }
-        else {
-          $('#comment-form-message').text(data.error);
-        }
-      }).fail(function(data) {
-        if (data.status !== 200) {
-          $('#comment-form-message').text(data.statusText);
-        }
-      });
-
-      return false;
-    });
-  }
-
-  render() {
-    // render nothing
-    return <div></div>;
-  }
-}
-
-PageCommentFormBehavior.propTypes = {
-  pageComments: PropTypes.instanceOf(PageComments),
-  crowi: PropTypes.object.isRequired,
-};

+ 23 - 12
resource/js/components/PageComments.js

@@ -1,6 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import GrowiRenderer from '../util/GrowiRenderer';
+
 import Comment from './PageComment/Comment';
 import DeleteCommentModal from './PageComment/DeleteCommentModal';
 
@@ -30,6 +32,8 @@ export default class PageComments extends React.Component {
       errorMessageForDeleting: undefined,
     };
 
+    this.growiRenderer = new GrowiRenderer(this.props.crowi, this.props.crowiOriginRenderer, {mode: 'comment'});
+
     this.init = this.init.bind(this);
     this.confirmToDeleteComment = this.confirmToDeleteComment.bind(this);
     this.deleteComment = this.deleteComment.bind(this);
@@ -43,6 +47,8 @@ export default class PageComments extends React.Component {
     if (pageId) {
       this.init();
     }
+
+    this.retrieveData = this.retrieveData.bind(this);
   }
 
   init() {
@@ -50,21 +56,23 @@ export default class PageComments extends React.Component {
       return ;
     }
 
-    const pageId = this.props.pageId;
-
     const layoutType = this.props.crowi.getConfig()['layoutType'];
     this.setState({isLayoutTypeGrowi: 'crowi-plus' === layoutType || 'growi' === layoutType});
 
-    // get data (desc order array)
-    this.props.crowi.apiGet('/comments.get', {page_id: pageId})
-    .then(res => {
-      if (res.ok) {
-        this.setState({comments: res.comments});
-      }
-    }).catch(err => {
-
-    });
+    this.retrieveData();
+  }
 
+  /**
+   * Load data of comments and store them in state
+   */
+  retrieveData() {
+    // get data (desc order array)
+    this.props.crowi.apiGet('/comments.get', {page_id: this.props.pageId})
+      .then(res => {
+        if (res.ok) {
+          this.setState({comments: res.comments});
+        }
+      });
   }
 
   confirmToDeleteComment(comment) {
@@ -123,7 +131,9 @@ export default class PageComments extends React.Component {
         <Comment key={comment._id} comment={comment}
           currentUserId={this.props.crowi.me}
           currentRevisionId={this.props.revisionId}
-          deleteBtnClicked={this.confirmToDeleteComment} />
+          deleteBtnClicked={this.confirmToDeleteComment}
+          crowi={this.props.crowi}
+          crowiRenderer={this.growiRenderer} />
       );
     });
   }
@@ -239,4 +249,5 @@ PageComments.propTypes = {
   revisionId: PropTypes.string,
   revisionCreatedAt: PropTypes.number,
   crowi: PropTypes.object.isRequired,
+  crowiOriginRenderer: PropTypes.object.isRequired,
 };

+ 8 - 0
resource/js/components/PageList/PageListMeta.js

@@ -14,6 +14,7 @@ export default class PageListMeta extends React.Component {
   render() {
     // TODO isPortal()
     const page = this.props.page;
+    const templateChecker = require('../../../../lib/util/templateChecker');
 
     // portal check
     let PortalLabel;
@@ -21,6 +22,12 @@ export default class PageListMeta extends React.Component {
       PortalLabel = <span className="label label-info">PORTAL</span>;
     }
 
+    // template check
+    let TemplateLabel;
+    if (templateChecker(page.path)) {
+      TemplateLabel = <span className="label label-info">TMPL</span>;
+    }
+
     let CommentCount;
     if (page.commentCount > 0) {
       CommentCount = <span><i className="icon-bubble" />{page.commentCount}</span>;
@@ -35,6 +42,7 @@ export default class PageListMeta extends React.Component {
     return (
       <span className="page-list-meta">
         {PortalLabel}
+        {TemplateLabel}
         {CommentCount}
         {LikerCount}
       </span>

+ 20 - 15
resource/js/util/GrowiRenderer.js

@@ -1,19 +1,21 @@
 import MarkdownIt from 'markdown-it';
+import xss from 'xss';
 
 import Linker        from './PreProcessor/Linker';
 import CsvToTable    from './PreProcessor/CsvToTable';
 import XssFilter     from './PreProcessor/XssFilter';
+import CrowiTemplate from './PostProcessor/CrowiTemplate';
 
-import Template from './LangProcessor/Template';
-
-import CommonPluginsConfigurer from './markdown-it/common-plugins';
 import EmojiConfigurer from './markdown-it/emoji';
+import FooternoteConfigurer from './markdown-it/footernote';
 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 TaskListsConfigurer from './markdown-it/task-lists';
 import TocAndAnchorConfigurer from './markdown-it/toc-and-anchor';
+import BlockdiagConfigurer from './markdown-it/blockdiag';
 
 export default class GrowiRenderer {
 
@@ -30,6 +32,8 @@ export default class GrowiRenderer {
       { isAutoSetup: true },      // default options
       options || {});             // specified options
 
+    this.xssFilterForCode = new xss.FilterXSS();
+
     // initialize processors
     //  that will be retrieved if originRenderer exists
     this.preProcessors = this.originRenderer.preProcessors || [
@@ -38,12 +42,9 @@ export default class GrowiRenderer {
       new XssFilter(crowi),
     ];
     this.postProcessors = this.originRenderer.postProcessors || [
+      new CrowiTemplate(crowi),
     ];
 
-    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);
@@ -69,12 +70,13 @@ export default class GrowiRenderer {
     this.isMarkdownItConfigured = false;
 
     this.markdownItConfigurers = [
-      new CommonPluginsConfigurer(crowi),
+      new TaskListsConfigurer(crowi),
       new HeaderConfigurer(crowi),
       new TableConfigurer(crowi),
       new EmojiConfigurer(crowi),
       new MathJaxConfigurer(crowi),
       new PlantUMLConfigurer(crowi),
+      new BlockdiagConfigurer(crowi),
     ];
 
     // add configurers according to mode
@@ -82,15 +84,21 @@ export default class GrowiRenderer {
     switch (mode) {
       case 'page':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new FooternoteConfigurer(crowi),
           new TocAndAnchorConfigurer(crowi, options.renderToc),
           new HeaderLineNumberConfigurer(crowi),
         ]);
         break;
       case 'editor':
         this.markdownItConfigurers = this.markdownItConfigurers.concat([
+          new FooternoteConfigurer(crowi),
           new HeaderLineNumberConfigurer(crowi)
         ]);
         break;
+      case 'comment':
+        this.markdownItConfigurers = this.markdownItConfigurers.concat([
+        ]);
+        break;
       default:
         break;
     }
@@ -147,11 +155,6 @@ export default class GrowiRenderer {
       const lang = langAndFn[0];
       const langFn = langAndFn[1] || null;
 
-      // process langProcessors
-      if (this.langProcessors[lang] != null) {
-        return this.langProcessors[lang].process(code, langExt);
-      }
-
       const citeTag = (langFn) ? `<cite>${langFn}</cite>` : '';
       if (hljs.getLanguage(lang)) {
         try {
@@ -162,11 +165,13 @@ export default class GrowiRenderer {
         }
       }
       else {
-        return `<pre class="hljs ${noborder}">${citeTag}<code>${code}</code></pre>`;
+        const escapedCode = this.xssFilterForCode.process(code);
+        return `<pre class="hljs ${noborder}">${citeTag}<code>${escapedCode}</code></pre>`;
       }
     }
 
-    return `<pre class="hljs ${noborder}"><code>${code}</code></pre>`;
+    const escapedCode = this.xssFilterForCode.process(code);
+    return `<pre class="hljs ${noborder}"><code>${escapedCode}</code></pre>`;
   }
 
 }

+ 0 - 72
resource/js/util/LangProcessor/Template.js

@@ -1,72 +0,0 @@
-import dateFnsFormat from 'date-fns/format';
-
-export default class Template {
-
-  constructor(crowi) {
-    this.templatePattern = {
-      'year': this.getYear,
-      'month': this.getMonth,
-      'date': this.getDate,
-      'user': this.getUser,
-    };
-  }
-
-  getYear() {
-    return dateFnsFormat(new Date(), 'YYYY');
-  }
-
-  getMonth() {
-    return dateFnsFormat(new Date(), 'YYYY/MM');
-  }
-
-  getDate() {
-    return dateFnsFormat(new Date(), 'YYYY/MM/DD');
-  }
-
-  getUser() {
-    // FIXME
-    const username = window.crowi.me || null;
-
-    if (!username) {
-      return '';
-    }
-
-    return `/user/${username}`;
-  }
-
-  parseTemplateString(templateString) {
-    let parsed = templateString;
-
-    Object.keys(this.templatePattern).forEach(key => {
-      const k = key .replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-      const matcher = new RegExp(`{${k}}`, 'g');
-      if (parsed.match(matcher)) {
-        const replacer = this.templatePattern[key]();
-        parsed = parsed.replace(matcher, replacer);
-      }
-    });
-
-    return parsed;
-  }
-
-  process(code, lang) {
-    const templateId = new Date().getTime().toString(16) + Math.floor(1000 * Math.random()).toString(16);
-    let pageName = lang;
-    if (lang.match(':')) {
-      pageName = this.parseTemplateString(lang.split(':')[1]);
-    }
-    code = this.parseTemplateString(code);
-
-    const content = `
-      <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>`;
-
-    // wrap with <pre-dummy>
-    //   to avoid to be wrapped with <pre><code> by markdown-it
-    return `<pre-dummy>${content}<pre-dummy>\n`;
-  }
-}

+ 82 - 0
resource/js/util/PostProcessor/CrowiTemplate.js

@@ -0,0 +1,82 @@
+import dateFnsFormat from 'date-fns/format';
+
+export default class CrowiTemplate {
+
+  constructor(crowi) {
+    this.templatePattern = {
+      'year': this.getYear,
+      'month': this.getMonth,
+      'date': this.getDate,
+      'user': this.getUser,
+    };
+  }
+
+  process(markdown) {
+    // see: https://regex101.com/r/WR6IvX/3
+    return markdown.replace(/:::\s*(\S+)[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group1, group2) => {
+      const lang = group1;
+      let code = group2;
+
+      if (!lang.match(/^template/)) {
+        return all;
+      }
+
+      const templateId = new Date().getTime().toString(16) + Math.floor(1000 * Math.random()).toString(16);
+      let pageName = lang;
+      if (lang.match(':')) {
+        pageName = this.parseTemplateString(lang.split(':')[1]);
+      }
+      code = this.parseTemplateString(code);
+
+      return (
+        /* eslint-disable quotes */
+        `<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>`
+        /* eslint-enable */
+      );
+    });
+  }
+
+  getYear() {
+    return dateFnsFormat(new Date(), 'YYYY');
+  }
+
+  getMonth() {
+    return dateFnsFormat(new Date(), 'YYYY/MM');
+  }
+
+  getDate() {
+    return dateFnsFormat(new Date(), 'YYYY/MM/DD');
+  }
+
+  getUser() {
+    // FIXME
+    const username = window.crowi.me || null;
+
+    if (!username) {
+      return '';
+    }
+
+    return `/user/${username}`;
+  }
+
+  parseTemplateString(templateString) {
+    let parsed = templateString;
+
+    Object.keys(this.templatePattern).forEach(key => {
+      const k = key .replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+      const matcher = new RegExp(`{${k}}`, 'g');
+      if (parsed.match(matcher)) {
+        const replacer = this.templatePattern[key]();
+        parsed = parsed.replace(matcher, replacer);
+      }
+    });
+
+    return parsed;
+  }
+
+}

+ 0 - 9
resource/js/util/PreProcessor/ImageExpander.js

@@ -1,9 +0,0 @@
-
-export default class ImageExpander {
-
-  process(markdown) {
-
-    return markdown
-      .replace(/\s(https?:\/\/[\S]+\.(jpg|jpeg|gif|png))/g, ' <a href="$1"><img src="$1" class="auto-expanded-image"></a>');
-  }
-}

+ 3 - 2
resource/js/util/interceptor/detach-code-blocks.js

@@ -104,14 +104,15 @@ export class RestoreCodeBlockInterceptor extends BasicInterceptor {
    * @inheritdoc
    */
   isInterceptWhen(contextName) {
-    return /^postPreProcess|preRenderHtml|preRenderPreviewHtml$/.test(contextName);
+    return /^postPreProcess|preRenderHtml|preRenderPreviewHtml|preRenderCommentHtml|preRenderCommentPreviewHtml$/.test(contextName);
   }
 
   getTargetKey(contextName) {
     if (contextName === 'postPreProcess') {
       return 'markdown';
     }
-    else if (contextName === 'preRenderHtml' || contextName === 'preRenderPreviewHtml') {
+    else if (contextName === 'preRenderHtml' || contextName === 'preRenderPreviewHtml'
+        || contextName === 'preRenderCommentHtml' || contextName === 'preRenderCommentPreviewHtml') {
       return 'parsedHTML';
     }
   }

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

@@ -0,0 +1,17 @@
+export default class BlockdiagConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+    const config = crowi.getConfig();
+
+    this.generateSourceUrl = config.env.BLOCKDIAG_URL || 'https://blockdiag-api.com/';
+  }
+
+  configure(md) {
+    //// disable temporary because this breaks /Sandbox -- 2018.06.08 Yuki Takei
+    // md.use(require('markdown-it-blockdiag'), {
+    //   generateSourceUrl: this.generateSourceUrl,
+    //   marker: ':::',
+    // });
+  }
+}

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

@@ -1,15 +0,0 @@
-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,
-      })
-    ;
-  }
-
-}

+ 11 - 0
resource/js/util/markdown-it/footernote.js

@@ -0,0 +1,11 @@
+export default class FooternoteConfigurer {
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  configure(md) {
+    md.use(require('markdown-it-footnote'));
+  }
+
+}

+ 13 - 0
resource/js/util/markdown-it/task-lists.js

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

+ 9 - 0
resource/styles/agile-admin/inverse/colors/_apply-colors-light.scss

@@ -24,6 +24,15 @@
   }
 }
 
+/*
+ * GROWI search-top
+ */
+.search-top {
+  .rbt-input.form-control {
+    background-color: rgba($bodycolor, 0.9);
+  }
+}
+
 /*
  * GROWI page list
  */

+ 7 - 0
resource/styles/agile-admin/inverse/colors/_apply-colors.scss

@@ -317,6 +317,13 @@ body.on-edit {
     &:before {
       border-right-color: darken($bodycolor, 4%);
     }
+    .nav.nav-tabs {
+      > li.active > a {
+        background: transparent;
+        border-bottom: solid 1px darken($bodycolor, 4%);
+        border-bottom-color: darken($bodycolor, 4%);
+      }
+    }
   }
 }
 

+ 0 - 5
resource/styles/agile-admin/inverse/colors/blue-night.scss

@@ -47,8 +47,3 @@ $inline-code-bg: #0a121b;
     }
   }
 }
-
-:not(.hljs) > pre:not(.hljs) {
-  color: $bodytext;
-  background-color: $inline-code-bg;
-}

+ 3 - 0
resource/styles/scss/_comment_growi.scss

@@ -94,6 +94,9 @@
     .comment-write {
       margin-bottom: 0.5em;
     }
+    .tab-content{
+      padding-top: 10px;
+    }
     .comment-form-comment {
       height: 80px;
       &:focus, &:not(:invalid) {

+ 8 - 0
resource/styles/scss/_create-template.scss

@@ -0,0 +1,8 @@
+.modal.create-template {
+  @media (min-width: $screen-sm) {
+    // align .panel-body heights
+    .panel-select-template .panel-body {
+      min-height: 120px;
+    }
+  }
+}

+ 0 - 1
resource/styles/scss/_search.scss

@@ -71,7 +71,6 @@
   // see: https://github.com/ericgio/react-bootstrap-typeahead
   .rbt-input.form-control {
     border: none;
-    background-color: rgba(255, 255, 255, 0.9);
     border-radius: 40px;
     border-top-right-radius: 40px;
     border-bottom-right-radius: 40px;

+ 55 - 0
resource/styles/scss/_wiki.scss

@@ -143,6 +143,61 @@ div.body {
       opacity: 1 !important;
     }
   }
+
+  $ratio: 0.95;
+  &.comment {
+    line-height: 1.5em;
+    font-size: 14px;
+
+    h1, h2, h3, h4, h5, h6 {
+      margin-top: 1.6em * $ratio;
+      margin-bottom: .8em * $ratio;
+    }
+
+    h1 {
+      margin-top: 2em * $ratio;
+      padding-bottom: 0.3em * $ratio;
+      font-size: 1.8em * $ratio;
+      line-height: 1.1em * $ratio;
+    }
+    h2 {
+      padding-bottom: 0.5em * $ratio;
+      font-size: 1.4em * $ratio;
+      line-height: 1.225 * $ratio;
+    }
+    h3 {
+      font-size: 1.2em * $ratio;
+    }
+
+    blockquote {
+      font-size: .9em * $ratio;
+    }
+
+    img.emojione {
+      margin-top: -0.3em * $ratio !important;
+    }
+
+    ul, ol {
+      li {
+        line-height: 1.8em * $ratio;
+      }
+    }
+
+    // borrowed from https://www.npmjs.com/package/github-markdown-css
+    .contains-task-list {
+      .task-list-item input {
+        margin: 0 0.2em * $ratio 0.25em * $ratio -1.6em * $ratio;
+      }
+    }
+
+    .revision-head {
+      .revision-head-link,
+      .revision-head-edit-button {
+        margin-left: 0.5em * $ratio;
+        font-size: 0.6em * $ratio;
+      }
+    }
+  }
 }
 
 // mobile

+ 1 - 0
resource/styles/scss/style.scss

@@ -20,6 +20,7 @@
 @import 'comment';
 @import 'comment_growi';
 @import 'create-page';
+@import 'create-template';
 @import 'layout';
 @import 'layout_crowi';
 @import 'layout_crowi_sidebar';

+ 3 - 1
test/models/page.test.js

@@ -200,7 +200,9 @@ describe('Page', () => {
       expect(Page.isCreatableName('/meeting/edit')).to.be.false;
 
       // under score
-      expect(Page.isCreatableName('/_')).to.be.false;
+      expect(Page.isCreatableName('/_')).to.be.true;
+      expect(Page.isCreatableName('/_template')).to.be.true;
+      expect(Page.isCreatableName('/__template')).to.be.true;
       expect(Page.isCreatableName('/_r/x')).to.be.false;
       expect(Page.isCreatableName('/_api')).to.be.false;
       expect(Page.isCreatableName('/_apix')).to.be.false;

+ 36 - 10
yarn.lock

@@ -2238,10 +2238,14 @@ diff@3.3.1:
   version "3.3.1"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.3.1.tgz#aa8567a6eed03c531fc89d3f711cd0e5259dec75"
 
-diff@^3.1.0, diff@^3.3.0, diff@^3.3.1:
+diff@^3.1.0, diff@^3.3.1:
   version "3.4.0"
   resolved "https://registry.yarnpkg.com/diff/-/diff-3.4.0.tgz#b1d85507daf3964828de54b37d0d73ba67dda56c"
 
+diff@^3.5.0:
+  version "3.5.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-3.5.0.tgz#800c0dd1e0a8bfbc95835c202ad220fe317e5a12"
+
 diffie-hellman@^5.0.0:
   version "5.0.2"
   resolved "https://registry.yarnpkg.com/diffie-hellman/-/diffie-hellman-5.0.2.tgz#b5835739270cfe26acf632099fded2a07f209e5e"
@@ -3236,13 +3240,13 @@ google-p12-pem@^1.0.0:
     node-forge "^0.7.1"
     pify "^3.0.0"
 
-googleapis@^30.0.0:
-  version "30.0.0"
-  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-30.0.0.tgz#4673ba34878217539ca5aa4216fef4db6c247649"
+googleapis@^31.0.2:
+  version "31.0.2"
+  resolved "https://registry.yarnpkg.com/googleapis/-/googleapis-31.0.2.tgz#03266287c8b52681e4311a28d9ff2d800e8f1afb"
   dependencies:
     google-auth-library "^1.4.0"
     pify "^3.0.0"
-    qs "^6.5.1"
+    qs "^6.5.2"
     url-template "^2.0.8"
     uuid "^3.2.1"
 
@@ -4366,10 +4370,24 @@ 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-blockdiag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it-blockdiag/-/markdown-it-blockdiag-1.0.0.tgz#d949ab426e59f948713eb9702ab186a92a572736"
+  dependencies:
+    markdown-it-fence "0.0.2"
+    pako "^1.0.6"
+    paths "^0.1.1"
+    url-join "^4.0.0"
+    utf8-bytes "0.0.1"
+
 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-fence@0.0.2:
+  version "0.0.2"
+  resolved "https://registry.yarnpkg.com/markdown-it-fence/-/markdown-it-fence-0.0.2.tgz#ce1fe95900891603300d9da519aada144d5de9fc"
+
 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"
@@ -4392,9 +4410,9 @@ 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-with-slugid@^1.1.3:
-  version "1.1.3"
-  resolved "https://registry.yarnpkg.com/markdown-it-toc-and-anchor-with-slugid/-/markdown-it-toc-and-anchor-with-slugid-1.1.3.tgz#31eed7da97c3fc06961a16f07cbffa469cc1a288"
+markdown-it-toc-and-anchor-with-slugid@^1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/markdown-it-toc-and-anchor-with-slugid/-/markdown-it-toc-and-anchor-with-slugid-1.1.4.tgz#92b6d71054d2103720c299bb236497cdaf4797df"
   dependencies:
     clone "^2.1.0"
     uslug "^1.0.4"
@@ -5214,7 +5232,7 @@ pako@1.0.3:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.3.tgz#5f515b0c6722e1982920ae8005eacb0b7ca73ccf"
 
-pako@~1.0.5:
+pako@^1.0.6, pako@~1.0.5:
   version "1.0.6"
   resolved "https://registry.yarnpkg.com/pako/-/pako-1.0.6.tgz#0101211baa70c4bca4a0f63f2206e97b7dfaf258"
 
@@ -5352,6 +5370,10 @@ path-type@^3.0.0:
   dependencies:
     pify "^3.0.0"
 
+paths@^0.1.1:
+  version "0.1.1"
+  resolved "https://registry.yarnpkg.com/paths/-/paths-0.1.1.tgz#9ad909d7f769dd8acb3a1c033c5eef43123d3d17"
+
 pathval@^1.0.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pathval/-/pathval-1.1.0.tgz#b942e6d4bde653005ef6b71361def8727d0645e0"
@@ -5823,10 +5845,14 @@ qs@6.2.1:
   version "6.2.1"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.2.1.tgz#ce03c5ff0935bc1d9d69a9f14cbd18e568d67625"
 
-qs@6.5.1, qs@^6.5.1, qs@~6.5.1:
+qs@6.5.1, qs@~6.5.1:
   version "6.5.1"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8"
 
+qs@^6.5.2:
+  version "6.5.2"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36"
+
 qs@~6.3.0:
   version "6.3.2"
   resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c"