Kaynağa Gözat

Merge branch 'master' into 148445-148447-upgrade-remark-growi-directive

reiji-h 1 yıl önce
ebeveyn
işleme
70389730a5
71 değiştirilmiş dosya ile 914 ekleme ve 1252 silme
  1. 1 1
      .github/workflows/reusable-app-prod.yml
  2. 4 1
      apps/app/nodemon.json
  3. 129 1
      apps/app/playwright/20-basic-features/access-to-page.spec.ts
  4. 28 0
      apps/app/playwright/20-basic-features/presentation.spec.ts
  5. 86 0
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  6. 27 0
      apps/app/playwright/23-editor/template-modal.spec.ts
  7. 7 0
      apps/app/public/images/growi-brand-logo-login.svg
  8. 7 7
      apps/app/public/images/logo.svg
  9. 3 31
      apps/app/public/static/locales/en_US/translation.json
  10. 3 31
      apps/app/public/static/locales/fr_FR/translation.json
  11. 3 31
      apps/app/public/static/locales/ja_JP/translation.json
  12. 3 31
      apps/app/public/static/locales/zh_CN/translation.json
  13. 29 31
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTable.tsx
  14. 3 3
      apps/app/src/client/components/Admin/ExportArchiveData/ArchiveFilesTableMenu.tsx
  15. 12 4
      apps/app/src/client/components/Admin/ExportArchiveData/SelectCollectionsModal.tsx
  16. 1 1
      apps/app/src/client/components/Admin/ExportArchiveDataPage.tsx
  17. 1 1
      apps/app/src/client/components/Common/CopyDropdown/CopyDropdown.jsx
  18. 3 1
      apps/app/src/client/components/PageComment/DeleteCommentModal.tsx
  19. 11 5
      apps/app/src/client/components/PageCreateModal.tsx
  20. 2 2
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  21. 27 4
      apps/app/src/client/components/PageHistory/RevisionDiff.tsx
  22. 3 1
      apps/app/src/client/components/PagePresentationModal.tsx
  23. 1 1
      apps/app/src/client/components/PageTags/TagEditModal.tsx
  24. 1 1
      apps/app/src/client/components/RevisionComparer/RevisionComparer.tsx
  25. 9 1
      apps/app/src/client/components/TemplateModal/TemplateModal.tsx
  26. 8 24
      apps/app/src/client/services/renderer/renderer.tsx
  27. 15 16
      apps/app/src/components/Common/GrowiLogo.jsx
  28. 1 1
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  29. 3 1
      apps/app/src/components/Layout/NoLoginLayout.tsx
  30. 31 0
      apps/app/src/components/PageView/PageAlerts/FullTextSearchNotCoverAlert.tsx
  31. 2 2
      apps/app/src/components/PageView/PageAlerts/PageAlerts.tsx
  32. 0 19
      apps/app/src/features/comment/server/models/comment.ts
  33. 11 1
      apps/app/src/features/rate-limiter/config/index.ts
  34. 2 1
      apps/app/src/features/rate-limiter/utils/config-generator.ts
  35. 4 0
      apps/app/src/pages/[[...path]].page.tsx
  36. 21 2
      apps/app/src/pages/_document.page.tsx
  37. 11 6
      apps/app/src/pages/utils/commons.ts
  38. 1 19
      apps/app/src/server/models/activity.ts
  39. 0 9
      apps/app/src/server/models/obsolete-page.js
  40. 6 9
      apps/app/src/server/models/page-tag-relation.ts
  41. 1 0
      apps/app/src/server/models/tag.ts
  42. 6 5
      apps/app/src/server/routes/apiv3/staffs.js
  43. 6 0
      apps/app/src/server/service/config-loader.ts
  44. 1 1
      apps/app/src/server/service/import.js
  45. 1 0
      apps/app/src/server/service/page/page-service.ts
  46. 139 0
      apps/app/src/server/service/search-delegator/aggregate-to-index.ts
  47. 52 0
      apps/app/src/server/service/search-delegator/bulk-write.d.ts
  48. 33 135
      apps/app/src/server/service/search-delegator/elasticsearch.ts
  49. 2 1
      apps/app/src/server/service/slack-command-handler/keep.js
  50. 3 2
      apps/app/src/server/service/slack-command-handler/togetter.js
  51. 34 18
      apps/app/src/server/service/socket-io.ts
  52. 19 17
      apps/app/src/services/renderer/renderer.tsx
  53. 4 0
      apps/app/src/stores-universal/context.tsx
  54. 2 1
      apps/app/src/stores/admin/customize.tsx
  55. 0 339
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  56. 0 219
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  57. 0 67
      apps/app/test/cypress/e2e/23-editor/23-editor--template-modal.cy.ts
  58. 2 2
      packages/core-styles/scss/variables/_growi-official-colors.scss
  59. 3 1
      packages/editor/package.json
  60. 2 0
      packages/editor/src/@types/emoji-mart.d.ts
  61. 23 79
      packages/editor/src/client/components-internal/CodeMirrorEditor/Toolbar/EmojiButton.tsx
  62. 4 6
      packages/editor/src/client/services-internal/extensions/emojiAutocompletionSettings.ts
  63. 0 3
      packages/editor/vite.config.ts
  64. 3 0
      packages/presentation/package.json
  65. 0 13
      packages/presentation/src/client/components/Presentation.global.scss
  66. 28 0
      packages/presentation/src/client/components/Presentation.module.scss
  67. 4 5
      packages/presentation/src/client/components/Presentation.tsx
  68. 2 1
      packages/presentation/src/client/services/growi-marpit.ts
  69. 1 1
      packages/presentation/src/client/services/renderer/extract-sections.ts
  70. 1 0
      packages/presentation/src/client/services/sanitize-option.ts
  71. 18 36
      yarn.lock

+ 1 - 1
.github/workflows/reusable-app-prod.yml

@@ -213,7 +213,7 @@ jobs:
       fail-fast: false
       matrix:
         # List string expressions that is comma separated ids of tests in "test/cypress/integration"
-        spec-group: ['20', '21', '23', '30', '50']
+        spec-group: ['21', '23', '30', '50']
 
     services:
       mongodb:

+ 4 - 1
apps/app/nodemon.json

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

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

@@ -1,4 +1,9 @@
-import { test, expect } from '@playwright/test';
+import { test, expect, type Page } from '@playwright/test';
+
+const appendTextToEditorUntilContains = async(page: Page, text: string) => {
+  await page.locator('.cm-content').fill(text);
+  await expect(page.getByTestId('page-editor-preview-body')).toContainText(text);
+};
 
 test('has title', async({ page }) => {
   await page.goto('/Sandbox');
@@ -14,9 +19,132 @@ test('get h1', async({ page }) => {
   await expect(page.getByRole('heading').filter({ hasText: /\/Sandbox/ })).toBeVisible();
 });
 
+test('/Sandbox/Math is successfully loaded', async({ page }) => {
+  await page.goto('/Sandbox/Math');
+
+  // Expect the Math-specific elements to be present
+  await expect(page.locator('.math').first()).toBeVisible();
+});
+
+test('Sandbox with edit is successfully loaded', async({ page }) => {
+  await page.goto('/Sandbox#edit');
+
+  // Expect the Editor-specific elements to be present
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+  await expect(page.getByTestId('save-page-btn')).toBeVisible();
+  await expect(page.getByTestId('grw-grant-selector')).toBeVisible();
+});
+
+test.describe.serial('PageEditor', () => {
+  const body1 = 'hello';
+  const body2 = ' world!';
+  const targetPath = '/Sandbox/testForUseEditingMarkdown';
+
+  test('Edit and save with save-page-btn', async({ page }) => {
+    await page.goto(targetPath);
+
+    await page.getByTestId('editor-button').click();
+    await appendTextToEditorUntilContains(page, body1);
+    await page.getByTestId('save-page-btn').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(body1);
+  });
+
+  test('Edit and save with shortcut key', async({ page }) => {
+    const savePageShortcutKey = 'Control+s';
+
+    await page.goto(targetPath);
+
+    await page.getByTestId('editor-button').click();
+
+    await expect(page.locator('.cm-content')).toContainText(body1);
+    await expect(page.getByTestId('page-editor-preview-body')).toContainText(body1);
+
+    await appendTextToEditorUntilContains(page, body1 + body2);
+    await page.keyboard.press(savePageShortcutKey);
+    await page.getByTestId('view-button').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(body1 + body2);
+  });
+});
+
 test('Access to /me page', async({ page }) => {
   await page.goto('/me');
 
   // Expect the UserSettgins-specific elements to be present when accessing /me (UserSettgins)
   await expect(page.getByTestId('grw-user-settings')).toBeVisible();
 });
+
+test('All In-App Notification list is successfully loaded', async({ page }) => {
+  await page.goto('/me/all-in-app-notifications');
+
+  // Expect the In-App Notification-specific elements to be present when accessing /me/all-in-app-notifications
+  await expect(page.getByTestId('grw-in-app-notification-page')).toBeVisible();
+});
+
+test('/trash is successfully loaded', async({ page }) => {
+  await page.goto('/trash');
+
+  await expect(page.getByTestId('trash-page-list')).toContainText('There are no pages under this page.');
+});
+
+test('/tags is successfully loaded', async({ page }) => {
+  await page.goto('/tags');
+
+  await expect(page.getByTestId('grw-tags-list')).toContainText('You have no tag, You can set tags on pages');
+});
+
+test.describe.serial('Access to Template Editing Mode', () => {
+  const templateBody1 = 'Template for children';
+  const templateBody2 = 'Template for descendants';
+
+  test('Successfully created template for children', async({ page }) => {
+    await page.goto('/Sandbox');
+
+    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+    await page.getByTestId('open-page-template-modal-btn').click();
+    expect(page.getByTestId('page-template-modal')).toBeVisible();
+
+    await page.getByTestId('template-button-children').click();
+
+    await appendTextToEditorUntilContains(page, templateBody1);
+    await page.getByTestId('save-page-btn').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(templateBody1);
+  });
+
+  test('Template is applied to pages created (template for children)', async({ page }) => {
+    await page.goto('/Sandbox');
+
+    await page.getByTestId('grw-page-create-button').click();
+
+    await expect(page.locator('.cm-content')).toContainText(templateBody1);
+    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody1);
+  });
+
+  test('Successfully created template for descendants', async({ page }) => {
+    await page.goto('/Sandbox');
+
+    await expect(page.getByTestId('grw-contextual-sub-nav')).toBeVisible();
+    await page.getByTestId('grw-contextual-sub-nav').getByTestId('open-page-item-control-btn').click();
+    await page.getByTestId('open-page-template-modal-btn').click();
+    expect(page.getByTestId('page-template-modal')).toBeVisible();
+
+    await page.getByTestId('template-button-descendants').click();
+
+    await appendTextToEditorUntilContains(page, templateBody2);
+    await page.getByTestId('save-page-btn').click();
+
+    await expect(page.locator('.wiki').first()).toContainText(templateBody2);
+  });
+
+  test('Template is applied to pages created (template for descendants)', async({ page }) => {
+    await page.goto('/Sandbox/Bootstrap5');
+
+    await page.getByTestId('grw-page-create-button').click();
+
+    await expect(page.locator('.cm-content')).toContainText(templateBody2);
+    await expect(page.getByTestId('page-editor-preview-body')).toContainText(templateBody2);
+  });
+});

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

+ 27 - 0
apps/app/playwright/23-editor/template-modal.spec.ts

@@ -0,0 +1,27 @@
+import { test, expect } from '@playwright/test';
+
+test('Successfully select template and template locale', async({ page }) => {
+  const jaText = '今日の目標';
+  const enText = "TODAY'S GOALS";
+  await page.goto('/Sandbox/TemplateModal');
+
+  // move to edit mode
+  await page.getByTestId('editor-button').click();
+  await expect(page.getByTestId('grw-editor-navbar-bottom')).toBeVisible();
+
+  // open TemplateModal
+  const templateModal = page.getByTestId('template-modal');
+  await page.getByTestId('open-template-button').click();
+  await expect(templateModal).toBeVisible();
+
+  // select template and template locale
+  await templateModal.locator('.list-group-item').nth(0).click();
+  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(enText);
+  await templateModal.getByTestId('select-locale-dropdown-toggle').click();
+  await templateModal.getByTestId('select-locale-dropdown-item').nth(1).click();
+  await expect(templateModal.locator('.card-body').locator('.has-data-line').nth(1)).toHaveText(jaText);
+
+  // insert
+  await templateModal.locator('.btn-primary').click();
+  await expect(page.locator('.has-data-line').nth(1)).toHaveText(jaText);
+});

+ 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>
     <style>
-      .group1 { fill: #74bc46; }
-      .group2 { fill: #175fa5; }
+      .group1 { fill: #7AD340; }
+      .group2 { fill: #428DD1; }
     </style>
   </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>

+ 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_plural": "More than {{count}} years has passed since last update.",
       "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": {
@@ -707,36 +709,6 @@
     "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."
   },
-  "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",
     "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_plural": "Plus de {{count}} années sont passées depuis la dernière mise à jour.",
       "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": {
@@ -701,36 +703,6 @@
     "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."
   },
-  "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": "Mode 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": "このページの閲覧は制限されています",
       "stale": "このページは最終更新日から{{count}}年以上が経過しています。",
       "expiration": "この共有パーマリンクの有効期限は <strong>{{expiredAt}}</strong> です。",
-      "no_deadline": "このページに有効期限は設定されていません。"
+      "no_deadline": "このページに有効期限は設定されていません。",
+      "not_indexed1": "このページは全文検索エンジンにインデックスされない可能性があります.",
+      "not_indexed2": "ページ本文が閾値を超えています: {{threshold}}."
     }
   },
   "page_edit": {
@@ -740,36 +742,6 @@
     "password_and_confirm_password_does_not_match": "パスワードと確認パスワードが一致しません",
     "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": "メンテナンスモード",
     "growi_is_under_maintenance": "GROWI はメンテナンス中です。終了するまでお待ちください",

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

@@ -311,7 +311,9 @@
       "restricted": "访问此页受到限制",
       "stale": "自上次更新以来,已超过{{count}年。",
       "stale_plural": "自上次更新以来已过去{{count}年以上。",
-      "no_deadline": "This page has no expiration date"
+      "no_deadline": "此页面没有到期日期",
+      "not_indexed1": "此页面可能不会被全文搜索引擎索引。",
+      "not_indexed2": "页面正文超过了{{threshold}}指定的阈值。"
 		}
 	},
 	"page_edit": {
@@ -710,36 +712,6 @@
     "password_and_confirm_password_does_not_match": "密码和确认密码不匹配",
     "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": "维护模式",
     "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();
 
   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();
 
   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>
       </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>
         <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')}

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

@@ -14,11 +14,13 @@ const GROUPS_PAGE = [
 ];
 const GROUPS_USER = [
   'users', 'externalaccounts', 'usergroups', 'usergrouprelations',
-  'useruisettings', 'editorsettings', 'bookmarks', 'subscriptions',
+  'externalusergroups', 'externalusergrouprelations',
+  'useruisettings', 'editorsettings', 'bookmarks', 'bookmarkfolders', 'subscriptions',
   'inappnotificationsettings',
 ];
 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);
 
@@ -102,8 +104,14 @@ const SelectCollectionsModal = (props: Props): JSX.Element => {
 
     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]);
 
   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 = [
-  'sessions', 'rlflx', 'activities',
+  'sessions', 'rlflx', 'activities', 'yjs-writings', 'transferkeys',
 ];
 
 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={`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 */

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

@@ -62,7 +62,9 @@ export const DeleteCommentModal = (props: DeleteCommentModalProps): JSX.Element
     return (
       <>
         <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,
 } from 'react';
 
+import path from 'path';
+
+
 import { Origin } from '@growi/core';
 import { pagePathUtils, pathUtils } from '@growi/core/dist/utils';
+import { normalizePath } from '@growi/core/dist/utils/path-utils';
 import { format } from 'date-fns/format';
 import { useTranslation } from 'next-i18next';
 import {
@@ -33,14 +37,13 @@ const PageCreateModal: React.FC = () => {
 
   const { data: pageCreateModalData, close: closeCreateModal } = usePageCreateModal();
 
-  const path = pageCreateModalData?.path;
   const isOpened = pageCreateModalData?.isOpened ?? false;
 
   const { create } = useCreatePage();
   const { createTemplate } = useCreateTemplatePage();
 
   const { data: isReachable } = useIsSearchServiceReachable();
-  const pathname = path || '';
+  const pathname = pageCreateModalData?.path ?? '';
   const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
@@ -106,16 +109,19 @@ const PageCreateModal: React.FC = () => {
    * access input page
    */
   const createInputPage = useCallback(async() => {
+    const targetPath = normalizePath(pageNameInput);
+    const parentPath = path.dirname(targetPath);
+
     return create(
       {
-        path: pageNameInput,
-        parentPath: pathname,
+        path: targetPath,
+        parentPath,
         wip: true,
         origin: Origin.View,
       },
       { onTerminated: closeCreateModal },
     );
-  }, [closeCreateModal, create, pageNameInput, pathname]);
+  }, [closeCreateModal, create, pageNameInput]);
 
   /**
    * 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(() => {
     setEditedPagePath(currentPagePath);
-    if (isUntitledPage && editorMode === EditorMode.Editor) {
-      setRenameInputShown(true);
+    if (isUntitledPage != null) {
+      setRenameInputShown(isUntitledPage && editorMode === EditorMode.Editor);
     }
   }, [currentPage._id, currentPagePath, editorMode, isUntitledPage]);
 

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

@@ -1,7 +1,9 @@
 import { useMemo } from 'react';
 
 import type { IRevisionHasPageId } from '@growi/core';
+import { GrowiThemeSchemeType } from '@growi/core';
 import { returnPathForURL } from '@growi/core/dist/utils/path-utils';
+import { PresetThemesMetadatas } from '@growi/preset-themes';
 import { createPatch } from 'diff';
 import type { Diff2HtmlConfig } from 'diff2html';
 import { html } from 'diff2html';
@@ -10,14 +12,19 @@ import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
 import urljoin from 'url-join';
 
+
 import { Themes, useNextThemes } from '~/stores-universal/use-next-themes';
 
 import UserDate from '../../../components/User/UserDate';
+import { useSWRxGrowiThemeSetting } from '../../../stores/admin/customize';
+
 
 import styles from './RevisionDiff.module.scss';
 
 import 'diff2html/bundles/css/diff2html.min.css';
 
+const moduleClass = styles['revision-diff-container'];
+
 type RevisioinDiffProps = {
   currentRevision: IRevisionHasPageId,
   previousRevision: IRevisionHasPageId,
@@ -34,10 +41,26 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
     currentRevision, previousRevision, revisionDiffOpened, currentPageId, currentPagePath, onClose,
   } = props;
 
-  const { theme } = useNextThemes();
+  const { theme: userTheme } = useNextThemes();
+  const { data: growiTheme } = useSWRxGrowiThemeSetting();
 
   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:
         return ColorSchemeType.DARK;
       case Themes.LIGHT:
@@ -45,7 +68,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
       default:
         return ColorSchemeType.AUTO;
     }
-  }, [theme]);
+  }, [growiTheme, userTheme]);
 
   const previousText = (currentRevision._id === previousRevision._id) ? '' : previousRevision.body;
 
@@ -66,7 +89,7 @@ export const RevisionDiff = (props: RevisioinDiffProps): JSX.Element => {
   const diffView = { __html: diffViewHTML };
 
   return (
-    <div className={`${styles['revision-diff-container']}`}>
+    <div className={moduleClass}>
       <div className="container">
         <div className="row mt-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';
 
+const moduleClass = styles['grw-presentation-modal'] ?? '';
+
 
 const Presentation = dynamic<PresentationProps>(() => import('./Presentation/Presentation').then(mod => mod.Presentation), {
   ssr: false,
@@ -71,7 +73,7 @@ const PagePresentationModal = (): JSX.Element => {
       isOpen={isOpen}
       toggle={closeHandler}
       data-testid="page-presentation-modal"
-      className={`grw-presentation-modal ${styles['grw-presentation-modal']}`}
+      className={moduleClass}
     >
       <div className="grw-presentation-controls d-flex">
         <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 />
       </ModalBody>
       <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')}
         </button>
       </ModalFooter>

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

@@ -17,7 +17,7 @@ const { encodeSpaces } = pagePathUtils;
 const DropdownItemContents = ({ title, contents }) => (
   <>
     <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>
   </>
 );
 

+ 9 - 1
apps/app/src/client/components/TemplateModal/TemplateModal.tsx

@@ -247,13 +247,21 @@ const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element
               </div>
               <div className="col-6 d-flex justify-content-end">
                 <UncontrolledDropdown>
-                  <DropdownToggle caret type="button" outline className="float-end" disabled={selectedTemplateSummary == null}>
+                  <DropdownToggle
+                    caret
+                    type="button"
+                    outline
+                    className="float-end"
+                    disabled={selectedTemplateSummary == null}
+                    data-testid="select-locale-dropdown-toggle"
+                  >
                     <span className="float-start">{selectedTemplateLocale != null ? selectedTemplateLocale : t('Language')}</span>
                   </DropdownToggle>
                   <DropdownMenu className="dropdown-menu" role="menu">
                     { selectedTemplateLocales != null && Array.from(selectedTemplateLocales).map((locale) => {
                       return (
                         <DropdownItem
+                          data-testid="select-locale-dropdown-item"
                           key={locale}
                           onClick={() => setSelectedTemplateLocale(locale)}
                         >

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

@@ -1,6 +1,7 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
+import * as presentation from '@growi/presentation/dist/client/services/sanitize-option';
 import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client';
 import * as drawio from '@growi/remark-drawio';
 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 * as mermaid from '~/features/mermaid';
 import type { RendererOptions } from '~/interfaces/renderer-options';
-import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import * as addLineNumberAttribute from '~/services/renderer/rehype-plugins/add-line-number-attribute';
 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 xsvToTable from '~/services/renderer/remark-plugins/xsv-to-table';
 import {
-  commonSanitizeOption, generateCommonOptions, injectCustomSanitizeOption, verifySanitizePlugin,
+  getCommonSanitizeOption, generateCommonOptions, verifySanitizePlugin,
 } from '~/services/renderer/renderer';
 import loggerFactory from '~/utils/logger';
 
@@ -71,13 +71,10 @@ export const generateViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
+      presentation.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
@@ -132,14 +129,9 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   // add remark plugins
   // remarkPlugins.push();
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
     )]
     : () => {};
 
@@ -184,14 +176,10 @@ export const generateSimpleViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
+      presentation.sanitizeOption,
       drawio.sanitizeOption,
       mermaid.sanitizeOption,
       attachment.sanitizeOption,
@@ -277,13 +265,9 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
-      commonSanitizeOption,
+      getCommonSanitizeOption(config),
       drawio.sanitizeOption,
       mermaid.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"
       width="32"
       height="32"
-      viewBox="0 0 226.44 196.11"
+      viewBox="0 0 64 56"
     >
       <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"
-      >
-      </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
-        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"
-      >
-      </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"
-      >
-      </path>
+      />
     </svg>
   </div>
 ));

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

@@ -25,7 +25,7 @@
     }
 
     .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 React from 'react';
 
+import Image from 'next/image';
+
 import { useAppTitle } from '~/stores-universal/context';
 
 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="d-flex justify-content-center align-items-center">
                   <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>
                 {appTitle !== 'GROWI' ? (
                   <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 { useIsNotFound } from '~/stores/page';
@@ -9,6 +7,7 @@ import { PageGrantAlert } from './PageGrantAlert';
 import { PageStaleAlert } from './PageStaleAlert';
 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 FixPageGrantAlert = dynamic(() => import('./FixPageGrantAlert').then(mod => mod.FixPageGrantAlert), { 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">
         {/* alerts */}
         { !isNotFound && <FixPageGrantAlert /> }
+        <FullTextSearchNotCoverAlert />
         <WipPageAlert />
         <PageGrantAlert />
         <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 FindCommentsByRevisionId = (revisionId: Types.ObjectId) => Query<CommentDocument[], CommentDocument>;
 type FindCreatorsByPage = (pageId: Types.ObjectId) => Promise<IUser[]>
-type GetPageIdToCommentMap = (pageIds: Types.ObjectId[]) => Promise<Record<string, CommentDocument[]>>
 type CountCommentByPageId = (pageId: Types.ObjectId) => Promise<number>
 
 export interface CommentModel extends Model<CommentDocument> {
@@ -35,7 +34,6 @@ export interface CommentModel extends Model<CommentDocument> {
   findCommentsByPageId: FindCommentsByPageId
   findCommentsByRevisionId: FindCommentsByRevisionId
   findCreatorsByPage: FindCreatorsByPage
-  getPageIdToCommentMap: GetPageIdToCommentMap
   countCommentByPageId: CountCommentByPageId
 }
 
@@ -91,23 +89,6 @@ commentSchema.statics.findCreatorsByPage = async function(page) {
   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) {
   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
-export const defaultConfigWithRegExp = {
+export const defaultConfigWithRegExp: IApiRateLimitEndpointMap = {
+  ...defaultConfigWithRegExpForDev,
+
   '/forgot-password/.*': {
     method: 'ALL',
     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 {
-  defaultConfig, defaultConfigWithRegExp, IApiRateLimitEndpointMap,
+  defaultConfig, defaultConfigWithRegExp,
 } from '../config';
 
 const envVar = process.env;

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

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

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

@@ -1,6 +1,7 @@
 /* eslint-disable @next/next/google-font-display */
 import React from 'react';
 
+import { Lang } from '@growi/core';
 import type { DocumentContext, DocumentInitialProps } from 'next/document';
 import Document, {
   Html, Head, Main, NextScript,
@@ -8,8 +9,12 @@ import Document, {
 
 import type { GrowiPluginResourceEntries } from '~/features/growi-plugin/server/services';
 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 { getLocateAtServerSide } from './utils/commons';
+
 const logger = loggerFactory('growi:page:_document');
 
 type HeadersForGrowiPluginProps = {
@@ -41,14 +46,24 @@ interface GrowiDocumentProps {
   customCss: string | null,
   customNoscript: string | null,
   pluginResourceEntries: GrowiPluginResourceEntries;
+  locale: string;
 }
 declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
 
 class GrowiDocument extends Document<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 { crowi } = ctx.req as CrowiRequest;
+    const req = ctx.req as CrowiRequest;
+    const { crowi } = req;
     const { customizeService } = crowi;
 
     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 pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
+    const locale = langMap[getLocateAtServerSide(req)];
+
     return {
       ...initialProps,
       themeHref,
@@ -67,6 +84,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
       customCss,
       customNoscript,
       pluginResourceEntries,
+      locale,
     };
   }
 
@@ -95,10 +113,11 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const {
       customCss, customScript, customNoscript,
       themeHref, pluginResourceEntries,
+      locale,
     } = this.props;
 
     return (
-      <Html>
+      <Html lang={locale}>
         <Head>
           {this.renderCustomScript(customScript)}
           <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 };
 };
 
+
+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(
     // 'serverSideTranslations' method should be given from Next.js Page
     //  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,
 ): Promise<SSRConfig> => {
 
-  const req: CrowiRequest = context.req as CrowiRequest;
-  const { crowi, user, headers } = req;
-  const { configManager } = crowi;
-
   // 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'];
   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 {
   Types, Document, Model, SortOrder,
 } from 'mongoose';
@@ -15,10 +14,7 @@ import {
 } from '~/interfaces/activity';
 
 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');
@@ -34,8 +30,6 @@ export interface ActivityDocument extends Document {
   event: Types.ObjectId
   action: SupportedActionType
   snapshot: ISnapshot
-
-  getNotificationTargetUsers(): Promise<any[]>
 }
 
 export interface ActivityModel extends Model<ActivityDocument> {
@@ -98,18 +92,6 @@ activitySchema.post('save', function() {
   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> {
   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] });
   };
 
-  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() {
     // TODO
 

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

@@ -1,6 +1,6 @@
 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 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 { getOrCreateModel } from '../util/mongoose-utils';
 
-import type { IdToNameMap } from './tag';
+import type { IdToNameMap, IdToNamesMap } from './tag';
 import Tag from './tag';
 
 
-const ObjectId = mongoose.Schema.Types.ObjectId;
-
-
 // disable no-return-await for model functions
 /* eslint-disable no-return-await */
 
@@ -36,7 +33,7 @@ type 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>
 
@@ -54,13 +51,13 @@ export interface PageTagRelationModel extends Model<PageTagRelationDocument> {
  */
 const schema = new mongoose.Schema<PageTagRelationDocument, PageTagRelationModel>({
   relatedPage: {
-    type: ObjectId,
+    type: mongoose.Schema.Types.ObjectId,
     ref: 'Page',
     required: true,
     index: true,
   },
   relatedTag: {
-    type: ObjectId,
+    type: mongoose.Schema.Types.ObjectId,
     ref: 'Tag',
     required: 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 IdToNamesMap = {[key: string] : string[] }
 
 export interface TagModel extends Model<TagDocument>{
   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';
 
 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');
 

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

@@ -282,6 +282,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type:    ValueType.BOOLEAN,
     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',

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

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

+ 1 - 0
apps/app/src/server/service/page/page-service.ts

@@ -32,4 +32,5 @@ export interface IPageService {
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
   ): boolean,
   getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
+  hasRevisionBodyDiff(pageId: string, comparisonTarget?: string): Promise<boolean>,
 }

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

+ 33 - 135
apps/app/src/server/service/search-delegator/elasticsearch.ts

@@ -1,11 +1,11 @@
 import { Writable, Transform } from 'stream';
 import { URL } from 'url';
 
+import { getIdForRef, type IPage } from '@growi/core';
 import gc from 'expose-gc/function';
 import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 
-import { Comment } from '~/features/comment/server';
 import { SearchDelegatorName } from '~/interfaces/named-query';
 import type { ISearchResult, ISearchResultData } from '~/interfaces/search';
 import { SORT_AXIS, SORT_ORDER } from '~/interfaces/search';
@@ -22,6 +22,8 @@ import { configManager } from '../config-manager';
 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';
 
 const logger = loggerFactory('growi:service:search-delegator:elasticsearch');
@@ -109,10 +111,6 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     return `${this.indexName}-alias`;
   }
 
-  shouldIndexed(page) {
-    return page.revision != null;
-  }
-
   initClient() {
     const { host, auth, indexName } = this.getConnectionInfo();
 
@@ -128,7 +126,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     this.indexName = indexName;
   }
 
-  getType() {
+  getType(): '_doc' | undefined {
     return this.isElasticsearchV7 ? '_doc' : undefined;
   }
 
@@ -358,20 +356,9 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   /**
    * 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 {
       grant: page.grant,
@@ -380,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 = {
       index: {
@@ -393,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,
       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,
       updated_at: page.updatedAt,
       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) {
@@ -456,91 +435,29 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
   async updateOrInsertPages(queryFactory, option: UpdateOrInsertPagesOpts = {}) {
     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 Bookmark = mongoose.model('Bookmark') as any; // TODO: typescriptize model
 
     const socket = shouldEmitProgress ? this.socketIoService.getAdminSocket() : null;
 
     // prepare functions invoked from custom streams
     const prepareBodyForCreate = this.prepareBodyForCreate.bind(this);
-    const shouldIndexed = this.shouldIndexed.bind(this);
     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 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 maxBodyLengthToIndex = configManager.getConfig('crowi', 'app:elasticsearchMaxBodyLengthToIndex');
+
+    const readStream = Page.aggregate<AggregatedPage>(
+      aggregatePipelineToIndex(maxBodyLengthToIndex, matchQuery),
+    ).cursor();
 
     const bulkSize: number = configManager.getConfig('crowi', 'app:elasticsearchReindexBulkSize');
     const batchStream = createBatchStream(bulkSize);
 
-    const appendBookmarkCountStream = new Transform({
-      objectMode: true,
-      async transform(chunk, encoding, callback) {
-        const pageIds = chunk.map(doc => doc._id);
-
-        const idToCountMap = await Bookmark.getPageIdToCountMap(pageIds);
-        const idsHavingCount = Object.keys(idToCountMap);
-
-        // 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 appendTagNamesStream = new Transform({
       objectMode: true,
       async transform(chunk, encoding, callback) {
@@ -552,7 +469,7 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         // append tagNames
         chunk
           .filter(doc => idsHavingTagNames.includes(doc._id.toString()))
-          .forEach((doc) => {
+          .forEach((doc: AggregatedPage) => {
             // append tagName from idToTagNamesMap
             doc.tagNames = idToTagNamesMap[doc._id.toString()];
           });
@@ -566,8 +483,10 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
     const writeStream = new Writable({
       objectMode: true,
       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 {
           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)`);
 
           if (shouldEmitProgress) {
-            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count, skipped });
+            socket?.emit(SocketEventName.AddPageProgress, { totalCount, count });
           }
         }
         catch (err) {
@@ -601,20 +520,17 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
         callback();
       },
       final(callback) {
-        logger.info(`Adding pages has completed: (totalCount=${totalCount}, skipped=${skipped})`);
+        logger.info(`Adding pages has completed: (totalCount=${totalCount})`);
 
         if (shouldEmitProgress) {
-          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count, skipped });
+          socket?.emit(SocketEventName.FinishAddPage, { totalCount, count });
         }
         callback();
       },
     });
 
     readStream
-      .pipe(thinOutStream)
       .pipe(batchStream)
-      .pipe(appendBookmarkCountStream)
-      .pipe(appendCommentStream)
       .pipe(appendTagNamesStream)
       .pipe(writeStream);
 
@@ -977,30 +893,12 @@ class ElasticsearchDelegator implements SearchDelegator<Data, ESTermsKey, ESQuer
 
   async syncPageUpdated(page, user) {
     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);
   }
 
   // remove pages whitch should nod Indexed
   async syncPagesUpdated(pages, user) {
     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
     try {

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

@@ -1,11 +1,12 @@
 import {
   inputBlock, actionsBlock, buttonElement, markdownSectionBlock,
 } 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';
 
 const logger = loggerFactory('growi:service:SlackBotService:keep');
-const { parse, format } = require('date-fns');
 
 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,
 } from '@growi/slack/dist/utils/block-kit-builder';
 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';
 
 // eslint-disable-next-line no-unused-vars
 const logger = loggerFactory('growi:service:SlackBotService:togetter');
 
-const { parse, format } = require('date-fns');
 
 const { SlackCommandHandlerError } = require('../../models/vo/slack-command-handler-error');
 
@@ -163,7 +164,7 @@ module.exports = (crowi) => {
         // include header
         else {
           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`);
           lastMessage = message;
         }

+ 34 - 18
apps/app/src/server/service/socket-io.js → apps/app/src/server/service/socket-io.ts

@@ -1,29 +1,44 @@
+import type { IncomingMessage } from 'http';
+
+import type { IUserHasId } from '@growi/core/dist/interfaces';
 import { GlobalSocketEventName } from '@growi/core/dist/interfaces';
+import expressSession from 'express-session';
+import passport from 'passport';
+import type { Namespace } from 'socket.io';
 import { Server } from 'socket.io';
+import type { Document } from 'y-socket.io/dist/server';
 
 import { SocketEventName } from '~/interfaces/websocket';
 import loggerFactory from '~/utils/logger';
 
+import type Crowi from '../crowi';
 import { RoomPrefix, getRoomNameWithId } from '../util/socket-io-helpers';
 
+import { configManager } from './config-manager';
 import { getYjsConnectionManager, extractPageIdFromYdocId } from './yjs-connection-manager';
 
 
-const expressSession = require('express-session');
-const passport = require('passport');
-
 const logger = loggerFactory('growi:service:socket-io');
 
 
+type RequestWithUser = IncomingMessage & { user: IUserHasId };
+
 /**
  * Serve socket.io for server-to-client messaging
  */
 class SocketIoService {
 
+  crowi: Crowi;
+
+  guestClients: Set<string>;
+
+  io: Server;
+
+  adminNamespace: Namespace;
+
+
   constructor(crowi) {
     this.crowi = crowi;
-    this.configManager = crowi.configManager;
-
     this.guestClients = new Set();
   }
 
@@ -33,11 +48,9 @@ class SocketIoService {
 
   // Since the Order is important, attachServer() should be async
   async attachServer(server) {
-    this.io = new Server({
-      transports: ['websocket'],
+    this.io = new Server(server, {
       serveClient: false,
     });
-    this.io.attach(server);
 
     // create namespace for admin
     this.adminNamespace = this.io.of('/admin');
@@ -128,7 +141,7 @@ class SocketIoService {
 
   setupStoreGuestIdEventHandler() {
     this.io.on('connection', (socket) => {
-      if (socket.request.user == null) {
+      if ((socket.request as RequestWithUser).user == null) {
         this.guestClients.add(socket.id);
 
         socket.on('disconnect', () => {
@@ -140,7 +153,7 @@ class SocketIoService {
 
   setupLoginedUserRoomsJoinOnConnection() {
     this.io.on('connection', (socket) => {
-      const user = socket.request.user;
+      const user = (socket.request as RequestWithUser).user;
       if (user == null) {
         logger.debug('Socket io: An anonymous user has connected');
         return;
@@ -171,9 +184,12 @@ class SocketIoService {
 
     this.io.on('connection', (socket) => {
 
-      yjsConnectionManager.ysocketioInstance.on('awareness-update', async(update) => {
-        const pageId = extractPageIdFromYdocId(update.name);
-        const awarenessStateSize = update.awareness.states.size;
+      yjsConnectionManager.ysocketioInstance.on('awareness-update', async(doc: Document) => {
+        const pageId = extractPageIdFromYdocId(doc.name);
+
+        if (pageId == null) return;
+
+        const awarenessStateSize = doc.awareness.states.size;
 
         // Triggered when awareness changes
         this.io
@@ -207,12 +223,12 @@ class SocketIoService {
     const namespaceName = socket.nsp.name;
 
     if (namespaceName === '/admin') {
-      const clients = await this.getAdminSocket().allSockets();
+      const clients = await this.getAdminSocket().fetchSockets();
       const clientsCount = clients.length;
 
       logger.debug('Current count of clients for \'/admin\':', clientsCount);
 
-      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
+      const limit = configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForAdmin');
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for '/admin' is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
@@ -231,7 +247,7 @@ class SocketIoService {
 
       logger.debug('Current count of clients for guests:', clientsCount);
 
-      const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
+      const limit = configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimitForGuest');
       if (limit <= clientsCount) {
         const msg = `The connection was refused because the current count of clients for guests is ${clientsCount} and exceeds the limit`;
         logger.warn(msg);
@@ -253,12 +269,12 @@ class SocketIoService {
       next();
     }
 
-    const clients = await this.getDefaultSocket().allSockets();
+    const clients = await this.getDefaultSocket().fetchSockets();
     const clientsCount = clients.length;
 
     logger.debug('Current count of clients for \'/\':', clientsCount);
 
-    const limit = this.configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
+    const limit = configManager.getConfig('crowi', 's2cMessagingPubsub:connectionsLimit');
     if (limit <= clientsCount) {
       const msg = `The connection was refused because the current count of clients for '/' is ${clientsCount} and exceeds the limit`;
       logger.warn(msg);

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

@@ -38,22 +38,28 @@ const logger = loggerFactory('growi:services:renderer');
 
 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 => {
   if (!Array.isArray(pluggable) || pluggable.length < 2) {
     return false;
@@ -131,13 +137,9 @@ export const generateSSRViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  if (config.sanitizeType === RehypeSanitizeType.CUSTOM) {
-    injectCustomSanitizeOption(config);
-  }
-
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [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);
 };
 
+export const useElasticsearchMaxBodyLengthToIndex = (initialData?: number) : SWRResponse<number, Error> => {
+  return useContextSWR('elasticsearchMaxBodyLengthToIndex', initialData);
+};
+
 export const useIsMailerSetup = (initialData?: boolean): SWRResponse<boolean, any> => {
   return useContextSWR('isMailerSetup', initialData);
 };

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

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

+ 0 - 339
apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -1,339 +0,0 @@
-function openEditor() {
-  cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-  cy.waitUntil(() => {
-    // do
-    cy.get('@pageEditorModeManager').within(() => {
-      cy.get('button:nth-child(2)').click();
-    });
-    // until
-    return cy.get('.layout-root').then($elem => $elem.hasClass('editing'));
-  });
-  cy.get('.CodeMirror').should('be.visible');
-}
-
-context('Access to page', () => {
-  const ssPrefix = 'access-to-page-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  // TODO: https://redmine.weseek.co.jp/issues/109939
-  it('/Sandbox with anchor hash is successfully loaded', () => {
-    cy.visit('/Sandbox#headers');
-    cy.collapseSidebar(true);
-
-    // for check download toc data
-    // https://redmine.weseek.co.jp/issues/111384
-    // cy.get('.toc-link').should('be.visible');
-
-    // assert the element is in viewport
-    cy.get('#headers').should('be.inViewport');
-
-    // remove animation for screenshot
-    // remove 'blink' class because ::after element cannot be operated
-    // https://stackoverflow.com/questions/5041494/selecting-and-manipulating-css-pseudo-elements-such-as-before-and-after-usin/21709814#21709814
-    cy.get('#headers').invoke('removeClass', 'blink');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-sandbox-headers`);
-  });
-
-  it('/Sandbox/Math is successfully loaded', () => {
-    cy.visit('/Sandbox/Math');
-    cy.collapseSidebar(true);
-
-    // for check download toc data
-    // https://redmine.weseek.co.jp/issues/111384
-    // cy.get('.toc-link').should('be.visible');
-
-    cy.get('.math').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-sandbox-math`);
-  });
-
-  it('/Sandbox with edit is successfully loaded', () => {
-    cy.visit('/Sandbox#edit');
-    cy.collapseSidebar(true);
-
-    cy.getByTestid('navbar-editor').should('be.visible');
-    cy.get('.grw-editor-navbar-bottom').should('be.visible');
-    cy.getByTestid('save-page-btn').should('be.visible');
-    cy.get('.grw-grant-selector').should('be.visible');
-
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-Sandbox-edit-page`);
-  })
-
-  const body1 = 'hello';
-  const body2 = ' world!';
-  it('Edit and save with save-page-btn', () => {
-    cy.visit('/Sandbox/testForUseEditingMarkdown');
-
-    openEditor();
-
-    // check edited contents after save
-    cy.appendTextToEditorUntilContains(body1);
-    cy.get('.page-editor-preview-body').should('contain.text', body1);
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').click();
-    cy.get('.wiki').should('be.visible');
-    cy.get('.wiki').children().first().should('have.text', body1);
-    cy.screenshot(`${ssPrefix}-edit-and-save-with-save-page-btn`);
-  })
-
-  it('Edit and save with shortcut key', () => {
-    const savePageShortcutKey = '{ctrl+s}';
-
-    cy.visit('/Sandbox/testForUseEditingMarkdown');
-
-    openEditor();
-
-    // check editing contents with shortcut key
-    cy.appendTextToEditorUntilContains(body2);
-    cy.get('.page-editor-preview-body').should('contain.text', body1+body2);
-    cy.get('.CodeMirror').click().type(savePageShortcutKey);
-    cy.get('.CodeMirror-code').should('contain.text', body1+body2);
-    cy.get('.page-editor-preview-body').should('contain.text', body1+body2);
-    cy.screenshot(`${ssPrefix}-edit-and-save-with-shortcut-key`);
-  })
-
-  it('/user/admin is successfully loaded', () => {
-    cy.visit('/user/admin');
-    cy.collapseSidebar(true);
-
-    // for check download toc data
-    // https://redmine.weseek.co.jp/issues/111384
-    // cy.get('.toc-link').should('be.visible');
-
-    // eslint-disable-next-line cypress/no-unnecessary-waiting
-    cy.wait(2000); // wait for calcViewHeight and rendering
-    cy.waitUntilSkeletonDisappear();
-    cy.screenshot(`${ssPrefix}-user-admin`);
-  });
-
-});
-
-
-context('Access to special pages', () => {
-  const ssPrefix = 'access-to-special-pages-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('/trash is successfully loaded', () => {
-    cy.visit('/trash');
-
-    cy.getByTestid('trash-page-list').should('contain.text', 'There are no pages under this page.');
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-trash`);
-  });
-
-  it('/tags is successfully loaded', { scrollBehavior: false } ,() => {
-    // open sidebar
-    // cy.collapseSidebar(false);
-
-    cy.visit('/tags');
-
-    // cy.getByTestid('grw-sidebar-content-tags').within(() => {
-    //   cy.getByTestid('grw-tags-list').should('be.visible');
-    //   cy.getByTestid('grw-tags-list').contains('You have no tag, You can set tags on pages');
-    // })
-
-    cy.getByTestid('tags-page').within(() => {
-      cy.getByTestid('grw-tags-list').should('be.visible');
-      cy.getByTestid('grw-tags-list').should('contain.text', 'You have no tag, You can set tags on pages');
-    });
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-tags`);
-  });
-
-});
-
-context('Access to Template Editing Mode', () => {
-  const ssPrefix = 'access-to-template-page-';
-  const templateBody1 = 'Template for children';
-  const templateBody2 = 'Template for descendants';
-
-  const createPageFromPageTreeTest = (newPagePath: string, parentPagePath: string, expectedBody: string) => {
-    cy.visit('/');
-    cy.waitUntilSkeletonDisappear();
-
-    // Open sidebar
-    cy.collapseSidebar(false);
-    cy.getByTestid('grw-sidebar-contents').should('be.visible');
-    cy.waitUntilSkeletonDisappear();
-
-    // If PageTree is not active when the sidebar is opened, make it active
-    cy.getByTestid('grw-sidebar-nav-primary-page-tree').should('be.visible')
-      .then($elem => {
-        if (!$elem.hasClass('active')) {
-          cy.getByTestid('grw-sidebar-nav-primary-page-tree').click();
-        }
-      });
-
-    // Create page (/{parentPath}}/{newPagePath}) from PageTree
-    cy.getByTestid('grw-sidebar-contents').within(() => {
-      cy.get('.grw-pagetree-item-children').first().as('pagetreeItem').within(() => {
-        cy.get('#page-create-button-in-page-tree').first().click({force: true})
-      });
-    });
-    cy.get('@pagetreeItem').within(() => {
-      cy.getByTestid('autosize-submittable-input').type(newPagePath).type('{enter}');
-    })
-
-    cy.visit(`/${parentPagePath}/${newPagePath}`);
-    cy.collapseSidebar(true);
-
-    cy.getByTestid('grw-contextual-sub-nav').should('be.visible');
-    cy.waitUntilSkeletonDisappear();
-
-    // Check if the template is applied
-    cy.getByTestid('search-result-base').within(() => {
-      cy.get('.wiki').should('be.visible');
-      cy.get('.wiki').children().first().should('have.text', expectedBody);
-    })
-
-    cy.screenshot(`${ssPrefix}-page(${newPagePath})-to-which-template-is-applied`)
-  }
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it("Successfully created template for children", () => {
-    cy.visit('/Sandbox');
-    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-template-modal-btn').filter(':visible').click({force: true});
-    cy.getByTestid('page-template-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}-open-page-template-modal`);
-
-    cy.getByTestid('template-button-children').click(({force: true}))
-    cy.waitUntilSkeletonDisappear();
-
-    cy.getByTestid('navbar-editor').should('be.visible').then(()=>{
-      cy.url().should('include', '/_template#edit');
-      cy.screenshot(`${ssPrefix}-open-template-page-for-children-in-editor-mode`);
-    });
-
-    cy.appendTextToEditorUntilContains(templateBody1);
-    cy.get('.page-editor-preview-body').should('contain.text', templateBody1);
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').click();
-  });
-
-  it('Template is applied to pages created from PageTree (template for children 1)', () => {
-    createPageFromPageTreeTest('template-test-page1', '/Sandbox' ,templateBody1);
-  });
-
-  it('Successfully created template for descendants', () => {
-    cy.visit('/Sandbox');
-    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-template-modal-btn').filter(':visible').click({force: true});
-    cy.getByTestid('page-template-modal').should('be.visible');
-
-    cy.getByTestid('template-button-descendants').click(({force: true}))
-    cy.waitUntilSkeletonDisappear();
-
-    cy.getByTestid('navbar-editor').should('be.visible').then(()=>{
-      cy.url().should('include', '/__template#edit');
-      cy.screenshot(`${ssPrefix}-open-template-page-for-descendants-in-editor-mode`);
-    })
-
-    cy.appendTextToEditorUntilContains(templateBody2);
-    cy.get('.page-editor-preview-body').should('contain.text', templateBody2);
-    cy.getByTestid('page-editor').should('be.visible');
-    cy.getByTestid('save-page-btn').click();
-  });
-
-  it('Template is applied to pages created from PageTree (template for children 2)', () => {
-    createPageFromPageTreeTest('template-test-page2','Sandbox',templateBody1);
-  });
-
-  it('Template is applied to pages created from PageTree (template for descendants)', () => {
-    // delete /Sandbox/_template
-    cy.visit('/Sandbox/_template');
-
-    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.intercept('POST', '/_api/pages.remove').as('remove');
-      cy.getByTestid('delete-page-button').click();
-      cy.wait('@remove')
-    });
-
-    createPageFromPageTreeTest('template-test-page3','Sandbox',`${templateBody1}\n${templateBody2}`);
-  })
-});
-
-context('Access to /me/all-in-app-notifications', () => {
-  const ssPrefix = 'in-app-notifications-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it('All In-App Notification list is successfully loaded', { scrollBehavior: false },() => {
-    cy.visit('/');
-    cy.get('.notification-wrapper').click();
-    cy.get('.notification-wrapper > .dropdown-menu > a').click();
-
-    cy.getByTestid('grw-in-app-notification-page').should('be.visible');
-    cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-see-all`);
-
-    cy.get('.grw-custom-nav-tab > div > ul > li:nth-child(2) > a').click();
-    cy.getByTestid('grw-in-app-notification-page-spinner').should('not.exist');
-
-    cy.collapseSidebar(true);
-    cy.screenshot(`${ssPrefix}-see-unread`);
-   });
-
-})

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

+ 0 - 67
apps/app/test/cypress/e2e/23-editor/23-editor--template-modal.cy.ts

@@ -1,67 +0,0 @@
-context('TemplateModal', () => {
-
-  const ssPrefix = 'template-modal-';
-
-  beforeEach(() => {
-    // login
-    cy.fixture("user-admin.json").then(user => {
-      cy.login(user.username, user.password);
-    });
-  });
-
-  it("TemplateModal is shown and closed successfully", () => {
-    cy.visit('/Sandbox/TemplateModal');
-    cy.collapseSidebar(true, true);
-
-    // move to edit mode
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.getByTestid('editor-button').click();
-    cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
-
-    // open TemplateModal
-    cy.getByTestid('open-template-button').click();
-    cy.getByTestid('template-modal').should('be.visible');
-    cy.screenshot(`${ssPrefix}opened`);
-
-    // close TemplateModal
-    cy.getByTestid('template-modal').should('be.visible').within(() => {
-      cy.get('.btn-close').click();
-    });
-    cy.screenshot(`${ssPrefix}close`);
-  });
-
-  it("Successfully select template and template locale", () => {
-    cy.visit('/Sandbox/TemplateModal');
-    cy.collapseSidebar(true, true);
-
-    // move to edit mode
-    cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
-    cy.getByTestid('editor-button').click();
-    cy.getByTestid('grw-editor-navbar-bottom').should('be.visible');
-
-    // open TemplateModal
-    cy.getByTestid('open-template-button').click();
-    cy.getByTestid('template-modal').should('be.visible');
-
-    // select template and template locale
-    cy.getByTestid('template-modal').should('be.visible').within(() => {
-      // select first template
-      cy.get('.list-group > .list-group-item:nth-child(1)').click();
-      // check preview exist
-      cy.get('.card-body').should('be.visible');
-      cy.screenshot(`${ssPrefix}select-template`);
-
-      // change template locale
-      cy.get('.modal-body > div:nth-child(1) > div:nth-child(3) > div:nth-child(1) > div:nth-child(2) > .dropdown > button').click();
-      cy.get('.modal-body > div:nth-child(1) > div:nth-child(3) > div:nth-child(1) > div:nth-child(2) > .dropdown > div > button:nth-child(2)').click();
-      cy.screenshot(`${ssPrefix}select-template-locale`);
-
-      // click insert button
-      cy.get('.modal-footer > button:nth-child(2)').click();
-    });
-
-    // check show template on markdown
-    cy.screenshot(`${ssPrefix}insert-template`);
-  });
-
-});

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

@@ -1,3 +1,3 @@
 // == 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/state": "^6.2.1",
     "@codemirror/view": "^6.15.3",
+    "@emoji-mart/data": "^1.2.1",
+    "@emoji-mart/react": "^1.1.1",
     "@growi/core": "link:../core",
     "@growi/core-styles": "link:../core-styles",
     "@popperjs/core": "^2.11.8",
@@ -51,7 +53,7 @@
     "cm6-theme-nord": "^0.2.0",
     "codemirror": "^6.0.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",
     "markdown-table": "^3.0.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/data';
+declare module '@emoji-mart/react';

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

@@ -1,13 +1,12 @@
 import {
   useState, useCallback,
-  type FC, type CSSProperties,
+  type CSSProperties,
 } 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 'emoji-mart/css/emoji-mart.css';
 import { useCodeMirrorEditorIsolated } from '../../../stores/codemirror-editor';
 import { useResolvedThemeForEditor } from '../../../stores/use-resolved-theme';
 
@@ -15,88 +14,32 @@ type Props = {
   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 [isOpen, setIsOpen] = useState(false);
 
   const { data: codeMirrorEditor } = useCodeMirrorEditorIsolated(editorKey);
   const { data: resolvedTheme } = useResolvedThemeForEditor();
-
-  const view = codeMirrorEditor?.view;
-  const cursorIndex = view?.state.selection.main.head;
   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;
     }
 
-    view?.dispatch({
-      changes: {
-        from: cursorIndex,
-        insert: emoji.colons,
-      },
-    });
+    codeMirrorEditor?.insertText(emoji.shortcodes);
 
     toggle();
-  }, [cursorIndex, isOpen, toggle, view]);
+  }, [isOpen, toggle, codeMirrorEditor]);
+
 
   const setStyle = useCallback((): CSSProperties => {
+
+    const view = codeMirrorEditor?.view;
+    const cursorIndex = view?.state.selection.main.head;
+
     if (view == null || cursorIndex == null || !isOpen) {
       return {};
     }
@@ -123,7 +66,7 @@ export const EmojiButton: FC<Props> = (props) => {
       left: cursorRect.left + offset,
       position: 'fixed',
     };
-  }, [cursorIndex, isOpen, view]);
+  }, [isOpen, codeMirrorEditor]);
 
   return (
     <>
@@ -134,14 +77,15 @@ export const EmojiButton: FC<Props> = (props) => {
       && (
         <div className="mb-2 d-none d-md-block">
           <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>
         </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 { 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 rawEmojiDataArray = emojiData.categories;
@@ -20,7 +20,7 @@ const getEmojiDataArray = (): string[] => {
   const fixedEmojiDataArray: string[] = [];
 
   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) {
       return;
@@ -60,9 +60,7 @@ export const emojiAutocompletionSettings = autocompletion({
   addToOptions: [{
     render: (completion: Completion) => {
       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');
       element.innerHTML = emoji;

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

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

+ 3 - 0
packages/presentation/package.json

@@ -18,6 +18,9 @@
     "./dist/client": {
       "import": "./dist/client/index.js"
     },
+    "./dist/client/services/sanitize-option": {
+      "import": "./dist/client/services/sanitize-option.js"
+    },
     "./dist/services": {
       "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 {
   // workaround from https://github.com/css-modules/css-modules/issues/295#issuecomment-952885628
   &: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 'reveal.js/dist/reveal.css';
-import './Presentation.global.scss';
-
 import styles from './Presentation.module.scss';
 
+const moduleClass = styles['grw-presentation'] ?? '';
+
 
 const baseRevealOptions: Reveal.Options = {
   // 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
  */
 const removeAllHiddenElements = () => {
-  const sections = document.querySelectorAll('.grw-presentation section');
+  const sections = document.querySelectorAll(`${moduleClass} section`);
   sections.forEach(section => section.removeAttribute('hidden'));
 };
 
@@ -59,7 +58,7 @@ export const Presentation = (props: PresentationProps): JSX.Element => {
   }, [children, revealOptions]);
 
   return (
-    <div className={`grw-presentation ${styles['grw-presentation']} reveal`}>
+    <div className={`${moduleClass} reveal`}>
       <Slides options={options} hasMarpFlag={marp} presentation>{children}</Slides>
     </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';
 
 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 = {
-  // 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"
     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"
   resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.7.tgz#f4f0d5530e8dbdf59b3451b9b3e594b6ba082e12"
   integrity sha512-UwgBRMjJP+xv857DCngvqXI3Iq6J4v0wXmwc6sapg+zyhbwmQX67LUEFrkK5tbyJ30jGuG3ZvWpBiB9LCy1kWw==
@@ -1896,6 +1896,16 @@
   dependencies:
     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":
   version "0.20.2"
   resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
@@ -8207,13 +8217,10 @@ emittery@^0.13.1:
   resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"
   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:
   version "8.0.0"
@@ -17377,7 +17384,7 @@ string-template@>=1.0.0:
   resolved "https://registry.yarnpkg.com/string-template/-/string-template-1.0.0.tgz#9e9f2233dc00f218718ec379a28a5673ecca8b96"
   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"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
   integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -17395,15 +17402,6 @@ string-width@=4.2.2:
     is-fullwidth-code-point "^3.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:
   version "5.1.2"
   resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794"
@@ -17487,7 +17485,7 @@ stringify-entities@^4.0.0:
     character-entities-html4 "^2.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"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
   integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -17501,13 +17499,6 @@ strip-ansi@^3.0.0:
   dependencies:
     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:
   version "7.1.0"
   resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45"
@@ -19331,7 +19322,7 @@ word-wrap@^1.2.3:
   resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
   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"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
   integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -19349,15 +19340,6 @@ wrap-ansi@^6.2.0:
     string-width "^4.1.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:
   version "8.1.0"
   resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"