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

Merge branch 'master' into dev/7.0.x

Yuki Takei 2 лет назад
Родитель
Сommit
b70dd30af6
72 измененных файлов с 1337 добавлено и 192 удалено
  1. 1 1
      README.md
  2. 1 1
      README_JP.md
  3. 1 0
      apps/app/config/logger/config.dev.js
  4. 2 0
      apps/app/next.config.js
  5. 1 0
      apps/app/package.json
  6. 11 5
      apps/app/public/static/locales/en_US/admin.json
  7. 0 1
      apps/app/public/static/locales/en_US/translation.json
  8. 12 6
      apps/app/public/static/locales/ja_JP/admin.json
  9. 0 1
      apps/app/public/static/locales/ja_JP/translation.json
  10. 11 5
      apps/app/public/static/locales/zh_CN/admin.json
  11. 0 1
      apps/app/public/static/locales/zh_CN/translation.json
  12. 1 1
      apps/app/resource/locales/en_US/welcome.md
  13. 4 4
      apps/app/resource/locales/ja_JP/admin/userInvitation.ejs
  14. 4 4
      apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs
  15. 6 6
      apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs
  16. 1 1
      apps/app/resource/locales/ja_JP/welcome.md
  17. 1 1
      apps/app/resource/locales/zh_CN/welcome.md
  18. 11 0
      apps/app/src/client/services/AdminCustomizeContainer.js
  19. 8 0
      apps/app/src/client/services/renderer/renderer.tsx
  20. 90 0
      apps/app/src/client/services/renderer/slide-viewer-renderer.tsx
  21. 29 0
      apps/app/src/components/Admin/Customize/CustomizeFunctionSetting.tsx
  22. 1 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  23. 1 1
      apps/app/src/components/LoginForm.tsx
  24. 0 2
      apps/app/src/components/PageEditor/Editor.tsx
  25. 5 1
      apps/app/src/components/PagePresentationModal.tsx
  26. 1 0
      apps/app/src/components/ReactMarkdownComponents/LightBox.tsx
  27. 33 0
      apps/app/src/components/ReactMarkdownComponents/SlideViewer.tsx
  28. 5 21
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  29. 64 0
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginDeleteModal.tsx
  30. 17 20
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  31. 47 0
      apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx
  32. 2 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  33. 2 0
      apps/app/src/features/questionnaire/server/models/schema/growi-info.ts
  34. 18 0
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  35. 7 0
      apps/app/src/interfaces/search.ts
  36. 1 0
      apps/app/src/interfaces/services/renderer.ts
  37. 4 1
      apps/app/src/pages/[[...path]].page.tsx
  38. 5 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  39. 1 0
      apps/app/src/pages/_search.page.tsx
  40. 4 1
      apps/app/src/pages/me/[[...path]].page.tsx
  41. 4 1
      apps/app/src/pages/share/[[...path]].page.tsx
  42. 2 0
      apps/app/src/server/models/config.ts
  43. 9 6
      apps/app/src/server/models/user.js
  44. 6 0
      apps/app/src/server/routes/apiv3/customize-setting.js
  45. 26 1
      apps/app/src/server/routes/apiv3/pages.js
  46. 17 1
      apps/app/src/server/routes/attachment.js
  47. 2 0
      apps/app/src/server/routes/tag.js
  48. 11 7
      apps/app/src/server/service/search-delegator/elasticsearch-client.ts
  49. 15 24
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  50. 2 0
      apps/app/src/services/renderer/renderer.tsx
  51. 4 0
      apps/app/src/stores/context.tsx
  52. 27 0
      apps/app/src/stores/slide-viewer-renderer.tsx
  53. 11 13
      apps/app/test/integration/models/user.test.js
  54. 12 0
      apps/app/test/integration/service/questionnaire-cron.test.ts
  55. 11 0
      apps/app/test/integration/service/questionnaire.test.ts
  56. 20 1
      packages/core/src/utils/page-path-utils/index.spec.ts
  57. 17 0
      packages/core/src/utils/page-path-utils/index.ts
  58. 4 1
      packages/presentation/package.json
  59. 55 0
      packages/presentation/src/components/GrowiSlides.tsx
  60. 30 0
      packages/presentation/src/components/MarpSlides.tsx
  61. 10 6
      packages/presentation/src/components/Presentation.tsx
  62. 43 0
      packages/presentation/src/components/RichSlideSection.tsx
  63. 1 1
      packages/presentation/src/components/Slides.global.scss
  64. 11 42
      packages/presentation/src/components/Slides.tsx
  65. 1 0
      packages/presentation/src/index.ts
  66. 63 0
      packages/presentation/src/services/growi-marpit.ts
  67. 43 0
      packages/presentation/src/services/parse-slide-frontmatter.ts
  68. 1 1
      packages/presentation/src/services/renderer/extract-sections.ts
  69. 89 0
      packages/presentation/src/services/renderer/slides.ts
  70. 3 0
      packages/preset-templates/dist/marp-example/en_US/meta.json
  71. 325 0
      packages/preset-templates/dist/marp-example/en_US/template.md
  72. 49 0
      yarn.lock

+ 1 - 1
README.md

@@ -37,7 +37,7 @@
 # Features
 
 - **Features**
-  - Create hierarchical pages with markdown -> [HERE](https://docs.growi.org/en/guide/getting-started/five_minutes.html) is 5 minutes tutorial
+  - Create hierarchical pages with markdown -> [Try GROWI on the demo site](https://docs.growi.org/en/guide/getting-started/try_growi.html)
   - Simultaneously edit with multiple people by [HackMD(CodiMD)](https://hackmd.io/) integration
     - [GROWI Docs: HackMD(CodiMD) Integration](https://docs.growi.org/en/admin-guide/admin-cookbook/integrate-with-hackmd.html)
   - Support Authentication with LDAP / Active Directory, OAuth

+ 1 - 1
README_JP.md

@@ -36,7 +36,7 @@
 # 機能紹介
 
 - **主な機能**
-  - マークダウンを使用してページを階層構造で作成することが可能です。 -> 5 分間チュートリアルは[こちら](https://docs.growi.org/ja/guide/getting-started/five_minutes.html)。
+  - マークダウンを使用してページを階層構造で作成することが可能です。 -> [デモサイトで GROWI を体験する](https://docs.growi.org/ja/guide/getting-started/try_growi.html)。
   - [HackMD(CodiMd)](https://hackmd.io/) と連携することで同時多人数編集が可能です。
     - [GROWI Docs: HackMD(CodiMD) 連携](https://docs.growi.org/ja/admin-guide/admin-cookbook/integrate-with-hackmd.html)
   - LDAP / Active Direcotry , OAuth 認証をサポートしています。

+ 1 - 0
apps/app/config/logger/config.dev.js

@@ -28,6 +28,7 @@ module.exports = {
   // 'growi:InterceptorManager': 'debug',
   'growi:service:search-delegator:elasticsearch': 'debug',
   'growi:service:g2g-transfer': 'debug',
+  'growi:service:questionnaire': 'debug',
 
   'growi:migration:add-installed-date-to-config': 'debug',
 

+ 2 - 0
apps/app/next.config.js

@@ -26,6 +26,8 @@ const getTranspilePackages = () => {
     'character-entities-legacy',
     'comma-separated-tokens',
     'decode-named-character-reference',
+    'devlop',
+    'fault',
     'escape-string-regexp',
     'hastscript',
     'html-void-elements',

+ 1 - 0
apps/app/package.json

@@ -184,6 +184,7 @@
     "rehype-toc": "^3.0.2",
     "remark-breaks": "^3.0.2",
     "remark-emoji": "^3.0.2",
+    "remark-frontmatter": "^4.0.1",
     "remark-gfm": "^3.0.1",
     "remark-math": "^5.1.1",
     "remark-toc": "^8.0.1",

+ 11 - 5
apps/app/public/static/locales/en_US/admin.json

@@ -47,8 +47,8 @@
     "anyone": "Anyone",
     "user_homepage_deletion": {
       "user_homepage_deletion": "User homepage deletion",
-      "enable_user_homepage_deletion": "Enable user homepage deletion",
-      "when_deleting_a_user_the_user_homepage_is_also_deleted": "When deleting a user, the user homepage is also deleted."
+      "enable_user_homepage_deletion": "Complete deletion of user homepage, when user deletion",
+      "desc": "When deleting a user, the user homepage and its sub pages are also completely deleted."
     },
     "session": "Session",
     "max_age": "Max age (msec)",
@@ -480,7 +480,13 @@
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted.",
       "select_search_scope_children_as_default": "Select 'Only children of this tree' as default value of search range",
-      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range."
+      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range.",
+      "enable_marp": "Enable Marp ",
+      "enable_marp_desc": "Marp can be used in presentation preview. This option may make you vulnerable to XSS.",
+      "marp_official_site": "The Marp Official Site",
+      "marp_official_site_link": "https://marp.app",
+      "marp_in_growi" : "GROWI Docs - Create slide using Marp",
+      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
     },
     "custom_title": "Custom title",
     "custom_title_detail": "You can customize <code>&lt;title&gt;</code> tag. Following placeholders will be automatically replaced:",
@@ -871,7 +877,7 @@
     "plugin_card": "Plugin Card",
     "plugin_is_not_installed": "Plugin is not installed",
     "install": "Install",
-    "delete": "Delete"
+    "confirm": "Delete plugin?"
   },
   "cloud_setting_management": {
     "to_cloud_settings": "Open GROWI.cloud Settings"
@@ -1062,6 +1068,6 @@
     "remove_plugin_success": "Succeeded to removing {{pluginName}}"
   },
   "forbidden_page": {
-    "do_not_have_admin_permission": "Users without administrative rights cannot access the administration screen."
+    "do_not_have_admin_permission": "Users without administrative rights cannot access the administration screen"
   }
 }

+ 0 - 1
apps/app/public/static/locales/en_US/translation.json

@@ -442,7 +442,6 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
   "toaster": {
-    "file_upload_succeeded": "File upload succeeded.",
     "file_upload_failed": "File upload failed.",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "remove_share_link_success": "Succeeded to remove {{shareLinkId}}",

+ 12 - 6
apps/app/public/static/locales/ja_JP/admin.json

@@ -54,9 +54,9 @@
     "admin_and_author": "管理者とページ作者が可能",
     "anyone": "誰でも可能",
     "user_homepage_deletion": {
-      "user_homepage_deletion": "ユーザーページの削除",
-      "enable_user_homepage_deletion": "ユーザーページの削除を有効化",
-      "when_deleting_a_user_the_user_homepage_is_also_deleted": "ユーザー削除時にユーザーページも削除します。"
+      "user_homepage_deletion": "ユーザーホームページの削除",
+      "enable_user_homepage_deletion": "ユーザー削除時にユーザーホームページを完全削除する",
+      "desc": "ユーザーを削除する際に、ユーザーホームページとその配下のページも完全削除されます。"
     },
     "session": "セッション",
     "max_age": "有効期間 (ミリ秒)",
@@ -488,7 +488,13 @@
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
-      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。"
+      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。",
+      "enable_marp": "Marp を有効化する",
+      "enable_marp_desc": "プレゼンテーション表示に Marp を利用できるようになります。ただし、XSS に対して脆弱になる恐れがあります。",
+      "marp_official_site": "参考:Marp 公式サイト",
+      "marp_official_site_link": "https://marp.app",
+      "marp_in_growi" : "参考:GROWI Docs - Marp でスライドを作成する",
+      "marp_in_growi_link": "https://docs.growi.org/ja/guide/features/marp.html"
     },
     "custom_title": "カスタム Title",
     "custom_title_detail": "<code>&lt;title&gt;</code>タグのコンテンツをカスタマイズできます。以下のプレースホルダーは自動的に置換されます:",
@@ -879,7 +885,7 @@
     "plugin_card": "プラグインカード",
     "plugin_is_not_installed": "プラグインがインストールされていません",
     "install": "インストール",
-    "delete": "削除"
+    "confirm": "プラグインを削除しますか?"
   },
   "cloud_setting_management": {
     "to_cloud_settings": "GROWI.cloud の管理画面へ"
@@ -1070,6 +1076,6 @@
     "remove_plugin_success": "{{pluginName}}を削除しました"
   },
   "forbidden_page": {
-    "do_not_have_admin_permission": "管理者権限のないユーザーでは管理画面にはアクセスできません"
+    "do_not_have_admin_permission": "管理者権限のないユーザーでは管理画面にはアクセスできません"
   }
 }

+ 0 - 1
apps/app/public/static/locales/ja_JP/translation.json

@@ -475,7 +475,6 @@
     "page_not_found_in_preview": "\"{{path}}\" というページはありません。"
   },
   "toaster": {
-    "file_upload_succeeded": "ファイルをアップロードしました",
     "file_upload_failed": "ファイルのアップロードに失敗しました",
     "initialize_successed": "{{target}}を初期化しました",
     "remove_share_link_success": "{{shareLinkId}}を削除しました",

+ 11 - 5
apps/app/public/static/locales/zh_CN/admin.json

@@ -55,8 +55,8 @@
 		"anyone": "任何人",
     "user_homepage_deletion": {
       "user_homepage_deletion": "删除用户页面",
-      "enable_user_homepage_deletion": "用删除用户页",
-      "when_deleting_a_user_the_user_homepage_is_also_deleted": "当一个用户被删除时,用户页面也会被删除。"
+      "enable_user_homepage_deletion": "用户删除时,完全删除用户页",
+      "desc": "删除用户时,用户主页及其下属页面也会被完全删除。"
     },
     "session": "会议",
     "max_age": "有效期间  (msec)",
@@ -488,7 +488,13 @@
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
-      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。"
+      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。",
+      "enable_marp": "启用 Marp",
+      "enable_marp_desc": "Marp 可在演示视图中使用。该选项可能会使您受到 XSS 的攻击。",
+      "marp_official_site": "参考资料:Marp 官方网站",
+      "marp_official_site_link": "https://marp.app",
+      "marp_in_growi" : "参考资料:GROWI Docs - Create slide using Marp",
+      "marp_in_growi_link": "https://docs.growi.org/en/guide/features/marp.html"
     },
     "custom_title": "自定义标题",
     "custom_title_detail": "您可以自定义<code>&lt;title&gt;</code>标记。<br><code>&123;&123;sitename&&125;&125;</code>将自动替换为应用程序名称,并且<code>&123;&123;page&&125;&125;</code>将替换为页面名称/路径。",
@@ -879,7 +885,7 @@
     "plugin_card": "Plugin Card",
     "plugin_is_not_installed": "Plugin is not installed",
     "install": "Install",
-    "delete": "Delete"
+    "confirm": "Delete plugin?"
   },
   "cloud_setting_management": {
     "to_cloud_settings": "進入 GROWI.cloud 的管理界面"
@@ -1070,6 +1076,6 @@
     "remove_plugin_success": "Succeeded to removing {{pluginName}}"
   },
   "forbidden_page": {
-    "do_not_have_admin_permission": "没有管理权限的用户无法访问管理屏幕"
+    "do_not_have_admin_permission": "没有管理权限的用户无法访问管理屏幕"
   }
 }

+ 0 - 1
apps/app/public/static/locales/zh_CN/translation.json

@@ -431,7 +431,6 @@
     "page_not_found_in_preview": "\"{{path}}\" is not a GROWI page."
   },
 	"toaster": {
-    "file_upload_succeeded": "文件上传成功",
     "file_upload_failed": "文件上传失败",
     "initialize_successed": "Succeeded to initialize {{target}}",
     "switch_disable_link_sharing_success": "成功更新分享链接设置",

+ 1 - 1
apps/app/resource/locales/en_US/welcome.md

@@ -20,7 +20,7 @@ Let's increase the information exchange everyday.
 - Once we finished, press the "**Update**" button to publish the page.
     - We can also save it by `Ctrl(⌘) + S`.
 
-For more information: [Tutorial#Create New Page](https://docs.growi.org/en/guide/tutorial/create_page.html#create-new-page)
+For more information: [Create page](https://docs.growi.org/en/guide/features/create_page.html)
 
 <div class="mt-4 card border-primary">
   <div class="card-header bg-primary text-light">

+ 4 - 4
apps/app/resource/locales/ja_JP/admin/userInvitation.ejs

@@ -1,12 +1,12 @@
-Hi, <%- email %>
+こんにちは、 <%- email %>
 
-You are invited to our Wiki, you can log in with following account:
+GROWIに招待されました!次のアカウント情報でログインしてください:
 
 Email: <%- email %>
 Password: <%- password %>
-(This password was auto generated. Update required at the first time you logging in)
+(このパスワードは自動生成されます。 初回ログイン時にアップデートが必要です。)
 
-We are waiting for you!
+あなたの参加を心よりお待ちしております。
 <%- url %>
 
 --

+ 4 - 4
apps/app/resource/locales/ja_JP/admin/userResetPassword.ejs

@@ -1,10 +1,10 @@
-Hi, <%- email %>
+こんにちは、 <%- email %>
 
-Your password has been reset by the administrator, you can log in with following account:
+あなたのパスワードは管理者によってリセットされました。次のアカウントでログインできます。:
 
 Email: <%- email %>
-New Password: <%- password %>
-(This password was auto generated. Update required at the first time you logging in)
+新しいパスワード: <%- password %>
+(このパスワードは自動生成されます。 初回ログイン時にアップデートが必要です。)
 
 --
 <%- appTitle %>

+ 6 - 6
apps/app/resource/locales/ja_JP/admin/userWaitingActivation.ejs

@@ -1,17 +1,17 @@
-Hi, <%- adminUser.name %>
+こんにちは、 <%- adminUser.name %>
 
-A user registered to <%- appTitle %>.
+<%- appTitle %> に登録されているユーザー。
 
 
 ====
-Created user:
+作成したユーザー:
 
-Name: <%- createdUser.name %>
-User Name: <%- createdUser.username %>
+名前: <%- createdUser.name %>
+ユーザーネーム: <%- createdUser.username %>
 Email: <%- createdUser.email %>
 ====
 
-Please do some action with following URL:
+次のURLでアクションを起こしてください:
 <%- url %>/admin/users
 
 

+ 1 - 1
apps/app/resource/locales/ja_JP/welcome.md

@@ -19,7 +19,7 @@ GROWI は個人・法人向けの Wiki | ナレッジベースツールです。
 - 書けたら "**更新**" ボタンを押してページを公開しましょう
     - `Ctrl(⌘) + S` でも保存できます
 
-さらに詳しくはこちら: [チュートリアル#新規ページ作成](https://docs.growi.org/ja/guide/tutorial/create_page.html#新規ページ作成)
+さらに詳しくはこちら: [ページを作成する](https://docs.growi.org/ja/guide/features/create_page.html)
 
 <div class="mt-4 card border-primary">
   <div class="card-header bg-primary text-light">Tips</div>

+ 1 - 1
apps/app/resource/locales/zh_CN/welcome.md

@@ -20,7 +20,7 @@ GROWI是一个针对个人和公司的Wiki - 一个知识库工具。
 - 一旦我们完成了,按 "**更新**"按钮来发布页面。
     - 我们也可以通过`Ctrl(⌘) + S`来保存。
 
-了解更多信息: [Tutorial#Create New Page](https://docs.growi.org/en/guide/tutorial/create_page.html#create-new-page)
+了解更多信息: [Create page](https://docs.growi.org/en/guide/features/create_page.html)
 
 <div class="mt-4 card border-primary">
   <div class="card-header bg-primary text-light">

+ 11 - 0
apps/app/src/client/services/AdminCustomizeContainer.js

@@ -34,6 +34,7 @@ export default class AdminCustomizeContainer extends Container {
       isEnabledStaleNotification: false,
       isAllReplyShown: false,
       isSearchScopeChildrenAsDefault: false,
+      isEnabledMarp: false,
       currentCustomizeTitle: '',
       currentCustomizeNoscript: '',
       currentCustomizeCss: '',
@@ -71,6 +72,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: customizeParams.isEnabledStaleNotification,
         isAllReplyShown: customizeParams.isAllReplyShown,
         isSearchScopeChildrenAsDefault: customizeParams.isSearchScopeChildrenAsDefault,
+        isEnabledMarp: customizeParams.isEnabledMarp,
         currentCustomizeTitle: customizeParams.customizeTitle,
         currentCustomizeNoscript: customizeParams.customizeNoscript,
         currentCustomizeCss: customizeParams.customizeCss,
@@ -149,6 +151,13 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ isSearchScopeChildrenAsDefault: !this.state.isSearchScopeChildrenAsDefault });
   }
 
+  /**
+   * Switch isEnabledMarp
+   */
+  switchIsEnabledMarp() {
+    this.setState({ isEnabledMarp: !this.state.isEnabledMarp });
+  }
+
   /**
    * Change customize Title
    */
@@ -194,6 +203,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
         isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
+        isEnabledMarp: this.state.isEnabledMarp,
       });
       const { customizedParams } = response.data;
       this.setState({
@@ -206,6 +216,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
         isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
+        isEnabledMarp: customizedParams.isEnabledMarp,
       });
     }
     catch (err) {

+ 8 - 0
apps/app/src/client/services/renderer/renderer.tsx

@@ -1,6 +1,7 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
+import * as slides from '@growi/presentation';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 // eslint-disable-next-line import/extensions
@@ -18,6 +19,7 @@ import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
 import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
+import { SlideViewer } from '~/components/ReactMarkdownComponents/SlideViewer';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
@@ -66,6 +68,7 @@ export const generateViewOptions = (
     attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
+    [slides.remarkPlugin, { isEnabledMarp: config.isEnabledMarp }],
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -81,6 +84,7 @@ export const generateViewOptions = (
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
+      slides.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
     )]
@@ -115,6 +119,7 @@ export const generateViewOptions = (
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
+    components.slide = SlideViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -257,6 +262,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     attachment.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
+    [slides.remarkPlugin, { isEnabledMarp: config.isEnabledMarp }],
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -275,6 +281,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
+      slides.sanitizeOption,
     )]
     : () => {};
 
@@ -299,6 +306,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.mermaid = mermaid.MermaidViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
+    components.slide = SlideViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 90 - 0
apps/app/src/client/services/renderer/slide-viewer-renderer.tsx

@@ -0,0 +1,90 @@
+import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
+import * as drawio from '@growi/remark-drawio';
+// eslint-disable-next-line import/extensions
+import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
+import katex from 'rehype-katex';
+import sanitize from 'rehype-sanitize';
+import math from 'remark-math';
+import deepmerge from 'ts-deepmerge';
+import type { Pluggable } from 'unified';
+
+import { LightBox } from '~/components/ReactMarkdownComponents/LightBox';
+import { RichAttachment } from '~/components/ReactMarkdownComponents/RichAttachment';
+import * as mermaid from '~/features/mermaid';
+import { RehypeSanitizeOption } from '~/interfaces/rehype';
+import type { RendererOptions } from '~/interfaces/renderer-options';
+import type { RendererConfig } from '~/interfaces/services/renderer';
+import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
+import * as attachment from '~/services/renderer/remark-plugins/attachment';
+import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
+import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
+import {
+  commonSanitizeOption, generateCommonOptions, injectCustomSanitizeOption, verifySanitizePlugin,
+} from '~/services/renderer/renderer';
+
+
+export const generatePresentationViewOptions = (
+    config: RendererConfig,
+    pagePath: string,
+): RendererOptions => {
+  const options = generateCommonOptions(pagePath);
+
+  const { remarkPlugins, rehypePlugins, components } = options;
+
+  // add remark plugins
+  remarkPlugins.push(
+    math,
+    [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
+    xsvToTable.remarkPlugin,
+    attachment.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
+  );
+
+  if (config.xssOption === RehypeSanitizeOption.CUSTOM) {
+    injectCustomSanitizeOption(config);
+  }
+
+
+  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+    ? [sanitize, deepmerge(
+      commonSanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      attachment.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
+      addLineNumberAttribute.sanitizeOption,
+    )]
+    : () => {};
+
+  // add rehype plugins
+  rehypePlugins.push(
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
+    rehypeSanitizePlugin,
+    addLineNumberAttribute.rehypePlugin,
+    katex,
+  );
+
+  // add components
+  if (components != null) {
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
+    components.attachment = RichAttachment;
+    components.img = LightBox;
+  }
+
+  if (config.isEnabledXssPrevention) {
+    verifySanitizePlugin(options, false);
+  }
+  return options;
+};

+ 29 - 0
apps/app/src/components/Admin/Customize/CustomizeFunctionSetting.tsx

@@ -133,6 +133,35 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             </div>
           </div>
 
+          <div className="form-group row">
+            <div className="offset-md-3 col-md-6 text-left">
+              <CustomizeFunctionOption
+                optionId="isEnabledMarp"
+                label={t('admin:customize_settings.function_options.enable_marp')}
+                isChecked={adminCustomizeContainer.state.isEnabledMarp || false}
+                onChecked={() => { adminCustomizeContainer.switchIsEnabledMarp() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_settings.function_options.enable_marp_desc')}
+                  <br></br>
+                  <a
+                    href={`${t('admin:customize_settings.function_options.marp_official_site_link')}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                  >{`${t('admin:customize_settings.function_options.marp_official_site')}`}
+                  </a>
+                  <br></br>
+                  <a
+                    href={`${t('admin:customize_settings.function_options.marp_in_gorwi_link')}`}
+                    target="_blank"
+                    rel="noopener noreferrer"
+                  >{`${t('admin:customize_settings.function_options.marp_in_growi')}`}
+                  </a>
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
+
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
         </div>
       </div>

+ 1 - 1
apps/app/src/components/Admin/Security/SecuritySetting.jsx

@@ -470,7 +470,7 @@ class SecuritySetting extends React.Component {
             </div>
             <p
               className="form-text text-muted small"
-              dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.when_deleting_a_user_the_user_homepage_is_also_deleted') }}
+              dangerouslySetInnerHTML={{ __html: t('security_settings.user_homepage_deletion.desc') }}
             />
           </div>
         </div>

+ 1 - 1
apps/app/src/components/LoginForm.tsx

@@ -518,7 +518,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           {/* Sign up button (submit) */}
           <div className="input-group justify-content-center my-4">
             <button
-              type="button"
+              type="submit"
               className="btn btn-fill rounded-0"
               id="register"
               disabled={(!isMailerSetup && isEmailAuthenticationEnabled) || isLoading}

+ 0 - 2
apps/app/src/components/PageEditor/Editor.tsx

@@ -134,8 +134,6 @@ const Editor: ForwardRefRenderFunction<IEditorMethods, EditorPropsType> = (props
   const pasteFilesHandler = useCallback((event) => {
     const items = event.clipboardData.items || event.clipboardData.files || [];
 
-    toastSuccess(t('toaster.file_upload_succeeded'));
-
     // abort if length is not 1
     if (items.length < 1) {
       return;

+ 5 - 1
apps/app/src/components/PagePresentationModal.tsx

@@ -8,6 +8,7 @@ import {
   Modal, ModalBody,
 } from 'reactstrap';
 
+import { useIsEnabledMarp } from '~/stores/context';
 import { usePagePresentationModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 import { usePresentationViewOptions } from '~/stores/renderer';
@@ -35,6 +36,8 @@ const PagePresentationModal = (): JSX.Element => {
   const { data: currentPage } = useSWRxCurrentPage();
   const { data: rendererOptions } = usePresentationViewOptions();
 
+  const { data: isEnabledMarp } = useIsEnabledMarp();
+
   const toggleFullscreenHandler = useCallback(() => {
     if (fullscreen.active) {
       fullscreen.exit();
@@ -77,7 +80,7 @@ const PagePresentationModal = (): JSX.Element => {
         <button className="btn-close" type="button" aria-label="Close" onClick={closeHandler}></button>
       </div>
       <ModalBody className="modal-body d-flex justify-content-center align-items-center">
-        { rendererOptions != null && (
+        { rendererOptions != null && isEnabledMarp != null && (
           <Presentation
             options={{
               rendererOptions: rendererOptions as ReactMarkdownOptions,
@@ -87,6 +90,7 @@ const PagePresentationModal = (): JSX.Element => {
               },
               isDarkMode,
             }}
+            isEnabledMarp={isEnabledMarp}
           >
             {markdown}
           </Presentation>

+ 1 - 0
apps/app/src/components/ReactMarkdownComponents/LightBox.tsx

@@ -10,6 +10,7 @@ export const LightBox = (props) => {
       <FsLightbox
         toggler={toggler}
         sources={[props.src]}
+        type="image"
       />
     </>
   );

+ 33 - 0
apps/app/src/components/ReactMarkdownComponents/SlideViewer.tsx

@@ -0,0 +1,33 @@
+import React from 'react';
+
+import dynamic from 'next/dynamic';
+import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+
+import { usePresentationViewOptions } from '~/stores/slide-viewer-renderer';
+
+
+const Slides = dynamic(() => import('@growi/presentation').then(mod => mod.Slides), { ssr: false });
+
+type SlideViewerProps = {
+  marp: string | undefined,
+  children: string,
+}
+
+export const SlideViewer: React.FC<SlideViewerProps> = React.memo((props: SlideViewerProps) => {
+  const {
+    marp, children,
+  } = props;
+
+  const { data: rendererOptions } = usePresentationViewOptions();
+
+  return (
+    <Slides
+      hasMarpFlag={marp != null}
+      options={{ rendererOptions: rendererOptions as ReactMarkdownOptions }}
+    >
+      {children}
+    </Slides>
+  );
+});
+
+SlideViewer.displayName = 'SlideViewer';

+ 5 - 21
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx

@@ -3,7 +3,7 @@ import React, { useState } from 'react';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 
-import { apiv3Delete, apiv3Put } from '~/client/util/apiv3-client';
+import { apiv3Put } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
 
 import styles from './PluginCard.module.scss';
@@ -13,14 +13,14 @@ type Props = {
   name: string,
   url: string,
   isEnalbed: boolean,
-  mutate: () => void,
   desc?: string,
+  onDelete: () => void,
 }
 
 export const PluginCard = (props: Props): JSX.Element => {
 
   const {
-    id, name, url, isEnalbed, desc, mutate,
+    id, name, url, isEnalbed, desc,
   } = props;
 
   const { t } = useTranslation('admin');
@@ -70,30 +70,14 @@ export const PluginCard = (props: Props): JSX.Element => {
 
   const PluginDeleteButton = (): JSX.Element => {
 
-    const onClickPluginDeleteBtnHandler = async() => {
-      const reqUrl = `/plugins/${id}/remove`;
-
-      try {
-        const res = await apiv3Delete(reqUrl);
-        const pluginName = res.data.pluginName;
-        toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
-      }
-      catch (err) {
-        toastError(err);
-      }
-      finally {
-        mutate();
-      }
-    };
-
     return (
       <div>
         <button
           type="submit"
           className="btn btn-primary"
-          onClick={() => onClickPluginDeleteBtnHandler()}
+          onClick={props.onDelete}
         >
-          {t('plugins.delete')}
+          {t('Delete')}
         </button>
       </div>
     );

+ 64 - 0
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginDeleteModal.tsx

@@ -0,0 +1,64 @@
+import React, { useCallback } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import Link from 'next/link';
+import {
+  Button, Modal, ModalHeader, ModalBody, ModalFooter,
+} from 'reactstrap';
+
+import { apiv3Delete } from '~/client/util/apiv3-client';
+import { toastSuccess, toastError } from '~/client/util/toastr';
+
+import { useSWRxAdminPlugins, usePluginDeleteModal } from '../../../stores/admin-plugins';
+
+export const PluginDeleteModal: React.FC = () => {
+
+  const { t } = useTranslation('admin');
+  const { mutate } = useSWRxAdminPlugins();
+  const { data: pluginDeleteModalData, close: closePluginDeleteModal } = usePluginDeleteModal();
+  const isOpen = pluginDeleteModalData?.isOpen;
+  const id = pluginDeleteModalData?.id;
+  const name = pluginDeleteModalData?.name;
+  const url = pluginDeleteModalData?.url;
+
+  const toggleHandler = useCallback(() => {
+    closePluginDeleteModal();
+  }, [closePluginDeleteModal]);
+
+  const onClickDeleteButtonHandler = useCallback(async() => {
+    const reqUrl = `/plugins/${id}/remove`;
+
+    try {
+      const res = await apiv3Delete(reqUrl);
+      const pluginName = res.data.pluginName;
+      closePluginDeleteModal();
+      toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
+      mutate();
+    }
+    catch (err) {
+      toastError(err);
+    }
+  }, [id, closePluginDeleteModal, t, mutate]);
+
+  return (
+    <Modal isOpen={isOpen} toggle={toggleHandler}>
+      <ModalHeader tag="h4" toggle={toggleHandler} className="bg-danger text-light" name={name}>
+        <span>
+          <i className="icon-fw icon-fire"></i>
+          {t('plugins.confirm')}
+        </span>
+      </ModalHeader>
+      <ModalBody>
+        <div className="card well mt-2 p-2" key={id}>
+          <Link href={`${url}`} legacyBehavior>{name}</Link>
+        </div>
+      </ModalBody>
+      <ModalFooter>
+        <Button color="danger" onClick={onClickDeleteButtonHandler}>
+          <i className="icon-fw icon-fire"></i>
+          {t('Delete')}
+        </Button>
+      </ModalFooter>
+    </Modal>
+  );
+};

+ 17 - 20
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -1,9 +1,10 @@
 import React from 'react';
 
 import { useTranslation } from 'next-i18next';
+import dynamic from 'next/dynamic';
 import { Spinner } from 'reactstrap';
 
-import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
+import { useSWRxAdminPlugins, usePluginDeleteModal } from '../../../stores/admin-plugins';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
@@ -18,8 +19,10 @@ const Loading = (): JSX.Element => {
 
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
-
+  const PluginDeleteModal = dynamic(() => import('./PluginDeleteModal')
+    .then(mod => mod.PluginDeleteModal), { ssr: false });
   const { data, mutate } = useSWRxAdminPlugins();
+  const { open: openPluginDeleteModal } = usePluginDeleteModal();
 
   return (
     <div>
@@ -45,28 +48,22 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
                 { data.plugins.length === 0 && (
                   <div>{t('plugins.plugin_is_not_installed')}</div>
                 )}
-                { data.plugins.map((plugin) => {
-                  const pluginId = plugin._id;
-                  const pluginName = plugin.meta.name;
-                  const pluginUrl = plugin.origin.url;
-                  const pluginIsEnabled = plugin.isEnabled;
-                  const pluginDiscription = plugin.meta.desc;
-                  return (
-                    <PluginCard
-                      key={pluginId}
-                      id={pluginId}
-                      name={pluginName}
-                      url={pluginUrl}
-                      isEnalbed={pluginIsEnabled}
-                      desc={pluginDiscription}
-                      mutate={mutate}
-                    />
-                  );
-                })}
+                {data.plugins.map(plugin => (
+                  <PluginCard
+                    key={plugin._id}
+                    id={plugin._id}
+                    name={plugin.meta.name}
+                    url={plugin.origin.url}
+                    isEnalbed={plugin.isEnabled}
+                    desc={plugin.meta.desc}
+                    onDelete={() => openPluginDeleteModal(plugin)}
+                  />
+                ))}
               </div>
             )}
         </div>
       </div>
+      <PluginDeleteModal />
 
     </div>
   );

+ 47 - 0
apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx

@@ -1,6 +1,7 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
+import { useStaticSWR } from '~/stores/use-static-swr';
 
 import type { IGrowiPluginHasId } from '../../interfaces';
 
@@ -22,3 +23,49 @@ export const useSWRxAdminPlugins = (): SWRResponse<Plugins, Error> => {
     },
   );
 };
+
+/*
+ * PluginDeleteModal
+ */
+type PluginDeleteModalStatus = {
+  isOpen: boolean,
+  id: string,
+  name: string,
+  url: string,
+}
+
+type PluginDeleteModalUtils = {
+  open(plugin: IGrowiPluginHasId): Promise<void>,
+  close(): Promise<void>,
+}
+
+export const usePluginDeleteModal = (): SWRResponse<PluginDeleteModalStatus, Error> & PluginDeleteModalUtils => {
+  const initialStatus: PluginDeleteModalStatus = {
+    isOpen: false,
+    id: '',
+    name: '',
+    url: '',
+  };
+
+  const swrResponse = useStaticSWR<PluginDeleteModalStatus, Error>('pluginDeleteModal', undefined, { fallbackData: initialStatus });
+  const { mutate } = swrResponse;
+
+  const open = async(plugin) => {
+    mutate({
+      isOpen: true,
+      id: plugin._id,
+      name: plugin.meta.name,
+      url: plugin.origin.url,
+    });
+  };
+
+  const close = async() => {
+    mutate(initialStatus);
+  };
+
+  return {
+    ...swrResponse,
+    open,
+    close,
+  };
+};

+ 2 - 0
apps/app/src/features/questionnaire/interfaces/growi-info.ts

@@ -44,6 +44,8 @@ export interface IGrowiInfo {
   version: string
   appSiteUrl?: string
   appSiteUrlHashed: string
+  installedAt: Date
+  installedAtByOldestUser: Date
   type: GrowiServiceType
   currentUsersCount: number
   currentActiveUsersCount: number

+ 2 - 0
apps/app/src/features/questionnaire/server/models/schema/growi-info.ts

@@ -8,6 +8,8 @@ export const growiInfoSchema = new Schema<IGrowiInfo>({
   version: { type: String, required: true },
   appSiteUrl: { type: String },
   appSiteUrlHashed: { type: String, required: true },
+  installedAt: { type: Date, required: true },
+  installedAtByOldestUser: { type: Date, required: true },
   type: { type: String, required: true, enum: Object.values(GrowiServiceType) },
   currentUsersCount: { type: Number, required: true },
   currentActiveUsersCount: { type: Number, required: true },

+ 18 - 0
apps/app/src/features/questionnaire/server/service/questionnaire.ts

@@ -4,7 +4,10 @@ import * as os from 'node:os';
 import type { IUserHasId } from '@growi/core';
 
 import { ObjectIdLike } from '~/server/interfaces/mongoose-utils';
+// eslint-disable-next-line import/no-named-as-default
+import Config from '~/server/models/config';
 import { aclService } from '~/server/service/acl';
+import loggerFactory from '~/utils/logger';
 
 import {
   GrowiWikiType, GrowiExternalAuthProviderType, IGrowiInfo, GrowiServiceType, GrowiAttachmentType, GrowiDeploymentType,
@@ -15,6 +18,9 @@ import QuestionnaireAnswerStatus from '../models/questionnaire-answer-status';
 import QuestionnaireOrder, { QuestionnaireOrderDocument } from '../models/questionnaire-order';
 import { isShowableCondition } from '../util/condition';
 
+
+const logger = loggerFactory('growi:service:questionnaire');
+
 class QuestionnaireService {
 
   crowi: any;
@@ -32,6 +38,16 @@ class QuestionnaireService {
     hasher.update(appSiteUrl);
     const appSiteUrlHashed = hasher.digest('hex');
 
+    // Get the oldest user who probably installed this GROWI.
+    // https://mongoosejs.com/docs/6.x/docs/api.html#model_Model-findOne
+    // https://stackoverflow.com/questions/13443069/mongoose-findone-with-sorting
+    const user = await User.findOne({ createdAt: { $ne: null } }).sort({ createdAt: 1 });
+
+    const installedAtByOldestUser = user ? user.createdAt : null;
+
+    const appInstalledConfig = await Config.findOne({ key: 'app:installed' });
+    const installedAt = appInstalledConfig != null && appInstalledConfig.createdAt != null ? appInstalledConfig.createdAt : installedAtByOldestUser;
+
     const currentUsersCount = await User.countDocuments();
     const currentActiveUsersCount = await User.countActiveUsers();
 
@@ -61,6 +77,8 @@ class QuestionnaireService {
       },
       appSiteUrl: this.crowi.configManager.getConfig('crowi', 'questionnaire:isAppSiteUrlHashed') ? null : appSiteUrl,
       appSiteUrlHashed,
+      installedAt,
+      installedAtByOldestUser,
       type,
       currentUsersCount,
       currentActiveUsersCount,

+ 7 - 0
apps/app/src/interfaces/search.ts

@@ -21,6 +21,13 @@ export type ISearchResultMeta = {
   },
 }
 
+export type ISearchResultData = {
+  _id: string
+  _score: number
+  _source: any
+  _highlight: any
+}
+
 export type ISearchResult<T> = ISearchResultMeta & {
   data: T[],
 }

+ 1 - 0
apps/app/src/interfaces/services/renderer.ts

@@ -7,6 +7,7 @@ export type RendererConfig = {
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
   highlightJsStyleBorder: boolean,
+  isEnabledMarp: boolean,
 
   drawioUri: string,
   plantumlUri: string,

+ 4 - 1
apps/app/src/pages/[[...path]].page.tsx

@@ -35,7 +35,7 @@ import {
   useIsSearchServiceConfigured, useIsSearchServiceReachable, useDisableLinkSharing,
   useHackmdUri, useDefaultIndentSize, useIsIndentSizeForced,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
-  useCsrfToken, useIsSearchScopeChildrenAsDefault, useCurrentPathname,
+  useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useEditorConfig, useIsAllReplyShown, useIsUploadableFile, useIsUploadableImage, useIsContainerFluid, useIsNotCreatable,
 } from '~/stores/context';
@@ -147,6 +147,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  isEnabledMarp: boolean,
 
   isSlackConfigured: boolean,
   // isMailerSetup: boolean,
@@ -215,6 +216,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsIndentSizeForced(props.isIndentSizeForced);
   useDisableLinkSharing(props.disableLinkSharing);
   useRendererConfig(props.rendererConfig);
+  useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   useIsAllReplyShown(props.isAllReplyShown);
@@ -581,6 +583,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.rendererConfig = {
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
     isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 

+ 5 - 1
apps/app/src/pages/_private-legacy-pages.page.tsx

@@ -12,7 +12,7 @@ import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import {
   useCsrfToken, useCurrentUser, useIsSearchPage, useIsSearchScopeChildrenAsDefault,
-  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri,
+  useIsSearchServiceConfigured, useIsSearchServiceReachable, useRendererConfig, useGrowiCloudUri, useIsEnabledMarp,
 } from '~/stores/context';
 
 import type { CommonProps } from './utils/commons';
@@ -28,6 +28,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  isEnabledMarp: boolean,
 
   // Render config
   rendererConfig: RendererConfig,
@@ -50,6 +51,7 @@ const PrivateLegacyPage: NextPage<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+  useIsEnabledMarp(props.isEnabledMarp);
 
   // init sidebar config with UserUISettings and sidebarConfig
   useInitSidebarConfig(props.sidebarConfig, props.userUISettings);
@@ -84,6 +86,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.isEnabledMarp = configManager.getConfig('crowi', 'customize:isEnabledMarp');
 
   props.sidebarConfig = {
     isSidebarDrawerMode: configManager.getConfig('crowi', 'customize:isSidebarDrawerMode'),
@@ -93,6 +96,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.rendererConfig = {
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
     isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 

+ 1 - 0
apps/app/src/pages/_search.page.tsx

@@ -120,6 +120,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.rendererConfig = {
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
     isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 

+ 4 - 1
apps/app/src/pages/me/[[...path]].page.tsx

@@ -17,7 +17,7 @@ import {
   useCurrentUser, useIsSearchPage, useGrowiCloudUri,
   useIsSearchServiceConfigured, useIsSearchServiceReachable,
   useCsrfToken, useIsSearchScopeChildrenAsDefault,
-  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig,
+  useRegistrationWhitelist, useShowPageLimitationXL, useRendererConfig, useIsEnabledMarp,
 } from '~/stores/context';
 import loggerFactory from '~/utils/logger';
 
@@ -34,6 +34,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  isEnabledMarp: boolean,
   rendererConfig: RendererConfig,
   showPageLimitationXL: number,
 
@@ -106,6 +107,7 @@ const MePage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
   useRendererConfig(props.rendererConfig);
+  useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
 
   const title = generateCustomTitle(props, targetPage.title);
 
@@ -163,6 +165,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.rendererConfig = {
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
     isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 

+ 4 - 1
apps/app/src/pages/share/[[...path]].page.tsx

@@ -20,7 +20,7 @@ import type { IShareLinkHasId } from '~/interfaces/share-link';
 import type { PageDocument } from '~/server/models/page';
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
-  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid,
+  useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
 } from '~/stores/context';
 import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
@@ -41,6 +41,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  isEnabledMarp: boolean,
   drawioUri: string | null,
   rendererConfig: RendererConfig,
   skipSSR: boolean,
@@ -92,6 +93,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
+  useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
   useIsContainerFluid(props.isContainerFluid);
 
   const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
@@ -166,6 +168,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
     isSharedPage: true,
     isEnabledLinebreaks: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaks'),
     isEnabledLinebreaksInComments: configManager.getConfig('markdown', 'markdown:isEnabledLinebreaksInComments'),
+    isEnabledMarp: configManager.getConfig('crowi', 'customize:isEnabledMarp'),
     adminPreferredIndentSize: configManager.getConfig('markdown', 'markdown:adminPreferredIndentSize'),
     isIndentSizeForced: configManager.getConfig('markdown', 'markdown:isIndentSizeForced'),
 

+ 2 - 0
apps/app/src/server/models/config.ts

@@ -11,6 +11,7 @@ export interface Config {
   ns: string;
   key: string;
   value: string;
+  createdAt: Date;
 }
 
 /*
@@ -131,6 +132,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'customize:isEnabledStaleNotification': false,
   'customize:isAllReplyShown': false,
   'customize:isSearchScopeChildrenAsDefault': false,
+  'customize:isEnabledMarp': false,
   'customize:isSidebarDrawerMode': false,
   'customize:isSidebarClosedAtDockMode': false,
 

+ 9 - 6
apps/app/src/server/models/user.js

@@ -1,4 +1,6 @@
 /* eslint-disable no-use-before-define */
+import { pagePathUtils } from '@growi/core/dist/utils';
+
 import { i18n } from '^/config/next-i18next.config';
 
 import { generateGravatarSrc } from '~/utils/gravatar';
@@ -700,14 +702,15 @@ module.exports = function(crowi) {
     });
   };
 
-  userSchema.statics.getUsernameByPath = function(path) {
-    let username = null;
-    const match = path.match(/^\/user\/([^/]+)\/?/);
-    if (match) {
-      username = match[1];
+  userSchema.statics.isExistUserByUserPagePath = async function(path) {
+    const username = pagePathUtils.getUsernameByPath(path);
+
+    if (username == null) {
+      return false;
     }
 
-    return username;
+    const user = await this.exists({ username });
+    return user != null;
   };
 
   userSchema.statics.updateIsInvitationEmailSended = async function(id) {

+ 6 - 0
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -61,6 +61,8 @@ const router = express.Router();
  *            type: boolean
  *          isSearchScopeChildrenAsDefault:
  *            type: boolean
+ *          isEnabledMarp:
+ *            type: boolean
  *      CustomizeHighlight:
  *        description: CustomizeHighlight
  *        type: object
@@ -125,6 +127,7 @@ module.exports = (crowi) => {
       body('isEnabledStaleNotification').isBoolean(),
       body('isAllReplyShown').isBoolean(),
       body('isSearchScopeChildrenAsDefault').isBoolean(),
+      body('isEnabledMarp').isBoolean(),
     ],
     customizeTitle: [
       body('customizeTitle').isString(),
@@ -181,6 +184,7 @@ module.exports = (crowi) => {
       isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
       isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
       isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
+      isEnabledMarp: await crowi.configManager.getConfig('crowi', 'customize:isEnabledMarp'),
       styleName: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyle'),
       styleBorder: await crowi.configManager.getConfig('crowi', 'customize:highlightJsStyleBorder'),
       customizeTitle: await crowi.configManager.getConfig('crowi', 'customize:title'),
@@ -407,6 +411,7 @@ module.exports = (crowi) => {
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
       'customize:isAllReplyShown': req.body.isAllReplyShown,
       'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
+      'customize:isEnabledMarp': req.body.isEnabledMarp,
     };
 
     try {
@@ -421,6 +426,7 @@ module.exports = (crowi) => {
         isEnabledStaleNotification: await crowi.configManager.getConfig('crowi', 'customize:isEnabledStaleNotification'),
         isAllReplyShown: await crowi.configManager.getConfig('crowi', 'customize:isAllReplyShown'),
         isSearchScopeChildrenAsDefault: await crowi.configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault'),
+        isEnabledMarp: await crowi.configManager.getConfig('crowi', 'customize:isEnabledMarp'),
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);

+ 26 - 1
apps/app/src/server/routes/apiv3/pages.js

@@ -1,7 +1,7 @@
 
 import { PageGrant } from '@growi/core';
 import { ErrorV3 } from '@growi/core/dist/models';
-import { isCreatablePage, isTrashPage } from '@growi/core/dist/utils/page-path-utils';
+import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
 import { normalizePath, addHeadingSlash, attachTitleHeader } from '@growi/core/dist/utils/path-utils';
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
@@ -303,6 +303,17 @@ module.exports = (crowi) => {
     // check whether path starts slash
     path = addHeadingSlash(path);
 
+    if (!isCreatablePage(path)) {
+      return res.apiv3Err(`Could not use the path '${path}'`);
+    }
+
+    if (isUserPage(path)) {
+      const isExistUser = await User.isExistUserByUserPagePath(path);
+      if (!isExistUser) {
+        return res.apiv3Err("Unable to create a page under a non-existent user's user page");
+      }
+    }
+
     const options = { overwriteScopesOfDescendants };
     if (grant != null) {
       options.grant = grant;
@@ -526,6 +537,13 @@ module.exports = (crowi) => {
       return res.apiv3Err(new ErrorV3(`Could not use the path '${newPagePath}'`, 'invalid_path'), 409);
     }
 
+    if (isUserPage(newPagePath)) {
+      const isExistUser = await User.isExistUserByUserPagePath(newPagePath);
+      if (!isExistUser) {
+        return res.apiv3Err("Unable to rename a page under a non-existent user's user page");
+      }
+    }
+
     // check whether path starts slash
     newPagePath = addHeadingSlash(newPagePath);
 
@@ -756,6 +774,13 @@ module.exports = (crowi) => {
         return res.apiv3Err(new ErrorV3('This page path is invalid', 'invalid_path'), 400);
       }
 
+      if (isUserPage(newPagePath)) {
+        const isExistUser = await User.isExistUserByUserPagePath(newPagePath);
+        if (!isExistUser) {
+          return res.apiv3Err("Unable to duplicate a page under a non-existent user's user page");
+        }
+      }
+
       // check page existence
       const isExist = (await Page.exists({ path: newPagePath, isEmpty: false }));
       if (isExist) {

+ 17 - 1
apps/app/src/server/routes/attachment.js

@@ -1,3 +1,5 @@
+import { isCreatablePage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
+
 import { SupportedAction } from '~/interfaces/activity';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
@@ -134,6 +136,7 @@ const ApiResponse = require('../util/apiResponse');
 module.exports = function(crowi, app) {
   const Attachment = crowi.model('Attachment');
   const Page = crowi.model('Page');
+  const User = crowi.model('User');
   const GlobalNotificationSetting = crowi.model('GlobalNotificationSetting');
   const { attachmentService, globalNotificationService } = crowi;
 
@@ -261,7 +264,9 @@ module.exports = function(crowi, app) {
     else {
       res.set({
         'Content-Type': attachment.fileFormat,
-        'Content-Security-Policy': "script-src 'unsafe-hashes'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+        // eslint-disable-next-line max-len
+        'Content-Security-Policy': "script-src 'unsafe-hashes'; style-src 'self' 'unsafe-inline'; object-src 'none'; require-trusted-types-for 'script'; media-src 'self'; default-src 'none';",
+        'Content-Disposition': `inline;filename*=UTF-8''${encodeURIComponent(attachment.originalName)}`,
       });
     }
   }
@@ -468,6 +473,17 @@ module.exports = function(crowi, app) {
     if (pageId == null) {
       logger.debug('Create page before file upload');
 
+      if (!isCreatablePage(pagePath)) {
+        return res.json(ApiResponse.error(`Could not use the path '${pagePath}'`));
+      }
+
+      if (isUserPage(pagePath)) {
+        const isExistUser = await User.isExistUserByUserPagePath(pagePath);
+        if (!isExistUser) {
+          return res.json(ApiResponse.error("Unable to create a page under a non-existent user's user page"));
+        }
+      }
+
       const isAclEnabled = crowi.aclService.isAclEnabled();
       const grant = isAclEnabled ? Page.GRANT_OWNER : Page.GRANT_PUBLIC;
 

+ 2 - 0
apps/app/src/server/routes/tag.js

@@ -107,6 +107,8 @@ module.exports = function(crowi, app) {
    *                properties:
    *                  pageId:
    *                    $ref: '#/components/schemas/Page/properties/_id'
+   *                  revisionId:
+   *                    $ref: '#/components/schemas/Revision/properties/_id'
    *                  tags:
    *                    $ref: '#/components/schemas/Tags'
    *        responses:

+ 11 - 7
apps/app/src/server/service/search-delegator/elasticsearch-client.ts

@@ -53,9 +53,9 @@ export default class ElasticsearchClient {
   };
 
   cluster = {
-    health: (params: ES7RequestParams.ClusterHealth & estypes.ClusterHealthRequest)
+    health: ()
     : Promise<ES7ApiResponse<ClusterHealthResponse> | estypes.ClusterHealthResponse> =>
-      this.client instanceof ES7Client ? this.client.cluster.health(params) : this.client.cluster.health(params),
+      this.client instanceof ES7Client ? this.client.cluster.health() : this.client.cluster.health(),
   };
 
   indices = {
@@ -71,9 +71,11 @@ export default class ElasticsearchClient {
     : Promise<IndicesExistsResponse | estypes.IndicesExistsResponse> =>
       this.client instanceof ES7Client ? (await this.client.indices.exists(params)).body as IndicesExistsResponse : this.client.indices.exists(params),
 
-    existsAlias: (params: ES7RequestParams.IndicesExistsAlias & estypes.IndicesExistsAliasRequest)
-    : Promise<ES7ApiResponse<IndicesExistsAliasResponse> | estypes.IndicesExistsAliasResponse> =>
-      this.client instanceof ES7Client ? this.client.indices.existsAlias(params) : this.client.indices.existsAlias(params),
+    existsAlias: async(params: ES7RequestParams.IndicesExistsAlias & estypes.IndicesExistsAliasRequest)
+    : Promise<IndicesExistsAliasResponse | estypes.IndicesExistsAliasResponse> =>
+      this.client instanceof ES7Client
+        ? (await this.client.indices.existsAlias(params)).body as IndicesExistsAliasResponse
+        : this.client.indices.existsAlias(params),
 
     // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
     putAlias: (params: ES7RequestParams.IndicesPutAlias & estypes.IndicesPutAliasRequest) =>
@@ -107,8 +109,10 @@ export default class ElasticsearchClient {
     return this.client instanceof ES7Client ? this.client.ping() : this.client.ping();
   }
 
-  reindex(params: ES7RequestParams.Reindex & estypes.ReindexRequest): Promise<ES7ApiResponse<ReindexResponse> | estypes.ReindexResponse> {
-    return this.client instanceof ES7Client ? this.client.reindex(params) : this.client.reindex(params);
+  reindex(indexName: string, tmpIndexName: string): Promise<ES7ApiResponse<ReindexResponse> | estypes.ReindexResponse> {
+    return this.client instanceof ES7Client
+      ? this.client.reindex({ wait_for_completion: false, body: { source: { index: indexName }, dest: { index: tmpIndexName } } })
+      : this.client.reindex({ wait_for_completion: false, source: { index: indexName }, dest: { index: tmpIndexName } });
   }
 
   async search(params: ES7RequestParams.Search & estypes.SearchRequest): Promise<SearchResponse | estypes.SearchResponse> {

+ 15 - 24
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -7,7 +7,7 @@ import streamToPromise from 'stream-to-promise';
 
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import {
-  IFormattedSearchResult, ISearchResult, SORT_AXIS, SORT_ORDER,
+  ISearchResult, ISearchResultData, SORT_AXIS, SORT_ORDER,
 } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 
@@ -58,7 +58,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   elasticsearch: any;
 
-  client: any;
+  client: ElasticsearchClient;
 
   queries: any;
 
@@ -80,7 +80,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     this.isElasticsearchV7 = elasticsearchVersion === 7;
 
     this.isElasticsearchReindexOnBoot = this.configManager.getConfig('crowi', 'app:elasticsearchReindexOnBoot');
-    this.client = null;
 
     // In Elasticsearch RegExp, we don't need to used ^ and $.
     // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
@@ -184,14 +183,14 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    */
   async getInfo() {
     const info = await this.client.nodes.info();
-    if (!info._nodes || !info.nodes) {
+    if (!info != null) {
       throw new Error('There is no nodes');
     }
 
     let esVersion = 'unknown';
     const esNodeInfos = {};
 
-    for (const [nodeName, nodeInfo] of Object.entries<any>(info.nodes)) {
+    for (const [nodeName, nodeInfo] of Object.entries<any>(info)) {
       esVersion = nodeInfo.version;
 
       const filteredInfo = {
@@ -275,24 +274,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
     const tmpIndexName = `${indexName}-tmp`;
 
-    const reindexRequest = this.isElasticsearchV7
-      ? {
-        waitForCompletion: false,
-        body: {
-          source: { index: indexName },
-          dest: { index: tmpIndexName },
-        },
-      }
-      : {
-        wait_for_completion: false,
-        source: { index: indexName },
-        dest: { index: tmpIndexName },
-      };
-
     try {
       // reindex to tmp index
       await this.createIndex(tmpIndexName);
-      await client.reindex(reindexRequest);
+      await client.reindex(indexName, tmpIndexName);
 
       // update alias
       await client.indices.updateAliases({
@@ -345,7 +330,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     }
 
     // create alias
-    const { body: isExistsAlias } = await client.indices.existsAlias({ name: aliasName, index: indexName });
+    const isExistsAlias = await client.indices.existsAlias({ name: aliasName, index: indexName });
     if (!isExistsAlias) {
       await client.indices.putAlias({
         name: aliasName,
@@ -653,7 +638,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
    *   data: [ pages ...],
    * }
    */
-  async searchKeyword(query): Promise<IFormattedSearchResult> {
+  async searchKeyword(query): Promise<ISearchResult<ISearchResultData>> {
 
     // for debug
     if (process.env.NODE_ENV === 'development') {
@@ -674,10 +659,16 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
     const searchResponse = await this.client.search(query);
 
+    const _total = searchResponse?.hits?.total;
+    let total = 0;
+    if (typeof _total === 'object') {
+      total = _total.value;
+    }
+
     return {
       meta: {
+        total,
         took: searchResponse.took,
-        total: searchResponse.hits.total.value,
         hitsCount: searchResponse.hits.hits.length,
       },
       data: searchResponse.hits.hits.map((elm) => {
@@ -944,7 +935,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
   }
 
-  async search(data: SearchableData<ESQueryTerms>, user, userGroups, option?): Promise<ISearchResult<unknown>> {
+  async search(data: SearchableData<ESQueryTerms>, user, userGroups, option?): Promise<ISearchResult<ISearchResultData>> {
     const { queryString, terms } = data;
 
     if (terms == null) {

+ 2 - 0
apps/app/src/services/renderer/renderer.tsx

@@ -6,6 +6,7 @@ import sanitize, { defaultSchema as rehypeSanitizeDefaultSchema } from 'rehype-s
 import slug from 'rehype-slug';
 import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
+import remarkFrontmatter from 'remark-frontmatter';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
 import toc from 'remark-toc';
@@ -99,6 +100,7 @@ export const generateCommonOptions = (pagePath: string|undefined): RendererOptio
       emoji,
       pukiwikiLikeLinker,
       growiDirective,
+      remarkFrontmatter,
     ],
     remarkRehypeOptions: {
       clobberPrefix: '', // remove clobber prefix

+ 4 - 0
apps/app/src/stores/context.tsx

@@ -104,6 +104,10 @@ export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRRe
   return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData, { fallbackData: false });
 };
 
+export const useIsEnabledMarp = (initialData?: boolean) : SWRResponse<boolean, Error> => {
+  return useContextSWR<boolean, Error>('isEnabledMarp', initialData, { fallbackData: false });
+};
+
 export const useIsSlackConfigured = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useContextSWR<boolean, Error>('isSlackConfigured', initialData);
 };

+ 27 - 0
apps/app/src/stores/slide-viewer-renderer.tsx

@@ -0,0 +1,27 @@
+import useSWR, { type SWRResponse } from 'swr';
+
+import type { RendererOptions } from '~/interfaces/renderer-options';
+import { useRendererConfig } from '~/stores/context';
+import { useCurrentPagePath } from '~/stores/page';
+
+
+export const usePresentationViewOptions = (): SWRResponse<RendererOptions, Error> => {
+  const { data: currentPagePath } = useCurrentPagePath();
+  const { data: rendererConfig } = useRendererConfig();
+
+  const isAllDataValid = currentPagePath != null && rendererConfig != null;
+
+  return useSWR(
+    isAllDataValid
+      ? ['presentationViewOptions', currentPagePath, rendererConfig]
+      : null,
+    async([, currentPagePath, rendererConfig]) => {
+      const { generatePresentationViewOptions } = await import('~/client/services/renderer/slide-viewer-renderer');
+      return generatePresentationViewOptions(rendererConfig, currentPagePath);
+    },
+    {
+      revalidateOnFocus: false,
+      revalidateOnReconnect: false,
+    },
+  );
+};

+ 11 - 13
apps/app/test/integration/models/user.test.js

@@ -73,7 +73,6 @@ describe('User', () => {
         expect(user).toBeInstanceOf(User);
         expect(user.name).toBe('Example for User Test');
       });
-
     });
 
   });
@@ -119,20 +118,19 @@ describe('User', () => {
   });
 
   describe('User Utilities', () => {
-    describe('Get username from path', () => {
-      test('found', () => {
-        let username = null;
-        username = User.getUsernameByPath('/user/sotarok');
-        expect(username).toEqual('sotarok');
-
-        username = User.getUsernameByPath('/user/some.user.name12/'); // with slash
-        expect(username).toEqual('some.user.name12');
+    describe('Get user exists from user page path', () => {
+      test('found', async() => {
+        const userPagePath = '/user/usertest';
+        const isExist = await User.isExistUserByUserPagePath(userPagePath);
+
+        expect(isExist).toBe(true);
       });
 
-      test('not found', () => {
-        let username = null;
-        username = User.getUsernameByPath('/the/page/is/not/related/to/user/page');
-        expect(username).toBeNull();
+      test('not found', async() => {
+        const userPagePath = '/user/usertest-hoge';
+        const isExist = await User.isExistUserByUserPagePath(userPagePath);
+
+        expect(isExist).toBe(false);
       });
     });
   });

+ 12 - 0
apps/app/test/integration/service/questionnaire-cron.test.ts

@@ -138,6 +138,14 @@ describe('QuestionnaireCronService', () => {
 
   beforeAll(async() => {
     crowi = await getInstance();
+    const User = crowi.model('User');
+    await User.create({
+      name: 'Example for Questionnaire Service Test',
+      username: 'questionnaire cron test user',
+      email: 'questionnaireCronTestUser@example.com',
+      password: 'usertestpass',
+      createdAt: '2020-01-01',
+    });
   });
 
   beforeEach(async() => {
@@ -267,6 +275,8 @@ describe('QuestionnaireCronService', () => {
       growiInfo: {
         version: '1.0',
         appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        installedAt: new Date('2000-01-01'),
+        installedAtByOldestUser: new Date('2020-01-01'),
         type: 'cloud',
         currentUsersCount: 100,
         currentActiveUsersCount: 50,
@@ -293,6 +303,8 @@ describe('QuestionnaireCronService', () => {
       growiInfo: {
         version: '1.0',
         appSiteUrlHashed: 'c83e8d2a1aa87b2a3f90561be372ca523bb931e2d00013c1d204879621a25b90',
+        installedAt: new Date('2000-01-01'),
+        installedAtByOldestUser: new Date('2020-01-01'),
         type: 'cloud',
         currentUsersCount: 100,
         currentActiveUsersCount: 50,

+ 11 - 0
apps/app/test/integration/service/questionnaire.test.ts

@@ -1,3 +1,5 @@
+import mongoose from 'mongoose';
+
 import { StatusType } from '../../../src/features/questionnaire/interfaces/questionnaire-answer-status';
 import QuestionnaireAnswerStatus from '../../../src/features/questionnaire/server/models/questionnaire-answer-status';
 import QuestionnaireOrder from '../../../src/features/questionnaire/server/models/questionnaire-order';
@@ -18,6 +20,13 @@ describe('QuestionnaireService', () => {
       'security:passport-github:isEnabled': true,
     });
 
+    await mongoose.model('Config').create({
+      ns: 'crowi',
+      key: 'app:installed',
+      value: true,
+      createdAt: '2000-01-01',
+    });
+
     crowi.setupQuestionnaireService();
 
     const User = crowi.model('User');
@@ -49,6 +58,8 @@ describe('QuestionnaireService', () => {
       expect(growiInfo).toEqual({
         activeExternalAccountTypes: ['saml', 'github'],
         appSiteUrl: 'http://growi.test.jp',
+        installedAt: new Date('2000-01-01'),
+        installedAtByOldestUser: new Date('2000-01-01'),
         attachmentType: 'aws',
         deploymentType: 'growi-docker-compose',
         type: 'on-premise',

+ 20 - 1
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -1,5 +1,5 @@
 import {
-  isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths,
+  isMovablePage, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
 } from './index';
 
 describe.concurrent('isMovablePage test', () => {
@@ -117,4 +117,23 @@ describe.concurrent('isCreatablePage test', () => {
       expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
     });
   });
+
+
+  describe.concurrent('Test getUsernameByPath', () => {
+    test.concurrent('found', () => {
+      const username = getUsernameByPath('/user/sotarok');
+      expect(username).toBe('sotarok');
+    });
+
+    test.concurrent('found with slash', () => {
+      const username = getUsernameByPath('/user/some.user.name12/');
+      expect(username).toBe('some.user.name12');
+    });
+
+    test.concurrent('not found', () => {
+      const username = getUsernameByPath('/the/page/is/not/related/to/user/page');
+      expect(username).toBeNull();
+    });
+  });
+
 });

+ 17 - 0
packages/core/src/utils/page-path-utils/index.ts

@@ -287,5 +287,22 @@ export const generateChildrenRegExp = (path: string): RegExp => {
   return new RegExp(`^${path}(\\/[^/]+)\\/?$`);
 };
 
+/**
+ * Get username from user page path
+ * @param path string
+ * @returns string | null
+ */
+export const getUsernameByPath = (path: string): string | null => {
+  let username: string | null = null;
+  // https://regex101.com/r/qj4SfD/1
+  const match = path.match(/^\/user\/([^/]+)\/?/);
+  if (match) {
+    username = match[1];
+  }
+
+  return username;
+};
+
+
 export * from './is-top-page';
 export * from './collect-ancestor-paths';

+ 4 - 1
packages/presentation/package.json

@@ -31,10 +31,13 @@
     "@marp-team/marp-core": "^3.6.0",
     "@types/reveal.js": "^4.4.1",
     "eslint-plugin-regex": "^1.8.0",
+    "reveal.js": "^4.4.0",
+    "mdast-util-frontmatter": "^1.0.0",
+    "mdast-util-gfm": "^2.0.1",
+    "mdast-util-to-markdown": "^1.3.0",
     "hast-util-sanitize": "^4.1.0",
     "hast-util-select": "^5.0.5",
     "react-markdown": "^8.0.7",
-    "reveal.js": "^4.4.0",
     "unified": "^10.1.2",
     "unist-util-find-after": "^4.0.0",
     "unist-util-visit": "^4.0.0"

+ 55 - 0
packages/presentation/src/components/GrowiSlides.tsx

@@ -0,0 +1,55 @@
+import Head from 'next/head';
+import { ReactMarkdown } from 'react-markdown/lib/react-markdown';
+
+import type { PresentationOptions } from '../consts';
+import { MARP_CONTAINER_CLASS_NAME, presentationMarpit, slideMarpit } from '../services/growi-marpit';
+import * as extractSections from '../services/renderer/extract-sections';
+
+
+import './Slides.global.scss';
+import { PresentationRichSlideSection, RichSlideSection } from './RichSlideSection';
+
+
+type Props = {
+  options: PresentationOptions,
+  children?: string,
+  presentation?: boolean,
+}
+
+export const GrowiSlides = (props: Props): JSX.Element => {
+  const {
+    options, children, presentation,
+  } = props;
+  const {
+    rendererOptions, isDarkMode, disableSeparationByHeader,
+  } = options;
+
+  if (rendererOptions == null || rendererOptions.remarkPlugins == null || rendererOptions.components == null) {
+    return <></>;
+  }
+
+  rendererOptions.remarkPlugins.push([
+    extractSections.remarkPlugin,
+    {
+      isDarkMode,
+      disableSeparationByHeader,
+    },
+  ]);
+  rendererOptions.components.section = presentation ? PresentationRichSlideSection : RichSlideSection;
+
+  const marpit = presentation ? presentationMarpit : slideMarpit;
+  const { css } = marpit.render('');
+  return (
+    <>
+      <Head>
+        <style>{css}</style>
+      </Head>
+      <div className={`slides ${MARP_CONTAINER_CLASS_NAME}`}>
+        <ReactMarkdown {...rendererOptions}>
+          { children ?? '## No Contents' }
+        </ReactMarkdown>
+      </div>
+    </>
+  );
+
+};

+ 30 - 0
packages/presentation/src/components/MarpSlides.tsx

@@ -0,0 +1,30 @@
+import Head from 'next/head';
+
+import './Slides.global.scss';
+import { presentationMarpit, slideMarpit } from '../services/growi-marpit';
+
+type Props = {
+  children?: string,
+  presentation?: boolean,
+}
+
+export const MarpSlides = (props: Props): JSX.Element => {
+  const { children, presentation } = props;
+
+  const marpit = presentation ? presentationMarpit : slideMarpit;
+  const { html, css } = marpit.render(children ?? '');
+  return (
+    <>
+      <Head>
+        <style>{css}</style>
+      </Head>
+      <div
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{
+          // DOMpurify.sanitize delete elements in <svg> so sanitize is not used here.
+          __html: html,
+        }}
+      />
+    </>
+  );
+};

+ 10 - 6
packages/presentation/src/components/Presentation.tsx

@@ -3,8 +3,9 @@ import React, { useEffect } from 'react';
 import Reveal from 'reveal.js';
 
 import type { PresentationOptions } from '../consts';
+import { parseSlideFrontmatterInMarkdown } from '../services/parse-slide-frontmatter';
 
-import { MARP_CONTAINER_CLASS_NAME, Slides } from './Slides';
+import { Slides } from './Slides';
 
 import 'reveal.js/dist/reveal.css';
 import './Presentation.global.scss';
@@ -18,6 +19,7 @@ const baseRevealOptions: Reveal.Options = {
   height: 720,
   maxScale: 1.2,
   slideNumber: 'c/t',
+  display: '',
 };
 
 /**
@@ -32,13 +34,17 @@ const removeAllHiddenElements = () => {
 
 export type PresentationProps = {
   options: PresentationOptions,
+  isEnabledMarp: boolean,
   children?: string,
 }
 
 export const Presentation = (props: PresentationProps): JSX.Element => {
-  const { options, children } = props;
+  const { options, isEnabledMarp, children } = props;
   const { revealOptions } = options;
 
+  const [marp] = parseSlideFrontmatterInMarkdown(children);
+  const hasMarpFlag = isEnabledMarp && marp;
+
   useEffect(() => {
     let deck: Reveal.Api;
     if (children != null) {
@@ -57,10 +63,8 @@ export const Presentation = (props: PresentationProps): JSX.Element => {
   }, [children, revealOptions]);
 
   return (
-    <div className={`grw-presentation ${styles['grw-presentation']} reveal ${MARP_CONTAINER_CLASS_NAME}`}>
-      <div className="slides">
-        <Slides options={options}>{children}</Slides>
-      </div>
+    <div className={`grw-presentation ${styles['grw-presentation']} reveal`}>
+      <Slides options={options} hasMarpFlag={hasMarpFlag} presentation>{children}</Slides>
     </div>
   );
 };

+ 43 - 0
packages/presentation/src/components/RichSlideSection.tsx

@@ -0,0 +1,43 @@
+import React, { ReactNode } from 'react';
+
+type RichSlideSectionProps = {
+  children: ReactNode,
+  presentation?: boolean,
+}
+
+const OriginalRichSlideSection = React.memo((props: RichSlideSectionProps): JSX.Element => {
+  const { children, presentation } = props;
+
+  return (
+    <section className={presentation ? 'm-2' : 'shadow rounded m-2'}>
+      <svg data-marpit-svg="" viewBox="0 0 1280 720">
+        <foreignObject width="1280" height="720">
+          <section>
+            {children}
+          </section>
+        </foreignObject>
+      </svg>
+    </section>
+  );
+});
+
+export const RichSlideSection = React.memo((props: RichSlideSectionProps): JSX.Element => {
+  const { children } = props;
+
+  return (
+    <OriginalRichSlideSection>
+      {children}
+    </OriginalRichSlideSection>
+  );
+});
+
+
+export const PresentationRichSlideSection = React.memo((props: RichSlideSectionProps): JSX.Element => {
+  const { children } = props;
+
+  return (
+    <OriginalRichSlideSection presentation>
+      {children}
+    </OriginalRichSlideSection>
+  );
+});

+ 1 - 1
packages/presentation/src/components/Slides.global.scss

@@ -1,4 +1,4 @@
-div.marpit > div.slides > section :is(pre, marp-pre) {
+div.slides.marpit > section :is(pre, marp-pre) {
   padding: 0;
   border: none;
 }

+ 11 - 42
packages/presentation/src/components/Slides.tsx

@@ -1,57 +1,26 @@
-import React from 'react';
-
-import { Marp } from '@marp-team/marp-core';
-import { Element } from '@marp-team/marpit';
-import Head from 'next/head';
-import { ReactMarkdown } from 'react-markdown/lib/react-markdown';
 
 import type { PresentationOptions } from '../consts';
-import * as extractSections from '../services/renderer/extract-sections';
-
-import './Slides.global.scss';
-
-export const MARP_CONTAINER_CLASS_NAME = 'marpit';
 
+import { GrowiSlides } from './GrowiSlides';
+import { MarpSlides } from './MarpSlides';
 
-const marp = new Marp({
-  container: [
-    new Element('div', { class: MARP_CONTAINER_CLASS_NAME }),
-    new Element('div', { class: 'slides' }),
-  ],
-  inlineSVG: false,
-  emoji: undefined,
-  html: false,
-  math: false,
-});
-
+import './Slides.global.scss';
 
 type Props = {
   options: PresentationOptions,
   children?: string,
+  hasMarpFlag?: boolean,
+  presentation?: boolean,
 }
 
 export const Slides = (props: Props): JSX.Element => {
-  const { options, children } = props;
-  const { rendererOptions, isDarkMode, disableSeparationByHeader } = options;
-
-  rendererOptions.remarkPlugins?.push([
-    extractSections.remarkPlugin,
-    {
-      isDarkMode,
-      disableSeparationByHeader,
-    },
-  ]);
-
-  const { css } = marp.render('', { htmlAsArray: true });
+  const {
+    options, children, hasMarpFlag, presentation,
+  } = props;
 
   return (
-    <>
-      <Head>
-        <style>{css}</style>
-      </Head>
-      <ReactMarkdown {...rendererOptions}>
-        { children ?? '## No Contents' }
-      </ReactMarkdown>
-    </>
+    hasMarpFlag
+      ? <MarpSlides presentation={presentation}>{children}</MarpSlides>
+      : <GrowiSlides options={options} presentation={presentation}>{children}</GrowiSlides>
   );
 };

+ 1 - 0
packages/presentation/src/index.ts

@@ -1,2 +1,3 @@
 export * from './components/Presentation';
 export * from './components/Slides';
+export * from './services/renderer/slides';

+ 63 - 0
packages/presentation/src/services/growi-marpit.ts

@@ -0,0 +1,63 @@
+import { Marp } from '@marp-team/marp-core';
+import { Element } from '@marp-team/marpit';
+
+export const MARP_CONTAINER_CLASS_NAME = 'marpit';
+
+// Add data-line to Marp slide.
+// https://github.com/marp-team/marp-vscode/blob/d9af184ed12b65bb28c0f328e250955d548ac1d1/src/plugins/line-number.ts
+const sourceMapIgnoredTypesForElements = ['inline', 'marpit_slide_open'];
+const lineNumber = (md) => {
+
+  const { marpit_slide_containers_open: marpitSlideContainersOpen } = md.renderer.rules;
+
+  // Enable line sync by per slides
+  md.renderer.rules.marpit_slide_containers_open = (tks, i, opts, env, slf) => {
+    const slide = tks.slice(i + 1).find(t => t.type === 'marpit_slide_open');
+
+    if (slide?.map?.length) {
+      tks[i].attrJoin('class', 'has-data-line');
+      tks[i].attrSet('data-line', slide.map[0]);
+    }
+
+    const renderer = marpitSlideContainersOpen || slf.renderToken;
+    return renderer.call(slf, tks, i, opts, env, slf);
+  };
+  // Enables line sync per elements
+  md.core.ruler.push('marp_growi_source_map_attr', (state) => {
+    for (const token of state.tokens) {
+      if (
+        token.map?.length
+        && !sourceMapIgnoredTypesForElements.includes(token.type)
+      ) {
+        token.attrJoin('class', 'has-data-line');
+        token.attrSet('data-line', token.map[0]);
+      }
+    }
+  });
+};
+
+export const slideMarpit = new Marp({
+  container: [
+    new Element('div', { class: `slides ${MARP_CONTAINER_CLASS_NAME}` }),
+  ],
+  slideContainer: [
+    new Element('section', { class: 'shadow rounded m-2' }),
+  ],
+  inlineSVG: true,
+  emoji: undefined,
+  html: false,
+  math: false,
+}).use(lineNumber);
+
+export const presentationMarpit = new Marp({
+  container: [
+    new Element('div', { class: `slides ${MARP_CONTAINER_CLASS_NAME}` }),
+  ],
+  slideContainer: [
+    new Element('section', { class: 'm-2' }),
+  ],
+  inlineSVG: true,
+  emoji: undefined,
+  html: false,
+  math: false,
+});

+ 43 - 0
packages/presentation/src/services/parse-slide-frontmatter.ts

@@ -0,0 +1,43 @@
+import remarkFrontmatter from 'remark-frontmatter';
+import remarkParse from 'remark-parse';
+import remarkStringify from 'remark-stringify';
+import { unified } from 'unified';
+
+
+export const parseSlideFrontmatter = (frontmatter: string): [boolean, boolean] => {
+
+  let marp = false;
+  let slide = false;
+
+  const lines = frontmatter.split('\n');
+  lines.forEach((line) => {
+    const [key, value] = line.split(':').map(part => part.trim());
+    if (key === 'marp' && value === 'true') {
+      marp = true;
+    }
+    if (key === 'slide' && value === 'true') {
+      slide = true;
+    }
+  });
+
+  return [marp, slide];
+};
+
+export const parseSlideFrontmatterInMarkdown = (markdown?: string): [boolean, boolean] => {
+
+  let marp = false;
+  let slide = false;
+
+  unified()
+    .use(remarkParse)
+    .use(remarkStringify)
+    .use(remarkFrontmatter, ['yaml'])
+    .use(() => ((obj) => {
+      if (obj.children[0]?.type === 'yaml') {
+        [marp, slide] = parseSlideFrontmatter(obj.children[0]?.value as string);
+      }
+    }))
+    .process(markdown as string);
+
+  return [marp, slide];
+};

+ 1 - 1
packages/presentation/src/services/renderer/extract-sections.ts

@@ -62,7 +62,7 @@ export const remarkPlugin: Plugin<[ExtractSectionsPluginParams]> = (options) =>
       tree,
       startCondition,
       (node, index, parent: Parent) => {
-        if (parent == null || parent.type !== 'root') {
+        if (parent == null || parent.type !== 'root' || node.type === 'yaml') {
           return;
         }
 

+ 89 - 0
packages/presentation/src/services/renderer/slides.ts

@@ -0,0 +1,89 @@
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type { Root } from 'mdast';
+import { frontmatterToMarkdown } from 'mdast-util-frontmatter';
+import { gfmToMarkdown } from 'mdast-util-gfm';
+import { toMarkdown } from 'mdast-util-to-markdown';
+import type { Plugin } from 'unified';
+import type { Node } from 'unist';
+import { visit } from 'unist-util-visit';
+
+import { parseSlideFrontmatter } from '../parse-slide-frontmatter';
+
+const SUPPORTED_ATTRIBUTES = ['children', 'marp'];
+
+const nodeToMakrdown = (node: Node) => {
+  return toMarkdown(node as Root, {
+    extensions: [
+      frontmatterToMarkdown(['yaml']),
+      gfmToMarkdown(),
+    ],
+  });
+};
+
+// Allow node tree to be converted to markdown
+const removeCustomType = (tree: Node) => {
+  // Try toMarkdown() on all Node.
+  visit(tree, (node) => {
+    const tmp = node?.children;
+    node.children = [];
+    try {
+      nodeToMakrdown(node);
+    }
+    catch (err) {
+      // if some Node cannot convert to markdown, change to a convertible type
+      node.type = 'text';
+      node.value = '';
+    }
+    finally {
+      node.children = tmp;
+    }
+  });
+};
+
+const rewriteNode = (tree: Node, node: Node, isEnabledMarp: boolean) => {
+
+  const [marp, slide] = parseSlideFrontmatter(node.value as string);
+
+  if ((marp && isEnabledMarp) || slide) {
+
+    removeCustomType(tree);
+
+    const markdown = nodeToMakrdown(tree);
+
+    const newNode: Node = {
+      type: 'root',
+      data: {},
+      position: tree.position,
+      children: tree.children,
+    };
+
+    const data = newNode.data ?? (newNode.data = {});
+    tree.children = [newNode];
+    data.hName = 'slide';
+    data.hProperties = {
+      marp: marp ? '' : undefined,
+      children: markdown,
+    };
+  }
+};
+
+type SlidePluginParams = {
+  isEnabledMarp: boolean,
+}
+
+export const remarkPlugin: Plugin<[SlidePluginParams]> = (options) => {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === 'yaml' && node.value != null) {
+        rewriteNode(tree, node, options.isEnabledMarp);
+      }
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['slide'],
+  attributes: {
+    slide: SUPPORTED_ATTRIBUTES,
+  },
+};

+ 3 - 0
packages/preset-templates/dist/marp-example/en_US/meta.json

@@ -0,0 +1,3 @@
+{
+  "title": "Presentation examples with Marp"
+}

+ 325 - 0
packages/preset-templates/dist/marp-example/en_US/template.md

@@ -0,0 +1,325 @@
+---
+marp: true
+---
+
+Marp
+===
+
+![h:250](https://avatars1.githubusercontent.com/u/20685754?v=4)
+
+##### Markdown presentation ecosystem
+
+###### by Marp Team ([@marp-team][marp-team])
+
+[marp-team]: https://github.com/marp-team
+[marpit]: https://github.com/marp-team/marpit
+[marp-core]: https://github.com/marp-team/marp-core
+[marp-cli]: https://github.com/marp-team/marp-cli
+[marp-vscode]: https://github.com/marp-team/marp-vscode
+
+---
+
+# Features
+
+- :memo: **Write slide deck with plain Markdown** (CommonMark)
+- :factory: Built on [Marpit framework][marpit]: A brand-new skinny framework for creating slide deck
+- :gear: [Marp Core][marp-core]: Easy to start using the core engine and built-in themes via npm
+- :tv: [Marp CLI][marp-cli]: Convert Markdown into HTML, PDF, PPTX, and images
+- :vs: [Marp for VS Code][marp-vscode]: Live-preview your deck while editting
+- and more...
+
+---
+
+# How to write slides?
+
+Split pages by horizontal ruler (e.g. `---`). It's very simple.
+
+```markdown
+# Slide 1
+
+foobar
+
+---
+
+# Slide 2
+
+foobar
+```
+
+---
+
+# Directives
+
+Marp has extended syntax called **"Directives"** to support creating beautiful slides.
+
+Insert front-matter to the top of Markdown:
+
+```
+---
+theme: default
+---
+```
+
+or HTML comment to anywhere:
+
+```html
+<!-- theme: default -->
+```
+
+https://marpit.marp.app/directives
+
+---
+
+## [Global directives](https://marpit.marp.app/directives?id=global-directives)
+
+- `theme`: Choose theme
+- `size`: Choose slide size from `16:9` and `4:3` *(except Marpit framework)*
+- [`headingDivider`](https://marpit.marp.app/directives?id=heading-divider): Instruct to divide slide pages at before of specified heading levels
+
+```
+---
+theme: gaia
+size: 4:3
+---
+
+# Content
+```
+
+> Marp can use [built-in themes in Marp Core](https://github.com/marp-team/marp-core/tree/master/themes#readme): `default`, `gaia`, and `uncover`.
+
+---
+
+## [Local directives](https://marpit.marp.app/directives?id=local-directives)
+
+These are the setting value per slide pages.
+
+- `paginate`: Show pagination by set `true`
+- `header`: Specify the contents for header
+- `footer`: Specify the contents for footer
+- `class`: Set HTML class for current slide
+- `color`: Set text color
+- `backgroundColor`: Set background color
+
+---
+
+### Spot directives
+
+Local directives would apply to **defined page and following pages**.
+
+They can apply to single page by using underscore prefix such as `_class`.
+
+![bg right 95%](https://marpit.marp.app/assets/directives.png)
+
+---
+
+### Example
+
+This page is using invert color scheme [defined in Marp built-in theme](https://github.com/marp-team/marp-core/tree/master/themes#readme).
+
+<!-- _class: invert -->
+
+```html
+<!-- _class: invert -->
+```
+
+---
+
+# [Image syntax](https://marpit.marp.app/image-syntax)
+
+You can resize image size and apply filters through keywords: `width` (`w`), `height` (`h`), and filter CSS keywords.
+
+```markdown
+![width:100px height:100px](image.png)
+```
+
+```markdown
+![blur sepia:50%](filters.png)
+```
+
+Please refer [resizing image syntax](https://marpit.marp.app/image-syntax?id=resizing-image) and [a list of CSS filters](https://marpit.marp.app/image-syntax?id=image-filters).
+
+![w:100px h:100px](https://avatars1.githubusercontent.com/u/20685754?v=4) ![w:100 h:100 blur sepia:50%](https://avatars1.githubusercontent.com/u/20685754?v=4)
+
+---
+
+# [Background image](https://marpit.marp.app/image-syntax?id=slide-backgrounds)
+
+You can set background image for a slide by using `bg` keyword.
+
+```markdown
+![bg opacity](https://yhatt-marp-cli-example.netlify.com/assets/gradient.jpg)
+```
+
+![bg opacity](https://yhatt-marp-cli-example.netlify.com/assets/gradient.jpg)
+
+---
+
+## Multiple backgrounds ([Marpit's advanced backgrounds](https://marpit.marp.app/image-syntax?id=advanced-backgrounds))
+
+Marp can use multiple background images.
+
+```markdown
+![bg blur:3px](https://fakeimg.pl/800x600/fff/ccc/?text=A)
+![bg blur:3px](https://fakeimg.pl/800x600/eee/ccc/?text=B)
+![bg blur:3px](https://fakeimg.pl/800x600/ddd/ccc/?text=C)
+```
+
+Also can change alignment direction by including `vertical` keyword.
+
+![bg blur:3px](https://fakeimg.pl/800x600/fff/ccc/?text=A)
+![bg blur:3px](https://fakeimg.pl/800x600/eee/ccc/?text=B)
+![bg blur:3px](https://fakeimg.pl/800x600/ddd/ccc/?text=C)
+
+---
+
+## [Split background](https://marpit.marp.app/image-syntax?id=split-backgrounds)
+
+Marp can use [Deckset](https://docs.deckset.com/English.lproj/Media/01-background-images.html#split-slides) style split background(s).
+
+Make a space for background by `bg` + `left` / `right` keywords.
+
+```markdown
+![bg right](image.jpg)
+```
+
+![bg right](https://images.unsplash.com/photo-1568488789544-e37edf90eb67?crop=entropy&cs=tinysrgb&fit=crop&fm=jpg&h=720&ixlib=rb-1.2.1&q=80&w=640)
+
+<!-- _footer: "*Photo by [Mohamed Nohassi](https://unsplash.com/@coopery?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)*" -->
+
+---
+
+## [Fragmented list](https://marpit.marp.app/fragmented-list)
+
+Marp will parse a list with asterisk marker as the fragmented list for appearing contents one by one. (_**Only for exported HTML** by [Marp CLI][marp-cli] / [Marp for VS Code][marp-vscode]_)
+
+```markdown
+# Bullet list
+
+- One
+- Two
+- Three
+
+---
+
+# Fragmented list
+
+* One
+* Two
+* Three
+```
+
+---
+
+## Math typesetting (only for [Marp Core][marp-core])
+
+[KaTeX](https://katex.org/) math typesetting such as $ax^2+bc+c$ can use with [Pandoc's math syntax](https://pandoc.org/MANUAL.html#math).
+
+$$I_{xx}=\int\int_Ry^2f(x,y)\cdot{}dydx$$
+
+```tex
+$ax^2+bc+c$
+```
+```tex
+$$I_{xx}=\int\int_Ry^2f(x,y)\cdot{}dydx$$
+```
+
+---
+
+## Auto-scaling (only for [Marp Core][marp-core])
+
+*Several built-in themes* are supported auto-scaling for code blocks and math typesettings.
+
+```text
+Too long code block will be scaled-down automatically. ------------>
+```
+```text
+Too long code block will be scaled-down automatically. ------------------------>
+```
+```text
+Too long code block will be scaled-down automatically. ------------------------------------------------>
+```
+
+---
+
+##### <!--fit--> Auto-fitting header (only for [Marp Core][marp-core])
+##### <!--fit--> is available by annotating `<!--fit-->` in headings.
+
+<br />
+
+```html
+## <!--fit--> Auto-fitting header (only for Marp Core)
+```
+
+---
+
+## [Theme CSS](https://marpit.marp.app/theme-css)
+
+Marp uses `<section>` as the container of each slide. And others are same as styling for plain Markdown. The customized theme can use in [Marp CLI][marp-cli] and [Marp for VS Code][marp-vscode].
+
+```css
+/* @theme your-theme */
+
+@import 'default';
+
+section {
+  /* Specify slide size */
+  width: 960px;
+  height: 720px;
+}
+
+h1 {
+  font-size: 30px;
+  color: #c33;
+}
+```
+
+---
+
+## [Tweak style in Markdown](https://marpit.marp.app/theme-css?id=tweak-style-through-markdown)
+
+`<style>` tag in Markdown will work in the context of theme CSS.
+
+```markdown
+---
+theme: default
+---
+
+<style>
+section {
+  background: yellow;
+}
+</style>
+
+Re-painted yellow background, ha-ha.
+```
+
+> You can also add custom styling by class like `section.custom-class { ... }`.
+> Apply style through `<!-- _class: custom-class -->`.
+
+---
+
+## [Scoped style](https://marpit.marp.app/theme-css?id=scoped-style)
+
+If you want one-shot styling for current page, you can use `<style scoped>`.
+
+```markdown
+<style scoped>
+a {
+  color: green;
+}
+</style>
+
+![Green link!](https://marp.app/)
+```
+
+<style scoped>
+a { color: green; }
+</style>
+
+---
+
+# Enjoy writing slides! :v: <!--fit-->
+
+##### ![w:1em h:1em](https://avatars1.githubusercontent.com/u/20685754?v=4)  Marp: Markdown presentation ecosystem — https://marp.app/
+
+###### by Marp Team ([@marp-team][marp-team])

+ 49 - 0
yarn.lock

@@ -8779,6 +8779,13 @@ fault@^1.0.0:
   dependencies:
     format "^0.2.0"
 
+fault@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/fault/-/fault-2.0.1.tgz#d47ca9f37ca26e4bd38374a7c500b5a384755b6c"
+  integrity sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==
+  dependencies:
+    format "^0.2.0"
+
 fb-watchman@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.0.tgz#54e9abf7dfa2f26cd9b1636c588c1afc05de5d58"
@@ -11895,6 +11902,15 @@ mdast-util-from-markdown@^1.0.0:
     unist-util-stringify-position "^3.0.0"
     uvu "^0.5.0"
 
+mdast-util-frontmatter@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/mdast-util-frontmatter/-/mdast-util-frontmatter-1.0.1.tgz#79c46d7414eb9d3acabe801ee4a70a70b75e5af1"
+  integrity sha512-JjA2OjxRqAa8wEG8hloD0uTU0kdn8kbtOWpPP94NBkfAlbxn4S8gCGf/9DwFtEeGPXrDcNXdiDjVaRdUFqYokw==
+  dependencies:
+    "@types/mdast" "^3.0.0"
+    mdast-util-to-markdown "^1.3.0"
+    micromark-extension-frontmatter "^1.0.0"
+
 mdast-util-gfm-autolink-literal@^1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-1.0.2.tgz#4032dcbaddaef7d4f2f3768ed830475bb22d3970"
@@ -11952,6 +11968,19 @@ mdast-util-gfm@^2.0.0:
     mdast-util-gfm-task-list-item "^1.0.0"
     mdast-util-to-markdown "^1.0.0"
 
+mdast-util-gfm@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/mdast-util-gfm/-/mdast-util-gfm-2.0.2.tgz#e92f4d8717d74bdba6de57ed21cc8b9552e2d0b6"
+  integrity sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==
+  dependencies:
+    mdast-util-from-markdown "^1.0.0"
+    mdast-util-gfm-autolink-literal "^1.0.0"
+    mdast-util-gfm-footnote "^1.0.0"
+    mdast-util-gfm-strikethrough "^1.0.0"
+    mdast-util-gfm-table "^1.0.0"
+    mdast-util-gfm-task-list-item "^1.0.0"
+    mdast-util-to-markdown "^1.0.0"
+
 mdast-util-math@^2.0.0:
   version "2.0.1"
   resolved "https://registry.yarnpkg.com/mdast-util-math/-/mdast-util-math-2.0.1.tgz#141b8e7e43731d2a7423c5eb8c0335c05d257ad2"
@@ -12172,6 +12201,16 @@ micromark-core-commonmark@^1.0.0, micromark-core-commonmark@^1.0.1:
     micromark-util-types "^1.0.1"
     uvu "^0.5.0"
 
+micromark-extension-frontmatter@^1.0.0:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/micromark-extension-frontmatter/-/micromark-extension-frontmatter-1.1.1.tgz#2946643938e491374145d0c9aacc3249e38a865f"
+  integrity sha512-m2UH9a7n3W8VAH9JO9y01APpPKmNNNs71P0RbknEmYSaZU5Ghogv38BYO94AI5Xw6OYfxZRdHZZ2nYjs/Z+SZQ==
+  dependencies:
+    fault "^2.0.0"
+    micromark-util-character "^1.0.0"
+    micromark-util-symbol "^1.0.0"
+    micromark-util-types "^1.0.0"
+
 micromark-extension-gfm-autolink-literal@^1.0.0:
   version "1.0.3"
   resolved "https://registry.yarnpkg.com/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-1.0.3.tgz#dc589f9c37eaff31a175bab49f12290edcf96058"
@@ -14913,6 +14952,16 @@ remark-emoji@^3.0.2:
     node-emoji "^1.11.0"
     unist-util-visit "^4.1.0"
 
+remark-frontmatter@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/remark-frontmatter/-/remark-frontmatter-4.0.1.tgz#84560f7ccef114ef076d3d3735be6d69f8922309"
+  integrity sha512-38fJrB0KnmD3E33a5jZC/5+gGAC2WKNiPw1/fdXJvijBlhA7RCsvJklrYJakS0HedninvaCYW8lQGf9C918GfA==
+  dependencies:
+    "@types/mdast" "^3.0.0"
+    mdast-util-frontmatter "^1.0.0"
+    micromark-extension-frontmatter "^1.0.0"
+    unified "^10.0.0"
+
 remark-gfm@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/remark-gfm/-/remark-gfm-3.0.1.tgz#0b180f095e3036545e9dddac0e8df3fa5cfee54f"