Răsfoiți Sursa

Merge branch 'master' into feat/ldap-group-sync

Futa Arai 2 ani în urmă
părinte
comite
83bbf1517b
49 a modificat fișierele cu 1046 adăugiri și 76 ștergeri
  1. 1 0
      apps/app/config/logger/config.dev.js
  2. 2 0
      apps/app/next.config.js
  3. 1 0
      apps/app/package.json
  4. 10 4
      apps/app/public/static/locales/en_US/admin.json
  5. 0 1
      apps/app/public/static/locales/en_US/translation.json
  6. 11 5
      apps/app/public/static/locales/ja_JP/admin.json
  7. 0 1
      apps/app/public/static/locales/ja_JP/translation.json
  8. 10 4
      apps/app/public/static/locales/zh_CN/admin.json
  9. 0 1
      apps/app/public/static/locales/zh_CN/translation.json
  10. 11 0
      apps/app/src/client/services/AdminCustomizeContainer.js
  11. 8 0
      apps/app/src/client/services/renderer/renderer.tsx
  12. 90 0
      apps/app/src/client/services/renderer/slide-viewer-renderer.tsx
  13. 29 0
      apps/app/src/components/Admin/Customize/CustomizeFunctionSetting.tsx
  14. 1 1
      apps/app/src/components/Admin/Security/SecuritySetting.jsx
  15. 1 1
      apps/app/src/components/LoginForm.tsx
  16. 0 2
      apps/app/src/components/PageEditor/Editor.tsx
  17. 5 1
      apps/app/src/components/PagePresentationModal.tsx
  18. 33 0
      apps/app/src/components/ReactMarkdownComponents/SlideViewer.tsx
  19. 2 0
      apps/app/src/features/questionnaire/interfaces/growi-info.ts
  20. 2 0
      apps/app/src/features/questionnaire/server/models/schema/growi-info.ts
  21. 18 0
      apps/app/src/features/questionnaire/server/service/questionnaire.ts
  22. 1 0
      apps/app/src/interfaces/services/renderer.ts
  23. 4 1
      apps/app/src/pages/[[...path]].page.tsx
  24. 5 1
      apps/app/src/pages/_private-legacy-pages.page.tsx
  25. 1 0
      apps/app/src/pages/_search.page.tsx
  26. 4 1
      apps/app/src/pages/me/[[...path]].page.tsx
  27. 4 1
      apps/app/src/pages/share/[[...path]].page.tsx
  28. 2 0
      apps/app/src/server/models/config.ts
  29. 6 0
      apps/app/src/server/routes/apiv3/customize-setting.js
  30. 2 0
      apps/app/src/services/renderer/renderer.tsx
  31. 4 0
      apps/app/src/stores/context.tsx
  32. 27 0
      apps/app/src/stores/slide-viewer-renderer.tsx
  33. 12 0
      apps/app/test/integration/service/questionnaire-cron.test.ts
  34. 11 0
      apps/app/test/integration/service/questionnaire.test.ts
  35. 4 1
      packages/presentation/package.json
  36. 55 0
      packages/presentation/src/components/GrowiSlides.tsx
  37. 30 0
      packages/presentation/src/components/MarpSlides.tsx
  38. 10 6
      packages/presentation/src/components/Presentation.tsx
  39. 43 0
      packages/presentation/src/components/RichSlideSection.tsx
  40. 1 1
      packages/presentation/src/components/Slides.global.scss
  41. 11 42
      packages/presentation/src/components/Slides.tsx
  42. 1 0
      packages/presentation/src/index.ts
  43. 63 0
      packages/presentation/src/services/growi-marpit.ts
  44. 43 0
      packages/presentation/src/services/parse-slide-frontmatter.ts
  45. 1 1
      packages/presentation/src/services/renderer/extract-sections.ts
  46. 89 0
      packages/presentation/src/services/renderer/slides.ts
  47. 3 0
      packages/preset-templates/dist/marp-example/en_US/meta.json
  48. 325 0
      packages/preset-templates/dist/marp-example/en_US/template.md
  49. 49 0
      yarn.lock

+ 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

@@ -186,6 +186,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",

+ 10 - 4
apps/app/public/static/locales/en_US/admin.json

@@ -48,8 +48,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:",
@@ -1092,6 +1098,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}}",

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

@@ -55,9 +55,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>タグのコンテンツをカスタマイズできます。以下のプレースホルダーは自動的に置換されます:",
@@ -1100,6 +1106,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}}を削除しました",

+ 10 - 4
apps/app/public/static/locales/zh_CN/admin.json

@@ -56,8 +56,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>将替换为页面名称/路径。",
@@ -1101,6 +1107,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": "成功更新分享链接设置",

+ 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();
@@ -75,7 +78,7 @@ const PagePresentationModal = (): JSX.Element => {
         </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,
@@ -85,6 +88,7 @@ const PagePresentationModal = (): JSX.Element => {
               },
               isDarkMode,
             }}
+            isEnabledMarp={isEnabledMarp}
           >
             {markdown}
           </Presentation>

+ 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';

+ 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,

+ 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';
@@ -150,6 +150,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  isEnabledMarp: boolean,
 
   isSlackConfigured: boolean,
   // isMailerSetup: boolean,
@@ -218,6 +219,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);
@@ -594,6 +596,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

@@ -122,6 +122,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,
 

+ 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);

+ 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,
+    },
+  );
+};

+ 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',

+ 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

@@ -8188,6 +8188,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"
@@ -11309,6 +11316,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"
@@ -11366,6 +11382,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"
@@ -11586,6 +11615,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"
@@ -14296,6 +14335,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"