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

Merge branch 'master' into imprv/140677-145630-new-design

kazutoweseek 1 год назад
Родитель
Сommit
d1fbfadb9c
64 измененных файлов с 736 добавлено и 844 удалено
  1. 4 1
      apps/app/nodemon.json
  2. 28 0
      apps/app/playwright/20-basic-features/presentation.spec.ts
  3. 86 0
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  4. 7 0
      apps/app/public/images/growi-brand-logo-login.svg
  5. 7 7
      apps/app/public/images/logo.svg
  6. 3 31
      apps/app/public/static/locales/en_US/translation.json
  7. 3 31
      apps/app/public/static/locales/fr_FR/translation.json
  8. 3 31
      apps/app/public/static/locales/ja_JP/translation.json
  9. 3 31
      apps/app/public/static/locales/zh_CN/translation.json
  10. 29 31
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx
  11. 3 3
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx
  12. 12 4
      apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  13. 1 1
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  14. 1 1
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx
  15. 3 1
      apps/app/src/client/components/PageComment/DeleteCommentModal.tsx
  16. 11 5
      apps/app/src/client/components/PageCreateModal.tsx
  17. 2 2
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  18. 27 4
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  19. 3 1
      apps/app/src/client/components/PagePresentationModal.tsx
  20. 1 1
      apps/app/src/client/components/PageTags/TagEditModal.tsx
  21. 1 1
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  22. 8 24
      apps/app/src/client/services/renderer/renderer.tsx
  23. 15 16
      apps/app/src/components/Common/GrowiLogo.jsx
  24. 1 1
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  25. 3 1
      apps/app/src/components/Layout/NoLoginLayout.tsx
  26. 31 0
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx
  27. 2 2
      apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx
  28. 0 19
      apps/app/src/features/comment/server/models/comment.ts
  29. 11 1
      apps/app/src/features/rate-limiter/config/index.ts
  30. 2 1
      apps/app/src/features/rate-limiter/utils/config-generator.ts
  31. 4 0
      apps/app/src/pages/[[...path]].page.tsx
  32. 21 2
      apps/app/src/pages/_document.page.tsx
  33. 11 6
      apps/app/src/pages/utils/commons.ts
  34. 1 19
      apps/app/src/server/models/activity.ts
  35. 0 9
      apps/app/src/server/models/obsolete-page.js
  36. 6 9
      apps/app/src/server/models/page-tag-relation.ts
  37. 1 0
      apps/app/src/server/models/tag.ts
  38. 6 5
      apps/app/src/server/routes/apiv3/staffs.js
  39. 12 0
      apps/app/src/server/service/config-loader.ts
  40. 1 1
      apps/app/src/server/service/import.js
  41. 139 0
      apps/app/src/server/service/search-delegator/aggregate-to-index.ts
  42. 52 0
      apps/app/src/server/service/search-delegator/bulk-write.d.ts
  43. 43 145
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  44. 7 9
      apps/app/src/server/service/search.ts
  45. 2 1
      apps/app/src/server/service/slack-command-handler/keep.js
  46. 3 2
      apps/app/src/server/service/slack-command-handler/togetter.js
  47. 19 17
      apps/app/src/services/renderer/renderer.tsx
  48. 4 0
      apps/app/src/stores-universal/context.tsx
  49. 2 1
      apps/app/src/stores/admin/customize.tsx
  50. 0 219
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  51. 2 2
      packages/core-styles/scss/variables/_growi-official-colors.scss
  52. 3 1
      packages/editor/package.json
  53. 2 0
      packages/editor/src/@types/emoji-mart.d.ts
  54. 23 79
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx
  55. 4 6
      packages/editor/src/client/services-internal/extensions/emojiAutocompletionSettings.ts
  56. 0 3
      packages/editor/vite.config.ts
  57. 3 0
      packages/presentation/package.json
  58. 0 13
      packages/presentation/src/client/components/Presentation.global.scss
  59. 28 0
      packages/presentation/src/client/components/Presentation.module.scss
  60. 4 5
      packages/presentation/src/client/components/Presentation.tsx
  61. 2 1
      packages/presentation/src/client/services/growi-marpit.ts
  62. 1 1
      packages/presentation/src/client/services/renderer/extract-sections.ts
  63. 1 0
      packages/presentation/src/client/services/sanitize-option.ts
  64. 18 36
      yarn.lock

+ 4 - 1
apps/app/nodemon.json

@@ -4,6 +4,9 @@
     ".next",
     ".next",
     "public/static",
     "public/static",
     "package.json",
     "package.json",
-    "playwright"
+    "playwright",
+    "test",
+    "test-with-vite",
+    "tmp"
   ]
   ]
 }
 }

+ 28 - 0
apps/app/playwright/20-basic-features/presentation.spec.ts

@@ -0,0 +1,28 @@
+import { test, expect } from '@playwright/test';
+
+test('Presentation', async({ page }) => {
+  await page.goto('/');
+
+  // show presentation modal
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+  await page.getByTestId('open-presentation-modal-btn').click();
+
+  // check the content of the h1
+  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
+    .toHaveText(/Welcome to GROWI/);
+
+  // forward the slide with keyboard
+  await page.keyboard.press('ArrowRight');
+
+  // check the content of the h1
+  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
+    .toHaveText(/What can you do with GROWI?/);
+
+  // forward the slide with button
+  await page.getByRole('application').getByLabel('next slide').click();
+
+  // check the content of the h2
+  await expect(page.getByRole('application').getByRole('heading', { level: 2 }))
+    .toHaveText(/1. Knowledge Management: Create pages to store information and knowledge/);
+
+});

+ 86 - 0
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -0,0 +1,86 @@
+import { test, expect, type Page } from '@playwright/test';
+
+const openPageItemControl = async(page: Page): Promise<void> => {
+  await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+  await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+};
+
+test('Page Deletion and PutBack is executed successfully', async({ page }) => {
+  await page.goto('/Sandbox/Bootstrap5');
+
+  // Delete
+  await openPageItemControl(page);
+  await page.getByTestId('open-page-delete-modal-btn').click();
+  await expect(page.getByTestId('page-delete-modal')).toBeVisible();
+  await page.getByTestId('delete-page-button').click();
+
+  // PutBack
+  await expect(page.getByTestId('trash-page-alert')).toBeVisible();
+  await page.getByTestId('put-back-button').click();
+  await expect(page.getByTestId('put-back-page-modal')).toBeVisible();
+  await page.getByTestId('put-back-execution-button').click();
+  await expect(page.getByTestId('trash-page-alert')).not.toBeVisible();
+});
+
+test('PageDuplicateModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  await openPageItemControl(page);
+  await page.getByTestId('open-page-duplicate-modal-btn').click();
+
+  await expect(page.getByTestId('page-duplicate-modal')).toBeVisible();
+});
+
+test('PageMoveRenameModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
+
+  await openPageItemControl(page);
+  await page.getByTestId('rename-page-btn').click();
+
+  await expect(page.getByTestId('page-rename-modal')).toBeVisible();
+});
+
+// TODO: Uncomment after https://redmine.weseek.co.jp/issues/149786
+// test('PresentationModal for "/" is shown successfully', async({ page }) => {
+//   await page.goto('/');
+
+//   await openPageItemControl(page);
+//   await page.getByTestId('open-presentation-modal-btn').click();
+
+//   expect(page.getByTestId('page-presentation-modal')).toBeVisible();
+// });
+
+test.describe('Page Accessories Modal', () => {
+  test.beforeEach(async({ page }) => {
+    await page.goto('/');
+    await openPageItemControl(page);
+  });
+
+  test('Page History is shown successfully', async({ page }) => {
+    await page.getByTestId('open-page-accessories-modal-btn-with-history-tab').click();
+    await expect(page.getByTestId(('page-history'))).toBeVisible();
+  });
+
+  test('Page Attachment Data is shown successfully', async({ page }) => {
+    await page.getByTestId('open-page-accessories-modal-btn-with-attachment-data-tab').click();
+    await expect(page.getByTestId('page-attachment')).toBeVisible();
+  });
+
+  test('Share Link Management is shown successfully', async({ page }) => {
+    await page.getByTestId('open-page-accessories-modal-btn-with-share-link-management-data-tab').click();
+    await expect(page.getByTestId('share-link-management')).toBeVisible();
+  });
+});
+
+test('Successfully add new tag', async({ page }) => {
+  const tag = 'we';
+  await page.goto('/Sandbox/Bootstrap5');
+
+  await page.locator('#edit-tags-btn-wrapper-for-tooltip').click();
+  await expect(page.locator('#edit-tag-modal')).toBeVisible();
+  await page.locator('.rbt-input-main').fill(tag);
+  await expect(page.locator('#tag-typeahead-asynctypeahead-item-0')).toBeVisible();
+  await page.locator('#tag-typeahead-asynctypeahead-item-0').click();
+  await page.getByTestId('tag-edit-done-btn').click();
+  await expect(page.getByTestId('grw-tag-labels')).toContainText(tag);
+});

+ 7 - 0
apps/app/public/images/growi-brand-logo-login.svg

@@ -0,0 +1,7 @@
+<svg viewBox="0 0 353 78" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M352.62 2.32007H338V75.4701H352.62V2.32007Z" fill="white"/>
+<path d="M293.71 75.4701L279.67 28.4501L265.56 75.4701H251.85L230.46 2.32007H245.76L258.94 50.6901L272.85 5.33007H286.81L300.39 50.4801L313.26 2.32007H328.81L308.26 75.4701H293.71Z" fill="white"/>
+<path d="M186.671 77.28C181.341 77.28 176.351 76.33 171.831 74.45C167.301 72.57 163.331 69.89 160.041 66.5C156.741 63.11 154.131 59.01 152.271 54.31C150.411 49.62 149.471 44.37 149.471 38.7C149.471 33.03 150.411 27.58 152.271 22.89C154.131 18.19 156.751 14.14 160.051 10.84C163.351 7.55001 167.321 4.97001 171.851 3.19001C176.371 1.41001 181.361 0.51001 186.681 0.51001C192.001 0.51001 197.101 1.41001 201.651 3.19001C206.221 4.97001 210.221 7.55001 213.541 10.84C216.881 14.14 219.511 18.19 221.371 22.89C223.231 27.58 224.171 32.9 224.171 38.7C224.171 44.5 223.231 49.62 221.371 54.31C219.511 59 216.881 63.1 213.551 66.5C210.221 69.89 206.221 72.57 201.661 74.45C197.101 76.33 192.061 77.28 186.681 77.28H186.671ZM186.761 13.62C183.401 13.62 180.321 14.27 177.621 15.54C174.911 16.82 172.541 18.61 170.601 20.84C168.651 23.09 167.121 25.75 166.051 28.77C164.981 31.8 164.431 35.13 164.431 38.69C164.431 42.25 164.971 45.8 166.051 48.86C167.121 51.91 168.661 54.59 170.651 56.83C172.631 59.07 175.001 60.83 177.711 62.08C180.421 63.33 183.461 63.96 186.761 63.96C190.061 63.96 193.131 63.33 195.871 62.08C198.611 60.83 201.001 59.07 202.981 56.83C204.961 54.59 206.511 51.91 207.571 48.86C208.641 45.79 209.191 42.37 209.191 38.69C209.191 35.01 208.641 31.81 207.571 28.77C206.501 25.75 204.971 23.09 203.021 20.84C201.081 18.61 198.701 16.82 195.961 15.54C193.221 14.26 190.131 13.61 186.771 13.61L186.761 13.62Z" fill="white"/>
+<path d="M123 75.4701L107.33 48.3101H98.8104V75.4701H84.1904V2.32007H109.9C113.31 2.32007 116.66 2.68007 119.87 3.40007C123.12 4.13007 126.05 5.34007 128.59 7.01007C131.16 8.70007 133.24 10.9401 134.79 13.6801C136.34 16.4301 137.13 21.5601 137.13 25.4701C137.13 32.6801 132.83 41.7601 122.21 46.3201L139.04 75.4801H123.01L123 75.4701ZM108.2 36.0101C109.96 36.0101 111.74 35.8801 113.49 35.6201C115.18 35.3701 116.75 34.9401 118.07 34.2101C120.83 32.6801 122.52 28.4301 122.52 25.4801C122.52 22.5301 121.06 19.0801 118.76 17.2001C117.09 15.8301 114.48 14.9401 109.31 14.9401H98.8204V36.0101H108.2Z" fill="white"/>
+<path d="M40.0404 77.28C34.1804 77.28 28.7504 76.33 23.9004 74.45C19.0404 72.57 14.8004 69.89 11.3104 66.5C7.81039 63.11 5.05039 59 3.09039 54.31C1.14039 49.62 0.150391 44.37 0.150391 38.7C0.150391 33.03 1.17039 27.57 3.20039 22.88C5.22039 18.18 8.04039 14.13 11.5704 10.84C15.1004 7.55002 19.3204 4.98002 24.1104 3.20002C28.8904 1.42002 34.1504 0.52002 39.7304 0.52002C45.3104 0.52002 50.8804 1.37002 55.6904 3.04002C60.5204 4.72002 64.4804 6.97002 67.4504 9.73002L68.1204 10.35L60.4204 23.7C58.7404 21 53.1704 17.01 50.0604 15.66C46.9504 14.31 43.5104 13.63 39.8404 13.63C36.1704 13.63 32.9404 14.28 30.0304 15.56C27.1204 16.84 24.6004 18.62 22.5604 20.86C20.5104 23.1 18.9004 25.77 17.7804 28.79C16.6504 31.82 16.0804 35.15 16.0804 38.71C16.0804 42.27 16.6504 45.82 17.7804 48.88C18.9004 51.93 20.5304 54.61 22.6104 56.85C24.6804 59.08 27.2404 60.85 30.2204 62.09C33.2004 63.34 36.6404 63.98 40.4504 63.98C45.4604 63.98 49.7604 63.29 53.5104 61.83V48.33H46.1004L38.9304 36.02H68.1204V71.4C64.4504 73.17 59.5604 74.83 55.1804 75.81C50.8004 76.79 45.7004 77.29 40.0304 77.29L40.0404 77.28Z" fill="white"/>
+</svg>

+ 7 - 7
apps/app/public/images/logo.svg

@@ -1,12 +1,12 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 226.44 196.11">
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 56">
   <defs>
   <defs>
     <style>
     <style>
-      .group1 { fill: #74bc46; }
-      .group2 { fill: #175fa5; }
+      .group1 { fill: #7AD340; }
+      .group2 { fill: #428DD1; }
     </style>
     </style>
   </defs>
   </defs>
-  <polygon class="group2" points="56.61 196.11 169.83 196.11 226.44 98.06 188.7 98.06 150.96 163.43 75.48 163.43 56.61 196.11" />
-  <polygon class="group1" points="75.48 98.05 94.35 65.37 150.96 65.38 207.57 65.37 207.57 65.38 226.44 98.06 169.83 98.06 113.22 98.06 94.39 130.66 94.3 130.66 84.92 114.4 75.48 98.05" />
-  <polygon class="group1" points="0 98.06 56.6 0 113.22 0.01 169.83 0.01 169.83 0.01 188.69 32.68 132.09 32.69 75.47 32.69 18.86 130.74 0 98.06" />
-  <polygon class="group1" points="75.48 163.43 56.61 130.74 37.71 163.46 47.15 179.81 56.54 196.07 56.63 196.07 75.48 163.43" />
+  <path d="M17.123 33.8015L10.4717 45.3855C10.2686 45.7427 10.2686 46.1829 10.4717 46.5337L15.5934 55.4514C15.7838 55.7767 16.171 55.9999 16.5645 55.9999H17.123L23.5014 44.9007L17.123 33.8015Z" class="group1"/>
+<path d="M50.8118 29.0493L42.0343 44.3331C41.8693 44.6138 41.571 44.9072 41.0632 44.9072H23.4956L17.1172 56H47.4607C47.8542 56 48.1842 55.8023 48.3873 55.4514L63.5559 29.043H50.8118V29.0493Z" class="group2"/>
+<path d="M63.8353 28.5773C64.0447 28.22 64.0574 27.8182 63.8543 27.461L58.7262 18.5369C58.5231 18.1797 58.174 17.9501 57.7615 17.9501H26.8975C26.485 17.9501 26.1106 18.1733 25.9011 18.5178L21.0586 26.9379L27.437 38.0499L32.1272 29.8849C32.4255 29.3746 32.9713 29.0557 33.5552 29.0557H63.5624L63.8353 28.5836V28.5773Z" class="group1"/>
+<path d="M22.956 11.0992H54.4546L48.4125 0.580476C48.2094 0.22326 47.8604 0 47.4478 0H16.5839C16.1714 0 15.7969 0.204123 15.5875 0.56134L0.152321 27.4227C-0.0507735 27.7799 -0.0507735 28.2137 0.152321 28.5709L6.20706 39.1088L21.9595 11.6606C22.1626 11.3033 22.5434 11.0928 22.956 11.0928V11.0992Z" class="group1"/>
 </svg>
 </svg>

+ 3 - 31
apps/app/public/static/locales/en_US/translation.json

@@ -321,7 +321,9 @@
       "stale": "More than {{count}} year has passed since last update.",
       "stale": "More than {{count}} year has passed since last update.",
       "stale_plural": "More than {{count}} years has passed since last update.",
       "stale_plural": "More than {{count}} years has passed since last update.",
       "expiration": "This share link will expire at <strong>{{expiredAt}}</strong>.",
       "expiration": "This share link will expire at <strong>{{expiredAt}}</strong>.",
-      "no_deadline": "This page has no expiration date"
+      "no_deadline": "This page has no expiration date",
+      "not_indexed1": "This page may not be indexed by Full-Text search engines.",
+      "not_indexed2": "Page body exceeds the threshold specified by {{threshold}}."
     }
     }
   },
   },
   "page_edit": {
   "page_edit": {
@@ -707,36 +709,6 @@
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match",
     "password_and_confirm_password_does_not_match": "Password and confirm password does not match",
     "please_enable_mailer_alert": "The password reset feature is disabled because email setup has not been completed. Please ask administrator to complete the email setup."
     "please_enable_mailer_alert": "The password reset feature is disabled because email setup has not been completed. Please ask administrator to complete the email setup."
   },
   },
-  "emoji": {
-    "title": "Pick an Emoji",
-    "search": "Search",
-    "clear": "Clear",
-    "notfound": "No Emoji Found",
-    "skintext": "Choose your default skin tone",
-    "categories": {
-      "search": "Search Results",
-      "recent": "Frequently Used",
-      "smileys": "Smileys & Emotion",
-      "people": "People & Body",
-      "nature": "Animals & Nature",
-      "foods": "Food & Drink",
-      "activity": "Activity",
-      "places": "Travel & Places",
-      "objects": "Objects",
-      "symbols": "Symbols",
-      "flags": "Flags",
-      "custom": "Custom"
-    },
-    "categorieslabel": "Emoji categories",
-    "skintones": {
-      "1": "Default Skin Tone",
-      "2": "Light Skin Tone",
-      "3": "Medium-Light Skin Tone",
-      "4": "Medium Skin Tone",
-      "5": "Medium-Dark Skin Tone",
-      "6": "Dark Skin Tone"
-    }
-  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "Maintenance Mode",
     "maintenance_mode": "Maintenance Mode",
     "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",
     "growi_is_under_maintenance": "GROWI is under maintenance. Please wait until it ends.",

+ 3 - 31
apps/app/public/static/locales/fr_FR/translation.json

@@ -321,7 +321,9 @@
       "stale": "Plus de {{count}} an est passé depuis la dernière mise à jour.",
       "stale": "Plus de {{count}} an est passé depuis la dernière mise à jour.",
       "stale_plural": "Plus de {{count}} années sont passées depuis la dernière mise à jour.",
       "stale_plural": "Plus de {{count}} années sont passées depuis la dernière mise à jour.",
       "expiration": "Ce lien expirera <strong>{{expiredAt}}</strong>.",
       "expiration": "Ce lien expirera <strong>{{expiredAt}}</strong>.",
-      "no_deadline": "Cette page n'a pas de date d'expiration"
+      "no_deadline": "Cette page n'a pas de date d'expiration",
+      "not_indexed1": "Cette page n'est peut-être pas indexée par les moteurs de recherche Full-Text.",
+      "not_indexed2": "Le corps de la page dépasse le seuil spécifié par {{threshold}}."
     }
     }
   },
   },
   "page_edit": {
   "page_edit": {
@@ -701,36 +703,6 @@
     "password_and_confirm_password_does_not_match": "Le mot de passe ne correspond pas",
     "password_and_confirm_password_does_not_match": "Le mot de passe ne correspond pas",
     "please_enable_mailer_alert": "La réinitialisation de mot de passe est désactivée, car la configuration d'envois de courriels est incomplète."
     "please_enable_mailer_alert": "La réinitialisation de mot de passe est désactivée, car la configuration d'envois de courriels est incomplète."
   },
   },
-  "emoji": {
-    "title": "Choisir un émoji",
-    "search": "Rechercher",
-    "clear": "Vider",
-    "notfound": "Aucun émoji trouvé",
-    "skintext": "Choisir le teint par défaut",
-    "categories": {
-      "search": "Résultats de recherche",
-      "recent": "Récents",
-      "smileys": "Émotions",
-      "people": "Individus & corps",
-      "nature": "Animaux & nature",
-      "foods": "Nourriture & boisson",
-      "activity": "Activités",
-      "places": "Voyage",
-      "objects": "Objets",
-      "symbols": "Symboles",
-      "flags": "Drapeaux",
-      "custom": "Personnalisé"
-    },
-    "categorieslabel": "Catégories d'émojis",
-    "skintones": {
-      "1": "Teint par défaut",
-      "2": "Teint clair",
-      "3": "Teint moyen-clair",
-      "4": "Teint moyen",
-      "5": "Teint moyen-foncé",
-      "6": "Teint foncé"
-    }
-  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "Mode maintenance",
     "maintenance_mode": "Mode maintenance",
     "growi_is_under_maintenance": "GROWI est actuellement en maintenance.",
     "growi_is_under_maintenance": "GROWI est actuellement en maintenance.",

+ 3 - 31
apps/app/public/static/locales/ja_JP/translation.json

@@ -354,7 +354,9 @@
       "restricted": "このページの閲覧は制限されています",
       "restricted": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。",
       "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。",
-      "no_deadline": "このページに有効期限は設定されていません。"
+      "no_deadline": "このページに有効期限は設定されていません。",
+      "not_indexed1": "このページは全文検索エンジンにインデックスされない可能性があります.",
+      "not_indexed2": "ページ本文が閾値を超えています: {{threshold}}."
     }
     }
   },
   },
   "page_edit": {
   "page_edit": {
@@ -740,36 +742,6 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
     "please_enable_mailer_alert": "メール設定が完了していないため、パスワード再設定機能が無効になっています。メール設定を完了させるよう管理者に依頼してください。"
     "please_enable_mailer_alert": "メール設定が完了していないため、パスワード再設定機能が無効になっています。メール設定を完了させるよう管理者に依頼してください。"
   },
   },
-  "emoji": {
-    "title": "絵文字を選択",
-    "search": "探す",
-    "clear": "リセット",
-    "notfound": "絵文字が見つかりません",
-    "skintext": "デフォルトの肌の色を選択",
-    "categories": {
-      "search": "検索結果",
-      "recent": "最新履歴",
-      "smileys": "スマイリーと感情",
-      "people": "人と体",
-      "nature": "動物と自然",
-      "foods": "食べ物や飲み物",
-      "activity": "アクティビティ",
-      "places": "旅行と場所",
-      "objects": "オブジェクト",
-      "symbols": "シンボル",
-      "flags": "国旗",
-      "custom": "カスタマイズ"
-    },
-    "categorieslabel": "絵文字カテゴリ",
-    "skintones": {
-      "1": "デフォルトの肌の色",
-      "2": "明るい肌のトーン",
-      "3": "ミディアム-明るい肌のトーン",
-      "4": "ミディアムスキントーン",
-      "5": "ミディアムダークスキントーン",
-      "6": "肌の色が濃い"
-    }
-  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "メンテナンスモード",
     "maintenance_mode": "メンテナンスモード",
     "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",
     "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",

+ 3 - 31
apps/app/public/static/locales/zh_CN/translation.json

@@ -311,7 +311,9 @@
       "restricted": "访问此页受到限制",
       "restricted": "访问此页受到限制",
       "stale": "自上次更新以来,已超过{{count}年。",
       "stale": "自上次更新以来,已超过{{count}年。",
       "stale_plural": "自上次更新以来已过去{{count}年以上。",
       "stale_plural": "自上次更新以来已过去{{count}年以上。",
-      "no_deadline": "This page has no expiration date"
+      "no_deadline": "此页面没有到期日期",
+      "not_indexed1": "此页面可能不会被全文搜索引擎索引。",
+      "not_indexed2": "页面正文超过了{{threshold}}指定的阈值。"
 		}
 		}
 	},
 	},
 	"page_edit": {
 	"page_edit": {
@@ -710,36 +712,6 @@
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
     "please_enable_mailer_alert": "密码重置功能被禁用,因为电子邮件设置尚未完成。请要求管理员完成电子邮件的设置。"
     "please_enable_mailer_alert": "密码重置功能被禁用,因为电子邮件设置尚未完成。请要求管理员完成电子邮件的设置。"
   },
   },
-  "emoji": {
-    "title": "选择一个表情符号",
-    "search": "搜索",
-    "clear": "重置",
-    "notfound": "找不到表情符号",
-    "skintext": "选择您的默认肤色",
-    "categories": {
-      "search": "搜索结果",
-      "recent": "经常使用",
-      "smileys": "笑脸和情感",
-      "people": "人和身体",
-      "nature": "动物与自然",
-      "foods": "食物和饮料",
-      "activity": "活动",
-      "places": "旅行和地方",
-      "objects": "对象",
-      "symbols": "符号",
-      "flags": "旗帜",
-      "custom": "定制"
-    },
-    "categorieslabel": "表情符号类别",
-    "skintones": {
-      "1": "默认肤色",
-      "2": "浅肤色",
-      "3": "中浅肤色",
-      "4": "中等肤色",
-      "5": "中深肤色",
-      "6": "深色肤色"
-    }
-  },
   "maintenance_mode": {
   "maintenance_mode": {
     "maintenance_mode": "维护模式",
     "maintenance_mode": "维护模式",
     "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",
     "growi_is_under_maintenance": "GROWI正在进行维护。请等待,直到它结束。",

+ 29 - 31
apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx

@@ -14,37 +14,35 @@ const ArchiveFilesTable = (props: ArchiveFilesTableProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
-    <div className="table-responsive">
-      <table className="table table-bordered">
-        <thead>
-          <tr>
-            <th>{t('admin:export_management.file')}</th>
-            <th>{t('admin:export_management.growi_version')}</th>
-            <th>{t('admin:export_management.collections')}</th>
-            <th>{t('admin:export_management.exported_at')}</th>
-            <th></th>
-          </tr>
-        </thead>
-        <tbody>
-          {props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
-            return (
-              <tr key={fileName}>
-                <th>{fileName}</th>
-                <td>{meta.version}</td>
-                <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
-                <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
-                <td>
-                  <ArchiveFilesTableMenu
-                    fileName={fileName}
-                    onZipFileStatRemove={props.onZipFileStatRemove}
-                  />
-                </td>
-              </tr>
-            );
-          })}
-        </tbody>
-      </table>
-    </div>
+    <table className="table table-bordered">
+      <thead>
+        <tr>
+          <th>{t('admin:export_management.file')}</th>
+          <th>{t('admin:export_management.growi_version')}</th>
+          <th>{t('admin:export_management.collections')}</th>
+          <th>{t('admin:export_management.exported_at')}</th>
+          <th></th>
+        </tr>
+      </thead>
+      <tbody>
+        {props.zipFileStats.map(({ meta, fileName, innerFileStats }) => {
+          return (
+            <tr key={fileName}>
+              <th>{fileName}</th>
+              <td>{meta.version}</td>
+              <td className="text-capitalize">{innerFileStats.map(fileStat => fileStat.collectionName).join(', ')}</td>
+              <td>{meta.exportedAt ? format(new Date(meta.exportedAt), 'yyyy/MM/dd HH:mm:ss') : ''}</td>
+              <td>
+                <ArchiveFilesTableMenu
+                  fileName={fileName}
+                  onZipFileStatRemove={props.onZipFileStatRemove}
+                />
+              </td>
+            </tr>
+          );
+        })}
+      </tbody>
+    </table>
   );
   );
 };
 };
 
 

+ 3 - 3
apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx

@@ -13,11 +13,11 @@ const ArchiveFilesTableMenu = (props: ArchiveFilesTableMenuProps):JSX.Element =>
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   return (
   return (
-    <div className="btn-group admin-user-menu dropdown">
-      <button type="button" className="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown">
+    <div className="dropdown">
+      <button type="button" className="btn btn-sm btn-outline-secondary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
         <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
         <span className="material-symbols-outlined">settings</span> <span className="caret"></span>
       </button>
       </button>
-      <ul className="dropdown-menu" role="menu">
+      <ul className="dropdown-menu dropdown-menu-end">
         <li className="dropdown-header">{t('admin:export_management.export_menu')}</li>
         <li className="dropdown-header">{t('admin:export_management.export_menu')}</li>
         <button type="button" className="dropdown-item" onClick={() => { window.location.href = `/admin/export/${props.fileName}` }}>
         <button type="button" className="dropdown-item" onClick={() => { window.location.href = `/admin/export/${props.fileName}` }}>
           <span className="material-symbols-outlined">cloud_download</span> {t('admin:export_management.download')}
           <span className="material-symbols-outlined">cloud_download</span> {t('admin:export_management.download')}

+ 12 - 4
apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx

@@ -14,11 +14,13 @@ const GROUPS_PAGE = [
 ];
 ];
 const GROUPS_USER = [
 const GROUPS_USER = [
   'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
   'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'externalusergroups', 'externalusergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'bookmarkfolders', 'subscriptions',
   'inappnotificationsettings',
   'inappnotificationsettings',
 ];
 ];
 const GROUPS_CONFIG = [
 const GROUPS_CONFIG = [
-  'configs', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+  'configs', 'migrations', 'updateposts', 'globalnotificationsettings', 'slackappintegrations',
+  'growiplugins',
 ];
 ];
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 const ALL_GROUPED_COLLECTIONS = GROUPS_PAGE.concat(GROUPS_USER).concat(GROUPS_CONFIG);
 
 
@@ -102,8 +104,14 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
 
 
     const html = t('admin:export_management.desc_password_seed');
     const html = t('admin:export_management.desc_password_seed');
 
 
-    // eslint-disable-next-line react/no-danger
-    return <div className="card custom-card bg-body-tertiary" dangerouslySetInnerHTML={{ __html: html }}></div>;
+    return (
+      <div className="card">
+        <div className="card-body">
+          {/* eslint-disable-next-line react/no-danger */}
+          <p className="card-text" dangerouslySetInnerHTML={{ __html: html }} />
+        </div>
+      </div>
+    );
   }, [selectedCollections, t]);
   }, [selectedCollections, t]);
 
 
   const renderCheckboxes = useCallback((collectionNames, color?) => {
   const renderCheckboxes = useCallback((collectionNames, color?) => {

+ 1 - 1
apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx

@@ -14,7 +14,7 @@ import SelectCollectionsModal from './ExportArchiveData/SelectCollectionsModal';
 
 
 
 
 const IGNORED_COLLECTION_NAMES = [
 const IGNORED_COLLECTION_NAMES = [
-  'sessions', 'rlflx', 'activities',
+  'sessions', 'rlflx', 'activities', 'yjs-writings', 'transferkeys',
 ];
 ];
 
 
 const ExportArchiveDataPage = (): JSX.Element => {
 const ExportArchiveDataPage = (): JSX.Element => {

+ 1 - 1
apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx

@@ -21,7 +21,7 @@ const DropdownItemContents = ({
 }) => (
 }) => (
   <>
   <>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className={`card custom-card mb-1 p-2 ${className}`} style={style}>{contents}</div>
+    <div className={`card mb-1 p-2 ${className}`} style={style}>{contents}</div>
   </>
   </>
 );
 );
 /* eslint-enable react/prop-types */
 /* eslint-enable react/prop-types */

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

@@ -62,7 +62,9 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
     return (
     return (
       <>
       <>
         <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
         <UserPicture user={creator} size="xs" /> <strong className="me-2"><Username user={creator}></Username></strong>{commentDate}:
-        <p className="card custom-card comment-body mt-2 p-2">{commentBodyElement}</p>
+        <div className="card mt-2">
+          <div className="card-body comment-body px-3 py-2">{commentBodyElement}</div>
+        </div>
       </>
       </>
     );
     );
   };
   };

+ 11 - 5
apps/app/src/client/components/PageCreateModal.tsx

@@ -2,8 +2,12 @@ import React, {
   useEffect, useState, useMemo, useCallback,
   useEffect, useState, useMemo, useCallback,
 } from 'react';
 } from 'react';
 
 
+import path from 'path';
+
+
 import { Origin } from '@growi/core';
 import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { format } from 'date-fns/format';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
 import {
 import {
@@ -33,14 +37,13 @@ const PageCreateModal: React.FC = () => {
 
 
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
 
 
-  const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
 
   const { create } = useCreatePage();
   const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
   const { createTemplate } = useCreateTemplatePage();
 
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const { data: isReachable } = useIsSearchServiceReachable();
-  const pathname = path || '';
+  const pathname = pageCreateModalData?.path ?? '';
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
@@ -106,16 +109,19 @@ const PageCreateModal: React.FC = () => {
    * access input page
    * access input page
    */
    */
   const createInputPage = useCallback(async() => {
   const createInputPage = useCallback(async() => {
+    const targetPath = normalizePath(pageNameInput);
+    const parentPath = path.dirname(targetPath);
+
     return create(
     return create(
       {
       {
-        path: pageNameInput,
-        parentPath: pathname,
+        path: targetPath,
+        parentPath,
         wip: true,
         wip: true,
         origin: Origin.View,
         origin: Origin.View,
       },
       },
       { onTerminated: closeCreateModal },
       { onTerminated: closeCreateModal },
     );
     );
-  }, [closeCreateModal, create, pageNameInput, pathname]);
+  }, [closeCreateModal, create, pageNameInput]);
 
 
   /**
   /**
    * access template page
    * access template page

+ 2 - 2
apps/app/src/client/components/PageHeader/PageTitleHeader.tsx

@@ -94,8 +94,8 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
 
   useEffect(() => {
   useEffect(() => {
     setEditedPagePath(currentPagePath);
     setEditedPagePath(currentPagePath);
-    if (isUntitledPage && editorMode === EditorMode.Editor) {
-      setRenameInputShown(true);
+    if (isUntitledPage != null) {
+      setRenameInputShown(isUntitledPage && editorMode === EditorMode.Editor);
     }
     }
   }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
   }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
 
 

+ 27 - 4
apps/app/src/client/components/PageHistory/RevisionDiff.tsx

@@ -1,7 +1,9 @@
 import { useMemo } from 'react';
 import { useMemo } from 'react';
 
 
 import type { IRevisionHasPageId } from '@growi/core';
 import type { IRevisionHasPageId } from '@growi/core';
+import { GrowiThemeSchemeType } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
+import { PresetThemesMetadatas } from '@growi/preset-themes';
 import { createPatch } from 'diff';
 import { createPatch } from 'diff';
 import type { Diff2HtmlConfig } from 'diff2html';
 import type { Diff2HtmlConfig } from 'diff2html';
 import { html } from 'diff2html';
 import { html } from 'diff2html';
@@ -10,14 +12,19 @@ import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import Link from 'next/link';
 import urljoin from 'url-join';
 import urljoin from 'url-join';
 
 
+
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 
 
 import UserDate from '../../../components/User/UserDate';
 import UserDate from '../../../components/User/UserDate';
+import { useSWRxGrowiThemeSetting } from '../../../stores/admin/customize';
+
 
 
 import styles from './RevisionDiff.module.scss';
 import styles from './RevisionDiff.module.scss';
 
 
 import 'diff2html/bundles/css/diff2html.min.css';
 import 'diff2html/bundles/css/diff2html.min.css';
 
 
+const moduleClass = styles['revision-diff-container'];
+
 type RevisioinDiffProps = {
 type RevisioinDiffProps = {
   currentRevision: IRevisionHasPageId,
   currentRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
@@ -34,10 +41,26 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
     currentRevision, previousRevision, revisionDiffOpened, currentPageId, currentPagePath, onClose,
     currentRevision, previousRevision, revisionDiffOpened, currentPageId, currentPagePath, onClose,
   } = props;
   } = props;
 
 
-  const { theme } = useNextThemes();
+  const { theme: userTheme } = useNextThemes();
+  const { data: growiTheme } = useSWRxGrowiThemeSetting();
 
 
   const colorScheme: ColorSchemeType = useMemo(() => {
   const colorScheme: ColorSchemeType = useMemo(() => {
-    switch (theme) {
+    if (growiTheme == null) {
+      return ColorSchemeType.AUTO;
+    }
+
+    const growiThemeSchemeType = growiTheme.pluginThemesMetadatas[0]?.schemeType
+        ?? PresetThemesMetadatas.find(theme => theme.name === growiTheme.currentTheme)?.schemeType;
+
+    switch (growiThemeSchemeType) {
+      case GrowiThemeSchemeType.DARK:
+        return ColorSchemeType.DARK;
+      case GrowiThemeSchemeType.LIGHT:
+        return ColorSchemeType.LIGHT;
+      default:
+        // growiThemeSchemeType === GrowiThemeSchemeType.BOTH
+    }
+    switch (userTheme) {
       case Themes.DARK:
       case Themes.DARK:
         return ColorSchemeType.DARK;
         return ColorSchemeType.DARK;
       case Themes.LIGHT:
       case Themes.LIGHT:
@@ -45,7 +68,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
       default:
       default:
         return ColorSchemeType.AUTO;
         return ColorSchemeType.AUTO;
     }
     }
-  }, [theme]);
+  }, [growiTheme, userTheme]);
 
 
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
 
 
@@ -66,7 +89,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
   const diffView = { __html: diffViewHTML };
   const diffView = { __html: diffViewHTML };
 
 
   return (
   return (
-    <div className={`${styles['revision-diff-container']}`}>
+    <div className={moduleClass}>
       <div className="container">
       <div className="container">
         <div className="row mt-2">
         <div className="row mt-2">
           <div className="col px-0 py-2">
           <div className="col px-0 py-2">

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

@@ -19,6 +19,8 @@ import { usePresentationViewOptions } from '~/stores/renderer';
 
 
 import styles from './PagePresentationModal.module.scss';
 import styles from './PagePresentationModal.module.scss';
 
 
+const moduleClass = styles['grw-presentation-modal'] ?? '';
+
 
 
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
   ssr: false,
   ssr: false,
@@ -71,7 +73,7 @@ const PagePresentationModal = (): JSX.Element => {
       isOpen={isOpen}
       isOpen={isOpen}
       toggle={closeHandler}
       toggle={closeHandler}
       data-testid="page-presentation-modal"
       data-testid="page-presentation-modal"
-      className={`grw-presentation-modal ${styles['grw-presentation-modal']}`}
+      className={moduleClass}
     >
     >
       <div className="grw-presentation-controls d-flex">
       <div className="grw-presentation-controls d-flex">
         <button
         <button

+ 1 - 1
apps/app/src/client/components/PageTags/TagEditModal.tsx

@@ -59,7 +59,7 @@ const TagEditModalSubstance: React.FC<TagEditModalSubstanceProps> = (props: TagE
         <TagsInput tags={initTags} onTagsUpdated={tags => setTags(tags)} autoFocus />
         <TagsInput tags={initTags} onTagsUpdated={tags => setTags(tags)} autoFocus />
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>
-        <button type="button" className="btn btn-primary" onClick={handleSubmit}>
+        <button type="button" data-testid="tag-edit-done-btn" className="btn btn-primary" onClick={handleSubmit}>
           {t('tag_edit_modal.done')}
           {t('tag_edit_modal.done')}
         </button>
         </button>
       </ModalFooter>
       </ModalFooter>

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

@@ -17,7 +17,7 @@ const { encodeSpaces } = pagePathUtils;
 const DropdownItemContents = ({ title, contents }) => (
 const DropdownItemContents = ({ title, contents }) => (
   <>
   <>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
     <div className="h6 mt-1 mb-2"><strong>{title}</strong></div>
-    <div className="card custom-card mb-1 p-2">{contents}</div>
+    <div className="card mb-1 p-2">{contents}</div>
   </>
   </>
 );
 );
 
 

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

@@ -1,6 +1,7 @@
 import assert from 'assert';
 import assert from 'assert';
 
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
 import { isClient } from '@growi/core/dist/utils/browser-utils';
+import * as presentation from '@growi/presentation/dist/client/services/sanitize-option';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 import * as drawio from '@growi/remark-drawio';
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
 import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client';
@@ -20,7 +21,6 @@ import { RichAttachment } from '~/client/components/ReactMarkdownComponents/Rich
 import { TableWithEditButton } from '~/client/components/ReactMarkdownComponents/TableWithEditButton';
 import { TableWithEditButton } from '~/client/components/ReactMarkdownComponents/TableWithEditButton';
 import * as mermaid from '~/features/mermaid';
 import * as mermaid from '~/features/mermaid';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererOptions } from '~/interfaces/renderer-options';
-import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
 import * as keywordHighlighter from '~/services/renderer/rehype-plugins/keyword-highlighter';
@@ -29,7 +29,7 @@ import * as attachment from '~/services/renderer/remark-plugins/attachment';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as plantuml from '~/services/renderer/remark-plugins/plantuml';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import * as xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
 import {
-  commonSanitizeOption, generateCommonOptions, injectCustomSanitizeOption, verifySanitizePlugin,
+  getCommonSanitizeOption, generateCommonOptions, verifySanitizePlugin,
 } from '~/services/renderer/renderer';
 } from '~/services/renderer/renderer';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
@@ -71,13 +71,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
+      presentation.sanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
       attachment.sanitizeOption,
@@ -132,14 +129,9 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // add remark plugins
   // remarkPlugins.push();
   // remarkPlugins.push();
 
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
     )]
     )]
     : () => {};
     : () => {};
 
 
@@ -184,14 +176,10 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
+      presentation.sanitizeOption,
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
       attachment.sanitizeOption,
@@ -277,13 +265,9 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
       drawio.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
       attachment.sanitizeOption,

+ 15 - 16
apps/app/src/components/Common/GrowiLogo.jsx

@@ -10,29 +10,28 @@ const GrowiLogo = memo(() => (
       xmlns="http://www.w3.org/2000/svg"
       xmlns="http://www.w3.org/2000/svg"
       width="32"
       width="32"
       height="32"
       height="32"
-      viewBox="0 0 226.44 196.11"
+      viewBox="0 0 64 56"
     >
     >
       <path
       <path
-        d="M56.61 196.11L169.83 196.11 226.44 98.06 188.7 98.06 150.96 163.43 75.48 163.43 56.61 196.11z"
-        className="group2"
-      >
-      </path>
-      <path
-      // eslint-disable-next-line max-len
-        d="M75.48 98.05L94.35 65.37 150.96 65.38 207.57 65.37 207.57 65.38 226.44 98.06 169.83 98.06 113.22 98.06 94.39 130.66 94.3 130.66 84.92 114.4 75.48 98.05z"
+        // eslint-disable-next-line max-len
+        d="M17.123 33.8015L10.4717 45.3855C10.2686 45.7427 10.2686 46.1829 10.4717 46.5337L15.5934 55.4514C15.7838 55.7767 16.171 55.9999 16.5645 55.9999H17.123L23.5014 44.9007L17.123 33.8015Z"
         className="group1"
         className="group1"
-      >
-      </path>
+      />
+      <path
+        // eslint-disable-next-line max-len
+        d="M50.8118 29.0493L42.0343 44.3331C41.8693 44.6138 41.571 44.9072 41.0632 44.9072H23.4956L17.1172 56H47.4607C47.8542 56 48.1842 55.8023 48.3873 55.4514L63.5559 29.043H50.8118V29.0493Z"
+        className="group2"
+      />
       <path
       <path
-        d="M0 98.06L56.6 0 113.22 0.01 169.83 0.01 169.83 0.01 188.69 32.68 132.09 32.69 75.47 32.69 18.86 130.74 0 98.06z"
+        // eslint-disable-next-line max-len
+        d="M63.8353 28.5773C64.0447 28.22 64.0574 27.8182 63.8543 27.461L58.7262 18.5369C58.5231 18.1797 58.174 17.9501 57.7615 17.9501H26.8975C26.485 17.9501 26.1106 18.1733 25.9011 18.5178L21.0586 26.9379L27.437 38.0499L32.1272 29.8849C32.4255 29.3746 32.9713 29.0557 33.5552 29.0557H63.5624L63.8353 28.5836V28.5773Z"
         className="group1"
         className="group1"
-      >
-      </path>
+      />
       <path
       <path
-        d="M75.48 163.43L56.61 130.74 37.71 163.46 47.15 179.81 56.54 196.07 56.63 196.07 75.48 163.43z"
+        // eslint-disable-next-line max-len
+        d="M22.956 11.0992H54.4546L48.4125 0.580476C48.2094 0.22326 47.8604 0 47.4478 0H16.5839C16.1714 0 15.7969 0.204123 15.5875 0.56134L0.152321 27.4227C-0.0507735 27.7799 -0.0507735 28.2137 0.152321 28.5709L6.20706 39.1088L21.9595 11.6606C22.1626 11.3033 22.5434 11.0928 22.956 11.0928V11.0992Z"
         className="group1"
         className="group1"
-      >
-      </path>
+      />
     </svg>
     </svg>
   </div>
   </div>
 ));
 ));

+ 1 - 1
apps/app/src/components/Layout/NoLoginLayout.module.scss

@@ -25,7 +25,7 @@
     }
     }
 
 
     .growi-logo-type {
     .growi-logo-type {
-      letter-spacing: .5rem;
+      margin-left: 7px;
     }
     }
 
 
   }
   }

+ 3 - 1
apps/app/src/components/Layout/NoLoginLayout.tsx

@@ -1,6 +1,8 @@
 import type { ReactNode } from 'react';
 import type { ReactNode } from 'react';
 import React from 'react';
 import React from 'react';
 
 
+import Image from 'next/image';
+
 import { useAppTitle } from '~/stores-universal/context';
 import { useAppTitle } from '~/stores-universal/context';
 
 
 import GrowiLogo from '../Common/GrowiLogo';
 import GrowiLogo from '../Common/GrowiLogo';
@@ -37,7 +39,7 @@ export const NoLoginLayout = ({
               <div className="nologin-header mx-auto rounded-4 rounded-bottom-0">
               <div className="nologin-header mx-auto rounded-4 rounded-bottom-0">
                 <div className="d-flex justify-content-center align-items-center">
                 <div className="d-flex justify-content-center align-items-center">
                   <GrowiLogo />
                   <GrowiLogo />
-                  <h1 className="growi-logo-type text-white fs-3 my-3 ms-3">GROWI</h1>
+                  <Image width={128.48} height={28} src="/images/growi-brand-logo-login.svg" alt="GROWI" className="growi-logo-type my-3" />
                 </div>
                 </div>
                 {appTitle !== 'GROWI' ? (
                 {appTitle !== 'GROWI' ? (
                   <h2 className="fs-4 text-center text-white">{ appTitle }</h2>
                   <h2 className="fs-4 text-center text-white">{ appTitle }</h2>

+ 31 - 0
apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx

@@ -0,0 +1,31 @@
+import { useTranslation } from 'react-i18next';
+
+import { useElasticsearchMaxBodyLengthToIndex } from '~/stores-universal/context';
+import { useSWRxCurrentPage } from '~/stores/page';
+
+export const FullTextSearchNotCoverAlert = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const { data: elasticsearchMaxBodyLengthToIndex } = useElasticsearchMaxBodyLengthToIndex();
+  const { data } = useSWRxCurrentPage();
+
+  const markdownLength = data?.revision?.body?.length;
+
+  if (markdownLength == null || elasticsearchMaxBodyLengthToIndex == null || markdownLength <= elasticsearchMaxBodyLengthToIndex) {
+    return <></>;
+  }
+
+  return (
+    <div className="alert alert-warning">
+      <strong>{t('Warning')}: {t('page_page.notice.not_indexed1')}</strong><br />
+      <small
+        // eslint-disable-next-line react/no-danger
+        dangerouslySetInnerHTML={{
+          __html: t('page_page.notice.not_indexed2', {
+            threshold: `<code>ELASTICSEARCH_MAX_BODY_LENGTH_TO_INDEX=${elasticsearchMaxBodyLengthToIndex}</code>`,
+          }),
+        }}
+      />
+    </div>
+  );
+};

+ 2 - 2
apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx

@@ -1,5 +1,3 @@
-import React from 'react';
-
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 
 
 import { useIsNotFound } from '~/stores/page';
 import { useIsNotFound } from '~/stores/page';
@@ -9,6 +7,7 @@ import { PageGrantAlert } from './PageGrantAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 import { WipPageAlert } from './WipPageAlert';
 import { WipPageAlert } from './WipPageAlert';
 
 
+const FullTextSearchNotCoverAlert = dynamic(() => import('./FullTextSearchNotCoverAlert').then(mod => mod.FullTextSearchNotCoverAlert), { ssr: false });
 const PageRedirectedAlert = dynamic(() => import('./PageRedirectedAlert').then(mod => mod.PageRedirectedAlert), { ssr: false });
 const PageRedirectedAlert = dynamic(() => import('./PageRedirectedAlert').then(mod => mod.PageRedirectedAlert), { ssr: false });
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 const FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { ssr: false });
 const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
 const TrashPageAlert = dynamic(() => import('./TrashPageAlert').then(mod => mod.TrashPageAlert), { ssr: false });
@@ -22,6 +21,7 @@ export const PageAlerts = (): JSX.Element => {
       <div className="col-sm-12">
       <div className="col-sm-12">
         {/* alerts */}
         {/* alerts */}
         { !isNotFound && <FixPageGrantAlert /> }
         { !isNotFound && <FixPageGrantAlert /> }
+        <FullTextSearchNotCoverAlert />
         <WipPageAlert />
         <WipPageAlert />
         <PageGrantAlert />
         <PageGrantAlert />
         <TrashPageAlert />
         <TrashPageAlert />

+ 0 - 19
apps/app/src/features/comment/server/models/comment.ts

@@ -27,7 +27,6 @@ type Add = (
 type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCommentsByPageId = (pageId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
 type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
-type GetPageIdToCommentMap = (pageIds: Types.ObjectId[]) => Promise<Record<string, CommentDocument[]>>
 type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
 type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
 
 
 export interface CommentModel extends Model<CommentDocument> {
 export interface CommentModel extends Model<CommentDocument> {
@@ -35,7 +34,6 @@ export interface CommentModel extends Model<CommentDocument> {
   findCommentsByPageId: FindCommentsByPageId
   findCommentsByPageId: FindCommentsByPageId
   findCommentsByRevisionId: FindCommentsByRevisionId
   findCommentsByRevisionId: FindCommentsByRevisionId
   findCreatorsByPage: FindCreatorsByPage
   findCreatorsByPage: FindCreatorsByPage
-  getPageIdToCommentMap: GetPageIdToCommentMap
   countCommentByPageId: CountCommentByPageId
   countCommentByPageId: CountCommentByPageId
 }
 }
 
 
@@ -91,23 +89,6 @@ commentSchema.statics.findCreatorsByPage = async function(page) {
   return this.distinct('creator', { page }).exec();
   return this.distinct('creator', { page }).exec();
 };
 };
 
 
-/**
- * @return {object} key: page._id, value: comments
- */
-commentSchema.statics.getPageIdToCommentMap = async function(pageIds) {
-  const results = await this.aggregate()
-    .match({ page: { $in: pageIds } })
-    .group({ _id: '$page', comments: { $push: '$comment' } });
-
-  // convert to map
-  const idToCommentMap = {};
-  results.forEach((result, i) => {
-    idToCommentMap[result._id] = result.comments;
-  });
-
-  return idToCommentMap;
-};
-
 commentSchema.statics.countCommentByPageId = async function(page) {
 commentSchema.statics.countCommentByPageId = async function(page) {
   return this.count({ page });
   return this.count({ page });
 };
 };

+ 11 - 1
apps/app/src/features/rate-limiter/config/index.ts

@@ -58,8 +58,18 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
   },
   },
 };
 };
 
 
+const isDev = process.env.NODE_ENV === 'development';
+const defaultConfigWithRegExpForDev: IApiRateLimitEndpointMap = isDev ? {
+  '/__nextjs_original-stack-frame': {
+    method: 'GET',
+    maxRequests: Infinity,
+  },
+} : {};
+
 // default config with reg exp
 // default config with reg exp
-export const defaultConfigWithRegExp = {
+export const defaultConfigWithRegExp: IApiRateLimitEndpointMap = {
+  ...defaultConfigWithRegExpForDev,
+
   '/forgot-password/.*': {
   '/forgot-password/.*': {
     method: 'ALL',
     method: 'ALL',
     maxRequests: MAX_REQUESTS_TIER_1,
     maxRequests: MAX_REQUESTS_TIER_1,

+ 2 - 1
apps/app/src/features/rate-limiter/utils/config-generator.ts

@@ -1,5 +1,6 @@
+import type { IApiRateLimitEndpointMap } from '../config';
 import {
 import {
-  defaultConfig, defaultConfigWithRegExp, IApiRateLimitEndpointMap,
+  defaultConfig, defaultConfigWithRegExp,
 } from '../config';
 } from '../config';
 
 
 const envVar = process.env;
 const envVar = process.env;

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

@@ -42,6 +42,7 @@ import {
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
   useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
+  useElasticsearchMaxBodyLengthToIndex,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
 import {
@@ -157,6 +158,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  elasticsearchMaxBodyLengthToIndex: number,
   isEnabledMarp: boolean,
   isEnabledMarp: boolean,
 
 
   sidebarConfig: ISidebarConfig,
   sidebarConfig: ISidebarConfig,
@@ -215,6 +217,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   useIsEnabledAttachTitleHeader(props.isEnabledAttachTitleHeader);
   useIsEnabledAttachTitleHeader(props.isEnabledAttachTitleHeader);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceConfigured(props.isSearchServiceConfigured);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
   useIsSearchServiceReachable(props.isSearchServiceReachable);
+  useElasticsearchMaxBodyLengthToIndex(props.elasticsearchMaxBodyLengthToIndex);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
 
 
   useIsSlackConfigured(props.isSlackConfigured);
   useIsSlackConfigured(props.isSlackConfigured);
@@ -537,6 +540,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceConfigured = searchService.isConfigured;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchServiceReachable = searchService.isReachable;
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
   props.isSearchScopeChildrenAsDefault = configManager.getConfig('crowi', 'customize:isSearchScopeChildrenAsDefault');
+  props.elasticsearchMaxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
 
 
   props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
   props.isSlackConfigured = crowi.slackIntegrationService.isSlackConfigured;
   // props.isMailerSetup = mailService.isMailerSetup;
   // props.isMailerSetup = mailService.isMailerSetup;

+ 21 - 2
apps/app/src/pages/_document.page.tsx

@@ -1,6 +1,7 @@
 /* eslint-disable @next/next/google-font-display */
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 import React from 'react';
 
 
+import { Lang } from '@growi/core';
 import type { DocumentContext, DocumentInitialProps } from 'next/document';
 import type { DocumentContext, DocumentInitialProps } from 'next/document';
 import Document, {
 import Document, {
   Html, Head, Main, NextScript,
   Html, Head, Main, NextScript,
@@ -8,8 +9,12 @@ import Document, {
 
 
 import type { GrowiPluginResourceEntries } from '~/features/growi-plugin/server/services';
 import type { GrowiPluginResourceEntries } from '~/features/growi-plugin/server/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
+import { configManager } from '~/server/service/config-manager';
+import { detectLocaleFromBrowserAcceptLanguage } from '~/server/util/locale-utils';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
+import { getLocateAtServerSide } from './utils/commons';
+
 const logger = loggerFactory('growi:page:_document');
 const logger = loggerFactory('growi:page:_document');
 
 
 type HeadersForGrowiPluginProps = {
 type HeadersForGrowiPluginProps = {
@@ -41,14 +46,24 @@ interface GrowiDocumentProps {
   customCss: string | null,
   customCss: string | null,
   customNoscript: string | null,
   customNoscript: string | null,
   pluginResourceEntries: GrowiPluginResourceEntries;
   pluginResourceEntries: GrowiPluginResourceEntries;
+  locale: string;
 }
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
 
 class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 class GrowiDocument extends Document<GrowiDocumentInitialProps> {
 
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
+
+    const langMap = {
+      [Lang.ja_JP]: 'ja-jp',
+      [Lang.en_US]: 'en-us',
+      [Lang.zh_CN]: 'zh-cn',
+      [Lang.fr_FR]: 'fr-fr',
+    } as const;
+
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
-    const { crowi } = ctx.req as CrowiRequest;
+    const req = ctx.req as CrowiRequest;
+    const { crowi } = req;
     const { customizeService } = crowi;
     const { customizeService } = crowi;
 
 
     const { themeHref } = customizeService;
     const { themeHref } = customizeService;
@@ -60,6 +75,8 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const growiPluginService = await import('~/features/growi-plugin/server/services').then(mod => mod.growiPluginService);
     const growiPluginService = await import('~/features/growi-plugin/server/services').then(mod => mod.growiPluginService);
     const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
     const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
 
+    const locale = langMap[getLocateAtServerSide(req)];
+
     return {
     return {
       ...initialProps,
       ...initialProps,
       themeHref,
       themeHref,
@@ -67,6 +84,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
       customCss,
       customCss,
       customNoscript,
       customNoscript,
       pluginResourceEntries,
       pluginResourceEntries,
+      locale,
     };
     };
   }
   }
 
 
@@ -95,10 +113,11 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const {
     const {
       customCss, customScript, customNoscript,
       customCss, customScript, customNoscript,
       themeHref, pluginResourceEntries,
       themeHref, pluginResourceEntries,
+      locale,
     } = this.props;
     } = this.props;
 
 
     return (
     return (
-      <Html>
+      <Html lang={locale}>
         <Head>
         <Head>
           {this.renderCustomScript(customScript)}
           {this.renderCustomScript(customScript)}
           <link rel="stylesheet" key="link-theme" href={themeHref} />
           <link rel="stylesheet" key="link-theme" href={themeHref} />

+ 11 - 6
apps/app/src/pages/utils/commons.ts

@@ -106,6 +106,15 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   return { props };
   return { props };
 };
 };
 
 
+
+export const getLocateAtServerSide = (req: CrowiRequest): Lang => {
+  const { user, headers } = req;
+  const { configManager } = req.crowi;
+
+  return user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
+    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US);
+};
+
 export const getNextI18NextConfig = async(
 export const getNextI18NextConfig = async(
     // 'serverSideTranslations' method should be given from Next.js Page
     // 'serverSideTranslations' method should be given from Next.js Page
     //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
     //  because importing it in this file causes https://github.com/isaachinman/next-i18next/issues/1545
@@ -115,13 +124,9 @@ export const getNextI18NextConfig = async(
     context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
     context: GetServerSidePropsContext, namespacesRequired?: string[] | undefined, preloadAllLang = false,
 ): Promise<SSRConfig> => {
 ): Promise<SSRConfig> => {
 
 
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi, user, headers } = req;
-  const { configManager } = crowi;
-
   // determine language
   // determine language
-  const locale = user == null ? detectLocaleFromBrowserAcceptLanguage(headers)
-    : (user.lang ?? configManager.getConfig('crowi', 'app:globalLang') as Lang ?? Lang.en_US);
+  const req: CrowiRequest = context.req as CrowiRequest;
+  const locale = getLocateAtServerSide(req);
 
 
   const namespaces = ['commons'];
   const namespaces = ['commons'];
   if (namespacesRequired != null) {
   if (namespacesRequired != null) {

+ 1 - 19
apps/app/src/server/models/activity.ts

@@ -1,4 +1,3 @@
-import type { Ref, IPage } from '@growi/core';
 import type {
 import type {
   Types, Document, Model, SortOrder,
   Types, Document, Model, SortOrder,
 } from 'mongoose';
 } from 'mongoose';
@@ -15,10 +14,7 @@ import {
 } from '~/interfaces/activity';
 } from '~/interfaces/activity';
 
 
 import loggerFactory from '../../utils/logger';
 import loggerFactory from '../../utils/logger';
-import { getOrCreateModel, getModelSafely } from '../util/mongoose-utils';
-
-
-import Subscription from './subscription';
+import { getOrCreateModel } from '../util/mongoose-utils';
 
 
 
 
 const logger = loggerFactory('growi:models:activity');
 const logger = loggerFactory('growi:models:activity');
@@ -34,8 +30,6 @@ export interface ActivityDocument extends Document {
   event: Types.ObjectId
   event: Types.ObjectId
   action: SupportedActionType
   action: SupportedActionType
   snapshot: ISnapshot
   snapshot: ISnapshot
-
-  getNotificationTargetUsers(): Promise<any[]>
 }
 }
 
 
 export interface ActivityModel extends Model<ActivityDocument> {
 export interface ActivityModel extends Model<ActivityDocument> {
@@ -98,18 +92,6 @@ activitySchema.post('save', function() {
   logger.debug('activity has been created', this);
   logger.debug('activity has been created', this);
 });
 });
 
 
-activitySchema.methods.getNotificationTargetUsers = async function() {
-  const User = getModelSafely('User') || require('~/server/models/user')();
-  const { user: actionUser, target } = this;
-  const subscribedUsers = await Subscription.getSubscription(target as unknown as Ref<IPage>);
-  const notificationUsers = subscribedUsers.filter(item => (item.toString() !== actionUser._id.toString()));
-  const activeNotificationUsers = await User.find({
-    _id: { $in: notificationUsers },
-    status: User.STATUS_ACTIVE,
-  }).distinct('_id');
-  return activeNotificationUsers;
-};
-
 activitySchema.statics.createByParameters = async function(parameters): Promise<IActivity> {
 activitySchema.statics.createByParameters = async function(parameters): Promise<IActivity> {
   const activity = await this.create(parameters) as unknown as IActivity;
   const activity = await this.create(parameters) as unknown as IActivity;
 
 

+ 0 - 9
apps/app/src/server/models/obsolete-page.js

@@ -668,15 +668,6 @@ export const getPageSchema = (crowi) => {
     await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroups: [transferToUserGroup] });
     await this.updateMany({ _id: { $in: pages.map(p => p._id) } }, { grantedGroups: [transferToUserGroup] });
   };
   };
 
 
-  pageSchema.methods.getNotificationTargetUsers = async function() {
-    const Revision = mongoose.model('Revision');
-
-    const [commentCreators, revisionAuthors] = await Promise.all([Comment.findCreatorsByPage(this), Revision.findAuthorsByPage(this)]);
-
-    const targetUsers = new Set([this.creator].concat(commentCreators, revisionAuthors));
-    return Array.from(targetUsers);
-  };
-
   pageSchema.statics.getHistories = function() {
   pageSchema.statics.getHistories = function() {
     // TODO
     // TODO
 
 

+ 6 - 9
apps/app/src/server/models/page-tag-relation.ts

@@ -1,6 +1,6 @@
 import type { ITag } from '@growi/core';
 import type { ITag } from '@growi/core';
-import type { Document, Model } from 'mongoose';
-import mongoose, { ObjectId } from 'mongoose';
+import type { Document, Model, ObjectId } from 'mongoose';
+import mongoose from 'mongoose';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import mongoosePaginate from 'mongoose-paginate-v2';
 import uniqueValidator from 'mongoose-unique-validator';
 import uniqueValidator from 'mongoose-unique-validator';
 
 
@@ -9,13 +9,10 @@ import type { IPageTagRelation } from '~/interfaces/page-tag-relation';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import type { ObjectIdLike } from '../interfaces/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 import { getOrCreateModel } from '../util/mongoose-utils';
 
 
-import type { IdToNameMap } from './tag';
+import type { IdToNameMap, IdToNamesMap } from './tag';
 import Tag from './tag';
 import Tag from './tag';
 
 
 
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
 // disable no-return-await for model functions
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 /* eslint-disable no-return-await */
 
 
@@ -36,7 +33,7 @@ type CreateTagListWithCountResult = {
 }
 }
 type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
 type CreateTagListWithCount = (this: PageTagRelationModel, opts?: CreateTagListWithCountOpts) => Promise<CreateTagListWithCountResult>;
 
 
-type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNameMap>;
+type GetIdToTagNamesMap = (this: PageTagRelationModel, pageIds: string[]) => Promise<IdToNamesMap>;
 
 
 type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
 type UpdatePageTags = (this: PageTagRelationModel, pageId: string, tags: string[]) => Promise<void>
 
 
@@ -54,13 +51,13 @@ export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
  */
  */
 const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
 const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
   relatedPage: {
   relatedPage: {
-    type: ObjectId,
+    type: mongoose.Schema.Types.ObjectId,
     ref: 'Page',
     ref: 'Page',
     required: true,
     required: true,
     index: true,
     index: true,
   },
   },
   relatedTag: {
   relatedTag: {
-    type: ObjectId,
+    type: mongoose.Schema.Types.ObjectId,
     ref: 'Tag',
     ref: 'Tag',
     required: true,
     required: true,
     index: true,
     index: true,

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

@@ -14,6 +14,7 @@ export interface TagDocument {
 }
 }
 
 
 export type IdToNameMap = {[key: string] : string }
 export type IdToNameMap = {[key: string] : string }
+export type IdToNamesMap = {[key: string] : string[] }
 
 
 export interface TagModel extends Model<TagDocument>{
 export interface TagModel extends Model<TagDocument>{
   getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap
   getIdToNameMap(tagIds: ObjectIdLike[]): IdToNameMap

+ 6 - 5
apps/app/src/server/routes/apiv3/staffs.js

@@ -1,13 +1,14 @@
+import axios from 'axios';
+import { addHours } from 'date-fns/addHours';
+import { isAfter } from 'date-fns/isAfter';
+import { Router } from 'express';
+
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
 const logger = loggerFactory('growi:routes:apiv3:staffs'); // eslint-disable-line no-unused-vars
 
 
-const express = require('express');
-
-const axios = require('axios');
 
 
-const router = express.Router();
-const { isAfter, addHours } = require('date-fns');
+const router = Router();
 
 
 const contributors = require('^/resource/Contributor');
 const contributors = require('^/resource/Contributor');
 
 

+ 12 - 0
apps/app/src/server/service/config-loader.ts

@@ -282,6 +282,18 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     type:    ValueType.BOOLEAN,
     default: false,
     default: false,
   },
   },
+  ELASTICSEARCH_MAX_BODY_LENGTH_TO_INDEX: {
+    ns:      'crowi',
+    key:     'app:elasticsearchMaxBodyLengthToIndex',
+    type:    ValueType.NUMBER,
+    default: 100000,
+  },
+  ELASTICSEARCH_REINDEX_BULK_SIZE: {
+    ns:      'crowi',
+    key:     'app:elasticsearchReindexBulkSize',
+    type:    ValueType.NUMBER,
+    default: 100,
+  },
   ELASTICSEARCH_REINDEX_ON_BOOT: {
   ELASTICSEARCH_REINDEX_ON_BOOT: {
     ns:      'crowi',
     ns:      'crowi',
     key:     'app:elasticsearchReindexOnBoot',
     key:     'app:elasticsearchReindexOnBoot',

+ 1 - 1
apps/app/src/server/service/import.js

@@ -3,6 +3,7 @@
  * @typedef {import("@types/unzip-stream").Entry} Entry
  * @typedef {import("@types/unzip-stream").Entry} Entry
  */
  */
 
 
+import { parseISO } from 'date-fns/parseISO';
 import gc from 'expose-gc/function';
 import gc from 'expose-gc/function';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -12,7 +13,6 @@ const path = require('path');
 const { Writable, Transform } = require('stream');
 const { Writable, Transform } = require('stream');
 
 
 const JSONStream = require('JSONStream');
 const JSONStream = require('JSONStream');
-const parseISO = require('date-fns/parseISO');
 const isIsoDate = require('is-iso-date');
 const isIsoDate = require('is-iso-date');
 const mongoose = require('mongoose');
 const mongoose = require('mongoose');
 const streamToPromise = require('stream-to-promise');
 const streamToPromise = require('stream-to-promise');

+ 139 - 0
apps/app/src/server/service/search-delegator/aggregate-to-index.ts

@@ -0,0 +1,139 @@
+import type { IPage } from '@growi/core';
+import type { PipelineStage, Query } from 'mongoose';
+
+import type { PageModel } from '~/server/models/page';
+
+export const aggregatePipelineToIndex = (maxBodyLengthToIndex: number, query?: Query<PageModel, IPage>): PipelineStage[] => {
+
+  const basePipeline = query == null
+    ? []
+    : [{ $match: query.getQuery() }];
+
+  return [
+    ...basePipeline,
+
+    // join Revision
+    {
+      $lookup: {
+        from: 'revisions',
+        localField: 'revision',
+        foreignField: '_id',
+        as: 'revision',
+      },
+    },
+    // unwind and filter pages that does not have revision
+    {
+      $unwind: {
+        path: '$revision',
+      },
+    },
+    {
+      $addFields: {
+        bodyLength: { $strLenCP: '$revision.body' },
+      },
+    },
+
+    // join User
+    {
+      $lookup: {
+        from: 'users',
+        localField: 'creator',
+        foreignField: '_id',
+        as: 'creator',
+      },
+    },
+    {
+      $unwind: {
+        path: '$creator',
+        preserveNullAndEmptyArrays: true,
+      },
+    },
+
+    // join Comment
+    {
+      $lookup: {
+        from: 'comments',
+        localField: '_id',
+        foreignField: 'page',
+        pipeline: [
+          {
+            $addFields: {
+              commentLength: { $strLenCP: '$comment' },
+            },
+          },
+        ],
+        as: 'comments',
+      },
+    },
+    {
+      $addFields: {
+        commentsCount: { $size: '$comments' },
+      },
+    },
+
+    // join Bookmark
+    {
+      $lookup: {
+        from: 'bookmarks',
+        localField: '_id',
+        foreignField: 'page',
+        as: 'bookmarks',
+      },
+    },
+    {
+      $addFields: {
+        bookmarksCount: { $size: '$bookmarks' },
+      },
+    },
+
+    // add counts for embedded arrays
+    {
+      $addFields: {
+        likeCount: { $size: '$liker' },
+      },
+    },
+    {
+      $addFields: {
+        seenUsersCount: { $size: '$seenUsers' },
+      },
+    },
+
+    // project
+    {
+      $project: {
+        path: 1,
+        createdAt: 1,
+        updatedAt: 1,
+        grant: 1,
+        grantedUsers: 1,
+        grantedGroups: 1,
+        'revision.body': {
+          $cond: {
+            if: { $lte: ['$bodyLength', maxBodyLengthToIndex] },
+            then: '$revision.body',
+            else: '',
+          },
+        },
+        comments: {
+          $map: {
+            input: '$comments',
+            as: 'comment',
+            in: {
+              $cond: {
+                if: { $lte: ['$$comment.commentLength', maxBodyLengthToIndex] },
+                then: '$$comment.comment',
+                else: '',
+              },
+            },
+          },
+        },
+        commentsCount: 1,
+        bookmarksCount: 1,
+        likeCount: 1,
+        seenUsersCount: 1,
+        'creator.username': 1,
+        'creator.email': 1,
+      },
+    },
+  ];
+};

+ 52 - 0
apps/app/src/server/service/search-delegator/bulk-write.d.ts

@@ -0,0 +1,52 @@
+import type { IPageHasId, PageGrant } from '@growi/core';
+
+export type AggregatedPage = Pick<IPageHasId,
+  '_id'
+  | 'path'
+  | 'createdAt'
+  | 'updatedAt'
+  | 'grant'
+  | 'grantedUsers'
+  | 'grantedGroups'
+> & {
+  revision: { body: string },
+  comments: string[],
+  commentsCount: number,
+  bookmarksCount: number,
+  likeCount: number,
+  seenUsersCount: number,
+  creator: {
+    username: string,
+    email: string,
+  },
+} & {
+  tagNames: string[],
+};
+
+export type BulkWriteCommand = {
+  index: {
+    _index: string,
+    _type: '_doc' | undefined,
+    _id: string,
+  },
+}
+
+export type BulkWriteBodyRestriction = {
+  grant: PageGrant,
+  granted_users?: string[],
+  granted_groups: string[],
+}
+
+export type BulkWriteBody = {
+  path: string;
+  created_at: Date;
+  updated_at: Date;
+  body: string;
+  username?: string;
+  comments?: string[];
+  comment_count: number;
+  bookmark_count: number;
+  seenUsers_count: number;
+  like_count: number;
+  tag_names?: string[];
+} & BulkWriteBodyRestriction;

+ 43 - 145
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,11 +1,11 @@
 import { Writable, Transform } from 'stream';
 import { Writable, Transform } from 'stream';
 import { URL } from 'url';
 import { URL } from 'url';
 
 
+import { getIdForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import streamToPromise from 'stream-to-promise';
 
 
-import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
@@ -18,16 +18,18 @@ import type {
 } from '../../interfaces/search';
 } from '../../interfaces/search';
 import type { PageModel } from '../../models/page';
 import type { PageModel } from '../../models/page';
 import { createBatchStream } from '../../util/batch-stream';
 import { createBatchStream } from '../../util/batch-stream';
+import { configManager } from '../config-manager';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 import type { UpdateOrInsertPagesOpts } from '../interfaces/search';
 
 
 
 
+import { aggregatePipelineToIndex } from './aggregate-to-index';
+import type { AggregatedPage, BulkWriteBody, BulkWriteCommand } from './bulk-write';
 import ElasticsearchClient from './elasticsearch-client';
 import ElasticsearchClient from './elasticsearch-client';
 
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
 
 
 const DEFAULT_OFFSET = 0;
 const DEFAULT_OFFSET = 0;
 const DEFAULT_LIMIT = 50;
 const DEFAULT_LIMIT = 50;
-const BULK_REINDEX_SIZE = 100;
 
 
 const { RELATION_SCORE, CREATED_AT, UPDATED_AT } = SORT_AXIS;
 const { RELATION_SCORE, CREATED_AT, UPDATED_AT } = SORT_AXIS;
 const { DESC, ASC } = SORT_ORDER;
 const { DESC, ASC } = SORT_ORDER;
@@ -68,12 +70,11 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   esUri: string;
   esUri: string;
 
 
-  constructor(configManager, socketIoService) {
+  constructor(socketIoService) {
     this.name = SearchDelegatorName.DEFAULT;
     this.name = SearchDelegatorName.DEFAULT;
-    this.configManager = configManager;
     this.socketIoService = socketIoService;
     this.socketIoService = socketIoService;
 
 
-    const elasticsearchVersion: number = this.configManager.getConfig('crowi', 'app:elasticsearchVersion');
+    const elasticsearchVersion: number = configManager.getConfig('crowi', 'app:elasticsearchVersion');
 
 
     if (elasticsearchVersion !== 7 && elasticsearchVersion !== 8) {
     if (elasticsearchVersion !== 7 && elasticsearchVersion !== 8) {
       throw new Error('Unsupported Elasticsearch version. Please specify a valid number to \'ELASTICSEARCH_VERSION\'');
       throw new Error('Unsupported Elasticsearch version. Please specify a valid number to \'ELASTICSEARCH_VERSION\'');
@@ -81,7 +82,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
     this.isElasticsearchV7 = elasticsearchVersion === 7;
     this.isElasticsearchV7 = elasticsearchVersion === 7;
 
 
-    this.isElasticsearchReindexOnBoot = this.configManager.getConfig('crowi', 'app:elasticsearchReindexOnBoot');
+    this.isElasticsearchReindexOnBoot = configManager.getConfig('crowi', 'app:elasticsearchReindexOnBoot');
 
 
     // In Elasticsearch RegExp, we don't need to used ^ and $.
     // In Elasticsearch RegExp, we don't need to used ^ and $.
     // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
     // Ref: https://www.elastic.co/guide/en/elasticsearch/reference/5.6/query-dsl-regexp-query.html#_standard_operators
@@ -110,26 +111,22 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     return `${this.indexName}-alias`;
     return `${this.indexName}-alias`;
   }
   }
 
 
-  shouldIndexed(page) {
-    return page.revision != null;
-  }
-
   initClient() {
   initClient() {
     const { host, auth, indexName } = this.getConnectionInfo();
     const { host, auth, indexName } = this.getConnectionInfo();
 
 
-    const rejectUnauthorized = this.configManager.getConfig('crowi', 'app:elasticsearchRejectUnauthorized');
+    const rejectUnauthorized = configManager.getConfig('crowi', 'app:elasticsearchRejectUnauthorized');
 
 
     const options = {
     const options = {
       node: host,
       node: host,
       auth,
       auth,
-      requestTimeout: this.configManager.getConfig('crowi', 'app:elasticsearchRequestTimeout'),
+      requestTimeout: configManager.getConfig('crowi', 'app:elasticsearchRequestTimeout'),
     };
     };
 
 
     this.client = new ElasticsearchClient(this.isElasticsearchV7, options, rejectUnauthorized);
     this.client = new ElasticsearchClient(this.isElasticsearchV7, options, rejectUnauthorized);
     this.indexName = indexName;
     this.indexName = indexName;
   }
   }
 
 
-  getType() {
+  getType(): '_doc' | undefined {
     return this.isElasticsearchV7 ? '_doc' : undefined;
     return this.isElasticsearchV7 ? '_doc' : undefined;
   }
   }
 
 
@@ -142,7 +139,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     let host = this.esUri;
     let host = this.esUri;
     let auth;
     let auth;
 
 
-    const elasticsearchUri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    const elasticsearchUri = configManager.getConfig('crowi', 'app:elasticsearchUri');
 
 
     const url = new URL(elasticsearchUri);
     const url = new URL(elasticsearchUri);
     if (url.pathname !== '/') {
     if (url.pathname !== '/') {
@@ -359,20 +356,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
   /**
    * generate object that is related to page.grant*
    * generate object that is related to page.grant*
    */
    */
-  generateDocContentsRelatedToRestriction(page) {
-    let grantedUserIds = null;
-    if (page.grantedUsers != null && page.grantedUsers.length > 0) {
-      grantedUserIds = page.grantedUsers.map((user) => {
-        const userId = (user._id == null) ? user : user._id;
-        return userId.toString();
-      });
-    }
-
-    let grantedGroupIds = [];
-    grantedGroupIds = page.grantedGroups.map((group) => {
-      const groupId = (group.item._id == null) ? group.item : group.item._id;
-      return groupId.toString();
-    });
+  generateDocContentsRelatedToRestriction(page: AggregatedPage) {
+    const grantedUserIds = page.grantedUsers.map(user => getIdForRef(user));
+    const grantedGroupIds = page.grantedGroups.map(group => getIdForRef(group.item));
 
 
     return {
     return {
       grant: page.grant,
       grant: page.grant,
@@ -381,10 +367,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     };
     };
   }
   }
 
 
-  prepareBodyForCreate(body, page) {
-    if (!Array.isArray(body)) {
-      throw new Error('Body must be an array.');
-    }
+  prepareBodyForCreate(page: AggregatedPage): [BulkWriteCommand, BulkWriteBody] {
 
 
     const command = {
     const command = {
       index: {
       index: {
@@ -394,27 +377,22 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
       },
       },
     };
     };
 
 
-    const bookmarkCount = page.bookmarkCount || 0;
-    const seenUsersCount = page.seenUsers?.length || 0;
-    let document = {
+    const document: BulkWriteBody = {
       path: page.path,
       path: page.path,
       body: page.revision.body,
       body: page.revision.body,
-      // username: page.creator?.username, // available Node.js v14 and above
-      username: page.creator != null ? page.creator.username : null,
-      comments: page.comments,
-      comment_count: page.commentCount,
-      bookmark_count: bookmarkCount,
-      seenUsers_count: seenUsersCount,
-      like_count: page.liker?.length || 0,
+      username: page.creator?.username,
+      comments: page.commentsCount > 0 ? page.comments : undefined,
+      comment_count: page.commentsCount,
+      bookmark_count: page.bookmarksCount,
+      like_count: page.likeCount,
+      seenUsers_count: page.seenUsersCount,
       created_at: page.createdAt,
       created_at: page.createdAt,
       updated_at: page.updatedAt,
       updated_at: page.updatedAt,
       tag_names: page.tagNames,
       tag_names: page.tagNames,
+      ...this.generateDocContentsRelatedToRestriction(page),
     };
     };
 
 
-    document = Object.assign(document, this.generateDocContentsRelatedToRestriction(page));
-
-    body.push(command);
-    body.push(document);
+    return [command, document];
   }
   }
 
 
   prepareBodyForDelete(body, page) {
   prepareBodyForDelete(body, page) {
@@ -457,89 +435,28 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
   async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
     const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
     const { shouldEmitProgress = false, invokeGarbageCollection = false } = option;
 
 
-    const Page = mongoose.model('Page') as unknown as PageModel;
+    const Page = mongoose.model<IPage, PageModel>('Page');
     const { PageQueryBuilder } = Page;
     const { PageQueryBuilder } = Page;
-    const Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
 
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 
 
     // prepare functions invoked from custom streams
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
-    const shouldIndexed = this.shouldIndexed.bind(this);
     const bulkWrite = this.client.bulk.bind(this.client);
     const bulkWrite = this.client.bulk.bind(this.client);
 
 
-    const findQuery = new PageQueryBuilder(queryFactory()).query;
-    const countQuery = new PageQueryBuilder(queryFactory()).query;
+    const matchQuery = new PageQueryBuilder(queryFactory()).query;
 
 
+    const countQuery = new PageQueryBuilder(queryFactory()).query;
     const totalCount = await countQuery.count();
     const totalCount = await countQuery.count();
 
 
-    const readStream = findQuery
-      // populate data which will be referenced by prepareBodyForCreate()
-      .populate([
-        { path: 'creator', model: 'User', select: 'username' },
-        { path: 'revision', model: 'Revision', select: 'body' },
-      ])
-      .lean()
-      .cursor();
-
-    let skipped = 0;
-    const thinOutStream = new Transform({
-      objectMode: true,
-      async transform(doc, encoding, callback) {
-        if (shouldIndexed(doc)) {
-          this.push(doc);
-        }
-        else {
-          skipped++;
-        }
-        callback();
-      },
-    });
-
-    const batchStream = createBatchStream(BULK_REINDEX_SIZE);
-
-    const appendBookmarkCountStream = new Transform({
-      objectMode: true,
-      async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
+    const maxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
 
 
-        const idToCountMap = await Bookmark.getPageIdToCountMap(pageIds);
-        const idsHavingCount = Object.keys(idToCountMap);
+    const readStream = Page.aggregate<AggregatedPage>(
+      aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
+    ).cursor();
 
 
-        // append count
-        chunk
-          .filter(doc => idsHavingCount.includes(doc._id.toString()))
-          .forEach((doc) => {
-            // append count from idToCountMap
-            doc.bookmarkCount = idToCountMap[doc._id.toString()];
-          });
-
-        this.push(chunk);
-        callback();
-      },
-    });
-
-
-    const appendCommentStream = new Transform({
-      objectMode: true,
-      async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
-
-        const idToCommentMap = await Comment.getPageIdToCommentMap(pageIds);
-        const idsHavingComment = Object.keys(idToCommentMap);
-
-        // append comments
-        chunk
-          .filter(doc => idsHavingComment.includes(doc._id.toString()))
-          .forEach((doc) => {
-            // append comments from idToCommentMap
-            doc.comments = idToCommentMap[doc._id.toString()];
-          });
-
-        this.push(chunk);
-        callback();
-      },
-    });
+    const bulkSize: number = configManager.getConfig('crowi', 'app:elasticsearchReindexBulkSize');
+    const batchStream = createBatchStream(bulkSize);
 
 
     const appendTagNamesStream = new Transform({
     const appendTagNamesStream = new Transform({
       objectMode: true,
       objectMode: true,
@@ -552,7 +469,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         // append tagNames
         // append tagNames
         chunk
         chunk
           .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
           .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
-          .forEach((doc) => {
+          .forEach((doc: AggregatedPage) => {
             // append tagName from idToTagNamesMap
             // append tagName from idToTagNamesMap
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
           });
           });
@@ -566,8 +483,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const writeStream = new Writable({
     const writeStream = new Writable({
       objectMode: true,
       objectMode: true,
       async write(batch, encoding, callback) {
       async write(batch, encoding, callback) {
-        const body = [];
-        batch.forEach(doc => prepareBodyForCreate(body, doc));
+        const body: (BulkWriteCommand|BulkWriteBody)[] = [];
+        batch.forEach((doc: AggregatedPage) => {
+          body.push(...prepareBodyForCreate(doc));
+        });
 
 
         try {
         try {
           const bulkResponse = await bulkWrite({
           const bulkResponse = await bulkWrite({
@@ -580,7 +499,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
           logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
           logger.info(`Adding pages progressing: (count=${count}, errors=${bulkResponse.errors}, took=${bulkResponse.took}ms)`);
 
 
           if (shouldEmitProgress) {
           if (shouldEmitProgress) {
-            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count, skipped });
+            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count });
           }
           }
         }
         }
         catch (err) {
         catch (err) {
@@ -601,20 +520,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         callback();
         callback();
       },
       },
       final(callback) {
       final(callback) {
-        logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
+        logger.info(`Adding pages has completed: (totalCount=${totalCount})`);
 
 
         if (shouldEmitProgress) {
         if (shouldEmitProgress) {
-          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count, skipped });
+          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count });
         }
         }
         callback();
         callback();
       },
       },
     });
     });
 
 
     readStream
     readStream
-      .pipe(thinOutStream)
       .pipe(batchStream)
       .pipe(batchStream)
-      .pipe(appendBookmarkCountStream)
-      .pipe(appendCommentStream)
       .pipe(appendTagNamesStream)
       .pipe(appendTagNamesStream)
       .pipe(writeStream);
       .pipe(writeStream);
 
 
@@ -829,8 +745,8 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   }
   }
 
 
   async filterPagesByViewer(query, user, userGroups) {
   async filterPagesByViewer(query, user, userGroups) {
-    const showPagesRestrictedByOwner = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
-    const showPagesRestrictedByGroup = !this.configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
+    const showPagesRestrictedByOwner = !configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByOwner');
+    const showPagesRestrictedByGroup = !configManager.getConfig('crowi', 'security:list-policy:hideRestrictedByGroup');
 
 
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
     query = this.initializeBoolQuery(query); // eslint-disable-line no-param-reassign
 
 
@@ -977,30 +893,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
 
   async syncPageUpdated(page, user) {
   async syncPageUpdated(page, user) {
     logger.debug('SearchClient.syncPageUpdated', page.path);
     logger.debug('SearchClient.syncPageUpdated', page.path);
-
-    // delete if page should not indexed
-    if (!this.shouldIndexed(page)) {
-      try {
-        await this.deletePages([page]);
-      }
-      catch (err) {
-        logger.error('deletePages:ES Error', err);
-      }
-      return;
-    }
-
     return this.updateOrInsertPageById(page._id);
     return this.updateOrInsertPageById(page._id);
   }
   }
 
 
   // remove pages whitch should nod Indexed
   // remove pages whitch should nod Indexed
   async syncPagesUpdated(pages, user) {
   async syncPagesUpdated(pages, user) {
     const shoudDeletePages: any[] = [];
     const shoudDeletePages: any[] = [];
-    pages.forEach((page) => {
-      logger.debug('SearchClient.syncPageUpdated', page.path);
-      if (!this.shouldIndexed(page)) {
-        shoudDeletePages.push(page);
-      }
-    });
 
 
     // delete if page should not indexed
     // delete if page should not indexed
     try {
     try {

+ 7 - 9
apps/app/src/server/service/search.ts

@@ -4,19 +4,20 @@ import { FilterXSS } from 'xss';
 
 
 import { CommentEvent, commentEvent } from '~/features/comment/server';
 import { CommentEvent, commentEvent } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import { SearchDelegatorName } from '~/interfaces/named-query';
-import { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
+import type { IFormattedSearchResult, IPageWithSearchMeta, ISearchResult } from '~/interfaces/search';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { ObjectIdLike } from '../interfaces/mongoose-utils';
-import {
+import type { ObjectIdLike } from '../interfaces/mongoose-utils';
+import type {
   SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, SearchableData, QueryTerms,
   SearchDelegator, SearchQueryParser, SearchResolver, ParsedQuery, SearchableData, QueryTerms,
 } from '../interfaces/search';
 } from '../interfaces/search';
 import NamedQuery from '../models/named-query';
 import NamedQuery from '../models/named-query';
-import { PageModel } from '../models/page';
+import type { PageModel } from '../models/page';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { serializeUserSecurely } from '../models/serializers/user-serializer';
 import { SearchError } from '../models/vo/search-error';
 import { SearchError } from '../models/vo/search-error';
 import { hasIntersection } from '../util/compare-objectId';
 import { hasIntersection } from '../util/compare-objectId';
 
 
+import { configManager } from './config-manager';
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import ElasticsearchDelegator from './search-delegator/elasticsearch';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 import PrivateLegacyPagesDelegator from './search-delegator/private-legacy-pages';
 
 
@@ -76,8 +77,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   crowi!: any;
   crowi!: any;
 
 
-  configManager!: any;
-
   isErrorOccuredOnHealthcheck: boolean | null;
   isErrorOccuredOnHealthcheck: boolean | null;
 
 
   isErrorOccuredOnSearching: boolean | null;
   isErrorOccuredOnSearching: boolean | null;
@@ -88,7 +87,6 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
   constructor(crowi) {
   constructor(crowi) {
     this.crowi = crowi;
     this.crowi = crowi;
-    this.configManager = crowi.configManager;
 
 
     this.isErrorOccuredOnHealthcheck = null;
     this.isErrorOccuredOnHealthcheck = null;
     this.isErrorOccuredOnSearching = null;
     this.isErrorOccuredOnSearching = null;
@@ -117,7 +115,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
   }
   }
 
 
   get isElasticsearchEnabled() {
   get isElasticsearchEnabled() {
-    const uri = this.configManager.getConfig('crowi', 'app:elasticsearchUri');
+    const uri = configManager.getConfig('crowi', 'app:elasticsearchUri');
     return uri != null && uri.length > 0;
     return uri != null && uri.length > 0;
   }
   }
 
 
@@ -126,7 +124,7 @@ class SearchService implements SearchQueryParser, SearchResolver {
 
 
     if (this.isElasticsearchEnabled) {
     if (this.isElasticsearchEnabled) {
       logger.info('Elasticsearch is enabled');
       logger.info('Elasticsearch is enabled');
-      return new ElasticsearchDelegator(this.configManager, this.crowi.socketIoService);
+      return new ElasticsearchDelegator(this.crowi.socketIoService);
     }
     }
 
 
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');
     logger.info('No elasticsearch URI is specified so that full text search is disabled.');

+ 2 - 1
apps/app/src/server/service/slack-command-handler/keep.js

@@ -1,11 +1,12 @@
 import {
 import {
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
+import { format } from 'date-fns/format';
+import { parse } from 'date-fns/parse';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 const logger = loggerFactory('growi:service:SlackBotService:keep');
 const logger = loggerFactory('growi:service:SlackBotService:keep');
-const { parse, format } = require('date-fns');
 
 
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
 

+ 3 - 2
apps/app/src/server/service/slack-command-handler/togetter.js

@@ -2,13 +2,14 @@ import {
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock, divider,
 } from '@growi/slack/dist/utils/block-kit-builder';
 } from '@growi/slack/dist/utils/block-kit-builder';
 import { respond, deleteOriginal } from '@growi/slack/dist/utils/response-url';
 import { respond, deleteOriginal } from '@growi/slack/dist/utils/response-url';
+import { format, formatDate } from 'date-fns/format';
+import { parse } from 'date-fns/parse';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 // eslint-disable-next-line no-unused-vars
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:SlackBotService:togetter');
 const logger = loggerFactory('growi:service:SlackBotService:togetter');
 
 
-const { parse, format } = require('date-fns');
 
 
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
 
@@ -163,7 +164,7 @@ module.exports = (crowi) => {
         // include header
         // include header
         else {
         else {
           const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
           const ts = (parseInt(message.ts) - grwTzoffset) * 1000;
-          const time = format(new Date(ts), 'h:mm a');
+          const time = formatDate(new Date(ts), 'h:mm a');
           cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
           cleanedContents.push(`${message.user}  ${time}\n${message.text}\n`);
           lastMessage = message;
           lastMessage = message;
         }
         }

+ 19 - 17
apps/app/src/services/renderer/renderer.tsx

@@ -38,22 +38,28 @@ const logger = loggerFactory('growi:services:renderer');
 
 
 type SanitizePlugin = PluginTuple<[SanitizeOption]>;
 type SanitizePlugin = PluginTuple<[SanitizeOption]>;
 
 
-export const commonSanitizeOption: SanitizeOption = {
-  tagNames: recommendedTagNames,
-  attributes: recommendedAttributes,
-  clobberPrefix: '', // remove clobber prefix
-};
-
-let isInjectedCustomSanitaizeOption = false;
+let currentInitializedSanitizeType: RehypeSanitizeType = RehypeSanitizeType.RECOMMENDED;
+let commonSanitizeOption: SanitizeOption;
+export const getCommonSanitizeOption = (config:RendererConfig): SanitizeOption => {
+  if (commonSanitizeOption == null || config.sanitizeType !== currentInitializedSanitizeType) {
+    // initialize
+    commonSanitizeOption = {
+      tagNames: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
+        ? recommendedTagNames
+        : config.customTagWhitelist ?? recommendedTagNames,
+      attributes: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
+        ? recommendedAttributes
+        : config.customAttrWhitelist ?? recommendedAttributes,
+      clobberPrefix: '', // remove clobber prefix
+    };
 
 
-export const injectCustomSanitizeOption = (config: RendererConfig): void => {
-  if (!isInjectedCustomSanitaizeOption && config.isEnabledXssPrevention && config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    commonSanitizeOption.tagNames = config.customTagWhitelist ?? recommendedTagNames;
-    commonSanitizeOption.attributes = config.customAttrWhitelist ?? recommendedAttributes;
-    isInjectedCustomSanitaizeOption = true;
+    currentInitializedSanitizeType = config.sanitizeType;
   }
   }
+
+  return commonSanitizeOption;
 };
 };
 
 
+
 const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
 const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
   if (!Array.isArray(pluggable) || pluggable.length < 2) {
   if (!Array.isArray(pluggable) || pluggable.length < 2) {
     return false;
     return false;
@@ -131,13 +137,9 @@ export const generateSSRViewOptions = (
     remarkPlugins.push(breaks);
     remarkPlugins.push(breaks);
   }
   }
 
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
     )]
     )]
     : () => {};
     : () => {};
 
 

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

@@ -88,6 +88,10 @@ export const useIsSearchServiceReachable = (initialData?: boolean) : SWRResponse
   return useContextSWR<boolean, Error>('isSearchServiceReachable', initialData);
   return useContextSWR<boolean, Error>('isSearchServiceReachable', initialData);
 };
 };
 
 
+export const useElasticsearchMaxBodyLengthToIndex = (initialData?: number) : SWRResponse<number, Error> => {
+  return useContextSWR('elasticsearchMaxBodyLengthToIndex', initialData);
+};
+
 export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, any> => {
 export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, any> => {
   return useContextSWR('isMailerSetup', initialData);
   return useContextSWR('isMailerSetup', initialData);
 };
 };

+ 2 - 1
apps/app/src/stores/admin/customize.tsx

@@ -1,6 +1,7 @@
 import { useCallback } from 'react';
 import { useCallback } from 'react';
 
 
-import useSWR, { SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWR from 'swr';
 import useSWRImmutable from 'swr/immutable';
 import useSWRImmutable from 'swr/immutable';
 
 
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';

+ 0 - 219
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts

@@ -1,219 +0,0 @@
-context('Modal for page operation', () => {
-
-  const ssPrefix = 'modal-for-page-operation-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('Page Deletion and PutBack is executed successfully', { scrollBehavior: false }, () => {
-    cy.visit('/Sandbox/Bootstrap5');
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
-      });
-      //wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-
-    cy.getByTestid('open-page-delete-modal-btn').filter(':visible').click({force: true});
-
-    cy.getByTestid('page-delete-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}-delete-modal`);
-      cy.getByTestid('delete-page-button').click();
-    });
-
-    cy.getByTestid('trash-page-alert').should('be.visible');
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-bootstrap5-is-in-garbage-box`);
-
-    cy.getByTestid('put-back-button').click();
-    cy.getByTestid('put-back-page-modal').should('be.visible').within(() => {
-      cy.screenshot(`${ssPrefix}-put-back-modal`);
-      cy.getByTestid('put-back-execution-button').should('be.visible').click();
-    });
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-put-backed-bootstrap5-page`);
-  });
-
-  it('PageDuplicateModal is shown successfully', () => {
-    cy.visit('/Sandbox/5');
-    cy.waitUntilSkeletonDisappear();
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-
-    cy.getByTestid('open-page-duplicate-modal-btn').filter(':visible').click({force: true});
-
-    cy.getByTestid('page-duplicate-modal').should('be.visible').screenshot(`${ssPrefix}-duplicate-bootstrap5`);
-  });
-
-  it('PageMoveRenameModal is shown successfully', () => {
-    cy.visit('/Sandbox/Bootstrap5');
-    cy.waitUntilSkeletonDisappear();
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
-      });
-      // wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-
-    cy.getByTestid('rename-page-btn').filter(':visible').click({force: true});
-    cy.getByTestid('grw-page-rename-button').should('be.disabled');
-
-    cy.getByTestid('page-rename-modal').should('be.visible').screenshot(`${ssPrefix}-rename-bootstrap5`);
-  });
-
-});
-
-
-// TODO: Uncomment after https://redmine.weseek.co.jp/issues/103121 is resolved
-// context('Open presentation modal', () => {
-
-//   const ssPrefix = 'access-to-presentation-modal-';
-
-//   beforeEach(() => {
-//     // login
-//     cy.fixture("user-admin.json").then(user => {
-//       cy.login(user.username, user.password);
-//     });
-//     cy.collapseSidebar(true);
-//   });
-
-//   it('PresentationModal for "/" is shown successfully', () => {
-//     cy.visit('/');
-
-//     cy.getByTestid('grw-contextual-sub-nav').within(() => {
-//       cy.getByTestid('open-page-item-control-btn').click({force: true});
-//       cy.getByTestid('open-presentation-modal-btn').click({force: true});
-//     });
-
-//     // eslint-disable-next-line cypress/no-unnecessary-waiting
-//     cy.wait(1500);
-//     cy.screenshot(`${ssPrefix}-open-top`);
-//   });
-
-// });
-
-context('Page Accessories Modal', () => {
-
-  const ssPrefix = 'access-to-page-accessories-modal';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-
-    cy.visit('/');
-    cy.collapseSidebar(true, true);
-    cy.waitUntilSkeletonDisappear();
-
-    cy.waitUntil(() => {
-      // do
-      cy.getByTestid('grw-contextual-sub-nav').within(() => {
-        cy.getByTestid('open-page-item-control-btn').find('button').click({force: true});
-      });
-      //wait until
-      return cy.getByTestid('page-item-control-menu').then($elem => $elem.is(':visible'))
-    });
-
-
-  });
-
-  it('Page History is shown successfully', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-accessories-modal-btn-with-history-tab').click({force: true});
-    });
-
-    cy.getByTestid('page-history').should('be.visible');
-
-    cy.waitUntilSpinnerDisappear();
-    cy.screenshot(`${ssPrefix}-open-page-history-bootstrap5`);
-  });
-
-  it('Page Attachment Data is shown successfully', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-accessories-modal-btn-with-attachment-data-tab').click({force: true});
-    });
-
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('page-attachment').should('be.visible').contains('No attachments yet.');
-
-    cy.screenshot(`${ssPrefix}-open-page-attachment-data-bootstrap5`);
-  });
-
-  it('Share Link Management is shown successfully', () => {
-    cy.get('.dropdown-menu.show').should('be.visible').within(() => {
-      cy.getByTestid('open-page-accessories-modal-btn-with-share-link-management-data-tab').click({force: true});
-    });
-
-    cy.waitUntilSpinnerDisappear();
-    cy.getByTestid('page-accessories-modal').should('be.visible');
-    cy.getByTestid('share-link-management').should('be.visible');
-
-    cy.screenshot(`${ssPrefix}-open-share-link-management-bootstrap5`);
-  });
-});
-
-context('Tag Oprations', { scrollBehavior: false }, () =>{
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('Successfully add new tag', () => {
-    const ssPrefix = 'tag-operations-add-new-tag-'
-    const tag = 'we';
-
-    cy.visit('/Sandbox/Bootstrap5');
-    cy.collapseSidebar(true);
-
-    // Add tag
-    cy.get('#edit-tags-btn-wrapper-for-tooltip').as('edit-tag-tooltip').should('be.visible');
-
-    // open Edit Tags Modal
-    cy.waitUntil(() => {
-      // do
-      cy.get('@edit-tag-tooltip').find('a').click({force: true});
-      // wait until
-      return cy.get('#edit-tag-modal').then($elem => $elem.is(':visible'));
-    });
-
-    cy.get('#edit-tag-modal').should('be.visible').screenshot(`${ssPrefix}1-edit-tag-input`);
-
-    cy.get('#edit-tag-modal').should('be.visible').within(() => {
-      cy.get('.rbt-input-main').type(tag);
-      cy.get('#tag-typeahead-asynctypeahead').should('be.visible');
-      cy.get('#tag-typeahead-asynctypeahead-item-0').should('be.visible');
-      // select
-      cy.get('a#tag-typeahead-asynctypeahead-item-0').click();
-      // save
-      cy.get('div.modal-footer > button').click();
-    });
-
-    cy.get('.Toastify__toast').should('be.visible').trigger('mouseover');
-    cy.getByTestid('grw-tag-labels').contains(tag).should('exist');
-
-    cy.screenshot(`${ssPrefix}2-click-done`);
-  });
-
-});

+ 2 - 2
packages/core-styles/scss/variables/_growi-official-colors.scss

@@ -1,3 +1,3 @@
 // == GROWI Official Color
 // == GROWI Official Color
-$growi-green: #74bc46;
-$growi-blue: #175fa5;
+$growi-green: #7AD340;
+$growi-blue: #428DD1;

+ 3 - 1
packages/editor/package.json

@@ -33,6 +33,8 @@
     "@codemirror/merge": "6.0.0",
     "@codemirror/merge": "6.0.0",
     "@codemirror/state": "^6.2.1",
     "@codemirror/state": "^6.2.1",
     "@codemirror/view": "^6.15.3",
     "@codemirror/view": "^6.15.3",
+    "@emoji-mart/data": "^1.2.1",
+    "@emoji-mart/react": "^1.1.1",
     "@growi/core": "link:../core",
     "@growi/core": "link:../core",
     "@growi/core-styles": "link:../core-styles",
     "@growi/core-styles": "link:../core-styles",
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
@@ -51,7 +53,7 @@
     "cm6-theme-nord": "^0.2.0",
     "cm6-theme-nord": "^0.2.0",
     "codemirror": "^6.0.1",
     "codemirror": "^6.0.1",
     "csv-to-markdown-table": "^1.4.1",
     "csv-to-markdown-table": "^1.4.1",
-    "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
+    "emoji-mart": "^5.6.0",
     "eslint-plugin-react-refresh": "^0.4.1",
     "eslint-plugin-react-refresh": "^0.4.1",
     "markdown-table": "^3.0.3",
     "markdown-table": "^3.0.3",
     "react-dropzone": "^14.2.3",
     "react-dropzone": "^14.2.3",

+ 2 - 0
packages/editor/src/@types/emoji-mart.d.ts

@@ -1 +1,3 @@
 declare module 'emoji-mart';
 declare module 'emoji-mart';
+declare module '@emoji-mart/data';
+declare module '@emoji-mart/react';

+ 23 - 79
packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx

@@ -1,13 +1,12 @@
 import {
 import {
   useState, useCallback,
   useState, useCallback,
-  type FC, type CSSProperties,
+  type CSSProperties,
 } from 'react';
 } from 'react';
 
 
-import { Picker } from 'emoji-mart';
-import i18n from 'i18next';
+import emojiData from '@emoji-mart/data';
+import Picker from '@emoji-mart/react';
 import { Modal } from 'reactstrap';
 import { Modal } from 'reactstrap';
 
 
-import 'emoji-mart/css/emoji-mart.css';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '../../../stores/use-resolved-theme';
 import { useResolvedThemeForEditor } from '../../../stores/use-resolved-theme';
 
 
@@ -15,88 +14,32 @@ type Props = {
   editorKey: string,
   editorKey: string,
 }
 }
 
 
-type Translation = {
-  search: string
-  clear: string
-  notfound: string
-  skintext: string
-  categories: object
-  categorieslabel: string
-  skintones: object
-  title: string
-}
-
-// TODO: https://redmine.weseek.co.jp/issues/133681
-const getEmojiTranslation = (): Translation => {
-
-  const categories: { [key: string]: string } = {};
-  [
-    'search',
-    'recent',
-    'smileys',
-    'people',
-    'nature',
-    'foods',
-    'activity',
-    'places',
-    'objects',
-    'symbols',
-    'flags',
-    'custom',
-  ].forEach((category) => {
-    categories[category] = i18n.t(`emoji.categories.${category}`);
-  });
-
-  const skintones: { [key: string]: string} = {};
-  (Array.from(Array(6).keys())).forEach((tone) => {
-    skintones[tone + 1] = i18n.t(`emoji.skintones.${tone + 1}`);
-  });
-
-  const translation = {
-    search: i18n.t('emoji.search'),
-    clear: i18n.t('emoji.clear'),
-    notfound: i18n.t('emoji.notfound'),
-    skintext: i18n.t('emoji.skintext'),
-    categories,
-    categorieslabel: i18n.t('emoji.categorieslabel'),
-    skintones,
-    title: i18n.t('emoji.title'),
-  };
-
-  return translation;
-};
-
-const translation = getEmojiTranslation();
-
-export const EmojiButton: FC<Props> = (props) => {
+export const EmojiButton = (props: Props): JSX.Element => {
   const { editorKey } = props;
   const { editorKey } = props;
 
 
   const [isOpen, setIsOpen] = useState(false);
   const [isOpen, setIsOpen] = useState(false);
 
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
   const { data: resolvedTheme } = useResolvedThemeForEditor();
   const { data: resolvedTheme } = useResolvedThemeForEditor();
-
-  const view = codeMirrorEditor?.view;
-  const cursorIndex = view?.state.selection.main.head;
   const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
   const toggle = useCallback(() => setIsOpen(!isOpen), [isOpen]);
 
 
-  const selectEmoji = useCallback((emoji: { colons: string }): void => {
+  const selectEmoji = useCallback((emoji: { shortcodes: string }): void => {
 
 
-    if (cursorIndex == null || !isOpen) {
+    if (!isOpen) {
       return;
       return;
     }
     }
 
 
-    view?.dispatch({
-      changes: {
-        from: cursorIndex,
-        insert: emoji.colons,
-      },
-    });
+    codeMirrorEditor?.insertText(emoji.shortcodes);
 
 
     toggle();
     toggle();
-  }, [cursorIndex, isOpen, toggle, view]);
+  }, [isOpen, toggle, codeMirrorEditor]);
+
 
 
   const setStyle = useCallback((): CSSProperties => {
   const setStyle = useCallback((): CSSProperties => {
+
+    const view = codeMirrorEditor?.view;
+    const cursorIndex = view?.state.selection.main.head;
+
     if (view == null || cursorIndex == null || !isOpen) {
     if (view == null || cursorIndex == null || !isOpen) {
       return {};
       return {};
     }
     }
@@ -123,7 +66,7 @@ export const EmojiButton: FC<Props> = (props) => {
       left: cursorRect.left + offset,
       left: cursorRect.left + offset,
       position: 'fixed',
       position: 'fixed',
     };
     };
-  }, [cursorIndex, isOpen, view]);
+  }, [isOpen, codeMirrorEditor]);
 
 
   return (
   return (
     <>
     <>
@@ -134,14 +77,15 @@ export const EmojiButton: FC<Props> = (props) => {
       && (
       && (
         <div className="mb-2 d-none d-md-block">
         <div className="mb-2 d-none d-md-block">
           <Modal isOpen={isOpen} toggle={toggle} backdropClassName="emoji-picker-modal" fade={false}>
           <Modal isOpen={isOpen} toggle={toggle} backdropClassName="emoji-picker-modal" fade={false}>
-            <Picker
-              onSelect={selectEmoji}
-              i18n={translation}
-              title={translation.title}
-              emojiTooltip
-              style={setStyle()}
-              theme={resolvedTheme}
-            />
+            <span style={setStyle()}>
+              <Picker
+                onEmojiSelect={selectEmoji}
+                theme={resolvedTheme?.themeData}
+                data={emojiData}
+                // TODO: https://redmine.weseek.co.jp/issues/133681
+                // i18n={}
+              />
+            </span>
           </Modal>
           </Modal>
         </div>
         </div>
       )}
       )}

+ 4 - 6
packages/editor/src/client/services-internal/extensions/emojiAutocompletionSettings.ts

@@ -1,7 +1,7 @@
 import { type CompletionContext, type Completion, autocompletion } from '@codemirror/autocomplete';
 import { type CompletionContext, type Completion, autocompletion } from '@codemirror/autocomplete';
 import { syntaxTree } from '@codemirror/language';
 import { syntaxTree } from '@codemirror/language';
-import { emojiIndex } from 'emoji-mart';
-import emojiData from 'emoji-mart/data/all.json';
+import emojiData from '@emoji-mart/data';
+
 
 
 const getEmojiDataArray = (): string[] => {
 const getEmojiDataArray = (): string[] => {
   const rawEmojiDataArray = emojiData.categories;
   const rawEmojiDataArray = emojiData.categories;
@@ -20,7 +20,7 @@ const getEmojiDataArray = (): string[] => {
   const fixedEmojiDataArray: string[] = [];
   const fixedEmojiDataArray: string[] = [];
 
 
   emojiCategoriesData.forEach((value) => {
   emojiCategoriesData.forEach((value) => {
-    const tempArray = rawEmojiDataArray.find(obj => obj.id === value)?.emojis;
+    const tempArray = rawEmojiDataArray.find((obj: {id: string}) => obj.id === value)?.emojis;
 
 
     if (tempArray == null) {
     if (tempArray == null) {
       return;
       return;
@@ -60,9 +60,7 @@ export const emojiAutocompletionSettings = autocompletion({
   addToOptions: [{
   addToOptions: [{
     render: (completion: Completion) => {
     render: (completion: Completion) => {
       const emojiName = completion.type ?? '';
       const emojiName = completion.type ?? '';
-      const emojiData = emojiIndex.emojis[emojiName];
-
-      const emoji = emojiData.native ?? emojiData[1].native;
+      const emoji = emojiData.emojis[emojiName].skins[0].native;
 
 
       const element = document.createElement('span');
       const element = document.createElement('span');
       element.innerHTML = emoji;
       element.innerHTML = emoji;

+ 0 - 3
packages/editor/vite.config.ts

@@ -50,9 +50,6 @@ export default defineConfig({
         preserveModules: true,
         preserveModules: true,
         preserveModulesRoot: 'src',
         preserveModulesRoot: 'src',
       },
       },
-      external: [
-        'emoji-mart/css/emoji-mart.css',
-      ],
     },
     },
   },
   },
 });
 });

+ 3 - 0
packages/presentation/package.json

@@ -18,6 +18,9 @@
     "./dist/client": {
     "./dist/client": {
       "import": "./dist/client/index.js"
       "import": "./dist/client/index.js"
     },
     },
+    "./dist/client/services/sanitize-option": {
+      "import": "./dist/client/services/sanitize-option.js"
+    },
     "./dist/services": {
     "./dist/services": {
       "import": "./dist/services/index.js"
       "import": "./dist/services/index.js"
     },
     },

+ 0 - 13
packages/presentation/src/client/components/Presentation.global.scss

@@ -1,13 +0,0 @@
-:root[data-bs-theme='light'] {
-  .reveal-viewport {
-    // adjust marp default theme
-    background-color: #fff;
-  }
-}
-
-:root[data-bs-theme='dark'] {
-  .reveal-viewport {
-    // adjust marp default theme
-    background-color: #0d1117;
-  }
-}

+ 28 - 0
packages/presentation/src/client/components/Presentation.module.scss

@@ -1,3 +1,10 @@
+:root {
+  :global {
+    /* stylelint-disable-next-line no-invalid-position-at-import-rule */
+    @import 'reveal.js/dist/reveal.css';
+  }
+}
+
 .grw-presentation {
 .grw-presentation {
   // workaround from https://github.com/css-modules/css-modules/issues/295#issuecomment-952885628
   // workaround from https://github.com/css-modules/css-modules/issues/295#issuecomment-952885628
   &:global(.reveal) :global {
   &:global(.reveal) :global {
@@ -19,3 +26,24 @@
   }
   }
 
 
 }
 }
+
+// adjust marp default theme
+:root[data-bs-theme='light'] {
+  .grw-presentation {
+    &:global {
+      &.reveal-viewport {
+        background-color: #fff;
+      }
+    }
+  }
+}
+
+:root[data-bs-theme='dark'] {
+  .grw-presentation {
+    &:global {
+      &.reveal-viewport {
+        background-color: #0d1117;
+      }
+    }
+  }
+}

+ 4 - 5
packages/presentation/src/client/components/Presentation.tsx

@@ -6,11 +6,10 @@ import type { PresentationOptions } from '../consts';
 
 
 import { Slides } from './Slides';
 import { Slides } from './Slides';
 
 
-import 'reveal.js/dist/reveal.css';
-import './Presentation.global.scss';
-
 import styles from './Presentation.module.scss';
 import styles from './Presentation.module.scss';
 
 
+const moduleClass = styles['grw-presentation'] ?? '';
+
 
 
 const baseRevealOptions: Reveal.Options = {
 const baseRevealOptions: Reveal.Options = {
   // adjust size to the marp preset size
   // adjust size to the marp preset size
@@ -27,7 +26,7 @@ const baseRevealOptions: Reveal.Options = {
  * @see https://getbootstrap.com/docs/4.6/content/reboot/#html5-hidden-attribute
  * @see https://getbootstrap.com/docs/4.6/content/reboot/#html5-hidden-attribute
  */
  */
 const removeAllHiddenElements = () => {
 const removeAllHiddenElements = () => {
-  const sections = document.querySelectorAll('.grw-presentation section');
+  const sections = document.querySelectorAll(`${moduleClass} section`);
   sections.forEach(section => section.removeAttribute('hidden'));
   sections.forEach(section => section.removeAttribute('hidden'));
 };
 };
 
 
@@ -59,7 +58,7 @@ export const Presentation = (props: PresentationProps): JSX.Element => {
   }, [children, revealOptions]);
   }, [children, revealOptions]);
 
 
   return (
   return (
-    <div className={`grw-presentation ${styles['grw-presentation']} reveal`}>
+    <div className={`${moduleClass} reveal`}>
       <Slides options={options} hasMarpFlag={marp} presentation>{children}</Slides>
       <Slides options={options} hasMarpFlag={marp} presentation>{children}</Slides>
     </div>
     </div>
   );
   );

+ 2 - 1
packages/presentation/src/client/services/growi-marpit.ts

@@ -1,4 +1,5 @@
-import { Marp, MarpOptions } from '@marp-team/marp-core';
+import type { MarpOptions } from '@marp-team/marp-core';
+import { Marp } from '@marp-team/marp-core';
 import { Element } from '@marp-team/marpit';
 import { Element } from '@marp-team/marpit';
 
 
 export const MARP_CONTAINER_CLASS_NAME = 'marpit';
 export const MARP_CONTAINER_CLASS_NAME = 'marpit';

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

@@ -83,5 +83,5 @@ export const remarkPlugin: Plugin<[ExtractSectionsPluginParams]> = (options) =>
 
 
 
 
 export const sanitizeOption: SanitizeOption = {
 export const sanitizeOption: SanitizeOption = {
-  // tagNames: ['slides', 'slide'],
+  tagNames: ['section'],
 };
 };

+ 1 - 0
packages/presentation/src/client/services/sanitize-option.ts

@@ -0,0 +1 @@
+export { sanitizeOption } from './renderer/extract-sections';

+ 18 - 36
yarn.lock

@@ -1183,7 +1183,7 @@
     core-js-pure "^3.20.2"
     core-js-pure "^3.20.2"
     regenerator-runtime "^0.13.4"
     regenerator-runtime "^0.13.4"
 
 
-"@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.1", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.15", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
+"@babel/runtime@^7.10.2", "@babel/runtime@^7.12.1", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.6", "@babel/runtime@^7.15.4", "@babel/runtime@^7.16.3", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.1", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.15", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.9", "@babel/runtime@^7.3.1", "@babel/runtime@^7.4.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2":
   version "7.24.7"
   version "7.24.7"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12"
   integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==
   integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==
@@ -1896,6 +1896,16 @@
   dependencies:
   dependencies:
     tslib "^2.4.0"
     tslib "^2.4.0"
 
 
+"@emoji-mart/data@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@emoji-mart/data/-/data-1.2.1.tgz#0ad70c662e3bc603e23e7d98413bd1e64c4fcb6c"
+  integrity sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==
+
+"@emoji-mart/react@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@emoji-mart/react/-/react-1.1.1.tgz#ddad52f93a25baf31c5383c3e7e4c6e05554312a"
+  integrity sha512-NMlFNeWgv1//uPsvLxvGQoIerPuVdXwK/EUek8OOkJ6wVOWPUizRBJU0hDqWZCOROVpfBgCemaC3m6jDOXi03g==
+
 "@esbuild/aix-ppc64@0.20.2":
 "@esbuild/aix-ppc64@0.20.2":
   version "0.20.2"
   version "0.20.2"
   resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
   resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
@@ -8181,13 +8191,10 @@ emittery@^0.13.1:
   resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
   resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
   integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
   integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==
 
 
-"emoji-mart@npm:panta82-emoji-mart@^3.0.1":
-  version "3.0.1003"
-  resolved "https://registry.yarnpkg.com/panta82-emoji-mart/-/panta82-emoji-mart-3.0.1003.tgz#8febed01a0a731ba84caaddf1ba5b1ac724562ac"
-  integrity sha512-JLCNrxoyOb/m/0kGWJZK7QGl/+t82cQrFgbbieeevBxp+lD8pnAb4Bsa4kJzV7xNwMYlNlHDAZJsM//Xb5eJ2Q==
-  dependencies:
-    "@babel/runtime" "^7.0.0"
-    prop-types "^15.6.0"
+emoji-mart@^5.6.0:
+  version "5.6.0"
+  resolved "https://registry.yarnpkg.com/emoji-mart/-/emoji-mart-5.6.0.tgz#71b3ed0091d3e8c68487b240d9d6d9a73c27f023"
+  integrity sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==
 
 
 emoji-regex@^8.0.0:
 emoji-regex@^8.0.0:
   version "8.0.0"
   version "8.0.0"
@@ -17233,7 +17240,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
   integrity sha1-np8iM9wA8hhxjsN5oopWc+zKi5Y=
 
 
-"string-width-cjs@npm:string-width@^4.2.0":
+"string-width-cjs@npm:string-width@^4.2.0", "string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
   version "4.2.3"
   version "4.2.3"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17251,15 +17258,6 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.0.0"
     is-fullwidth-code-point "^3.0.0"
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
 
 
-"string-width@^1.0.2 || 2 || 3 || 4", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
-  version "4.2.3"
-  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
-  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
-  dependencies:
-    emoji-regex "^8.0.0"
-    is-fullwidth-code-point "^3.0.0"
-    strip-ansi "^6.0.1"
-
 string-width@^5.0.1, string-width@^5.1.2:
 string-width@^5.0.1, string-width@^5.1.2:
   version "5.1.2"
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -17343,7 +17341,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.0.0"
     character-entities-html4 "^2.0.0"
     character-entities-legacy "^3.0.0"
     character-entities-legacy "^3.0.0"
 
 
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
   version "6.0.1"
   version "6.0.1"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17357,13 +17355,6 @@ strip-ansi@^3.0.0:
   dependencies:
   dependencies:
     ansi-regex "^2.0.0"
     ansi-regex "^2.0.0"
 
 
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
-  version "6.0.1"
-  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
-  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
-  dependencies:
-    ansi-regex "^5.0.1"
-
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
 strip-ansi@^7.0.1, strip-ansi@^7.1.0:
   version "7.1.0"
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19185,7 +19176,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
   integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
 
 
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
   version "7.0.0"
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19203,15 +19194,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.0"
     string-width "^4.1.0"
     strip-ansi "^6.0.0"
     strip-ansi "^6.0.0"
 
 
-wrap-ansi@^7.0.0:
-  version "7.0.0"
-  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
-  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
-  dependencies:
-    ansi-styles "^4.0.0"
-    string-width "^4.1.0"
-    strip-ansi "^6.0.0"
-
 wrap-ansi@^8.1.0:
 wrap-ansi@^8.1.0:
   version "8.1.0"
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"