Jelajahi Sumber

Merge remote-tracking branch 'origin/dev/7.1.x' into feat/openai-vector-searching

Yuki Takei 1 tahun lalu
induk
melakukan
339d65a1e0
100 mengubah file dengan 1244 tambahan dan 643 penghapusan
  1. 5 0
      .changeset/real-onions-vanish.md
  2. 0 1
      .github/mergify.yml
  3. 12 5
      .github/workflows/ci-app-prod.yml
  4. 8 0
      apps/app/next.config.js
  5. 17 15
      apps/app/package.json
  6. 1 1
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  7. 1 1
      apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts
  8. 6 0
      apps/app/public/static/locales/en_US/admin.json
  9. 1 1
      apps/app/public/static/locales/en_US/commons.json
  10. 8 1
      apps/app/public/static/locales/en_US/translation.json
  11. 6 0
      apps/app/public/static/locales/fr_FR/admin.json
  12. 1 1
      apps/app/public/static/locales/fr_FR/commons.json
  13. 8 1
      apps/app/public/static/locales/fr_FR/translation.json
  14. 6 0
      apps/app/public/static/locales/ja_JP/admin.json
  15. 1 1
      apps/app/public/static/locales/ja_JP/commons.json
  16. 8 1
      apps/app/public/static/locales/ja_JP/translation.json
  17. 6 0
      apps/app/public/static/locales/zh_CN/admin.json
  18. 1 1
      apps/app/public/static/locales/zh_CN/commons.json
  19. 8 1
      apps/app/public/static/locales/zh_CN/translation.json
  20. 34 1
      apps/app/src/client/components/Admin/Security/SecuritySetting.jsx
  21. 0 19
      apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx
  22. 7 2
      apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx
  23. 4 22
      apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx
  24. 24 1
      apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx
  25. 1 1
      apps/app/src/client/components/Page/SlideRenderer.tsx
  26. 3 3
      apps/app/src/client/components/PageComment.tsx
  27. 16 11
      apps/app/src/client/components/PageComment/CommentControl.tsx
  28. 3 3
      apps/app/src/client/components/PageComment/CommentEditor.tsx
  29. 34 1
      apps/app/src/client/components/PageEditor/OptionsSelector.tsx
  30. 1 1
      apps/app/src/client/components/PagePresentationModal.tsx
  31. 3 4
      apps/app/src/client/components/ReactMarkdownComponents/Header.tsx
  32. 5 5
      apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx
  33. 1 17
      apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx
  34. 11 0
      apps/app/src/client/services/AdminGeneralSecurityContainer.js
  35. 28 5
      apps/app/src/client/services/renderer/renderer.tsx
  36. 12 6
      apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx
  37. 90 0
      apps/app/src/features/callout/components/CalloutViewer.module.scss
  38. 64 0
      apps/app/src/features/callout/components/CalloutViewer.tsx
  39. 1 0
      apps/app/src/features/callout/components/index.ts
  40. 2 0
      apps/app/src/features/callout/index.ts
  41. 23 0
      apps/app/src/features/callout/services/callout.ts
  42. 5 0
      apps/app/src/features/callout/services/consts.ts
  43. 1 0
      apps/app/src/features/callout/services/index.ts
  44. 2 1
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  45. 5 5
      apps/app/src/features/mermaid/services/mermaid.ts
  46. 1 2
      apps/app/src/interfaces/in-app-notification.ts
  47. 2 5
      apps/app/src/interfaces/renderer-options.ts
  48. 3 1
      apps/app/src/interfaces/services/rehype-sanitize.ts
  49. 27 0
      apps/app/src/migrations/20240924181317-changed-status-in-inappnotifications-from-unread-to-unopened.js
  50. 7 3
      apps/app/src/pages/[[...path]].page.tsx
  51. 26 2
      apps/app/src/server/middlewares/exclude-read-only-user.ts
  52. 1 0
      apps/app/src/server/models/config.ts
  53. 2 6
      apps/app/src/server/models/in-app-notification.ts
  54. 0 12
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  55. 3 0
      apps/app/src/server/routes/apiv3/security-settings/index.js
  56. 4 4
      apps/app/src/server/routes/index.js
  57. 3 11
      apps/app/src/server/service/in-app-notification.ts
  58. 9 0
      apps/app/src/services/renderer/recommended-whitelist.spec.ts
  59. 2 1
      apps/app/src/services/renderer/recommended-whitelist.ts
  60. 2 3
      apps/app/src/services/renderer/rehype-plugins/add-class.ts
  61. 1 1
      apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts
  62. 2 2
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  63. 1 1
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  64. 1 1
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  65. 8 10
      apps/app/src/services/renderer/rehype-plugins/relocate-toc.ts
  66. 11 11
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  67. 23 0
      apps/app/src/services/renderer/remark-plugins/codeblock.ts
  68. 9 10
      apps/app/src/services/renderer/remark-plugins/xsv-to-table.ts
  69. 3 1
      apps/app/src/services/renderer/renderer.tsx
  70. 4 0
      apps/app/src/stores-universal/context.tsx
  71. 1 2
      apps/app/src/stores/renderer.tsx
  72. 1 1
      apps/slackbot-proxy/package.json
  73. 1 1
      package.json
  74. 19 18
      packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx
  75. 7 3
      packages/editor/src/client/components-internal/playground/Playground.tsx
  76. 5 2
      packages/editor/src/client/components-internal/playground/PlaygroundController.tsx
  77. 1 4
      packages/editor/src/client/services-internal/file-dropzone/use-file-dropzone/use-file-dropzone.ts
  78. 14 11
      packages/editor/src/client/stores/use-editor-settings.ts
  79. 2 0
      packages/editor/src/consts/editor-settings.ts
  80. 1 0
      packages/editor/src/consts/index.ts
  81. 10 0
      packages/editor/src/consts/paste-mode.ts
  82. 12 12
      packages/presentation/package.json
  83. 1 1
      packages/presentation/src/client/components/GrowiSlides.tsx
  84. 11 7
      packages/presentation/src/client/components/RichSlideSection.tsx
  85. 1 1
      packages/presentation/src/client/consts/index.ts
  86. 1 1
      packages/presentation/src/client/services/renderer/extract-sections.ts
  87. 5 4
      packages/presentation/src/services/use-slides-by-frontmatter.ts
  88. 5 5
      packages/remark-attachment-refs/package.json
  89. 6 4
      packages/remark-attachment-refs/src/client/services/renderer/refs.ts
  90. 3 3
      packages/remark-drawio/package.json
  91. 17 10
      packages/remark-drawio/src/services/renderer/remark-drawio.ts
  92. 17 18
      packages/remark-growi-directive/package.json
  93. 7 2
      packages/remark-growi-directive/src/index.js
  94. 0 32
      packages/remark-growi-directive/src/mdast-util-growi-directive/complex-types.d.ts
  95. 0 4
      packages/remark-growi-directive/src/mdast-util-growi-directive/consts.js
  96. 177 0
      packages/remark-growi-directive/src/mdast-util-growi-directive/index.d.ts
  97. 2 267
      packages/remark-growi-directive/src/mdast-util-growi-directive/index.js
  98. 276 0
      packages/remark-growi-directive/src/mdast-util-growi-directive/lib/index.js
  99. 1 2
      packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/directive-leaf.js
  100. 1 2
      packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/directive-text.js

+ 5 - 0
.changeset/real-onions-vanish.md

@@ -0,0 +1,5 @@
+---
+"@growi/remark-growi-directive": minor
+---
+
+Convert unit test by tape to Vitest

+ 0 - 1
.github/mergify.yml

@@ -25,7 +25,6 @@ pull_request_rules:
       - '#approved-reviews-by >= 1'
       - '#changes-requested-reviews-by = 0'
       - '#review-requested = 0'
-      - check-success = check-title
     actions:
       queue:
 

+ 12 - 5
.github/workflows/ci-app-prod.yml

@@ -19,11 +19,6 @@ on:
       - '!apps/app/docker/**'
       - packages/**
   pull_request:
-    branches:
-      - master
-      - dev/7.*.x
-      - dev/6.*.x
-      - release/*
     types: [opened, reopened, synchronize]
     paths:
       - .github/mergify.yml
@@ -47,6 +42,12 @@ jobs:
 
   test-prod-node18:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    if: |
+      ( github.event_name == 'push'
+        || github.base_ref == 'master'
+        || github.base_ref == 'dev/7.*.x'
+        || startsWith( github.base_ref, 'release/' )
+        || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
       node-version: 18.x
       skip-e2e-test: true
@@ -56,6 +57,12 @@ jobs:
 
   test-prod-node20:
     uses: weseek/growi/.github/workflows/reusable-app-prod.yml@master
+    if: |
+      ( github.event_name == 'push'
+        || github.base_ref == 'master'
+        || github.base_ref == 'dev/7.*.x'
+        || startsWith( github.base_ref, 'release/' )
+        || startsWith( github.head_ref, 'mergify/merge-queue/' ))
     with:
       node-version: 20.x
       skip-e2e-test: ${{ contains( github.event.pull_request.labels.*.name, 'dependencies' ) }}

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

@@ -48,6 +48,14 @@ const getTranspilePackages = () => {
     'emoticon',
     'direction', // for hast-util-select
     'bcp-47-match', // for hast-util-select
+    'parse-entities',
+    'character-reference-invalid',
+    'is-hexadecimal',
+    'is-alphabetical',
+    'is-alphanumerical',
+    'github-slugger',
+    'html-url-attributes',
+    'estree-util-is-identifier-name',
     ...listPrefixedPackages(['remark-', 'rehype-', 'hast-', 'mdast-', 'micromark-', 'unist-']),
   ];
 

+ 17 - 15
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.0.21-RC.0",
+  "version": "7.1.0-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -119,7 +119,7 @@
     "extensible-custom-error": "^0.0.7",
     "form-data": "^4.0.0",
     "graceful-fs": "^4.1.11",
-    "hast-util-select": "^5.0.5",
+    "hast-util-select": "^6.0.2",
     "helmet": "^4.6.0",
     "http-errors": "^2.0.0",
     "i18next": "^23.10.1",
@@ -170,7 +170,7 @@
     "react-error-boundary": "^3.1.4",
     "react-i18next": "^14.1.0",
     "react-image-crop": "^8.3.0",
-    "react-markdown": "^8.0.7",
+    "react-markdown": "^9.0.1",
     "react-multiline-clamp": "^2.0.0",
     "react-scroll": "^1.8.7",
     "react-stickynode": "^4.1.1",
@@ -179,18 +179,18 @@
     "reactstrap": "^9.2.2",
     "reconnecting-websocket": "^4.4.0",
     "redis": "^3.0.2",
-    "rehype-katex": "^6.0.2",
-    "rehype-raw": "^6.1.1",
-    "rehype-sanitize": "^5.0.1",
-    "rehype-slug": "^5.0.1",
+    "rehype-katex": "^7.0.0",
+    "rehype-raw": "^7.0.0",
+    "rehype-sanitize": "^6.0.0",
+    "rehype-slug": "^6.0.0",
     "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",
-    "remark-wiki-link": "^1.0.4",
+    "remark-breaks": "^4.0.0",
+    "remark-emoji": "^5.0.0",
+    "remark-frontmatter": "^5.0.0",
+    "remark-gfm": "^4.0.0",
+    "remark-math": "^6.0.0",
+    "remark-toc": "^9.0.0",
+    "remark-wiki-link": "^2.0.1",
     "sanitize-filename": "^1.6.3",
     "socket.io": "^4.7.5",
     "stream-to-promise": "^3.0.0",
@@ -275,7 +275,9 @@
     "react-hotkeys": "^2.0.0",
     "react-input-autosize": "^3.0.0",
     "react-toastify": "^9.1.3",
-    "rehype-rewrite": "^3.0.6",
+    "remark-directive": "^3.0.0",
+    "remark-github-admonitions-to-directives": "^2.0.0",
+    "rehype-rewrite": "^4.0.2",
     "replacestream": "^4.0.3",
     "sass": "^1.53.0",
     "simple-load-script": "^1.0.2",

+ 1 - 1
apps/app/playwright/20-basic-features/access-to-page.spec.ts

@@ -23,7 +23,7 @@ test('/Sandbox/Math is successfully loaded', async({ page }) => {
   await page.goto('/Sandbox/Math');
 
   // Expect the Math-specific elements to be present
-  await expect(page.locator('.math').first()).toBeVisible();
+  await expect(page.locator('.katex').first()).toBeVisible();
 });
 
 test('Sandbox with edit is successfully loaded', async({ page }) => {

+ 1 - 1
apps/app/playwright/21-basic-features-for-guest/access-to-page.spec.ts

@@ -15,7 +15,7 @@ test('/Sandbox/math is successfully loaded', async({ page }) => {
   await page.goto('/Sandbox/Math');
 
   // Check if the math elements are visible
-  await expect(page.locator('.math').first()).toBeVisible();
+  await expect(page.locator('.katex').first()).toBeVisible();
 });
 
 test('Access to /me page', async({ page }) => {

+ 6 - 0
apps/app/public/static/locales/en_US/admin.json

@@ -15,6 +15,7 @@
     "scope_of_page_disclosure": "Scope of page disclosure",
     "set_point": "Set point",
     "Guest Users Access": "Guest users access",
+    "readonly_users_access": "ROM users' access",
     "always_hidden": "Always hidden",
     "always_displayed": "Always displayed",
     "displayed_or_hidden": "Hidden / Displayed",
@@ -37,6 +38,7 @@
     "page_delete_rights": "Delete rights",
     "page_delete": "Page Delete",
     "page_delete_completely": "Page Delete Completely",
+    "comment_manage_rights": "Comment management rights",
     "other_options": "Other options",
     "deletion_explanation": "Restricts users who can trash the selected single page.",
     "complete_deletion_explanation": "Restricts users who can completely delete  selected single page.",
@@ -85,6 +87,10 @@
       "deny": "Deny (Registered users only)",
       "readonly": "Accept (Guests can read only)"
     },
+    "rom_users_comment": {
+      "deny": "Deny (Prohibit ROM users from comment management)",
+      "accept": "Allow (ROM users can manage comments)"
+    },
     "registration_mode": {
       "open": "Open (Anyone can register)",
       "restricted": "Restricted (Requires approval by administrators)",

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

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publish transfer key",
     "transfer_key_limit": "Transfer keys are valid for 1 hour after issuance.",
     "once_transfer_key_used": "Once the transfer key is used for transfer, it cannot be used for any other transfer.",
-    "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
+    "transfer_to_growi_cloud": "For more details, please click <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>here.</a>"
   }
 }

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

@@ -331,6 +331,12 @@
     "theme": "Theme",
     "keymap": "Keymap",
     "indent": "Indent",
+    "paste": {
+      "title": "Paste behavior",
+      "both": "Both",
+      "text": "Text only",
+      "file": "File only"
+    },
     "editor_config": "Editor Config",
     "Show active line": "Show active line",
     "auto_format_table": "Auto format table",
@@ -349,7 +355,8 @@
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
     "no_user_found": "No user found",
     "reply": "Reply",
-    "delete_comment": "Delete comment?"
+    "delete_comment": "Delete comment?",
+    "comment_management_is_not_allowed": "Comment management is not allowed."
   },
   "page_api_error": {
     "notfound_or_forbidden": "Original page is not found or forbidden.",

+ 6 - 0
apps/app/public/static/locales/fr_FR/admin.json

@@ -15,6 +15,7 @@
     "scope_of_page_disclosure": "Confidentialité de la page",
     "set_point": "Valeur",
     "Guest Users Access": "Accès invité",
+    "readonly_users_access": "Accès des utilisateurs ROM",
     "always_hidden": "Toujours caché",
     "always_displayed": "Toujours affiché",
     "displayed_or_hidden": "Caché / Affiché",
@@ -37,6 +38,7 @@
     "page_delete_rights": "Droits de suppression",
     "page_delete": "Suppression de page",
     "page_delete_completely": "Suppression complète de page",
+    "comment_manage_rights": "Droits de gestion des commentaires",
     "other_options": "Paramètres supplémentaires",
     "deletion_explanation": "Restreindre les utilisateurs pouvant supprimer une page.",
     "complete_deletion_explanation": "Restreindre les utilisateurs pouvant supprimer complètement une page.",
@@ -85,6 +87,10 @@
       "deny": "Refuser (Utilisateurs inscrits seulement)",
       "readonly": "Autoriser (Lecture seule)"
     },
+    "rom_users_comment": {
+      "deny": "Refuser (Interdire la gestion des commentaires aux utilisateurs ROM)",
+      "accept": "Autoriser (Les utilisateurs ROM peuvent gérer les commentaires)"
+    },
     "registration_mode": {
       "open": "Ouvert (Tout le monde peut s'inscrire)",
       "restricted": "Restreint (Requiert l'approbation d'administrateurs)",

+ 1 - 1
apps/app/public/static/locales/fr_FR/commons.json

@@ -157,6 +157,6 @@
     "publish_transfer_key": "Publier la clé de transfert",
     "transfer_key_limit": "Les clés de transfert sont valides durant une heure.",
     "once_transfer_key_used": "Les clés de transfert sont à usage unique.",
-    "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
+    "transfer_to_growi_cloud": "Pour plus de détails, veuillez cliquer <a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>ici.</a>"
   }
 }

+ 8 - 1
apps/app/public/static/locales/fr_FR/translation.json

@@ -331,6 +331,12 @@
     "theme": "Thème",
     "keymap": "Touches",
     "indent": "Indentation",
+    "paste": {
+      "title": "Comportement du collage",
+      "both": "Les deux",
+      "text": "Texte seulement",
+      "file": "Fichier seulement"
+    },
     "editor_config": "Configuration de l'éditeur",
     "Show active line": "Montrer la ligne active",
     "auto_format_table": "Formattage les tables",
@@ -349,7 +355,8 @@
     "display_the_page_when_posting_this_comment": "Afficher la page en postant le commentaire",
     "no_user_found": "Aucun utilisateur trouvé",
     "reply": "Répondre",
-    "delete_comment": "Supprimer?"
+    "delete_comment": "Supprimer?",
+    "comment_management_is_not_allowed": "La gestion des commentaires n'est pas autorisée."
   },
   "page_api_error": {
     "notfound_or_forbidden": "Page originale introuvable ou accès restreint.",

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

@@ -24,6 +24,7 @@
     "scope_of_page_disclosure": "ページの公開範囲",
     "set_point": "設定値",
     "Guest Users Access":"ゲストユーザーのアクセス",
+    "readonly_users_access": "ROMユーザーのアクセス",
     "always_hidden": "非表示 (固定)",
     "always_displayed": "表示 (固定)",
     "displayed_or_hidden": "非表示 / 表示",
@@ -46,6 +47,7 @@
     "page_delete_rights": "ページの削除権限",
     "page_delete": "ゴミ箱に入れる",
     "page_delete_completely": "完全に削除する",
+    "comment_manage_rights": "コメントの操作権限",
     "other_options": "その他のオプション",
     "deletion_explanation": "ページをゴミ箱に入れることができるユーザーを制限します。",
     "complete_deletion_explanation": "ページを完全削除することができるユーザーを制限します。",
@@ -94,6 +96,10 @@
       "deny": "拒否 (アカウントを持つユーザーのみ利用可能)",
       "readonly": "許可 (ゲストユーザーも閲覧のみ可能)"
     },
+    "rom_users_comment": {
+      "deny": "拒否 (ROMユーザーのコメント操作を禁止)",
+      "accept": "許可 (ROMユーザーもコメント操作可能)"
+    },
     "registration_mode": {
       "open": "公開 (だれでも登録可能)",
       "restricted": "制限 (登録完了には管理者の承認が必要)",

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

@@ -159,6 +159,6 @@
     "publish_transfer_key": "移行キーを発行する",
     "transfer_key_limit": "※ 移行キーの有効期限は発行から1時間となります。",
     "once_transfer_key_used": "※ 移行キーは一度移行に利用するとそれ以降はご利用いただけなくなります。",
-    "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
+    "transfer_to_growi_cloud": "※ 詳しくは <a href='{{documentationUrl}}ja/admin-guide/management-cookbook/g2g-transfer.html'> GROWI お引越し機能</a>をご確認ください。"
   }
 }

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

@@ -364,6 +364,12 @@
     "theme": "テーマ",
     "keymap": "キーマップ",
     "indent": "インデント",
+    "paste": {
+      "title": "ペースト時の動作",
+      "both": "両方",
+      "text": "テキストのみ",
+      "file": "ファイルのみ"
+    },
     "editor_config": "エディタ設定",
     "Show active line": "アクティブ行をハイライト",
     "auto_format_table": "表の自動整形",
@@ -382,7 +388,8 @@
     "display_the_page_when_posting_this_comment": "投稿時のページを表示する",
     "no_user_found": "ユーザー名が見つかりません",
     "reply": "返信",
-    "delete_comment": "コメントを削除しますか?"
+    "delete_comment": "コメントを削除しますか?",
+    "comment_management_is_not_allowed": "コメントの操作が許可されていません。"
   },
   "page_api_error": {
     "notfound_or_forbidden": "元のページが見つからないか、アクセス権がありません。",

+ 6 - 0
apps/app/public/static/locales/zh_CN/admin.json

@@ -27,6 +27,7 @@
     "always_hidden": "总是隐藏",
     "displayed_or_hidden": "隐藏 / 显示",
     "Guest Users Access": "来宾用户访问",
+    "readonly_users_access": "ROM用户的访问",
 		"Fixed by env var": "这是由env var<code>%s=%s</code>修复的。",
 		"register_limitation": "注册限制",
 		"register_limitation_desc": "限制新用户注册",
@@ -46,6 +47,7 @@
     "page_delete_rights": "删除权限",
     "page_delete": "删除",
     "page_delete_completely": "彻底删除",
+    "comment_manage_rights": "评论管理权限",
     "other_options": "其他选项",
     "deletion_explanation": "限制用户对选定的单一页面进行垃圾处理。",
     "complete_deletion_explanation": "限制可以完全删除所选单页的用户。",
@@ -94,6 +96,10 @@
 			"deny": "拒绝(仅限注册用户)",
 			"readonly": "接受(来宾可以只读)"
 		},
+    "rom_users_comment": {
+      "deny": "拒绝 (禁止ROM用户操作评论)",
+      "accept": "允许 (ROM用户可以管理评论)"
+    },
 		"registration_mode": {
 			"open": "打开(任何人都可以注册)",
 			"restricted": "受限(需要管理员批准)",

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

@@ -160,6 +160,6 @@
     "publish_transfer_key": "发布迁移密钥",
     "transfer_key_limit": "迁移密钥在签发后一小时内有效。",
     "once_transfer_key_used": "一旦迁移密钥被用于迁移,它将不再可用于进一步的迁移。",
-    "transfer_to_growi_cloud": "有关更多详情,请点击<a href='https://{{documentationUrl}}/ja/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
+    "transfer_to_growi_cloud": "有关更多详情,请点击<a href='{{documentationUrl}}en/admin-guide/management-cookbook/g2g-transfer.html'>此处</a>。"
   }
 }

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

@@ -321,6 +321,12 @@
     "theme": "主题",
     "keymap": "键表",
     "indent": "缩进",
+    "paste": {
+      "title": "粘贴行为",
+      "both": "两者",
+      "text": "仅文本",
+      "file": "仅文件"
+    },
     "editor_config": "编辑器配置",
 		"Show active line": "显示活动行",
 		"auto_format_table": "自动格式化表格",
@@ -339,7 +345,8 @@
     "display_the_page_when_posting_this_comment": "Display the page when posting this comment",
     "no_user_found": "未找到用户名",
     "reply": "Reply",
-    "delete_comment": "Delete comment?"
+    "delete_comment": "Delete comment?",
+    "comment_management_is_not_allowed": "不允许操作评论。"
   },
   "page_api_error": {
     "notfound_or_forbidden": "未找到或禁止原始页。",

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

@@ -333,7 +333,7 @@ class SecuritySetting extends React.Component {
     const { t, adminGeneralSecurityContainer } = this.props;
     const {
       currentRestrictGuestMode, currentPageDeletionAuthority, currentPageCompleteDeletionAuthority,
-      currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority,
+      currentPageRecursiveDeletionAuthority, currentPageRecursiveCompleteDeletionAuthority, isRomUserAllowedToComment,
     } = adminGeneralSecurityContainer.state;
 
     const isButtonDisabledForDeletion = !validateDeleteConfigs(
@@ -509,6 +509,39 @@ class SecuritySetting extends React.Component {
           </div>
         </div>
 
+        <h4 className="mb-3">{t('security_settings.comment_manage_rights')}</h4>
+        <div className="row mb-4">
+          <div className="col-md-4 text-md-end py-2">
+            <strong>{t('security_settings.readonly_users_access')}</strong>
+          </div>
+          <div className="col-md-8">
+            <div className="dropdown">
+              <button
+                className={`btn btn-outline-secondary dropdown-toggle text-end col-12
+                            col-md-auto ${adminGeneralSecurityContainer.isWikiModeForced && 'disabled'}`}
+                type="button"
+                id="dropdownMenuButton"
+                data-bs-toggle="dropdown"
+                aria-haspopup="true"
+                aria-expanded="true"
+              >
+                <span className="float-start">
+                  {isRomUserAllowedToComment === true && t('security_settings.rom_users_comment.accept')}
+                  {isRomUserAllowedToComment === false && t('security_settings.rom_users_comment.deny')}
+                </span>
+              </button>
+              <div className="dropdown-menu" aria-labelledby="dropdownMenuButton">
+                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(false) }}>
+                  {t('security_settings.rom_users_comment.deny')}
+                </button>
+                <button className="dropdown-item" type="button" onClick={() => { adminGeneralSecurityContainer.switchIsRomUserAllowedToComment(true) }}>
+                  {t('security_settings.rom_users_comment.accept')}
+                </button>
+              </div>
+            </div>
+          </div>
+        </div>
+
         <h4>{t('security_settings.session')}</h4>
         <div className="row">
           <label className="text-start text-md-end col-md-3 col-form-label">{t('security_settings.max_age')}</label>

+ 0 - 19
apps/app/src/client/components/InAppNotification/InAppNotificationDropdown.tsx

@@ -6,19 +6,11 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-
-import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastError } from '~/client/util/toastr';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useDefaultSocket } from '~/stores/socket-io';
-import loggerFactory from '~/utils/logger';
 
 import InAppNotificationList from './InAppNotificationList';
 
-
-const logger = loggerFactory('growi:InAppNotificationDropdown');
-
-
 export const InAppNotificationDropdown = (): JSX.Element => {
   const { t } = useTranslation('commons');
 
@@ -36,16 +28,6 @@ export const InAppNotificationDropdown = (): JSX.Element => {
   const buttonRef = useRef(null);
   useRipple(buttonRef, { rippleColor: 'rgba(255, 255, 255, 0.3)' });
 
-  const updateNotificationStatus = async() => {
-    try {
-      await apiv3Post('/in-app-notification/read');
-    }
-    catch (err) {
-      toastError(err);
-      logger.error(err);
-    }
-  };
-
   useEffect(() => {
     if (socket != null) {
       socket.on('notificationUpdated', () => {
@@ -62,7 +44,6 @@ export const InAppNotificationDropdown = (): JSX.Element => {
 
   const toggleDropdownHandler = async() => {
     if (!isOpen && inAppNotificationUnreadStatusCount != null && inAppNotificationUnreadStatusCount > 0) {
-      await updateNotificationStatus();
       mutateInAppNotificationUnreadStatusCount();
     }
 

+ 7 - 2
apps/app/src/client/components/InAppNotification/InAppNotificationElm.tsx

@@ -1,10 +1,13 @@
-import React, { FC } from 'react';
+import type { FC } from 'react';
+import React from 'react';
 
 import type { HasObjectId } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { IInAppNotification, InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import type { IInAppNotification } from '~/interfaces/in-app-notification';
+import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
+import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 
 import { useModelNotification } from './PageNotification';
 
@@ -21,6 +24,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
 
   const Notification = modelNotificationUtils?.Notification;
   const publishOpen = modelNotificationUtils?.publishOpen;
+  const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
   if (Notification == null || publishOpen == null) {
     return <></>;
@@ -31,6 +35,7 @@ const InAppNotificationElm: FC<Props> = (props: Props) => {
       // set notification status "OPEND"
       await apiv3Post('/in-app-notification/open', { id: notification._id });
       onUnopenedNotificationOpend?.();
+      mutateNotificationCount();
     }
 
     publishOpen();

+ 4 - 22
apps/app/src/client/components/InAppNotification/InAppNotificationPage.tsx

@@ -1,46 +1,26 @@
 import type { FC } from 'react';
-import React, { useState, useEffect, useCallback } from 'react';
+import React, { useState } from 'react';
 
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useTranslation } from 'next-i18next';
 
-import { apiv3Put, apiv3Post } from '~/client/util/apiv3-client';
+import { apiv3Put } from '~/client/util/apiv3-client';
 import { InAppNotificationStatuses } from '~/interfaces/in-app-notification';
 import { useShowPageLimitationXL } from '~/stores-universal/context';
 import { useSWRxInAppNotifications, useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
-import loggerFactory from '~/utils/logger';
 
 import CustomNavAndContents from '../CustomNavigation/CustomNavAndContents';
 import PaginationWrapper from '../PaginationWrapper';
 
 import InAppNotificationList from './InAppNotificationList';
 
-
-const logger = loggerFactory('growi:InAppNotificationPage');
-
-
 export const InAppNotificationPage: FC = () => {
   const { t } = useTranslation('commons');
-  const { mutate } = useSWRxInAppNotificationStatus();
 
   const { data: showPageLimitationXL } = useShowPageLimitationXL();
 
   const limit = showPageLimitationXL != null ? showPageLimitationXL : 20;
 
-  const updateNotificationStatus = useCallback(async() => {
-    try {
-      await apiv3Post('/in-app-notification/read');
-      mutate();
-    }
-    catch (err) {
-      logger.error(err);
-    }
-  }, [mutate]);
-
-  useEffect(() => {
-    updateNotificationStatus();
-  }, [updateNotificationStatus]);
-
   const InAppNotificationCategoryByStatus = (status?: InAppNotificationStatuses) => {
     const [activePage, setActivePage] = useState(1);
     const offset = (activePage - 1) * limit;
@@ -56,6 +36,7 @@ export const InAppNotificationPage: FC = () => {
 
     const { data: notificationData, mutate: mutateNotificationData } = useSWRxInAppNotifications(limit, offset, categoryStatus);
     const { mutate: mutateAllNotificationData } = useSWRxInAppNotifications(limit, offset, undefined);
+    const { mutate: mutateNotificationCount } = useSWRxInAppNotificationStatus();
 
     const setAllNotificationPageNumber = (selectedPageNumber): void => {
       setActivePage(selectedPageNumber);
@@ -78,6 +59,7 @@ export const InAppNotificationPage: FC = () => {
       mutateNotificationData();
       // mutate notification statuses in 'ALL' Category
       mutateAllNotificationData();
+      mutateNotificationCount();
     };
 
 

+ 24 - 1
apps/app/src/client/components/NotAvailableForReadOnlyUser.tsx

@@ -2,7 +2,7 @@ import React from 'react';
 
 import { useTranslation } from 'next-i18next';
 
-import { useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsReadOnlyUser, useIsRomUserAllowedToComment } from '~/stores-universal/context';
 
 import { NotAvailable } from './NotAvailable';
 
@@ -26,3 +26,26 @@ export const NotAvailableForReadOnlyUser: React.FC<{
   );
 });
 NotAvailableForReadOnlyUser.displayName = 'NotAvailableForReadOnlyUser';
+
+export const NotAvailableIfReadOnlyUserNotAllowedToComment: React.FC<{
+  children: JSX.Element
+}> = React.memo(({ children }) => {
+  const { t } = useTranslation();
+  const { data: isReadOnlyUser } = useIsReadOnlyUser();
+
+  const { data: isRomUserAllowedToComment } = useIsRomUserAllowedToComment();
+
+  const isDisabled = !!isReadOnlyUser && !isRomUserAllowedToComment;
+  const title = t('page_comment.comment_management_is_not_allowed');
+
+  return (
+    <NotAvailable
+      isDisabled={isDisabled}
+      title={title}
+      classNamePrefix="grw-not-available-for-read-only-user"
+    >
+      {children}
+    </NotAvailable>
+  );
+});
+NotAvailableIfReadOnlyUserNotAllowedToComment.displayName = 'NotAvailableIfReadOnlyUserNotAllowedToComment';

+ 1 - 1
apps/app/src/client/components/Page/SlideRenderer.tsx

@@ -1,4 +1,4 @@
-import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+import type { Options as ReactMarkdownOptions } from 'react-markdown';
 
 import { usePresentationViewOptions } from '~/stores/renderer';
 

+ 3 - 3
apps/app/src/client/components/PageComment.tsx

@@ -20,7 +20,7 @@ import type { ICommentHasId, ICommentHasIdList } from '../../interfaces/comment'
 import { useSWRxPageComment } from '../../stores/comment';
 
 import { NotAvailableForGuest } from './NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from './NotAvailableForReadOnlyUser';
+import { NotAvailableIfReadOnlyUserNotAllowedToComment } from './NotAvailableForReadOnlyUser';
 import { Comment } from './PageComment/Comment';
 import { CommentEditor } from './PageComment/CommentEditor';
 import { DeleteCommentModal } from './PageComment/DeleteCommentModal';
@@ -183,7 +183,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
                 {(!isReadOnly && !showEditorIds.has(comment._id)) && (
                   <div className="d-flex flex-row-reverse">
                     <NotAvailableForGuest>
-                      <NotAvailableForReadOnlyUser>
+                      <NotAvailableIfReadOnlyUserNotAllowedToComment>
                         <button
                           type="button"
                           data-testid="comment-reply-button"
@@ -193,7 +193,7 @@ export const PageComment: FC<PageCommentProps> = memo((props: PageCommentProps):
                           <UserPicture user={currentUser} noLink noTooltip additionalClassName="me-2" />
                           <span className="material-symbols-outlined me-1 fs-5 pb-1">reply</span><small>{t('page_comment.reply')}...</small>
                         </button>
-                      </NotAvailableForReadOnlyUser>
+                      </NotAvailableIfReadOnlyUserNotAllowedToComment>
                     </NotAvailableForGuest>
                   </div>
                 )}

+ 16 - 11
apps/app/src/client/components/PageComment/CommentControl.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 
+import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
 
 type CommentControlProps = {
   onClickEditBtn: () => void,
@@ -13,17 +14,21 @@ export const CommentControl = (props: CommentControlProps): JSX.Element => {
   return (
     // The page-comment-control class is imported from Comment.module.scss
     <div className="page-comment-control">
-      <button type="button" className="btn btn-link p-2 opacity-50" onClick={onClickEditBtn}>
-        <span className="material-symbols-outlined">edit</span>
-      </button>
-      <button
-        data-testid="comment-delete-button"
-        type="button"
-        className="btn btn-link p-2 me-2 opacity-50"
-        onClick={onClickDeleteBtn}
-      >
-        <span className="material-symbols-outlined">close</span>
-      </button>
+      <NotAvailableIfReadOnlyUserNotAllowedToComment>
+        <>
+          <button type="button" className="btn btn-link p-2 opacity-50" onClick={onClickEditBtn}>
+            <span className="material-symbols-outlined">edit</span>
+          </button>
+          <button
+            data-testid="comment-delete-button"
+            type="button"
+            className="btn btn-link p-2 me-2 opacity-50"
+            onClick={onClickDeleteBtn}
+          >
+            <span className="material-symbols-outlined">close</span>
+          </button>
+        </>
+      </NotAvailableIfReadOnlyUserNotAllowedToComment>
     </div>
   );
 

+ 3 - 3
apps/app/src/client/components/PageComment/CommentEditor.tsx

@@ -32,7 +32,7 @@ import { useCommentEditorDirtyMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { NotAvailableForGuest } from '../NotAvailableForGuest';
-import { NotAvailableForReadOnlyUser } from '../NotAvailableForReadOnlyUser';
+import { NotAvailableIfReadOnlyUserNotAllowedToComment } from '../NotAvailableForReadOnlyUser';
 
 import { CommentPreview } from './CommentPreview';
 import { SwitchingButtonGroup } from './SwitchingButtonGroup';
@@ -330,7 +330,7 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
     return (
       <CommentEditorLayout>
         <NotAvailableForGuest>
-          <NotAvailableForReadOnlyUser>
+          <NotAvailableIfReadOnlyUserNotAllowedToComment>
             <button
               type="button"
               className="btn btn-outline-primary w-100 text-start py-3"
@@ -341,7 +341,7 @@ export const CommentEditorPre = (props: CommentEditorProps): JSX.Element => {
               <span className="material-symbols-outlined me-1 fs-5">add_comment</span>
               <small>{t('page_comment.add_a_comment')}...</small>
             </button>
-          </NotAvailableForReadOnlyUser>
+          </NotAvailableIfReadOnlyUserNotAllowedToComment>
         </NotAvailableForGuest>
       </CommentEditorLayout>
     );

+ 34 - 1
apps/app/src/client/components/PageEditor/OptionsSelector.tsx

@@ -3,7 +3,7 @@ import React, {
 } from 'react';
 
 import {
-  type EditorTheme, type KeyMapMode, DEFAULT_KEYMAP, DEFAULT_THEME,
+  type EditorTheme, type KeyMapMode, PasteMode, AllPasteMode, DEFAULT_KEYMAP, DEFAULT_PASTE_MODE, DEFAULT_THEME,
 } from '@growi/editor';
 import { useTranslation } from 'next-i18next';
 import Image from 'next/image';
@@ -174,6 +174,29 @@ const IndentSizeSelector = memo(({ onClickBefore }: {onClickBefore: () => void})
 IndentSizeSelector.displayName = 'IndentSizeSelector';
 
 
+const PasteSelector = memo(({ onClickBefore }: {onClickBefore: () => void}): JSX.Element => {
+
+  const { t } = useTranslation();
+  const { data: editorSettings, update } = useEditorSettings();
+  const selectedPasteMode = editorSettings?.pasteMode ?? DEFAULT_PASTE_MODE;
+
+  const listItems = useMemo(() => (
+    <>
+      { (AllPasteMode).map((pasteMode) => {
+        return (
+          <RadioListItem onClick={() => update({ pasteMode })} text={t(`page_edit.paste.${pasteMode}`) ?? ''} checked={pasteMode === selectedPasteMode} />
+        );
+      }) }
+    </>
+  ), [update, t, selectedPasteMode]);
+
+  return (
+    <Selector header={t('page_edit.paste.title')} onClickBefore={onClickBefore} items={listItems} />
+  );
+});
+PasteSelector.displayName = 'PasteSelector';
+
+
 type SwitchItemProps = {
   inputId: string,
   onChange: () => void,
@@ -269,6 +292,7 @@ const OptionsStatus = {
   Theme: 'Theme',
   Keymap: 'Keymap',
   Indent: 'Indent',
+  Paste: 'Paste',
 } as const;
 type OptionStatus = typeof OptionsStatus[keyof typeof OptionsStatus];
 
@@ -330,6 +354,12 @@ export const OptionsSelector = (): JSX.Element => {
                 data={currentIndentSize.toString() ?? ''}
               />
               <hr className="my-1" />
+              <ChangeStateButton
+                onClick={() => setStatus(OptionsStatus.Paste)}
+                header={t('page_edit.paste.title')}
+                data={t(`page_edit.paste.${editorSettings.pasteMode ?? PasteMode.both}`) ?? ''}
+              />
+              <hr className="my-1" />
               <ConfigurationSelector />
             </div>
           )
@@ -346,6 +376,9 @@ export const OptionsSelector = (): JSX.Element => {
           <IndentSizeSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
         )
         }
+        { status === OptionsStatus.Paste && (
+          <PasteSelector onClickBefore={() => setStatus(OptionsStatus.Home)} />
+        )}
       </DropdownMenu>
     </Dropdown>
   );

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

@@ -5,7 +5,7 @@ import { useSlidesByFrontmatter } from '@growi/presentation/dist/services';
 import { LoadingSpinner } from '@growi/ui/dist/components';
 import { useFullScreen } from '@growi/ui/dist/utils';
 import dynamic from 'next/dynamic';
-import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+import type { Options as ReactMarkdownOptions } from 'react-markdown';
 import {
   Modal, ModalBody,
 } from 'reactstrap';

+ 3 - 4
apps/app/src/client/components/ReactMarkdownComponents/Header.tsx

@@ -2,8 +2,8 @@ import { useCallback, useEffect, useState } from 'react';
 
 import type EventEmitter from 'events';
 
+import type { Element } from 'hast';
 import { useRouter } from 'next/router';
-import type { Element } from 'react-markdown/lib/rehype-filter';
 
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import {
@@ -54,13 +54,12 @@ const EditLink = (props: EditLinkProps): JSX.Element => {
 type HeaderProps = {
   children: React.ReactNode,
   node: Element,
-  level: number,
   id?: string,
 }
 
 export const Header = (props: HeaderProps): JSX.Element => {
   const {
-    node, id, children, level,
+    node, id, children,
   } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -73,7 +72,7 @@ export const Header = (props: HeaderProps): JSX.Element => {
 
   const [isActive, setActive] = useState(false);
 
-  const CustomTag = `h${level}` as keyof JSX.IntrinsicElements;
+  const CustomTag = node.tagName as keyof JSX.IntrinsicElements;
 
   const activateByHash = useCallback((url: string) => {
     try {

+ 5 - 5
apps/app/src/client/components/ReactMarkdownComponents/TableWithEditButton.tsx

@@ -2,7 +2,7 @@ import React, { useCallback } from 'react';
 
 import type EventEmitter from 'events';
 
-import type { Element } from 'react-markdown/lib/rehype-filter';
+import type { Element } from 'hast';
 
 import {
   useIsGuestUser, useIsReadOnlyUser, useIsSharedUser, useShareLinkId,
@@ -23,8 +23,7 @@ type TableWithEditButtonProps = {
   className?: string
 }
 
-export const TableWithEditButton = React.memo((props: TableWithEditButtonProps): JSX.Element => {
-
+const TableWithEditButtonNoMemorized = (props: TableWithEditButtonProps): JSX.Element => {
   const { children, node, className } = props;
 
   const { data: isGuestUser } = useIsGuestUser();
@@ -61,5 +60,6 @@ export const TableWithEditButton = React.memo((props: TableWithEditButtonProps):
       </table>
     </div>
   );
-});
-TableWithEditButton.displayName = 'TableWithEditButton';
+};
+TableWithEditButtonNoMemorized.displayName = 'TableWithEditButton';
+export const TableWithEditButton = React.memo(TableWithEditButtonNoMemorized) as typeof TableWithEditButtonNoMemorized;

+ 1 - 17
apps/app/src/client/components/Sidebar/InAppNotification/PrimaryItemForNotification.tsx

@@ -1,15 +1,11 @@
 import { memo, useCallback, useEffect } from 'react';
 
-import { apiv3Post } from '~/client/util/apiv3-client';
 import { SidebarContentsType } from '~/interfaces/ui';
 import { useSWRxInAppNotificationStatus } from '~/stores/in-app-notification';
 import { useDefaultSocket } from '~/stores/socket-io';
-import loggerFactory from '~/utils/logger';
 
 import { PrimaryItem, type Props } from '../SidebarNav/PrimaryItem';
 
-const logger = loggerFactory('growi:PrimaryItemsForNotification');
-
 type PrimaryItemForNotificationProps = Omit<Props, 'onClick' | 'label' | 'iconName' | 'contents' | 'badgeContents' >
 
 // TODO(after v7 release): https://redmine.weseek.co.jp/issues/138463
@@ -22,20 +18,9 @@ export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificatio
 
   const badgeContents = notificationCount != null && notificationCount > 0 ? notificationCount : undefined;
 
-  const updateNotificationStatus = useCallback(async() => {
-    try {
-      await apiv3Post('/in-app-notification/read');
-      mutateNotificationCount();
-    }
-    catch (err) {
-      logger.error(err);
-    }
-  }, [mutateNotificationCount]);
-
   const itemHoverHandler = useCallback((contents: SidebarContentsType) => {
     onHover?.(contents);
-    updateNotificationStatus();
-  }, [onHover, updateNotificationStatus]);
+  }, [onHover]);
 
   useEffect(() => {
     if (socket != null) {
@@ -57,7 +42,6 @@ export const PrimaryItemForNotification = memo((props: PrimaryItemForNotificatio
       label="In-App Notification"
       iconName="notifications"
       badgeContents={badgeContents}
-      onClick={updateNotificationStatus}
       onHover={itemHoverHandler}
     />
   );

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

@@ -41,6 +41,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByGroup: false,
       isUsersHomepageDeletionEnabled: false,
       isForceDeleteUserHomepageOnUserDeletion: false,
+      isRomUserAllowedToComment: false,
       isLocalEnabled: false,
       isLdapEnabled: false,
       isSamlEnabled: false,
@@ -79,6 +80,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       isShowRestrictedByGroup: !generalSetting.hideRestrictedByGroup,
       isUsersHomepageDeletionEnabled: generalSetting.isUsersHomepageDeletionEnabled,
       isForceDeleteUserHomepageOnUserDeletion: generalSetting.isForceDeleteUserHomepageOnUserDeletion,
+      isRomUserAllowedToComment: generalSetting.isRomUserAllowedToComment,
       sessionMaxAge: generalSetting.sessionMaxAge,
       wikiMode: generalSetting.wikiMode,
       disableLinkSharing: shareLinkSetting.disableLinkSharing,
@@ -220,6 +222,14 @@ export default class AdminGeneralSecurityContainer extends Container {
     this.setState({ isForceDeleteUserHomepageOnUserDeletion: !this.state.isForceDeleteUserHomepageOnUserDeletion });
   }
 
+  /**
+   * switch isRomUserAllowedToComment
+   */
+  switchIsRomUserAllowedToComment(bool) {
+    this.setState({ isRomUserAllowedToComment: bool });
+  }
+
+
   /**
    * Update restrictGuestMode
    * @memberOf AdminGeneralSecuritySContainer
@@ -239,6 +249,7 @@ export default class AdminGeneralSecurityContainer extends Container {
       hideRestrictedByOwner: !this.state.isShowRestrictedByOwner,
       isUsersHomepageDeletionEnabled: this.state.isUsersHomepageDeletionEnabled,
       isForceDeleteUserHomepageOnUserDeletion: this.state.isForceDeleteUserHomepageOnUserDeletion,
+      isRomUserAllowedToComment: this.state.isRomUserAllowedToComment,
     };
 
     requestParams = await removeNullPropertyFromObject(requestParams);

+ 28 - 5
apps/app/src/client/services/renderer/renderer.tsx

@@ -10,6 +10,8 @@ import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
 import type { HtmlElementNode } from 'rehype-toc';
 import breaks from 'remark-breaks';
+import remarkDirective from 'remark-directive';
+import remarkGithubAdmonitionsToDirectives from 'remark-github-admonitions-to-directives';
 import math from 'remark-math';
 import deepmerge from 'ts-deepmerge';
 import type { Pluggable } from 'unified';
@@ -19,6 +21,7 @@ import { Header } from '~/client/components/ReactMarkdownComponents/Header';
 import { LightBox } from '~/client/components/ReactMarkdownComponents/LightBox';
 import { RichAttachment } from '~/client/components/ReactMarkdownComponents/RichAttachment';
 import { TableWithEditButton } from '~/client/components/ReactMarkdownComponents/TableWithEditButton';
+import * as callout from '~/features/callout';
 import * as mermaid from '~/features/mermaid';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -26,6 +29,7 @@ import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as relocateToc from '~/services/renderer/rehype-plugins/relocate-toc';
 import * as attachment from '~/services/renderer/remark-plugins/attachment';
+import * as codeBlock from '~/services/renderer/remark-plugins/codeblock';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
@@ -64,6 +68,9 @@ export const generateViewOptions = (
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     attachment.remarkPlugin,
+    remarkGithubAdmonitionsToDirectives,
+    remarkDirective,
+    callout.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
@@ -71,15 +78,17 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       getCommonSanitizeOption(config),
       presentation.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
+      callout.sanitizeOption,
       attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
+      codeBlock.sanitizeOption,
     )]
     : () => {};
 
@@ -110,6 +119,7 @@ export const generateViewOptions = (
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
     components.mermaid = mermaid.MermaidViewer;
+    components.callout = callout.CalloutViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
   }
@@ -129,9 +139,10 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       getCommonSanitizeOption(config),
+      codeBlock.sanitizeOption,
     )]
     : () => {};
 
@@ -166,6 +177,9 @@ export const generateSimpleViewOptions = (
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     attachment.remarkPlugin,
+    remarkGithubAdmonitionsToDirectives,
+    remarkDirective,
+    callout.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
@@ -176,15 +190,17 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       getCommonSanitizeOption(config),
       presentation.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
+      callout.sanitizeOption,
       attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
+      codeBlock.sanitizeOption,
     )]
     : () => {};
 
@@ -207,6 +223,7 @@ export const generateSimpleViewOptions = (
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.callout = callout.CalloutViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
   }
@@ -227,7 +244,7 @@ export const generatePresentationViewOptions = (
   const { rehypePlugins } = options;
 
 
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       addLineNumberAttribute.sanitizeOption,
     )]
@@ -258,6 +275,9 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     attachment.remarkPlugin,
+    remarkGithubAdmonitionsToDirectives,
+    remarkDirective,
+    callout.remarkPlugin,
     lsxGrowiDirective.remarkPlugin,
     refsGrowiDirective.remarkPlugin,
   );
@@ -265,15 +285,17 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       getCommonSanitizeOption(config),
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
+      callout.sanitizeOption,
       attachment.sanitizeOption,
       lsxGrowiDirective.sanitizeOption,
       refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
+      codeBlock.sanitizeOption,
     )]
     : () => {};
 
@@ -296,6 +318,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     components.gallery = refsGrowiDirective.GalleryImmutable;
     components.drawio = drawio.DrawioViewer;
     components.mermaid = mermaid.MermaidViewer;
+    components.callout = callout.CalloutViewer;
     components.attachment = RichAttachment;
     components.img = LightBox;
   }

+ 12 - 6
apps/app/src/components/ReactMarkdownComponents/CodeBlock.tsx

@@ -1,6 +1,5 @@
 import type { ReactNode } from 'react';
 
-import type { CodeComponent, CodeProps } from 'react-markdown/lib/ast-to-react';
 import { PrismAsyncLight } from 'react-syntax-highlighter';
 import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
 
@@ -45,7 +44,8 @@ function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactN
   // see: https://github.com/weseek/growi/pull/7484
   //
   // Note: You can also remove this code if the user requests to see the code highlighted in Prism as-is.
-  const isSimpleString = Array.isArray(children) && children.length === 1 && typeof children[0] === 'string';
+
+  const isSimpleString = typeof children === 'string' || (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string');
   if (!isSimpleString) {
     return (
       <div style={oneDark['pre[class*="language-"]']}>
@@ -67,13 +67,19 @@ function CodeBlockSubstance({ lang, children }: { lang: string, children: ReactN
   );
 }
 
-export const CodeBlock: CodeComponent = ({ inline, className, children }: CodeProps) => {
+type CodeBlockProps = {
+  children: ReactNode,
+  className?: string,
+  inline?: string, // "" or undefined
+}
 
-  if (inline) {
-    return <code className={`code-inline ${className ?? ''}`}>{children}</code>;
-  }
+export const CodeBlock = (props: CodeBlockProps): JSX.Element => {
 
   // TODO: set border according to the value of 'customize:highlightJsStyleBorder'
+  const { className, children, inline } = props;
+  if (inline != null) {
+    return <code className={`code-inline ${className ?? ''}`}>{children}</code>;
+  }
 
   const match = /language-(\w+)(:?.+)?/.exec(className || '');
   const lang = match && match[1] ? match[1] : '';

+ 90 - 0
apps/app/src/features/callout/components/CalloutViewer.module.scss

@@ -0,0 +1,90 @@
+@use '@growi/core-styles/scss/bootstrap/init' as bs;
+
+// == Colors
+@include bs.color-mode(light) {
+  .callout-viewer {
+    --callout-accent-note: hsl(212, 92%, 45%);
+    --callout-accent-tip: hsl(137, 66%, 30%);
+    --callout-accent-important: hsl(261, 69%, 59%);
+    --callout-accent-warning: hsl(40, 100%, 30%);
+    --callout-accent-caution: hsl(356, 71%, 48%);
+  }
+}
+
+@include bs.color-mode(dark) {
+  .callout-viewer {
+    --callout-accent-note: hsl(215, 93%, 58%);
+    --callout-accent-tip: hsl(128, 49%, 49%);
+    --callout-accent-important: hsl(262, 89%, 71%);
+    --callout-accent-warning: hsl(41, 72%, 48%);
+    --callout-accent-caution: hsl(3, 93%, 63%);
+  }
+}
+
+.callout-viewer :global{
+
+  .callout {
+    padding: 0.5rem 1rem;
+    margin: 1rem 0rem;
+    color: inherit;
+  }
+
+  .callout-indicator {
+    display: flex;
+    align-items: center;
+    margin-bottom: 16px;
+    line-height: 1;
+  }
+
+  .callout-hint {
+    display: inline-block;
+    margin-right: 0.3rem;
+    vertical-align: text-bottom;
+    fill: currentColor;
+  }
+
+  .callout-note {
+    .callout-indicator {
+      color: var(--callout-accent-note);
+    }
+    border-left: .25em solid var(--callout-accent-note);
+  }
+
+  .callout-tip {
+    .callout-indicator {
+      color: var(--callout-accent-tip);
+    }
+    border-left: .25em solid var(--callout-accent-tip);
+  }
+
+  .callout-warning {
+    .callout-indicator {
+      color: var(--callout-accent-warning);
+    }
+    border-left:.25em solid var(--callout-accent-warning);
+  }
+
+  .callout-caution {
+    .callout-indicator {
+      color: var(--callout-accent-caution);
+    }
+    border-left:.25em solid var(--callout-accent-caution);
+  }
+
+  .callout-important {
+    .callout-indicator {
+      color: var(--callout-accent-important);
+    }
+    border-left:.25em solid var(--callout-accent-important);
+  }
+
+  .callout-content:first-child,
+  .callout-content:only-child {
+    margin-block-start: 0;
+  }
+
+  .callout-content:last-child,
+  .callout-content:only-child {
+    margin-block-end: 0;
+  }
+}

+ 64 - 0
apps/app/src/features/callout/components/CalloutViewer.tsx

@@ -0,0 +1,64 @@
+// Ref: https://github.com/Microflash/remark-callout-directives/blob/fabe4d8adc7738469f253836f0da346591ea2a2b/README.md
+
+import type { ReactNode } from 'react';
+import React from 'react';
+
+import { type Callout } from '../services/consts';
+
+import styles from './CalloutViewer.module.scss';
+
+const moduleClass = styles['callout-viewer'];
+
+type CALLOUT_TO = {
+  [key in Callout]: string;
+}
+
+const CALLOUT_TO_TITLE: CALLOUT_TO = {
+  note: 'Note',
+  tip: 'Tip',
+  important: 'Important',
+  warning: 'Warning',
+  caution: 'Caution',
+};
+
+const CALLOUT_TO_ICON: CALLOUT_TO = {
+  note: 'info',
+  tip: 'lightbulb',
+  important: 'feedback',
+  warning: 'warning',
+  caution: 'report',
+};
+
+type CalloutViewerProps = {
+  children: ReactNode,
+  node: Element,
+  name: string
+}
+
+export const CalloutViewer = React.memo((props: CalloutViewerProps): JSX.Element => {
+
+  const { node, name, children } = props;
+
+  if (node == null) {
+    return <></>;
+  }
+
+  return (
+    <div className={`${moduleClass} callout-viewer`}>
+      <div className={`callout callout-${CALLOUT_TO_TITLE[name].toLowerCase()}`}>
+        <div className="callout-indicator">
+          <div className="callout-hint">
+            <span className="material-symbols-outlined">{CALLOUT_TO_ICON[name]}</span>
+          </div>
+          <div className="callout-title">
+            {CALLOUT_TO_TITLE[name]}
+          </div>
+        </div>
+        <div className="callout-content">
+          {children}
+        </div>
+      </div>
+    </div>
+  );
+});
+CalloutViewer.displayName = 'CalloutViewer';

+ 1 - 0
apps/app/src/features/callout/components/index.ts

@@ -0,0 +1 @@
+export { CalloutViewer } from './CalloutViewer';

+ 2 - 0
apps/app/src/features/callout/index.ts

@@ -0,0 +1,2 @@
+export * from './components';
+export * from './services';

+ 23 - 0
apps/app/src/features/callout/services/callout.ts

@@ -0,0 +1,23 @@
+import type { ContainerDirective } from 'mdast-util-directive';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+import { AllCallout } from './consts';
+
+export const remarkPlugin: Plugin = () => {
+  return (tree) => {
+    visit(tree, 'containerDirective', (node: ContainerDirective) => {
+      if (AllCallout.some(name => name === node.name)) {
+        const data = node.data ?? (node.data = {});
+        data.hName = 'callout';
+        data.hProperties = {
+          name: node.name,
+        };
+      }
+    });
+  };
+};
+
+export const sanitizeOption = {
+  tagNames: ['callout'],
+};

+ 5 - 0
apps/app/src/features/callout/services/consts.ts

@@ -0,0 +1,5 @@
+// Ref: https://github.com/Microflash/remark-callout-directives/blob/fabe4d8adc7738469f253836f0da346591ea2a2b/themes/github/index.js
+// Ref: https://github.com/orgs/community/discussions/16925
+
+export const AllCallout = ['note', 'tip', 'important', 'warning', 'caution'] as const;
+export type Callout = typeof AllCallout[number];

+ 1 - 0
apps/app/src/features/callout/services/index.ts

@@ -0,0 +1 @@
+export { sanitizeOption, remarkPlugin } from './callout';

+ 2 - 1
apps/app/src/features/mermaid/components/MermaidViewer.tsx

@@ -1,4 +1,5 @@
-import React, { useRef, useEffect, ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React, { useRef, useEffect } from 'react';
 
 import mermaid from 'mermaid';
 

+ 5 - 5
apps/app/src/features/mermaid/services/mermaid.ts

@@ -1,9 +1,9 @@
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
-import { Plugin } from 'unified';
-import { Node } from 'unist';
+import type { Code } from 'mdast';
+import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
-function rewriteNode(node: Node) {
+function rewriteNode(node: Code) {
   // replace node
   const data = node.data ?? (node.data = {});
   data.hName = 'mermaid';
@@ -11,8 +11,8 @@ function rewriteNode(node: Node) {
 
 export const remarkPlugin: Plugin = function() {
   return (tree) => {
-    visit(tree, (node) => {
-      if (node.type === 'code' && node.lang === 'mermaid') {
+    visit(tree, 'code', (node: Code) => {
+      if (node.lang === 'mermaid') {
         rewriteNode(node);
       }
     });

+ 1 - 2
apps/app/src/interfaces/in-app-notification.ts

@@ -1,9 +1,8 @@
 import type { IUser } from '@growi/core';
 
-import { SupportedTargetModelType, SupportedActionType } from './activity';
+import type { SupportedTargetModelType, SupportedActionType } from './activity';
 
 export enum InAppNotificationStatuses {
-  STATUS_UNREAD = 'UNREAD',
   STATUS_UNOPENED = 'UNOPENED',
   STATUS_OPENED = 'OPENED',
 }

+ 2 - 5
apps/app/src/interfaces/renderer-options.ts

@@ -1,8 +1,6 @@
 import type { ComponentType } from 'react';
 
-import type { SpecialComponents } from 'react-markdown/lib/ast-to-react';
-import type { NormalComponents } from 'react-markdown/lib/complex-types';
-import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+import type { Options as ReactMarkdownOptions, Components } from 'react-markdown';
 import type { PluggableList } from 'unified';
 
 export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'> & {
@@ -10,8 +8,7 @@ export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehy
   rehypePlugins: PluggableList,
   components?:
     | Partial<
-        Omit<NormalComponents, keyof SpecialComponents>
-        & SpecialComponents
+        Components
         & {
           [elem: string]: ComponentType<any>,
         }

+ 3 - 1
apps/app/src/interfaces/services/rehype-sanitize.ts

@@ -1,4 +1,6 @@
-import type { Attributes } from 'hast-util-sanitize/lib';
+import type { defaultSchema } from 'hast-util-sanitize';
+
+type Attributes = typeof defaultSchema.attributes;
 
 export const RehypeSanitizeType = {
   RECOMMENDED: 'Recommended',

+ 27 - 0
apps/app/src/migrations/20240924181317-changed-status-in-inappnotifications-from-unread-to-unopened.js

@@ -0,0 +1,27 @@
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:changed-status-in-inappnotifications-from-unread-to-unopened');
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+
+    const unreadInAppnotifications = await db.collection('inappnotifications');
+    await unreadInAppnotifications.updateMany(
+      { status: { $eq: 'UNREAD' } },
+      [
+        {
+          $set: {
+            status: 'UNOPENED',
+          },
+        },
+      ],
+    );
+
+    logger.info('Migration has successfully applied');
+  },
+
+  async down() {
+    // No rollback
+  },
+};

+ 7 - 3
apps/app/src/pages/[[...path]].page.tsx

@@ -44,7 +44,9 @@ import {
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useElasticsearchMaxBodyLengthToIndex,
-  useIsLocalAccountRegistrationEnabled, useIsAiEnabled,
+  useIsLocalAccountRegistrationEnabled,
+  useIsRomUserAllowedToComment,
+  useIsAiEnabled,
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
@@ -164,6 +166,8 @@ type Props = CommonProps & {
   elasticsearchMaxBodyLengthToIndex: number,
   isEnabledMarp: boolean,
 
+  isRomUserAllowedToComment: boolean,
+
   sidebarConfig: ISidebarConfig,
 
   isSlackConfigured: boolean,
@@ -244,8 +248,6 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
 
-  useIsAiEnabled(props.aiEnabled);
-
   const { pageWithMeta } = props;
 
   const pageId = pageWithMeta?.data._id;
@@ -557,6 +559,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.elasticsearchMaxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
 
+  props.isRomUserAllowedToComment = configManager.getConfig('crowi', 'security:isRomUserAllowedToComment');
+
   props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
   // props.isMailerSetup = mailService.isMailerSetup;
   props.isAclEnabled = aclService.isAclEnabled();

+ 26 - 2
apps/app/src/server/middlewares/exclude-read-only-user.ts

@@ -1,9 +1,12 @@
 import { ErrorV3 } from '@growi/core/dist/models';
-import { NextFunction, Response } from 'express';
-import { Request } from 'express-validator/src/base';
+import type { NextFunction, Response } from 'express';
+import type { Request } from 'express-validator/src/base';
 
 import loggerFactory from '~/utils/logger';
 
+import { configManager } from '../service/config-manager';
+
+
 const logger = loggerFactory('growi:middleware:exclude-read-only-user');
 
 export const excludeReadOnlyUser = (req: Request, res: Response & { apiv3Err }, next: () => NextFunction): NextFunction => {
@@ -23,3 +26,24 @@ export const excludeReadOnlyUser = (req: Request, res: Response & { apiv3Err },
 
   return next();
 };
+
+export const excludeReadOnlyUserIfCommentNotAllowed = (req: Request, res: Response & { apiv3Err }, next: () => NextFunction): NextFunction => {
+  const user = req.user;
+
+  const isRomUserAllowedToComment = configManager.getConfig('crowi', 'security:isRomUserAllowedToComment');
+
+  if (user == null) {
+    logger.warn('req.user is null');
+    return next();
+  }
+
+  if (user.readOnly && !isRomUserAllowedToComment) {
+    const message = 'This user is read only user and comment is not allowed';
+    logger.warn(message);
+
+    return res.apiv3Err(new ErrorV3(message, 'validation_failed'));
+  }
+
+  return next();
+
+};

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

@@ -75,6 +75,7 @@ export const defaultCrowiConfigs: { [key: string]: any } = {
   'security:disableLinkSharing' : false,
   'security:user-homepage-deletion:isEnabled': false,
   'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': false,
+  'security:isRomUserAllowedToComment': false,
 
   'security:passport-local:isEnabled' : true,
   'security:passport-ldap:isEnabled' : false,

+ 2 - 6
apps/app/src/server/models/in-app-notification.ts

@@ -10,7 +10,7 @@ import { getOrCreateModel } from '../util/mongoose-utils';
 import type { ActivityDocument } from './activity';
 
 
-const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
+const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 export interface InAppNotificationDocument extends Document {
   _id: Types.ObjectId
@@ -31,7 +31,6 @@ export interface InAppNotificationModel extends Model<InAppNotificationDocument>
   open(user, id: Types.ObjectId): Promise<InAppNotificationDocument | null>
   read(user) /* : Promise<Query<any>> */
 
-  STATUS_UNREAD: string
   STATUS_UNOPENED: string
   STATUS_OPENED: string
 }
@@ -66,7 +65,7 @@ const inAppNotificationSchema = new Schema<InAppNotificationDocument, InAppNotif
   ],
   status: {
     type: String,
-    default: STATUS_UNREAD,
+    default: STATUS_UNOPENED,
     enum: InAppNotificationStatuses,
     index: true,
     required: true,
@@ -95,9 +94,6 @@ inAppNotificationSchema.index({
 inAppNotificationSchema.statics.STATUS_UNOPENED = function() {
   return STATUS_UNOPENED;
 };
-inAppNotificationSchema.statics.STATUS_UNREAD = function() {
-  return STATUS_UNREAD;
-};
 inAppNotificationSchema.statics.STATUS_OPENED = function() {
   return STATUS_OPENED;
 };

+ 0 - 12
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -93,18 +93,6 @@ module.exports = (crowi) => {
     }
   });
 
-  router.post('/read', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
-    const user = req.user;
-
-    try {
-      await inAppNotificationService.read(user);
-      return res.apiv3();
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
   router.post('/open', accessTokenParser, loginRequiredStrictly, async(req: CrowiRequest, res: ApiV3Response) => {
     // user must be set by loginRequiredStrictly
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion

+ 3 - 0
apps/app/src/server/routes/apiv3/security-settings/index.js

@@ -364,6 +364,7 @@ module.exports = (crowi) => {
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
         isForceDeleteUserHomepageOnUserDeletion:
         await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
+        isRomUserAllowedToComment: await configManager.getConfig('crowi', 'security:isRomUserAllowedToComment'),
         wikiMode: await configManager.getConfig('crowi', 'security:wikiMode'),
         sessionMaxAge: await configManager.getConfig('crowi', 'security:sessionMaxAge'),
       },
@@ -637,6 +638,7 @@ module.exports = (crowi) => {
       'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion': req.body.isUsersHomepageDeletionEnabled
         ? req.body.isForceDeleteUserHomepageOnUserDeletion
         : false,
+      'security:isRomUserAllowedToComment': req.body.isRomUserAllowedToComment,
     };
 
     // Validate delete config
@@ -670,6 +672,7 @@ module.exports = (crowi) => {
         isUsersHomepageDeletionEnabled: await configManager.getConfig('crowi', 'security:user-homepage-deletion:isEnabled'),
         isForceDeleteUserHomepageOnUserDeletion:
         await configManager.getConfig('crowi', 'security:user-homepage-deletion:isForceDeleteUserHomepageOnUserDeletion'),
+        isRomUserAllowedToComment: await configManager.getConfig('crowi', 'security:isRomUserAllowedToComment'),
       };
 
       const parameters = { action: SupportedAction.ACTION_ADMIN_SECURITY_SETTINGS_UPDATE };

+ 4 - 4
apps/app/src/server/routes/index.js

@@ -5,7 +5,7 @@ import { middlewareFactory as rateLimiterFactory } from '~/features/rate-limiter
 
 import { generateAddActivityMiddleware } from '../middlewares/add-activity';
 import apiV1FormValidator from '../middlewares/apiv1-form-validator';
-import { excludeReadOnlyUser } from '../middlewares/exclude-read-only-user';
+import { excludeReadOnlyUser, excludeReadOnlyUserIfCommentNotAllowed } from '../middlewares/exclude-read-only-user';
 import injectResetOrderByTokenMiddleware from '../middlewares/inject-reset-order-by-token-middleware';
 import injectUserRegistrationOrderByTokenMiddleware from '../middlewares/inject-user-registration-order-by-token-middleware';
 import * as loginFormValidator from '../middlewares/login-form-validator';
@@ -131,9 +131,9 @@ module.exports = function(crowi, app) {
   apiV1Router.get('/tags.search'         , accessTokenParser, loginRequired, tag.api.search);
   apiV1Router.post('/tags.update'        , accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, addActivity, tag.api.update);
   apiV1Router.get('/comments.get'        , accessTokenParser , loginRequired , comment.api.get);
-  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.add);
-  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.update);
-  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity, comment.api.remove);
+  apiV1Router.post('/comments.add'       , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.add);
+  apiV1Router.post('/comments.update'    , comment.api.validators.add(), accessTokenParser , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.update);
+  apiV1Router.post('/comments.remove'    , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUserIfCommentNotAllowed, addActivity, comment.api.remove);
 
   apiV1Router.post('/attachments.uploadProfileImage'   , uploads.single('file'), autoReap, accessTokenParser, loginRequiredStrictly , excludeReadOnlyUser, attachmentApi.uploadProfileImage);
   apiV1Router.post('/attachments.remove'               , accessTokenParser , loginRequiredStrictly , excludeReadOnlyUser, addActivity ,attachmentApi.remove);

+ 3 - 11
apps/app/src/server/service/in-app-notification.ts

@@ -25,7 +25,7 @@ import { preNotifyService, type PreNotify } from './pre-notify';
 import { RoomPrefix, getRoomNameWithId } from './socket-io/helper';
 
 
-const { STATUS_UNREAD, STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
+const { STATUS_UNOPENED, STATUS_OPENED } = InAppNotificationStatuses;
 
 const logger = loggerFactory('growi:service:inAppNotification');
 
@@ -93,7 +93,7 @@ export default class InAppNotificationService {
         targetModel,
         target,
         action,
-        status: STATUS_UNREAD,
+        status: STATUS_UNOPENED,
         createdAt: now,
         snapshot,
         $addToSet: { activities: activityId },
@@ -147,14 +147,6 @@ export default class InAppNotificationService {
     }
   };
 
-  read = async function(user: Types.ObjectId): Promise<void> {
-    const query = { user, status: STATUS_UNREAD };
-    const parameters = { status: STATUS_UNOPENED };
-    await InAppNotification.updateMany(query, parameters);
-
-    return;
-  };
-
   open = async function(user: IUser & HasObjectId, id: Types.ObjectId): Promise<void> {
     const query = { _id: id, user: user._id };
     const parameters = { status: STATUS_OPENED };
@@ -173,7 +165,7 @@ export default class InAppNotificationService {
   };
 
   getUnreadCountByUser = async function(user: Types.ObjectId): Promise<number| undefined> {
-    const query = { user, status: STATUS_UNREAD };
+    const query = { user, status: STATUS_UNOPENED };
 
     try {
       const count = await InAppNotification.countDocuments(query);

+ 9 - 0
apps/app/src/services/renderer/recommended-whitelist.spec.ts

@@ -14,6 +14,9 @@ describe('recommended-whitelist', () => {
 
   test('.attributes should return data attributes', () => {
     expect(attributes).not.toBeNull();
+
+    assert(attributes != null);
+
     expect(Object.keys(attributes)).includes('*');
     expect(attributes['*']).includes('alt');
     expect(attributes['*']).includes('align');
@@ -25,12 +28,18 @@ describe('recommended-whitelist', () => {
 
   test('.attributes should return iframe attributes', () => {
     expect(attributes).not.toBeNull();
+
+    assert(attributes != null);
+
     expect(Object.keys(attributes)).includes('iframe');
     expect(attributes.iframe).includes('src');
   });
 
   test('.attributes should return video attributes', () => {
     expect(attributes).not.toBeNull();
+
+    assert(attributes != null);
+
     expect(Object.keys(attributes)).includes('video');
     expect(attributes.iframe).includes('src');
   });

+ 2 - 1
apps/app/src/services/renderer/recommended-whitelist.ts

@@ -1,7 +1,8 @@
 import { defaultSchema } from 'hast-util-sanitize';
-import type { Attributes } from 'hast-util-sanitize/lib';
 import deepmerge from 'ts-deepmerge';
 
+type Attributes = typeof defaultSchema.attributes;
+
 /**
  * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites,
  *            https://github.com/jch/html-pipeline/blob/70b6903b025c668ff3c02a6fa382031661182147/lib/html/pipeline/sanitization_filter.rb#L41

+ 2 - 3
apps/app/src/services/renderer/rehype-plugins/add-class.ts

@@ -1,9 +1,8 @@
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // Re-implemeted in TypeScript.
+import type { Nodes as HastNode, Element, Properties } from 'hast';
 import { selectAll } from 'hast-util-select';
-import type { Node as HastNode, Element } from 'hast-util-select/lib/types';
-import { Properties } from 'hast-util-select/lib/types';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 export type SelectorName = string; // e.g. 'h1'
 export type ClassName = string; // e.g. 'header'

+ 1 - 1
apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts

@@ -1,5 +1,5 @@
+import type { Element } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
-import type { Element } from 'hast-util-select/lib/types';
 import type { Plugin } from 'unified';
 import { visit, EXIT, CONTINUE } from 'unist-util-visit';
 

+ 2 - 2
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts

@@ -1,5 +1,5 @@
+import type { Nodes as HastNode, Text } from 'hast';
 import { select } from 'hast-util-select';
-import type { Node as HastNode } from 'hast-util-select/lib/types';
 import parse from 'remark-parse';
 import rehype from 'remark-rehype';
 import { unified } from 'unified';
@@ -45,7 +45,7 @@ describe('relativeLinksByPukiwikiLikeLinker', () => {
 
       expect(anchorElement?.children[0]).not.toBeNull();
       expect(anchorElement?.children[0].type).toEqual('text');
-      expect(anchorElement?.children[0].value).toEqual(expectedValue);
+      expect((anchorElement?.children[0] as HastNode as Text).value).toEqual(expectedValue);
 
     });
   });

+ 1 - 1
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -1,6 +1,6 @@
 
+import type { Nodes as HastNode } from 'hast';
 import { select } from 'hast-util-select';
-import type { Node as HastNode } from 'hast-util-select/lib/types';
 import parse from 'remark-parse';
 import remarkRehype from 'remark-rehype';
 import { unified } from 'unified';

+ 1 - 1
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,7 +1,7 @@
 import assert from 'assert';
 
+import type { Nodes as HastNode, Element } from 'hast';
 import { selectAll } from 'hast-util-select';
-import type { Node as HastNode, Element } from 'hast-util-select/lib/types';
 import isAbsolute from 'is-absolute-url';
 import type { Plugin } from 'unified';
 

+ 8 - 10
apps/app/src/services/renderer/rehype-plugins/relocate-toc.ts

@@ -1,6 +1,7 @@
-import rehypeToc, { type HtmlElementNode } from 'rehype-toc';
+import rehypeToc from 'rehype-toc';
+import type { HtmlElementNode } from 'rehype-toc';
 import type { Plugin } from 'unified';
-import type { Node } from 'unist';
+import { visit } from 'unist-util-visit';
 
 type StoreTocPluginParams = {
   storeTocNode: (toc: HtmlElementNode) => void,
@@ -22,13 +23,10 @@ export const rehypePluginStore: Plugin<[StoreTocPluginParams]> = (options) => {
 
 
 // method for replace <ol> to <ul>
-const replaceOlToUl = (children: Node[]) => {
-  children.forEach((child) => {
-    if (child.type === 'element' && child.tagName === 'ol') {
-      child.tagName = 'ul';
-    }
-    if (child.children != null) {
-      replaceOlToUl(child.children as Node[]);
+const replaceOlToUl = (tree: HtmlElementNode) => {
+  visit(tree, 'element', (node: HtmlElementNode) => {
+    if (node.tagName === 'ol') {
+      node.tagName = 'ul';
     }
   });
 };
@@ -44,7 +42,7 @@ export const rehypePluginRestore: Plugin<[RestoreTocPluginParams]> = (options) =
     headings: ['h1', 'h2', 'h3'],
     customizeTOC: () => {
       if (tocNode != null) {
-        replaceOlToUl([tocNode]); // replace <ol> to <ul>
+        replaceOlToUl(tocNode); // replace <ol> to <ul>
 
         // restore toc
         return tocNode;

+ 11 - 11
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -1,8 +1,8 @@
 import path from 'path';
 
-import { Schema as SanitizeOption } from 'hast-util-sanitize';
-import { Plugin } from 'unified';
-import { Node } from 'unist';
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type { Link } from 'mdast';
+import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
 const SUPPORTED_ATTRIBUTES = ['attachmentId', 'url', 'attachmentName'];
@@ -13,25 +13,25 @@ const isAttachmentLink = (url: string): boolean => {
   return attachmentUrlFormat.test(url);
 };
 
-const rewriteNode = (node: Node) => {
-  const attachmentId = path.basename(node.url as string);
+const rewriteNode = (node: Link) => {
+  const attachmentId = path.basename(node.url);
+  const attachmentName = node.children[0] != null && node.children[0].type === 'text' ? node.children[0].value : '';
+
   const data = node.data ?? (node.data = {});
   data.hName = 'attachment';
   data.hProperties = {
     attachmentId,
     url: node.url,
-    attachmentName: (node.children as any)[0]?.value,
+    attachmentName,
   };
 };
 
 
 export const remarkPlugin: Plugin = () => {
   return (tree) => {
-    visit(tree, (node) => {
-      if (node.type === 'link') {
-        if (isAttachmentLink(node.url as string)) {
-          rewriteNode(node);
-        }
+    visit(tree, 'link', (node: Link) => {
+      if (isAttachmentLink(node.url)) {
+        rewriteNode(node);
       }
     });
   };

+ 23 - 0
apps/app/src/services/renderer/remark-plugins/codeblock.ts

@@ -0,0 +1,23 @@
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type { InlineCode } from 'mdast';
+import type { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+
+const SUPPORTED_CODE = ['inline'];
+
+export const remarkPlugin: Plugin = () => {
+  return (tree) => {
+    visit(tree, 'inlineCode', (node: InlineCode) => {
+      const data = node.data || (node.data = {});
+      data.hProperties = { inline: true };
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['code'],
+  attributes: {
+    code: SUPPORTED_CODE,
+  },
+};

+ 9 - 10
apps/app/src/services/renderer/remark-plugins/xsv-to-table.ts

@@ -1,4 +1,5 @@
 import csvToMarkdownTable from 'csv-to-markdown-table';
+import type { Code, Table } from 'mdast';
 import { fromMarkdown } from 'mdast-util-from-markdown';
 import { gfmTableFromMarkdown } from 'mdast-util-gfm-table';
 import { gfmTable } from 'micromark-extension-gfm-table';
@@ -8,12 +9,12 @@ import { visit } from 'unist-util-visit';
 
 type Lang = 'csv' | 'csv-h' | 'tsv' | 'tsv-h';
 
-function isXsv(lang: unknown): lang is Lang {
+function isXsv(lang?: string | null | undefined): lang is Lang {
   return /^(csv|csv-h|tsv|tsv-h)$/.test(lang as string);
 }
 
 function rewriteNode(node: Node, lang: Lang) {
-  const tableContents = node.value as string;
+  const tableContents = (node as Code).value;
 
   const tableDoc = csvToMarkdownTable(
     tableContents,
@@ -21,24 +22,22 @@ function rewriteNode(node: Node, lang: Lang) {
     lang === 'csv-h' || lang === 'tsv-h',
   );
   const tableTree = fromMarkdown(tableDoc, {
-    extensions: [gfmTable],
-    mdastExtensions: [gfmTableFromMarkdown],
+    extensions: [gfmTable()],
+    mdastExtensions: [gfmTableFromMarkdown()],
   });
 
   // replace node
   if (tableTree.children[0] != null) {
     node.type = 'table';
-    node.children = tableTree.children[0].children;
+    (node as Table).children = (tableTree.children[0] as Table).children;
   }
 }
 
 export const remarkPlugin: Plugin = function() {
   return (tree) => {
-    visit(tree, (node) => {
-      if (node.type === 'code') {
-        if (isXsv(node.lang)) {
-          rewriteNode(node, node.lang);
-        }
+    visit(tree, 'code', (node: Code) => {
+      if (isXsv(node.lang)) {
+        rewriteNode(node, node.lang);
       }
     });
   };

+ 3 - 1
apps/app/src/services/renderer/renderer.tsx

@@ -25,6 +25,7 @@ import { tagNames as recommendedTagNames, attributes as recommendedAttributes }
 import * as addClass from './rehype-plugins/add-class';
 import { relativeLinks } from './rehype-plugins/relative-links';
 import { relativeLinksByPukiwikiLikeLinker } from './rehype-plugins/relative-links-by-pukiwiki-like-linker';
+import * as codeBlock from './remark-plugins/codeblock';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 import * as xsvToTable from './remark-plugins/xsv-to-table';
 
@@ -96,6 +97,7 @@ export const generateCommonOptions = (pagePath: string|undefined): RendererOptio
       pukiwikiLikeLinker,
       growiDirective,
       remarkFrontmatter,
+      codeBlock.remarkPlugin,
     ],
     remarkRehypeOptions: {
       clobberPrefix: '', // remove clobber prefix
@@ -137,7 +139,7 @@ export const generateSSRViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
+  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       getCommonSanitizeOption(config),
     )]

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

@@ -206,6 +206,10 @@ export const useIsLocalAccountRegistrationEnabled = (initialData?: boolean): SWR
   return useContextSWR('isLocalAccountRegistrationEnabled', initialData);
 };
 
+export const useIsRomUserAllowedToComment = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('isRomUserAllowedToComment', initialData);
+};
+
 export const useIsAiEnabled = (initialData?: boolean): SWRResponse<boolean, Error> => {
   return useContextSWR('isAiEnabled', initialData);
 };

+ 1 - 2
apps/app/src/stores/renderer.tsx

@@ -5,11 +5,10 @@ import useSWR, { type SWRConfiguration, type SWRResponse } from 'swr';
 
 import { getGrowiFacade } from '~/features/growi-plugin/client/utils/growi-facade-utils';
 import type { RendererOptions } from '~/interfaces/renderer-options';
-
-
 import {
   useRendererConfig,
 } from '~/stores-universal/context';
+
 import { useCurrentPagePath } from './page';
 import { useCurrentPageTocNode } from './ui';
 

+ 1 - 1
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.0.21-slackbot-proxy.0",
+  "version": "7.1.0-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.0.21-RC.0",
+  "version": "7.1.0-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",

+ 19 - 18
packages/editor/src/client/components-internal/CodeMirrorEditor/CodeMirrorEditor.tsx

@@ -10,10 +10,9 @@ import {
 import { AcceptedUploadFileType } from '@growi/core';
 import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 
-import type { EditorSettings, GlobalCodeMirrorEditorKey } from '../../../consts';
+import { PasteMode, type EditorSettings, type GlobalCodeMirrorEditorKey } from '../../../consts';
 import {
-  useFileDropzone, FileDropzoneOverlay,
-  adjustPasteData, getStrFromBol, useShowTableIcon,
+  useFileDropzone, FileDropzoneOverlay, useShowTableIcon, getStrFromBol, adjustPasteData,
 } from '../../services-internal';
 import { useCodeMirrorEditorIsolated } from '../../stores/codemirror-editor';
 import { useDefaultExtensions } from '../../stores/use-default-extensions';
@@ -85,34 +84,36 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
 
   }, [codeMirrorEditor, indentSize]);
 
-
+  const pasteMode = editorSettings?.pasteMode;
   useEffect(() => {
     const handlePaste = (event: ClipboardEvent) => {
       event.preventDefault();
 
       const editor = codeMirrorEditor?.view;
 
-      if (editor == null) {
+      if (editor == null || event.clipboardData == null) {
         return;
       }
 
-      if (event.clipboardData == null) {
-        return;
-      }
-
-      if (onUpload != null && event.clipboardData.types.includes('Files')) {
-        onUpload(Array.from(event.clipboardData.files));
-      }
-
       if (event.clipboardData.types.includes('text/plain')) {
+        if (codeMirrorEditor == null) return;
 
-        const textData = event.clipboardData.getData('text/plain');
+        if (pasteMode == null || pasteMode === PasteMode.both || pasteMode === PasteMode.text) {
+          const textData = event.clipboardData.getData('text/plain');
 
-        const strFromBol = getStrFromBol(editor);
+          const strFromBol = getStrFromBol(editor);
+          const adjusted = adjustPasteData(strFromBol, textData);
 
-        const adjusted = adjustPasteData(strFromBol, textData);
+          codeMirrorEditor.replaceText(adjusted);
+        }
+      }
 
-        codeMirrorEditor?.replaceText(adjusted);
+      if (event.clipboardData.types.includes('Files')) {
+        if (onUpload == null) return;
+
+        if (pasteMode == null || pasteMode === PasteMode.both || pasteMode === PasteMode.file) {
+          onUpload(Array.from(event.clipboardData.files));
+        }
       }
     };
 
@@ -123,7 +124,7 @@ export const CodeMirrorEditor = (props: Props): JSX.Element => {
     const cleanupFunction = codeMirrorEditor?.appendExtensions(extension);
     return cleanupFunction;
 
-  }, [codeMirrorEditor, onUpload]);
+  }, [codeMirrorEditor, pasteMode, onUpload]);
 
   useEffect(() => {
 

+ 7 - 3
packages/editor/src/client/components-internal/playground/Playground.tsx

@@ -7,7 +7,9 @@ import type { ReactCodeMirrorProps } from '@uiw/react-codemirror';
 import { toast } from 'react-toastify';
 
 import { GlobalCodeMirrorEditorKey } from '../../../consts';
-import type { EditorSettings, EditorTheme, KeyMapMode } from '../../../consts';
+import type {
+  EditorSettings, EditorTheme, KeyMapMode, PasteMode,
+} from '../../../consts';
 import { CodeMirrorEditorMain } from '../../components/CodeMirrorEditorMain';
 import { useCodeMirrorEditorIsolated } from '../../stores/codemirror-editor';
 
@@ -19,6 +21,7 @@ export const Playground = (): JSX.Element => {
   const [markdownToPreview, setMarkdownToPreview] = useState('');
   const [editorTheme, setEditorTheme] = useState<EditorTheme>('defaultlight');
   const [editorKeymap, setEditorKeymap] = useState<KeyMapMode>('default');
+  const [editorPaste, setEditorPaste] = useState<PasteMode>('both');
   const [editorSettings, setEditorSettings] = useState<EditorSettings>();
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(GlobalCodeMirrorEditorKey.MAIN);
@@ -42,8 +45,9 @@ export const Playground = (): JSX.Element => {
       keymapMode: editorKeymap,
       styleActiveLine: true,
       autoFormatMarkdownTable: true,
+      pasteMode: editorPaste,
     });
-  }, [setEditorSettings, editorKeymap, editorTheme]);
+  }, [setEditorSettings, editorKeymap, editorTheme, editorPaste]);
 
   // set handler to save with shortcut key
   const saveHandler = useCallback(() => {
@@ -86,7 +90,7 @@ export const Playground = (): JSX.Element => {
         </div>
         <div className="flex-expand-vert d-none d-lg-flex bg-light text-dark border-start border-dark-subtle p-3">
           <Preview markdown={markdownToPreview} />
-          <PlaygroundController setEditorTheme={setEditorTheme} setEditorKeymap={setEditorKeymap} />
+          <PlaygroundController setEditorTheme={setEditorTheme} setEditorKeymap={setEditorKeymap} setEditorPaste={setEditorPaste} />
         </div>
       </div>
       <div className="flex-expand-vert justify-content-center align-items-center bg-dark" style={{ minHeight: '50px' }}>

+ 5 - 2
packages/editor/src/client/components-internal/playground/PlaygroundController.tsx

@@ -2,10 +2,11 @@ import { useCallback } from 'react';
 
 import { useForm } from 'react-hook-form';
 
-import type { EditorTheme, KeyMapMode } from '../../../consts';
+import type { EditorTheme, KeyMapMode, PasteMode } from '../../../consts';
 import {
   GlobalCodeMirrorEditorKey,
   AllEditorTheme, AllKeyMap,
+  AllPasteMode,
 } from '../../../consts';
 import { useCodeMirrorEditorIsolated } from '../../stores/codemirror-editor';
 
@@ -111,16 +112,18 @@ const SetParamRow = (
 type PlaygroundControllerProps = {
   setEditorTheme: (value: EditorTheme) => void
   setEditorKeymap: (value: KeyMapMode) => void
+  setEditorPaste: (value: PasteMode) => void
 };
 
 export const PlaygroundController = (props: PlaygroundControllerProps): JSX.Element => {
-  const { setEditorTheme, setEditorKeymap } = props;
+  const { setEditorTheme, setEditorKeymap, setEditorPaste } = props;
   return (
     <div className="container mt-5">
       <InitEditorValueRow />
       <SetCaretLineRow />
       <SetParamRow update={setEditorTheme} items={AllEditorTheme} />
       <SetParamRow update={setEditorKeymap} items={AllKeyMap} />
+      <SetParamRow update={setEditorPaste} items={AllPasteMode} />
     </div>
   );
 };

+ 1 - 4
packages/editor/src/client/services-internal/file-dropzone/use-file-dropzone/use-file-dropzone.ts

@@ -35,10 +35,7 @@ export const useFileDropzone = (props: Props): FileDropzoneState => {
 
   }, [onUpload, setIsUploading, acceptedUploadFileType]);
 
-  let accept: Accept | undefined;
-  if (acceptedUploadFileType === AcceptedUploadFileType.IMAGE) {
-    accept = { 'image/*': [] };
-  }
+  const accept: Accept | undefined = (acceptedUploadFileType === AcceptedUploadFileType.IMAGE) ? { 'image/*': [] } : undefined;
 
   const dzState = useDropzone({
     onDrop: dropHandler,

+ 14 - 11
packages/editor/src/client/stores/use-editor-settings.ts

@@ -6,7 +6,9 @@ import {
   keymap, type Command, highlightActiveLine, highlightActiveLineGutter,
 } from '@codemirror/view';
 
-import type { EditorSettings, KeyMapMode, EditorTheme } from '../../consts';
+import {
+  type EditorSettings, type KeyMapMode, type EditorTheme,
+} from '../../consts';
 import type { UseCodeMirrorEditor } from '../services';
 import {
   getEditorTheme, getKeymap, insertNewlineContinueMarkup, insertNewRowToMarkdownTable, isInTable,
@@ -15,29 +17,29 @@ import {
 
 export const useEditorSettings = (
     codeMirrorEditor?: UseCodeMirrorEditor,
-    editorSetings?: EditorSettings,
+    editorSettings?: EditorSettings,
     onSave?: () => void,
 ): void => {
 
   useEffect(() => {
-    if (editorSetings?.styleActiveLine == null) {
+    if (editorSettings?.styleActiveLine == null) {
       return;
     }
-    const extensions = (editorSetings?.styleActiveLine) ? [[highlightActiveLine(), highlightActiveLineGutter()]] : [[]];
+    const extensions = (editorSettings?.styleActiveLine) ? [[highlightActiveLine(), highlightActiveLineGutter()]] : [[]];
 
     const cleanupFunction = codeMirrorEditor?.appendExtensions?.(extensions);
     return cleanupFunction;
 
-  }, [codeMirrorEditor, editorSetings?.styleActiveLine]);
+  }, [codeMirrorEditor, editorSettings?.styleActiveLine]);
 
   const onPressEnter: Command = useCallback((editor) => {
-    if (isInTable(editor) && editorSetings?.autoFormatMarkdownTable) {
+    if (isInTable(editor) && editorSettings?.autoFormatMarkdownTable) {
       insertNewRowToMarkdownTable(editor);
       return true;
     }
     insertNewlineContinueMarkup(editor);
     return true;
-  }, [editorSetings?.autoFormatMarkdownTable]);
+  }, [editorSettings?.autoFormatMarkdownTable]);
 
 
   useEffect(() => {
@@ -56,8 +58,8 @@ export const useEditorSettings = (
     const settingTheme = async(name?: EditorTheme) => {
       setThemeExtension(await getEditorTheme(name));
     };
-    settingTheme(editorSetings?.theme);
-  }, [codeMirrorEditor, editorSetings?.theme, setThemeExtension]);
+    settingTheme(editorSettings?.theme);
+  }, [codeMirrorEditor, editorSettings?.theme, setThemeExtension]);
 
   useEffect(() => {
     if (themeExtension == null) {
@@ -75,9 +77,9 @@ export const useEditorSettings = (
     const settingKeyMap = async(name?: KeyMapMode) => {
       setKeymapExtension(await getKeymap(name, onSave));
     };
-    settingKeyMap(editorSetings?.keymapMode);
+    settingKeyMap(editorSettings?.keymapMode);
 
-  }, [codeMirrorEditor, editorSetings?.keymapMode, setKeymapExtension, onSave]);
+  }, [codeMirrorEditor, editorSettings?.keymapMode, setKeymapExtension, onSave]);
 
   useEffect(() => {
     if (keymapExtension == null) {
@@ -90,4 +92,5 @@ export const useEditorSettings = (
 
   }, [codeMirrorEditor, keymapExtension]);
 
+
 };

+ 2 - 0
packages/editor/src/consts/editor-settings.ts

@@ -1,9 +1,11 @@
 import type { EditorTheme } from './editor-themes';
 import type { KeyMapMode } from './keymaps';
+import type { PasteMode } from './paste-mode';
 
 export interface EditorSettings {
   theme: undefined | EditorTheme,
   keymapMode: undefined | KeyMapMode,
+  pasteMode: undefined | PasteMode,
   styleActiveLine: boolean,
   autoFormatMarkdownTable: boolean,
 }

+ 1 - 0
packages/editor/src/consts/index.ts

@@ -3,3 +3,4 @@ export * from './ydoc-awareness-user-color';
 export * from './editor-settings';
 export * from './editor-themes';
 export * from './keymaps';
+export * from './paste-mode';

+ 10 - 0
packages/editor/src/consts/paste-mode.ts

@@ -0,0 +1,10 @@
+
+export const PasteMode = {
+  both: 'both',
+  text: 'text',
+  file: 'file',
+} as const;
+
+export const DEFAULT_PASTE_MODE = PasteMode.both;
+export const AllPasteMode = Object.values(PasteMode);
+export type PasteMode = typeof PasteMode[keyof typeof PasteMode];

+ 12 - 12
packages/presentation/package.json

@@ -46,19 +46,19 @@
     "@marp-team/marpit": "^2.6.1",
     "@types/reveal.js": "^4.4.1",
     "eslint-plugin-regex": "^1.8.0",
-    "hast-util-sanitize": "^4.1.0",
-    "hast-util-select": "^5.0.5",
-    "mdast-util-frontmatter": "^1.0.0",
-    "mdast-util-gfm": "^2.0.1",
-    "mdast-util-to-markdown": "^1.3.0",
-    "react-markdown": "^8.0.7",
-    "remark-frontmatter": "^4.0.1",
-    "remark-parse": "^10.0.0",
-    "remark-stringify": "^10.0.0",
+    "hast-util-sanitize": "^5.0.1",
+    "hast-util-select": "^6.0.2",
+    "mdast-util-frontmatter": "^2.0.1",
+    "mdast-util-gfm": "^3.0.0",
+    "mdast-util-to-markdown": "^2.1.0",
+    "react-markdown": "^9.0.1",
+    "remark-frontmatter": "^5.0.0",
+    "remark-parse": "^11.0.0",
+    "remark-stringify": "^11.0.0",
     "reveal.js": "^4.4.0",
-    "unified": "^10.1.2",
-    "unist-util-find-after": "^4.0.0",
-    "unist-util-visit": "^4.0.0"
+    "unified": "^11.0.0",
+    "unist-util-find-after": "^5.0.0",
+    "unist-util-visit": "^5.0.0"
   },
   "peerDependencies": {
     "next": "^14",

+ 1 - 1
packages/presentation/src/client/components/GrowiSlides.tsx

@@ -1,5 +1,5 @@
 import Head from 'next/head';
-import { ReactMarkdown } from 'react-markdown/lib/react-markdown';
+import ReactMarkdown from 'react-markdown';
 
 import type { PresentationOptions } from '../consts';
 import { MARP_CONTAINER_CLASS_NAME, presentationMarpit, slideMarpit } from '../services/growi-marpit';

+ 11 - 7
packages/presentation/src/client/components/RichSlideSection.tsx

@@ -1,7 +1,8 @@
-import React, { ReactNode } from 'react';
+import type { ReactNode } from 'react';
+import React from 'react';
 
 type RichSlideSectionProps = {
-  children: ReactNode,
+  children?: ReactNode,
   presentation?: boolean,
 }
 
@@ -13,7 +14,7 @@ const OriginalRichSlideSection = React.memo((props: RichSlideSectionProps): JSX.
       <svg data-marpit-svg="" viewBox="0 0 1280 720">
         <foreignObject width="1280" height="720">
           <section>
-            {children}
+            {children ?? <></>}
           </section>
         </foreignObject>
       </svg>
@@ -21,7 +22,8 @@ const OriginalRichSlideSection = React.memo((props: RichSlideSectionProps): JSX.
   );
 });
 
-export const RichSlideSection = React.memo((props: RichSlideSectionProps): JSX.Element => {
+
+const RichSlideSectionNoMemorized = (props: RichSlideSectionProps): JSX.Element => {
   const { children } = props;
 
   return (
@@ -29,10 +31,11 @@ export const RichSlideSection = React.memo((props: RichSlideSectionProps): JSX.E
       {children}
     </OriginalRichSlideSection>
   );
-});
+};
+export const RichSlideSection = React.memo(RichSlideSectionNoMemorized) as typeof RichSlideSectionNoMemorized;
 
 
-export const PresentationRichSlideSection = React.memo((props: RichSlideSectionProps): JSX.Element => {
+const PresentationRichSlideSectionNoMemorized = (props: RichSlideSectionProps): JSX.Element => {
   const { children } = props;
 
   return (
@@ -40,4 +43,5 @@ export const PresentationRichSlideSection = React.memo((props: RichSlideSectionP
       {children}
     </OriginalRichSlideSection>
   );
-});
+};
+export const PresentationRichSlideSection = React.memo(PresentationRichSlideSectionNoMemorized) as typeof PresentationRichSlideSectionNoMemorized;

+ 1 - 1
packages/presentation/src/client/consts/index.ts

@@ -1,4 +1,4 @@
-import type { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
+import type { Options as ReactMarkdownOptions } from 'react-markdown';
 import type { Options as RevealOptions } from 'reveal.js';
 
 export type PresentationOptions = {

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

@@ -5,7 +5,7 @@ import { findAfter } from 'unist-util-find-after';
 import { visit } from 'unist-util-visit';
 
 
-function wrapWithSection(parentNode: Parent, startElem: Node, endElem: Node | null, isDarkMode?: boolean): void {
+function wrapWithSection(parentNode: Parent, startElem: Node, endElem?: Node | null, isDarkMode?: boolean): void {
   const siblings = parentNode.children;
 
   const startIndex = siblings.indexOf(startElem);

+ 5 - 4
packages/presentation/src/services/use-slides-by-frontmatter.ts

@@ -1,5 +1,6 @@
 import { useEffect, useState } from 'react';
 
+import type { Parent, Root } from 'mdast';
 import type { Processor } from 'unified';
 
 type ParseResult = {
@@ -39,11 +40,11 @@ const generateFrontmatterProcessor = async(opts?: ProcessorOpts) => {
   const remarkStringify = (await import('remark-stringify')).default;
   const unified = (await import('unified')).unified;
 
-  return unified()
+  return (unified()
     .use(remarkParse)
     .use(remarkStringify)
     .use(remarkFrontmatter, ['yaml'])
-    .use(() => ((obj) => {
+    .use(() => ((obj: Parent) => {
       if (obj.children[0]?.type === 'yaml') {
         const result = parseSlideFrontmatter(obj.children[0]?.value);
         opts?.onParsed?.(result);
@@ -51,7 +52,7 @@ const generateFrontmatterProcessor = async(opts?: ProcessorOpts) => {
       else {
         opts?.onSkipped?.();
       }
-    }));
+    })));
 };
 
 export type UseSlide = {
@@ -65,7 +66,7 @@ export type UseSlide = {
  */
 export const useSlidesByFrontmatter = (markdown?: string, isEnabledMarp?: boolean): UseSlide | undefined => {
 
-  const [processor, setProcessor] = useState<Processor|undefined>();
+  const [processor, setProcessor] = useState<Processor<Root, undefined, undefined, Root, string>|undefined>();
   const [parseResult, setParseResult] = useState<UseSlide|undefined>();
 
   useEffect(() => {

+ 5 - 5
packages/remark-attachment-refs/package.json

@@ -49,8 +49,8 @@
     "@growi/ui": "link:../ui",
     "axios": "^0.24.0",
     "bunyan": "^1.8.15",
+    "hast-util-select": "^6.0.2",
     "express": "^4.20.0",
-    "hast-util-select": "^5.0.5",
     "mongoose": "^6.11.3",
     "swr": "^2.0.3",
     "universal-bunyan": "^0.9.2",
@@ -59,11 +59,11 @@
   "devDependencies": {
     "csstype": "^3.0.2",
     "eslint-plugin-regex": "^1.8.0",
-    "hast-util-sanitize": "^4.1.0",
-    "hast-util-select": "^5.0.5",
+    "hast-util-sanitize": "^5.0.1",
+    "hast-util-select": "^6.0.2",
     "npm-run-all": "^4.1.5",
-    "unified": "^10.1.2",
-    "unist-util-visit": "^4.0.0"
+    "unified": "^11.0.0",
+    "unist-util-visit": "^5.0.0"
   },
   "peerDependencies": {
     "react": "^18.2.0",

+ 6 - 4
packages/remark-attachment-refs/src/client/services/renderer/refs.ts

@@ -1,9 +1,10 @@
 import { pathUtils } from '@growi/core/dist/utils';
+import type { TextGrowiPluginDirective, LeafGrowiPluginDirective } from '@growi/remark-growi-directive';
 import { remarkGrowiDirectivePluginType } from '@growi/remark-growi-directive';
-import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type { Nodes as HastNode } from 'hast';
+import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import { selectAll } from 'hast-util-select';
-import type { Node as HastNode } from 'hast-util-select/lib/types';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
 import loggerFactory from '../../../utils/logger';
@@ -21,10 +22,11 @@ const REFS_IMG_SUPPORTED_ATTRIBUTES = [
 ];
 
 type DirectiveAttributes = Record<string, string>
+type GrowiPluginDirective = TextGrowiPluginDirective | LeafGrowiPluginDirective
 
 export const remarkPlugin: Plugin = function() {
   return (tree) => {
-    visit(tree, (node) => {
+    visit(tree, (node: GrowiPluginDirective) => {
       if (node.type === remarkGrowiDirectivePluginType.Text || node.type === remarkGrowiDirectivePluginType.Leaf) {
         if (typeof node.name !== 'string') {
           return;

+ 3 - 3
packages/remark-drawio/package.json

@@ -33,11 +33,11 @@
   "dependencies": {},
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
-    "hast-util-sanitize": "^4.1.0",
+    "hast-util-sanitize": "^5.0.1",
     "pako": "^2.1.0",
     "throttle-debounce": "^5.0.0",
-    "unified": "^10.1.2",
-    "unist-util-visit": "^4.0.0"
+    "unified": "^11.0.0",
+    "unist-util-visit": "^5.0.0"
   },
   "peerDependencies": {
     "react": "^18.2.0",

+ 17 - 10
packages/remark-drawio/src/services/renderer/remark-drawio.ts

@@ -1,21 +1,30 @@
+import type { Properties } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
+import type {
+  Code, Node, Paragraph,
+} from 'mdast';
 import type { Plugin } from 'unified';
-import type { Node } from 'unist';
 import { visit } from 'unist-util-visit';
 
 const SUPPORTED_ATTRIBUTES = ['diagramIndex', 'bol', 'eol'];
 
+interface Data {
+  hName?: string,
+  hProperties?: Properties,
+}
+
 type Lang = 'drawio';
 
-function isDrawioBlock(lang: unknown): lang is Lang {
-  return /^drawio$/.test(lang as string);
+function isDrawioBlock(lang?: string | null): lang is Lang {
+  return /^drawio$/.test(lang ?? '');
 }
 
 function rewriteNode(node: Node, index: number) {
-  const data = node.data ?? (node.data = {});
 
   node.type = 'paragraph';
-  node.children = [{ type: 'text', value: node.value }];
+  (node as Paragraph).children = [{ type: 'text', value: (node as Code).value }];
+
+  const data: Data = node.data ?? (node.data = {});
   data.hName = 'drawio';
   data.hProperties = {
     diagramIndex: index,
@@ -27,11 +36,9 @@ function rewriteNode(node: Node, index: number) {
 
 export const remarkPlugin: Plugin = function() {
   return (tree) => {
-    visit(tree, (node, index) => {
-      if (node.type === 'code') {
-        if (isDrawioBlock(node.lang)) {
-          rewriteNode(node, index ?? 0);
-        }
+    visit(tree, 'code', (node: Code, index) => {
+      if (isDrawioBlock(node.lang)) {
+        rewriteNode(node, index ?? 0);
       }
     });
   };

+ 17 - 18
packages/remark-growi-directive/package.json

@@ -18,43 +18,42 @@
   "typings": "dist/index.d.ts",
   "scripts": {
     "build": "yarn tsc -p tsconfig.build.json",
+    "postbuild": "shx cp ./src/mdast-util-growi-directive/index.d.ts ./dist/mdast-util-growi-directive/index.d.ts",
     "clean": "shx rm -rf dist",
     "dev": "yarn build",
     "watch": "yarn tsc -w",
     "test": "cross-env NODE_ENV=test npm run test-coverage",
-    "test-api": "tape --conditions development test/**.test.js",
+    "test-api": "vitest run --coverage",
     "test-coverage": "c8 --check-coverage --branches 100 --functions 100 --lines 100 --statements 100 --reporter lcov npm run test-api",
     "lint": "yarn eslint \"**/*.{cjs, js,jsx,ts,tsx}\"",
     "lint:fix": "yarn eslint \"**/*.{cjs, js,jsx,ts,tsx}\" --fix"
   },
   "dependencies": {
-    "@types/mdast": "^3.0.0",
-    "@types/unist": "^2.0.0",
-    "mdast-util-to-markdown": "^1.3.0",
-    "micromark-factory-space": "^1.0.0",
-    "micromark-factory-whitespace": "^1.0.0",
-    "micromark-util-character": "^1.0.0",
-    "micromark-util-symbol": "^1.0.0",
-    "micromark-util-types": "^1.0.0",
+    "@types/mdast": "^4.0.0",
+    "@types/unist": "^3.0.0",
+    "mdast-util-to-markdown": "^2.1.0",
+    "micromark-factory-space": "^2.0.0",
+    "micromark-factory-whitespace": "^2.0.0",
+    "micromark-util-character": "^2.1.0",
+    "micromark-util-symbol": "^2.0.0",
+    "micromark-util-types": "^2.0.0",
     "parse-entities": "^4.0.0",
     "stringify-entities": "^4.0.0",
-    "unified": "^10.0.0",
-    "unist-util-visit-parents": "^5.0.0",
+    "unified": "^11.0.0",
+    "unist-util-visit-parents": "^6.0.0",
     "uvu": "^0.5.0"
   },
   "devDependencies": {
-    "@types/tape": "^4.0.0",
-    "c8": "^7.0.0",
+    "c8": "^8.0.0",
     "html-void-elements": "^2.0.0",
     "is-hidden": "^2.0.0",
-    "mdast-util-from-markdown": "^1.0.0",
-    "micromark": "^3.0.0",
-    "remark": "^14.0.0",
+    "mdast-util-from-markdown": "^2.0.1",
+    "micromark": "^4.0.0",
+    "remark": "^15.0.1",
     "rimraf": "^3.0.0",
-    "tape": "^5.0.0",
     "to-vfile": "^7.0.0",
     "type-coverage": "^2.0.0",
-    "unist-util-remove-position": "^4.0.0"
+    "unist-util-remove-position": "^5.0.0"
   },
   "typeCoverage": {
     "atLeast": 100,

+ 7 - 2
packages/remark-growi-directive/src/index.js

@@ -1,6 +1,11 @@
-import { DirectiveType } from './mdast-util-growi-directive/consts.js';
 import { remarkGrowiDirectivePlugin } from './remark-growi-directive.js';
 
-export { DirectiveType as remarkGrowiDirectivePluginType };
+export {
+  DirectiveTypeObject as remarkGrowiDirectivePluginType,
+  LeafGrowiPluginDirective,
+  TextGrowiPluginDirective,
+  LeafGrowiPluginDirectiveData,
+  TextGrowiPluginDirectiveData,
+} from './mdast-util-growi-directive';
 
 export default remarkGrowiDirectivePlugin;

+ 0 - 32
packages/remark-growi-directive/src/mdast-util-growi-directive/complex-types.d.ts

@@ -1,32 +0,0 @@
-import type { PhrasingContent } from 'mdast';
-import type { Parent } from 'unist';
-
-import type { DirectiveType } from './consts.js';
-
-
-type DirectiveAttributes = Record<string, string>
-
-interface DirectiveFields {
-  name: string
-  attributes?: DirectiveAttributes
-}
-
-export interface TextDirective extends Parent, DirectiveFields {
-  type: DirectiveType.Text
-  children: PhrasingContent[]
-}
-
-export interface LeafDirective extends Parent, DirectiveFields {
-  type: DirectiveType.Leaf
-  children: PhrasingContent[]
-}
-
-declare module 'mdast' {
-  interface StaticPhrasingContentMap {
-    [DirectiveType.Text]: TextDirective
-  }
-
-  interface BlockContentMap {
-    [DirectiveType.Leaf]: LeafDirective
-  }
-}

+ 0 - 4
packages/remark-growi-directive/src/mdast-util-growi-directive/consts.js

@@ -1,4 +0,0 @@
-export const DirectiveType = Object.freeze({
-  Text: 'textGrowiPluginDirective',
-  Leaf: 'leafGrowiPluginDirective',
-});

+ 177 - 0
packages/remark-growi-directive/src/mdast-util-growi-directive/index.d.ts

@@ -0,0 +1,177 @@
+import type {
+  Data,
+  Parent,
+  PhrasingContent,
+} from 'mdast';
+
+import { DirectiveType as DirectiveTypeObject } from './lib/index.js';
+
+export { directiveFromMarkdown, directiveToMarkdown } from './lib/index.js';
+export { DirectiveTypeObject };
+
+type DirectiveType = typeof DirectiveTypeObject;
+
+/**
+ * Fields shared by directives.
+ */
+interface DirectiveFields {
+  /**
+   * Directive name.
+   */
+  name: string
+
+  /**
+   * Directive attributes.
+   */
+  attributes?: Record<string, string | null | undefined> | null | undefined
+}
+
+/**
+ * Markdown directive (leaf form).
+ */
+export interface LeafGrowiPluginDirective extends Parent, DirectiveFields {
+  /**
+   * Node type of leaf directive.
+   */
+  type: DirectiveType['Leaf']
+
+  /**
+   * Children of leaf directive.
+   */
+  children: PhrasingContent[]
+
+  /**
+   * Data associated with the mdast leaf directive.
+   */
+  data?: LeafGrowiPluginDirectiveData | undefined
+}
+
+/**
+ * Info associated with mdast leaf directive nodes by the ecosystem.
+ */
+export interface LeafGrowiPluginDirectiveData extends Data {
+  hName?: string,
+  hProperties?: Record<string, string>
+}
+
+/**
+ * Markdown directive (text form).
+ */
+export interface TextGrowiPluginDirective extends Parent, DirectiveFields {
+  /**
+   * Node type of text directive.
+   */
+  type: DirectiveType['Text']
+
+  /**
+   * Children of text directive.
+   */
+  children: PhrasingContent[]
+
+  /**
+   * Data associated with the text leaf directive.
+   */
+  data?: TextGrowiPluginDirectiveData | undefined
+}
+
+/**
+ * Info associated with mdast text directive nodes by the ecosystem.
+ */
+export interface TextGrowiPluginDirectiveData extends Data {
+  hName?: string,
+  hProperties?: Record<string, string>
+}
+
+
+/**
+ * Union of registered mdast directive nodes.
+ *
+ * It is not possible to register custom mdast directive node types.
+ */
+export type Directives = LeafGrowiPluginDirective | TextGrowiPluginDirective
+
+// Add custom data tracked to turn markdown into a tree.
+declare module 'mdast-util-from-markdown' {
+  interface CompileData {
+    /**
+     * Attributes for current directive.
+     */
+    directiveAttributes?: Array<[string, string]> | undefined
+  }
+}
+
+// Add custom data tracked to turn a syntax tree into markdown.
+declare module 'mdast-util-to-markdown' {
+  interface ConstructNameMap {
+    /**
+     * Whole leaf directive.
+     *
+     * ```markdown
+     * > | ::a
+     *     ^^^
+     * ```
+     */
+    leafGrowiPluginDirective: 'leafGrowiPluginDirective'
+
+    /**
+     * Label of a leaf directive.
+     *
+     * ```markdown
+     * > | ::a[b]
+     *        ^^^
+     * ```
+     */
+    leafGrowiPluginDirectiveLabel: 'leafGrowiPluginDirectiveLabel'
+
+    /**
+     * Whole text directive.
+     *
+     * ```markdown
+     * > | :a
+     *     ^^
+     * ```
+     */
+    textGrowiPluginDirective: 'textGrowiPluginDirective'
+
+    /**
+     * Label of a text directive.
+     *
+     * ```markdown
+     * > | :a[b]
+     *       ^^^
+     * ```
+     */
+    textGrowiPluginDirectiveLabel: 'textGrowiPluginDirectiveLabel'
+  }
+}
+
+// Add nodes to content, register `data` on paragraph.
+declare module 'mdast' {
+  interface BlockContentMap {
+    /**
+     * Directive in flow content (such as in the root document, or block
+     * quotes), which contains nothing.
+     */
+    leafGrowiPluginDirective: LeafGrowiPluginDirective
+  }
+
+  interface PhrasingContentMap {
+    /**
+     * Directive in phrasing content (such as in paragraphs, headings).
+     */
+    textGrowiPluginDirective: TextGrowiPluginDirective
+  }
+
+  interface RootContentMap {
+    /**
+     * Directive in flow content (such as in the root document, or block
+     * quotes), which contains nothing.
+     */
+    leafGrowiPluginDirective: LeafGrowiPluginDirective
+
+    /**
+     * Directive in phrasing content (such as in paragraphs, headings).
+     */
+    textGrowiPluginDirective: TextGrowiPluginDirective
+  }
+}

+ 2 - 267
packages/remark-growi-directive/src/mdast-util-growi-directive/index.js

@@ -1,267 +1,2 @@
-/**
- * @typedef {import('mdast').BlockContent} BlockContent
- * @typedef {import('mdast').Root} Root
- * @typedef {import('mdast').Paragraph} Paragraph
- * @typedef {import('mdast-util-from-markdown').Handle} FromMarkdownHandle
- * @typedef {import('mdast-util-from-markdown').Extension} FromMarkdownExtension
- * @typedef {import('mdast-util-from-markdown').CompileContext} CompileContext
- * @typedef {import('mdast-util-from-markdown').Token} Token
- * @typedef {import('mdast-util-to-markdown/lib/types.js').Handle} ToMarkdownHandle
- * @typedef {import('mdast-util-to-markdown/lib/types.js').Context} Context
- * @typedef {import('mdast-util-to-markdown/lib/types.js').Options} ToMarkdownExtension
- * @typedef {import('./complex-types').LeafDirective} LeafDirective
- * @typedef {import('./complex-types').TextDirective} TextDirective
- * @typedef {LeafDirective|TextDirective} Directive
- */
-
-import { checkQuote } from 'mdast-util-to-markdown/lib/util/check-quote.js';
-import { containerPhrasing } from 'mdast-util-to-markdown/lib/util/container-phrasing.js';
-import { track } from 'mdast-util-to-markdown/lib/util/track.js';
-import { parseEntities } from 'parse-entities';
-import { stringifyEntitiesLight } from 'stringify-entities';
-
-import { DirectiveType } from './consts.js';
-
-const own = {}.hasOwnProperty;
-
-const shortcut = /^[^\t\n\r "#'.<=>`}]+$/;
-
-handleDirective.peek = peekDirective;
-
-/** @type {FromMarkdownExtension} */
-export const directiveFromMarkdown = {
-  canContainEols: [DirectiveType.Text],
-  enter: {
-    directiveLeaf: enterLeaf,
-    directiveLeafAttributes: enterAttributes,
-
-    directiveText: enterText,
-    directiveTextAttributes: enterAttributes,
-  },
-  exit: {
-    directiveLeaf: exit,
-    directiveLeafAttributeName: exitAttributeName,
-    directiveLeafAttributeValue: exitAttributeValue,
-    directiveLeafAttributes: exitAttributes,
-    directiveLeafName: exitName,
-
-    directiveText: exit,
-    directiveTextAttributeName: exitAttributeName,
-    directiveTextAttributeValue: exitAttributeValue,
-    directiveTextAttributes: exitAttributes,
-    directiveTextName: exitName,
-  },
-};
-
-/** @type {ToMarkdownExtension} */
-export const directiveToMarkdown = {
-  unsafe: [
-    {
-      character: '\r',
-      inConstruct: [DirectiveType.Leaf],
-    },
-    {
-      character: '\n',
-      inConstruct: [DirectiveType.Leaf],
-    },
-    {
-      before: '[^$]',
-      character: '$',
-      after: '[A-Za-z]',
-      inConstruct: ['phrasing'],
-    },
-    { atBreak: true, character: '$', after: '$' },
-  ],
-  handlers: {
-    [DirectiveType.Leaf]: handleDirective,
-    [DirectiveType.Text]: handleDirective,
-  },
-};
-
-/** @type {FromMarkdownHandle} */
-function enterLeaf(token) {
-  enter.call(this, DirectiveType.Leaf, token);
-}
-
-/** @type {FromMarkdownHandle} */
-function enterText(token) {
-  enter.call(this, DirectiveType.Text, token);
-}
-
-/**
- * @this {CompileContext}
- * @param {Directive['type']} type
- * @param {Token} token
- */
-function enter(type, token) {
-  this.enter({
-    type, name: '', attributes: {}, children: [],
-  }, token);
-}
-
-/**
- * @this {CompileContext}
- * @param {Token} token
- */
-function exitName(token) {
-  const node = /** @type {Directive} */ (this.stack[this.stack.length - 1]);
-  node.name = this.sliceSerialize(token);
-}
-
-/** @type {FromMarkdownHandle} */
-function enterAttributes() {
-  this.setData('directiveAttributes', []);
-  this.buffer(); // Capture EOLs
-}
-
-/** @type {FromMarkdownHandle} */
-function exitAttributeValue(token) {
-  const list = /** @type {Array.<[string, string]>} */ (
-    this.getData('directiveAttributes')
-  );
-  list[list.length - 1][1] = parseEntities(this.sliceSerialize(token));
-}
-
-/** @type {FromMarkdownHandle} */
-function exitAttributeName(token) {
-  const list = /** @type {Array.<[string, string]>} */ (
-    this.getData('directiveAttributes')
-  );
-
-  // Attribute names in CommonMark are significantly limited, so character
-  // references can’t exist.
-  list.push([this.sliceSerialize(token), '']);
-}
-
-/** @type {FromMarkdownHandle} */
-function exitAttributes() {
-  const list = /** @type {Array.<[string, string]>} */ (
-    this.getData('directiveAttributes')
-  );
-  /** @type {Record.<string, string>} */
-  const cleaned = {};
-  let index = -1;
-
-  while (++index < list.length) {
-    const attribute = list[index];
-
-    cleaned[attribute[0]] = attribute[1];
-  }
-
-  this.setData('directiveAttributes');
-  this.resume(); // Drop EOLs
-  const node = /** @type {Directive} */ (this.stack[this.stack.length - 1]);
-  node.attributes = cleaned;
-}
-
-/** @type {FromMarkdownHandle} */
-function exit(token) {
-  this.exit(token);
-}
-
-/**
- * @type {ToMarkdownHandle}
- * @param {Directive} node
- */
-function handleDirective(node, _, context, safeOptions) {
-  const tracker = track(safeOptions);
-  const sequence = fence(node);
-  const exit = context.enter(node.type);
-  let value = tracker.move(sequence + (node.name || ''));
-  /** @type {Directive|Paragraph|undefined} */
-  const label = node;
-
-  if (label && label.children && label.children.length > 0) {
-    const exit = context.enter('label');
-    const subexit = context.enter(`${node.type}Label`);
-    value += tracker.move('[');
-    value += tracker.move(
-      containerPhrasing(label, context, {
-        ...tracker.current(),
-        before: value,
-        after: ']',
-      }),
-    );
-    value += tracker.move(']');
-    subexit();
-    exit();
-  }
-
-  value += tracker.move(attributes(node, context));
-
-  exit();
-  return value;
-}
-
-/** @type {ToMarkdownHandle} */
-function peekDirective() {
-  return '$';
-}
-
-/**
- * @param {Directive} node
- * @param {Context} context
- * @returns {string}
- */
-function attributes(node, context) {
-  const quote = checkQuote(context);
-  const subset = node.type === DirectiveType.Text ? [quote] : [quote, '\n', '\r'];
-  const attrs = node.attributes || {};
-  /** @type {Array.<string>} */
-  const values = [];
-  /** @type {string|undefined} */
-  let classesFull;
-  /** @type {string|undefined} */
-  let classes;
-  /** @type {string|undefined} */
-  let id;
-  /** @type {string} */
-  let key;
-
-  // eslint-disable-next-line no-restricted-syntax
-  for (key in attrs) {
-    if (
-      own.call(attrs, key)
-      && attrs[key] !== undefined
-      && attrs[key] !== null
-    ) {
-      const value = String(attrs[key]);
-
-      values.push(quoted(key, value));
-    }
-  }
-
-  return values.length > 0 ? `(${values.join(' ')})` : '';
-
-  /**
-   * @param {string} key
-   * @param {string} value
-   * @returns {string}
-   */
-  function quoted(key, value) {
-    return (
-      key
-      + (value
-        ? `=${quote}${stringifyEntitiesLight(value, { subset })}${quote}`
-        : '')
-    );
-  }
-}
-
-/**
- * @param {Directive} node
- * @returns {string}
- */
-function fence(node) {
-  let size = 0;
-
-  if (node.type === DirectiveType.Leaf) {
-    size = 1;
-  }
-  else {
-    size = 1;
-  }
-
-  return '$'.repeat(size);
-
-}
+export { directiveFromMarkdown, directiveToMarkdown } from './lib/index.js';
+export { DirectiveType as DirectiveTypeObject } from './lib/index.js';

+ 276 - 0
packages/remark-growi-directive/src/mdast-util-growi-directive/lib/index.js

@@ -0,0 +1,276 @@
+/**
+ * @typedef {import('mdast').Node} Node
+ * @typedef {import('mdast').Paragraph} Paragraph
+ *
+ * @typedef {import('mdast-util-from-markdown').CompileContext} CompileContext
+ * @typedef {import('mdast-util-from-markdown').Extension} FromMarkdownExtension
+ * @typedef {import('mdast-util-from-markdown').Handle} FromMarkdownHandle
+ * @typedef {import('mdast-util-from-markdown').Token} Token
+ *
+ * @typedef {import('mdast-util-to-markdown').ConstructName} ConstructName
+ * @typedef {import('mdast-util-to-markdown').Handle} ToMarkdownHandle
+ * @typedef {import('mdast-util-to-markdown').Options} ToMarkdownExtension
+ * @typedef {import('mdast-util-to-markdown').State} State
+ *
+ * @typedef {import('../types/index.js').LeafGrowiPluginDirective} LeafGrowiPluginDirective
+ * @typedef {import('../types/index.js').TextGrowiPluginDirective} TextGrowiPluginDirective
+ * @typedef {import('../types/index.js').Directives} Directives
+ */
+
+import { parseEntities } from 'parse-entities';
+import { stringifyEntitiesLight } from 'stringify-entities';
+
+const own = {}.hasOwnProperty;
+
+const shortcut = /^[^\t\n\r "#'.<=>`}]+$/;
+
+
+export const DirectiveType = Object.freeze({
+  Text: 'textGrowiPluginDirective',
+  Leaf: 'leafGrowiPluginDirective',
+});
+
+handleDirective.peek = peekDirective;
+
+/** @type {FromMarkdownExtension} */
+export function directiveFromMarkdown() {
+  return {
+    canContainEols: [DirectiveType.Text],
+    enter: {
+      directiveLeaf: enterLeaf,
+      directiveLeafAttributes: enterAttributes,
+
+      directiveText: enterText,
+      directiveTextAttributes: enterAttributes,
+    },
+    exit: {
+      directiveLeaf: exit,
+      directiveLeafAttributeName: exitAttributeName,
+      directiveLeafAttributeValue: exitAttributeValue,
+      directiveLeafAttributes: exitAttributes,
+      directiveLeafName: exitName,
+
+      directiveText: exit,
+      directiveTextAttributeName: exitAttributeName,
+      directiveTextAttributeValue: exitAttributeValue,
+      directiveTextAttributes: exitAttributes,
+      directiveTextName: exitName,
+
+    },
+  };
+}
+
+/** @type {ToMarkdownExtension} */
+export function directiveToMarkdown() {
+  return {
+    unsafe: [
+      {
+        character: '\r',
+        inConstruct: [DirectiveType.Leaf],
+      },
+      {
+        character: '\n',
+        inConstruct: [DirectiveType.Leaf],
+      },
+      {
+        before: '[^$]',
+        character: '$',
+        after: '[A-Za-z]',
+        inConstruct: ['phrasing'],
+      },
+      { atBreak: true, character: '$', after: '$' },
+    ],
+    handlers: {
+      [DirectiveType.Leaf]: handleDirective,
+      [DirectiveType.Text]: handleDirective,
+    },
+  };
+}
+
+/** @type {FromMarkdownHandle} */
+function enterLeaf(token) {
+  enter.call(this, DirectiveType.Leaf, token);
+}
+
+/** @type {FromMarkdownHandle} */
+function enterText(token) {
+  enter.call(this, DirectiveType.Text, token);
+}
+
+/**
+ * @this {CompileContext}
+ * @param {Directive['type']} type
+ * @param {Token} token
+ */
+function enter(type, token) {
+  this.enter({
+    type, name: '', attributes: {}, children: [],
+  }, token);
+}
+
+/**
+ * @this {CompileContext}
+ * @param {Token} token
+ */
+function exitName(token) {
+  const node = /** @type {Directive} */ (this.stack[this.stack.length - 1]);
+  node.name = this.sliceSerialize(token);
+}
+
+/** @type {FromMarkdownHandle} */
+function enterAttributes() {
+  this.data.directiveAttributes = [];
+  this.buffer(); // Capture EOLs
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributeValue(token) {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.data.directiveAttributes
+  );
+  list[list.length - 1][1] = parseEntities(this.sliceSerialize(token));
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributeName(token) {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.data.directiveAttributes
+  );
+
+  // Attribute names in CommonMark are significantly limited, so character
+  // references can’t exist.
+  list.push([this.sliceSerialize(token), '']);
+}
+
+/** @type {FromMarkdownHandle} */
+function exitAttributes() {
+  const list = /** @type {Array.<[string, string]>} */ (
+    this.data.directiveAttributes
+  );
+  /** @type {Record.<string, string>} */
+  const cleaned = {};
+  let index = -1;
+
+  while (++index < list.length) {
+    const attribute = list[index];
+
+    cleaned[attribute[0]] = attribute[1];
+  }
+
+  this.data.directiveAttributes = [];
+  this.resume(); // Drop EOLs
+  const node = /** @type {Directive} */ (this.stack[this.stack.length - 1]);
+  node.attributes = cleaned;
+}
+
+/** @type {FromMarkdownHandle} */
+function exit(token) {
+  this.exit(token);
+}
+
+/**
+ * @type {ToMarkdownHandle}
+ * @param {Directive} node
+ */
+function handleDirective(node, _, context, safeOptions) {
+  const tracker = context.createTracker(safeOptions);
+  const sequence = fence(node);
+  const exit = context.enter(node.type);
+  let value = tracker.move(sequence + (node.name || ''));
+  /** @type {Directive|Paragraph|undefined} */
+  const label = node;
+
+  if (label && label.children && label.children.length > 0) {
+    const exit = context.enter('label');
+    const subexit = context.enter(`${node.type}Label`);
+    value += tracker.move('[');
+    value += tracker.move(
+      context.containerPhrasing(label, {
+        ...tracker.current(),
+        before: value,
+        after: ']',
+      }),
+    );
+    value += tracker.move(']');
+    subexit();
+    exit();
+  }
+
+  value += tracker.move(attributes(node, context));
+
+  exit();
+  return value;
+}
+
+/** @type {ToMarkdownHandle} */
+function peekDirective() {
+  return '$';
+}
+
+/**
+ * @param {Directive} node
+ * @param {State} state
+ * @returns {string}
+ */
+function attributes(node, state) {
+  const quote = state.options.quote || '"';
+  const subset = node.type === DirectiveType.Text ? [quote] : [quote, '\n', '\r'];
+  const attrs = node.attributes || {};
+  /** @type {Array.<string>} */
+  const values = [];
+  /** @type {string|undefined} */
+  let classesFull;
+  /** @type {string|undefined} */
+  let classes;
+  /** @type {string|undefined} */
+  let id;
+  /** @type {string} */
+  let key;
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (key in attrs) {
+    if (
+      own.call(attrs, key)
+      && attrs[key] !== undefined
+      && attrs[key] !== null
+    ) {
+      const value = String(attrs[key]);
+
+      values.push(quoted(key, value));
+    }
+  }
+
+  return values.length > 0 ? `(${values.join(' ')})` : '';
+
+  /**
+   * @param {string} key
+   * @param {string} value
+   * @returns {string}
+   */
+  function quoted(key, value) {
+    return (
+      key
+      + (value
+        ? `=${quote}${stringifyEntitiesLight(value, { subset })}${quote}`
+        : '')
+    );
+  }
+}
+
+/**
+ * @param {Directive} node
+ * @returns {string}
+ */
+function fence(node) {
+  let size = 0;
+
+  if (node.type === DirectiveType.Leaf) {
+    size = 1;
+  }
+  else {
+    size = 1;
+  }
+
+  return '$'.repeat(size);
+
+}

+ 1 - 2
packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/directive-leaf.js

@@ -6,8 +6,7 @@
 
 import { factorySpace } from 'micromark-factory-space';
 import { markdownLineEnding } from 'micromark-util-character';
-import { codes } from 'micromark-util-symbol/codes.js';
-import { types } from 'micromark-util-symbol/types.js';
+import { codes, types } from 'micromark-util-symbol';
 import { ok as assert } from 'uvu/assert';
 
 import { factoryAttributes } from './factory-attributes.js';

+ 1 - 2
packages/remark-growi-directive/src/micromark-extension-growi-directive/lib/directive-text.js

@@ -5,8 +5,7 @@
  * @typedef {import('micromark-util-types').State} State
  */
 
-import { codes } from 'micromark-util-symbol/codes.js';
-import { types } from 'micromark-util-symbol/types.js';
+import { codes, types } from 'micromark-util-symbol';
 import { ok as assert } from 'uvu/assert';
 
 import { factoryAttributes } from './factory-attributes.js';

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini