Przeglądaj źródła

Merge branch 'master' into feat/enhanced-access-token

reiji-h 1 rok temu
rodzic
commit
71ac0e1293
37 zmienionych plików z 889 dodań i 111 usunięć
  1. 3 1
      apps/app/public/static/locales/en_US/admin.json
  2. 4 3
      apps/app/public/static/locales/en_US/translation.json
  3. 3 1
      apps/app/public/static/locales/fr_FR/admin.json
  4. 4 3
      apps/app/public/static/locales/fr_FR/translation.json
  5. 3 2
      apps/app/public/static/locales/ja_JP/admin.json
  6. 4 3
      apps/app/public/static/locales/ja_JP/translation.json
  7. 3 1
      apps/app/public/static/locales/zh_CN/admin.json
  8. 7 6
      apps/app/public/static/locales/zh_CN/translation.json
  9. 14 0
      apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx
  10. 1 1
      apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss
  11. 9 9
      apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx
  12. 0 5
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.module.scss
  13. 0 45
      apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.tsx
  14. 15 1
      apps/app/src/client/components/PageSideContents/PageSideContents.tsx
  15. 1 1
      apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx
  16. 11 0
      apps/app/src/client/services/AdminCustomizeContainer.js
  17. 4 0
      apps/app/src/components/PageView/PageViewLayout.module.scss
  18. 4 1
      apps/app/src/pages/[[...path]].page.tsx
  19. 5 1
      apps/app/src/pages/share/[[...path]].page.tsx
  20. 4 0
      apps/app/src/server/routes/apiv3/customize-setting.js
  21. 290 8
      apps/app/src/server/routes/apiv3/page/index.ts
  22. 232 7
      apps/app/src/server/routes/apiv3/pages/index.js
  23. 26 4
      apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js
  24. 7 3
      apps/app/src/server/service/config-manager/config-definition.ts
  25. 4 0
      apps/app/src/stores-universal/context.tsx
  26. 3 2
      bin/data-migrations/README.md
  27. 2 1
      bin/data-migrations/src/migrations/v60x/index.js
  28. 25 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/README.ja.md
  29. 43 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/example-expected.md
  30. 37 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/example.md
  31. 1 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/index.js
  32. 65 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.js
  33. 43 0
      bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.spec.js
  34. 9 0
      bin/vitest.config.ts
  35. 1 1
      packages/preset-themes/src/styles/classic.scss
  36. 1 1
      packages/preset-themes/src/styles/default.scss
  37. 1 0
      vitest.workspace.mts

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

@@ -493,7 +493,9 @@
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments": "Show all reply comments",
       "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted.",
       "show_all_reply_comments_desc": "When the setting value is off, comments other than the latest two are omitted.",
       "select_search_scope_children_as_default": "Select 'Only children of this tree' as default value of search range",
       "select_search_scope_children_as_default": "Select 'Only children of this tree' as default value of search range",
-      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range."
+      "select_search_scope_children_as_default_desc": "When the setting value is off, 'All pages' is used as default value of search range.",
+      "show_page_side_authors": "Always display creators and updaters above the table of contents",
+      "show_page_side_authors_desc": "Displays information about the creator and the last updater above the table of contents in the page sidebar."
     },
     },
       "presentation": "Presentation",
       "presentation": "Presentation",
     "presentation_options": {
     "presentation_options": {

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

@@ -184,7 +184,9 @@
   },
   },
   "author_info": {
   "author_info": {
     "created_at": "Created at",
     "created_at": "Created at",
-    "last_revision_posted_at": "Last revision posted at"
+    "created_by": "Created by",
+    "last_revision_posted_at": "Last revision posted at",
+    "updated_by": "Updated by"
   },
   },
   "installer": {
   "installer": {
     "tab": "Create account",
     "tab": "Create account",
@@ -873,8 +875,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "Show WIP",
     "show_wip_page": "Show WIP",
-    "size_s": "Size: S",
-    "size_l": "Size: L"
+    "compact_view": "Compact View"
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"

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

@@ -493,7 +493,9 @@
       "show_all_reply_comments": "Afficher tout les commentaires",
       "show_all_reply_comments": "Afficher tout les commentaires",
       "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
       "show_all_reply_comments_desc": "Lorsque désactivé, seul les deux commentaires les plus récents sont affichés",
       "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
       "select_search_scope_children_as_default": "'Seulement enfant de ce chemin' lors de la recherche",
-      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche."
+      "select_search_scope_children_as_default_desc": "Lorsque désactivé, utilise 'Toutes les pages' en portée de recherche.",
+      "show_page_side_authors": "Toujours afficher les créateurs et les modificateurs au-dessus de la table des matières",
+      "show_page_side_authors_desc": "Affiche les informations sur le créateur et le dernier modificateur au-dessus de la table des matières dans la barre latérale de la page."
     },
     },
     "presentation": "Présentation",
     "presentation": "Présentation",
     "presentation_options": {
     "presentation_options": {

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

@@ -185,7 +185,9 @@
   },
   },
   "author_info": {
   "author_info": {
     "created_at": "Crée le",
     "created_at": "Crée le",
-    "last_revision_posted_at": "Dernière révision le"
+    "created_by": "Créé par",
+    "last_revision_posted_at": "Dernière révision le",
+    "updated_by": "Mis à jour par"
   },
   },
   "installer": {
   "installer": {
     "tab": "Créer compte",
     "tab": "Créer compte",
@@ -867,8 +869,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "Voir brouillon",
     "show_wip_page": "Voir brouillon",
-    "size_s": "Taille: P",
-    "size_l": "Taille: G"
+    "compact_view": "Vue compacte"
   },
   },
   "sync-latest-revision-body": {
   "sync-latest-revision-body": {
     "menuitem": "Synchroniser avec la dernière révision",
     "menuitem": "Synchroniser avec la dernière révision",

+ 3 - 2
apps/app/public/static/locales/ja_JP/admin.json

@@ -502,8 +502,9 @@
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments": "返信コメントを全て表示する",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "show_all_reply_comments_desc": "OFFの場合、最新2件のコメント以外が省略されます。",
       "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
       "select_search_scope_children_as_default": "検索範囲のデフォルト設定を「この階層下の子ページ」にする",
-      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。"
-
+      "select_search_scope_children_as_default_desc": "OFFの場合、検索範囲のデフォルト設定は「全てのページ」になります。",
+      "show_page_side_authors": "作成者・更新者を目次上部に常時表示する",
+      "show_page_side_authors_desc": "ページサイドバーの目次上部に作成者と最終更新者の情報を表示します。"
     },
     },
     "presentation":"プレゼンテーション",
     "presentation":"プレゼンテーション",
     "presentation_options":{
     "presentation_options":{

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

@@ -185,7 +185,9 @@
   },
   },
   "author_info": {
   "author_info": {
     "created_at": "作成日",
     "created_at": "作成日",
-    "last_revision_posted_at": "最終更新日"
+    "created_by": "作成者:",
+    "last_revision_posted_at": "最終更新日",
+    "updated_by": "最終更新者:"
   },
   },
   "installer": {
   "installer": {
     "tab": "アカウント作成",
     "tab": "アカウント作成",
@@ -905,8 +907,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "WIP を表示",
     "show_wip_page": "WIP を表示",
-    "size_s": "サイズ: S",
-    "size_l": "サイズ: L"
+    "compact_view": "コンパクト表示"
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "無題のページ"
     "untitled": "無題のページ"

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

@@ -502,7 +502,9 @@
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments": "显示所有回复评论",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "show_all_reply_comments_desc": "当设置值为“关”时,将忽略最近两个之外的注释。",
       "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
       "select_search_scope_children_as_default": "选择“当前分支以下内容”, 作为搜索范围的默认值",
-      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。"
+      "select_search_scope_children_as_default_desc": "当设置值为“关”时,“所有页面”被作为搜索范围的默认值。",
+      "show_page_side_authors": "在目录上方始终显示创建者和更新者",
+      "show_page_side_authors_desc": "在页面侧边栏的目录上方显示创建者和最后更新者的信息。"
     },
     },
       "presentation": "表达",
       "presentation": "表达",
       "presentation_options": {
       "presentation_options": {

+ 7 - 6
apps/app/public/static/locales/zh_CN/translation.json

@@ -189,10 +189,12 @@
   "custom_navigation": {
   "custom_navigation": {
     "no_pages_under_this_page": "There are no pages under this page."
     "no_pages_under_this_page": "There are no pages under this page."
   },
   },
-  "author_info": {
-    "created_at": "Created at",
-    "last_revision_posted_at": "Last revision posted at"
-  },
+"author_info": {
+  "created_at": "创建日期",
+  "created_by": "创建者:",
+  "last_revision_posted_at": "最后更新日期",
+  "updated_by": "更新者:"
+},
   "installer": {
   "installer": {
     "tab": "创建账户",
     "tab": "创建账户",
     "title": "安装",
     "title": "安装",
@@ -876,8 +878,7 @@
   },
   },
   "sidebar_header": {
   "sidebar_header": {
     "show_wip_page": "显示 WIP",
     "show_wip_page": "显示 WIP",
-    "size_s": "尺寸: S",
-    "size_l": "尺寸: L"
+    "compact_view": "紧凑视图"
   },
   },
   "create_page": {
   "create_page": {
     "untitled": "Untitled"
     "untitled": "Untitled"

+ 14 - 0
apps/app/src/client/components/Admin/Customize/CustomizeFunctionSetting.tsx

@@ -133,6 +133,20 @@ const CustomizeFunctionSetting = (props: Props): JSX.Element => {
             </div>
             </div>
           </div>
           </div>
 
 
+          <div className="row">
+            <div className="offset-md-2 col-md-7 text-start">
+              <CustomizeFunctionOption
+                optionId="showPageSideAuthors"
+                label={t('admin:customize_settings.function_options.show_page_side_authors')}
+                isChecked={adminCustomizeContainer.state.showPageSideAuthors}
+                onChecked={() => { adminCustomizeContainer.switchShowPageSideAuthors() }}
+              >
+                <p className="form-text text-muted">
+                  {t('admin:customize_settings.function_options.show_page_side_authors_desc')}
+                </p>
+              </CustomizeFunctionOption>
+            </div>
+          </div>
 
 
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
           <AdminUpdateButtonRow onClick={onClickSubmit} disabled={adminCustomizeContainer.state.retrieveError != null} />
         </div>
         </div>

+ 1 - 1
apps/app/src/client/components/AuthorInfo/AuthorInfo.module.scss

@@ -1,7 +1,7 @@
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 @use '@growi/core-styles/scss/bootstrap/init' as bs;
 
 
 $author-font-size: 12px;
 $author-font-size: 12px;
-$date-font-size: 11px;
+$date-font-size: 12px;
 
 
 .grw-author-info :global {
 .grw-author-info :global {
   font-size: $author-font-size;
   font-size: $author-font-size;

+ 9 - 9
apps/app/src/client/components/AuthorInfo/AuthorInfo.tsx

@@ -28,20 +28,20 @@ type AuthorInfoProps = {
   date: Date,
   date: Date,
   user?: IUserHasId | Ref<IUser>,
   user?: IUserHasId | Ref<IUser>,
   mode: 'create' | 'update',
   mode: 'create' | 'update',
-  locate: 'subnav' | 'footer',
+  locate: 'pageSide' | 'footer',
 }
 }
 
 
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
 export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   const { t } = useTranslation();
   const { t } = useTranslation();
   const {
   const {
-    date, user, mode = 'create', locate = 'subnav',
+    date, user, mode = 'create', locate = 'pageSide',
   } = props;
   } = props;
 
 
   const formatType = 'yyyy/MM/dd HH:mm';
   const formatType = 'yyyy/MM/dd HH:mm';
 
 
-  const infoLabelForSubNav = mode === 'create'
-    ? 'Created by'
-    : 'Updated by';
+  const infoLabelForPageSide = mode === 'create'
+    ? t('author_info.created_by')
+    : t('author_info.updated_by');
   const nullinfoLabelForFooter = mode === 'create'
   const nullinfoLabelForFooter = mode === 'create'
     ? 'Created by'
     ? 'Created by'
     : 'Updated by';
     : 'Updated by';
@@ -76,13 +76,13 @@ export const AuthorInfo = (props: AuthorInfoProps): JSX.Element => {
   };
   };
 
 
   return (
   return (
-    <div className={`grw-author-info ${styles['grw-author-info']} d-flex align-items-center`}>
-      <div className="me-2">
+    <div className={`grw-author-info ${styles['grw-author-info']} d-flex align-items-center mb-2`}>
+      <div className="me-2 d-none d-lg-block">
         <UserPicture user={user} size="sm" />
         <UserPicture user={user} size="sm" />
       </div>
       </div>
       <div>
       <div>
-        <div>{infoLabelForSubNav} {userLabel}</div>
-        <div className="text-muted text-date" data-vrt-blackout-datetime>
+        <div className="text-secondary mb-1">{infoLabelForPageSide} <br className="d-lg-none" />{userLabel}</div>
+        <div className="text-secondary text-date" data-vrt-blackout-datetime>
           {renderParsedDate()}
           {renderParsedDate()}
         </div>
         </div>
       </div>
       </div>

+ 0 - 5
apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.module.scss

@@ -1,5 +0,0 @@
-.grw-page-author-info :global {
-  li {
-    list-style: none;
-  }
-}

+ 0 - 45
apps/app/src/client/components/PageAuthorInfo/PageAuthorInfo.tsx

@@ -1,45 +0,0 @@
-import { memo } from 'react';
-
-import { pagePathUtils } from '@growi/core/dist/utils';
-
-import { useCurrentPathname } from '~/stores-universal/context';
-import { useSWRxCurrentPage } from '~/stores/page';
-import { useIsAbleToShowPageAuthors } from '~/stores/ui';
-
-import { AuthorInfo } from '../AuthorInfo';
-
-
-import styles from './PageAuthorInfo.module.scss';
-
-
-export const PageAuthorInfo = memo((): JSX.Element => {
-  const { data: currentPage } = useSWRxCurrentPage();
-
-  const { data: currentPathname } = useCurrentPathname();
-  const { data: isAbleToShowPageAuthors } = useIsAbleToShowPageAuthors();
-
-  if (!isAbleToShowPageAuthors) {
-    return <></>;
-  }
-
-  const path = currentPage?.path ?? currentPathname;
-
-  if (pagePathUtils.isUsersHomepage(path ?? '')) {
-    return <></>;
-  }
-
-  return (
-    <ul className={`grw-page-author-info ${styles['grw-page-author-info']} text-nowrap border-start d-none d-lg-block d-edit-none py-2 ps-4 mb-0 ms-3`}>
-      <li className="pb-1">
-        {currentPage != null && (
-          <AuthorInfo user={currentPage.creator} date={currentPage.createdAt} mode="create" locate="subnav" />
-        )}
-      </li>
-      <li className="mt-1 pt-1 border-top">
-        {currentPage != null && (
-          <AuthorInfo user={currentPage.lastUpdateUser} date={currentPage.updatedAt} mode="update" locate="subnav" />
-        )}
-      </li>
-    </ul>
-  );
-});

+ 15 - 1
apps/app/src/client/components/PageSideContents/PageSideContents.tsx

@@ -6,7 +6,7 @@ import { useTranslation } from 'next-i18next';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { scroller } from 'react-scroll';
 import { scroller } from 'react-scroll';
 
 
-import { useIsGuestUser, useIsReadOnlyUser } from '~/stores-universal/context';
+import { useIsGuestUser, useIsReadOnlyUser, useShowPageSideAuthors } from '~/stores-universal/context';
 import { useDescendantsPageListModal, useTagEditModal } from '~/stores/modal';
 import { useDescendantsPageListModal, useTagEditModal } from '~/stores/modal';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useSWRxPageInfo, useSWRxTagsInfo } from '~/stores/page';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
 import { useIsAbleToShowTagLabel } from '~/stores/ui';
@@ -28,6 +28,7 @@ const PageTags = dynamic(() => import('../PageTags').then(mod => mod.PageTags),
   loading: PageTagsSkeleton,
   loading: PageTagsSkeleton,
 });
 });
 
 
+const AuthorInfo = dynamic(() => import('~/client/components/AuthorInfo').then(mod => mod.AuthorInfo), { ssr: false });
 
 
 type TagsProps = {
 type TagsProps = {
   pageId: string,
   pageId: string,
@@ -84,6 +85,11 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
   const tagsRef = useRef<HTMLDivElement>(null);
   const tagsRef = useRef<HTMLDivElement>(null);
 
 
   const { data: pageInfo } = useSWRxPageInfo(page._id);
   const { data: pageInfo } = useSWRxPageInfo(page._id);
+  const { data: showPageSideAuthors } = useShowPageSideAuthors();
+
+  const {
+    creator, lastUpdateUser, createdAt, updatedAt,
+  } = page;
 
 
   const pagePath = page.path;
   const pagePath = page.path;
   const isTopPagePath = isTopPage(pagePath);
   const isTopPagePath = isTopPage(pagePath);
@@ -92,6 +98,14 @@ export const PageSideContents = (props: PageSideContentsProps): JSX.Element => {
 
 
   return (
   return (
     <>
     <>
+      {/* AuthorInfo */}
+      {showPageSideAuthors && (
+        <div className="d-none d-md-block page-meta border-bottom pb-2 ms-lg-3 mb-3">
+          <AuthorInfo user={creator} date={createdAt} mode="create" locate="pageSide" />
+          <AuthorInfo user={lastUpdateUser} date={updatedAt} mode="update" locate="pageSide" />
+        </div>
+      )}
+
       {/* Tags */}
       {/* Tags */}
       { page.revision != null && (
       { page.revision != null && (
         <div ref={tagsRef}>
         <div ref={tagsRef}>

+ 1 - 1
apps/app/src/client/components/Sidebar/RecentChanges/RecentChangesSubstance.tsx

@@ -201,7 +201,7 @@ export const RecentChangesHeader = ({
                 onChange={() => {}}
                 onChange={() => {}}
               />
               />
               <label className="form-check-label pe-none" aria-disabled="true">
               <label className="form-check-label pe-none" aria-disabled="true">
-                {isSmall ? t('sidebar_header.size_s') : t('sidebar_header.size_l')}
+                {t('sidebar_header.compact_view')}
               </label>
               </label>
             </div>
             </div>
           </li>
           </li>

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

@@ -40,11 +40,13 @@ export default class AdminCustomizeContainer extends Container {
       currentCustomizeNoscript: '',
       currentCustomizeNoscript: '',
       currentCustomizeCss: '',
       currentCustomizeCss: '',
       currentCustomizeScript: '',
       currentCustomizeScript: '',
+      showPageSideAuthors: false,
     };
     };
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationS = this.switchPageListLimitationS.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationM = this.switchPageListLimitationM.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
     this.switchPageListLimitationL = this.switchPageListLimitationL.bind(this);
     this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
     this.switchPageListLimitationXL = this.switchPageListLimitationXL.bind(this);
+    this.switchShowPageSideAuthors = this.switchShowPageSideAuthors.bind(this);
 
 
   }
   }
 
 
@@ -78,6 +80,7 @@ export default class AdminCustomizeContainer extends Container {
         currentCustomizeNoscript: customizeParams.customizeNoscript,
         currentCustomizeNoscript: customizeParams.customizeNoscript,
         currentCustomizeCss: customizeParams.customizeCss,
         currentCustomizeCss: customizeParams.customizeCss,
         currentCustomizeScript: customizeParams.customizeScript,
         currentCustomizeScript: customizeParams.customizeScript,
+        showPageSideAuthors: customizeParams.showPageSideAuthors,
       });
       });
     }
     }
     catch (err) {
     catch (err) {
@@ -187,6 +190,12 @@ export default class AdminCustomizeContainer extends Container {
     this.setState({ currentCustomizeScript: inpuValue });
     this.setState({ currentCustomizeScript: inpuValue });
   }
   }
 
 
+  /**
+   * Switch showPageSideAuthors
+   */
+  switchShowPageSideAuthors() {
+    this.setState({ showPageSideAuthors: !this.state.showPageSideAuthors });
+  }
 
 
   /**
   /**
    * Update function
    * Update function
@@ -204,6 +213,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isEnabledStaleNotification: this.state.isEnabledStaleNotification,
         isAllReplyShown: this.state.isAllReplyShown,
         isAllReplyShown: this.state.isAllReplyShown,
         isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
         isSearchScopeChildrenAsDefault: this.state.isSearchScopeChildrenAsDefault,
+        showPageSideAuthors: this.state.showPageSideAuthors,
       });
       });
       const { customizedParams } = response.data;
       const { customizedParams } = response.data;
       this.setState({
       this.setState({
@@ -216,6 +226,7 @@ export default class AdminCustomizeContainer extends Container {
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isEnabledStaleNotification: customizedParams.isEnabledStaleNotification,
         isAllReplyShown: customizedParams.isAllReplyShown,
         isAllReplyShown: customizedParams.isAllReplyShown,
         isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
         isSearchScopeChildrenAsDefault: customizedParams.isSearchScopeChildrenAsDefault,
+        showPageSideAuthors: customizedParams.showPageSideAuthors,
       });
       });
     }
     }
     catch (err) {
     catch (err) {

+ 4 - 0
apps/app/src/components/PageView/PageViewLayout.module.scss

@@ -29,6 +29,10 @@ $page-view-layout-margin-top: 32px;
       margin-left: 30px;
       margin-left: 30px;
     }
     }
 
 
+    @include bs.media-breakpoint-up(md) {
+      max-width: 170px;
+    }
+
     @include bs.media-breakpoint-down(sm) {
     @include bs.media-breakpoint-down(sm) {
       position: fixed;
       position: fixed;
       right: 1rem;
       right: 1rem;

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

@@ -41,7 +41,7 @@ import {
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useIsAclEnabled, useIsSearchPage, useIsEnabledAttachTitleHeader,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useCsrfToken, useIsSearchScopeChildrenAsDefault, useIsEnabledMarp, useCurrentPathname,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
   useIsSlackConfigured, useRendererConfig, useGrowiCloudUri,
-  useIsAllReplyShown, useIsContainerFluid, useIsNotCreatable,
+  useIsAllReplyShown, useShowPageSideAuthors, useIsContainerFluid, useIsNotCreatable,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useIsUploadAllFileAllowed, useIsUploadEnabled,
   useElasticsearchMaxBodyLengthToIndex,
   useElasticsearchMaxBodyLengthToIndex,
   useIsLocalAccountRegistrationEnabled,
   useIsLocalAccountRegistrationEnabled,
@@ -177,6 +177,7 @@ type Props = CommonProps & {
   drawioUri: string | null,
   drawioUri: string | null,
   // highlightJsStyle: string,
   // highlightJsStyle: string,
   isAllReplyShown: boolean,
   isAllReplyShown: boolean,
+  showPageSideAuthors: boolean,
   isContainerFluid: boolean,
   isContainerFluid: boolean,
   isUploadEnabled: boolean,
   isUploadEnabled: boolean,
   isUploadAllFileAllowed: boolean,
   isUploadAllFileAllowed: boolean,
@@ -240,6 +241,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useRendererSettings(props.rendererSettingsStr != null ? JSON.parse(props.rendererSettingsStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   // useGrowiRendererConfig(props.growiRendererConfigStr != null ? JSON.parse(props.growiRendererConfigStr) : undefined);
   useIsAllReplyShown(props.isAllReplyShown);
   useIsAllReplyShown(props.isAllReplyShown);
+  useShowPageSideAuthors(props.showPageSideAuthors);
 
 
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadAllFileAllowed(props.isUploadAllFileAllowed);
   useIsUploadEnabled(props.isUploadEnabled);
   useIsUploadEnabled(props.isUploadEnabled);
@@ -581,6 +583,7 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
   props.drawioUri = configManager.getConfig('app:drawioUri');
   props.drawioUri = configManager.getConfig('app:drawioUri');
   // props.highlightJsStyle = configManager.getConfig('customize:highlightJsStyle');
   // props.highlightJsStyle = configManager.getConfig('customize:highlightJsStyle');
   props.isAllReplyShown = configManager.getConfig('customize:isAllReplyShown');
   props.isAllReplyShown = configManager.getConfig('customize:isAllReplyShown');
+  props.showPageSideAuthors = configManager.getConfig('customize:showPageSideAuthors');
   props.isContainerFluid = configManager.getConfig('customize:isContainerFluid');
   props.isContainerFluid = configManager.getConfig('customize:isContainerFluid');
   props.isEnabledStaleNotification = configManager.getConfig('customize:isEnabledStaleNotification');
   props.isEnabledStaleNotification = configManager.getConfig('customize:isEnabledStaleNotification');
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');
   props.disableLinkSharing = configManager.getConfig('security:disableLinkSharing');

+ 5 - 1
apps/app/src/pages/share/[[...path]].page.tsx

@@ -23,7 +23,7 @@ import ShareLink from '~/server/models/share-link';
 import {
 import {
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useCurrentUser, useRendererConfig, useIsSearchPage, useCurrentPathname,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
   useShareLinkId, useIsSearchServiceConfigured, useIsSearchServiceReachable, useIsSearchScopeChildrenAsDefault, useIsContainerFluid, useIsEnabledMarp,
-  useIsLocalAccountRegistrationEnabled,
+  useIsLocalAccountRegistrationEnabled, useShowPageSideAuthors,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import { useCurrentPageId, useIsNotFound, useSWRMUTxCurrentPage } from '~/stores/page';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
@@ -49,6 +49,7 @@ type Props = CommonProps & {
   isSearchServiceConfigured: boolean,
   isSearchServiceConfigured: boolean,
   isSearchServiceReachable: boolean,
   isSearchServiceReachable: boolean,
   isSearchScopeChildrenAsDefault: boolean,
   isSearchScopeChildrenAsDefault: boolean,
+  showPageSideAuthors: boolean,
   isEnabledMarp: boolean,
   isEnabledMarp: boolean,
   isLocalAccountRegistrationEnabled: boolean,
   isLocalAccountRegistrationEnabled: boolean,
   drawioUri: string | null,
   drawioUri: string | null,
@@ -99,6 +100,7 @@ const SharedPage: NextPageWithLayout<Props> = (props: Props) => {
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsSearchScopeChildrenAsDefault(props.isSearchScopeChildrenAsDefault);
   useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
   useIsEnabledMarp(props.rendererConfig.isEnabledMarp);
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
   useIsLocalAccountRegistrationEnabled(props.isLocalAccountRegistrationEnabled);
+  useShowPageSideAuthors(props.showPageSideAuthors);
   useIsContainerFluid(props.isContainerFluid);
   useIsContainerFluid(props.isContainerFluid);
 
 
   const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
   const { trigger: mutateCurrentPage, data: currentPage } = useSWRMUTxCurrentPage();
@@ -164,6 +166,8 @@ function injectServerConfigurations(context: GetServerSidePropsContext, props: P
 
 
   props.drawioUri = configManager.getConfig('app:drawioUri');
   props.drawioUri = configManager.getConfig('app:drawioUri');
 
 
+  props.showPageSideAuthors = configManager.getConfig('customize:showPageSideAuthors');
+
   props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
   props.isLocalAccountRegistrationEnabled = crowi.passportService.isLocalStrategySetup
     && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;
     && configManager.getConfig('security:registrationMode') !== RegistrationMode.CLOSED;
 
 

+ 4 - 0
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -222,6 +222,7 @@ module.exports = (crowi) => {
       body('isEnabledStaleNotification').isBoolean(),
       body('isEnabledStaleNotification').isBoolean(),
       body('isAllReplyShown').isBoolean(),
       body('isAllReplyShown').isBoolean(),
       body('isSearchScopeChildrenAsDefault').isBoolean(),
       body('isSearchScopeChildrenAsDefault').isBoolean(),
+      body('showPageSideAuthors').isBoolean(),
     ],
     ],
     CustomizePresentation: [
     CustomizePresentation: [
       body('isEnabledMarp').isBoolean(),
       body('isEnabledMarp').isBoolean(),
@@ -283,6 +284,7 @@ module.exports = (crowi) => {
       pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
       pageLimitationXL: await configManager.getConfig('customize:showPageLimitationXL'),
       isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
       isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
       isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
       isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
+      showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
       isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
       isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
       isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
       isEnabledMarp: await configManager.getConfig('customize:isEnabledMarp'),
       styleName: await configManager.getConfig('customize:highlightJsStyle'),
       styleName: await configManager.getConfig('customize:highlightJsStyle'),
@@ -601,6 +603,7 @@ module.exports = (crowi) => {
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
       'customize:isEnabledStaleNotification': req.body.isEnabledStaleNotification,
       'customize:isAllReplyShown': req.body.isAllReplyShown,
       'customize:isAllReplyShown': req.body.isAllReplyShown,
       'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
       'customize:isSearchScopeChildrenAsDefault': req.body.isSearchScopeChildrenAsDefault,
+      'customize:showPageSideAuthors': req.body.showPageSideAuthors,
     };
     };
 
 
     try {
     try {
@@ -615,6 +618,7 @@ module.exports = (crowi) => {
         isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
         isEnabledStaleNotification: await configManager.getConfig('customize:isEnabledStaleNotification'),
         isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
         isAllReplyShown: await configManager.getConfig('customize:isAllReplyShown'),
         isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
         isSearchScopeChildrenAsDefault: await configManager.getConfig('customize:isSearchScopeChildrenAsDefault'),
+        showPageSideAuthors: await configManager.getConfig('customize:showPageSideAuthors'),
       };
       };
       const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
       const parameters = { action: SupportedAction.ACTION_ADMIN_FUNCTION_UPDATE };
       activityEvent.emit('update', res.locals.activity._id, parameters);
       activityEvent.emit('update', res.locals.activity._id, parameters);

+ 290 - 8
apps/app/src/server/routes/apiv3/page/index.ts

@@ -189,7 +189,7 @@ module.exports = (crowi) => {
    *      get:
    *      get:
    *        tags: [Page]
    *        tags: [Page]
    *        operationId: getPage
    *        operationId: getPage
-   *        summary: /page
+   *        summary: Get page
    *        description: get page by pagePath or pageId
    *        description: get page by pagePath or pageId
    *        parameters:
    *        parameters:
    *          - name: pageId
    *          - name: pageId
@@ -266,6 +266,31 @@ module.exports = (crowi) => {
     return res.apiv3({ page, pages });
     return res.apiv3({ page, pages });
   });
   });
 
 
+  /**
+   * @swagger
+   *   /page/exist:
+   *     get:
+   *       tags: [Page]
+   *       summary: Check if page exists
+   *       description: Check if a page exists at the specified path
+   *       parameters:
+   *         - name: path
+   *           in: query
+   *           description: The path to check for existence
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully checked page existence.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   isExist:
+   *                     type: boolean
+   */
   router.get('/exist', checkPageExistenceHandlersFactory(crowi));
   router.get('/exist', checkPageExistenceHandlersFactory(crowi));
 
 
   /**
   /**
@@ -274,6 +299,7 @@ module.exports = (crowi) => {
    *    /page:
    *    /page:
    *      post:
    *      post:
    *        tags: [Page]
    *        tags: [Page]
+   *        summary: Create page
    *        operationId: createPage
    *        operationId: createPage
    *        description: Create page
    *        description: Create page
    *        requestBody:
    *        requestBody:
@@ -397,7 +423,7 @@ module.exports = (crowi) => {
    *    /page/likes:
    *    /page/likes:
    *      put:
    *      put:
    *        tags: [Page]
    *        tags: [Page]
-   *        summary: /page/likes
+   *        summary: Get page likes
    *        description: Update liked status
    *        description: Update liked status
    *        operationId: updateLikedStatus
    *        operationId: updateLikedStatus
    *        requestBody:
    *        requestBody:
@@ -465,7 +491,7 @@ module.exports = (crowi) => {
    *    /page/info:
    *    /page/info:
    *      get:
    *      get:
    *        tags: [Page]
    *        tags: [Page]
-   *        summary: /page/info
+   *        summary: Get page info
    *        description: Retrieve current page info
    *        description: Retrieve current page info
    *        operationId: getPageInfo
    *        operationId: getPageInfo
    *        requestBody:
    *        requestBody:
@@ -509,7 +535,7 @@ module.exports = (crowi) => {
    *    /page/grant-data:
    *    /page/grant-data:
    *      get:
    *      get:
    *        tags: [Page]
    *        tags: [Page]
-   *        summary: /page/info
+   *        summary: Get page grant data
    *        description: Retrieve current page's grant data
    *        description: Retrieve current page's grant data
    *        operationId: getPageGrantData
    *        operationId: getPageGrantData
    *        parameters:
    *        parameters:
@@ -604,6 +630,37 @@ module.exports = (crowi) => {
 
 
   // Check if non user related groups are granted page access.
   // Check if non user related groups are granted page access.
   // If specified page does not exist, check the closest ancestor.
   // If specified page does not exist, check the closest ancestor.
+  /**
+   * @swagger
+   *   /page/non-user-related-groups-granted:
+   *     get:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Check if non-user related groups are granted page access
+   *       description: Check if non-user related groups are granted access to a specific page or its closest ancestor
+   *       parameters:
+   *         - name: path
+   *           in: query
+   *           description: Path of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully checked non-user related groups access.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   isNonUserRelatedGroupsGranted:
+   *                     type: boolean
+   *         403:
+   *           description: Forbidden. Cannot access page or ancestor.
+   *         500:
+   *           description: Internal server error.
+   */
   router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
   router.get('/non-user-related-groups-granted', loginRequiredStrictly, validator.nonUserRelatedGroupsGranted, apiV3FormValidator,
     async(req, res: ApiV3Response) => {
     async(req, res: ApiV3Response) => {
       const { user } = req;
       const { user } = req;
@@ -635,7 +692,45 @@ module.exports = (crowi) => {
         return res.apiv3Err(err, 500);
         return res.apiv3Err(err, 500);
       }
       }
     });
     });
-
+  /**
+   * @swagger
+   *   /page/applicable-grant:
+   *     get:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Get applicable grant data
+   *       description: Retrieve applicable grant data for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: query
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully retrieved applicable grant data.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   grant:
+   *                     type: number
+   *                   grantedUsers:
+   *                     type: array
+   *                     items:
+   *                       type: string
+   *                   grantedGroups:
+   *                     type: array
+   *                     items:
+   *                       type: string
+   *         400:
+   *           description: Bad request. Page is unreachable or empty.
+   *         500:
+   *           description: Internal server error.
+   */
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
   router.get('/applicable-grant', loginRequiredStrictly, validator.applicableGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.query;
     const { pageId } = req.query;
 
 
@@ -659,6 +754,43 @@ module.exports = (crowi) => {
     return res.apiv3(data);
     return res.apiv3(data);
   });
   });
 
 
+  /**
+   * @swagger
+   *   /:pageId/grant:
+   *     put:
+   *       tags: [Page]
+   *       security:
+   *         - cookieAuth: []
+   *       summary: Update page grant
+   *       description: Update the grant of a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 grant:
+   *                   type: number
+   *                   description: Grant level
+   *                 userRelatedGrantedGroups:
+   *                   type: array
+   *                   items:
+   *                     type: string
+   *                   description: Array of user-related granted group IDs
+   *       responses:
+   *         200:
+   *           description: Successfully updated page grant.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
   router.put('/:pageId/grant', loginRequiredStrictly, excludeReadOnlyUser, validator.updateGrant, apiV3FormValidator, async(req, res) => {
     const { pageId } = req.params;
     const { pageId } = req.params;
     const { grant, userRelatedGrantedGroups } = req.body;
     const { grant, userRelatedGrantedGroups } = req.body;
@@ -692,6 +824,8 @@ module.exports = (crowi) => {
   *    /page/export:
   *    /page/export:
   *      get:
   *      get:
   *        tags: [Page]
   *        tags: [Page]
+  *        security:
+  *          - cookieAuth: []
   *        description: return page's markdown
   *        description: return page's markdown
   *        responses:
   *        responses:
   *          200:
   *          200:
@@ -792,7 +926,9 @@ module.exports = (crowi) => {
    *    /page/exist-paths:
    *    /page/exist-paths:
    *      get:
    *      get:
    *        tags: [Page]
    *        tags: [Page]
-   *        summary: /page/exist-paths
+   *        security:
+   *          - cookieAuth: []
+   *        summary: Get already exist paths
    *        description: Get already exist paths
    *        description: Get already exist paths
    *        operationId: getAlreadyExistPaths
    *        operationId: getAlreadyExistPaths
    *        parameters:
    *        parameters:
@@ -853,7 +989,7 @@ module.exports = (crowi) => {
    *    /page/subscribe:
    *    /page/subscribe:
    *      put:
    *      put:
    *        tags: [Page]
    *        tags: [Page]
-   *        summary: /page/subscribe
+   *        summary: Update subscription status
    *        description: Update subscription status
    *        description: Update subscription status
    *        operationId: updateSubscriptionStatus
    *        operationId: updateSubscriptionStatus
    *        requestBody:
    *        requestBody:
@@ -900,6 +1036,39 @@ module.exports = (crowi) => {
   });
   });
 
 
 
 
+  /**
+   * @swagger
+   *
+   *   /:pageId/content-width:
+   *     put:
+   *       tags: [Page]
+   *       summary: Update content width
+   *       description: Update the content width setting for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 expandContentWidth:
+   *                   type: boolean
+   *                   description: Whether to expand the content width
+   *       responses:
+   *         200:
+   *           description: Successfully updated content width.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 properties:
+   *                   page:
+   *                     $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
   router.put('/:pageId/content-width', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser,
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
     validator.contentWidth, apiV3FormValidator, async(req, res) => {
       const { pageId } = req.params;
       const { pageId } = req.params;
@@ -921,13 +1090,126 @@ module.exports = (crowi) => {
       }
       }
     });
     });
 
 
-
+  /**
+   * @swagger
+   *   /:pageId/publish:
+   *     put:
+   *       tags: [Page]
+   *       summary: Publish page
+   *       description: Publish a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully published the page.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/publish', publishPageHandlersFactory(crowi));
   router.put('/:pageId/publish', publishPageHandlersFactory(crowi));
 
 
+  /**
+   * @swagger
+   *   /:pageId/unpublish:
+   *     put:
+   *       tags: [Page]
+   *       summary: Unpublish page
+   *       description: Unpublish a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully unpublished the page.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 $ref: '#/components/schemas/Page'
+   */
   router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
   router.put('/:pageId/unpublish', unpublishPageHandlersFactory(crowi));
 
 
+  /**
+   * @swagger
+   *   /:pageId/yjs-data:
+   *     get:
+   *       tags: [Page]
+   *       summary: Get Yjs data
+   *       description: Retrieve Yjs data for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       responses:
+   *         200:
+   *           description: Successfully retrieved Yjs data.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   yjsData:
+   *                     type: object
+   *                     description: Yjs data
+   *                     properties:
+   *                       hasYdocsNewerThanLatestRevision:
+   *                         type: boolean
+   *                         description: Whether Yjs documents are newer than the latest revision
+   *                       awarenessStateSize:
+   *                         type: number
+   *                         description: Size of the awareness state
+   */
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
   router.get('/:pageId/yjs-data', getYjsDataHandlerFactory(crowi));
 
 
+  /**
+   * @swagger
+   *   /:pageId/sync-latest-revision-body-to-yjs-draft:
+   *     put:
+   *       tags: [Page]
+   *       summary: Sync latest revision body to Yjs draft
+   *       description: Sync the latest revision body to the Yjs draft for a specific page
+   *       parameters:
+   *         - name: pageId
+   *           in: path
+   *           description: ID of the page
+   *           required: true
+   *           schema:
+   *             type: string
+   *       requestBody:
+   *         content:
+   *           application/json:
+   *             schema:
+   *               properties:
+   *                 editingMarkdownLength:
+   *                   type: integer
+   *                   description: Length of the editing markdown
+   *       responses:
+   *         200:
+   *           description: Successfully synced the latest revision body to Yjs draft.
+   *           content:
+   *             application/json:
+   *               schema:
+   *                 type: object
+   *                 properties:
+   *                   synced:
+   *                     type: boolean
+   *                     description: Whether the latest revision body is synced to the Yjs draft
+   *                   isYjsDataBroken:
+   *                     type: boolean
+   *                     description: Whether Yjs data is broken
+   */
   router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
   router.put('/:pageId/sync-latest-revision-body-to-yjs-draft', syncLatestRevisionBodyToYjsDraftHandlerFactory(crowi));
 
 
   return router;
   return router;

+ 232 - 7
apps/app/src/server/routes/apiv3/pages/index.js

@@ -129,10 +129,27 @@ module.exports = (crowi) => {
    *      get:
    *      get:
    *        tags: [Pages]
    *        tags: [Pages]
    *        description: Get recently updated pages
    *        description: Get recently updated pages
+   *        parameters:
+   *          - name: limit
+   *            in: query
+   *            description: Limit of acquisitions
+   *            schema:
+   *              type: number
+   *            example: 10
+   *          - name: offset
+   *            in: query
+   *            description: Offset of acquisitions
+   *            schema:
+   *              type: number
+   *            example: 0
+   *          - name: includeWipPage
+   *            in: query
+   *            description: Whether to include WIP pages
+   *            schema:
+   *              type: string
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Return pages recently updated
    *            description: Return pages recently updated
-   *
    */
    */
   router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
   router.get('/recent', accessTokenParser, loginRequired, validator.recent, apiV3FormValidator, async(req, res) => {
     const limit = parseInt(req.query.limit) || 20;
     const limit = parseInt(req.query.limit) || 20;
@@ -233,6 +250,9 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                  isRecursively:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether rename page with descendants
    *                    description: whether rename page with descendants
+   *                  isMoveMode:
+   *                    type: boolean
+   *                    description: whether rename page with moving
    *                required:
    *                required:
    *                  - pageId
    *                  - pageId
    *                  - revisionId
    *                  - revisionId
@@ -328,6 +348,28 @@ module.exports = (crowi) => {
     }
     }
   });
   });
 
 
+  /**
+    * @swagger
+    *    /pages/resume-rename:
+    *      post:
+    *        tags: [Pages]
+    *        operationId: resumeRenamePage
+    *        description: Resume rename page operation
+    *        requestBody:
+    *          content:
+    *            application/json:
+    *              schema:
+    *                properties:
+    *                  pageId:
+    *                    $ref: '#/components/schemas/Page/properties/_id'
+    *                required:
+    *                  - pageId
+    *        responses:
+    *          200:
+    *            description: Succeeded to resume rename page operation.
+    *            content:
+    *              description: Empty response
+    */
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
   router.post('/resume-rename', accessTokenParser, loginRequiredStrictly, validator.resumeRenamePage, apiV3FormValidator,
     async(req, res) => {
     async(req, res) => {
 
 
@@ -369,6 +411,14 @@ module.exports = (crowi) => {
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to remove all trash pages
    *            description: Succeeded to remove all trash pages
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    deletablePages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
    */
    */
   router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
   router.delete('/empty-trash', accessTokenParser, loginRequired, excludeReadOnlyUser, addActivity, apiV3FormValidator, async(req, res) => {
     const options = {};
     const options = {};
@@ -423,6 +473,59 @@ module.exports = (crowi) => {
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
     query('limit').if(value => value != null).isInt({ max: 100 }).withMessage('You should set less than 100 or not to set limit.'),
   ];
   ];
 
 
+  /**
+    * @swagger
+    *
+    *    /pages/list:
+    *      get:
+    *        tags: [Pages]
+    *        operationId: getList
+    *        description: Get list of pages
+    *        parameters:
+    *          - name: path
+    *            in: query
+    *            description: Path to search
+    *            schema:
+    *              type: string
+    *          - name: limit
+    *            in: query
+    *            description: Limit of acquisitions
+    *            schema:
+    *              type: number
+    *          - name: page
+    *            in: query
+    *            description: Page number
+    *            schema:
+    *              type: number
+    *        responses:
+    *          200:
+    *            description: Succeeded to retrieve pages.
+    *            content:
+    *              application/json:
+    *                schema:
+    *                  properties:
+    *                    totalCount:
+    *                      type: number
+    *                      description: Total count of pages
+    *                      example: 3
+    *                    offset:
+    *                      type: number
+    *                      description: Offset of pages
+    *                      example: 0
+    *                    limit:
+    *                      type: number
+    *                      description: Limit of pages
+    *                      example: 10
+    *                    pages:
+    *                      type: array
+    *                      items:
+    *                        allOf:
+    *                          - $ref: '#/components/schemas/Page'
+    *                          - type: object
+    *                            properties:
+    *                              lastUpdateUser:
+    *                                $ref: '#/components/schemas/User'
+    */
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
 
 
     const { path } = req.query;
     const { path } = req.query;
@@ -480,6 +583,9 @@ module.exports = (crowi) => {
    *                  isRecursively:
    *                  isRecursively:
    *                    type: boolean
    *                    type: boolean
    *                    description: whether duplicate page with descendants
    *                    description: whether duplicate page with descendants
+   *                  onlyDuplicateUserRelatedResources:
+   *                    type: boolean
+   *                    description: whether duplicate only user related resources
    *                required:
    *                required:
    *                  - pageId
    *                  - pageId
    *        responses:
    *        responses:
@@ -589,11 +695,10 @@ module.exports = (crowi) => {
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    subordinatedPaths:
-   *                      type: object
-   *                      description: descendants page
-   *          500:
-   *            description: Internal server error.
+   *                    subordinatedPages:
+   *                      type: array
+   *                      items:
+   *                        $ref: '#/components/schemas/Page'
    */
    */
   router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/subordinated-list', accessTokenParser, loginRequired, async(req, res) => {
     const { path } = req.query;
     const { path } = req.query;
@@ -611,6 +716,50 @@ module.exports = (crowi) => {
 
 
   });
   });
 
 
+  /**
+    * @swagger
+    *    /pages/delete:
+    *      post:
+    *        tags: [Pages]
+    *        operationId: deletePages
+    *        description: Delete pages
+    *        requestBody:
+    *          content:
+    *            application/json:
+    *              schema:
+    *                properties:
+    *                  pageIdToRevisionIdMap:
+    *                    type: object
+    *                    description: Map of page IDs to revision IDs
+    *                    example: { "5e2d6aede35da4004ef7e0b7": "5e07345972560e001761fa63" }
+    *                  isCompletely:
+    *                    type: boolean
+    *                    description: Whether to delete pages completely
+    *                  isRecursively:
+    *                    type: boolean
+    *                    description: Whether to delete pages recursively
+    *                  isAnyoneWithTheLink:
+    *                    type: boolean
+    *                    description: Whether the page is restricted to anyone with the link
+    *        responses:
+    *          200:
+    *            description: Succeeded to delete pages.
+    *            content:
+    *              application/json:
+    *                schema:
+    *                  properties:
+    *                    paths:
+    *                      type: array
+    *                      items:
+    *                        type: string
+    *                      description: List of deleted page paths
+    *                    isRecursively:
+    *                      type: boolean
+    *                      description: Whether pages were deleted recursively
+    *                    isCompletely:
+    *                      type: boolean
+    *                      description: Whether pages were deleted completely
+    */
   router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
   router.post('/delete', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, validator.deletePages, apiV3FormValidator, async(req, res) => {
     const {
     const {
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
       pageIdToRevisionIdMap, isCompletely, isRecursively, isAnyoneWithTheLink,
@@ -665,7 +814,32 @@ module.exports = (crowi) => {
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
     return res.apiv3({ paths: pagesCanBeDeleted.map(p => p.path), isRecursively, isCompletely });
   });
   });
 
 
-
+  /**
+   * @swagger
+   *
+   *    /pages/convert-pages-by-path:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: convertPagesByPath
+   *        description: Convert pages by path
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  convertPath:
+   *                    type: string
+   *                    description: Path to convert
+   *                    example: /user/alice
+   *        responses:
+   *          200:
+   *            description: Succeeded to convert pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  description: Empty object
+   */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
   router.post('/convert-pages-by-path', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, adminRequired, validator.convertPagesByPath, apiV3FormValidator, async(req, res) => {
     const { convertPath } = req.body;
     const { convertPath } = req.body;
@@ -688,6 +862,36 @@ module.exports = (crowi) => {
     return res.apiv3({});
     return res.apiv3({});
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /pages/legacy-pages-migration:
+   *      post:
+   *        tags: [Pages]
+   *        operationId: legacyPagesMigration
+   *        description: Migrate legacy pages
+   *        requestBody:
+   *          content:
+   *            application/json:
+   *              schema:
+   *                properties:
+   *                  pageIds:
+   *                    type: array
+   *                    items:
+   *                      type: string
+   *                    description: List of page IDs to migrate
+   *                  isRecursively:
+   *                    type: boolean
+   *                    description: Whether to migrate pages recursively
+   *        responses:
+   *          200:
+   *            description: Succeeded to migrate legacy pages.
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  type: object
+   *                  description: Empty object
+  */
   // eslint-disable-next-line max-len
   // eslint-disable-next-line max-len
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
   router.post('/legacy-pages-migration', accessTokenParser, loginRequired, excludeReadOnlyUser, validator.legacyPagesMigration, apiV3FormValidator, async(req, res) => {
     const { pageIds: _pageIds, isRecursively } = req.body;
     const { pageIds: _pageIds, isRecursively } = req.body;
@@ -717,6 +921,27 @@ module.exports = (crowi) => {
     return res.apiv3({});
     return res.apiv3({});
   });
   });
 
 
+  /**
+   * @swagger
+   *
+   *    /pages/v5-migration-status:
+   *      get:
+   *        tags: [Pages]
+   *        description: Get V5 migration status
+   *        responses:
+   *          200:
+   *            description: Return V5 migration status
+   *            content:
+   *              application/json:
+   *                schema:
+   *                  properties:
+   *                    isV5Compatible:
+   *                      type: boolean
+   *                      description: Whether the app is V5 compatible
+   *                    migratablePagesCount:
+   *                      type: number
+   *                      description: Number of pages that can be migrated
+   */
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
   router.get('/v5-migration-status', accessTokenParser, loginRequired, async(req, res) => {
     try {
     try {
       const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');
       const isV5Compatible = crowi.configManager.getConfig('app:isV5Compatible');

+ 26 - 4
apps/app/src/server/routes/apiv3/slack-integration-legacy-settings.js

@@ -55,6 +55,8 @@ module.exports = (crowi) => {
    *    /slack-integration-legacy-setting/:
    *    /slack-integration-legacy-setting/:
    *      get:
    *      get:
    *        tags: [SlackIntegrationLegacySetting]
    *        tags: [SlackIntegrationLegacySetting]
+   *        security:
+   *          - cookieAuth: []
    *        description: Get slack configuration setting
    *        description: Get slack configuration setting
    *        responses:
    *        responses:
    *          200:
    *          200:
@@ -63,9 +65,15 @@ module.exports = (crowi) => {
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
    *                  properties:
    *                  properties:
-   *                    notificationParams:
+   *                    slackIntegrationParams:
    *                      type: object
    *                      type: object
-   *                      description: slack configuration setting params
+   *                      allOf:
+   *                        - $ref: '#/components/schemas/SlackConfigurationParams'
+   *                        - type: object
+   *                          properties:
+   *                            isSlackbotConfigured:
+   *                              type: boolean
+   *                              description: whether slackbot is configured
    */
    */
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
   router.get('/', loginRequiredStrictly, adminRequired, async(req, res) => {
 
 
@@ -84,20 +92,34 @@ module.exports = (crowi) => {
    *    /slack-integration-legacy-setting/:
    *    /slack-integration-legacy-setting/:
    *      put:
    *      put:
    *        tags: [SlackIntegrationLegacySetting]
    *        tags: [SlackIntegrationLegacySetting]
+   *        security:
+   *          - cookieAuth: []
    *        description: Update slack configuration setting
    *        description: Update slack configuration setting
    *        requestBody:
    *        requestBody:
    *          required: true
    *          required: true
    *          content:
    *          content:
    *            application/json:
    *            application/json:
    *              schema:
    *              schema:
-   *                $ref: '#/components/schemas/SlackConfigurationParams'
+   *                properties:
+   *                  webhookUrl:
+   *                    type: string
+   *                    description: incoming webhooks url
+   *                  isIncomingWebhookPrioritized:
+   *                    type: boolean
+   *                    description: use incoming webhooks even if Slack App settings are enabled
+   *                  slackToken:
+   *                    type: string
+   *                    description: OAuth access token
    *        responses:
    *        responses:
    *          200:
    *          200:
    *            description: Succeeded to update slack configuration setting
    *            description: Succeeded to update slack configuration setting
    *            content:
    *            content:
    *              application/json:
    *              application/json:
    *                schema:
    *                schema:
-   *                  $ref: '#/components/schemas/SlackConfigurationParams'
+   *                  properties:
+   *                    responseParams:
+   *                      type: object
+   *                      $ref: '#/components/schemas/SlackConfigurationParams'
    */
    */
   router.put('/', loginRequiredStrictly, adminRequired, addActivity, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
   router.put('/', loginRequiredStrictly, adminRequired, addActivity, validator.slackConfiguration, apiV3FormValidator, async(req, res) => {
 
 

+ 7 - 3
apps/app/src/server/service/config-manager/config-definition.ts

@@ -218,6 +218,7 @@ export const CONFIG_KEYS = [
   'customize:isEnabledStaleNotification',
   'customize:isEnabledStaleNotification',
   'customize:isAllReplyShown',
   'customize:isAllReplyShown',
   'customize:isSearchScopeChildrenAsDefault',
   'customize:isSearchScopeChildrenAsDefault',
+  'customize:showPageSideAuthors',
   'customize:isEnabledMarp',
   'customize:isEnabledMarp',
   'customize:isSidebarCollapsedMode',
   'customize:isSidebarCollapsedMode',
   'customize:isSidebarClosedAtDockMode',
   'customize:isSidebarClosedAtDockMode',
@@ -597,15 +598,15 @@ export const CONFIG_DEFINITIONS = {
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-saml:entryPoint': defineConfig<string | undefined>({
   'security:passport-saml:entryPoint': defineConfig<string | undefined>({
-    envVarName: 'SECURITY_PASSPORT_SAML_ENTRY_POINT',
+    envVarName: 'SAML_ENTRY_POINT',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-saml:issuer': defineConfig<string | undefined>({
   'security:passport-saml:issuer': defineConfig<string | undefined>({
-    envVarName: 'SECURITY_PASSPORT_SAML_ISSUER',
+    envVarName: 'SAML_ISSUER',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-saml:cert': defineConfig<string | undefined>({
   'security:passport-saml:cert': defineConfig<string | undefined>({
-    envVarName: 'SECURITY_PASSPORT_SAML_CERT',
+    envVarName: 'SAML_CERT',
     defaultValue: undefined,
     defaultValue: undefined,
   }),
   }),
   'security:passport-oidc:timeoutMultiplier': defineConfig<number>({
   'security:passport-oidc:timeoutMultiplier': defineConfig<number>({
@@ -970,6 +971,9 @@ export const CONFIG_DEFINITIONS = {
   'customize:isSearchScopeChildrenAsDefault': defineConfig<boolean>({
   'customize:isSearchScopeChildrenAsDefault': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),
+  'customize:showPageSideAuthors': defineConfig<boolean>({
+    defaultValue: false,
+  }),
   'customize:isEnabledMarp': defineConfig<boolean>({
   'customize:isEnabledMarp': defineConfig<boolean>({
     defaultValue: false,
     defaultValue: false,
   }),
   }),

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

@@ -100,6 +100,10 @@ export const useIsSearchScopeChildrenAsDefault = (initialData?: boolean) : SWRRe
   return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData, { fallbackData: false });
   return useContextSWR<boolean, Error>('isSearchScopeChildrenAsDefault', initialData, { fallbackData: false });
 };
 };
 
 
+export const useShowPageSideAuthors = (initialData?: boolean): SWRResponse<boolean, Error> => {
+  return useContextSWR('showPageSideAuthors', initialData, { fallbackData: false });
+};
+
 export const useIsEnabledMarp = (initialData?: boolean) : SWRResponse<boolean, Error> => {
 export const useIsEnabledMarp = (initialData?: boolean) : SWRResponse<boolean, Error> => {
   return useContextSWR<boolean, Error>('isEnabledMarp', initialData, { fallbackData: false });
   return useContextSWR<boolean, Error>('isEnabledMarp', initialData, { fallbackData: false });
 };
 };

+ 3 - 2
bin/data-migrations/README.md

@@ -8,8 +8,9 @@
 git clone https://github.com/weseek/growi
 git clone https://github.com/weseek/growi
 cd growi/bin/data-migrations
 cd growi/bin/data-migrations
 
 
-NETWORK=growi_devcontainer_default \
-MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi \
+NETWORK=growi_devcontainer_default
+MONGO_URI=mongodb://growi_devcontainer_mongo_1/growi
+
 docker run --rm \
 docker run --rm \
   --network $NETWORK \
   --network $NETWORK \
   -v "$(pwd)"/src:/opt \
   -v "$(pwd)"/src:/opt \

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

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

+ 25 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/README.ja.md

@@ -0,0 +1,25 @@
+# remark-growi-directive
+
+以下の要領で replace する
+
+なお、`$foo()` は一例であり、`$bar()`, `$baz()`, `$foo-2()` など、さまざまな directive に対応する必要がある
+
+## 1. HTMLタグ内で `$foo()` を利用している箇所
+- 置換対象文章の詳細
+  - `$foo()`がHTMLタグ内かつ、当該`$foo()`記述の1行前が空行ではない場合に1行前に空行を挿入する
+  - `$foo()`がHTMLタグ内かつ、`$foo()`記述行の行頭にインデントがついている場合に当該行のインデントを削除する
+  - `$foo()`がHTMLタグ内かつ、当該`$foo()`記述の1行後のHTMLタグ記述行にインデントがついている場合にその行頭のインデントを削除する
+
+## 2. `$foo()` を利用している箇所
+- 置換対象文章の詳細
+  - `$foo()`の引数内で `filter=` あるいは `except=` に対する値に括弧 `()` を使用している場合、括弧を削除する
+    - before: `$foo()`(depth=2, filter=(AAA), except=(BBB))
+    - after: `$foo()`(depth=2, filter=AAA, except=BBB)
+
+## テストについて
+
+以下を満たす
+
+- input が `example.md` のとき、`example-expected.md` を出力する
+- input が `example-expected.md` のとき、`example-expected.md` を出力する (変更が起こらない)
+

+ 43 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/example-expected.md

@@ -0,0 +1,43 @@
+# Should not be replaced
+
+filter=(FOO), except=(word1|word2|word3)
+
+# Should be replaced
+
+<div class="container-fluid">
+    <div class="row">
+        <div>
+            <div>FOO</div>
+
+$foo(depth=2, filter=FOO)
+</div>
+        <div>
+            <div>BAR</div>
+
+$bar(depth=2, filter=BAR)
+</div>
+        <div>
+            <div>BAZ</div>
+
+$baz(depth=2, filter=BAZ)
+</div>
+    </div>
+    <hr>
+    <div class="row">
+        <div>
+            <div>FOO</div>
+
+$foo(depth=2, filter=FOO, except=word1|word2|word3)
+</div>
+        <div>
+            <div>BAR</div>
+
+$bar(depth=2, filter=BAR, except=word1|word2|word3)
+</div>
+        <div>
+                <div>BAZ</div>
+
+$baz(depth=2, filter=BAZ, except=word1|word2|word3)
+</div>
+    </div>
+</div>

+ 37 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/example.md

@@ -0,0 +1,37 @@
+# Should not be replaced
+
+filter=(FOO), except=(word1|word2|word3)
+
+# Should be replaced
+
+<div class="container-fluid">
+    <div class="row">
+        <div>
+            <div>FOO</div>
+            $foo(depth=2, filter=(FOO))
+        </div>
+        <div>
+            <div>BAR</div>
+            $bar(depth=2, filter=(BAR))
+        </div>
+        <div>
+            <div>BAZ</div>
+            $baz(depth=2, filter=(BAZ))
+        </div>
+    </div>
+    <hr>
+    <div class="row">
+        <div>
+            <div>FOO</div>
+            $foo(depth=2, filter=(FOO), except=(word1|word2|word3))
+        </div>
+        <div>
+            <div>BAR</div>
+            $bar(depth=2, filter=(BAR), except=(word1|word2|word3))
+        </div>
+        <div>
+                <div>BAZ</div>
+            $baz(depth=2, filter=(BAZ), except=(word1|word2|word3))
+        </div>
+    </div>
+</div>

+ 1 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/index.js

@@ -0,0 +1 @@
+module.exports = require('./remark-growi-directive');

+ 65 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.js

@@ -0,0 +1,65 @@
+/**
+ * @typedef {import('../../../types').MigrationModule} MigrationModule
+ */
+
+module.exports = [
+  /**
+   * Adjust line breaks and indentation for any directives within HTML tags
+   * @type {MigrationModule}
+   */
+  (body) => {
+    const lines = body.split('\n');
+    const directivePattern = /\$[\w\-_]+\([^)]*\)/;
+    let lastDirectiveLineIndex = -1;
+
+    for (let i = 0; i < lines.length; i++) {
+      if (directivePattern.test(lines[i])) {
+        const currentLine = lines[i];
+        const prevLine = i > 0 ? lines[i - 1] : '';
+        const nextLine = i < lines.length - 1 ? lines[i + 1] : '';
+
+        // Always remove indentation from directive line
+        lines[i] = currentLine.trimStart();
+
+        // Insert empty line only if:
+        // 1. Previous line contains an HTML tag (ends with >)
+        // 2. Previous line is not empty
+        // 3. Previous line is not a directive line
+        const isPrevLineHtmlTag = prevLine.match(/>[^\n]*$/) && !prevLine.match(directivePattern);
+        const isNotAfterDirective = i - 1 !== lastDirectiveLineIndex;
+
+        if (isPrevLineHtmlTag && prevLine.trim() !== '' && isNotAfterDirective) {
+          lines.splice(i, 0, '');
+          i++;
+        }
+
+        // Update the last directive line index
+        lastDirectiveLineIndex = i;
+
+        // Handle next line if it's a closing tag
+        if (nextLine.match(/^\s*<\//)) {
+          lines[i + 1] = nextLine.trimStart();
+        }
+      }
+    }
+
+    return lines.join('\n');
+  },
+
+  /**
+   * Remove unnecessary parentheses in directive arguments
+   * @type {MigrationModule}
+   */
+  (body) => {
+    // Detect and process directive-containing lines in multiline mode
+    return body.replace(/^.*\$[\w\-_]+\([^)]*\).*$/gm, (line) => {
+      // Convert filter=(value) to filter=value
+      let processedLine = line.replace(/filter=\(([^)]+)\)/g, 'filter=$1');
+
+      // Convert except=(value) to except=value
+      processedLine = processedLine.replace(/except=\(([^)]+)\)/g, 'except=$1');
+
+      return processedLine;
+    });
+  },
+];

+ 43 - 0
bin/data-migrations/src/migrations/v60x/remark-growi-directive/remark-growi-directive.spec.js

@@ -0,0 +1,43 @@
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { describe, test, expect } from 'vitest';
+
+import migrations from './remark-growi-directive';
+
+describe('remark-growi-directive migrations', () => {
+  test('should transform example.md to match example-expected.md', () => {
+    const input = fs.readFileSync(path.join(__dirname, 'example.md'), 'utf8');
+    const expected = fs.readFileSync(path.join(__dirname, 'example-expected.md'), 'utf8');
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(expected);
+  });
+
+  test('should not modify example-expected.md', () => {
+    const input = fs.readFileSync(path.join(__dirname, 'example-expected.md'), 'utf8');
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(input);
+  });
+
+  test('should handle various directive patterns', () => {
+    const input = `
+<div>
+    $foo(filter=(AAA))
+    $bar-2(except=(BBB))
+    $baz_3(filter=(CCC), except=(DDD))
+</div>`;
+
+    const expected = `
+<div>
+
+$foo(filter=AAA)
+$bar-2(except=BBB)
+$baz_3(filter=CCC, except=DDD)
+</div>`;
+
+    const result = migrations.reduce((text, migration) => migration(text), input);
+    expect(result).toBe(expected);
+  });
+});

+ 9 - 0
bin/vitest.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+  },
+});

+ 1 - 1
packages/preset-themes/src/styles/classic.scss

@@ -45,7 +45,7 @@
   $body-secondary-bg-dark: $gray-800;
   $body-secondary-bg-dark: $gray-800;
   $body-tertiary-color-dark: rgba($body-color-dark, .5);
   $body-tertiary-color-dark: rgba($body-color-dark, .5);
   $body-tertiary-bg-dark: color.mix($gray-800, $gray-900, 50%);
   $body-tertiary-bg-dark: color.mix($gray-800, $gray-900, 50%);
-  $border-color-dark: var(--grw-highlight-200);
+  $border-color-dark: var(--grw-highlight-700);
   $link-color-dark: color.mix(#68829D, white, 80%);
   $link-color-dark: color.mix(#68829D, white, 80%);
 
 
   @import 'bootstrap/scss/variables';
   @import 'bootstrap/scss/variables';

+ 1 - 1
packages/preset-themes/src/styles/default.scss

@@ -47,7 +47,7 @@
   $body-secondary-bg-dark: $gray-800;
   $body-secondary-bg-dark: $gray-800;
   $body-tertiary-color-dark: rgba($body-color-dark, .5);
   $body-tertiary-color-dark: rgba($body-color-dark, .5);
   $body-tertiary-bg-dark: color.mix($gray-800, $gray-900, 50%);
   $body-tertiary-bg-dark: color.mix($gray-800, $gray-900, 50%);
-  $border-color-dark: var(--grw-highlight-200);
+  $border-color-dark: var(--grw-highlight-800);
   $link-color-dark: $gray-500;
   $link-color-dark: $gray-500;
 
 
   @import 'bootstrap/scss/variables';
   @import 'bootstrap/scss/variables';

+ 1 - 0
vitest.workspace.mts

@@ -1,6 +1,7 @@
 export default [
 export default [
   'apps/*/vitest.config.ts',
   'apps/*/vitest.config.ts',
   'apps/*/vitest.workspace.ts',
   'apps/*/vitest.workspace.ts',
+  'bin/vitest.config.ts',
   'packages/*/vitest.config.ts',
   'packages/*/vitest.config.ts',
   'packages/*/vitest.workspace.ts',
   'packages/*/vitest.workspace.ts',
 ];
 ];