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

Merge branch 'imprv/integrate-customize-user-page-delete' into imprv/105325-123796-integrate-homepage

ryoji-s 2 лет назад
Родитель
Сommit
3f74749387
54 измененных файлов с 349 добавлено и 187 удалено
  1. 1 1
      .github/workflows/reusable-app-prod.yml
  2. 1 0
      apps/app/config/ci/.env.local.for-ci
  3. 1 1
      apps/app/cypress.config.ts
  4. 6 1
      apps/app/src/components/Admin/AuditLog/ActivityTable.tsx
  5. 52 29
      apps/app/src/components/BookmarkButtons.tsx
  6. 38 26
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  7. 10 12
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  8. 2 2
      apps/app/src/components/Navbar/AuthorInfo.tsx
  9. 3 1
      apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx
  10. 2 1
      apps/app/src/components/Navbar/PersonalDropdown.jsx
  11. 3 5
      apps/app/src/components/Navbar/SubNavButtons.tsx
  12. 4 3
      apps/app/src/components/PageAlert/TrashPageAlert.tsx
  13. 4 4
      apps/app/src/components/PageCreateModal.jsx
  14. 10 10
      apps/app/src/components/PageEditor.tsx
  15. 6 6
      apps/app/src/components/PageList/PageListItemL.tsx
  16. 7 6
      apps/app/src/components/Sidebar/PageTree/Item.tsx
  17. 2 2
      apps/app/src/components/User/Username.tsx
  18. 25 4
      apps/app/src/pages/[[...path]].page.tsx
  19. 20 6
      apps/app/src/pages/share/[[...path]].page.tsx
  20. 24 2
      apps/app/src/pages/utils/commons.ts
  21. 4 2
      apps/app/src/server/events/user.ts
  22. 5 4
      apps/app/src/server/models/obsolete-page.js
  23. 2 0
      apps/app/src/server/models/page.ts
  24. 2 2
      apps/app/src/server/routes/apiv3/users.js
  25. 6 0
      apps/app/src/server/service/config-loader.ts
  26. 26 12
      apps/app/src/stores/bookmark.ts
  27. 11 6
      apps/app/src/stores/editor.tsx
  28. 31 9
      apps/app/src/stores/page.tsx
  29. 0 0
      apps/app/test/cypress/e2e/0-advanced-examples/misc.cy.ts
  30. 0 0
      apps/app/test/cypress/e2e/0-advanced-examples/viewport.cy.ts
  31. 0 0
      apps/app/test/cypress/e2e/10-install/10-install--install.cy.ts
  32. 15 22
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts
  33. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-pagelist.cy.ts
  34. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--click-page-icons.cy.ts
  35. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts
  36. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--use-tools.cy.ts
  37. 0 0
      apps/app/test/cypress/e2e/20-basic-features/20-basic-features--username-mention.cy.ts
  38. 0 0
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts
  39. 0 0
      apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts
  40. 0 0
      apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts
  41. 0 0
      apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts
  42. 12 3
      apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts
  43. 0 0
      apps/app/test/cypress/e2e/23-editor/assets/example.txt
  44. 0 0
      apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts
  45. 0 0
      apps/app/test/cypress/e2e/40-admin/40-admin--access-to-admin-page.cy.ts
  46. 0 0
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts
  47. 0 0
      apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts
  48. 0 0
      apps/app/test/cypress/e2e/60-home/60-home--home.cy.ts
  49. 2 1
      bin/data-migrations/src/migrations/v60x/index.js
  50. 2 0
      package.json
  51. 1 0
      packages/core/src/interfaces/page.ts
  52. 2 2
      packages/core/src/utils/page-path-utils/index.ts
  53. 2 2
      packages/ui/src/components/User/UserPicture.tsx
  54. 5 0
      yarn.lock

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

@@ -289,7 +289,7 @@ jobs:
     - name: Determine spec expression
       id: determine-spec-exp
       run: |
-        SPEC=`node bin/github-actions/generate-cypress-spec-arg.mjs --prefix="test/cypress/integration/" --suffix="-*/**" "${{ matrix.spec-group }}"`
+        SPEC=`node bin/github-actions/generate-cypress-spec-arg.mjs --prefix="test/cypress/e2e/" --suffix="-*/*.cy.{ts,tsx}" "${{ matrix.spec-group }}"`
         echo "value=$SPEC" >> $GITHUB_OUTPUT
 
     - name: Copy dotenv file for ci

+ 1 - 0
apps/app/config/ci/.env.local.for-ci

@@ -1 +1,2 @@
 FORMAT_NODE_LOG=true
+FILE_UPLOAD=mongodb

+ 1 - 1
apps/app/cypress.config.ts

@@ -3,7 +3,7 @@ import { defineConfig } from 'cypress';
 export default defineConfig({
   e2e: {
     baseUrl: 'http://localhost:3000',
-    specPattern: 'test/cypress/integration/',
+    specPattern: 'test/cypress/e2e/**/*.cy.{ts,tsx}',
     supportFile: 'test/cypress/support/index.ts',
     setupNodeEvents: (on) => {
       // change screen size

+ 6 - 1
apps/app/src/components/Admin/AuditLog/ActivityTable.tsx

@@ -48,7 +48,12 @@ export const ActivityTable : FC<Props> = (props: Props) => {
                   { activity.user != null && (
                     <>
                       <UserPicture user={activity.user} />
-                      <a className="ml-2" href={pagePathUtils.userPageRoot(activity.user)}>{activity.snapshot?.username}</a>
+                      <a
+                        className="ml-2"
+                        href={pagePathUtils.userHomepagePath(activity.user)}
+                      >
+                        {activity.snapshot?.username}
+                      </a>
                     </>
                   )}
                 </td>

+ 52 - 29
apps/app/src/components/BookmarkButtons.tsx

@@ -3,38 +3,49 @@ import React, {
 } from 'react';
 
 import { useTranslation } from 'next-i18next';
-import {
-  UncontrolledTooltip, Popover, PopoverBody, DropdownToggle,
-} from 'reactstrap';
+import DropdownToggle from 'reactstrap/es/DropdownToggle';
+import Popover from 'reactstrap/es/Popover';
+import PopoverBody from 'reactstrap/es/PopoverBody';
+import UncontrolledTooltip from 'reactstrap/es/UncontrolledTooltip';
 
-import { IBookmarkInfo } from '~/interfaces/bookmark-info';
+import { useSWRxBookmarkedUsers } from '~/stores/bookmark';
 import { useIsGuestUser } from '~/stores/context';
 
-import { IUser } from '../interfaces/user';
-
 import { BookmarkFolderMenu } from './Bookmarks/BookmarkFolderMenu';
 import UserPictureList from './User/UserPictureList';
 
 import styles from './BookmarkButtons.module.scss';
 
 interface Props {
-  bookmarkedUsers?: IUser[]
-  hideTotalNumber?: boolean
-  bookmarkInfo? : IBookmarkInfo
+  pageId: string,
+  isBookmarked?: boolean,
+  bookmarkCount: number,
+  hideTotalNumber?: boolean,
 }
 
 export const BookmarkButtons: FC<Props> = (props: Props) => {
   const { t } = useTranslation();
   const {
-    bookmarkedUsers, hideTotalNumber, bookmarkInfo,
+    pageId, isBookmarked, bookmarkCount, hideTotalNumber,
   } = props;
 
-  const [isPopoverOpen, setIsPopoverOpen] = useState(false);
+  const [isBookmarkFolderMenuOpen, setBookmarkFolderMenuOpen] = useState(false);
+  const [isBookmarkUsersPopoverOpen, setBookmarkUsersPopoverOpen] = useState(false);
 
   const { data: isGuestUser } = useIsGuestUser();
 
-  const togglePopover = () => {
-    setIsPopoverOpen(!isPopoverOpen);
+  const { data: bookmarkedUsers, isLoading: isLoadingBookmarkedUsers } = useSWRxBookmarkedUsers(isBookmarkUsersPopoverOpen ? pageId : null);
+
+  const unbookmarkHandler = () => {
+    setBookmarkFolderMenuOpen(false);
+  };
+
+  const toggleBookmarkFolderMenuHandler = () => {
+    setBookmarkFolderMenuOpen(v => !v);
+  };
+
+  const toggleBookmarkUsersPopover = () => {
+    setBookmarkUsersPopoverOpen(v => !v);
   };
 
   const getTooltipMessage = useCallback(() => {
@@ -45,19 +56,23 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
     return 'tooltip.bookmark';
   }, [isGuestUser]);
 
-  if (bookmarkInfo == null) {
+  if (pageId == null) {
     return <></>;
   }
 
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-      <BookmarkFolderMenu bookmarkInfo={bookmarkInfo}>
+
+      <BookmarkFolderMenu
+        isOpen={isBookmarkFolderMenuOpen} pageId={pageId} isBookmarked={isBookmarked ?? false}
+        onToggle={toggleBookmarkFolderMenuHandler}
+        onUnbookmark={unbookmarkHandler}
+      >
         <DropdownToggle id='bookmark-dropdown-btn' color="transparent" className={`shadow-none btn btn-bookmark border-0
-          ${bookmarkInfo.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
-          <i className={`fa ${bookmarkInfo.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+          ${isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
+          <i className={`fa ${isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         </DropdownToggle>
       </BookmarkFolderMenu>
-
       <UncontrolledTooltip placement="top" data-testid="bookmark-button-tooltip" target="bookmark-dropdown-btn" fade={false}>
         {t(getTooltipMessage())}
       </UncontrolledTooltip>
@@ -68,19 +83,27 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
             type="button"
             id="po-total-bookmarks"
             className={`shadow-none btn btn-bookmark border-0
-              total-bookmarks ${bookmarkInfo.isBookmarked ? 'active' : ''}`}
+              total-bookmarks ${isBookmarked ? 'active' : ''}`}
           >
-            {bookmarkInfo.sumOfBookmarks ?? 0}
+            {bookmarkCount}
           </button>
-          { bookmarkedUsers != null && (
-            <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">
-              <PopoverBody className="user-list-popover">
-                <div className="px-2 text-right user-list-content text-truncate text-muted">
-                  {bookmarkedUsers.length ? <UserPictureList users={props.bookmarkedUsers} /> : t('No users have bookmarked yet')}
-                </div>
-              </PopoverBody>
-            </Popover>
-          ) }
+          <Popover placement="bottom" isOpen={isBookmarkUsersPopoverOpen} target="po-total-bookmarks" toggle={toggleBookmarkUsersPopover} trigger="legacy">
+            <PopoverBody className="user-list-popover">
+              { isLoadingBookmarkedUsers && <i className="fa fa-spinner fa-pulse"></i> }
+              { !isLoadingBookmarkedUsers && bookmarkedUsers != null && (
+                <>
+                  { bookmarkedUsers.length > 0
+                    ? (
+                      <div className="px-2 text-right user-list-content text-truncate text-muted">
+                        <UserPictureList users={bookmarkedUsers} />
+                      </div>
+                    )
+                    : t('No users have bookmarked yet')
+                  }
+                </>
+              ) }
+            </PopoverBody>
+          </Popover>
         </>
       ) }
     </div>

+ 38 - 26
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -6,28 +6,37 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
-import { IBookmarkInfo } from '~/interfaces/bookmark-info';
-import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxPageInfo } from '~/stores/page';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 
 import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 
-export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkInfo: IBookmarkInfo }> = ({ children, bookmarkInfo }): JSX.Element => {
+
+type BookmarkFolderMenuProps = {
+  isOpen: boolean,
+  pageId: string,
+  isBookmarked: boolean,
+  onToggle?: () => void,
+  onUnbookmark?: () => void,
+  children?: React.ReactNode,
+}
+
+export const BookmarkFolderMenu = (props: BookmarkFolderMenuProps): JSX.Element => {
+  const {
+    isOpen, pageId, isBookmarked, onToggle, onUnbookmark, children,
+  } = props;
+
   const { t } = useTranslation();
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
-  const [isOpen, setIsOpen] = useState(false);
 
   const { data: currentUser } = useCurrentUser();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(bookmarkInfo.pageId);
-
-  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkInfo.pageId);
 
-  const isBookmarked = bookmarkInfo.isBookmarked ?? false;
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageId);
 
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
@@ -35,36 +44,40 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
 
   const toggleBookmarkHandler = useCallback(async() => {
     try {
-      await toggleBookmark(bookmarkInfo.pageId, isBookmarked);
+      await toggleBookmark(pageId, isBookmarked);
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkInfo.pageId, isBookmarked]);
+  }, [isBookmarked, pageId]);
 
   const onUnbookmarkHandler = useCallback(async() => {
+    if (onUnbookmark != null) {
+      onUnbookmark();
+    }
     await toggleBookmarkHandler();
-    setIsOpen(false);
     setSelectedItem(null);
-    mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
     mutateBookmarkFolders();
     mutatePageInfo();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutatePageInfo, mutateUserBookmarks, toggleBookmarkHandler]);
+  }, [onUnbookmark, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
   const toggleHandler = useCallback(async() => {
-    setIsOpen(!isOpen);
-
+    // on close
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
         bookmarkFolder.bookmarks.forEach((bookmark) => {
-          if (bookmark.page._id === bookmarkInfo.pageId) {
+          if (bookmark.page._id === pageId) {
             setSelectedItem(bookmarkFolder._id);
           }
         });
       });
     }
 
+    if (onToggle != null) {
+      onToggle();
+    }
+
     if (selectedItem == null) {
       setSelectedItem('root');
     }
@@ -72,8 +85,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
     if (!isOpen && !isBookmarked) {
       try {
         await toggleBookmarkHandler();
-        mutateUserBookmarks();
-        mutateBookmarkInfo();
+        mutateCurrentUserBookmarks();
         mutatePageInfo();
       }
       catch (err) {
@@ -81,7 +93,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
       }
     }
   },
-  [isOpen, bookmarkFolders, selectedItem, isBookmarked, bookmarkInfo.pageId, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
+  [isOpen, bookmarkFolders, onToggle, selectedItem, isBookmarked, pageId, toggleBookmarkHandler, mutateCurrentUserBookmarks, mutatePageInfo]);
 
   const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
     e.stopPropagation();
@@ -89,15 +101,15 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkI
     setSelectedItem(itemId);
 
     try {
-      await addBookmarkToFolder(bookmarkInfo.pageId, itemId === 'root' ? null : itemId);
-      mutateUserBookmarks();
+      await addBookmarkToFolder(pageId, itemId === 'root' ? null : itemId);
+      mutateCurrentUserBookmarks();
       mutateBookmarkFolders();
-      mutateBookmarkInfo();
+      mutatePageInfo();
     }
     catch (err) {
       toastError(err);
     }
-  }, [bookmarkInfo.pageId, mutateUserBookmarks, mutateBookmarkFolders, mutateBookmarkInfo]);
+  }, [pageId, mutateCurrentUserBookmarks, mutateBookmarkFolders, mutatePageInfo]);
 
   const renderBookmarkMenuItem = () => {
     return (

+ 10 - 12
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -6,11 +6,13 @@ import { useTranslation } from 'next-i18next';
 import { toastSuccess } from '~/client/util/toastr';
 import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
-import { useSWRxUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
+import {
+  useSWRxUserBookmarks, useSWRMUTxCurrentUserBookmarks,
+} from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useIsReadOnlyUser, useCurrentUser } from '~/stores/context';
+import { useIsReadOnlyUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
-import { useSWRxCurrentPage } from '~/stores/page';
+import { useSWRMUTxPageInfo, useSWRxCurrentPage } from '~/stores/page';
 
 import { BookmarkFolderItem } from './BookmarkFolderItem';
 import { BookmarkItem } from './BookmarkItem';
@@ -35,24 +37,20 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
 
-  // In order to update the bookmark information in the sidebar when bookmarking or unbookmarking a page on someone else's user homepage
-  const { data: currentUser } = useCurrentUser();
-  const shouldMutateCurrentUserbookmarks = currentUser?._id !== userId;
-
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
-  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId);
-  const { mutate: mutateCurrentUserBookmarks } = useSWRxUserBookmarks(shouldMutateCurrentUserbookmarks ? currentUser?._id : undefined);
+  const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId ?? null);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(currentPage?._id ?? null);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
   const { open: openDeleteModal } = usePageDeleteModal();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
     mutateCurrentUserBookmarks();
-    mutateBookmarkInfo();
+    mutatePageInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutatePageInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
   const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {

+ 2 - 2
apps/app/src/components/Navbar/AuthorInfo.tsx

@@ -18,7 +18,7 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     date, user, mode = 'create', locate = 'subnav',
   } = props;
 
-  const { userPageRoot } = pagePathUtils;
+  const { userHomepagePath } = pagePathUtils;
   const formatType = 'yyyy/MM/dd HH:mm';
 
   const infoLabelForSubNav = mode === 'create'
@@ -32,7 +32,7 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
     : 'Last revision posted at';
   const userLabel = user != null
     ? (
-      <Link href={userPageRoot(user)} prefetch={false}>
+      <Link href={userHomepagePath(user)} prefetch={false}>
         {user.name}
       </Link>
     )

+ 3 - 1
apps/app/src/components/Navbar/GrowiContextualSubNavigation.tsx

@@ -317,9 +317,11 @@ const GrowiContextualSubNavigation = (props: GrowiContextualSubNavigationProps):
       else if (currentPathname != null) {
         router.push(currentPathname);
       }
+
+      mutateCurrentPage();
     };
     openDeleteModal([pageWithMeta], { onDeleted: deletedHandler });
-  }, [currentPathname, openDeleteModal, router]);
+  }, [currentPathname, mutateCurrentPage, openDeleteModal, router]);
 
   const switchContentWidthHandler = useCallback(async(pageId: string, value: boolean) => {
     if (!isSharedPage) {

+ 2 - 1
apps/app/src/components/Navbar/PersonalDropdown.jsx

@@ -1,5 +1,6 @@
 import { useRef, useState } from 'react';
 
+import { pagePathUtils } from '@growi/core';
 import { UserPicture } from '@growi/ui/dist/components/User/UserPicture';
 import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
@@ -64,7 +65,7 @@ const PersonalDropdown = () => {
 
           <div className="btn-group btn-block mt-2" role="group">
             <Link
-              href={`/user/${currentUser.username}`}
+              href={pagePathUtils.userHomepagePath(currentUser)}
               className="btn btn-sm btn-outline-secondary col"
               data-testid="grw-personal-dropdown-menu-user-home"
             >

+ 3 - 5
apps/app/src/components/Navbar/SubNavButtons.tsx

@@ -13,7 +13,6 @@ import {
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
 
-import { useSWRBookmarkInfo } from '../../stores/bookmark';
 import { useSWRxPageInfo } from '../../stores/page';
 import { useSWRxUsersList } from '../../stores/user';
 import { BookmarkButtons } from '../BookmarkButtons';
@@ -94,8 +93,6 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
-  const { data: bookmarkInfo } = useSWRBookmarkInfo(pageId);
-
   const likerIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.likerIds ?? []).slice(0, 15) : [];
   const seenUserIds = isIPageInfoForEntity(pageInfo) ? (pageInfo.seenUserIds ?? []).slice(0, 15) : [];
 
@@ -227,9 +224,10 @@ const SubNavButtonsSubstance = (props: SubNavButtonsSubstanceProps): JSX.Element
       )}
       {revisionId != null && (
         <BookmarkButtons
+          pageId={pageId}
+          isBookmarked={pageInfo.isBookmarked}
+          bookmarkCount={pageInfo.bookmarkCount}
           hideTotalNumber={isCompactMode}
-          bookmarkedUsers={bookmarkInfo?.bookmarkedUsers}
-          bookmarkInfo={bookmarkInfo}
         />
       )}
       {revisionId != null && !isCompactMode && (

+ 4 - 3
apps/app/src/components/PageAlert/TrashPageAlert.tsx

@@ -9,7 +9,7 @@ import { unlink } from '~/client/services/page-operation';
 import { toastError } from '~/client/util/toastr';
 import { usePageDeleteModal, usePutBackPageModal } from '~/stores/modal';
 import {
-  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage,
+  useCurrentPagePath, useSWRxPageInfo, useSWRxCurrentPage, useIsTrashPage, useSWRMUTxCurrentPage,
 } from '~/stores/page';
 import { useIsAbleToShowTrashPageManagementButtons } from '~/stores/ui';
 
@@ -33,11 +33,11 @@ export const TrashPageAlert = (): JSX.Element => {
   const pagePath = pageData?.path;
   const { data: pageInfo } = useSWRxPageInfo(pageId ?? null);
 
-
   const { open: openDeleteModal } = usePageDeleteModal();
   const { open: openPutBackPageModal } = usePutBackPageModal();
   const { data: currentPagePath } = useCurrentPagePath();
 
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
 
   const deleteUser = pageData?.deleteUser;
   const deletedAt = pageData?.deletedAt ? format(new Date(pageData?.deletedAt), 'yyyy/MM/dd HH:mm') : '';
@@ -55,13 +55,14 @@ export const TrashPageAlert = (): JSX.Element => {
       try {
         unlink(currentPagePath);
         router.push(`/${pageId}`);
+        mutateCurrentPage();
       }
       catch (err) {
         toastError(err);
       }
     };
     openPutBackPageModal({ pageId, path: pagePath }, { onPutBacked: putBackedHandler });
-  }, [currentPagePath, openPutBackPageModal, pageId, pagePath, router]);
+  }, [currentPagePath, mutateCurrentPage, openPutBackPageModal, pageId, pagePath, router]);
 
   const openPageDeleteModalHandler = useCallback(() => {
     if (pageId === undefined || revisionId === undefined || pagePath === undefined) {

+ 4 - 4
apps/app/src/components/PageCreateModal.jsx

@@ -21,7 +21,7 @@ import PagePathAutoComplete from './PagePathAutoComplete';
 import styles from './PageCreateModal.module.scss';
 
 const {
-  userPageRoot, isCreatablePage, generateEditorPath, isUsersHomepage,
+  isCreatablePage, generateEditorPath, isUsersHomepage,
 } = pagePathUtils;
 
 const PageCreateModal = () => {
@@ -35,7 +35,7 @@ const PageCreateModal = () => {
 
   const { data: isReachable } = useIsSearchServiceReachable();
   const pathname = path || '';
-  const userPageRootPath = userPageRoot(currentUser);
+  const userHomepagePath = pagePathUtils.userHomepagePath(currentUser);
   const isCreatable = isCreatablePage(pathname) || isUsersHomepage(pathname);
   const pageNameInputInitialValue = isCreatable ? pathUtils.addTrailingSlash(pathname) : '/';
   const now = format(new Date(), 'yyyy/MM/dd');
@@ -129,7 +129,7 @@ const PageCreateModal = () => {
     if (tmpTodayInput1 === '') {
       tmpTodayInput1 = t('Memo');
     }
-    redirectToEditor(userPageRootPath, tmpTodayInput1, now, todayInput2);
+    redirectToEditor(userHomepagePath, tmpTodayInput1, now, todayInput2);
   }
 
   /**
@@ -164,7 +164,7 @@ const PageCreateModal = () => {
 
             <div className="d-flex align-items-center flex-fill flex-wrap flex-lg-nowrap">
               <div className="d-flex align-items-center">
-                <span>{userPageRootPath}/</span>
+                <span>{userHomepagePath}/</span>
                 <form onSubmit={e => transitBySubmitEvent(e, createTodayPage)}>
                   <input
                     type="text"

+ 10 - 10
apps/app/src/components/PageEditor.tsx

@@ -132,8 +132,6 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
-  const [isPageCreatedWithAttachmentUpload, setIsPageCreatedWithAttachmentUpload] = useState(false);
-
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -327,7 +325,6 @@ const PageEditor = React.memo((): JSX.Element => {
       // Not using 'mutateGrant' to inherit the grant of the parent page
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
-        setIsPageCreatedWithAttachmentUpload(true);
         globalEmitter.emit('resetInitializedHackMdStatus');
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
@@ -522,14 +519,17 @@ const PageEditor = React.memo((): JSX.Element => {
 
   // when transitioning to a different page, if the initialValue is the same,
   // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
-  // Also, if an attachment is uploaded and a new page is created,
-  // "useCurrentPagePath" changes, but no page transition is made, so nothing is done.
+  const onRouterChangeComplete = useCallback(() => {
+    editorRef.current?.setValue(initialValue);
+    editorRef.current?.setCaretLine(0);
+  }, [initialValue]);
+
   useEffect(() => {
-    if (currentPagePath != null && !isPageCreatedWithAttachmentUpload) {
-      editorRef.current?.setValue(initialValue);
-    }
-    setIsPageCreatedWithAttachmentUpload(false);
-  }, [currentPagePath, initialValue, isPageCreatedWithAttachmentUpload]);
+    router.events.on('routeChangeComplete', onRouterChangeComplete);
+    return () => {
+      router.events.off('routeChangeComplete', onRouterChangeComplete);
+    };
+  }, [onRouterChangeComplete, router.events]);
 
   if (!isEditable) {
     return <></>;

+ 6 - 6
apps/app/src/components/PageList/PageListItemL.tsx

@@ -24,13 +24,13 @@ import {
   OnDuplicatedFunction, OnRenamedFunction, OnDeletedFunction, OnPutBackedFunction,
 } from '~/interfaces/ui';
 import LinkedPagePath from '~/models/linked-page-path';
-import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import {
   usePageRenameModal, usePageDuplicateModal, usePageDeleteModal, usePutBackPageModal,
 } from '~/stores/modal';
 import { useIsDeviceSmallerThanLg } from '~/stores/ui';
 
-import { useSWRxPageInfo } from '../../stores/page';
+import { useSWRMUTxPageInfo, useSWRxPageInfo } from '../../stores/page';
 import { ForceHideMenuItems, PageItemControl } from '../Common/Dropdown/PageItemControl';
 import PagePathHierarchicalLink from '../PagePathHierarchicalLink';
 
@@ -90,8 +90,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
 
   const shouldFetch = isSelected && (pageData != null || pageMeta != null);
   const { data: pageInfo } = useSWRxPageInfo(shouldFetch ? pageData?._id : null);
-  const { mutate: mutateUserBookmark } = useSWRxUserBookmarks();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(pageData?._id);
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(pageData?._id ?? null);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
   const elasticSearchResult = isIPageSearchMeta(pageMeta) ? pageMeta.elasticSearchResult : null;
   const revisionShortBody = isIPageInfoForListing(pageMeta) ? pageMeta.revisionShortBody : null;
 
@@ -128,8 +128,8 @@ const PageListItemLSubstance: ForwardRefRenderFunction<ISelectable, Props> = (pr
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
-    mutateUserBookmark();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
   };
 
   const duplicateMenuItemClickHandler = useCallback(() => {

+ 7 - 6
apps/app/src/components/Sidebar/PageTree/Item.tsx

@@ -5,7 +5,7 @@ import React, {
 import nodePath from 'path';
 
 import {
-  pathUtils, pagePathUtils, Nullable, DevidedPagePath,
+  pathUtils, pagePathUtils, Nullable,
 } from '@growi/core';
 import { useTranslation } from 'next-i18next';
 import Link from 'next/link';
@@ -22,8 +22,9 @@ import { NotAvailableForReadOnlyUser } from '~/components/NotAvailableForReadOnl
 import {
   IPageHasId, IPageInfoAll, IPageToDeleteWithMeta,
 } from '~/interfaces/page';
-import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
+import { useSWRMUTxCurrentUserBookmarks } from '~/stores/bookmark';
 import { IPageForPageDuplicateModal } from '~/stores/modal';
+import { useSWRMUTxPageInfo } from '~/stores/page';
 import { mutatePageTree, useSWRxPageChildren } from '~/stores/page-listing';
 import { usePageTreeDescCountMap } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
@@ -124,8 +125,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const [isCreating, setCreating] = useState(false);
 
   const { data, mutate: mutateChildren } = useSWRxPageChildren(isOpen ? page._id : null);
-  const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks();
-  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(page._id);
+  const { trigger: mutateCurrentUserBookmarks } = useSWRMUTxCurrentUserBookmarks();
+  const { trigger: mutatePageInfo } = useSWRMUTxPageInfo(page._id ?? null);
 
   // descendantCount
   const { getDescCount } = usePageTreeDescCountMap();
@@ -261,8 +262,8 @@ const Item: FC<ItemProps> = (props: ItemProps) => {
   const bookmarkMenuItemClickHandler = async(_pageId: string, _newValue: boolean): Promise<void> => {
     const bookmarkOperation = _newValue ? bookmark : unbookmark;
     await bookmarkOperation(_pageId);
-    mutateUserBookmarks();
-    mutateBookmarkInfo();
+    mutateCurrentUserBookmarks();
+    mutatePageInfo();
   };
 
   const duplicateMenuItemClickHandler = useCallback((): void => {

+ 2 - 2
apps/app/src/components/User/Username.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 
-import type { IUser } from '@growi/core';
+import { type IUser, pagePathUtils } from '@growi/core';
 import Link from 'next/link';
 
 type UsernameProps = {
@@ -16,7 +16,7 @@ export const Username = (props: UsernameProps): JSX.Element => {
 
   const name = user.name || '(no name)';
   const username = user.username;
-  const href = `/user/${user.username}`;
+  const href = pagePathUtils.userHomepagePath(user);
 
   return (
     <Link href={href} prefetch={false}>

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

@@ -41,7 +41,8 @@ import {
 import { useEditingMarkdown } from '~/stores/editor';
 import { useHasDraftOnHackmd, usePageIdOnHackmd, useRevisionIdHackmdSynced } from '~/stores/hackmd';
 import {
-  useSWRxCurrentPage, useSWRxIsGrantNormalized, useCurrentPageId, useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
+  useSWRxCurrentPage, useSWRMUTxCurrentPage, useSWRxIsGrantNormalized, useCurrentPageId,
+  useIsNotFound, useIsLatestRevision, useTemplateTagData, useTemplateBodyData,
 } from '~/stores/page';
 import { useRedirectFrom } from '~/stores/page-redirect';
 import { useRemoteRevisionId } from '~/stores/remote-latest-page';
@@ -57,7 +58,7 @@ import { DisplaySwitcher } from '../components/Page/DisplaySwitcher';
 import type { NextPageWithLayout } from './_app.page';
 import type { CommonProps } from './utils/commons';
 import {
-  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig,
+  getNextI18NextConfig, getServerSideCommonProps, generateCustomTitleForPage, useInitSidebarConfig, skipSSR,
 } from './utils/commons';
 
 
@@ -172,6 +173,7 @@ type Props = CommonProps & {
   adminPreferredIndentSize: number,
   isIndentSizeForced: boolean,
   disableLinkSharing: boolean,
+  skipSSR: boolean,
 
   grantData?: IPageGrantData,
 
@@ -237,9 +239,11 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
   useSWRxCurrentPage(pageWithMeta?.data ?? null); // store initial data
 
+  const { trigger: mutateCurrentPage } = useSWRMUTxCurrentPage();
+
   const { mutate: mutateIsNotFound } = useIsNotFound();
 
-  const { mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
 
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateIsLatestRevision } = useIsLatestRevision();
@@ -262,6 +266,22 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
     ? _isTrashPage(pageWithMeta.data.path)
     : false;
 
+
+  useEffect(() => {
+    if (!props.skipSSR) {
+      return;
+    }
+
+    if (currentPageId != null && !props.isNotFound) {
+      const mutatePageData = async() => {
+        const pageData = await mutateCurrentPage();
+        mutateEditingMarkdown(pageData?.revision.body);
+      };
+
+      mutatePageData();
+    }
+  }, [currentPageId, mutateCurrentPage, mutateEditingMarkdown, props.isNotFound, props.skipSSR]);
+
   // sync grant data
   useEffect(() => {
     const grantDataToApply = props.grantData ? props.grantData : grantData?.grantData.currentPageGrant;
@@ -464,8 +484,9 @@ async function injectPageData(context: GetServerSidePropsContext, props: Props):
   // populate & check if the revision is latest
   if (page != null) {
     page.initLatestRevisionField(revisionId);
-    await page.populateDataToShowRevision();
     props.isLatestRevision = page.isLatestRevision();
+    props.skipSSR = await skipSSR(page);
+    await page.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
   }
 
   if (page == null && user != null) {

+ 20 - 6
apps/app/src/pages/share/[[...path]].page.tsx

@@ -1,4 +1,4 @@
-import React from 'react';
+import React, { useEffect } from 'react';
 
 import type { IUserHasId, IPagePopulatedToShowRevision } from '@growi/core';
 import type {
@@ -22,12 +22,12 @@ import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid,
 } from '~/stores/context';
-import { useCurrentPageId, useIsNotFound } from '~/stores/page';
+import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 
 import type { NextPageWithLayout } from '../_app.page';
 import {
-  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps,
+  getServerSideCommonProps, generateCustomTitleForPage, getNextI18NextConfig, CommonProps, skipSSR,
 } from '../utils/commons';
 
 const logger = loggerFactory('growi:next-page:share');
@@ -43,6 +43,7 @@ type Props = CommonProps & {
   isSearchScopeChildrenAsDefault: boolean,
   drawioUri: string | null,
   rendererConfig: RendererConfig,
+  skipSSR: boolean,
 };
 
 type IShareLinkRelatedPage = IPagePopulatedToShowRevision & PageDocument;
@@ -92,6 +93,18 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsContainerFluid(props.isContainerFluid);
 
+  const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
+
+  useEffect(() => {
+    if (!props.skipSSR) {
+      return;
+    }
+
+    if (props.shareLink?.relatedPage._id != null && !props.isNotFound) {
+      mutateCurrentPage();
+    }
+  }, [mutateCurrentPage, props.isNotFound, props.shareLink?.relatedPage._id, props.skipSSR]);
+
 
   const growiLayoutFluidClass = useCurrentGrowiLayoutFluidClassName(props.shareLinkRelatedPage);
 
@@ -107,7 +120,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
 
       <div className={`dynamic-layout-root ${growiLayoutFluidClass} h-100 d-flex flex-column justify-content-between`}>
         <header className="py-0 position-relative">
-          <GrowiContextualSubNavigationForSharedPage page={props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
+          <GrowiContextualSubNavigationForSharedPage page={currentPage ?? props.shareLinkRelatedPage} isLinkSharingDisabled={props.disableLinkSharing} />
         </header>
 
         <div id="grw-fav-sticky-trigger" className="sticky-top"></div>
@@ -115,7 +128,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
         <ShareLinkPageView
           pagePath={pagePath}
           rendererConfig={props.rendererConfig}
-          page={props.shareLinkRelatedPage}
+          page={currentPage ?? props.shareLinkRelatedPage}
           shareLink={props.shareLink}
           isExpired={props.isExpired}
           disableLinkSharing={props.disableLinkSharing}
@@ -221,7 +234,8 @@ export const getServerSideProps: GetServerSideProps = async(context: GetServerSi
     }
     else {
       props.isNotFound = false;
-      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision();
+      props.skipSSR = await skipSSR(shareLink.relatedPage);
+      props.shareLinkRelatedPage = await shareLink.relatedPage.populateDataToShowRevision(props.skipSSR); // shouldExcludeBody = skipSSR
       props.isExpired = shareLink.isExpired();
       props.shareLink = shareLink.toObject();
     }

+ 24 - 2
apps/app/src/pages/utils/commons.ts

@@ -1,6 +1,6 @@
 import type { ColorScheme, IUserHasId } from '@growi/core';
 import {
-  DevidedPagePath, Lang, AllLang,
+  DevidedPagePath, Lang, AllLang, isServer,
 } from '@growi/core';
 import type { GetServerSideProps, GetServerSidePropsContext } from 'next';
 import type { SSRConfig, UserConfig } from 'next-i18next';
@@ -11,6 +11,7 @@ import { detectLocaleFromBrowserAcceptLanguage } from '~/client/util/locale-util
 import type { CrowiRequest } from '~/interfaces/crowi-request';
 import type { ISidebarConfig } from '~/interfaces/sidebar-config';
 import type { IUserUISettings } from '~/interfaces/user-ui-settings';
+import type { PageDocument } from '~/server/models/page';
 import type { UserUISettingsDocument } from '~/server/models/user-ui-settings';
 import {
   useCurrentProductNavWidth, useCurrentSidebarContents, usePreferDrawerModeByUser, usePreferDrawerModeOnEditByUser, useSidebarCollapsed,
@@ -74,7 +75,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
   const isDefaultLogo = crowi.configManager.getConfig('crowi', 'customize:isDefaultLogo') || !isCustomizedLogoUploaded;
   const forcedColorScheme = crowi.customizeService.forcedColorScheme;
 
-  // retrieve UserUISettings
+  // retrieve UserUISett ings
   const UserUISettings = getModelSafely<UserUISettingsDocument>('UserUISettings');
   const userUISettings = user != null && UserUISettings != null
     ? await UserUISettings.findOne({ user: user._id }).exec()
@@ -168,3 +169,24 @@ export const useInitSidebarConfig = (sidebarConfig: ISidebarConfig, userUISettin
   useCurrentSidebarContents(userUISettings?.currentSidebarContents);
   useCurrentProductNavWidth(userUISettings?.currentProductNavWidth);
 };
+
+
+export const skipSSR = async(page: PageDocument): Promise<boolean> => {
+  if (!isServer()) {
+    throw new Error('This method is not available on the client-side');
+  }
+
+  // page document only stores the bodyLength of the latest revision
+  if (!page.isLatestRevision() || page.latestRevisionBodyLength == null) {
+    return true;
+  }
+
+  const { configManager } = await import('~/server/service/config-manager');
+  await configManager.loadConfigs();
+  const ssrMaxRevisionBodyLength = configManager.getConfig('crowi', 'app:ssrMaxRevisionBodyLength');
+  if (ssrMaxRevisionBodyLength < page.latestRevisionBodyLength) {
+    return true;
+  }
+
+  return false;
+};

+ 4 - 2
apps/app/src/server/events/user.ts

@@ -1,6 +1,6 @@
 import EventEmitter from 'events';
 
-import type { IUserHasId } from '@growi/core';
+import { type IUserHasId, pagePathUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
@@ -10,6 +10,7 @@ class UserEvent extends EventEmitter {
 
   crowi: any;
 
+  // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
   constructor(crowi: any) {
     super();
     this.crowi = crowi;
@@ -22,7 +23,8 @@ class UserEvent extends EventEmitter {
     }
 
     const Page = this.crowi.model('Page');
-    const userHomepagePath = `/user/${user.username}`;
+    const userHomepagePath = pagePathUtils.userHomepagePath(user);
+
     // TODO: Delete user arg.
     // see: https://redmine.weseek.co.jp/issues/124326
     let page = await Page.findByPath(userHomepagePath, user);

+ 5 - 4
apps/app/src/server/models/obsolete-page.js

@@ -63,16 +63,17 @@ export const extractToAncestorsPaths = (pagePath) => {
  * populate page (Query or Document) to show revision
  * @param {any} page Query or Document
  * @param {string} userPublicFields string to set to select
+ * @param {boolean} shouldExcludeBody boolean indicating whether to include 'revision.body' or not
  */
 /* eslint-disable object-curly-newline, object-property-newline */
-export const populateDataToShowRevision = (page, userPublicFields) => {
+export const populateDataToShowRevision = (page, userPublicFields, shouldExcludeBody = false) => {
   return page
     .populate([
       { path: 'lastUpdateUser', model: 'User', select: userPublicFields },
       { path: 'creator', model: 'User', select: userPublicFields },
       { path: 'deleteUser', model: 'User', select: userPublicFields },
       { path: 'grantedGroup', model: 'UserGroup' },
-      { path: 'revision', model: 'Revision', populate: {
+      { path: 'revision', model: 'Revision', select: shouldExcludeBody ? '-body' : undefined, populate: {
         path: 'author', model: 'User', select: userPublicFields,
       } },
     ]);
@@ -233,11 +234,11 @@ export const getPageSchema = (crowi) => {
     }
   };
 
-  pageSchema.methods.populateDataToShowRevision = async function() {
+  pageSchema.methods.populateDataToShowRevision = async function(shouldExcludeBody) {
     validateCrowi();
 
     const User = crowi.model('User');
-    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL);
+    return populateDataToShowRevision(this, User.USER_FIELDS_EXCEPT_CONFIDENTIAL, shouldExcludeBody);
   };
 
   pageSchema.methods.populateDataToMakePresentation = async function(revisionId) {

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

@@ -94,6 +94,7 @@ const schema = new Schema<PageDocument, PageModel>({
     type: String, required: true, index: true,
   },
   revision: { type: ObjectId, ref: 'Revision' },
+  latestRevisionBodyLength: { type: Number },
   status: { type: String, default: STATUS_PUBLISHED, index: true },
   grant: { type: Number, default: GRANT_PUBLIC, index: true },
   grantedUsers: [{ type: ObjectId, ref: 'User' }],
@@ -715,6 +716,7 @@ export async function pushRevision(pageData, newRevision, user) {
   await newRevision.save();
 
   pageData.revision = newRevision;
+  pageData.latestRevisionBodyLength = newRevision.body.length;
   pageData.lastUpdateUser = user?._id ?? user;
   pageData.updatedAt = Date.now();
 

+ 2 - 2
apps/app/src/server/routes/apiv3/users.js

@@ -1,4 +1,4 @@
-import { ErrorV3 } from '@growi/core';
+import { ErrorV3, pagePathUtils } from '@growi/core';
 
 import { SupportedAction } from '~/interfaces/activity';
 import Activity from '~/server/models/activity';
@@ -790,7 +790,7 @@ module.exports = (crowi) => {
       const user = await User.findById(id);
       // !! DO NOT MOVE userHomepagePath FROM THIS POSITION !! -- 05.31.2023
       // catch username before delete user because username will be change to deleted_at_*
-      const userHomepagePath = `/user/${user.username}`;
+      const userHomepagePath = pagePathUtils.userHomepagePath(user);
 
       await UserGroupRelation.remove({ relatedUser: user });
       await user.statusDelete();

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

@@ -682,6 +682,12 @@ const ENV_VAR_NAME_TO_CONFIG_INFO = {
     type: ValueType.STRING,
     default: null,
   },
+  SSR_MAX_REVISION_BODY_LENGTH: {
+    ns: 'crowi',
+    key: 'app:ssrMaxRevisionBodyLength',
+    type: ValueType.NUMBER,
+    default: 30000,
+  },
 };
 
 

+ 26 - 12
apps/app/src/stores/bookmark.ts

@@ -1,26 +1,24 @@
-import { SWRResponse } from 'swr';
+import { IUser } from '@growi/core';
+import useSWR, { SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
+
 
 import { IPageHasId } from '~/interfaces/page';
 
 import { apiv3Get } from '../client/util/apiv3-client';
 import { IBookmarkInfo } from '../interfaces/bookmark-info';
 
-export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRResponse<IBookmarkInfo, Error> => {
-  return useSWRImmutable(
+import { useCurrentUser } from './context';
+
+export const useSWRxBookmarkedUsers = (pageId: string | null): SWRResponse<IUser[], Error> => {
+  return useSWR(
     pageId != null ? `/bookmarks/info?pageId=${pageId}` : null,
-    endpoint => apiv3Get(endpoint).then((response) => {
-      return {
-        sumOfBookmarks: response.data.sumOfBookmarks,
-        isBookmarked: response.data.isBookmarked,
-        bookmarkedUsers: response.data.bookmarkedUsers,
-        pageId: response.data.pageId,
-      };
-    }),
+    endpoint => apiv3Get<IBookmarkInfo>(endpoint).then(response => response.data.bookmarkedUsers),
   );
 };
 
-export const useSWRxUserBookmarks = (userId?: string): SWRResponse<IPageHasId[], Error> => {
+export const useSWRxUserBookmarks = (userId: string | null): SWRResponse<IPageHasId[], Error> => {
   return useSWRImmutable(
     userId != null ? `/bookmarks/${userId}` : null,
     endpoint => apiv3Get(endpoint).then((response) => {
@@ -33,3 +31,19 @@ export const useSWRxUserBookmarks = (userId?: string): SWRResponse<IPageHasId[],
     }),
   );
 };
+
+export const useSWRMUTxCurrentUserBookmarks = (): SWRMutationResponse<IPageHasId[], Error> => {
+  const { data: currentUser } = useCurrentUser();
+
+  return useSWRMutation(
+    currentUser != null ? `/bookmarks/${currentUser?._id}` : null,
+    endpoint => apiv3Get(endpoint).then((response) => {
+      const { userRootBookmarks } = response.data;
+      return userRootBookmarks.map((item) => {
+        return {
+          ...item.page,
+        };
+      });
+    }),
+  );
+};

+ 11 - 6
apps/app/src/stores/editor.tsx

@@ -1,13 +1,13 @@
 import { useCallback } from 'react';
 
 import { Nullable, withUtils, type SWRResponseWithUtils } from '@growi/core';
-import { SWRResponse } from 'swr';
+import useSWR, { type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get, apiv3Put } from '~/client/util/apiv3-client';
-import { IEditorSettings } from '~/interfaces/editor-settings';
-import { SlackChannels } from '~/interfaces/user-trigger-notification';
+import type { IEditorSettings } from '~/interfaces/editor-settings';
+import type { SlackChannels } from '~/interfaces/user-trigger-notification';
 
 import {
   useCurrentUser, useDefaultIndentSize, useIsGuestUser, useIsReadOnlyUser,
@@ -41,7 +41,9 @@ export const useEditorSettings = (): SWRResponseWithUtils<EditorSettingsOperatio
 
   const swrResult = useSWRImmutable(
     (isGuestUser || isReadOnlyUser) ? null : ['/personal-setting/editor-settings', currentUser?.username],
-    ([endpoint]) => apiv3Get(endpoint).then(result => result.data),
+    ([endpoint]) => {
+      return apiv3Get(endpoint).then(result => result.data);
+    },
     {
       // use: [localStorageMiddleware], // store to localStorage for initialization fastly
       // fallbackData: undefined,
@@ -78,10 +80,13 @@ export const useCurrentIndentSize = (): SWRResponse<number, Error> => {
 */
 export const useSWRxSlackChannels = (currentPagePath: Nullable<string>): SWRResponse<string[], Error> => {
   const shouldFetch: boolean = currentPagePath != null;
-  return useSWRImmutable(
+  return useSWR(
     shouldFetch ? ['/pages.updatePost', currentPagePath] : null,
     ([endpoint, path]) => apiGet(endpoint, { path }).then((response: SlackChannels) => response.updatePost),
-    { fallbackData: [''] },
+    {
+      revalidateOnFocus: false,
+      fallbackData: [''],
+    },
   );
 };
 

+ 31 - 9
apps/app/src/stores/page.tsx

@@ -4,20 +4,20 @@ import type {
   IPageInfoForEntity, IPagePopulatedToShowRevision, Nullable, SWRInfinitePageRevisionsResponse,
 } from '@growi/core';
 import { Ref, isClient, pagePathUtils } from '@growi/core';
-import useSWR, { mutate, SWRResponse } from 'swr';
+import useSWR, { mutate, useSWRConfig, type SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
-import useSWRInfinite, { SWRInfiniteResponse } from 'swr/infinite';
-import useSWRMutation, { SWRMutationResponse } from 'swr/mutation';
+import useSWRInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
+import useSWRMutation, { type SWRMutationResponse } from 'swr/mutation';
 
 import { apiGet } from '~/client/util/apiv1-client';
 import { apiv3Get } from '~/client/util/apiv3-client';
-import {
+import type {
   IPageInfo, IPageInfoForOperation,
 } from '~/interfaces/page';
-import { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
-import { IRevision, IRevisionHasId } from '~/interfaces/revision';
+import type { IRecordApplicableGrant, IResIsGrantNormalized } from '~/interfaces/page-grant';
+import type { IRevision, IRevisionHasId } from '~/interfaces/revision';
 
-import { IPageTagsInfo } from '../interfaces/tag';
+import type { IPageTagsInfo } from '../interfaces/tag';
 
 import {
   useCurrentPathname, useShareLinkId, useIsGuestUser, useIsReadOnlyUser,
@@ -47,18 +47,22 @@ export const useTemplateBodyData = (initialData?: string): SWRResponse<string, E
   return useStaticSWR<string, Error>('templateBodyData', initialData);
 };
 
+/** "useSWRxCurrentPage" is intended for initial data retrieval only. Use "useSWRMUTxCurrentPage" for revalidation */
 export const useSWRxCurrentPage = (initialData?: IPagePopulatedToShowRevision|null): SWRResponse<IPagePopulatedToShowRevision|null> => {
   const key = 'currentPage';
 
+  const { cache } = useSWRConfig();
+  const shouldMutate = initialData?._id !== cache.get(key)?.data?._id && initialData !== undefined;
+
   useEffect(() => {
-    if (initialData !== undefined) {
+    if (shouldMutate) {
       mutate(key, initialData, {
         optimisticData: initialData,
         populateCache: true,
         revalidate: false,
       });
     }
-  }, [initialData, key]);
+  }, [initialData, key, shouldMutate]);
 
   return useSWR(key, null, {
     keepPreviousData: true,
@@ -157,6 +161,24 @@ export const useSWRxPageInfo = (
   return swrResult;
 };
 
+export const useSWRMUTxPageInfo = (
+    pageId: string | null | undefined,
+    shareLinkId?: string | null,
+): SWRMutationResponse<IPageInfo | IPageInfoForOperation> => {
+
+  // assign null if shareLinkId is undefined in order to identify SWR key only by pageId
+  const fixedShareLinkId = shareLinkId ?? null;
+
+  const key = useMemo(() => {
+    return pageId != null ? ['/page/info', pageId, fixedShareLinkId] : null;
+  }, [fixedShareLinkId, pageId]);
+
+  return useSWRMutation(
+    key,
+    ([endpoint, pageId, shareLinkId]: [string, string, string|null]) => apiv3Get(endpoint, { pageId, shareLinkId }).then(response => response.data),
+  );
+};
+
 export const useSWRxPageRevision = (pageId: string, revisionId: Ref<IRevision>): SWRResponse<IRevisionHasId> => {
   const key = [`/revisions/${revisionId}`, pageId, revisionId];
   return useSWRImmutable(

+ 0 - 0
apps/app/test/cypress/integration/0-advanced-examples/misc.spec.ts → apps/app/test/cypress/e2e/0-advanced-examples/misc.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/0-advanced-examples/viewport.spec.ts → apps/app/test/cypress/e2e/0-advanced-examples/viewport.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/10-install/10-install--install.spec.ts → apps/app/test/cypress/e2e/10-install/10-install--install.cy.ts


+ 15 - 22
apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-page.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-page.cy.ts

@@ -1,3 +1,16 @@
+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-';
 
@@ -72,17 +85,7 @@ context('Access to page', () => {
     const body1 = 'hello';
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
-    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('.grw-editor-navbar-bottom').should('be.visible');
+    openEditor();
 
     // check edited contents after save
     cy.get('.CodeMirror').type(body1);
@@ -100,17 +103,7 @@ context('Access to page', () => {
 
     cy.visit('/Sandbox/testForUseEditingMarkdown');
 
-    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('.grw-editor-navbar-bottom').should('be.visible');
+    openEditor();
 
     // check editing contents with shortcut key
     cy.get('.CodeMirror-line').children().first().invoke('text').then((text) => {

+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--access-to-pagelist.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--access-to-pagelist.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--click-page-icons.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--click-page-icons.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--sticky-features.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--sticky-features.cy.ts


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


+ 0 - 0
apps/app/test/cypress/integration/20-basic-features/20-basic-features--username-mention.spec.ts → apps/app/test/cypress/e2e/20-basic-features/20-basic-features--username-mention.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.spec.ts → apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--access-to-page.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.spec.ts → apps/app/test/cypress/e2e/21-basic-features-for-guest/21-basic-features-for-guest--sticky-for-guest.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/22-sharelink/22-sharelink--access-to-sharelink.spec.ts → apps/app/test/cypress/e2e/22-sharelink/22-sharelink--access-to-sharelink.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/23-editor/23-editor--saving.spec.ts → apps/app/test/cypress/e2e/23-editor/23-editor--saving.cy.ts


+ 12 - 3
apps/app/test/cypress/integration/23-editor/23-editor--with-navigation.ts → apps/app/test/cypress/e2e/23-editor/23-editor--with-navigation.cy.ts

@@ -1,3 +1,5 @@
+import path from 'path-browserify';
+
 function openEditor() {
   cy.get('#grw-page-editor-mode-manager').as('pageEditorModeManager').should('be.visible');
   cy.waitUntil(() => {
@@ -22,7 +24,11 @@ context('Editor while uploading to a new page', () => {
     });
   });
 
-  // for https://redmine.weseek.co.jp/issues/122040
+  /**
+   * for the issues:
+   * @see https://redmine.weseek.co.jp/issues/122040
+   * @see https://redmine.weseek.co.jp/issues/124281
+   */
   it('should not be cleared and should prevent GrantSelector from modified', { scrollBehavior: false }, () => {
     cy.visit('/Sandbox/for-122040');
 
@@ -59,7 +65,7 @@ context('Editor while uploading to a new page', () => {
     cy.screenshot(`${ssPrefix}-prevent-grantselector-modified-2`);
 
     // drag-drop a file
-    const filePath = 'test/cypress/integration/23-editor/assets/example.txt';
+    const filePath = path.relative('/', path.resolve(Cypress.spec.relative, '../assets/example.txt'));
     cy.get('.dropzone').selectFile(filePath, { action: 'drag-drop' });
 
     // expect
@@ -82,7 +88,10 @@ context.skip('Editor while navigation', () => {
     });
   });
 
-  // for https://redmine.weseek.co.jp/issues/115285
+  /**
+   * for the issue:
+   * @see https://redmine.weseek.co.jp/issues/115285
+   */
   it('Successfully updating the page body', { scrollBehavior: false }, () => {
     const page1Path = '/Sandbox/for-115285/page1';
     const page2Path = '/Sandbox/for-115285/page2';

+ 0 - 0
apps/app/test/cypress/integration/23-editor/assets/example.txt → apps/app/test/cypress/e2e/23-editor/assets/example.txt


+ 0 - 0
apps/app/test/cypress/integration/30-search/30-search--search.spec.ts → apps/app/test/cypress/e2e/30-search/30-search--search.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/40-admin/40-admin--access-to-admin-page.spec.ts → apps/app/test/cypress/e2e/40-admin/40-admin--access-to-admin-page.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/50-sidebar/50-sidebar--access-to-side-bar.spec.ts → apps/app/test/cypress/e2e/50-sidebar/50-sidebar--access-to-side-bar.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/50-sidebar/50-sidebar--switching-sidebar-mode.spec.ts → apps/app/test/cypress/e2e/50-sidebar/50-sidebar--switching-sidebar-mode.cy.ts


+ 0 - 0
apps/app/test/cypress/integration/60-home/60-home--home.spec.ts → apps/app/test/cypress/e2e/60-home/60-home--home.cy.ts


+ 2 - 1
bin/data-migrations/src/migrations/v60x/index.js

@@ -1,6 +1,7 @@
 const bracketlink = require('./bracketlink');
 const csv = require('./csv');
+const drawio = require('./drawio');
 const plantUML = require('./plantuml');
 const tsv = require('./tsv');
 
-module.exports = [...bracketlink, ...csv, ...plantUML, ...tsv];
+module.exports = [...bracketlink, ...csv, ...drawio, ...plantUML, ...tsv];

+ 2 - 0
package.json

@@ -61,6 +61,7 @@
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
     "@types/node": "^17.0.43",
+    "@types/path-browserify": "^1.0.0",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
     "@typescript-eslint/parser": "^5.59.7",
@@ -80,6 +81,7 @@
     "eslint-plugin-vitest": "^0.2.3",
     "glob": "^8.1.0",
     "mock-require": "^3.0.3",
+    "path-browserify": "^1.0.1",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
     "reg-keygen-git-hash-plugin": "^0.11.1",

+ 1 - 0
packages/core/src/interfaces/page.ts

@@ -31,6 +31,7 @@ export type IPage = {
   deleteUser: Ref<IUser>,
   deletedAt: Date,
   latestRevision?: Ref<IRevision>,
+  latestRevisionBodyLength?: number,
   expandContentWidth?: boolean,
 }
 

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

@@ -121,11 +121,11 @@ export const isCreatablePage = (path: string): boolean => {
 };
 
 /**
- * return user path
+ * return user's homepage path
  * @param user
  */
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
-export const userPageRoot = (user: any): string => {
+export const userHomepagePath = (user: any): string => {
   if (!user || !user.username) {
     return '';
   }

+ 2 - 2
packages/ui/src/components/User/UserPicture.tsx

@@ -10,7 +10,7 @@ import type { UncontrolledTooltipProps } from 'reactstrap';
 
 const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
 
-const { userPageRoot } = pagePathUtils;
+const { userHomepagePath } = pagePathUtils;
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
@@ -29,7 +29,7 @@ const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootProps
   const router = useRouter();
 
   const { user } = props;
-  const href = userPageRoot(user);
+  const href = userHomepagePath(user);
 
   const clickHandler = useCallback(() => {
     router.push(href);

+ 5 - 0
yarn.lock

@@ -3974,6 +3974,11 @@
   resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.3.tgz#705bb349e789efa06f43f128cef51240753424cb"
   integrity sha512-SuT16Q1K51EAVPz1K29DJ/sXjhSQ0zjvsypYJ6tlwVsRV9jwW5Adq2ch8Dq8kDBCkYnELS7N7VNCSB5nC56t/g==
 
+"@types/path-browserify@^1.0.0":
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/@types/path-browserify/-/path-browserify-1.0.0.tgz#294ec6e88b6b0d340a3897b7120e5b393f16690e"
+  integrity sha512-XMCcyhSvxcch8b7rZAtFAaierBYdeHXVvg2iYnxOV0MCQHmPuRRmGZPFDRzPayxcGiiSL1Te9UIO+f3cuj0tfw==
+
 "@types/pixelmatch@^5.2.2":
   version "5.2.4"
   resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6"