Browse Source

Merge pull request #10358 from growilabs/master

Release v7.3.3
mergify[bot] 5 months ago
parent
commit
54e32be902
100 changed files with 2199 additions and 1003 deletions
  1. 186 0
      .serena/memories/apps-app-pagetree-performance-refactor-plan.md
  2. 8 0
      apps/app/.eslintrc.js
  3. 2 4
      apps/app/package.json
  4. 40 40
      apps/app/public/images/icons/favicon/manifest.json
  5. 1 1
      apps/app/public/static/locales/en_US/admin.json
  6. 1 1
      apps/app/public/static/locales/en_US/translation.json
  7. 1 1
      apps/app/public/static/locales/fr_FR/admin.json
  8. 1 1
      apps/app/public/static/locales/fr_FR/translation.json
  9. 1 1
      apps/app/public/static/locales/ja_JP/admin.json
  10. 1 1
      apps/app/public/static/locales/ja_JP/translation.json
  11. 1 1
      apps/app/public/static/locales/ko_KR/admin.json
  12. 1 1
      apps/app/public/static/locales/ko_KR/translation.json
  13. 1 1
      apps/app/public/static/locales/zh_CN/admin.json
  14. 1 1
      apps/app/public/static/locales/zh_CN/translation.json
  15. 3 0
      apps/app/src/client/components/PageEditor/PageEditor.tsx
  16. 0 1
      apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx
  17. 3 3
      apps/app/src/client/services/renderer/renderer.tsx
  18. 2 0
      apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts
  19. 2 0
      apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts
  20. 1 1
      apps/app/src/client/util/apiv1-client.ts
  21. 1 1
      apps/app/src/client/util/apiv3-client.ts
  22. 18 23
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  23. 22 14
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginDeleteModal.tsx
  24. 49 33
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  25. 38 28
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  26. 11 8
      apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx
  27. 30 26
      apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx
  28. 1 5
      apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts
  29. 30 25
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  30. 19 18
      apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts
  31. 45 20
      apps/app/src/features/growi-plugin/server/models/growi-plugin.ts
  32. 11 9
      apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts
  33. 13 10
      apps/app/src/features/growi-plugin/server/models/vo/github-url.ts
  34. 62 36
      apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts
  35. 11 3
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts
  36. 4 1
      apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts
  37. 42 25
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts
  38. 190 123
      apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts
  39. 9 0
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.module.scss
  40. 38 34
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  41. 32 4
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  42. 3 0
      apps/app/src/features/opentelemetry/server/custom-metrics/index.ts
  43. 147 0
      apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.spec.ts
  44. 42 0
      apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.ts
  45. 1 0
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts
  46. 10 7
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts
  47. 6 4
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts
  48. 38 24
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  49. 1 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  50. 1 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts
  51. 10 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts
  52. 10 2
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts
  53. 14 12
      apps/app/src/features/rate-limiter/config/index.ts
  54. 10 8
      apps/app/src/features/rate-limiter/middleware/consume-points.integ.ts
  55. 15 5
      apps/app/src/features/rate-limiter/middleware/consume-points.ts
  56. 26 22
      apps/app/src/features/rate-limiter/middleware/factory.ts
  57. 4 3
      apps/app/src/features/rate-limiter/middleware/rate-limiter-factory.ts
  58. 30 17
      apps/app/src/features/rate-limiter/utils/config-generator.ts
  59. 3 11
      apps/app/src/interfaces/page-listing-results.ts
  60. 14 0
      apps/app/src/interfaces/page.ts
  61. 5 3
      apps/app/src/models/admin/growi-archive-import-option.ts
  62. 1 1
      apps/app/src/models/admin/import-mode.ts
  63. 8 4
      apps/app/src/models/admin/import-option-for-pages.ts
  64. 5 3
      apps/app/src/models/admin/import-option-for-revisions.ts
  65. 0 2
      apps/app/src/models/cdn-resource.js
  66. 0 4
      apps/app/src/models/linked-page-path.js
  67. 8 4
      apps/app/src/models/serializers/in-app-notification-snapshot/page-bulk-export-job.ts
  68. 2 2
      apps/app/src/models/serializers/in-app-notification-snapshot/page.ts
  69. 1 1
      apps/app/src/models/serializers/in-app-notification-snapshot/user.ts
  70. 0 2
      apps/app/src/models/vo/external-account-login-error.ts
  71. 7 5
      apps/app/src/pages/[[...path]].page.tsx
  72. 3 6
      apps/app/src/server/crowi/index.js
  73. 34 0
      apps/app/src/server/models/openapi/page-listing.ts
  74. 9 80
      apps/app/src/server/routes/apiv3/page-listing.ts
  75. 3 0
      apps/app/src/server/routes/apiv3/page/update-page.ts
  76. 1 6
      apps/app/src/server/routes/apiv3/pages/index.js
  77. 1 1
      apps/app/src/server/service/customize.ts
  78. 20 0
      apps/app/src/server/service/growi-info/growi-info.integ.ts
  79. 11 1
      apps/app/src/server/service/growi-info/growi-info.ts
  80. 1 1
      apps/app/src/server/service/page-grant.ts
  81. 1 0
      apps/app/src/server/service/page-listing/index.ts
  82. 407 0
      apps/app/src/server/service/page-listing/page-listing.integ.ts
  83. 114 0
      apps/app/src/server/service/page-listing/page-listing.ts
  84. 15 2
      apps/app/src/server/service/page-operation.ts
  85. 10 107
      apps/app/src/server/service/page/index.ts
  86. 20 4
      apps/app/src/server/service/page/page-service.ts
  87. 3 3
      apps/app/src/services/general-xss-filter/general-xss-filter.spec.ts
  88. 4 5
      apps/app/src/services/general-xss-filter/general-xss-filter.ts
  89. 6 2
      apps/app/src/services/layout/use-should-expand-content.ts
  90. 6 5
      apps/app/src/services/renderer/markdown-it/PreProcessor/EasyGrid.js
  91. 13 6
      apps/app/src/services/renderer/recommended-whitelist.spec.ts
  92. 31 20
      apps/app/src/services/renderer/recommended-whitelist.ts
  93. 6 3
      apps/app/src/services/renderer/rehype-plugins/add-class.ts
  94. 4 1
      apps/app/src/services/renderer/rehype-plugins/add-inline-code-property.ts
  95. 4 2
      apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts
  96. 21 11
      apps/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts
  97. 45 39
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  98. 6 2
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  99. 31 33
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  100. 14 8
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts

+ 186 - 0
.serena/memories/apps-app-pagetree-performance-refactor-plan.md

@@ -0,0 +1,186 @@
+# PageTree パフォーマンス改善リファクタ計画 - 現実的戦略
+
+## 🎯 目標
+現在のパフォーマンス問題を解決:
+- **問題**: 5000件の兄弟ページで初期レンダリングが重い
+- **目標**: 表示速度を10-20倍改善、UX維持
+
+## ✅ 戦略2: API軽量化 - **完了済み**
+
+### 実装済み内容
+- **ファイル**: `apps/app/src/server/service/page-listing/page-listing.ts:77`
+- **変更内容**: `.select('_id path parent descendantCount grant isEmpty createdAt updatedAt wip')` を追加
+- **型定義**: `apps/app/src/interfaces/page.ts` の `IPageForTreeItem` 型も対応済み
+- **追加改善**: 計画にはなかった `wip` フィールドも最適化対象に含める
+
+### 実現できた効果
+- **データサイズ**: 推定 500バイト → 約100バイト(5倍軽量化)
+- **ネットワーク転送**: 5000ページ時 2.5MB → 500KB程度に削減
+- **状況**: **実装完了・効果発現中**
+
+---
+
+## 🚀 戦略1: 既存アーキテクチャ活用 + headless-tree部分導入 - **現実的戦略**
+
+### 前回のreact-window失敗原因
+1. **動的itemCount**: ツリー展開時にアイテム数が変化→react-windowの前提と衝突
+2. **非同期ローディング**: APIレスポンス待ちでフラット化不可
+3. **複雑な状態管理**: SWRとreact-windowの状態同期が困難
+
+### 現実的制約の認識
+**ItemsTree/TreeItemLayoutは廃止困難**:
+- **CustomTreeItemの出し分け**: `PageTreeItem` vs `TreeItemForModal`  
+- **共通副作用処理**: rename/duplicate/delete時のmutation処理
+- **多箇所からの利用**: PageTree, PageSelectModal, AiAssistant等
+
+## 📋 修正された実装戦略: **ハイブリッドアプローチ**
+
+### **核心アプローチ**: ItemsTreeを**dataProvider**として活用
+
+**既存の責務は保持しつつ、内部実装のみheadless-tree化**:
+
+1. **ItemsTree**: UIロジック・副作用処理はそのまま
+2. **TreeItemLayout**: 個別アイテムレンダリングはそのまま  
+3. **データ管理**: 内部でheadless-treeを使用(SWR → headless-tree)
+4. **Virtualization**: ItemsTree内部にreact-virtualを導入
+
+### **実装計画: 段階的移行**
+
+#### **Phase 1: データ層のheadless-tree化**
+
+**ファイル**: `ItemsTree.tsx`
+```typescript
+// Before: 複雑なSWR + 子コンポーネント管理
+const tree = useTree<IPageForTreeItem>({
+  rootItemId: initialItemNode.page._id,
+  dataLoader: {
+    getItem: async (itemId) => {
+      const response = await apiv3Get('/page-listing/item', { id: itemId });
+      return response.data;
+    },
+    getChildren: async (itemId) => {
+      const response = await apiv3Get('/page-listing/children', { id: itemId });
+      return response.data.children.map(child => child._id);
+    },
+  },
+  features: [asyncDataLoaderFeature],
+});
+
+// 既存のCustomTreeItemに渡すためのアダプター
+const adaptedNodes = tree.getItems().map(item => 
+  new ItemNode(item.getItemData())
+);
+
+return (
+  <ul className={`${moduleClass} list-group`}>
+    {adaptedNodes.map(node => (
+      <CustomTreeItem
+        key={node.page._id}
+        itemNode={node}
+        // ... 既存のpropsをそのまま渡す
+        onRenamed={onRenamed}
+        onClickDuplicateMenuItem={onClickDuplicateMenuItem}
+        onClickDeleteMenuItem={onClickDeleteMenuItem}
+      />
+    ))}
+  </ul>
+);
+```
+
+#### **Phase 2: Virtualization導入**
+
+**ファイル**: `ItemsTree.tsx` (Phase1をベースに拡張)
+```typescript
+const virtualizer = useVirtualizer({
+  count: adaptedNodes.length,
+  getScrollElement: () => containerRef.current,
+  estimateSize: () => 40,
+});
+
+return (
+  <div ref={containerRef} className="tree-container">
+    <div style={{ height: virtualizer.getTotalSize() }}>
+      {virtualizer.getVirtualItems().map(virtualItem => {
+        const node = adaptedNodes[virtualItem.index];
+        return (
+          <div
+            key={node.page._id}
+            style={{
+              position: 'absolute',
+              top: virtualItem.start,
+              height: virtualItem.size,
+              width: '100%',
+            }}
+          >
+            <CustomTreeItem
+              itemNode={node}
+              // ... 既存props
+            />
+          </div>
+        );
+      })}
+    </div>
+  </div>
+);
+```
+
+#### **Phase 3 (将来): 完全なheadless-tree移行**
+
+最終的にはdrag&drop、selection等のUI機能もheadless-treeに移行可能ですが、**今回のスコープ外**。
+
+## 📁 現実的なファイル変更まとめ
+
+| アクション | ファイル | 内容 | スコープ |
+|---------|---------|------|------|
+| ✅ **完了** | **apps/app/src/server/service/page-listing/page-listing.ts** | selectクエリ追加 | API軽量化 |
+| ✅ **完了** | **apps/app/src/interfaces/page.ts** | IPageForTreeItem型定義 | API軽量化 |
+| 🔄 **修正** | **src/client/components/ItemsTree/ItemsTree.tsx** | headless-tree + virtualization導入 | **今回の核心** |
+| 🆕 **新規** | **src/client/components/ItemsTree/usePageTreeDataLoader.ts** | データローダー分離 | 保守性向上 |
+| ⚠️ **保持** | **src/client/components/TreeItem/TreeItemLayout.tsx** | 既存のまま(後方互換) | 既存責務保持 |
+| ⚠️ **部分削除** | **src/stores/page-listing.tsx** | useSWRxPageChildren削除 | 重複排除 |
+
+**新規ファイル**: 1個(データローダー分離のみ)  
+**変更ファイル**: 2個(ItemsTree改修 + store整理)  
+**削除ファイル**: 0個(既存アーキテクチャ尊重)
+
+---
+
+## 🎯 実装優先順位
+
+**✅ Phase 1**: API軽量化(低リスク・即効性) - **完了**
+
+**📋 Phase 2-A**: ItemsTree内部のheadless-tree化
+- **工数**: 2-3日
+- **リスク**: 低(外部IF変更なし)
+- **効果**: 非同期ローディング最適化、キャッシュ改善
+
+**📋 Phase 2-B**: Virtualization導入  
+- **工数**: 2-3日
+- **リスク**: 低(内部実装のみ)
+- **効果**: レンダリング性能10-20倍改善
+
+**現在の効果**: API軽量化により 5倍のデータ転送量削減済み  
+**Phase 2完了時の予想効果**: 初期表示速度 20-50倍改善
+
+---
+
+## 🏗️ 実装方針: **既存アーキテクチャ尊重**
+
+**基本方針**:
+- **既存のCustomTreeItem責務**は保持(rename/duplicate/delete等)
+- **データ管理層のみ**をheadless-tree化  
+- **外部インターフェース**は変更せず、内部最適化に集中
+- **段階的移行**で低リスク実装
+
+**今回のスコープ**:
+- ✅ 非同期データローディング最適化
+- ✅ Virtualizationによる大量要素対応  
+- ❌ drag&drop/selection(将来フェーズ)
+- ❌ 既存アーキテクチャの破壊的変更
+
+---
+
+## 技術的参考資料
+- **headless-tree**: https://headless-tree.lukasbach.com/ (データ管理層のみ利用)
+- **react-virtual**: @tanstack/react-virtualを使用  
+- **アプローチ**: 既存ItemsTree内部でheadless-tree + virtualizationをハイブリッド活用

+ 8 - 0
apps/app/.eslintrc.js

@@ -25,11 +25,16 @@ module.exports = {
     'test/integration/middlewares/**',
     'test/integration/middlewares/**',
     'test/integration/migrations/**',
     'test/integration/migrations/**',
     'test/integration/models/**',
     'test/integration/models/**',
+    'test/integration/service/**',
     'test/integration/setup.js',
     'test/integration/setup.js',
+    'test-with-vite/**',
+    'public/**',
     'bin/**',
     'bin/**',
     'config/**',
     'config/**',
+    'src/styles/**',
     'src/linter-checker/**',
     'src/linter-checker/**',
     'src/migrations/**',
     'src/migrations/**',
+    'src/models/**',
     'src/features/callout/**',
     'src/features/callout/**',
     'src/features/comment/**',
     'src/features/comment/**',
     'src/features/templates/**',
     'src/features/templates/**',
@@ -38,10 +43,13 @@ module.exports = {
     'src/features/plantuml/**',
     'src/features/plantuml/**',
     'src/features/external-user-group/**',
     'src/features/external-user-group/**',
     'src/features/page-bulk-export/**',
     'src/features/page-bulk-export/**',
+    'src/features/growi-plugin/**',
     'src/features/opentelemetry/**',
     'src/features/opentelemetry/**',
+    'src/features/rate-limiter/**',
     'src/stores-universal/**',
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/interfaces/**',
     'src/utils/**',
     'src/utils/**',
+    'src/services/**',
   ],
   ],
   settings: {
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     // resolve path aliases by eslint-import-resolver-typescript

+ 2 - 4
apps/app/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@growi/app",
   "name": "@growi/app",
-  "version": "7.3.2",
+  "version": "7.3.3-RC.0",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
   "scripts": {
   "scripts": {
@@ -104,7 +104,7 @@
     "archiver": "^5.3.0",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
     "array.prototype.flatmap": "^1.2.2",
     "async-canvas-to-blob": "^1.0.3",
     "async-canvas-to-blob": "^1.0.3",
-    "axios": "^0.24.0",
+    "axios": "^1.11.0",
     "axios-retry": "^3.2.4",
     "axios-retry": "^3.2.4",
     "babel-plugin-superjson-next": "^0.4.2",
     "babel-plugin-superjson-next": "^0.4.2",
     "body-parser": "^1.20.3",
     "body-parser": "^1.20.3",
@@ -273,9 +273,7 @@
     "@popperjs/core": "^2.11.8",
     "@popperjs/core": "^2.11.8",
     "@swc-node/jest": "^1.8.1",
     "@swc-node/jest": "^1.8.1",
     "@swc/jest": "^0.2.36",
     "@swc/jest": "^0.2.36",
-    "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.5.0",
     "@testing-library/jest-dom": "^6.5.0",
-    "@testing-library/react": "^16.0.1",
     "@testing-library/user-event": "^14.5.2",
     "@testing-library/user-event": "^14.5.2",
     "@types/archiver": "^6.0.2",
     "@types/archiver": "^6.0.2",
     "@types/bunyan": "^1.8.11",
     "@types/bunyan": "^1.8.11",

+ 40 - 40
apps/app/public/images/icons/favicon/manifest.json

@@ -1,41 +1,41 @@
 {
 {
- "name": "App",
- "icons": [
-  {
-   "src": "\/android-icon-36x36.png",
-   "sizes": "36x36",
-   "type": "image\/png",
-   "density": "0.75"
-  },
-  {
-   "src": "\/android-icon-48x48.png",
-   "sizes": "48x48",
-   "type": "image\/png",
-   "density": "1.0"
-  },
-  {
-   "src": "\/android-icon-72x72.png",
-   "sizes": "72x72",
-   "type": "image\/png",
-   "density": "1.5"
-  },
-  {
-   "src": "\/android-icon-96x96.png",
-   "sizes": "96x96",
-   "type": "image\/png",
-   "density": "2.0"
-  },
-  {
-   "src": "\/android-icon-144x144.png",
-   "sizes": "144x144",
-   "type": "image\/png",
-   "density": "3.0"
-  },
-  {
-   "src": "\/android-icon-192x192.png",
-   "sizes": "192x192",
-   "type": "image\/png",
-   "density": "4.0"
-  }
- ]
-}
+  "name": "App",
+  "icons": [
+    {
+      "src": "\/android-icon-36x36.png",
+      "sizes": "36x36",
+      "type": "image\/png",
+      "density": "0.75"
+    },
+    {
+      "src": "\/android-icon-48x48.png",
+      "sizes": "48x48",
+      "type": "image\/png",
+      "density": "1.0"
+    },
+    {
+      "src": "\/android-icon-72x72.png",
+      "sizes": "72x72",
+      "type": "image\/png",
+      "density": "1.5"
+    },
+    {
+      "src": "\/android-icon-96x96.png",
+      "sizes": "96x96",
+      "type": "image\/png",
+      "density": "2.0"
+    },
+    {
+      "src": "\/android-icon-144x144.png",
+      "sizes": "144x144",
+      "type": "image\/png",
+      "density": "3.0"
+    },
+    {
+      "src": "\/android-icon-192x192.png",
+      "sizes": "192x192",
+      "type": "image\/png",
+      "density": "4.0"
+    }
+  ]
+}

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

@@ -1139,4 +1139,4 @@
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "disable_mode_explanation": "Currently, AI integration is disabled. To enable it, configure the <code>AI_ENABLED</code> environment variable along with the required additional variables.<br><br>For details, please refer to the <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "ai_search_management": "AI search management"
     "ai_search_management": "AI search management"
   }
   }
-}
+}

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

@@ -1062,4 +1062,4 @@
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "skipped-toaster": "Skipped synchronizing since the editor is not activated. Please open the editor and try again.",
     "error-toaster": "Synchronization of the latest text failed"
     "error-toaster": "Synchronization of the latest text failed"
   }
   }
-}
+}

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

@@ -1138,4 +1138,4 @@
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "disable_mode_explanation": "Actuellement, l'intégration AI est désactivée. Pour l'activer, configurez la variable d'environnement <code>AI_ENABLED</code> ainsi que les autres variables nécessaires.<br><br>Pour plus de détails, veuillez consulter la <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>documentation</a>.",
     "ai_search_management": "Gestion de la recherche par l'IA"
     "ai_search_management": "Gestion de la recherche par l'IA"
   }
   }
-}
+}

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

@@ -1053,4 +1053,4 @@
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "skipped-toaster": "L'éditeur n'est pas actif. Synchronisation annulée.",
     "error-toaster": "Synchronisation échouée"
     "error-toaster": "Synchronisation échouée"
   }
   }
-}
+}

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

@@ -1148,4 +1148,4 @@
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
     "disable_mode_explanation": "現在、AI 連携は無効になっています。有効にする場合は環境変数 <code>AI_ENABLED</code> の他、必要な環境変数を設定してください。<br><br>詳細は<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}ja/guide/features/ai-knowledge-assistant.html>ドキュメント</a>を参照してください。",
     "ai_search_management": "AI 検索管理"
     "ai_search_management": "AI 検索管理"
   }
   }
-}
+}

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

@@ -1095,4 +1095,4 @@
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "skipped-toaster": "エディターがアクティブではないため、同期をスキップしました。エディターを開いて再度お試しください。",
     "error-toaster": "最新の本文の同期に失敗しました"
     "error-toaster": "最新の本文の同期に失敗しました"
   }
   }
-}
+}

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

@@ -1139,4 +1139,4 @@
     "disable_mode_explanation": "현재 AI 통합이 비활성화되어 있습니다. 활성화하려면 <code>AI_ENABLED</code> 환경 변수와 필요한 추가 변수를 구성하십시오.<br><br>자세한 내용은 <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>문서</a>를 참조하십시오.",
     "disable_mode_explanation": "현재 AI 통합이 비활성화되어 있습니다. 활성화하려면 <code>AI_ENABLED</code> 환경 변수와 필요한 추가 변수를 구성하십시오.<br><br>자세한 내용은 <a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>문서</a>를 참조하십시오.",
     "ai_search_management": "AI 검색 관리"
     "ai_search_management": "AI 검색 관리"
   }
   }
-}
+}

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

@@ -1022,4 +1022,4 @@
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "skipped-toaster": "편집기가 활성화되지 않아 동기화 건너뜀. 편집기를 열고 다시 시도하십시오.",
     "error-toaster": "최신 텍스트 동기화 실패"
     "error-toaster": "최신 텍스트 동기화 실패"
   }
   }
-}
+}

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

@@ -1148,4 +1148,4 @@
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
     "disable_mode_explanation": "目前,AI 集成已被禁用。若要启用,请配置 <code>AI_ENABLED</code> 环境变量以及其他必要的变量。<br><br>详细信息请参考<a target='blank' rel='noopener noreferrer' href={{documentationUrl}}en/guide/features/ai-knowledge-assistant.html>文档</a>。",
     "ai_search_management": "AI 搜索管理"
     "ai_search_management": "AI 搜索管理"
   }
   }
-}
+}

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

@@ -1067,4 +1067,4 @@
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "skipped-toaster": "由于编辑器未激活,因此跳过同步。 请打开编辑器并重试。",
     "error-toaster": "同步最新文本失败"
     "error-toaster": "同步最新文本失败"
   }
   }
-}
+}

+ 3 - 0
apps/app/src/client/components/PageEditor/PageEditor.tsx

@@ -128,6 +128,9 @@ export const PageEditorSubstance = (props: Props): JSX.Element => {
   mutateResolvedTheme({ themeData: resolvedTheme });
   mutateResolvedTheme({ themeData: resolvedTheme });
 
 
   const currentRevisionId = currentPage?.revision?._id;
   const currentRevisionId = currentPage?.revision?._id;
+
+  // There are cases where "revisionId" is not required for revision updates
+  // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
   const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
   const isRevisionIdRequiredForPageUpdate = currentPage?.revision?.origin === undefined;
 
 
   const initialValueRef = useRef('');
   const initialValueRef = useRef('');

+ 0 - 1
apps/app/src/client/components/ReactMarkdownComponents/DrawioViewerWithEditButton.tsx

@@ -29,7 +29,6 @@ export const DrawioViewerWithEditButton = React.memo((props: DrawioViewerProps):
   const { t } = useTranslation();
   const { t } = useTranslation();
 
 
   const { bol, eol } = props;
   const { bol, eol } = props;
-
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: isSharedUser } = useIsSharedUser();
   const { data: isSharedUser } = useIsSharedUser();

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

@@ -63,7 +63,7 @@ export const generateViewOptions = (
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
-    drawio.remarkPlugin,
+    [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
     attachment.remarkPlugin,
     attachment.remarkPlugin,
@@ -171,7 +171,7 @@ export const generateSimpleViewOptions = (
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
-    drawio.remarkPlugin,
+    [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
     attachment.remarkPlugin,
     attachment.remarkPlugin,
@@ -268,7 +268,7 @@ export const generatePreviewOptions = (config: RendererConfigExt, pagePath: stri
   remarkPlugins.push(
   remarkPlugins.push(
     math,
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri, isDarkMode: config.isDarkMode }],
-    drawio.remarkPlugin,
+    [drawio.remarkPlugin, { isDarkMode: config.isDarkMode }],
     mermaid.remarkPlugin,
     mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
     xsvToTable.remarkPlugin,
     attachment.remarkPlugin,
     attachment.remarkPlugin,

+ 2 - 0
apps/app/src/client/services/side-effects/drawio-modal-launcher-for-view.ts

@@ -46,6 +46,8 @@ export const useDrawioModalLauncherForView = (opts?: {
       return;
       return;
     }
     }
 
 
+    // There are cases where "revisionId" is not required for revision updates
+    // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
     try {
     try {
       await _updatePage({
       await _updatePage({
         pageId: currentPage._id,
         pageId: currentPage._id,

+ 2 - 0
apps/app/src/client/services/side-effects/handsontable-modal-launcher-for-view.ts

@@ -47,6 +47,8 @@ export const useHandsontableModalLauncherForView = (opts?: {
     }
     }
 
 
     try {
     try {
+      // There are cases where "revisionId" is not required for revision updates
+      // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
       await _updatePage({
       await _updatePage({
         pageId: currentPage._id,
         pageId: currentPage._id,
         revisionId,
         revisionId,

+ 1 - 1
apps/app/src/client/util/apiv1-client.ts

@@ -46,7 +46,7 @@ export async function apiPost<T>(path: string, params: unknown = {}): Promise<T>
 }
 }
 
 
 export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
 export async function apiPostForm<T>(path: string, formData: FormData): Promise<T> {
-  return apiPost<T>(path, formData);
+  return apiRequest<T>('postForm', path, formData);
 }
 }
 
 
 export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {
 export async function apiDelete<T>(path: string, params: unknown = {}): Promise<T> {

+ 1 - 1
apps/app/src/client/util/apiv3-client.ts

@@ -50,7 +50,7 @@ export async function apiv3Post<T = any>(path: string, params: unknown = {}): Pr
 }
 }
 
 
 export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
 export async function apiv3PostForm<T = any>(path: string, formData: FormData): Promise<AxiosResponse<T>> {
-  return apiv3Post<T>(path, formData);
+  return apiv3Request('postForm', path, formData);
 }
 }
 
 
 export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {
 export async function apiv3Put<T = any>(path: string, params: unknown = {}): Promise<AxiosResponse<T>> {

+ 18 - 23
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginCard.tsx

@@ -1,34 +1,31 @@
-import React, { useState, type JSX } from 'react';
+import Link from 'next/link';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
+import React, { type JSX, useState } from 'react';
 
 
 import { apiv3Put } from '~/client/util/apiv3-client';
 import { apiv3Put } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import styles from './PluginCard.module.scss';
 import styles from './PluginCard.module.scss';
 
 
 type Props = {
 type Props = {
-  id: string,
-  name: string,
-  url: string,
-  isEnabled: boolean,
-  desc?: string,
-  onDelete: () => void,
-}
+  id: string;
+  name: string;
+  url: string;
+  isEnabled: boolean;
+  desc?: string;
+  onDelete: () => void;
+};
 
 
 export const PluginCard = (props: Props): JSX.Element => {
 export const PluginCard = (props: Props): JSX.Element => {
-
-  const {
-    id, name, url, isEnabled, desc,
-  } = props;
+  const { id, name, url, isEnabled, desc } = props;
 
 
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
   const PluginCardButton = (): JSX.Element => {
   const PluginCardButton = (): JSX.Element => {
     const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
     const [_isEnabled, setIsEnabled] = useState<boolean>(isEnabled);
 
 
-    const onChangeHandler = async() => {
+    const onChangeHandler = async () => {
       try {
       try {
         if (_isEnabled) {
         if (_isEnabled) {
           const reqUrl = `/plugins/${id}/deactivate`;
           const reqUrl = `/plugins/${id}/deactivate`;
@@ -36,16 +33,14 @@ export const PluginCard = (props: Props): JSX.Element => {
           setIsEnabled(!_isEnabled);
           setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
           toastSuccess(t('toaster.deactivate_plugin_success', { pluginName }));
-        }
-        else {
+        } else {
           const reqUrl = `/plugins/${id}/activate`;
           const reqUrl = `/plugins/${id}/activate`;
           const res = await apiv3Put(reqUrl);
           const res = await apiv3Put(reqUrl);
           setIsEnabled(!_isEnabled);
           setIsEnabled(!_isEnabled);
           const pluginName = res.data.pluginName;
           const pluginName = res.data.pluginName;
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
           toastSuccess(t('toaster.activate_plugin_success', { pluginName }));
         }
         }
-      }
-      catch (err) {
+      } catch (err) {
         toastError(err);
         toastError(err);
       }
       }
     };
     };
@@ -69,7 +64,6 @@ export const PluginCard = (props: Props): JSX.Element => {
   };
   };
 
 
   const PluginDeleteButton = (): JSX.Element => {
   const PluginDeleteButton = (): JSX.Element => {
-
     return (
     return (
       <div>
       <div>
         <button
         <button
@@ -89,7 +83,9 @@ export const PluginCard = (props: Props): JSX.Element => {
         <div className="row mb-3">
         <div className="row mb-3">
           <div className="col-9">
           <div className="col-9">
             <h2 className="card-title h3 border-bottom pb-2 mb-3">
             <h2 className="card-title h3 border-bottom pb-2 mb-3">
-              <Link href={`${url}`} legacyBehavior>{name}</Link>
+              <Link href={`${url}`} legacyBehavior>
+                {name}
+              </Link>
             </h2>
             </h2>
             <p className="card-text text-muted">{desc}</p>
             <p className="card-text text-muted">{desc}</p>
           </div>
           </div>
@@ -104,8 +100,7 @@ export const PluginCard = (props: Props): JSX.Element => {
         </div>
         </div>
       </div>
       </div>
       <div className="card-footer px-5 border-top-0">
       <div className="card-footer px-5 border-top-0">
-        <p className="d-flex justify-content-between align-self-center mb-0">
-        </p>
+        <p className="d-flex justify-content-between align-self-center mb-0"></p>
       </div>
       </div>
     </div>
     </div>
   );
   );

+ 22 - 14
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginDeleteModal.tsx

@@ -1,21 +1,23 @@
-import React, { useCallback } from 'react';
+import Link from 'next/link';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import Link from 'next/link';
-import {
-  Button, Modal, ModalHeader, ModalBody, ModalFooter,
-} from 'reactstrap';
+import type React from 'react';
+import { useCallback } from 'react';
+import { Button, Modal, ModalBody, ModalFooter, ModalHeader } from 'reactstrap';
 
 
 import { apiv3Delete } from '~/client/util/apiv3-client';
 import { apiv3Delete } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
-import { useSWRxAdminPlugins, usePluginDeleteModal } from '../../../stores/admin-plugins';
+import {
+  usePluginDeleteModal,
+  useSWRxAdminPlugins,
+} from '../../../stores/admin-plugins';
 
 
 export const PluginDeleteModal: React.FC = () => {
 export const PluginDeleteModal: React.FC = () => {
-
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
   const { mutate } = useSWRxAdminPlugins();
   const { mutate } = useSWRxAdminPlugins();
-  const { data: pluginDeleteModalData, close: closePluginDeleteModal } = usePluginDeleteModal();
+  const { data: pluginDeleteModalData, close: closePluginDeleteModal } =
+    usePluginDeleteModal();
   const isOpen = pluginDeleteModalData?.isOpen;
   const isOpen = pluginDeleteModalData?.isOpen;
   const id = pluginDeleteModalData?.id;
   const id = pluginDeleteModalData?.id;
   const name = pluginDeleteModalData?.name;
   const name = pluginDeleteModalData?.name;
@@ -25,7 +27,7 @@ export const PluginDeleteModal: React.FC = () => {
     closePluginDeleteModal();
     closePluginDeleteModal();
   }, [closePluginDeleteModal]);
   }, [closePluginDeleteModal]);
 
 
-  const onClickDeleteButtonHandler = useCallback(async() => {
+  const onClickDeleteButtonHandler = useCallback(async () => {
     const reqUrl = `/plugins/${id}/remove`;
     const reqUrl = `/plugins/${id}/remove`;
 
 
     try {
     try {
@@ -34,15 +36,19 @@ export const PluginDeleteModal: React.FC = () => {
       closePluginDeleteModal();
       closePluginDeleteModal();
       toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
       toastSuccess(t('toaster.remove_plugin_success', { pluginName }));
       mutate();
       mutate();
-    }
-    catch (err) {
+    } catch (err) {
       toastError(err);
       toastError(err);
     }
     }
   }, [id, closePluginDeleteModal, t, mutate]);
   }, [id, closePluginDeleteModal, t, mutate]);
 
 
   return (
   return (
     <Modal isOpen={isOpen} toggle={toggleHandler}>
     <Modal isOpen={isOpen} toggle={toggleHandler}>
-      <ModalHeader tag="h4" toggle={toggleHandler} className="text-danger" name={name}>
+      <ModalHeader
+        tag="h4"
+        toggle={toggleHandler}
+        className="text-danger"
+        name={name}
+      >
         <span>
         <span>
           <span className="material-symbols-outlined">delete_forever</span>
           <span className="material-symbols-outlined">delete_forever</span>
           {t('plugins.confirm')}
           {t('plugins.confirm')}
@@ -50,7 +56,9 @@ export const PluginDeleteModal: React.FC = () => {
       </ModalHeader>
       </ModalHeader>
       <ModalBody>
       <ModalBody>
         <div className="card well mt-2 p-2" key={id}>
         <div className="card well mt-2 p-2" key={id}>
-          <Link href={`${url}`} legacyBehavior>{name}</Link>
+          <Link href={`${url}`} legacyBehavior>
+            {name}
+          </Link>
         </div>
         </div>
       </ModalBody>
       </ModalBody>
       <ModalFooter>
       <ModalFooter>

+ 49 - 33
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -1,9 +1,8 @@
-import React, { useCallback, type JSX } from 'react';
-
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
+import React, { type JSX, useCallback } from 'react';
 
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { toastSuccess, toastError } from '~/client/util/toastr';
+import { toastError, toastSuccess } from '~/client/util/toastr';
 
 
 import type { IGrowiPluginOrigin } from '../../../../interfaces';
 import type { IGrowiPluginOrigin } from '../../../../interfaces';
 import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
 import { useSWRxAdminPlugins } from '../../../stores/admin-plugins';
@@ -12,40 +11,46 @@ export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxAdminPlugins();
   const { mutate } = useSWRxAdminPlugins();
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
 
 
-  const submitHandler = useCallback(async(e) => {
-    e.preventDefault();
+  const submitHandler = useCallback(
+    async (e) => {
+      e.preventDefault();
 
 
-    const formData = e.target.elements;
+      const formData = e.target.elements;
 
 
-    const {
-      'pluginInstallerForm[url]': { value: url },
-      'pluginInstallerForm[ghBranch]': { value: ghBranch },
-      // 'pluginInstallerForm[ghTag]': { value: ghTag },
-    } = formData;
+      const {
+        'pluginInstallerForm[url]': { value: url },
+        'pluginInstallerForm[ghBranch]': { value: ghBranch },
+        // 'pluginInstallerForm[ghTag]': { value: ghTag },
+      } = formData;
 
 
-    const pluginInstallerForm: IGrowiPluginOrigin = {
-      url,
-      ghBranch: ghBranch || 'main',
-      // ghTag,
-    };
+      const pluginInstallerForm: IGrowiPluginOrigin = {
+        url,
+        ghBranch: ghBranch || 'main',
+        // ghTag,
+      };
 
 
-    try {
-      const res = await apiv3Post('/plugins', { pluginInstallerForm });
-      const pluginName = res.data.pluginName;
-      toastSuccess(t('toaster.install_plugin_success', { pluginName }));
-    }
-    catch (e) {
-      toastError(e);
-    }
-    finally {
-      mutate();
-    }
-  }, [mutate, t]);
+      try {
+        const res = await apiv3Post('/plugins', { pluginInstallerForm });
+        const pluginName = res.data.pluginName;
+        toastSuccess(t('toaster.install_plugin_success', { pluginName }));
+      } catch (e) {
+        toastError(e);
+      } finally {
+        mutate();
+      }
+    },
+    [mutate, t],
+  );
 
 
   return (
   return (
-    <form role="form" onSubmit={submitHandler}>
+    <form onSubmit={submitHandler}>
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('plugins.form.label_url')}</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="repoUrl"
+        >
+          {t('plugins.form.label_url')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control"
             className="form-control"
@@ -53,26 +58,37 @@ export const PluginInstallerForm = (): JSX.Element => {
             name="pluginInstallerForm[url]"
             name="pluginInstallerForm[url]"
             placeholder="https://github.com/growilabs/growi-plugins-example"
             placeholder="https://github.com/growilabs/growi-plugins-example"
             required
             required
+            id="repoUrl"
           />
           />
           <p className="form-text text-muted">{t('plugins.form.desc_url')}</p>
           <p className="form-text text-muted">{t('plugins.form.desc_url')}</p>
         </div>
         </div>
       </div>
       </div>
       <div className="row">
       <div className="row">
-        <label className="text-start text-md-end col-md-3 col-form-label">{t('plugins.form.label_branch')}</label>
+        <label
+          className="text-start text-md-end col-md-3 col-form-label"
+          htmlFor="branchName"
+        >
+          {t('plugins.form.label_branch')}
+        </label>
         <div className="col-md-6">
         <div className="col-md-6">
           <input
           <input
             className="form-control col-md-3"
             className="form-control col-md-3"
             type="text"
             type="text"
             name="pluginInstallerForm[ghBranch]"
             name="pluginInstallerForm[ghBranch]"
             placeholder="main"
             placeholder="main"
+            id="branchName"
           />
           />
-          <p className="form-text text-muted">{t('plugins.form.desc_branch')}</p>
+          <p className="form-text text-muted">
+            {t('plugins.form.desc_branch')}
+          </p>
         </div>
         </div>
       </div>
       </div>
 
 
       <div className="row my-3">
       <div className="row my-3">
         <div className="mx-auto">
         <div className="mx-auto">
-          <button type="submit" className="btn btn-primary">{t('plugins.install')}</button>
+          <button type="submit" className="btn btn-primary">
+            {t('plugins.install')}
+          </button>
         </div>
         </div>
       </div>
       </div>
     </form>
     </form>

+ 38 - 28
apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -1,10 +1,13 @@
-import React, { type JSX } from 'react';
+import dynamic from 'next/dynamic';
 
 
 import { useTranslation } from 'next-i18next';
 import { useTranslation } from 'next-i18next';
-import dynamic from 'next/dynamic';
+import React, { type JSX } from 'react';
 import { Spinner } from 'reactstrap';
 import { Spinner } from 'reactstrap';
 
 
-import { useSWRxAdminPlugins, usePluginDeleteModal } from '../../../stores/admin-plugins';
+import {
+  usePluginDeleteModal,
+  useSWRxAdminPlugins,
+} from '../../../stores/admin-plugins';
 
 
 import { PluginCard } from './PluginCard';
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';
 import { PluginInstallerForm } from './PluginInstallerForm';
@@ -19,8 +22,10 @@ const Loading = (): JSX.Element => {
 
 
 export const PluginsExtensionPageContents = (): JSX.Element => {
 export const PluginsExtensionPageContents = (): JSX.Element => {
   const { t } = useTranslation('admin');
   const { t } = useTranslation('admin');
-  const PluginDeleteModal = dynamic(() => import('./PluginDeleteModal')
-    .then(mod => mod.PluginDeleteModal), { ssr: false });
+  const PluginDeleteModal = dynamic(
+    () => import('./PluginDeleteModal').then((mod) => mod.PluginDeleteModal),
+    { ssr: false },
+  );
   const { data, mutate } = useSWRxAdminPlugins();
   const { data, mutate } = useSWRxAdminPlugins();
   const { open: openPluginDeleteModal } = usePluginDeleteModal();
   const { open: openPluginDeleteModal } = usePluginDeleteModal();
 
 
@@ -28,7 +33,9 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
     <div>
     <div>
       <div className="row mb-5">
       <div className="row mb-5">
         <div className="col-lg-12">
         <div className="col-lg-12">
-          <h2 className="admin-setting-header">{t('plugins.plugin_installer')}</h2>
+          <h2 className="admin-setting-header">
+            {t('plugins.plugin_installer')}
+          </h2>
           <PluginInstallerForm />
           <PluginInstallerForm />
         </div>
         </div>
       </div>
       </div>
@@ -37,34 +44,37 @@ export const PluginsExtensionPageContents = (): JSX.Element => {
         <div className="col-lg-12">
         <div className="col-lg-12">
           <h2 className="admin-setting-header">
           <h2 className="admin-setting-header">
             {t('plugins.plugin_card')}
             {t('plugins.plugin_card')}
-            <button type="button" className="btn btn-sm ms-auto grw-btn-reload" onClick={() => mutate()}>
+            <button
+              type="button"
+              className="btn btn-sm ms-auto grw-btn-reload"
+              onClick={() => mutate()}
+            >
               <span className="material-symbols-outlined">refresh</span>
               <span className="material-symbols-outlined">refresh</span>
             </button>
             </button>
           </h2>
           </h2>
-          {data?.plugins == null
-            ? <Loading />
-            : (
-              <div className="d-grid gap-5">
-                { data.plugins.length === 0 && (
-                  <div>{t('plugins.plugin_is_not_installed')}</div>
-                )}
-                {data.plugins.map(plugin => (
-                  <PluginCard
-                    key={plugin._id}
-                    id={plugin._id}
-                    name={plugin.meta.name}
-                    url={plugin.origin.url}
-                    isEnabled={plugin.isEnabled}
-                    desc={plugin.meta.desc}
-                    onDelete={() => openPluginDeleteModal(plugin)}
-                  />
-                ))}
-              </div>
-            )}
+          {data?.plugins == null ? (
+            <Loading />
+          ) : (
+            <div className="d-grid gap-5">
+              {data.plugins.length === 0 && (
+                <div>{t('plugins.plugin_is_not_installed')}</div>
+              )}
+              {data.plugins.map((plugin) => (
+                <PluginCard
+                  key={plugin._id}
+                  id={plugin._id}
+                  name={plugin.meta.name}
+                  url={plugin.origin.url}
+                  isEnabled={plugin.isEnabled}
+                  desc={plugin.meta.desc}
+                  onDelete={() => openPluginDeleteModal(plugin)}
+                />
+              ))}
+            </div>
+          )}
         </div>
         </div>
       </div>
       </div>
       <PluginDeleteModal />
       <PluginDeleteModal />
-
     </div>
     </div>
   );
   );
 };
 };

+ 11 - 8
apps/app/src/features/growi-plugin/client/components/GrowiPluginsActivator.tsx

@@ -1,14 +1,17 @@
-import React, { useEffect, type JSX } from 'react';
+import React, { type JSX, useEffect } from 'react';
 
 
-import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
+import {
+  initializeGrowiFacade,
+  registerGrowiFacade,
+} from '../utils/growi-facade-utils';
 
 
 declare global {
 declare global {
   // eslint-disable-next-line vars-on-top, no-var
   // eslint-disable-next-line vars-on-top, no-var
   var pluginActivators: {
   var pluginActivators: {
     [key: string]: {
     [key: string]: {
-      activate: () => void,
-      deactivate: () => void,
-    },
+      activate: () => void;
+      deactivate: () => void;
+    };
   };
   };
 }
 }
 
 
@@ -16,7 +19,9 @@ async function activateAll(): Promise<void> {
   initializeGrowiFacade();
   initializeGrowiFacade();
 
 
   // register renderer options to facade
   // register renderer options to facade
-  const { generateViewOptions, generatePreviewOptions } = await import('~/client/services/renderer/renderer');
+  const { generateViewOptions, generatePreviewOptions } = await import(
+    '~/client/services/renderer/renderer'
+  );
   registerGrowiFacade({
   registerGrowiFacade({
     markdownRenderer: {
     markdownRenderer: {
       optionsGenerators: {
       optionsGenerators: {
@@ -36,9 +41,7 @@ async function activateAll(): Promise<void> {
   });
   });
 }
 }
 
 
-
 export const GrowiPluginsActivator = (): JSX.Element => {
 export const GrowiPluginsActivator = (): JSX.Element => {
-
   useEffect(() => {
   useEffect(() => {
     activateAll();
     activateAll();
   }, []);
   }, []);

+ 30 - 26
apps/app/src/features/growi-plugin/client/stores/admin-plugins.tsx

@@ -7,40 +7,40 @@ import { useStaticSWR } from '~/stores/use-static-swr';
 import type { IGrowiPluginHasId } from '../../interfaces';
 import type { IGrowiPluginHasId } from '../../interfaces';
 
 
 type Plugins = {
 type Plugins = {
-  plugins: IGrowiPluginHasId[]
-}
+  plugins: IGrowiPluginHasId[];
+};
 
 
 export const useSWRxAdminPlugins = (): SWRResponse<Plugins, Error> => {
 export const useSWRxAdminPlugins = (): SWRResponse<Plugins, Error> => {
-  return useSWR(
-    '/plugins',
-    async(endpoint) => {
-      try {
-        const res = await apiv3Get<Plugins>(endpoint);
-        return res.data;
-      }
-      catch (err) {
-        throw new Error(err);
-      }
-    },
-  );
+  return useSWR('/plugins', async (endpoint) => {
+    try {
+      const res = await apiv3Get<Plugins>(endpoint);
+      return res.data;
+    } catch (err) {
+      throw new Error(err);
+    }
+  });
 };
 };
 
 
 /*
 /*
  * PluginDeleteModal
  * PluginDeleteModal
  */
  */
 type PluginDeleteModalStatus = {
 type PluginDeleteModalStatus = {
-  isOpen: boolean,
-  id: string,
-  name: string,
-  url: string,
-}
+  isOpen: boolean;
+  id: string;
+  name: string;
+  url: string;
+};
 
 
 type PluginDeleteModalUtils = {
 type PluginDeleteModalUtils = {
-  open(plugin: IGrowiPluginHasId): Promise<void>,
-  close(): Promise<void>,
-}
+  open(plugin: IGrowiPluginHasId): Promise<void>;
+  close(): Promise<void>;
+};
 
 
-export const usePluginDeleteModal = (): SWRResponse<PluginDeleteModalStatus, Error> & PluginDeleteModalUtils => {
+export const usePluginDeleteModal = (): SWRResponse<
+  PluginDeleteModalStatus,
+  Error
+> &
+  PluginDeleteModalUtils => {
   const initialStatus: PluginDeleteModalStatus = {
   const initialStatus: PluginDeleteModalStatus = {
     isOpen: false,
     isOpen: false,
     id: '',
     id: '',
@@ -48,10 +48,14 @@ export const usePluginDeleteModal = (): SWRResponse<PluginDeleteModalStatus, Err
     url: '',
     url: '',
   };
   };
 
 
-  const swrResponse = useStaticSWR<PluginDeleteModalStatus, Error>('pluginDeleteModal', undefined, { fallbackData: initialStatus });
+  const swrResponse = useStaticSWR<PluginDeleteModalStatus, Error>(
+    'pluginDeleteModal',
+    undefined,
+    { fallbackData: initialStatus },
+  );
   const { mutate } = swrResponse;
   const { mutate } = swrResponse;
 
 
-  const open = async(plugin) => {
+  const open = async (plugin) => {
     mutate({
     mutate({
       isOpen: true,
       isOpen: true,
       id: plugin._id,
       id: plugin._id,
@@ -60,7 +64,7 @@ export const usePluginDeleteModal = (): SWRResponse<PluginDeleteModalStatus, Err
     });
     });
   };
   };
 
 
-  const close = async() => {
+  const close = async () => {
     mutate(initialStatus);
     mutate(initialStatus);
   };
   };
 
 

+ 1 - 5
apps/app/src/features/growi-plugin/client/utils/growi-facade-utils.ts

@@ -7,7 +7,6 @@ declare global {
   var growiFacade: GrowiFacade;
   var growiFacade: GrowiFacade;
 }
 }
 
 
-
 export const initializeGrowiFacade = (): void => {
 export const initializeGrowiFacade = (): void => {
   if (isServer()) {
   if (isServer()) {
     return;
     return;
@@ -33,8 +32,5 @@ export const registerGrowiFacade = (addedFacade: GrowiFacade): void => {
     throw new Error('This method is available only in client.');
     throw new Error('This method is available only in client.');
   }
   }
 
 
-  window.growiFacade = deepmerge(
-    getGrowiFacade(),
-    addedFacade,
-  );
+  window.growiFacade = deepmerge(getGrowiFacade(), addedFacade);
 };
 };

+ 30 - 25
apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -1,39 +1,44 @@
-import type { GrowiPluginType, GrowiThemeMetadata, HasObjectId } from '@growi/core';
+import type {
+  GrowiPluginType,
+  GrowiThemeMetadata,
+  HasObjectId,
+} from '@growi/core';
 import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
 import type { TemplateSummary } from '@growi/pluginkit/dist/v4';
 
 
 export type IGrowiPluginOrigin = {
 export type IGrowiPluginOrigin = {
-  url: string,
-  ghBranch?: string,
-  ghTag?: string,
-}
+  url: string;
+  ghBranch?: string;
+  ghTag?: string;
+};
 
 
 export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
 export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
-  isEnabled: boolean,
-  installedPath: string,
-  organizationName: string,
-  origin: IGrowiPluginOrigin,
-  meta: M,
-}
+  isEnabled: boolean;
+  installedPath: string;
+  organizationName: string;
+  origin: IGrowiPluginOrigin;
+  meta: M;
+};
 
 
 export type IGrowiPluginMeta = {
 export type IGrowiPluginMeta = {
-  name: string,
-  types: GrowiPluginType[],
-  desc?: string,
-  author?: string,
-}
+  name: string;
+  types: GrowiPluginType[];
+  desc?: string;
+  author?: string;
+};
 
 
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
 export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
-  themes: GrowiThemeMetadata[],
-}
+  themes: GrowiThemeMetadata[];
+};
 
 
 export type IGrowiTemplatePluginMeta = IGrowiPluginMeta & {
 export type IGrowiTemplatePluginMeta = IGrowiPluginMeta & {
-  templateSummaries: TemplateSummary[],
-}
+  templateSummaries: TemplateSummary[];
+};
 
 
-export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> = T extends 'theme'
-  ? IGrowiThemePluginMeta
-  : T extends 'template'
-    ? IGrowiTemplatePluginMeta
-    : IGrowiPluginMeta;
+export type IGrowiPluginMetaByType<T extends GrowiPluginType = any> =
+  T extends 'theme'
+    ? IGrowiThemePluginMeta
+    : T extends 'template'
+      ? IGrowiTemplatePluginMeta
+      : IGrowiPluginMeta;
 
 
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;
 export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 19 - 18
apps/app/src/features/growi-plugin/server/models/growi-plugin.integ.ts

@@ -3,8 +3,7 @@ import { GrowiPluginType } from '@growi/core';
 import { GrowiPlugin } from './growi-plugin';
 import { GrowiPlugin } from './growi-plugin';
 
 
 describe('GrowiPlugin find methods', () => {
 describe('GrowiPlugin find methods', () => {
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     await GrowiPlugin.insertMany([
     await GrowiPlugin.insertMany([
       {
       {
         isEnabled: false,
         isEnabled: false,
@@ -57,16 +56,16 @@ describe('GrowiPlugin find methods', () => {
     ]);
     ]);
   });
   });
 
 
-  afterAll(async() => {
+  afterAll(async () => {
     await GrowiPlugin.deleteMany({});
     await GrowiPlugin.deleteMany({});
   });
   });
 
 
   describe.concurrent('.findEnabledPlugins', () => {
   describe.concurrent('.findEnabledPlugins', () => {
-    it('shoud returns documents which isEnabled is true', async() => {
+    it('shoud returns documents which isEnabled is true', async () => {
       // when
       // when
       const results = await GrowiPlugin.findEnabledPlugins();
       const results = await GrowiPlugin.findEnabledPlugins();
 
 
-      const pluginNames = results.map(p => p.meta.name);
+      const pluginNames = results.map((p) => p.meta.name);
 
 
       // then
       // then
       expect(results.length === 2).toBeTruthy();
       expect(results.length === 2).toBeTruthy();
@@ -76,24 +75,23 @@ describe('GrowiPlugin find methods', () => {
   });
   });
 
 
   describe.concurrent('.findEnabledPluginsByType', () => {
   describe.concurrent('.findEnabledPluginsByType', () => {
-    it("shoud returns documents which type is 'template'", async() => {
+    it("shoud returns documents which type is 'template'", async () => {
       // when
       // when
-      const results = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Template);
+      const results = await GrowiPlugin.findEnabledPluginsByType(
+        GrowiPluginType.Template,
+      );
 
 
-      const pluginNames = results.map(p => p.meta.name);
+      const pluginNames = results.map((p) => p.meta.name);
 
 
       // then
       // then
       expect(results.length === 1).toBeTruthy();
       expect(results.length === 1).toBeTruthy();
       expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
       expect(pluginNames.includes('@growi/growi-plugin-example2')).toBeTruthy();
     });
     });
   });
   });
-
 });
 });
 
 
-
 describe('GrowiPlugin activate/deactivate', () => {
 describe('GrowiPlugin activate/deactivate', () => {
-
-  beforeAll(async() => {
+  beforeAll(async () => {
     await GrowiPlugin.insertMany([
     await GrowiPlugin.insertMany([
       {
       {
         isEnabled: false,
         isEnabled: false,
@@ -110,12 +108,12 @@ describe('GrowiPlugin activate/deactivate', () => {
     ]);
     ]);
   });
   });
 
 
-  afterAll(async() => {
+  afterAll(async () => {
     await GrowiPlugin.deleteMany({});
     await GrowiPlugin.deleteMany({});
   });
   });
 
 
   describe('.activatePlugin', () => {
   describe('.activatePlugin', () => {
-    it('shoud update the property "isEnabled" to true', async() => {
+    it('shoud update the property "isEnabled" to true', async () => {
       // setup
       // setup
       const plugin = await GrowiPlugin.findOne({});
       const plugin = await GrowiPlugin.findOne({});
       assert(plugin != null);
       assert(plugin != null);
@@ -124,7 +122,9 @@ describe('GrowiPlugin activate/deactivate', () => {
 
 
       // when
       // when
       const result = await GrowiPlugin.activatePlugin(plugin._id);
       const result = await GrowiPlugin.activatePlugin(plugin._id);
-      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+      const pluginAfterActivated = await GrowiPlugin.findOne({
+        _id: plugin._id,
+      });
 
 
       // then
       // then
       expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
       expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
@@ -135,7 +135,7 @@ describe('GrowiPlugin activate/deactivate', () => {
   });
   });
 
 
   describe('.deactivatePlugin', () => {
   describe('.deactivatePlugin', () => {
-    it('shoud update the property "isEnabled" to true', async() => {
+    it('shoud update the property "isEnabled" to true', async () => {
       // setup
       // setup
       const plugin = await GrowiPlugin.findOne({});
       const plugin = await GrowiPlugin.findOne({});
       assert(plugin != null);
       assert(plugin != null);
@@ -144,7 +144,9 @@ describe('GrowiPlugin activate/deactivate', () => {
 
 
       // when
       // when
       const result = await GrowiPlugin.deactivatePlugin(plugin._id);
       const result = await GrowiPlugin.deactivatePlugin(plugin._id);
-      const pluginAfterActivated = await GrowiPlugin.findOne({ _id: plugin._id });
+      const pluginAfterActivated = await GrowiPlugin.findOne({
+        _id: plugin._id,
+      });
 
 
       // then
       // then
       expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
       expect(result).toEqual('@growi/growi-plugin-example1'); // equals to meta.name
@@ -153,5 +155,4 @@ describe('GrowiPlugin activate/deactivate', () => {
       expect(pluginAfterActivated.isEnabled).toBeFalsy(); // isEnabled: false
       expect(pluginAfterActivated.isEnabled).toBeFalsy(); // isEnabled: false
     });
     });
   });
   });
-
 });
 });

+ 45 - 20
apps/app/src/features/growi-plugin/server/models/growi-plugin.ts

@@ -1,25 +1,35 @@
 import { GrowiPluginType } from '@growi/core';
 import { GrowiPluginType } from '@growi/core';
-import {
-  Schema, type Model, type Document, type Types,
-} from 'mongoose';
+import { type Document, type Model, Schema, type Types } from 'mongoose';
 
 
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
 
 import type {
 import type {
-  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginMetaByType, IGrowiPluginOrigin, IGrowiTemplatePluginMeta, IGrowiThemePluginMeta,
+  IGrowiPlugin,
+  IGrowiPluginMeta,
+  IGrowiPluginMetaByType,
+  IGrowiPluginOrigin,
+  IGrowiTemplatePluginMeta,
+  IGrowiThemePluginMeta,
 } from '../../interfaces';
 } from '../../interfaces';
 
 
-export interface IGrowiPluginDocument<M extends IGrowiPluginMeta = IGrowiPluginMeta> extends IGrowiPlugin<M>, Document {
-  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta,
+export interface IGrowiPluginDocument<
+  M extends IGrowiPluginMeta = IGrowiPluginMeta,
+> extends IGrowiPlugin<M>,
+    Document {
+  metaJson: IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta;
 }
 }
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
 export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
-  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>
-  findEnabledPluginsByType<T extends GrowiPluginType>(type: T): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>
-  activatePlugin(id: Types.ObjectId): Promise<string>
-  deactivatePlugin(id: Types.ObjectId): Promise<string>
+  findEnabledPlugins(): Promise<IGrowiPluginDocument[]>;
+  findEnabledPluginsByType<T extends GrowiPluginType>(
+    type: T,
+  ): Promise<IGrowiPluginDocument<IGrowiPluginMetaByType<T>>[]>;
+  activatePlugin(id: Types.ObjectId): Promise<string>;
+  deactivatePlugin(id: Types.ObjectId): Promise<string>;
 }
 }
 
 
-const growiPluginMetaSchema = new Schema<IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta>({
+const growiPluginMetaSchema = new Schema<
+  IGrowiPluginMeta & IGrowiThemePluginMeta & IGrowiTemplatePluginMeta
+>({
   name: { type: String, required: true },
   name: { type: String, required: true },
   types: {
   types: {
     type: [String],
     type: [String],
@@ -46,21 +56,28 @@ const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
   meta: growiPluginMetaSchema,
   meta: growiPluginMetaSchema,
 });
 });
 
 
-growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPlugins = async function (): Promise<
+  IGrowiPlugin[]
+> {
   return this.find({ isEnabled: true }).lean();
   return this.find({ isEnabled: true }).lean();
 };
 };
 
 
-growiPluginSchema.statics.findEnabledPluginsByType = async function<T extends GrowiPluginType>(
-    type: T,
-): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
+growiPluginSchema.statics.findEnabledPluginsByType = async function <
+  T extends GrowiPluginType,
+>(type: T): Promise<IGrowiPlugin<IGrowiPluginMetaByType<T>>[]> {
   return this.find({
   return this.find({
     isEnabled: true,
     isEnabled: true,
     'meta.types': { $in: type },
     'meta.types': { $in: type },
   }).lean();
   }).lean();
 };
 };
 
 
-growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): Promise<string> {
-  const growiPlugin = await this.findOneAndUpdate({ _id: id }, { isEnabled: true });
+growiPluginSchema.statics.activatePlugin = async function (
+  id: Types.ObjectId,
+): Promise<string> {
+  const growiPlugin = await this.findOneAndUpdate(
+    { _id: id },
+    { isEnabled: true },
+  );
   if (growiPlugin == null) {
   if (growiPlugin == null) {
     const message = 'No plugin found for this ID.';
     const message = 'No plugin found for this ID.';
     throw new Error(message);
     throw new Error(message);
@@ -69,8 +86,13 @@ growiPluginSchema.statics.activatePlugin = async function(id: Types.ObjectId): P
   return pluginName;
   return pluginName;
 };
 };
 
 
-growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId): Promise<string> {
-  const growiPlugin = await this.findOneAndUpdate({ _id: id }, { isEnabled: false });
+growiPluginSchema.statics.deactivatePlugin = async function (
+  id: Types.ObjectId,
+): Promise<string> {
+  const growiPlugin = await this.findOneAndUpdate(
+    { _id: id },
+    { isEnabled: false },
+  );
   if (growiPlugin == null) {
   if (growiPlugin == null) {
     const message = 'No plugin found for this ID.';
     const message = 'No plugin found for this ID.';
     throw new Error(message);
     throw new Error(message);
@@ -79,4 +101,7 @@ growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId):
   return pluginName;
   return pluginName;
 };
 };
 
 
-export const GrowiPlugin = getOrCreateModel<IGrowiPluginDocument, IGrowiPluginModel>('GrowiPlugin', growiPluginSchema);
+export const GrowiPlugin = getOrCreateModel<
+  IGrowiPluginDocument,
+  IGrowiPluginModel
+>('GrowiPlugin', growiPluginSchema);

+ 11 - 9
apps/app/src/features/growi-plugin/server/models/vo/github-url.spec.ts

@@ -1,7 +1,6 @@
 import { GitHubUrl } from './github-url';
 import { GitHubUrl } from './github-url';
 
 
 describe('GitHubUrl Constructor throws an error when the url string is', () => {
 describe('GitHubUrl Constructor throws an error when the url string is', () => {
-
   it.concurrent.each`
   it.concurrent.each`
     url
     url
     ${'//example.com/org/repos'}
     ${'//example.com/org/repos'}
@@ -14,11 +13,9 @@ describe('GitHubUrl Constructor throws an error when the url string is', () => {
     // then
     // then
     expect(caller).toThrowError(`The specified URL is invalid. : url='${url}'`);
     expect(caller).toThrowError(`The specified URL is invalid. : url='${url}'`);
   });
   });
-
 });
 });
 
 
 describe('The constructor is successfully processed', () => {
 describe('The constructor is successfully processed', () => {
-
   it('with http schemed url', () => {
   it('with http schemed url', () => {
     // when
     // when
     const githubUrl = new GitHubUrl('http://github.com/org/repos');
     const githubUrl = new GitHubUrl('http://github.com/org/repos');
@@ -51,7 +48,6 @@ describe('The constructor is successfully processed', () => {
     expect(githubUrl.reposName).toEqual('repos');
     expect(githubUrl.reposName).toEqual('repos');
     expect(githubUrl.branchName).toEqual('fix/bug');
     expect(githubUrl.branchName).toEqual('fix/bug');
   });
   });
-
 });
 });
 
 
 describe('archiveUrl()', () => {
 describe('archiveUrl()', () => {
@@ -63,12 +59,13 @@ describe('archiveUrl()', () => {
     const { archiveUrl } = githubUrl;
     const { archiveUrl } = githubUrl;
 
 
     // then
     // then
-    expect(archiveUrl).toEqual('https://github.com/org/repos/archive/refs/heads/fix%2Fbug.zip');
+    expect(archiveUrl).toEqual(
+      'https://github.com/org/repos/archive/refs/heads/fix%2Fbug.zip',
+    );
   });
   });
 });
 });
 
 
 describe('extractedArchiveDirName()', () => {
 describe('extractedArchiveDirName()', () => {
-
   describe('certain characters in the branch name are converted to slashes, and if they are consecutive, they become a single hyphen', () => {
   describe('certain characters in the branch name are converted to slashes, and if they are consecutive, they become a single hyphen', () => {
     it.concurrent.each`
     it.concurrent.each`
       branchName
       branchName
@@ -76,7 +73,10 @@ describe('extractedArchiveDirName()', () => {
       ${'a---b'}
       ${'a---b'}
     `("'$branchName'", ({ branchName }) => {
     `("'$branchName'", ({ branchName }) => {
       // setup
       // setup
-      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+      const githubUrl = new GitHubUrl(
+        'https://github.com/org/repos',
+        branchName,
+      );
 
 
       // when
       // when
       const { extractedArchiveDirName } = githubUrl;
       const { extractedArchiveDirName } = githubUrl;
@@ -93,7 +93,10 @@ describe('extractedArchiveDirName()', () => {
       ${'a_b'}
       ${'a_b'}
     `("'$branchName'", ({ branchName }) => {
     `("'$branchName'", ({ branchName }) => {
       // setup
       // setup
-      const githubUrl = new GitHubUrl('https://github.com/org/repos', branchName);
+      const githubUrl = new GitHubUrl(
+        'https://github.com/org/repos',
+        branchName,
+      );
 
 
       // when
       // when
       const { extractedArchiveDirName } = githubUrl;
       const { extractedArchiveDirName } = githubUrl;
@@ -102,5 +105,4 @@ describe('extractedArchiveDirName()', () => {
       expect(extractedArchiveDirName).toEqual(branchName);
       expect(extractedArchiveDirName).toEqual(branchName);
     });
     });
   });
   });
-
 });
 });

+ 13 - 10
apps/app/src/features/growi-plugin/server/models/vo/github-url.ts

@@ -1,4 +1,3 @@
-
 import sanitize from 'sanitize-filename';
 import sanitize from 'sanitize-filename';
 
 
 // https://regex101.com/r/fK2rV3/1
 // https://regex101.com/r/fK2rV3/1
@@ -11,7 +10,6 @@ const sanitizeSymbolsChars = new RegExp(/[^a-zA-Z0-9_.]+/g);
 const sanitizeVersionChars = new RegExp(/^v[\d]/gi);
 const sanitizeVersionChars = new RegExp(/^v[\d]/gi);
 
 
 export class GitHubUrl {
 export class GitHubUrl {
-
   private _organizationName: string;
   private _organizationName: string;
 
 
   private _reposName: string;
   private _reposName: string;
@@ -39,19 +37,26 @@ export class GitHubUrl {
   get archiveUrl(): string {
   get archiveUrl(): string {
     const encodedBranchName = encodeURIComponent(this.branchName);
     const encodedBranchName = encodeURIComponent(this.branchName);
     const encodedTagName = encodeURIComponent(this.tagName);
     const encodedTagName = encodeURIComponent(this.tagName);
-    const zipUrl = encodedTagName !== '' ? `tags/${encodedTagName}` : `heads/${encodedBranchName}`;
-    const ghUrl = new URL(`/${this.organizationName}/${this.reposName}/archive/refs/${zipUrl}.zip`, 'https://github.com');
+    const zipUrl =
+      encodedTagName !== ''
+        ? `tags/${encodedTagName}`
+        : `heads/${encodedBranchName}`;
+    const ghUrl = new URL(
+      `/${this.organizationName}/${this.reposName}/archive/refs/${zipUrl}.zip`,
+      'https://github.com',
+    );
     return ghUrl.toString();
     return ghUrl.toString();
   }
   }
 
 
   get extractedArchiveDirName(): string {
   get extractedArchiveDirName(): string {
     const name = this._tagName !== '' ? this._tagName : this._branchName;
     const name = this._tagName !== '' ? this._tagName : this._branchName;
-    return name.replace(sanitizeVersionChars, m => m.substring(1)).replaceAll(sanitizeSymbolsChars, '-');
+    return name
+      .replace(sanitizeVersionChars, (m) => m.substring(1))
+      .replaceAll(sanitizeSymbolsChars, '-');
   }
   }
 
 
   constructor(url: string, branchName = 'main', tagName = '') {
   constructor(url: string, branchName = 'main', tagName = '') {
-
-    let matched;
+    let matched: RegExpMatchArray | null;
     try {
     try {
       const ghUrl = new URL(url);
       const ghUrl = new URL(url);
 
 
@@ -60,8 +65,7 @@ export class GitHubUrl {
       if (ghUrl.hostname !== 'github.com' || matched == null) {
       if (ghUrl.hostname !== 'github.com' || matched == null) {
         throw new Error();
         throw new Error();
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       throw new Error(`The specified URL is invalid. : url='${url}'`);
       throw new Error(`The specified URL is invalid. : url='${url}'`);
     }
     }
 
 
@@ -71,5 +75,4 @@ export class GitHubUrl {
     this._organizationName = sanitize(matched[1]);
     this._organizationName = sanitize(matched[1]);
     this._reposName = sanitize(matched[2]);
     this._reposName = sanitize(matched[2]);
   }
   }
-
 }
 }

+ 62 - 36
apps/app/src/features/growi-plugin/server/routes/apiv3/admin/index.ts

@@ -1,9 +1,8 @@
+import { SCOPE } from '@growi/core/dist/interfaces';
 import type { Request, Router } from 'express';
 import type { Request, Router } from 'express';
 import express from 'express';
 import express from 'express';
 import { body, query } from 'express-validator';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
-
-import { SCOPE } from '@growi/core/dist/interfaces';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
 import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
@@ -11,7 +10,6 @@ import type { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-respo
 import { GrowiPlugin } from '../../../models';
 import { GrowiPlugin } from '../../../models';
 import { growiPluginService } from '../../../services';
 import { growiPluginService } from '../../../services';
 
 
-
 const ObjectID = mongoose.Types.ObjectId;
 const ObjectID = mongoose.Types.ObjectId;
 
 
 /*
 /*
@@ -22,26 +20,34 @@ const validator = {
     query('id').isMongoId().withMessage('pluginId is required'),
     query('id').isMongoId().withMessage('pluginId is required'),
   ],
   ],
   pluginFormValueisRequired: [
   pluginFormValueisRequired: [
-    body('pluginInstallerForm').isString().withMessage('pluginFormValue is required'),
+    body('pluginInstallerForm')
+      .isString()
+      .withMessage('pluginFormValue is required'),
   ],
   ],
 };
 };
 
 
 module.exports = (crowi: Crowi): Router => {
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(
+    crowi,
+  );
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
   const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
 
   const router = express.Router();
   const router = express.Router();
 
 
-  router.get('/', accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
-    try {
-      const data = await GrowiPlugin.find({});
-      return res.apiv3({ plugins: data });
-    }
-    catch (err) {
-      return res.apiv3Err(err);
-    }
-  });
-
+  router.get(
+    '/',
+    accessTokenParser([SCOPE.READ.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    async (req: Request, res: ApiV3Response) => {
+      try {
+        const data = await GrowiPlugin.find({});
+        return res.apiv3({ plugins: data });
+      } catch (err) {
+        return res.apiv3Err(err);
+      }
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -82,18 +88,23 @@ module.exports = (crowi: Crowi): Router => {
    *                   description: The name of the installed plugin
    *                   description: The name of the installed plugin
    *
    *
    */
    */
-  router.post('/', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired,
-    async(req: Request, res: ApiV3Response) => {
+  router.post(
+    '/',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginFormValueisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { pluginInstallerForm: formValue } = req.body;
       const { pluginInstallerForm: formValue } = req.body;
 
 
       try {
       try {
         const pluginName = await growiPluginService.install(formValue);
         const pluginName = await growiPluginService.install(formValue);
         return res.apiv3({ pluginName });
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -123,33 +134,43 @@ module.exports = (crowi: Crowi): Router => {
    *                   type: string
    *                   type: string
    *                   description: The name of the activated plugin
    *                   description: The name of the activated plugin
    */
    */
-  router.put('/:id/activate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
-    async(req: Request, res: ApiV3Response) => {
+  router.put(
+    '/:id/activate',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginIdisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { id } = req.params;
       const { id } = req.params;
       const pluginId = new ObjectID(id);
       const pluginId = new ObjectID(id);
 
 
       try {
       try {
         const pluginName = await GrowiPlugin.activatePlugin(pluginId);
         const pluginName = await GrowiPlugin.activatePlugin(pluginId);
         return res.apiv3({ pluginName });
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
-
-  router.put('/:id/deactivate', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
-    async(req: Request, res: ApiV3Response) => {
+    },
+  );
+
+  router.put(
+    '/:id/deactivate',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginIdisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { id } = req.params;
       const { id } = req.params;
       const pluginId = new ObjectID(id);
       const pluginId = new ObjectID(id);
 
 
       try {
       try {
         const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
         const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
         return res.apiv3({ pluginName });
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
+    },
+  );
 
 
   /**
   /**
    * @swagger
    * @swagger
@@ -179,19 +200,24 @@ module.exports = (crowi: Crowi): Router => {
    *                   type: string
    *                   type: string
    *                   description: The name of the removed plugin
    *                   description: The name of the removed plugin
    */
    */
-  router.delete('/:id/remove', accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]), loginRequiredStrictly, adminRequired, validator.pluginIdisRequired,
-    async(req: Request, res: ApiV3Response) => {
+  router.delete(
+    '/:id/remove',
+    accessTokenParser([SCOPE.WRITE.ADMIN.PLUGIN]),
+    loginRequiredStrictly,
+    adminRequired,
+    validator.pluginIdisRequired,
+    async (req: Request, res: ApiV3Response) => {
       const { id } = req.params;
       const { id } = req.params;
       const pluginId = new ObjectID(id);
       const pluginId = new ObjectID(id);
 
 
       try {
       try {
         const pluginName = await growiPluginService.deletePlugin(pluginId);
         const pluginName = await growiPluginService.deletePlugin(pluginId);
         return res.apiv3({ pluginName });
         return res.apiv3({ pluginName });
-      }
-      catch (err) {
+      } catch (err) {
         return res.apiv3Err(err);
         return res.apiv3Err(err);
       }
       }
-    });
+    },
+  );
 
 
   return router;
   return router;
 };
 };

+ 11 - 3
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-template-plugin-meta.ts

@@ -1,11 +1,19 @@
 import type { GrowiPluginValidationData } from '@growi/pluginkit';
 import type { GrowiPluginValidationData } from '@growi/pluginkit';
 import { scanAllTemplates } from '@growi/pluginkit/dist/v4/server/index.cjs';
 import { scanAllTemplates } from '@growi/pluginkit/dist/v4/server/index.cjs';
 
 
-import type { IGrowiPlugin, IGrowiTemplatePluginMeta } from '../../../interfaces';
+import type {
+  IGrowiPlugin,
+  IGrowiTemplatePluginMeta,
+} from '../../../interfaces';
 
 
-export const generateTemplatePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiTemplatePluginMeta> => {
+export const generateTemplatePluginMeta = async (
+  plugin: IGrowiPlugin,
+  validationData: GrowiPluginValidationData,
+): Promise<IGrowiTemplatePluginMeta> => {
   return {
   return {
     ...plugin.meta,
     ...plugin.meta,
-    templateSummaries: await scanAllTemplates(validationData.projectDirRoot, { pluginId: plugin.installedPath }),
+    templateSummaries: await scanAllTemplates(validationData.projectDirRoot, {
+      pluginId: plugin.installedPath,
+    }),
   };
   };
 };
 };

+ 4 - 1
apps/app/src/features/growi-plugin/server/services/growi-plugin/generate-theme-plugin-meta.ts

@@ -2,7 +2,10 @@ import type { GrowiPluginValidationData } from '@growi/pluginkit';
 
 
 import type { IGrowiPlugin, IGrowiThemePluginMeta } from '../../../interfaces';
 import type { IGrowiPlugin, IGrowiThemePluginMeta } from '../../../interfaces';
 
 
-export const generateThemePluginMeta = async(plugin: IGrowiPlugin, validationData: GrowiPluginValidationData): Promise<IGrowiThemePluginMeta> => {
+export const generateThemePluginMeta = async (
+  plugin: IGrowiPlugin,
+  validationData: GrowiPluginValidationData,
+): Promise<IGrowiThemePluginMeta> => {
   // TODO: validate as a theme plugin
   // TODO: validate as a theme plugin
 
 
   return {
   return {

+ 42 - 25
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.integ.ts

@@ -7,27 +7,34 @@ import { GrowiPlugin } from '../../models';
 import { growiPluginService } from './growi-plugin';
 import { growiPluginService } from './growi-plugin';
 
 
 describe('Installing a GROWI template plugin', () => {
 describe('Installing a GROWI template plugin', () => {
-
-  it('install() should success', async() => {
+  it('install() should success', async () => {
     // when
     // when
     const result = await growiPluginService.install({
     const result = await growiPluginService.install({
       url: 'https://github.com/growilabs/growi-plugin-templates-for-office',
       url: 'https://github.com/growilabs/growi-plugin-templates-for-office',
     });
     });
-    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    const count = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-templates-for-office',
+    });
 
 
     // expect
     // expect
     expect(result).toEqual('growi-plugin-templates-for-office');
     expect(result).toEqual('growi-plugin-templates-for-office');
     expect(count).toBe(1);
     expect(count).toBe(1);
-    expect(fs.existsSync(path.join(
-      PLUGIN_STORING_PATH,
-      'growilabs',
-      'growi-plugin-templates-for-office',
-    ))).toBeTruthy();
+    expect(
+      fs.existsSync(
+        path.join(
+          PLUGIN_STORING_PATH,
+          'growilabs',
+          'growi-plugin-templates-for-office',
+        ),
+      ),
+    ).toBeTruthy();
   });
   });
 
 
-  it('install() should success (re-install)', async() => {
+  it('install() should success (re-install)', async () => {
     // confirm
     // confirm
-    const count1 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    const count1 = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-templates-for-office',
+    });
     expect(count1).toBe(1);
     expect(count1).toBe(1);
 
 
     // setup
     // setup
@@ -44,38 +51,46 @@ describe('Installing a GROWI template plugin', () => {
     const result = await growiPluginService.install({
     const result = await growiPluginService.install({
       url: 'https://github.com/growilabs/growi-plugin-templates-for-office',
       url: 'https://github.com/growilabs/growi-plugin-templates-for-office',
     });
     });
-    const count2 = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-templates-for-office' });
+    const count2 = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-templates-for-office',
+    });
 
 
     // expect
     // expect
     expect(result).toEqual('growi-plugin-templates-for-office');
     expect(result).toEqual('growi-plugin-templates-for-office');
     expect(count2).toBe(1);
     expect(count2).toBe(1);
     expect(fs.existsSync(dummyFilePath)).toBeFalsy(); // the dummy file should be removed
     expect(fs.existsSync(dummyFilePath)).toBeFalsy(); // the dummy file should be removed
   });
   });
-
 });
 });
 
 
 describe('Installing a GROWI theme plugin', () => {
 describe('Installing a GROWI theme plugin', () => {
-
-  it('install() should success', async() => {
+  it('install() should success', async () => {
     // when
     // when
     const result = await growiPluginService.install({
     const result = await growiPluginService.install({
       url: 'https://github.com/growilabs/growi-plugin-theme-vivid-internet',
       url: 'https://github.com/growilabs/growi-plugin-theme-vivid-internet',
     });
     });
-    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-vivid-internet' });
+    const count = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-theme-vivid-internet',
+    });
 
 
     // expect
     // expect
     expect(result).toEqual('growi-plugin-theme-vivid-internet');
     expect(result).toEqual('growi-plugin-theme-vivid-internet');
     expect(count).toBe(1);
     expect(count).toBe(1);
-    expect(fs.existsSync(path.join(
-      PLUGIN_STORING_PATH,
-      'growilabs',
-      'growi-plugin-theme-vivid-internet',
-    ))).toBeTruthy();
+    expect(
+      fs.existsSync(
+        path.join(
+          PLUGIN_STORING_PATH,
+          'growilabs',
+          'growi-plugin-theme-vivid-internet',
+        ),
+      ),
+    ).toBeTruthy();
   });
   });
 
 
-  it('findThemePlugin() should return data with metadata and manifest', async() => {
+  it('findThemePlugin() should return data with metadata and manifest', async () => {
     // confirm
     // confirm
-    const count = await GrowiPlugin.count({ 'meta.name': 'growi-plugin-theme-vivid-internet' });
+    const count = await GrowiPlugin.count({
+      'meta.name': 'growi-plugin-theme-vivid-internet',
+    });
     expect(count).toBe(1);
     expect(count).toBe(1);
 
 
     // when
     // when
@@ -87,8 +102,10 @@ describe('Installing a GROWI theme plugin', () => {
     expect(results.growiPlugin).not.toBeNull();
     expect(results.growiPlugin).not.toBeNull();
     expect(results.themeMetadata).not.toBeNull();
     expect(results.themeMetadata).not.toBeNull();
     expect(results.themeHref).not.toBeNull();
     expect(results.themeHref).not.toBeNull();
-    expect(results.themeHref
-      .startsWith('/static/plugins/growilabs/growi-plugin-theme-vivid-internet/dist/assets/style-')).toBeTruthy();
+    expect(
+      results.themeHref?.startsWith(
+        '/static/plugins/growilabs/growi-plugin-theme-vivid-internet/dist/assets/style-',
+      ),
+    ).toBeTruthy();
   });
   });
-
 });
 });

+ 190 - 123
apps/app/src/features/growi-plugin/server/services/growi-plugin/growi-plugin.ts

@@ -1,20 +1,24 @@
-import fs, { readFileSync } from 'fs';
-import path from 'path';
-import { pipeline } from 'stream/promises';
-
-import { GrowiPluginType } from '@growi/core';
 import type { GrowiThemeMetadata, ViteManifest } from '@growi/core';
 import type { GrowiThemeMetadata, ViteManifest } from '@growi/core';
+import { GrowiPluginType } from '@growi/core';
 import type { GrowiPluginPackageData } from '@growi/pluginkit';
 import type { GrowiPluginPackageData } from '@growi/pluginkit';
-import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist/v4/server/index.cjs';
+import {
+  importPackageJson,
+  validateGrowiDirective,
+} from '@growi/pluginkit/dist/v4/server/index.cjs';
 // eslint-disable-next-line no-restricted-imports
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import axios from 'axios';
+import fs, { readFileSync } from 'fs';
 import type mongoose from 'mongoose';
 import type mongoose from 'mongoose';
+import path from 'path';
+import { pipeline } from 'stream/promises';
 import unzipStream from 'unzip-stream';
 import unzipStream from 'unzip-stream';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type {
 import type {
-  IGrowiPlugin, IGrowiPluginOrigin, IGrowiPluginMeta,
+  IGrowiPlugin,
+  IGrowiPluginMeta,
+  IGrowiPluginOrigin,
 } from '../../../interfaces';
 } from '../../../interfaces';
 import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '../../consts';
 import { PLUGIN_EXPRESS_STATIC_DIR, PLUGIN_STORING_PATH } from '../../consts';
 import { GrowiPlugin } from '../../models';
 import { GrowiPlugin } from '../../models';
@@ -25,12 +29,25 @@ import { generateThemePluginMeta } from './generate-theme-plugin-meta';
 
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
 
-export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
+export type GrowiPluginResourceEntries = [
+  installedPath: string,
+  href: string,
+][];
 
 
-function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest | undefined {
+function retrievePluginManifest(
+  growiPlugin: IGrowiPlugin,
+): ViteManifest | undefined {
   // ref: https://vitejs.dev/guide/migration.html#manifest-files-are-now-generated-in-vite-directory-by-default
   // ref: https://vitejs.dev/guide/migration.html#manifest-files-are-now-generated-in-vite-directory-by-default
-  const manifestPathByVite4 = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath, 'dist/manifest.json');
-  const manifestPath = path.join(PLUGIN_STORING_PATH, growiPlugin.installedPath, 'dist/.vite/manifest.json');
+  const manifestPathByVite4 = path.join(
+    PLUGIN_STORING_PATH,
+    growiPlugin.installedPath,
+    'dist/manifest.json',
+  );
+  const manifestPath = path.join(
+    PLUGIN_STORING_PATH,
+    growiPlugin.installedPath,
+    'dist/.vite/manifest.json',
+  );
 
 
   const isManifestByVite4Exists = fs.existsSync(manifestPathByVite4);
   const isManifestByVite4Exists = fs.existsSync(manifestPathByVite4);
   const isManifestExists = fs.existsSync(manifestPath);
   const isManifestExists = fs.existsSync(manifestPath);
@@ -46,25 +63,23 @@ function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest | undef
   return JSON.parse(manifestStr);
   return JSON.parse(manifestStr);
 }
 }
 
 
-
 type FindThemePluginResult = {
 type FindThemePluginResult = {
-  growiPlugin: IGrowiPlugin,
-  themeMetadata: GrowiThemeMetadata,
-  themeHref: string,
-}
+  growiPlugin: IGrowiPlugin;
+  themeMetadata: GrowiThemeMetadata;
+  themeHref: string | undefined;
+};
 
 
 export interface IGrowiPluginService {
 export interface IGrowiPluginService {
-  install(origin: IGrowiPluginOrigin): Promise<string>
-  findThemePlugin(theme: string): Promise<FindThemePluginResult | null>
-  retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
-  downloadNotExistPluginRepositories(): Promise<void>
+  install(origin: IGrowiPluginOrigin): Promise<string>;
+  findThemePlugin(theme: string): Promise<FindThemePluginResult | null>;
+  retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>;
+  downloadNotExistPluginRepositories(): Promise<void>;
 }
 }
 
 
 export class GrowiPluginService implements IGrowiPluginService {
 export class GrowiPluginService implements IGrowiPluginService {
-
   /*
   /*
-  * Downloading a non-existent repository to the file system
-  */
+   * Downloading a non-existent repository to the file system
+   */
   async downloadNotExistPluginRepositories(): Promise<void> {
   async downloadNotExistPluginRepositories(): Promise<void> {
     try {
     try {
       // find all growi plugin documents
       // find all growi plugin documents
@@ -72,69 +87,93 @@ export class GrowiPluginService implements IGrowiPluginService {
 
 
       // if not exists repository in file system, download latest plugin repository
       // if not exists repository in file system, download latest plugin repository
       for await (const growiPlugin of growiPlugins) {
       for await (const growiPlugin of growiPlugins) {
-        let pluginPath :fs.PathLike|undefined;
-        let organizationName :fs.PathLike|undefined;
+        let pluginPath: fs.PathLike | undefined;
+        let organizationName: fs.PathLike | undefined;
         try {
         try {
-          pluginPath = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugin.installedPath);
-          organizationName = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugin.organizationName);
-        }
-        catch (err) {
+          pluginPath = this.joinAndValidatePath(
+            PLUGIN_STORING_PATH,
+            growiPlugin.installedPath,
+          );
+          organizationName = this.joinAndValidatePath(
+            PLUGIN_STORING_PATH,
+            growiPlugin.organizationName,
+          );
+        } catch (err) {
           logger.error(err);
           logger.error(err);
           continue;
           continue;
         }
         }
         if (fs.existsSync(pluginPath)) {
         if (fs.existsSync(pluginPath)) {
-          continue;
-        }
-        else {
+        } else {
           if (!fs.existsSync(organizationName)) {
           if (!fs.existsSync(organizationName)) {
             fs.mkdirSync(organizationName);
             fs.mkdirSync(organizationName);
           }
           }
 
 
           // TODO: imprv Document version and repository version possibly different.
           // TODO: imprv Document version and repository version possibly different.
-          const ghUrl = new GitHubUrl(growiPlugin.origin.url, growiPlugin.origin.ghBranch);
+          const ghUrl = new GitHubUrl(
+            growiPlugin.origin.url,
+            growiPlugin.origin.ghBranch,
+          );
           const { reposName, archiveUrl, extractedArchiveDirName } = ghUrl;
           const { reposName, archiveUrl, extractedArchiveDirName } = ghUrl;
 
 
-          const zipFilePath = path.join(PLUGIN_STORING_PATH, `${extractedArchiveDirName}.zip`);
+          const zipFilePath = path.join(
+            PLUGIN_STORING_PATH,
+            `${extractedArchiveDirName}.zip`,
+          );
           const unzippedPath = PLUGIN_STORING_PATH;
           const unzippedPath = PLUGIN_STORING_PATH;
-          const unzippedReposPath = path.join(PLUGIN_STORING_PATH, `${reposName}-${extractedArchiveDirName}`);
+          const unzippedReposPath = path.join(
+            PLUGIN_STORING_PATH,
+            `${reposName}-${extractedArchiveDirName}`,
+          );
 
 
           try {
           try {
             // download github repository to local file system
             // download github repository to local file system
             await this.download(archiveUrl, zipFilePath);
             await this.download(archiveUrl, zipFilePath);
             await this.unzip(zipFilePath, unzippedPath);
             await this.unzip(zipFilePath, unzippedPath);
             fs.renameSync(unzippedReposPath, pluginPath);
             fs.renameSync(unzippedReposPath, pluginPath);
-          }
-          catch (err) {
+          } catch (err) {
             // clean up, documents are not operated
             // clean up, documents are not operated
-            if (fs.existsSync(unzippedReposPath)) await fs.promises.rm(unzippedReposPath, { recursive: true });
-            if (fs.existsSync(pluginPath)) await fs.promises.rm(pluginPath, { recursive: true });
+            if (fs.existsSync(unzippedReposPath))
+              await fs.promises.rm(unzippedReposPath, { recursive: true });
+            if (fs.existsSync(pluginPath))
+              await fs.promises.rm(pluginPath, { recursive: true });
             logger.error(err);
             logger.error(err);
           }
           }
-
-          continue;
         }
         }
       }
       }
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
     }
     }
   }
   }
 
 
   /*
   /*
-  * Install a plugin from URL and save it in the DB and file system.
-  */
+   * Install a plugin from URL and save it in the DB and file system.
+   */
   async install(origin: IGrowiPluginOrigin): Promise<string> {
   async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
     const ghUrl = new GitHubUrl(origin.url, origin.ghBranch);
-    const {
-      organizationName, reposName, archiveUrl, extractedArchiveDirName,
-    } = ghUrl;
+    const { organizationName, reposName, archiveUrl, extractedArchiveDirName } =
+      ghUrl;
 
 
     const installedPath = `${organizationName}/${reposName}`;
     const installedPath = `${organizationName}/${reposName}`;
 
 
-    const organizationPath = path.join(PLUGIN_STORING_PATH, organizationName);
-    const zipFilePath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}.zip`);
-    const temporaryReposPath = path.join(organizationPath, `${reposName}-${extractedArchiveDirName}`);
-    const reposPath = path.join(organizationPath, reposName);
+    const organizationPath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+    );
+    const zipFilePath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+      `${reposName}-${extractedArchiveDirName}.zip`,
+    );
+    const temporaryReposPath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+      `${reposName}-${extractedArchiveDirName}`,
+    );
+    const reposPath = this.joinAndValidatePath(
+      PLUGIN_STORING_PATH,
+      organizationName,
+      reposName,
+    );
 
 
     if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
     if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
 
@@ -146,22 +185,27 @@ export class GrowiPluginService implements IGrowiPluginService {
       await this.unzip(zipFilePath, organizationPath);
       await this.unzip(zipFilePath, organizationPath);
 
 
       // detect plugins
       // detect plugins
-      plugins = await GrowiPluginService.detectPlugins(origin, organizationName, reposName, { packageRootPath: temporaryReposPath });
+      plugins = await GrowiPluginService.detectPlugins(
+        origin,
+        organizationName,
+        reposName,
+        { packageRootPath: temporaryReposPath },
+      );
 
 
       // remove the old repository from the storing path
       // remove the old repository from the storing path
-      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
+      if (fs.existsSync(reposPath))
+        await fs.promises.rm(reposPath, { recursive: true });
 
 
       // move new repository from temporary path to storing path.
       // move new repository from temporary path to storing path.
       fs.renameSync(temporaryReposPath, reposPath);
       fs.renameSync(temporaryReposPath, reposPath);
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw err;
       throw err;
-    }
-    finally {
+    } finally {
       // clean up
       // clean up
       if (fs.existsSync(zipFilePath)) await fs.promises.rm(zipFilePath);
       if (fs.existsSync(zipFilePath)) await fs.promises.rm(zipFilePath);
-      if (fs.existsSync(temporaryReposPath)) await fs.promises.rm(temporaryReposPath, { recursive: true });
+      if (fs.existsSync(temporaryReposPath))
+        await fs.promises.rm(temporaryReposPath, { recursive: true });
     }
     }
 
 
     try {
     try {
@@ -172,10 +216,10 @@ export class GrowiPluginService implements IGrowiPluginService {
       await this.savePluginMetaData(plugins);
       await this.savePluginMetaData(plugins);
 
 
       return plugins[0].meta.name;
       return plugins[0].meta.name;
-    }
-    catch (err) {
+    } catch (err) {
       // uninstall
       // uninstall
-      if (fs.existsSync(reposPath)) await fs.promises.rm(reposPath, { recursive: true });
+      if (fs.existsSync(reposPath))
+        await fs.promises.rm(reposPath, { recursive: true });
       await this.deleteOldPluginDocument(installedPath);
       await this.deleteOldPluginDocument(installedPath);
 
 
       logger.error(err);
       logger.error(err);
@@ -198,16 +242,17 @@ export class GrowiPluginService implements IGrowiPluginService {
         .then((res) => {
         .then((res) => {
           if (res.status === 200) {
           if (res.status === 200) {
             const file = fs.createWriteStream(filePath);
             const file = fs.createWriteStream(filePath);
-            res.data.pipe(file)
+            res.data
+              .pipe(file)
               .on('close', () => file.close())
               .on('close', () => file.close())
               .on('finish', () => {
               .on('finish', () => {
                 return resolve();
                 return resolve();
               });
               });
-          }
-          else {
+          } else {
             rejects(res.status);
             rejects(res.status);
           }
           }
-        }).catch((err) => {
+        })
+        .catch((err) => {
           logger.error(err);
           logger.error(err);
           // eslint-disable-next-line prefer-promise-reject-errors
           // eslint-disable-next-line prefer-promise-reject-errors
           rejects('Failed to download file.');
           rejects('Failed to download file.');
@@ -215,12 +260,17 @@ export class GrowiPluginService implements IGrowiPluginService {
     });
     });
   }
   }
 
 
-  private async unzip(zipFilePath: fs.PathLike, destPath: fs.PathLike): Promise<void> {
+  private async unzip(
+    zipFilePath: fs.PathLike,
+    destPath: fs.PathLike,
+  ): Promise<void> {
     try {
     try {
       const readZipStream = fs.createReadStream(zipFilePath);
       const readZipStream = fs.createReadStream(zipFilePath);
-      await pipeline(readZipStream, unzipStream.Extract({ path: destPath.toString() }));
-    }
-    catch (err) {
+      await pipeline(
+        readZipStream,
+        unzipStream.Extract({ path: destPath.toString() }),
+      );
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to unzip.');
       throw new Error('Failed to unzip.');
     }
     }
@@ -232,32 +282,44 @@ export class GrowiPluginService implements IGrowiPluginService {
 
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
   private static async detectPlugins(
   private static async detectPlugins(
-      origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string,
-      opts?: {
-        packageRootPath?: string,
-        parentPackageData?: GrowiPluginPackageData,
-      },
+    origin: IGrowiPluginOrigin,
+    ghOrganizationName: string,
+    ghReposName: string,
+    opts?: {
+      packageRootPath?: string;
+      parentPackageData?: GrowiPluginPackageData;
+    },
   ): Promise<IGrowiPlugin[]> {
   ): Promise<IGrowiPlugin[]> {
-    const packageRootPath = opts?.packageRootPath ?? path.resolve(PLUGIN_STORING_PATH, ghOrganizationName, ghReposName);
+    const packageRootPath =
+      opts?.packageRootPath ??
+      path.resolve(PLUGIN_STORING_PATH, ghOrganizationName, ghReposName);
 
 
     // validate
     // validate
     const validationData = await validateGrowiDirective(packageRootPath);
     const validationData = await validateGrowiDirective(packageRootPath);
 
 
-    const packageData = opts?.parentPackageData ?? importPackageJson(packageRootPath);
+    const packageData =
+      opts?.parentPackageData ?? importPackageJson(packageRootPath);
 
 
     const { growiPlugin } = validationData;
     const { growiPlugin } = validationData;
     const {
     const {
-      name: packageName, description: packageDesc, author: packageAuthor,
+      name: packageName,
+      description: packageDesc,
+      author: packageAuthor,
     } = packageData;
     } = packageData;
 
 
     // detect sub plugins for monorepo
     // detect sub plugins for monorepo
     if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
     if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
       const plugins = await Promise.all(
       const plugins = await Promise.all(
-        growiPlugin.packages.map(async(subPackagePath) => {
-          return this.detectPlugins(origin, ghOrganizationName, ghReposName, {
-            packageRootPath: path.join(packageRootPath, subPackagePath),
-            parentPackageData: packageData,
-          });
+        growiPlugin.packages.map(async (subPackagePath) => {
+          return GrowiPluginService.detectPlugins(
+            origin,
+            ghOrganizationName,
+            ghReposName,
+            {
+              packageRootPath: path.join(packageRootPath, subPackagePath),
+              parentPackageData: packageData,
+            },
+          );
         }),
         }),
       );
       );
       return plugins.flat();
       return plugins.flat();
@@ -310,31 +372,32 @@ export class GrowiPluginService implements IGrowiPluginService {
 
 
     try {
     try {
       await GrowiPlugin.deleteOne({ _id: pluginId });
       await GrowiPlugin.deleteOne({ _id: pluginId });
-    }
-    catch (err) {
+    } catch (err) {
       logger.error(err);
       logger.error(err);
       throw new Error('Failed to delete plugin from GrowiPlugin documents.');
       throw new Error('Failed to delete plugin from GrowiPlugin documents.');
     }
     }
 
 
     let growiPluginsPath: fs.PathLike | undefined;
     let growiPluginsPath: fs.PathLike | undefined;
     try {
     try {
-      growiPluginsPath = this.joinAndValidatePath(PLUGIN_STORING_PATH, growiPlugins.installedPath);
-    }
-    catch (err) {
+      growiPluginsPath = this.joinAndValidatePath(
+        PLUGIN_STORING_PATH,
+        growiPlugins.installedPath,
+      );
+    } catch (err) {
       logger.error(err);
       logger.error(err);
-      throw new Error('The installedPath for the plugin is invalid, and the plugin has already been removed.');
+      throw new Error(
+        'The installedPath for the plugin is invalid, and the plugin has already been removed.',
+      );
     }
     }
 
 
     if (growiPluginsPath && fs.existsSync(growiPluginsPath)) {
     if (growiPluginsPath && fs.existsSync(growiPluginsPath)) {
       try {
       try {
         await deleteFolder(growiPluginsPath);
         await deleteFolder(growiPluginsPath);
-      }
-      catch (err) {
+      } catch (err) {
         logger.error(err);
         logger.error(err);
         throw new Error('Failed to delete plugin repository.');
         throw new Error('Failed to delete plugin repository.');
       }
       }
-    }
-    else {
+    } else {
       logger.warn(`Plugin path does not exist : ${growiPluginsPath}`);
       logger.warn(`Plugin path does not exist : ${growiPluginsPath}`);
     }
     }
     return growiPlugins.meta.name;
     return growiPlugins.meta.name;
@@ -346,51 +409,56 @@ export class GrowiPluginService implements IGrowiPluginService {
 
 
     try {
     try {
       // retrieve plugin manifests
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsByType(GrowiPluginType.Theme);
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsByType(
+        GrowiPluginType.Theme,
+      );
 
 
-      growiPlugins
-        .forEach((growiPlugin) => {
-          const themeMetadatas = growiPlugin.meta.themes;
-          const themeMetadata = themeMetadatas.find(t => t.name === theme);
+      growiPlugins.forEach((growiPlugin) => {
+        const themeMetadatas = growiPlugin.meta.themes;
+        const themeMetadata = themeMetadatas.find((t) => t.name === theme);
 
 
-          // found
-          if (themeMetadata != null) {
-            matchedPlugin = growiPlugin;
-            matchedThemeMetadata = themeMetadata;
-          }
-        });
-    }
-    catch (e) {
-      logger.error(`Could not find the theme '${theme}' from GrowiPlugin documents.`, e);
+        // found
+        if (themeMetadata != null) {
+          matchedPlugin = growiPlugin;
+          matchedThemeMetadata = themeMetadata;
+        }
+      });
+    } catch (e) {
+      logger.error(
+        `Could not find the theme '${theme}' from GrowiPlugin documents.`,
+        e,
+      );
     }
     }
 
 
     if (matchedPlugin == null || matchedThemeMetadata == null) {
     if (matchedPlugin == null || matchedThemeMetadata == null) {
       return null;
       return null;
     }
     }
 
 
-    let themeHref;
+    let themeHref: string | undefined;
     try {
     try {
       const manifest = retrievePluginManifest(matchedPlugin);
       const manifest = retrievePluginManifest(matchedPlugin);
       if (manifest == null) {
       if (manifest == null) {
         throw new Error('The manifest file does not exists');
         throw new Error('The manifest file does not exists');
       }
       }
       themeHref = `${PLUGIN_EXPRESS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
       themeHref = `${PLUGIN_EXPRESS_STATIC_DIR}/${matchedPlugin.installedPath}/dist/${manifest[matchedThemeMetadata.manifestKey].file}`;
-    }
-    catch (e) {
+    } catch (e) {
       logger.error(`Could not read manifest file for the theme '${theme}'`, e);
       logger.error(`Could not read manifest file for the theme '${theme}'`, e);
     }
     }
 
 
-    return { growiPlugin: matchedPlugin, themeMetadata: matchedThemeMetadata, themeHref };
+    return {
+      growiPlugin: matchedPlugin,
+      themeMetadata: matchedThemeMetadata,
+      themeHref,
+    };
   }
   }
 
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
-
     const entries: GrowiPluginResourceEntries = [];
     const entries: GrowiPluginResourceEntries = [];
 
 
     try {
     try {
       const growiPlugins = await GrowiPlugin.findEnabledPlugins();
       const growiPlugins = await GrowiPlugin.findEnabledPlugins();
 
 
-      growiPlugins.forEach(async(growiPlugin) => {
+      growiPlugins.forEach(async (growiPlugin) => {
         try {
         try {
           const { types } = growiPlugin.meta;
           const { types } = growiPlugin.meta;
           const manifest = await retrievePluginManifest(growiPlugin);
           const manifest = await retrievePluginManifest(growiPlugin);
@@ -405,35 +473,34 @@ export class GrowiPluginService implements IGrowiPluginService {
             entries.push([growiPlugin.installedPath, href]);
             entries.push([growiPlugin.installedPath, href]);
           }
           }
           // add link
           // add link
-          if (types.includes(GrowiPluginType.Script) || types.includes(GrowiPluginType.Style)) {
+          if (
+            types.includes(GrowiPluginType.Script) ||
+            types.includes(GrowiPluginType.Style)
+          ) {
             const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
             const href = `${PLUGIN_EXPRESS_STATIC_DIR}/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`;
             entries.push([growiPlugin.installedPath, href]);
             entries.push([growiPlugin.installedPath, href]);
           }
           }
-        }
-        catch (e) {
+        } catch (e) {
           logger.warn(e);
           logger.warn(e);
         }
         }
       });
       });
-    }
-    catch (e) {
+    } catch (e) {
       logger.error('Could not retrieve GrowiPlugin documents.', e);
       logger.error('Could not retrieve GrowiPlugin documents.', e);
     }
     }
 
 
     return entries;
     return entries;
   }
   }
 
 
-  private joinAndValidatePath(baseDir: string, ...paths: string[]):fs.PathLike {
+  private joinAndValidatePath(baseDir: string, ...paths: string[]): string {
     const joinedPath = path.join(baseDir, ...paths);
     const joinedPath = path.join(baseDir, ...paths);
     if (!joinedPath.startsWith(baseDir)) {
     if (!joinedPath.startsWith(baseDir)) {
       throw new Error(
       throw new Error(
-        'Invalid plugin path detected! Access outside of the allowed directory is not permitted.'
-        + `\nAttempted Path: ${joinedPath}`,
+        'Invalid plugin path detected! Access outside of the allowed directory is not permitted.' +
+          `\nAttempted Path: ${joinedPath}`,
       );
       );
     }
     }
     return joinedPath;
     return joinedPath;
   }
   }
-
 }
 }
 
 
-
 export const growiPluginService = new GrowiPluginService();
 export const growiPluginService = new GrowiPluginService();

+ 9 - 0
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.module.scss

@@ -17,6 +17,15 @@
   .btn-submit {
   .btn-submit {
     font-size: 1.1em;
     font-size: 1.1em;
   }
   }
+
+  .thread-title-sticky {
+    -webkit-backdrop-filter: blur(10px);
+    backdrop-filter: blur(10px);
+  }
+
+  .input-form-area {
+    box-shadow: 0 -10px 20px 10px rgba(var(--bs-body-bg-rgb), 1);
+  }
 }
 }
 
 
 // == Colors
 // == Colors

+ 38 - 34
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -82,6 +82,7 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
     headerIcon: headerIconForKnowledgeAssistant,
     headerIcon: headerIconForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
     headerText: headerTextForKnowledgeAssistant,
     placeHolder: placeHolderForKnowledgeAssistant,
     placeHolder: placeHolderForKnowledgeAssistant,
+    threadTitleView: threadTitleViewForKnowledgeAssistant,
   } = useKnowledgeAssistant();
   } = useKnowledgeAssistant();
 
 
   const {
   const {
@@ -432,47 +433,50 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
             className="h-100"
             className="h-100"
             autoHide
             autoHide
           >
           >
-            <div className="p-4 d-flex flex-column gap-4 flex-grow-1">
-              { threadData != null
-                ? (
-                  <div className="vstack gap-4 pb-2">
-                    { messageLogs.map(message => (
-                      <>
+            {!isEditorAssistant && threadTitleViewForKnowledgeAssistant}
+            <div className="p-4">
+              <div className="d-flex flex-column gap-4 flex-grow-1">
+                { threadData != null
+                  ? (
+                    <div className="vstack gap-4 pb-2">
+                      { messageLogs.map(message => (
+                        <>
+                          <MessageCard
+                            role={message.isUserMessage ? 'user' : 'assistant'}
+                            additionalItem={messageCardAdditionalItemForGeneratedMessage(message.id)}
+                          >
+                            {message.content}
+                          </MessageCard>
+                        </>
+                      )) }
+                      { generatingAnswerMessage != null && (
                         <MessageCard
                         <MessageCard
-                          role={message.isUserMessage ? 'user' : 'assistant'}
-                          additionalItem={messageCardAdditionalItemForGeneratedMessage(message.id)}
+                          role="assistant"
+                          additionalItem={messageCardAdditionalItemForGeneratingMessage}
                         >
                         >
-                          {message.content}
+                          {generatingAnswerMessage.content}
                         </MessageCard>
                         </MessageCard>
-                      </>
-                    )) }
-                    { generatingAnswerMessage != null && (
-                      <MessageCard
-                        role="assistant"
-                        additionalItem={messageCardAdditionalItemForGeneratingMessage}
-                      >
-                        {generatingAnswerMessage.content}
-                      </MessageCard>
-                    )}
-                    { isEditorAssistant && partialContentWarnLabel }
-                    { messageLogs.length > 0 && (
-                      <div className="d-flex justify-content-center">
-                        <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
-                          {t('sidebar_ai_assistant.caution_against_hallucination')}
-                        </span>
-                      </div>
-                    )}
-                  </div>
-                )
-                : (
-                  <>{ initialView }</>
-                )
-              }
+                      )}
+                      { isEditorAssistant && partialContentWarnLabel }
+                      { messageLogs.length > 0 && (
+                        <div className="d-flex justify-content-center">
+                          <span className="bg-body-tertiary text-body-secondary rounded-pill px-3 py-1" style={{ fontSize: 'smaller' }}>
+                            {t('sidebar_ai_assistant.caution_against_hallucination')}
+                          </span>
+                        </div>
+                      )}
+                    </div>
+                  )
+                  : (
+                    <>{ initialView }</>
+                  )
+                }
+              </div>
             </div>
             </div>
           </SimpleBar>
           </SimpleBar>
         </div>
         </div>
 
 
-        <div className="position-sticky bottom-0 bg-body z-2 p-3 border-top">
+        <div className="input-form-area position-sticky bg-body z-2 p-3">
           <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-1">
           <form onSubmit={form.handleSubmit(submit)} className="flex-fill vstack gap-1">
             <Controller
             <Controller
               name="input"
               name="input"

+ 32 - 4
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -61,6 +61,7 @@ type UseKnowledgeAssistant = () => {
   processMessage: ProcessMessage
   processMessage: ProcessMessage
   form: UseFormReturn<FormData>
   form: UseFormReturn<FormData>
   resetForm: () => void
   resetForm: () => void
+  threadTitleView: JSX.Element
 
 
   // Views
   // Views
   initialView: JSX.Element
   initialView: JSX.Element
@@ -72,8 +73,8 @@ type UseKnowledgeAssistant = () => {
 
 
 export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
 export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   // Hooks
   // Hooks
-  const { data: aiAssistantSidebarData } = useAiAssistantSidebar();
-  const { aiAssistantData, threadData } = aiAssistantSidebarData ?? {};
+  const { data: aiAssistantSidebarData, refreshThreadData } = useAiAssistantSidebar();
+  const { aiAssistantData } = aiAssistantSidebarData ?? {};
   const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { mutate: mutateRecentThreads } = useSWRINFxRecentThreads();
   const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
   const { trigger: mutateThreadData } = useSWRMUTxThreads(aiAssistantData?._id);
   const { t } = useTranslation();
   const { t } = useTranslation();
@@ -85,6 +86,9 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
       extendedThinkingMode: false,
       extendedThinkingMode: false,
     },
     },
   });
   });
+  const handleBackToInitialView = useCallback(() => {
+    refreshThreadData(undefined);
+  }, [refreshThreadData]);
 
 
   // Functions
   // Functions
   const resetForm = useCallback(() => {
   const resetForm = useCallback(() => {
@@ -141,8 +145,8 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
   }, []);
   }, []);
 
 
   const headerText = useMemo(() => {
   const headerText = useMemo(() => {
-    return <>{threadData?.title ?? aiAssistantData?.name}</>;
-  }, [aiAssistantData?.name, threadData?.title]);
+    return <>{aiAssistantData?.name}</>;
+  }, [aiAssistantData?.name]);
 
 
   const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
   const placeHolder = useMemo(() => { return 'sidebar_ai_assistant.knowledge_assistant_placeholder' }, []);
 
 
@@ -231,12 +235,36 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     );
     );
   }, [dropdownOpen, toggleDropdown, form, t]);
   }, [dropdownOpen, toggleDropdown, form, t]);
 
 
+  const threadTitleView = useMemo(() => {
+    const { threadData } = aiAssistantSidebarData ?? {};
+
+    if (threadData?.title == null) {
+      return <></>;
+    }
+
+    return (
+      <div className="thread-title-sticky sticky-top bg-body bg-opacity-75 py-2 px-3 z-1 ">
+        <div className="d-flex align-items-center gap-2">
+          <button
+            type="button"
+            className="btn btn-sm btn-link p-0 text-secondary"
+            onClick={handleBackToInitialView}
+          >
+            <span className="material-symbols-outlined">chevron_left</span>
+          </button>
+          <span className="text-truncate small">{threadData.title}</span>
+        </div>
+      </div>
+    );
+  }, [aiAssistantSidebarData, handleBackToInitialView]);
+
   return {
   return {
     createThread,
     createThread,
     postMessage,
     postMessage,
     processMessage,
     processMessage,
     form,
     form,
     resetForm,
     resetForm,
+    threadTitleView,
 
 
     // Views
     // Views
     initialView,
     initialView,

+ 3 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/index.ts

@@ -1,11 +1,14 @@
 export { addApplicationMetrics } from './application-metrics';
 export { addApplicationMetrics } from './application-metrics';
+export { addPageCountsMetrics } from './page-counts-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 export { addUserCountsMetrics } from './user-counts-metrics';
 
 
 export const setupCustomMetrics = async (): Promise<void> => {
 export const setupCustomMetrics = async (): Promise<void> => {
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addApplicationMetrics } = await import('./application-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
   const { addUserCountsMetrics } = await import('./user-counts-metrics');
+  const { addPageCountsMetrics } = await import('./page-counts-metrics');
 
 
   // Add custom metrics
   // Add custom metrics
   addApplicationMetrics();
   addApplicationMetrics();
   addUserCountsMetrics();
   addUserCountsMetrics();
+  addPageCountsMetrics();
 };
 };

+ 147 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.spec.ts

@@ -0,0 +1,147 @@
+import { type Meter, metrics, type ObservableGauge } from '@opentelemetry/api';
+import { mock } from 'vitest-mock-extended';
+
+import { addPageCountsMetrics } from './page-counts-metrics';
+
+// Mock external dependencies
+vi.mock('~/utils/logger', () => ({
+  default: () => ({
+    info: vi.fn(),
+  }),
+}));
+
+vi.mock('@opentelemetry/api', () => ({
+  diag: {
+    createComponentLogger: () => ({
+      error: vi.fn(),
+    }),
+  },
+  metrics: {
+    getMeter: vi.fn(),
+  },
+}));
+
+// Mock growi-info service
+const mockGrowiInfoService = {
+  getGrowiInfo: vi.fn(),
+};
+vi.mock('~/server/service/growi-info', async () => ({
+  growiInfoService: mockGrowiInfoService,
+}));
+
+describe('addPageCountsMetrics', () => {
+  const mockMeter = mock<Meter>();
+  const mockPageCountGauge = mock<ObservableGauge>();
+
+  beforeEach(() => {
+    vi.clearAllMocks();
+    vi.mocked(metrics.getMeter).mockReturnValue(mockMeter);
+    mockMeter.createObservableGauge.mockReturnValueOnce(mockPageCountGauge);
+  });
+
+  afterEach(() => {
+    vi.restoreAllMocks();
+  });
+
+  it('should create observable gauges and set up metrics collection', () => {
+    addPageCountsMetrics();
+
+    expect(metrics.getMeter).toHaveBeenCalledWith(
+      'growi-page-counts-metrics',
+      '1.0.0',
+    );
+    expect(mockMeter.createObservableGauge).toHaveBeenCalledWith(
+      'growi.pages.total',
+      {
+        description: 'Total number of pages in GROWI',
+        unit: 'pages',
+      },
+    );
+    expect(mockMeter.addBatchObservableCallback).toHaveBeenCalledWith(
+      expect.any(Function),
+      [mockPageCountGauge],
+    );
+  });
+
+  describe('metrics callback behavior', () => {
+    const mockGrowiInfo = {
+      additionalInfo: {
+        currentPagesCount: 1234,
+      },
+    };
+
+    beforeEach(() => {
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(mockGrowiInfo);
+    });
+
+    it('should observe page count metrics when growi info is available', async () => {
+      const mockResult = { observe: vi.fn() };
+
+      addPageCountsMetrics();
+
+      // Get the callback function that was passed to addBatchObservableCallback
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockGrowiInfoService.getGrowiInfo).toHaveBeenCalledWith({
+        includePageCountInfo: true,
+      });
+      expect(mockResult.observe).toHaveBeenCalledWith(mockPageCountGauge, 1234);
+    });
+
+    it('should use default values when page counts are missing', async () => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutCounts = {
+        additionalInfo: {
+          // Missing currentPagesCount
+        },
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutCounts,
+      );
+
+      addPageCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockPageCountGauge, 0);
+    });
+
+    it('should handle missing additionalInfo gracefully', async () => {
+      const mockResult = { observe: vi.fn() };
+
+      const growiInfoWithoutAdditionalInfo = {
+        // Missing additionalInfo entirely
+      };
+      mockGrowiInfoService.getGrowiInfo.mockResolvedValue(
+        growiInfoWithoutAdditionalInfo,
+      );
+
+      addPageCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+      await callback(mockResult);
+
+      expect(mockResult.observe).toHaveBeenCalledWith(mockPageCountGauge, 0);
+    });
+
+    it('should handle errors in metrics collection gracefully', async () => {
+      mockGrowiInfoService.getGrowiInfo.mockRejectedValue(
+        new Error('Service unavailable'),
+      );
+      const mockResult = { observe: vi.fn() };
+
+      addPageCountsMetrics();
+
+      const callback = mockMeter.addBatchObservableCallback.mock.calls[0][0];
+
+      // Should not throw error
+      await expect(callback(mockResult)).resolves.toBeUndefined();
+
+      // Should not call observe when error occurs
+      expect(mockResult.observe).not.toHaveBeenCalled();
+    });
+  });
+});

+ 42 - 0
apps/app/src/features/opentelemetry/server/custom-metrics/page-counts-metrics.ts

@@ -0,0 +1,42 @@
+import { diag, metrics } from '@opentelemetry/api';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:opentelemetry:custom-metrics:page-counts');
+const loggerDiag = diag.createComponentLogger({
+  namespace: 'growi:custom-metrics:page-counts',
+});
+
+export function addPageCountsMetrics(): void {
+  logger.info('Starting page counts metrics collection');
+
+  const meter = metrics.getMeter('growi-page-counts-metrics', '1.0.0');
+
+  const pageCountGauge = meter.createObservableGauge('growi.pages.total', {
+    description: 'Total number of pages in GROWI',
+    unit: 'pages',
+  });
+
+  meter.addBatchObservableCallback(
+    async (result) => {
+      try {
+        const { growiInfoService } = await import(
+          '~/server/service/growi-info'
+        );
+
+        const growiInfo = await growiInfoService.getGrowiInfo({
+          includePageCountInfo: true,
+        });
+
+        result.observe(
+          pageCountGauge,
+          growiInfo.additionalInfo?.currentPagesCount || 0,
+        );
+      } catch (error) {
+        loggerDiag.error('Failed to collect page counts metrics', { error });
+      }
+    },
+    [pageCountGauge],
+  );
+  logger.info('Page counts metrics collection started successfully');
+}

+ 1 - 0
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.integ.ts

@@ -31,6 +31,7 @@ vi.mock('./page-bulk-export-job-cron', () => {
   return {
   return {
     pageBulkExportJobCronService: {
     pageBulkExportJobCronService: {
       cleanUpExportJobResources: vi.fn(() => Promise.resolve()),
       cleanUpExportJobResources: vi.fn(() => Promise.resolve()),
+      notifyExportResultAndCleanUp: vi.fn(() => Promise.resolve()),
     },
     },
   };
   };
 });
 });

+ 10 - 7
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-clean-up-cron.ts

@@ -1,5 +1,5 @@
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
-
+import { SupportedAction } from '~/interfaces/activity';
 import type Crowi from '~/server/crowi';
 import type Crowi from '~/server/crowi';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import CronService from '~/server/service/cron';
 import CronService from '~/server/service/cron';
@@ -57,13 +57,16 @@ class PageBulkExportJobCleanUpCronService extends CronService {
       },
       },
     });
     });
 
 
-    if (pageBulkExportJobCronService != null) {
-      await this.cleanUpAndDeleteBulkExportJobs(
-        expiredExportJobs,
-        pageBulkExportJobCronService.cleanUpExportJobResources.bind(
-          pageBulkExportJobCronService,
-        ),
+    const cleanUp = async (job: PageBulkExportJobDocument) => {
+      await pageBulkExportJobCronService?.notifyExportResultAndCleanUp(
+        SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
+        job,
       );
       );
+      logger.error(`Bulk export job has expired: ${job._id.toString()}`);
+    };
+
+    if (pageBulkExportJobCronService != null) {
+      await this.cleanUpAndDeleteBulkExportJobs(expiredExportJobs, cleanUp);
     }
     }
   }
   }
 
 

+ 6 - 4
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/errors.ts

@@ -1,11 +1,13 @@
+import type { PageBulkExportJobDocument } from '../../models/page-bulk-export-job';
+
 export class BulkExportJobExpiredError extends Error {
 export class BulkExportJobExpiredError extends Error {
-  constructor() {
-    super('Bulk export job has expired');
+  constructor(pageBulkExportJob: PageBulkExportJobDocument) {
+    super(`Bulk export job has expired: ${pageBulkExportJob._id.toString()}`);
   }
   }
 }
 }
 
 
-export class BulkExportJobRestartedError extends Error {
+export class BulkExportJobStreamDestroyedByCleanupError extends Error {
   constructor() {
   constructor() {
-    super('Bulk export job has restarted');
+    super('Bulk export job stream was destroyed by cleanup');
   }
   }
 }
 }

+ 38 - 24
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts

@@ -1,6 +1,6 @@
 import fs from 'node:fs';
 import fs from 'node:fs';
 import path from 'node:path';
 import path from 'node:path';
-import type { Readable } from 'node:stream';
+import type { Readable, Writable } from 'node:stream';
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import { getIdForRef, isPopulated } from '@growi/core';
 import { getIdForRef, isPopulated } from '@growi/core';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
@@ -26,7 +26,7 @@ import PageBulkExportPageSnapshot from '../../models/page-bulk-export-page-snaps
 
 
 import {
 import {
   BulkExportJobExpiredError,
   BulkExportJobExpiredError,
-  BulkExportJobRestartedError,
+  BulkExportJobStreamDestroyedByCleanupError,
 } from './errors';
 } from './errors';
 import { requestPdfConverter } from './request-pdf-converter';
 import { requestPdfConverter } from './request-pdf-converter';
 import { compressAndUpload } from './steps/compress-and-upload';
 import { compressAndUpload } from './steps/compress-and-upload';
@@ -40,7 +40,10 @@ export interface IPageBulkExportJobCronService {
   pageBatchSize: number;
   pageBatchSize: number;
   maxPartSize: number;
   maxPartSize: number;
   compressExtension: string;
   compressExtension: string;
-  setStreamInExecution(jobId: ObjectIdLike, stream: Readable): void;
+  setStreamsInExecution(
+    jobId: ObjectIdLike,
+    ...streams: (Readable | Writable)[]
+  ): void;
   removeStreamInExecution(jobId: ObjectIdLike): void;
   removeStreamInExecution(jobId: ObjectIdLike): void;
   handleError(
   handleError(
     err: Error | null,
     err: Error | null,
@@ -78,10 +81,10 @@ class PageBulkExportJobCronService
   // temporal path of local fs to output page files before upload
   // temporal path of local fs to output page files before upload
   tmpOutputRootDir = '/tmp/page-bulk-export';
   tmpOutputRootDir = '/tmp/page-bulk-export';
 
 
-  // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
-  // The key is the id of a PageBulkExportJob.
+  // Keep track of all streams executed for PageBulkExportJob to destroy them on job failure.
+  // The key is the id of a PageBulkExportJob, value is array of streams.
   private streamInExecutionMemo: {
   private streamInExecutionMemo: {
-    [key: string]: Readable;
+    [key: string]: (Readable | Writable)[];
   } = {};
   } = {};
 
 
   private parallelExecLimit: number;
   private parallelExecLimit: number;
@@ -133,22 +136,27 @@ class PageBulkExportJobCronService
   }
   }
 
 
   /**
   /**
-   * Get the stream in execution for a job.
+   * Get all streams in execution for a job.
    * A getter method that includes "undefined" in the return type
    * A getter method that includes "undefined" in the return type
    */
    */
-  getStreamInExecution(jobId: ObjectIdLike): Readable | undefined {
+  getStreamsInExecution(
+    jobId: ObjectIdLike,
+  ): (Readable | Writable)[] | undefined {
     return this.streamInExecutionMemo[jobId.toString()];
     return this.streamInExecutionMemo[jobId.toString()];
   }
   }
 
 
   /**
   /**
-   * Set the stream in execution for a job
+   * Set streams in execution for a job
    */
    */
-  setStreamInExecution(jobId: ObjectIdLike, stream: Readable) {
-    this.streamInExecutionMemo[jobId.toString()] = stream;
+  setStreamsInExecution(
+    jobId: ObjectIdLike,
+    ...streams: (Readable | Writable)[]
+  ) {
+    this.streamInExecutionMemo[jobId.toString()] = streams;
   }
   }
 
 
   /**
   /**
-   * Remove the stream in execution for a job
+   * Remove all streams in execution for a job
    */
    */
   removeStreamInExecution(jobId: ObjectIdLike) {
   removeStreamInExecution(jobId: ObjectIdLike) {
     delete this.streamInExecutionMemo[jobId.toString()];
     delete this.streamInExecutionMemo[jobId.toString()];
@@ -161,7 +169,7 @@ class PageBulkExportJobCronService
   async proceedBulkExportJob(pageBulkExportJob: PageBulkExportJobDocument) {
   async proceedBulkExportJob(pageBulkExportJob: PageBulkExportJobDocument) {
     try {
     try {
       if (pageBulkExportJob.restartFlag) {
       if (pageBulkExportJob.restartFlag) {
-        await this.cleanUpExportJobResources(pageBulkExportJob, true);
+        await this.cleanUpExportJobResources(pageBulkExportJob);
         pageBulkExportJob.restartFlag = false;
         pageBulkExportJob.restartFlag = false;
         pageBulkExportJob.status = PageBulkExportJobStatus.initializing;
         pageBulkExportJob.status = PageBulkExportJobStatus.initializing;
         pageBulkExportJob.statusOnPreviousCronExec = undefined;
         pageBulkExportJob.statusOnPreviousCronExec = undefined;
@@ -226,9 +234,6 @@ class PageBulkExportJobCronService
         SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
         SupportedAction.ACTION_PAGE_BULK_EXPORT_JOB_EXPIRED,
         pageBulkExportJob,
         pageBulkExportJob,
       );
       );
-    } else if (err instanceof BulkExportJobRestartedError) {
-      logger.info(err.message);
-      await this.cleanUpExportJobResources(pageBulkExportJob);
     } else {
     } else {
       logger.error(err);
       logger.error(err);
       await this.notifyExportResultAndCleanUp(
       await this.notifyExportResultAndCleanUp(
@@ -269,15 +274,24 @@ class PageBulkExportJobCronService
    */
    */
   async cleanUpExportJobResources(
   async cleanUpExportJobResources(
     pageBulkExportJob: PageBulkExportJobDocument,
     pageBulkExportJob: PageBulkExportJobDocument,
-    restarted = false,
   ) {
   ) {
-    const streamInExecution = this.getStreamInExecution(pageBulkExportJob._id);
-    if (streamInExecution != null) {
-      if (restarted) {
-        streamInExecution.destroy(new BulkExportJobRestartedError());
-      } else {
-        streamInExecution.destroy(new BulkExportJobExpiredError());
-      }
+    const streamsInExecution = this.getStreamsInExecution(
+      pageBulkExportJob._id,
+    );
+    if (streamsInExecution != null && streamsInExecution.length > 0) {
+      // Wait for all streams to be destroyed before proceeding with cleanup
+      await Promise.allSettled(
+        streamsInExecution.map((stream) => {
+          if (!stream.destroyed) {
+            return new Promise<void>((resolve) => {
+              stream.destroy(new BulkExportJobStreamDestroyedByCleanupError());
+              stream.once('close', () => resolve());
+            });
+          }
+          return Promise.resolve();
+        }),
+      );
+
       this.removeStreamInExecution(pageBulkExportJob._id);
       this.removeStreamInExecution(pageBulkExportJob._id);
     }
     }
 
 

+ 1 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts

@@ -49,7 +49,7 @@ export async function requestPdfConverter(
   }
   }
 
 
   if (new Date() > bulkExportJobExpirationDate) {
   if (new Date() > bulkExportJobExpirationDate) {
-    throw new BulkExportJobExpiredError();
+    throw new BulkExportJobExpiredError(pageBulkExportJob);
   }
   }
 
 
   try {
   try {

+ 1 - 1
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/compress-and-upload.ts

@@ -76,7 +76,7 @@ export async function compressAndUpload(
 
 
   pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
   pageArchiver.directory(this.getTmpOutputDir(pageBulkExportJob), false);
   pageArchiver.finalize();
   pageArchiver.finalize();
-  this.setStreamInExecution(pageBulkExportJob._id, pageArchiver);
+  this.setStreamsInExecution(pageBulkExportJob._id, pageArchiver);
 
 
   try {
   try {
     await fileUploadService.uploadAttachment(pageArchiver, attachment);
     await fileUploadService.uploadAttachment(pageArchiver, attachment);

+ 10 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/create-page-snapshots-async.ts

@@ -11,6 +11,7 @@ import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export
 import PageBulkExportJob from '../../../models/page-bulk-export-job';
 import PageBulkExportJob from '../../../models/page-bulk-export-job';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
 import type { IPageBulkExportJobCronService } from '..';
 import type { IPageBulkExportJobCronService } from '..';
+import { BulkExportJobStreamDestroyedByCleanupError } from '../errors';
 
 
 async function reuseDuplicateExportIfExists(
 async function reuseDuplicateExportIfExists(
   this: IPageBulkExportJobCronService,
   this: IPageBulkExportJobCronService,
@@ -100,9 +101,16 @@ export async function createPageSnapshotsAsync(
     },
     },
   });
   });
 
 
-  this.setStreamInExecution(pageBulkExportJob._id, pagesReadable);
+  this.setStreamsInExecution(
+    pageBulkExportJob._id,
+    pagesReadable,
+    pageSnapshotsWritable,
+  );
 
 
   pipeline(pagesReadable, pageSnapshotsWritable, (err) => {
   pipeline(pagesReadable, pageSnapshotsWritable, (err) => {
-    this.handleError(err, pageBulkExportJob);
+    // prevent overlapping cleanup
+    if (!(err instanceof BulkExportJobStreamDestroyedByCleanupError)) {
+      this.handleError(err, pageBulkExportJob);
+    }
   });
   });
 }
 }

+ 10 - 2
apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/steps/export-pages-to-fs-async.ts

@@ -20,6 +20,7 @@ import type { PageBulkExportJobDocument } from '../../../models/page-bulk-export
 import type { PageBulkExportPageSnapshotDocument } from '../../../models/page-bulk-export-page-snapshot';
 import type { PageBulkExportPageSnapshotDocument } from '../../../models/page-bulk-export-page-snapshot';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
 import PageBulkExportPageSnapshot from '../../../models/page-bulk-export-page-snapshot';
 import type { IPageBulkExportJobCronService } from '..';
 import type { IPageBulkExportJobCronService } from '..';
+import { BulkExportJobStreamDestroyedByCleanupError } from '../errors';
 
 
 async function convertMdToHtml(
 async function convertMdToHtml(
   md: string,
   md: string,
@@ -132,9 +133,16 @@ export async function exportPagesToFsAsync(
 
 
   const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);
   const pagesWritable = await getPageWritable.bind(this)(pageBulkExportJob);
 
 
-  this.setStreamInExecution(pageBulkExportJob._id, pageSnapshotsReadable);
+  this.setStreamsInExecution(
+    pageBulkExportJob._id,
+    pageSnapshotsReadable,
+    pagesWritable,
+  );
 
 
   pipeline(pageSnapshotsReadable, pagesWritable, (err) => {
   pipeline(pageSnapshotsReadable, pagesWritable, (err) => {
-    this.handleError(err, pageBulkExportJob);
+    // prevent overlapping cleanup
+    if (!(err instanceof BulkExportJobStreamDestroyedByCleanupError)) {
+      this.handleError(err, pageBulkExportJob);
+    }
   });
   });
 }
 }

+ 14 - 12
apps/app/src/features/rate-limiter/config/index.ts

@@ -1,11 +1,11 @@
 export type IApiRateLimitConfig = {
 export type IApiRateLimitConfig = {
-  method: string,
-  maxRequests: number,
-  usersPerIpProspection?: number,
-}
+  method: string;
+  maxRequests: number;
+  usersPerIpProspection?: number;
+};
 export type IApiRateLimitEndpointMap = {
 export type IApiRateLimitEndpointMap = {
-  [endpoint: string]: IApiRateLimitConfig
-}
+  [endpoint: string]: IApiRateLimitConfig;
+};
 
 
 export const DEFAULT_MAX_REQUESTS = 500;
 export const DEFAULT_MAX_REQUESTS = 500;
 export const DEFAULT_DURATION_SEC = 60;
 export const DEFAULT_DURATION_SEC = 60;
@@ -59,12 +59,14 @@ export const defaultConfig: IApiRateLimitEndpointMap = {
 };
 };
 
 
 const isDev = process.env.NODE_ENV === 'development';
 const isDev = process.env.NODE_ENV === 'development';
-const defaultConfigWithRegExpForDev: IApiRateLimitEndpointMap = isDev ? {
-  '/__nextjs_original-stack-frame': {
-    method: 'GET',
-    maxRequests: Infinity,
-  },
-} : {};
+const defaultConfigWithRegExpForDev: IApiRateLimitEndpointMap = isDev
+  ? {
+      '/__nextjs_original-stack-frame': {
+        method: 'GET',
+        maxRequests: Infinity,
+      },
+    }
+  : {};
 
 
 // default config with reg exp
 // default config with reg exp
 export const defaultConfigWithRegExp: IApiRateLimitEndpointMap = {
 export const defaultConfigWithRegExp: IApiRateLimitEndpointMap = {

+ 10 - 8
apps/app/src/features/rate-limiter/middleware/consume-points.integ.ts

@@ -1,6 +1,10 @@
 import { faker } from '@faker-js/faker';
 import { faker } from '@faker-js/faker';
 
 
-const testRateLimitErrorWhenExceedingMaxRequests = async(method: string, key: string, maxRequests: number): Promise<void> => {
+const testRateLimitErrorWhenExceedingMaxRequests = async (
+  method: string,
+  key: string,
+  maxRequests: number,
+): Promise<void> => {
   // dynamic import is used because rateLimiterMongo needs to be initialized after connecting to DB
   // dynamic import is used because rateLimiterMongo needs to be initialized after connecting to DB
   // Issue: https://github.com/animir/node-rate-limiter-flexible/issues/216
   // Issue: https://github.com/animir/node-rate-limiter-flexible/issues/216
   const { consumePoints } = await import('./consume-points');
   const { consumePoints } = await import('./consume-points');
@@ -20,8 +24,7 @@ const testRateLimitErrorWhenExceedingMaxRequests = async(method: string, key: st
         throw new Error('Exception occurred');
         throw new Error('Exception occurred');
       }
       }
     }
     }
-  }
-  catch (err) {
+  } catch (err) {
     // Expect rate limit error to be called
     // Expect rate limit error to be called
     expect(err.message).not.toBe('Exception occurred');
     expect(err.message).not.toBe('Exception occurred');
     // Expect rate limit error at maxRequest + 1
     // Expect rate limit error at maxRequest + 1
@@ -29,9 +32,8 @@ const testRateLimitErrorWhenExceedingMaxRequests = async(method: string, key: st
   }
   }
 };
 };
 
 
-
-describe('consume-points.ts', async() => {
-  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 1)', async() => {
+describe('consume-points.ts', async () => {
+  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 1)', async () => {
     // setup
     // setup
     const method = 'GET';
     const method = 'GET';
     const key = 'test-key-1';
     const key = 'test-key-1';
@@ -40,7 +42,7 @@ describe('consume-points.ts', async() => {
     await testRateLimitErrorWhenExceedingMaxRequests(method, key, maxRequests);
     await testRateLimitErrorWhenExceedingMaxRequests(method, key, maxRequests);
   });
   });
 
 
-  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 500)', async() => {
+  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: 500)', async () => {
     // setup
     // setup
     const method = 'GET';
     const method = 'GET';
     const key = 'test-key-2';
     const key = 'test-key-2';
@@ -49,7 +51,7 @@ describe('consume-points.ts', async() => {
     await testRateLimitErrorWhenExceedingMaxRequests(method, key, maxRequests);
     await testRateLimitErrorWhenExceedingMaxRequests(method, key, maxRequests);
   });
   });
 
 
-  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: {random integer between 1 and 1000})', async() => {
+  it('Should trigger a rate limit error when maxRequest is exceeded (maxRequest: {random integer between 1 and 1000})', async () => {
     // setup
     // setup
     const method = 'GET';
     const method = 'GET';
     const key = 'test-key-3';
     const key = 'test-key-3';

+ 15 - 5
apps/app/src/features/rate-limiter/middleware/consume-points.ts

@@ -1,11 +1,14 @@
-import { type RateLimiterRes } from 'rate-limiter-flexible';
+import type { RateLimiterRes } from 'rate-limiter-flexible';
 
 
 import { DEFAULT_MAX_REQUESTS, type IApiRateLimitConfig } from '../config';
 import { DEFAULT_MAX_REQUESTS, type IApiRateLimitConfig } from '../config';
 
 
 import { rateLimiterFactory } from './rate-limiter-factory';
 import { rateLimiterFactory } from './rate-limiter-factory';
 
 
-export const consumePoints = async(
-    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig, maxRequestsMultiplier?: number,
+export const consumePoints = async (
+  method: string,
+  key: string | null,
+  customizedConfig?: IApiRateLimitConfig,
+  maxRequestsMultiplier?: number,
 ): Promise<RateLimiterRes | undefined> => {
 ): Promise<RateLimiterRes | undefined> => {
   if (key == null) {
   if (key == null) {
     return;
     return;
@@ -14,7 +17,11 @@ export const consumePoints = async(
   let maxRequests = DEFAULT_MAX_REQUESTS;
   let maxRequests = DEFAULT_MAX_REQUESTS;
 
 
   // use customizedConfig
   // use customizedConfig
-  if (customizedConfig != null && (customizedConfig.method.includes(method) || customizedConfig.method === 'ALL')) {
+  if (
+    customizedConfig != null &&
+    (customizedConfig.method.includes(method) ||
+      customizedConfig.method === 'ALL')
+  ) {
     maxRequests = customizedConfig.maxRequests;
     maxRequests = customizedConfig.maxRequests;
   }
   }
 
 
@@ -23,7 +30,10 @@ export const consumePoints = async(
     maxRequests *= maxRequestsMultiplier;
     maxRequests *= maxRequestsMultiplier;
   }
   }
 
 
-  const rateLimiter = rateLimiterFactory.getOrCreateRateLimiter(key, maxRequests);
+  const rateLimiter = rateLimiterFactory.getOrCreateRateLimiter(
+    key,
+    maxRequests,
+  );
 
 
   const pointsToConsume = 1;
   const pointsToConsume = 1;
   const rateLimiterRes = await rateLimiter.consume(key, pointsToConsume);
   const rateLimiterRes = await rateLimiter.consume(key, pointsToConsume);

+ 26 - 22
apps/app/src/features/rate-limiter/middleware/factory.ts

@@ -1,11 +1,14 @@
 import type { IUserHasId } from '@growi/core';
 import type { IUserHasId } from '@growi/core';
 import type { Handler, Request } from 'express';
 import type { Handler, Request } from 'express';
 import md5 from 'md5';
 import md5 from 'md5';
-import { type RateLimiterRes } from 'rate-limiter-flexible';
+import type { RateLimiterRes } from 'rate-limiter-flexible';
 
 
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
-import { DEFAULT_USERS_PER_IP_PROSPECTION, type IApiRateLimitConfig } from '../config';
+import {
+  DEFAULT_USERS_PER_IP_PROSPECTION,
+  type IApiRateLimitConfig,
+} from '../config';
 import { generateApiRateLimitConfig } from '../utils/config-generator';
 import { generateApiRateLimitConfig } from '../utils/config-generator';
 
 
 import { consumePoints } from './consume-points';
 import { consumePoints } from './consume-points';
@@ -22,10 +25,11 @@ const apiRateLimitConfig = generateApiRateLimitConfig();
 const configWithoutRegExp = apiRateLimitConfig.withoutRegExp;
 const configWithoutRegExp = apiRateLimitConfig.withoutRegExp;
 const configWithRegExp = apiRateLimitConfig.withRegExp;
 const configWithRegExp = apiRateLimitConfig.withRegExp;
 const allRegExp = new RegExp(Object.keys(configWithRegExp).join('|'));
 const allRegExp = new RegExp(Object.keys(configWithRegExp).join('|'));
-const keysWithRegExp = Object.keys(configWithRegExp).map(key => new RegExp(`^${key}`));
+const keysWithRegExp = Object.keys(configWithRegExp).map(
+  (key) => new RegExp(`^${key}`),
+);
 const valuesWithRegExp = Object.values(configWithRegExp);
 const valuesWithRegExp = Object.values(configWithRegExp);
 
 
-
 /**
 /**
  * consume per user per endpoint
  * consume per user per endpoint
  * @param method
  * @param method
@@ -33,8 +37,10 @@ const valuesWithRegExp = Object.values(configWithRegExp);
  * @param customizedConfig
  * @param customizedConfig
  * @returns
  * @returns
  */
  */
-const consumePointsByUser = async(
-    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig,
+const consumePointsByUser = async (
+  method: string,
+  key: string | null,
+  customizedConfig?: IApiRateLimitConfig,
 ): Promise<RateLimiterRes | undefined> => {
 ): Promise<RateLimiterRes | undefined> => {
   return consumePoints(method, key, customizedConfig);
   return consumePoints(method, key, customizedConfig);
 };
 };
@@ -46,24 +52,25 @@ const consumePointsByUser = async(
  * @param customizedConfig
  * @param customizedConfig
  * @returns
  * @returns
  */
  */
-const consumePointsByIp = async(
-    method: string, key: string | null, customizedConfig?: IApiRateLimitConfig,
+const consumePointsByIp = async (
+  method: string,
+  key: string | null,
+  customizedConfig?: IApiRateLimitConfig,
 ): Promise<RateLimiterRes | undefined> => {
 ): Promise<RateLimiterRes | undefined> => {
-  const maxRequestsMultiplier = customizedConfig?.usersPerIpProspection ?? DEFAULT_USERS_PER_IP_PROSPECTION;
+  const maxRequestsMultiplier =
+    customizedConfig?.usersPerIpProspection ?? DEFAULT_USERS_PER_IP_PROSPECTION;
   return consumePoints(method, key, customizedConfig, maxRequestsMultiplier);
   return consumePoints(method, key, customizedConfig, maxRequestsMultiplier);
 };
 };
 
 
-
 export const middlewareFactory = (): Handler => {
 export const middlewareFactory = (): Handler => {
-
-  return async(req: Request & { user?: IUserHasId }, res, next) => {
-
+  return async (req: Request & { user?: IUserHasId }, res, next) => {
     const endpoint = req.path;
     const endpoint = req.path;
 
 
     // determine keys
     // determine keys
-    const keyForUser: string | null = req.user != null
-      ? md5(`${req.user._id}_${endpoint}_${req.method}`)
-      : null;
+    const keyForUser: string | null =
+      req.user != null
+        ? md5(`${req.user._id}_${endpoint}_${req.method}`)
+        : null;
     const keyForIp: string = md5(`${req.ip}_${endpoint}_${req.method}`);
     const keyForIp: string = md5(`${req.ip}_${endpoint}_${req.method}`);
 
 
     // determine customized config
     // determine customized config
@@ -71,8 +78,7 @@ export const middlewareFactory = (): Handler => {
     const configForEndpoint = configWithoutRegExp[endpoint];
     const configForEndpoint = configWithoutRegExp[endpoint];
     if (configForEndpoint) {
     if (configForEndpoint) {
       customizedConfig = configForEndpoint;
       customizedConfig = configForEndpoint;
-    }
-    else if (allRegExp.test(endpoint)) {
+    } else if (allRegExp.test(endpoint)) {
       keysWithRegExp.forEach((key, index) => {
       keysWithRegExp.forEach((key, index) => {
         if (key.test(endpoint)) {
         if (key.test(endpoint)) {
           customizedConfig = valuesWithRegExp[index];
           customizedConfig = valuesWithRegExp[index];
@@ -84,8 +90,7 @@ export const middlewareFactory = (): Handler => {
     if (req.user != null) {
     if (req.user != null) {
       try {
       try {
         await consumePointsByUser(req.method, keyForUser, customizedConfig);
         await consumePointsByUser(req.method, keyForUser, customizedConfig);
-      }
-      catch {
+      } catch {
         logger.error(`${req.user._id}: too many request at ${endpoint}`);
         logger.error(`${req.user._id}: too many request at ${endpoint}`);
         return res.sendStatus(429);
         return res.sendStatus(429);
       }
       }
@@ -94,8 +99,7 @@ export const middlewareFactory = (): Handler => {
     // check for ip
     // check for ip
     try {
     try {
       await consumePointsByIp(req.method, keyForIp, customizedConfig);
       await consumePointsByIp(req.method, keyForIp, customizedConfig);
-    }
-    catch {
+    } catch {
       logger.error(`${req.ip}: too many request at ${endpoint}`);
       logger.error(`${req.ip}: too many request at ${endpoint}`);
       return res.sendStatus(429);
       return res.sendStatus(429);
     }
     }

+ 4 - 3
apps/app/src/features/rate-limiter/middleware/rate-limiter-factory.ts

@@ -1,10 +1,12 @@
 import { connection } from 'mongoose';
 import { connection } from 'mongoose';
-import { type IRateLimiterMongoOptions, RateLimiterMongo } from 'rate-limiter-flexible';
+import {
+  type IRateLimiterMongoOptions,
+  RateLimiterMongo,
+} from 'rate-limiter-flexible';
 
 
 import { DEFAULT_DURATION_SEC } from '../config';
 import { DEFAULT_DURATION_SEC } from '../config';
 
 
 class RateLimiterFactory {
 class RateLimiterFactory {
-
   private rateLimiters: Map<string, RateLimiterMongo> = new Map();
   private rateLimiters: Map<string, RateLimiterMongo> = new Map();
 
 
   getOrCreateRateLimiter(key: string, maxRequests: number): RateLimiterMongo {
   getOrCreateRateLimiter(key: string, maxRequests: number): RateLimiterMongo {
@@ -24,7 +26,6 @@ class RateLimiterFactory {
 
 
     return rateLimiter;
     return rateLimiter;
   }
   }
-
 }
 }
 
 
 export const rateLimiterFactory = new RateLimiterFactory();
 export const rateLimiterFactory = new RateLimiterFactory();

+ 30 - 17
apps/app/src/features/rate-limiter/utils/config-generator.ts

@@ -1,18 +1,21 @@
 import type { IApiRateLimitEndpointMap } from '../config';
 import type { IApiRateLimitEndpointMap } from '../config';
-import {
-  defaultConfig, defaultConfigWithRegExp,
-} from '../config';
+import { defaultConfig, defaultConfigWithRegExp } from '../config';
 
 
 const envVar = process.env;
 const envVar = process.env;
 
 
 // https://regex101.com/r/aNDjmI/1
 // https://regex101.com/r/aNDjmI/1
 const regExp = /^API_RATE_LIMIT_(\w+)_ENDPOINT(_WITH_REGEXP)?$/;
 const regExp = /^API_RATE_LIMIT_(\w+)_ENDPOINT(_WITH_REGEXP)?$/;
 
 
-const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targets: string[], withRegExp: boolean): IApiRateLimitEndpointMap => {
+const generateApiRateLimitConfigFromEndpoint = (
+  envVar: NodeJS.ProcessEnv,
+  targets: string[],
+  withRegExp: boolean,
+): IApiRateLimitEndpointMap => {
   const apiRateLimitConfig: IApiRateLimitEndpointMap = {};
   const apiRateLimitConfig: IApiRateLimitEndpointMap = {};
   targets.forEach((target) => {
   targets.forEach((target) => {
-
-    const endpointKey = withRegExp ? `API_RATE_LIMIT_${target}_ENDPOINT_WITH_REGEXP` : `API_RATE_LIMIT_${target}_ENDPOINT`;
+    const endpointKey = withRegExp
+      ? `API_RATE_LIMIT_${target}_ENDPOINT_WITH_REGEXP`
+      : `API_RATE_LIMIT_${target}_ENDPOINT`;
 
 
     const endpoint = envVar[endpointKey];
     const endpoint = envVar[endpointKey];
 
 
@@ -43,26 +46,26 @@ const generateApiRateLimitConfigFromEndpoint = (envVar: NodeJS.ProcessEnv, targe
 };
 };
 
 
 type ApiRateLimitConfigResult = {
 type ApiRateLimitConfigResult = {
-  'withoutRegExp': IApiRateLimitEndpointMap,
-  'withRegExp': IApiRateLimitEndpointMap
-}
+  withoutRegExp: IApiRateLimitEndpointMap;
+  withRegExp: IApiRateLimitEndpointMap;
+};
 
 
 export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
 export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
-
   const apiRateConfigTargets: string[] = [];
   const apiRateConfigTargets: string[] = [];
   const apiRateConfigTargetsWithRegExp: string[] = [];
   const apiRateConfigTargetsWithRegExp: string[] = [];
   Object.keys(envVar).forEach((key) => {
   Object.keys(envVar).forEach((key) => {
     const result = key.match(regExp);
     const result = key.match(regExp);
 
 
-    if (result == null) { return null }
+    if (result == null) {
+      return null;
+    }
 
 
     const target = result[1];
     const target = result[1];
     const isWithRegExp = result[2] != null;
     const isWithRegExp = result[2] != null;
 
 
     if (isWithRegExp) {
     if (isWithRegExp) {
       apiRateConfigTargetsWithRegExp.push(target);
       apiRateConfigTargetsWithRegExp.push(target);
-    }
-    else {
+    } else {
       apiRateConfigTargets.push(target);
       apiRateConfigTargets.push(target);
     }
     }
   });
   });
@@ -72,17 +75,27 @@ export const generateApiRateLimitConfig = (): ApiRateLimitConfigResult => {
   apiRateConfigTargetsWithRegExp.sort();
   apiRateConfigTargetsWithRegExp.sort();
 
 
   // get config
   // get config
-  const apiRateLimitConfig = generateApiRateLimitConfigFromEndpoint(envVar, apiRateConfigTargets, false);
-  const apiRateLimitConfigWithRegExp = generateApiRateLimitConfigFromEndpoint(envVar, apiRateConfigTargetsWithRegExp, true);
+  const apiRateLimitConfig = generateApiRateLimitConfigFromEndpoint(
+    envVar,
+    apiRateConfigTargets,
+    false,
+  );
+  const apiRateLimitConfigWithRegExp = generateApiRateLimitConfigFromEndpoint(
+    envVar,
+    apiRateConfigTargetsWithRegExp,
+    true,
+  );
 
 
   const config = { ...defaultConfig, ...apiRateLimitConfig };
   const config = { ...defaultConfig, ...apiRateLimitConfig };
-  const configWithRegExp = { ...defaultConfigWithRegExp, ...apiRateLimitConfigWithRegExp };
+  const configWithRegExp = {
+    ...defaultConfigWithRegExp,
+    ...apiRateLimitConfigWithRegExp,
+  };
 
 
   const result: ApiRateLimitConfigResult = {
   const result: ApiRateLimitConfigResult = {
     withoutRegExp: config,
     withoutRegExp: config,
     withRegExp: configWithRegExp,
     withRegExp: configWithRegExp,
   };
   };
 
 
-
   return result;
   return result;
 };
 };

+ 3 - 11
apps/app/src/interfaces/page-listing-results.ts

@@ -1,19 +1,11 @@
-import type { IPageHasId } from '@growi/core';
-
-import type { IPageForItem } from './page';
-
-type ParentPath = string;
+import type { IPageForTreeItem } from './page';
 
 
 export interface RootPageResult {
 export interface RootPageResult {
-  rootPage: IPageHasId;
-}
-
-export interface AncestorsChildrenResult {
-  ancestorsChildren: Record<ParentPath, Partial<IPageForItem>[]>;
+  rootPage: IPageForTreeItem;
 }
 }
 
 
 export interface ChildrenResult {
 export interface ChildrenResult {
-  children: Partial<IPageForItem>[];
+  children: IPageForTreeItem[];
 }
 }
 
 
 export interface V5MigrationStatus {
 export interface V5MigrationStatus {

+ 14 - 0
apps/app/src/interfaces/page.ts

@@ -21,6 +21,20 @@ export type IPageForItem = Partial<
   IPageHasId & { processData?: IPageOperationProcessData }
   IPageHasId & { processData?: IPageOperationProcessData }
 >;
 >;
 
 
+export type IPageForTreeItem = Pick<
+  IPageHasId,
+  | '_id'
+  | 'path'
+  | 'parent'
+  | 'descendantCount'
+  | 'revision'
+  | 'grant'
+  | 'isEmpty'
+  | 'wip'
+> & {
+  processData?: IPageOperationProcessData;
+};
+
 export const UserGroupPageGrantStatus = {
 export const UserGroupPageGrantStatus = {
   isGranted: 'isGranted',
   isGranted: 'isGranted',
   notGranted: 'notGranted',
   notGranted: 'notGranted',

+ 5 - 3
apps/app/src/models/admin/growi-archive-import-option.ts

@@ -1,12 +1,15 @@
 import { ImportMode } from './import-mode';
 import { ImportMode } from './import-mode';
 
 
 export class GrowiArchiveImportOption {
 export class GrowiArchiveImportOption {
-
   collectionName: string;
   collectionName: string;
 
 
   mode: ImportMode;
   mode: ImportMode;
 
 
-  constructor(collectionName: string, mode: ImportMode = ImportMode.insert, initProps = {}) {
+  constructor(
+    collectionName: string,
+    mode: ImportMode = ImportMode.insert,
+    initProps = {},
+  ) {
     this.collectionName = collectionName;
     this.collectionName = collectionName;
     this.mode = mode;
     this.mode = mode;
 
 
@@ -14,5 +17,4 @@ export class GrowiArchiveImportOption {
       this[key] = value;
       this[key] = value;
     });
     });
   }
   }
-
 }
 }

+ 1 - 1
apps/app/src/models/admin/import-mode.ts

@@ -3,4 +3,4 @@ export const ImportMode = {
   upsert: 'upsert',
   upsert: 'upsert',
   flushAndInsert: 'flushAndInsert',
   flushAndInsert: 'flushAndInsert',
 } as const;
 } as const;
-export type ImportMode = typeof ImportMode[keyof typeof ImportMode];
+export type ImportMode = (typeof ImportMode)[keyof typeof ImportMode];

+ 8 - 4
apps/app/src/models/admin/import-option-for-pages.ts

@@ -11,7 +11,6 @@ const DEFAULT_PROPS = {
 };
 };
 
 
 export class ImportOptionForPages extends GrowiArchiveImportOption {
 export class ImportOptionForPages extends GrowiArchiveImportOption {
-
   isOverwriteAuthorWithCurrentUser;
   isOverwriteAuthorWithCurrentUser;
 
 
   makePublicForGrant2;
   makePublicForGrant2;
@@ -22,12 +21,17 @@ export class ImportOptionForPages extends GrowiArchiveImportOption {
 
 
   initPageMetadatas;
   initPageMetadatas;
 
 
-  constructor(collectionName: string, mode: ImportMode = ImportMode.insert, initProps = DEFAULT_PROPS) {
+  constructor(
+    collectionName: string,
+    mode: ImportMode = ImportMode.insert,
+    initProps = DEFAULT_PROPS,
+  ) {
     super(collectionName, mode, initProps);
     super(collectionName, mode, initProps);
   }
   }
-
 }
 }
 
 
-export const isImportOptionForPages = (opt: GrowiArchiveImportOption): opt is ImportOptionForPages => {
+export const isImportOptionForPages = (
+  opt: GrowiArchiveImportOption,
+): opt is ImportOptionForPages => {
   return 'isOverwriteAuthorWithCurrentUser' in opt;
   return 'isOverwriteAuthorWithCurrentUser' in opt;
 };
 };

+ 5 - 3
apps/app/src/models/admin/import-option-for-revisions.ts

@@ -7,9 +7,11 @@ const DEFAULT_PROPS = {
 };
 };
 
 
 export class ImportOptionForRevisions extends GrowiArchiveImportOption {
 export class ImportOptionForRevisions extends GrowiArchiveImportOption {
-
-  constructor(collectionName: string, mode: ImportMode = ImportMode.insert, initProps = DEFAULT_PROPS) {
+  constructor(
+    collectionName: string,
+    mode: ImportMode = ImportMode.insert,
+    initProps = DEFAULT_PROPS,
+  ) {
     super(collectionName, mode, initProps);
     super(collectionName, mode, initProps);
   }
   }
-
 }
 }

+ 0 - 2
apps/app/src/models/cdn-resource.js

@@ -2,13 +2,11 @@
  * Value Object
  * Value Object
  */
  */
 class CdnResource {
 class CdnResource {
-
   constructor(name, url, outDir) {
   constructor(name, url, outDir) {
     this.name = name;
     this.name = name;
     this.url = url;
     this.url = url;
     this.outDir = outDir;
     this.outDir = outDir;
   }
   }
-
 }
 }
 
 
 module.exports = CdnResource;
 module.exports = CdnResource;

+ 0 - 4
apps/app/src/models/linked-page-path.js

@@ -7,9 +7,7 @@ const { isTrashPage } = pagePathUtils;
  * Linked Array Structured PagePath Model
  * Linked Array Structured PagePath Model
  */
  */
 export default class LinkedPagePath {
 export default class LinkedPagePath {
-
   constructor(path) {
   constructor(path) {
-
     const pagePath = new DevidedPagePath(path);
     const pagePath = new DevidedPagePath(path);
 
 
     this.path = path;
     this.path = path;
@@ -18,7 +16,6 @@ export default class LinkedPagePath {
     this.parent = pagePath.isRoot
     this.parent = pagePath.isRoot
       ? null
       ? null
       : new LinkedPagePath(pagePath.former, true);
       : new LinkedPagePath(pagePath.former, true);
-
   }
   }
 
 
   get href() {
   get href() {
@@ -32,5 +29,4 @@ export default class LinkedPagePath {
   get isInTrash() {
   get isInTrash() {
     return isTrashPage(this.path);
     return isTrashPage(this.path);
   }
   }
-
 }
 }

+ 8 - 4
apps/app/src/models/serializers/in-app-notification-snapshot/page-bulk-export-job.ts

@@ -1,17 +1,21 @@
-import { isPopulated } from '@growi/core';
 import type { IPage } from '@growi/core';
 import type { IPage } from '@growi/core';
+import { isPopulated } from '@growi/core';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { IPageBulkExportJob } from '~/features/page-bulk-export/interfaces/page-bulk-export';
 import type { PageModel } from '~/server/models/page';
 import type { PageModel } from '~/server/models/page';
 
 
 export interface IPageBulkExportJobSnapshot {
 export interface IPageBulkExportJobSnapshot {
-  path: string
+  path: string;
 }
 }
 
 
-export const stringifySnapshot = async(exportJob: IPageBulkExportJob): Promise<string | undefined> => {
+export const stringifySnapshot = async (
+  exportJob: IPageBulkExportJob,
+): Promise<string | undefined> => {
   const Page = mongoose.model<IPage, PageModel>('Page');
   const Page = mongoose.model<IPage, PageModel>('Page');
-  const page = isPopulated(exportJob.page) ? exportJob.page : (await Page.findById(exportJob.page));
+  const page = isPopulated(exportJob.page)
+    ? exportJob.page
+    : await Page.findById(exportJob.page);
 
 
   if (page != null) {
   if (page != null) {
     return JSON.stringify({
     return JSON.stringify({

+ 2 - 2
apps/app/src/models/serializers/in-app-notification-snapshot/page.ts

@@ -1,8 +1,8 @@
 import type { IPage, IUser } from '@growi/core';
 import type { IPage, IUser } from '@growi/core';
 
 
 export interface IPageSnapshot {
 export interface IPageSnapshot {
-  path: string
-  creator: IUser
+  path: string;
+  creator: IUser;
 }
 }
 
 
 export const stringifySnapshot = (page: IPage): string => {
 export const stringifySnapshot = (page: IPage): string => {

+ 1 - 1
apps/app/src/models/serializers/in-app-notification-snapshot/user.ts

@@ -1,7 +1,7 @@
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 
 
 export interface IUserSnapshot {
 export interface IUserSnapshot {
-  username: string
+  username: string;
 }
 }
 
 
 export const stringifySnapshot = (user: IUser): string => {
 export const stringifySnapshot = (user: IUser): string => {

+ 0 - 2
apps/app/src/models/vo/external-account-login-error.ts

@@ -1,5 +1,4 @@
 export class ExternalAccountLoginError extends Error {
 export class ExternalAccountLoginError extends Error {
-
   args?: any;
   args?: any;
 
 
   constructor(message = '', args = undefined) {
   constructor(message = '', args = undefined) {
@@ -7,5 +6,4 @@ export class ExternalAccountLoginError extends Error {
     this.message = message;
     this.message = message;
     this.args = args;
     this.args = args;
   }
   }
-
 }
 }

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

@@ -47,7 +47,7 @@ import {
   useIsLocalAccountRegistrationEnabled,
   useIsLocalAccountRegistrationEnabled,
   useIsRomUserAllowedToComment,
   useIsRomUserAllowedToComment,
   useIsPdfBulkExportEnabled,
   useIsPdfBulkExportEnabled,
-  useIsAiEnabled, useLimitLearnablePageCountPerAssistant, useIsUsersHomepageDeletionEnabled,
+  useIsAiEnabled, useLimitLearnablePageCountPerAssistant, useIsUsersHomepageDeletionEnabled, useIsGuestUser,
 } from '~/stores-universal/context';
 } from '~/stores-universal/context';
 import { useEditingMarkdown } from '~/stores/editor';
 import { useEditingMarkdown } from '~/stores/editor';
 import {
 import {
@@ -276,6 +276,7 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
 
 
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { mutate: mutateEditingMarkdown } = useEditingMarkdown();
   const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
   const { data: currentPageId, mutate: mutateCurrentPageId } = useCurrentPageId();
+  const { data: isGuestUser } = useIsGuestUser();
 
 
   const { mutate: mutateIsNotFound } = useIsNotFound();
   const { mutate: mutateIsNotFound } = useIsNotFound();
 
 
@@ -308,16 +309,17 @@ const Page: NextPageWithLayout<Props> = (props: Props) => {
       mutatePageData();
       mutatePageData();
     }
     }
   }, [
   }, [
-    revisionId, currentPageId, mutateCurrentPage,
-    mutateCurrentPageYjsDataFromApi, mutateEditingMarkdown, props.isNotFound, props.skipSSR,
+    revisionId, currentPageId,
+    mutateCurrentPage, mutateEditingMarkdown,
+    props.isNotFound, props.skipSSR,
   ]);
   ]);
 
 
   // Load current yjs data
   // Load current yjs data
   useEffect(() => {
   useEffect(() => {
-    if (currentPageId != null && revisionId != null && !props.isNotFound) {
+    if (!isGuestUser && currentPageId != null && revisionId != null && mutateCurrentPageYjsDataFromApi != null && !props.isNotFound) {
       mutateCurrentPageYjsDataFromApi();
       mutateCurrentPageYjsDataFromApi();
     }
     }
-  }, [currentPageId, mutateCurrentPageYjsDataFromApi, props.isNotFound, revisionId]);
+  }, [isGuestUser, currentPageId, mutateCurrentPageYjsDataFromApi, props.isNotFound, revisionId]);
 
 
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   // sync pathname by Shallow Routing https://nextjs.org/docs/routing/shallow-routing
   useEffect(() => {
   useEffect(() => {

+ 3 - 6
apps/app/src/server/crowi/index.js

@@ -38,7 +38,7 @@ import { InstallerService } from '../service/installer';
 import { normalizeData } from '../service/normalize-data';
 import { normalizeData } from '../service/normalize-data';
 import PageService from '../service/page';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageGrantService from '../service/page-grant';
-import PageOperationService from '../service/page-operation';
+import instanciatePageOperationService from '../service/page-operation';
 import PassportService from '../service/passport';
 import PassportService from '../service/passport';
 import SearchService from '../service/search';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { SlackIntegrationService } from '../service/slack-integration';
@@ -91,7 +91,7 @@ class Crowi {
   /** @type {import('../service/page-grant').default} */
   /** @type {import('../service/page-grant').default} */
   pageGrantService;
   pageGrantService;
 
 
-  /** @type {import('../service/page-operation').default} */
+  /** @type {import('../service/page-operation').IPageOperationService} */
   pageOperationService;
   pageOperationService;
 
 
   /** @type {PassportService} */
   /** @type {PassportService} */
@@ -734,10 +734,7 @@ Crowi.prototype.setupPageService = async function() {
     this.pageService = new PageService(this);
     this.pageService = new PageService(this);
     await this.pageService.createTtlIndex();
     await this.pageService.createTtlIndex();
   }
   }
-  if (this.pageOperationService == null) {
-    this.pageOperationService = new PageOperationService(this);
-    await this.pageOperationService.init();
-  }
+  this.pageOperationService = instanciatePageOperationService(this);
 };
 };
 
 
 Crowi.prototype.setupInAppNotificationService = async function() {
 Crowi.prototype.setupInAppNotificationService = async function() {

+ 34 - 0
apps/app/src/server/models/openapi/page-listing.ts

@@ -0,0 +1,34 @@
+/**
+ * @swagger
+ *
+ *  components:
+ *    schemas:
+ *      PageForTreeItem:
+ *        description: Page
+ *        type: object
+ *        properties:
+ *          _id:
+ *            $ref: '#/components/schemas/ObjectId'
+ *          path:
+ *            $ref: '#/components/schemas/PagePath'
+ *          parent:
+ *            $ref: '#/components/schemas/PagePath'
+ *          grant:
+ *            $ref: '#/components/schemas/PageGrant'
+ *          lastUpdateUser:
+ *            $ref: '#/components/schemas/User'
+ *          descendantCount:
+ *            type: number
+ *          isEmpty:
+ *           type: boolean
+ *          wip:
+ *            type: boolean
+ *          createdAt:
+ *            type: string
+ *            description: date created at
+ *            example: 2010-01-01T00:00:00.000Z
+ *          updatedAt:
+ *            type: string
+ *            description: date updated at
+ *            example: 2010-01-01T00:00:00.000Z
+ */

+ 9 - 80
apps/app/src/server/routes/apiv3/page-listing.ts

@@ -1,5 +1,5 @@
 import type {
 import type {
-  IPageInfoForListing, IPageInfo, IPage, IUserHasId,
+  IPageInfoForListing, IPageInfo, IUserHasId,
 } from '@growi/core';
 } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { getIdForRef, isIPageInfoForEntity } from '@growi/core';
 import { SCOPE } from '@growi/core/dist/interfaces';
 import { SCOPE } from '@growi/core/dist/interfaces';
@@ -10,9 +10,11 @@ import { query, oneOf } from 'express-validator';
 import type { HydratedDocument } from 'mongoose';
 import type { HydratedDocument } from 'mongoose';
 import mongoose from 'mongoose';
 import mongoose from 'mongoose';
 
 
+import type { IPageForTreeItem } from '~/interfaces/page';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import type { IPageGrantService } from '~/server/service/page-grant';
 import type { IPageGrantService } from '~/server/service/page-grant';
+import { pageListingService } from '~/server/service/page-listing';
 import loggerFactory from '~/utils/logger';
 import loggerFactory from '~/utils/logger';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
@@ -87,88 +89,17 @@ const routerFactory = (crowi: Crowi): Router => {
    *               type: object
    *               type: object
    *               properties:
    *               properties:
    *                 rootPage:
    *                 rootPage:
-   *                   $ref: '#/components/schemas/Page'
+   *                   $ref: '#/components/schemas/PageForTreeItem'
    */
    */
   router.get('/root',
   router.get('/root',
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
     accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }), loginRequired, async(req: AuthorizedRequest, res: ApiV3Response) => {
-      const Page = mongoose.model<IPage, PageModel>('Page');
-
-      let rootPage;
       try {
       try {
-        rootPage = await Page.findByPathAndViewer('/', req.user, null, true);
+        const rootPage: IPageForTreeItem = await pageListingService.findRootByViewer(req.user);
+        return res.apiv3({ rootPage });
       }
       }
       catch (err) {
       catch (err) {
         return res.apiv3Err(new ErrorV3('rootPage not found'));
         return res.apiv3Err(new ErrorV3('rootPage not found'));
       }
       }
-
-      return res.apiv3({ rootPage });
-    });
-
-  /**
-   * @swagger
-   *
-   * /page-listing/ancestors-children:
-   *   get:
-   *     tags: [PageListing]
-   *     security:
-   *       - bearer: []
-   *       - accessTokenInQuery: []
-   *     summary: /page-listing/ancestors-children
-   *     description: Get the ancestors and children of a page
-   *     parameters:
-   *       - name: path
-   *         in: query
-   *         required: true
-   *         schema:
-   *           type: string
-   *     responses:
-   *       200:
-   *         description: Get the ancestors and children of a page
-   *         content:
-   *           application/json:
-   *             schema:
-   *               type: object
-   *               properties:
-   *                 ancestorsChildren:
-   *                   type: object
-   *                   additionalProperties:
-   *                     type: object
-   *                     properties:
-   *                       _id:
-   *                         type: string
-   *                         description: Document ID
-   *                       descendantCount:
-   *                         type: integer
-   *                         description: Number of descendants
-   *                       isEmpty:
-   *                         type: boolean
-   *                         description: Indicates if the node is empty
-   *                       grant:
-   *                         type: integer
-   *                         description: Access level
-   *                       path:
-   *                         type: string
-   *                         description: Path string
-   *                       revision:
-   *                         type: string
-   *                         nullable: true
-   *                         description: Revision ID (nullable)
-   */
-  router.get('/ancestors-children',
-    accessTokenParser([SCOPE.READ.FEATURES.PAGE], { acceptLegacy: true }),
-    loginRequired, ...validator.pagePathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response): Promise<any> => {
-      const { path } = req.query;
-
-      const pageService = crowi.pageService;
-      try {
-        const ancestorsChildren = await pageService.findAncestorsChildrenByPathAndViewer(path as string, req.user);
-        return res.apiv3({ ancestorsChildren });
-      }
-      catch (err) {
-        logger.error('Failed to get ancestorsChildren.', err);
-        return res.apiv3Err(new ErrorV3('Failed to get ancestorsChildren.'));
-      }
-
     });
     });
 
 
   /**
   /**
@@ -202,7 +133,7 @@ const routerFactory = (crowi: Crowi): Router => {
    *                 children:
    *                 children:
    *                   type: array
    *                   type: array
    *                   items:
    *                   items:
-   *                     $ref: '#/components/schemas/Page'
+   *                     $ref: '#/components/schemas/PageForTreeItem'
    */
    */
   /*
   /*
    * In most cases, using id should be prioritized
    * In most cases, using id should be prioritized
@@ -212,14 +143,12 @@ const routerFactory = (crowi: Crowi): Router => {
     loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
     loginRequired, validator.pageIdOrPathRequired, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {
       const { id, path } = req.query;
       const { id, path } = req.query;
 
 
-      const pageService = crowi.pageService;
-
       const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
       const hideRestrictedByOwner = await configManager.getConfig('security:list-policy:hideRestrictedByOwner');
       const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
       const hideRestrictedByGroup = await configManager.getConfig('security:list-policy:hideRestrictedByGroup');
 
 
       try {
       try {
-        const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        const pages = await pageListingService.findChildrenByParentPathOrIdAndViewer(
+          (id || path) as string, req.user, !hideRestrictedByOwner, !hideRestrictedByGroup,
         );
         );
         return res.apiv3({ children: pages });
         return res.apiv3({ children: pages });
       }
       }

+ 3 - 0
apps/app/src/server/routes/apiv3/page/update-page.ts

@@ -196,6 +196,9 @@ export const updatePageHandlersFactory: UpdatePageHandlersFactory = (crowi) => {
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
           options.userRelatedGrantUserGroupIds = userRelatedGrantUserGroupIds;
         }
         }
         previousRevision = await Revision.findById(sanitizeRevisionId);
         previousRevision = await Revision.findById(sanitizeRevisionId);
+
+        // There are cases where "revisionId" is not required for revision updates
+        // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
         updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
         updatedPage = await crowi.pageService.updatePage(currentPage, body, previousRevision?.body ?? null, req.user, options);
       }
       }
       catch (err) {
       catch (err) {

+ 1 - 6
apps/app/src/server/routes/apiv3/pages/index.js

@@ -1,5 +1,6 @@
 
 
 import { PageGrant } from '@growi/core';
 import { PageGrant } from '@growi/core';
+import { SCOPE } from '@growi/core/dist/interfaces';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { ErrorV3 } from '@growi/core/dist/models';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { serializeUserSecurely } from '@growi/core/dist/models/serializers';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
 import { isCreatablePage, isTrashPage, isUserPage } from '@growi/core/dist/utils/page-path-utils';
@@ -9,7 +10,6 @@ import { body, query } from 'express-validator';
 
 
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { SupportedTargetModel, SupportedAction } from '~/interfaces/activity';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
 import { subscribeRuleNames } from '~/interfaces/in-app-notification';
-import { SCOPE } from '@growi/core/dist/interfaces';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { accessTokenParser } from '~/server/middlewares/access-token-parser';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import { GlobalNotificationSettingEvent } from '~/server/models/GlobalNotificationSetting';
 import PageTagRelation from '~/server/models/page-tag-relation';
 import PageTagRelation from '~/server/models/page-tag-relation';
@@ -53,7 +53,6 @@ module.exports = (crowi) => {
     ],
     ],
     renamePage: [
     renamePage: [
       body('pageId').isMongoId().withMessage('pageId is required'),
       body('pageId').isMongoId().withMessage('pageId is required'),
-      body('revisionId').optional({ nullable: true }).isMongoId().withMessage('revisionId is required'), // required when v4
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('newPagePath').isLength({ min: 1 }).withMessage('newPagePath is required'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRecursively').if(value => value != null).isBoolean().withMessage('isRecursively must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
       body('isRenameRedirect').if(value => value != null).isBoolean().withMessage('isRenameRedirect must be boolean'),
@@ -210,10 +209,6 @@ module.exports = (crowi) => {
    *                    $ref: '#/components/schemas/ObjectId'
    *                    $ref: '#/components/schemas/ObjectId'
    *                  path:
    *                  path:
    *                    $ref: '#/components/schemas/PagePath'
    *                    $ref: '#/components/schemas/PagePath'
-   *                  revisionId:
-   *                    type: string
-   *                    description: revision ID
-   *                    example: 5e07345972560e001761fa63
    *                  newPagePath:
    *                  newPagePath:
    *                    type: string
    *                    type: string
    *                    description: new path
    *                    description: new path

+ 1 - 1
apps/app/src/server/service/customize.ts

@@ -36,7 +36,7 @@ class CustomizeService implements S2sMessageHandlable {
 
 
   theme: string;
   theme: string;
 
 
-  themeHref: string;
+  themeHref: string | undefined;
 
 
   forcedColorScheme?: ColorScheme;
   forcedColorScheme?: ColorScheme;
 
 

+ 20 - 0
apps/app/src/server/service/growi-info/growi-info.integ.ts

@@ -1,3 +1,5 @@
+import type { IPage } from '^/../../packages/core/dist';
+import mongoose from 'mongoose';
 import { mock } from 'vitest-mock-extended';
 import { mock } from 'vitest-mock-extended';
 
 
 import pkg from '^/package.json';
 import pkg from '^/package.json';
@@ -7,6 +9,8 @@ import { Config } from '~/server/models/config';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 
 
 import type Crowi from '../../crowi';
 import type Crowi from '../../crowi';
+import type { PageModel } from '../../models/page';
+import pageModel from '../../models/page';
 
 
 import { growiInfoService } from './growi-info';
 import { growiInfoService } from './growi-info';
 
 
@@ -14,12 +18,17 @@ describe('GrowiInfoService', () => {
   const appVersion = pkg.version;
   const appVersion = pkg.version;
 
 
   let User;
   let User;
+  let Page;
 
 
   beforeAll(async() => {
   beforeAll(async() => {
     process.env.APP_SITE_URL = 'http://growi.test.jp';
     process.env.APP_SITE_URL = 'http://growi.test.jp';
     process.env.DEPLOYMENT_TYPE = 'growi-docker-compose';
     process.env.DEPLOYMENT_TYPE = 'growi-docker-compose';
     process.env.SAML_ENABLED = 'true';
     process.env.SAML_ENABLED = 'true';
 
 
+    // setup page model before loading configs
+    pageModel(null);
+    Page = mongoose.model<IPage, PageModel>('Page');
+
     await configManager.loadConfigs();
     await configManager.loadConfigs();
     await configManager.updateConfigs({
     await configManager.updateConfigs({
       'security:passport-saml:isEnabled': true,
       'security:passport-saml:isEnabled': true,
@@ -47,6 +56,12 @@ describe('GrowiInfoService', () => {
     User = userModelFactory(crowiMock);
     User = userModelFactory(crowiMock);
 
 
     await User.deleteMany({}); // clear users
     await User.deleteMany({}); // clear users
+    await Page.deleteMany({}); // clear pages
+
+    await Page.create({
+      path: '/',
+      descendantCount: 0,
+    });
   });
   });
 
 
   describe('getGrowiInfo', () => {
   describe('getGrowiInfo', () => {
@@ -109,6 +124,7 @@ describe('GrowiInfoService', () => {
           currentActiveUsersCount: 1,
           currentActiveUsersCount: 1,
           attachmentType: 'aws',
           attachmentType: 'aws',
           activeExternalAccountTypes: ['saml', 'github'],
           activeExternalAccountTypes: ['saml', 'github'],
+          currentPagesCount: 1,
         },
         },
       });
       });
     });
     });
@@ -158,6 +174,7 @@ describe('GrowiInfoService', () => {
       const growiInfo = await growiInfoService.getGrowiInfo({
       const growiInfo = await growiInfoService.getGrowiInfo({
         includeAttachmentInfo: true,
         includeAttachmentInfo: true,
         includeUserCountInfo: true,
         includeUserCountInfo: true,
+        includePageCountInfo: true,
       });
       });
 
 
       // assert
       // assert
@@ -167,6 +184,7 @@ describe('GrowiInfoService', () => {
         activeExternalAccountTypes: ['saml', 'github'],
         activeExternalAccountTypes: ['saml', 'github'],
         currentUsersCount: 1,
         currentUsersCount: 1,
         currentActiveUsersCount: 1,
         currentActiveUsersCount: 1,
+        currentPagesCount: 1,
       });
       });
     });
     });
 
 
@@ -176,6 +194,7 @@ describe('GrowiInfoService', () => {
         includeAttachmentInfo: true,
         includeAttachmentInfo: true,
         includeInstalledInfo: true,
         includeInstalledInfo: true,
         includeUserCountInfo: true,
         includeUserCountInfo: true,
+        includePageCountInfo: true,
       });
       });
 
 
       // assert
       // assert
@@ -187,6 +206,7 @@ describe('GrowiInfoService', () => {
         installedAtByOldestUser: new Date('2000-01-01'),
         installedAtByOldestUser: new Date('2000-01-01'),
         currentUsersCount: 1,
         currentUsersCount: 1,
         currentActiveUsersCount: 1,
         currentActiveUsersCount: 1,
+        currentPagesCount: 1,
       });
       });
     });
     });
 
 

+ 11 - 1
apps/app/src/server/service/growi-info/growi-info.ts

@@ -13,6 +13,7 @@ import mongoose from 'mongoose';
 
 
 import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { IExternalAuthProviderType } from '~/interfaces/external-auth-provider';
 import { Config } from '~/server/models/config';
 import { Config } from '~/server/models/config';
+import type { PageDocument, PageModel } from '~/server/models/page';
 import { aclService } from '~/server/service/acl';
 import { aclService } from '~/server/service/acl';
 import { configManager } from '~/server/service/config-manager';
 import { configManager } from '~/server/service/config-manager';
 import { getGrowiVersion } from '~/utils/growi-version';
 import { getGrowiVersion } from '~/utils/growi-version';
@@ -22,6 +23,7 @@ const FULL_ADDITIONAL_INFO_OPTIONS = {
   includeAttachmentInfo: true,
   includeAttachmentInfo: true,
   includeInstalledInfo: true,
   includeInstalledInfo: true,
   includeUserCountInfo: true,
   includeUserCountInfo: true,
+  includePageCountInfo: true,
 } as const;
 } as const;
 
 
 
 
@@ -116,9 +118,10 @@ export class GrowiInfoService {
 
 
   private async getAdditionalInfoByOptions<T extends GrowiInfoOptions>(options: T): Promise<IGrowiAdditionalInfoResult<T>> {
   private async getAdditionalInfoByOptions<T extends GrowiInfoOptions>(options: T): Promise<IGrowiAdditionalInfoResult<T>> {
     const User = mongoose.model<IUser, Model<IUser>>('User');
     const User = mongoose.model<IUser, Model<IUser>>('User');
+    const Page = mongoose.model<PageDocument, PageModel>('Page');
 
 
     // Check if any option is enabled to determine if we should return additional info
     // Check if any option is enabled to determine if we should return additional info
-    const hasAnyOption = options.includeAttachmentInfo || options.includeInstalledInfo || options.includeUserCountInfo;
+    const hasAnyOption = options.includeAttachmentInfo || options.includeInstalledInfo || options.includeUserCountInfo || options.includePageCountInfo;
 
 
     if (!hasAnyOption) {
     if (!hasAnyOption) {
       return undefined as IGrowiAdditionalInfoResult<T>;
       return undefined as IGrowiAdditionalInfoResult<T>;
@@ -137,6 +140,7 @@ export class GrowiInfoService {
       installedAtByOldestUser: Date | null;
       installedAtByOldestUser: Date | null;
       currentUsersCount: number;
       currentUsersCount: number;
       currentActiveUsersCount: number;
       currentActiveUsersCount: number;
+      currentPagesCount: number;
     }> = {
     }> = {
       attachmentType: configManager.getConfig('app:fileUploadType'),
       attachmentType: configManager.getConfig('app:fileUploadType'),
       activeExternalAccountTypes,
       activeExternalAccountTypes,
@@ -163,6 +167,12 @@ export class GrowiInfoService {
       partialResult.currentActiveUsersCount = currentActiveUsersCount;
       partialResult.currentActiveUsersCount = currentActiveUsersCount;
     }
     }
 
 
+    if (options.includePageCountInfo) {
+      const rootPage = await Page.findOne({ path: '/' });
+      const currentPagesCount = (rootPage?.descendantCount ?? 0) + 1;
+      partialResult.currentPagesCount = currentPagesCount;
+    }
+
     return partialResult as IGrowiAdditionalInfoResult<T>;
     return partialResult as IGrowiAdditionalInfoResult<T>;
   }
   }
 
 

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

@@ -729,7 +729,7 @@ class PageGrantService implements IPageGrantService {
   /*
   /*
    * get all groups that user is related to
    * get all groups that user is related to
    */
    */
-  async getUserRelatedGroups(user?: IUserHasId | null): Promise<PopulatedGrantedGroup[]> {
+  async getUserRelatedGroups(user?: IUserHasId | HydratedDocument<IUser> | null): Promise<PopulatedGrantedGroup[]> {
     if (user == null) {
     if (user == null) {
       return [];
       return [];
     }
     }

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

@@ -0,0 +1 @@
+export * from './page-listing';

+ 407 - 0
apps/app/src/server/service/page-listing/page-listing.integ.ts

@@ -0,0 +1,407 @@
+import type { IPage, IUser } from '@growi/core/dist/interfaces';
+import { isValidObjectId } from '@growi/core/dist/utils/objectid-utils';
+import mongoose from 'mongoose';
+import type { HydratedDocument, Model } from 'mongoose';
+
+import { PageActionType, PageActionStage } from '~/interfaces/page-operation';
+import type { PageModel } from '~/server/models/page';
+import type { IPageOperation } from '~/server/models/page-operation';
+
+import { pageListingService } from './page-listing';
+
+// Mock the page-operation service
+vi.mock('~/server/service/page-operation', () => ({
+  pageOperationService: {
+    generateProcessInfo: vi.fn((pageOperations: IPageOperation[]) => {
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any
+      const processInfo: Record<string, any> = {};
+      pageOperations.forEach((pageOp) => {
+        const pageId = pageOp.page._id.toString();
+        processInfo[pageId] = {
+          [pageOp.actionType]: {
+            [PageActionStage.Main]: { isProcessable: true },
+            [PageActionStage.Sub]: undefined,
+          },
+        };
+      });
+      return processInfo;
+    }),
+  },
+}));
+
+describe('page-listing store integration tests', () => {
+  let Page: PageModel;
+  let User: Model<IUser>;
+  let PageOperation: Model<IPageOperation>;
+  let testUser: HydratedDocument<IUser>;
+  let rootPage: HydratedDocument<IPage>;
+
+  // Helper function to validate IPageForTreeItem type structure
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const validatePageForTreeItem = (page: any): void => {
+    expect(page).toBeDefined();
+    expect(page._id).toBeDefined();
+    expect(typeof page.path).toBe('string');
+    expect(page.grant).toBeDefined();
+    expect(typeof page.isEmpty).toBe('boolean');
+    expect(typeof page.descendantCount).toBe('number');
+    // revision is required when isEmpty is false
+    if (page.isEmpty === false) {
+      expect(page.revision).toBeDefined();
+      expect(isValidObjectId(page.revision)).toBe(true);
+    }
+    // processData is optional
+    if (page.processData !== undefined) {
+      expect(page.processData).toBeInstanceOf(Object);
+    }
+  };
+
+  beforeAll(async() => {
+    // setup models
+    const setupPage = (await import('~/server/models/page')).default;
+    setupPage(null);
+    const setupUser = (await import('~/server/models/user')).default;
+    setupUser(null);
+
+    // get models
+    Page = mongoose.model<IPage, PageModel>('Page');
+    User = mongoose.model<IUser>('User');
+    PageOperation = (await import('~/server/models/page-operation')).default;
+  });
+
+  beforeEach(async() => {
+    // Clean up database
+    await Page.deleteMany({});
+    await User.deleteMany({});
+    await PageOperation.deleteMany({});
+
+    // Create test user
+    testUser = await User.create({
+      name: 'Test User',
+      username: 'testuser',
+      email: 'test@example.com',
+      lang: 'en_US',
+    });
+
+    // Create root page
+    rootPage = await Page.create({
+      path: '/',
+      revision: new mongoose.Types.ObjectId(),
+      creator: testUser._id,
+      lastUpdateUser: testUser._id,
+      grant: 1, // GRANT_PUBLIC
+      isEmpty: false,
+      descendantCount: 0,
+    });
+  });
+
+  describe('pageListingService.findRootByViewer', () => {
+    test('should return root page successfully', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+
+      expect(rootPageResult).toBeDefined();
+      expect(rootPageResult.path).toBe('/');
+      expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
+      expect(rootPageResult.grant).toBe(1);
+      expect(rootPageResult.isEmpty).toBe(false);
+      expect(rootPageResult.descendantCount).toBe(0);
+    });
+
+    test('should handle error when root page does not exist', async() => {
+      // Remove the root page
+      await Page.deleteOne({ path: '/' });
+
+      try {
+        await pageListingService.findRootByViewer(testUser);
+        // Should not reach here
+        expect(true).toBe(false);
+      }
+      catch (error) {
+        expect(error).toBeDefined();
+      }
+    });
+
+    test('should return proper page structure that matches IPageForTreeItem type', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer(testUser);
+
+      // Use helper function to validate type structure
+      validatePageForTreeItem(rootPageResult);
+
+      // Additional type-specific validations
+      expect(typeof rootPageResult._id).toBe('object'); // ObjectId
+      expect(rootPageResult.path).toBe('/');
+      expect([null, 1, 2, 3, 4, 5]).toContain(rootPageResult.grant); // Valid grant values
+      expect(rootPageResult.parent).toBeNull(); // Root page has no parent
+    });
+
+    test('should work without user (guest access) and return type-safe result', async() => {
+      const rootPageResult = await pageListingService.findRootByViewer();
+
+      validatePageForTreeItem(rootPageResult);
+      expect(rootPageResult.path).toBe('/');
+      expect(rootPageResult._id.toString()).toBe(rootPage._id.toString());
+    });
+  });
+
+  describe('pageListingService.findChildrenByParentPathOrIdAndViewer', () => {
+    let childPage1: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create child pages
+      childPage1 = await Page.create({
+        path: '/child1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 1,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/child2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create grandchild page
+      await Page.create({
+        path: '/child1/grandchild',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: childPage1._id,
+      });
+
+      // Update root page descendant count
+      await Page.updateOne(
+        { _id: rootPage._id },
+        { descendantCount: 2 },
+      );
+    });
+
+    test('should find children by parent path and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+        expect(['/child1', '/child2']).toContain(child.path);
+      });
+    });
+
+    test('should find children by parent ID and return type-safe results', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer(rootPage._id.toString(), testUser);
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+        expect(child.parent?.toString()).toBe(rootPage._id.toString());
+      });
+    });
+
+    test('should handle nested children correctly', async() => {
+      const nestedChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child1', testUser);
+
+      expect(nestedChildren).toHaveLength(1);
+      const grandChild = nestedChildren[0];
+      validatePageForTreeItem(grandChild);
+      expect(grandChild.path).toBe('/child1/grandchild');
+      expect(grandChild.parent?.toString()).toBe(childPage1._id.toString());
+    });
+
+    test('should return empty array when no children exist', async() => {
+      const noChildren = await pageListingService.findChildrenByParentPathOrIdAndViewer('/child2', testUser);
+
+      expect(noChildren).toHaveLength(0);
+      expect(Array.isArray(noChildren)).toBe(true);
+    });
+
+    test('should work without user (guest access)', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/');
+
+      expect(children).toHaveLength(2);
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+      });
+    });
+
+    test('should sort children by path in ascending order', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(children).toHaveLength(2);
+      expect(children[0].path).toBe('/child1');
+      expect(children[1].path).toBe('/child2');
+    });
+  });
+
+  describe('pageListingService processData injection', () => {
+    let operatingPage: HydratedDocument<IPage>;
+
+    beforeEach(async() => {
+      // Create a page that will have operations
+      operatingPage = await Page.create({
+        path: '/operating-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      // Create a PageOperation for this page
+      await PageOperation.create({
+        actionType: PageActionType.Rename,
+        actionStage: PageActionStage.Main,
+        page: {
+          _id: operatingPage._id,
+          path: operatingPage.path,
+          isEmpty: operatingPage.isEmpty,
+          grant: operatingPage.grant,
+          grantedGroups: [],
+          descendantCount: operatingPage.descendantCount,
+        },
+        user: {
+          _id: testUser._id,
+        },
+        fromPath: '/operating-page',
+        toPath: '/renamed-operating-page',
+        options: {},
+      });
+    });
+
+    test('should inject processData for pages with operations', async() => {
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // Find the operating page in results
+      const operatingResult = children.find(child => child.path === '/operating-page');
+      expect(operatingResult).toBeDefined();
+
+      // Validate type structure
+      if (operatingResult) {
+        validatePageForTreeItem(operatingResult);
+
+        // Check that processData was injected
+        expect(operatingResult.processData).toBeDefined();
+        expect(operatingResult.processData).toBeInstanceOf(Object);
+      }
+    });
+
+    test('should set processData to undefined for pages without operations', async() => {
+      // Create another page without operations
+      await Page.create({
+        path: '/normal-page',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+      const normalPage = children.find(child => child.path === '/normal-page');
+
+      expect(normalPage).toBeDefined();
+      if (normalPage) {
+        validatePageForTreeItem(normalPage);
+        expect(normalPage.processData).toBeUndefined();
+      }
+    });
+
+    test('should maintain type safety with mixed processData scenarios', async() => {
+      // Create pages with and without operations
+      await Page.create({
+        path: '/mixed-test-1',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      await Page.create({
+        path: '/mixed-test-2',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const children = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      // All results should be type-safe regardless of processData presence
+      children.forEach((child) => {
+        validatePageForTreeItem(child);
+
+        // processData should be either undefined or a valid object
+        if (child.processData !== undefined) {
+          expect(child.processData).toBeInstanceOf(Object);
+        }
+      });
+    });
+  });
+
+  describe('PageQueryBuilder exec() type safety tests', () => {
+    test('findRootByViewer should return object with correct _id type', async() => {
+      const result = await pageListingService.findRootByViewer(testUser);
+
+      // PageQueryBuilder.exec() returns any, but we expect ObjectId-like behavior
+      expect(result._id).toBeDefined();
+      expect(result._id.toString).toBeDefined();
+      expect(typeof result._id.toString()).toBe('string');
+      expect(result._id.toString().length).toBe(24); // MongoDB ObjectId string length
+    });
+
+    test('findChildrenByParentPathOrIdAndViewer should return array with correct _id types', async() => {
+      // Create test child page first
+      await Page.create({
+        path: '/test-child',
+        revision: new mongoose.Types.ObjectId(),
+        creator: testUser._id,
+        lastUpdateUser: testUser._id,
+        grant: 1, // GRANT_PUBLIC
+        isEmpty: false,
+        descendantCount: 0,
+        parent: rootPage._id,
+      });
+
+      const results = await pageListingService.findChildrenByParentPathOrIdAndViewer('/', testUser);
+
+      expect(Array.isArray(results)).toBe(true);
+      results.forEach((result) => {
+        // Validate _id behavior from exec() any return type
+        expect(result._id).toBeDefined();
+        expect(result._id.toString).toBeDefined();
+        expect(typeof result._id.toString()).toBe('string');
+        expect(result._id.toString().length).toBe(24);
+
+        // Validate parent _id behavior
+        if (result.parent) {
+          expect(result.parent.toString).toBeDefined();
+          expect(typeof result.parent.toString()).toBe('string');
+          expect(result.parent.toString().length).toBe(24);
+        }
+      });
+    });
+
+  });
+});

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

@@ -0,0 +1,114 @@
+import type { IUser } from '@growi/core/dist/interfaces';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import mongoose, { type HydratedDocument } from 'mongoose';
+
+import type { IPageForTreeItem } from '~/interfaces/page';
+import { PageActionType, type IPageOperationProcessInfo, type IPageOperationProcessData } from '~/interfaces/page-operation';
+import { PageQueryBuilder, type PageDocument, type PageModel } from '~/server/models/page';
+import PageOperation from '~/server/models/page-operation';
+
+import type { IPageOperationService } from '../page-operation';
+
+const { hasSlash, generateChildrenRegExp } = pagePathUtils;
+
+
+export interface IPageListingService {
+  findRootByViewer(user: IUser): Promise<IPageForTreeItem>,
+  findChildrenByParentPathOrIdAndViewer(
+    parentPathOrId: string,
+    user?: IUser,
+    showPagesRestrictedByOwner?: boolean,
+    showPagesRestrictedByGroup?: boolean,
+  ): Promise<IPageForTreeItem[]>,
+}
+
+let pageOperationService: IPageOperationService;
+async function getPageOperationServiceInstance(): Promise<IPageOperationService> {
+  if (pageOperationService == null) {
+    // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+    pageOperationService = await import('../page-operation').then(mod => mod.pageOperationService!);
+  }
+  return pageOperationService;
+}
+
+class PageListingService implements IPageListingService {
+
+  async findRootByViewer(user?: IUser): Promise<IPageForTreeItem> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+
+    const builder = new PageQueryBuilder(Page.findOne({ path: '/' }));
+    await builder.addViewerCondition(user);
+
+    return builder.query
+      .select('_id path parent revision descendantCount grant isEmpty wip')
+      .lean()
+      .exec();
+  }
+
+  async findChildrenByParentPathOrIdAndViewer(
+      parentPathOrId: string,
+      user?: IUser,
+      showPagesRestrictedByOwner = false,
+      showPagesRestrictedByGroup = false,
+  ): Promise<IPageForTreeItem[]> {
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    let queryBuilder: PageQueryBuilder;
+    if (hasSlash(parentPathOrId)) {
+      const path = parentPathOrId;
+      const regexp = generateChildrenRegExp(path);
+      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
+    }
+    else {
+      const parentId = parentPathOrId;
+      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
+      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } }), true);
+    }
+    await queryBuilder.addViewerCondition(user, null, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
+
+    const pages: HydratedDocument<Omit<IPageForTreeItem, 'processData'>>[] = await queryBuilder
+      .addConditionToSortPagesByAscPath()
+      .query
+      .select('_id path parent revision descendantCount grant isEmpty wip')
+      .lean()
+      .exec();
+
+    const injectedPages = await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
+
+    // Type-safe conversion to IPageForTreeItem
+    return injectedPages.map(page => (
+      Object.assign(page, { _id: page._id.toString() })
+    ));
+  }
+
+  /**
+   * Inject processData into page docuements
+   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
+   */
+  private async injectProcessDataIntoPagesByActionTypes<T>(
+      pages: HydratedDocument<T>[],
+      actionTypes: PageActionType[],
+  ): Promise<(HydratedDocument<T> & { processData?: IPageOperationProcessData })[]> {
+
+    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
+    if (pageOperations == null || pageOperations.length === 0) {
+      return pages.map(page => Object.assign(page, { processData: undefined }));
+    }
+
+    const pageOperationService = await getPageOperationServiceInstance();
+    const processInfo: IPageOperationProcessInfo = pageOperationService.generateProcessInfo(pageOperations);
+    const operatingPageIds: string[] = Object.keys(processInfo);
+
+    // inject processData into pages
+    return pages.map((page) => {
+      const pageId = page._id.toString();
+      if (operatingPageIds.includes(pageId)) {
+        const processData: IPageOperationProcessData = processInfo[pageId];
+        return Object.assign(page, { processData });
+      }
+      return Object.assign(page, { processData: undefined });
+    });
+  }
+
+}
+
+export const pageListingService = new PageListingService();

+ 15 - 2
apps/app/src/server/service/page-operation.ts

@@ -25,7 +25,15 @@ const {
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
   Duplicate, Delete, DeleteCompletely, Revert, NormalizeParent,
 } = PageActionType;
 } = PageActionType;
 
 
-class PageOperationService {
+export interface IPageOperationService {
+  generateProcessInfo(pageOperations: PageOperationDocument[]): IPageOperationProcessInfo;
+  canOperate(isRecursively: boolean, fromPathToOp: string | null, toPathToOp: string | null): Promise<boolean>;
+  autoUpdateExpiryDate(operationId: ObjectIdLike): NodeJS.Timeout;
+  clearAutoUpdateInterval(timerObj: NodeJS.Timeout): void;
+  getAncestorsPathsByFromAndToPath(fromPath: string, toPath: string): string[];
+}
+
+class PageOperationService implements IPageOperationService {
 
 
   crowi: Crowi;
   crowi: Crowi;
 
 
@@ -201,4 +209,9 @@ class PageOperationService {
 
 
 }
 }
 
 
-export default PageOperationService;
+// eslint-disable-next-line import/no-mutable-exports
+export let pageOperationService: PageOperationService | undefined; // singleton instance
+export default function instanciate(crowi: Crowi): PageOperationService {
+  pageOperationService = new PageOperationService(crowi);
+  return pageOperationService;
+}

+ 10 - 107
apps/app/src/server/service/page/index.ts

@@ -33,9 +33,7 @@ import {
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
   PageDeleteConfigValue, PageSingleDeleteCompConfigValue,
 } from '~/interfaces/page-delete-config';
 } from '~/interfaces/page-delete-config';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
 import type { PopulatedGrantedGroup } from '~/interfaces/page-grant';
-import {
-  type IPageOperationProcessInfo, type IPageOperationProcessData, PageActionStage, PageActionType,
-} from '~/interfaces/page-operation';
+import { PageActionStage, PageActionType } from '~/interfaces/page-operation';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { PageActionOnGroupDelete } from '~/interfaces/user-group';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import { SocketEventName, type PageMigrationErrorData, type UpdateDescCountRawData } from '~/interfaces/websocket';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
 import type { CurrentPageYjsData } from '~/interfaces/yjs';
@@ -4124,6 +4122,9 @@ class PageService implements IPageService {
     return [...userUnrelatedPreviousGrantedGroups, ...userRelatedGrantedGroups];
     return [...userUnrelatedPreviousGrantedGroups, ...userRelatedGrantedGroups];
   }
   }
 
 
+
+  // There are cases where "revisionId" is not required for revision updates
+  // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
   async updatePage(
   async updatePage(
       pageData: HydratedDocument<PageDocument>,
       pageData: HydratedDocument<PageDocument>,
       body: string | null,
       body: string | null,
@@ -4273,6 +4274,8 @@ class PageService implements IPageService {
   }
   }
 
 
 
 
+  // There are cases where "revisionId" is not required for revision updates
+  // See: https://dev.growi.org/651a6f4a008fee2f99187431#origin-%E3%81%AE%E5%BC%B7%E5%BC%B1
   async updatePageV4(
   async updatePageV4(
       pageData: HydratedDocument<PageDocument>, body, previousBody, user, options: IOptionsForUpdate = {},
       pageData: HydratedDocument<PageDocument>, body, previousBody, user, options: IOptionsForUpdate = {},
   ): Promise<HydratedDocument<PageDocument>> {
   ): Promise<HydratedDocument<PageDocument>> {
@@ -4293,10 +4296,10 @@ class PageService implements IPageService {
     let savedPage = await pageData.save();
     let savedPage = await pageData.save();
 
 
     // Update revision
     // Update revision
-    const isBodyPresent = body != null && previousBody != null;
+    const isBodyPresent = body != null;
     const shouldUpdateBody = isBodyPresent;
     const shouldUpdateBody = isBodyPresent;
     if (shouldUpdateBody) {
     if (shouldUpdateBody) {
-      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user);
+      const newRevision = await Revision.prepareRevision(pageData, body, previousBody, user, options.origin);
       savedPage = await pushRevision(savedPage, newRevision, user);
       savedPage = await pushRevision(savedPage, newRevision, user);
       await savedPage.populateDataToShowRevision();
       await savedPage.populateDataToShowRevision();
     }
     }
@@ -4312,45 +4315,10 @@ class PageService implements IPageService {
     return savedPage;
     return savedPage;
   }
   }
 
 
-  /*
-   * Find all children by parent's path or id. Using id should be prioritized
-   */
-  async findChildrenByParentPathOrIdAndViewer(
-      parentPathOrId: string,
-      user,
-      userGroups = null,
-      showPagesRestrictedByOwner = false,
-      showPagesRestrictedByGroup = false,
-  ): Promise<(HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[]> {
-    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
-    let queryBuilder: PageQueryBuilder;
-    if (hasSlash(parentPathOrId)) {
-      const path = parentPathOrId;
-      const regexp = generateChildrenRegExp(path);
-      queryBuilder = new PageQueryBuilder(Page.find({ path: { $regex: regexp } }), true);
-    }
-    else {
-      const parentId = parentPathOrId;
-      // Use $eq for user-controlled sources. see: https://codeql.github.com/codeql-query-help/javascript/js-sql-injection/#recommendation
-      queryBuilder = new PageQueryBuilder(Page.find({ parent: { $eq: parentId } } as any), true); // TODO: improve type
-    }
-    await queryBuilder.addViewerCondition(user, userGroups, undefined, showPagesRestrictedByOwner, showPagesRestrictedByGroup);
-
-    const pages: HydratedDocument<PageDocument>[] = await queryBuilder
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    return pages;
-  }
-
   /**
   /**
    * Find all pages in trash page
    * Find all pages in trash page
    */
    */
-  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<PageDocument[]> {
+  async findAllTrashPages(user: IUserHasId, userGroups = null): Promise<HydratedDocument<IPage>[]> {
     const Page = mongoose.model('Page') as unknown as PageModel;
     const Page = mongoose.model('Page') as unknown as PageModel;
 
 
     // https://regex101.com/r/KYZWls/1
     // https://regex101.com/r/KYZWls/1
@@ -4360,80 +4328,15 @@ class PageService implements IPageService {
 
 
     await queryBuilder.addViewerCondition(user, userGroups);
     await queryBuilder.addViewerCondition(user, userGroups);
 
 
-    const pages = await queryBuilder
+    const pages: HydratedDocument<IPage>[] = await queryBuilder
       .addConditionToSortPagesByAscPath()
       .addConditionToSortPagesByAscPath()
       .query
       .query
       .lean()
       .lean()
       .exec();
       .exec();
 
 
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
     return pages;
     return pages;
   }
   }
 
 
-  async findAncestorsChildrenByPathAndViewer(path: string, user, userGroups = null): Promise<Record<string, PageDocument[]>> {
-    const Page = mongoose.model('Page') as unknown as PageModel;
-
-    const ancestorPaths = isTopPage(path) ? ['/'] : collectAncestorPaths(path); // root path is necessary for rendering
-    const regexps = ancestorPaths.map(path => generateChildrenRegExp(path)); // cannot use re2
-
-    // get pages at once
-    const queryBuilder = new PageQueryBuilder(Page.find({ path: { $in: regexps } }), true);
-    await queryBuilder.addViewerCondition(user, userGroups);
-    const pages = await queryBuilder
-      .addConditionAsOnTree()
-      .addConditionToMinimizeDataForRendering()
-      .addConditionToSortPagesByAscPath()
-      .query
-      .lean()
-      .exec();
-
-    await this.injectProcessDataIntoPagesByActionTypes(pages, [PageActionType.Rename]);
-
-    /*
-     * If any non-migrated page is found during creating the pathToChildren map, it will stop incrementing at that moment
-     */
-    const pathToChildren: Record<string, PageDocument[]> = {};
-    const sortedPaths = ancestorPaths.sort((a, b) => a.length - b.length); // sort paths by path.length
-    sortedPaths.every((path) => {
-      const children = pages.filter(page => pathlib.dirname(page.path) === path);
-      if (children.length === 0) {
-        return false; // break when children do not exist
-      }
-      pathToChildren[path] = children;
-      return true;
-    });
-
-    return pathToChildren;
-  }
-
-  /**
-   * Inject processData into page docuements
-   * The processData is a combination of actionType as a key and information on whether the action is processable as a value.
-   */
-  private async injectProcessDataIntoPagesByActionTypes(
-      pages: (HydratedDocument<PageDocument> & { processData?: IPageOperationProcessData })[],
-      actionTypes: PageActionType[],
-  ): Promise<void> {
-
-    const pageOperations = await PageOperation.find({ actionType: { $in: actionTypes } });
-    if (pageOperations == null || pageOperations.length === 0) {
-      return;
-    }
-
-    const processInfo: IPageOperationProcessInfo = this.crowi.pageOperationService.generateProcessInfo(pageOperations);
-    const operatingPageIds: string[] = Object.keys(processInfo);
-
-    // inject processData into pages
-    pages.forEach((page) => {
-      const pageId = page._id.toString();
-      if (operatingPageIds.includes(pageId)) {
-        const processData: IPageOperationProcessData = processInfo[pageId];
-        page.processData = processData;
-      }
-    });
-  }
-
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
   async getYjsData(pageId: string): Promise<CurrentPageYjsData> {
     const yjsService = getYjsService();
     const yjsService = getYjsService();
 
 

+ 20 - 4
apps/app/src/server/service/page/page-service.ts

@@ -4,6 +4,7 @@ import type {
   HasObjectId,
   HasObjectId,
   IDataWithMeta,
   IDataWithMeta,
   IGrantedGroup,
   IGrantedGroup,
+  IPage,
   IPageInfo, IPageInfoAll, IPageInfoForEntity, IUser,
   IPageInfo, IPageInfoAll, IPageInfoForEntity, IUser,
 } from '@growi/core';
 } from '@growi/core';
 import type { HydratedDocument, Types } from 'mongoose';
 import type { HydratedDocument, Types } from 'mongoose';
@@ -31,10 +32,6 @@ export interface IPageService {
   findPageAndMetaDataByViewer(
   findPageAndMetaDataByViewer(
       pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty?: boolean, isSharedPage?: boolean,
       pageId: string | null, path: string, user?: HydratedDocument<IUser>, includeEmpty?: boolean, isSharedPage?: boolean,
   ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null>
   ): Promise<IDataWithMeta<HydratedDocument<PageDocument>, IPageInfoAll>|null>
-  findAncestorsChildrenByPathAndViewer(path: string, user, userGroups?): Promise<Record<string, PageDocument[]>>,
-  findChildrenByParentPathOrIdAndViewer(
-    parentPathOrId: string, user, userGroups?, showPagesRestrictedByOwner?: boolean, showPagesRestrictedByGroup?: boolean,
-  ): Promise<PageDocument[]>,
   resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
   resumeRenameSubOperation(renamedPage: PageDocument, pageOp: PageOperationDocument, activity?): Promise<void>
   handlePrivatePagesForGroupsToDelete(
   handlePrivatePagesForGroupsToDelete(
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
     groupsToDelete: UserGroupDocument[] | ExternalUserGroupDocument[],
@@ -53,4 +50,23 @@ export interface IPageService {
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
     page: PageDocument, creatorId: ObjectIdLike | null, operator: any | null, userRelatedGroups: PopulatedGrantedGroup[]
   ): boolean,
   ): boolean,
   getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
   getYjsData(pageId: string, revisionBody?: string): Promise<CurrentPageYjsData>,
+  updateDescendantCountOfPagesWithPaths(paths: string[]): Promise<void>,
+  revertRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  revertDeletedPage(page, user, options, isRecursively: boolean, activityParameters?),
+  deleteCompletelyRecursivelyMainOperation(page, user, options, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  deleteCompletely(page, user, options, isRecursively: boolean, preventEmitting: boolean, activityParameters),
+  deleteRecursivelyMainOperation(page, user, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  deletePage(page, user, options, isRecursively: boolean, activityParameters),
+  duplicateRecursivelyMainOperation(
+    page: PageDocument,
+    newPagePath: string,
+    user,
+    pageOpId: ObjectIdLike,
+    onlyDuplicateUserRelatedResources: boolean,
+  ): Promise<void>,
+  duplicate(page: PageDocument, newPagePath: string, user, isRecursively: boolean, onlyDuplicateUserRelatedResources: boolean),
+  renameSubOperation(page, newPagePath: string, user, options, renamedPage, pageOpId: ObjectIdLike, activity?): Promise<void>,
+  renamePage(page: IPage, newPagePath, user, options, activityParameters): Promise<PageDocument | null>,
+  renameMainOperation(page, newPagePath: string, user, options, pageOpId: ObjectIdLike, activity?): Promise<PageDocument | null>,
+  createSubOperation(page, user, options: IOptionsForCreate, pageOpId: ObjectIdLike): Promise<void>,
 }
 }

+ 3 - 3
apps/app/src/services/general-xss-filter/general-xss-filter.spec.ts

@@ -1,7 +1,6 @@
 import { generalXssFilter } from './general-xss-filter';
 import { generalXssFilter } from './general-xss-filter';
 
 
 describe('generalXssFilter', () => {
 describe('generalXssFilter', () => {
-
   test('should be sanitize script tag', () => {
   test('should be sanitize script tag', () => {
     // Act
     // Act
     const result = generalXssFilter.process('<script>alert("XSS")</script>');
     const result = generalXssFilter.process('<script>alert("XSS")</script>');
@@ -12,7 +11,9 @@ describe('generalXssFilter', () => {
 
 
   test('should be sanitize nested script tag recursively', () => {
   test('should be sanitize nested script tag recursively', () => {
     // Act
     // Act
-    const result = generalXssFilter.process('<scr<script>ipt>alert("XSS")</scr<script>ipt>');
+    const result = generalXssFilter.process(
+      '<scr<script>ipt>alert("XSS")</scr<script>ipt>',
+    );
 
 
     // Assert
     // Assert
     expect(result).toBe('alert("XSS")');
     expect(result).toBe('alert("XSS")');
@@ -35,5 +36,4 @@ describe('generalXssFilter', () => {
     // Assert
     // Assert
     expect(result).toBe('<span>text</span>');
     expect(result).toBe('<span>text</span>');
   });
   });
-
 });
 });

+ 4 - 5
apps/app/src/services/general-xss-filter/general-xss-filter.ts

@@ -7,11 +7,12 @@ const option: IFilterXSSOptions = {
   stripIgnoreTag: true,
   stripIgnoreTag: true,
   stripIgnoreTagBody: false, // see https://github.com/growilabs/growi/pull/505
   stripIgnoreTagBody: false, // see https://github.com/growilabs/growi/pull/505
   css: false,
   css: false,
-  escapeHtml: (html) => { return html }, // resolve https://github.com/growilabs/growi/issues/221
+  escapeHtml: (html) => {
+    return html;
+  }, // resolve https://github.com/growilabs/growi/issues/221
 };
 };
 
 
 class GeneralXssFilter extends FilterXSS {
 class GeneralXssFilter extends FilterXSS {
-
   override process(document: string | undefined): string {
   override process(document: string | undefined): string {
     let count = 0;
     let count = 0;
     let currDoc = document;
     let currDoc = document;
@@ -26,12 +27,10 @@ class GeneralXssFilter extends FilterXSS {
 
 
       prevDoc = currDoc;
       prevDoc = currDoc;
       currDoc = super.process(currDoc ?? '');
       currDoc = super.process(currDoc ?? '');
-    }
-    while (currDoc !== prevDoc);
+    } while (currDoc !== prevDoc);
 
 
     return currDoc;
     return currDoc;
   }
   }
-
 }
 }
 
 
 export const generalXssFilter = new GeneralXssFilter(option);
 export const generalXssFilter = new GeneralXssFilter(option);

+ 6 - 2
apps/app/src/services/layout/use-should-expand-content.ts

@@ -2,14 +2,18 @@ import type { IPage, IPagePopulatedToShowRevision } from '@growi/core';
 
 
 import { useIsContainerFluid } from '~/stores-universal/context';
 import { useIsContainerFluid } from '~/stores-universal/context';
 
 
-const useDetermineExpandContent = (expandContentWidth?: boolean | null): boolean => {
+const useDetermineExpandContent = (
+  expandContentWidth?: boolean | null,
+): boolean => {
   const { data: dataIsContainerFluid } = useIsContainerFluid();
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 
 
   const isContainerFluidDefault = dataIsContainerFluid;
   const isContainerFluidDefault = dataIsContainerFluid;
   return expandContentWidth ?? isContainerFluidDefault ?? false;
   return expandContentWidth ?? isContainerFluidDefault ?? false;
 };
 };
 
 
-export const useShouldExpandContent = (data?: IPage | IPagePopulatedToShowRevision | boolean | null): boolean => {
+export const useShouldExpandContent = (
+  data?: IPage | IPagePopulatedToShowRevision | boolean | null,
+): boolean => {
   const expandContentWidth = (() => {
   const expandContentWidth = (() => {
     // when data is null
     // when data is null
     if (data == null) {
     if (data == null) {

+ 6 - 5
apps/app/src/services/renderer/markdown-it/PreProcessor/EasyGrid.js

@@ -1,10 +1,11 @@
 export default class EasyGrid {
 export default class EasyGrid {
-
   process(markdown) {
   process(markdown) {
     // see: https://regex101.com/r/7NWvUU/2
     // see: https://regex101.com/r/7NWvUU/2
-    return markdown.replace(/:::\s*editable-row[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group) => {
-      return group;
-    });
+    return markdown.replace(
+      /:::\s*editable-row[\r\n]((.|[\r\n])*?)[\r\n]:::/gm,
+      (all, group) => {
+        return group;
+      },
+    );
   }
   }
-
 }
 }

+ 13 - 6
apps/app/src/services/renderer/recommended-whitelist.spec.ts

@@ -1,9 +1,8 @@
 import { notDeepEqual } from 'assert';
 import { notDeepEqual } from 'assert';
 
 
-import { tagNames, attributes } from './recommended-whitelist';
+import { attributes, tagNames } from './recommended-whitelist';
 
 
 describe('recommended-whitelist', () => {
 describe('recommended-whitelist', () => {
-
   test('.tagNames should return iframe tag', () => {
   test('.tagNames should return iframe tag', () => {
     expect(tagNames).not.toBeNull();
     expect(tagNames).not.toBeNull();
     expect(tagNames).includes('iframe');
     expect(tagNames).includes('iframe');
@@ -52,7 +51,10 @@ describe('recommended-whitelist', () => {
     assert(attributes != null);
     assert(attributes != null);
 
 
     expect(Object.keys(attributes)).includes('a');
     expect(Object.keys(attributes)).includes('a');
-    expect(attributes.a).not.toContainEqual(['className', 'data-footnote-backref']);
+    expect(attributes.a).not.toContainEqual([
+      'className',
+      'data-footnote-backref',
+    ]);
   });
   });
 
 
   test('.attributes.ul should allow class and className by excluding partial className specification', () => {
   test('.attributes.ul should allow class and className by excluding partial className specification', () => {
@@ -61,7 +63,10 @@ describe('recommended-whitelist', () => {
     assert(attributes != null);
     assert(attributes != null);
 
 
     expect(Object.keys(attributes)).includes('a');
     expect(Object.keys(attributes)).includes('a');
-    expect(attributes.a).not.toContainEqual(['className', 'data-footnote-backref']);
+    expect(attributes.a).not.toContainEqual([
+      'className',
+      'data-footnote-backref',
+    ]);
   });
   });
 
 
   test('.attributes.li should allow class and className by excluding partial className specification', () => {
   test('.attributes.li should allow class and className by excluding partial className specification', () => {
@@ -70,7 +75,9 @@ describe('recommended-whitelist', () => {
     assert(attributes != null);
     assert(attributes != null);
 
 
     expect(Object.keys(attributes)).includes('a');
     expect(Object.keys(attributes)).includes('a');
-    expect(attributes.a).not.toContainEqual(['className', 'data-footnote-backref']);
+    expect(attributes.a).not.toContainEqual([
+      'className',
+      'data-footnote-backref',
+    ]);
   });
   });
-
 });
 });

+ 31 - 20
apps/app/src/services/renderer/recommended-whitelist.ts

@@ -9,7 +9,9 @@ type ExtractPropertyDefinition<T> = T extends Record<string, (infer U)[]>
 
 
 type PropertyDefinition = ExtractPropertyDefinition<NonNullable<Attributes>>;
 type PropertyDefinition = ExtractPropertyDefinition<NonNullable<Attributes>>;
 
 
-const excludeRestrictedClassAttributes = (propertyDefinitions: PropertyDefinition[]): PropertyDefinition[] => {
+const excludeRestrictedClassAttributes = (
+  propertyDefinitions: PropertyDefinition[],
+): PropertyDefinition[] => {
   if (propertyDefinitions == null) {
   if (propertyDefinitions == null) {
     return propertyDefinitions;
     return propertyDefinitions;
   }
   }
@@ -18,15 +20,24 @@ const excludeRestrictedClassAttributes = (propertyDefinitions: PropertyDefinitio
     if (!Array.isArray(propertyDefinition)) {
     if (!Array.isArray(propertyDefinition)) {
       return true;
       return true;
     }
     }
-    return propertyDefinition[0] !== 'class' && propertyDefinition[0] !== 'className';
+    return (
+      propertyDefinition[0] !== 'class' && propertyDefinition[0] !== 'className'
+    );
   });
   });
 };
 };
 
 
 // generate relaxed schema
 // generate relaxed schema
-const relaxedSchemaAttributes: Record<string, PropertyDefinition[]> = structuredClone(defaultSchema.attributes) ?? {};
-relaxedSchemaAttributes.a = excludeRestrictedClassAttributes(relaxedSchemaAttributes.a);
-relaxedSchemaAttributes.ul = excludeRestrictedClassAttributes(relaxedSchemaAttributes.ul);
-relaxedSchemaAttributes.li = excludeRestrictedClassAttributes(relaxedSchemaAttributes.li);
+const relaxedSchemaAttributes: Record<string, PropertyDefinition[]> =
+  structuredClone(defaultSchema.attributes) ?? {};
+relaxedSchemaAttributes.a = excludeRestrictedClassAttributes(
+  relaxedSchemaAttributes.a,
+);
+relaxedSchemaAttributes.ul = excludeRestrictedClassAttributes(
+  relaxedSchemaAttributes.ul,
+);
+relaxedSchemaAttributes.li = excludeRestrictedClassAttributes(
+  relaxedSchemaAttributes.li,
+);
 
 
 /**
 /**
  * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites,
  * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites,
@@ -34,23 +45,23 @@ relaxedSchemaAttributes.li = excludeRestrictedClassAttributes(relaxedSchemaAttri
  */
  */
 
 
 export const tagNames: Array<string> = [
 export const tagNames: Array<string> = [
-  ...defaultSchema.tagNames ?? [],
-  '-', 'bdi',
+  ...(defaultSchema.tagNames ?? []),
+  '-',
+  'bdi',
   'button',
   'button',
-  'col', 'colgroup',
+  'col',
+  'colgroup',
   'data',
   'data',
   'iframe',
   'iframe',
   'video',
   'video',
-  'rb', 'u',
+  'rb',
+  'u',
 ];
 ];
 
 
-export const attributes: Attributes = deepmerge(
-  relaxedSchemaAttributes,
-  {
-    iframe: ['allow', 'referrerpolicy', 'sandbox', 'src'],
-    video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
-    // The special value 'data*' as a property name can be used to allow all data properties.
-    // see: https://github.com/syntax-tree/hast-util-sanitize/
-    '*': ['key', 'class', 'className', 'style', 'role', 'data*'],
-  },
-);
+export const attributes: Attributes = deepmerge(relaxedSchemaAttributes, {
+  iframe: ['allow', 'referrerpolicy', 'sandbox', 'src'],
+  video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
+  // The special value 'data*' as a property name can be used to allow all data properties.
+  // see: https://github.com/syntax-tree/hast-util-sanitize/
+  '*': ['key', 'class', 'className', 'style', 'role', 'data*'],
+});

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

@@ -1,6 +1,6 @@
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // Re-implemeted in TypeScript.
 // Re-implemeted in TypeScript.
-import type { Nodes as HastNode, Element, Properties } from 'hast';
+import type { Element, Nodes as HastNode, Properties } from 'hast';
 import { selectAll } from 'hast-util-select';
 import { selectAll } from 'hast-util-select';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 
 
@@ -9,7 +9,10 @@ export type ClassName = string; // e.g. 'header'
 export type Additions = Record<SelectorName, ClassName>;
 export type Additions = Record<SelectorName, ClassName>;
 export type AdditionsEntry = [SelectorName, ClassName];
 export type AdditionsEntry = [SelectorName, ClassName];
 
 
-export const addClassToProperties = (properties: Properties | undefined, className: string): void => {
+export const addClassToProperties = (
+  properties: Properties | undefined,
+  className: string,
+): void => {
   if (properties == null) {
   if (properties == null) {
     return;
     return;
   }
   }
@@ -42,5 +45,5 @@ const adder = (entry: AdditionsEntry) => {
 export const rehypePlugin: Plugin<[Additions]> = (additions) => {
 export const rehypePlugin: Plugin<[Additions]> = (additions) => {
   const adders = Object.entries(additions).map(adder);
   const adders = Object.entries(additions).map(adder);
 
 
-  return node => adders.forEach(a => a(node as HastNode));
+  return (node) => adders.forEach((a) => a(node as HastNode));
 };
 };

+ 4 - 1
apps/app/src/services/renderer/rehype-plugins/add-inline-code-property.ts

@@ -3,7 +3,10 @@ import type { Plugin } from 'unified';
 import { is } from 'unist-util-is';
 import { is } from 'unist-util-is';
 import { visitParents } from 'unist-util-visit-parents';
 import { visitParents } from 'unist-util-visit-parents';
 
 
-const isInlineCodeTag = (node: Element, parent: Element | Root | null): boolean => {
+const isInlineCodeTag = (
+  node: Element,
+  parent: Element | Root | null,
+): boolean => {
   if (node.tagName !== 'code') {
   if (node.tagName !== 'code') {
     return false;
     return false;
   }
   }

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

@@ -1,11 +1,13 @@
 import type { Element } from 'hast';
 import type { Element } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
-import { visit, EXIT, CONTINUE } from 'unist-util-visit';
+import { CONTINUE, EXIT, visit } from 'unist-util-visit';
 
 
 import { addClassToProperties } from './add-class';
 import { addClassToProperties } from './add-class';
 
 
-const REGEXP_TARGET_TAGNAMES = new RegExp(/^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul|table)$/);
+const REGEXP_TARGET_TAGNAMES = new RegExp(
+  /^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul|table)$/,
+);
 
 
 export const rehypePlugin: Plugin = () => {
 export const rehypePlugin: Plugin = () => {
   return (tree) => {
   return (tree) => {

+ 21 - 11
apps/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts

@@ -1,8 +1,7 @@
-import type { Root, Element, Text } from 'hast';
+import type { Element, Root, Text } from 'hast';
 import rehypeRewrite from 'rehype-rewrite';
 import rehypeRewrite from 'rehype-rewrite';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 
 
-
 /**
 /**
  * This method returns ['foo', 'bar', 'foo']
  * This method returns ['foo', 'bar', 'foo']
  *  when the arguments are { keyword: 'foo', value: 'foobarfoo' }
  *  when the arguments are { keyword: 'foo', value: 'foobarfoo' }
@@ -50,7 +49,12 @@ function wrapWithEm(textElement: Text): Element {
   };
   };
 }
 }
 
 
-function highlight(lowercasedKeyword: string, node: Text, index: number, parent: Root | Element): void {
+function highlight(
+  lowercasedKeyword: string,
+  node: Text,
+  index: number,
+  parent: Root | Element,
+): void {
   if (node.value.toLowerCase().includes(lowercasedKeyword)) {
   if (node.value.toLowerCase().includes(lowercasedKeyword)) {
     const splitted = splitWithKeyword(lowercasedKeyword, node.value);
     const splitted = splitWithKeyword(lowercasedKeyword, node.value);
 
 
@@ -67,25 +71,31 @@ function highlight(lowercasedKeyword: string, node: Text, index: number, parent:
   }
   }
 }
 }
 
 
-
 export type KeywordHighlighterPluginParams = {
 export type KeywordHighlighterPluginParams = {
-  keywords?: string | string[],
-}
+  keywords?: string | string[];
+};
 
 
-export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (options) => {
+export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (
+  options,
+) => {
   if (options?.keywords == null) {
   if (options?.keywords == null) {
-    return node => node;
+    return (node) => node;
   }
   }
 
 
-  const keywords = (typeof options.keywords === 'string') ? [options.keywords] : options.keywords;
+  const keywords =
+    typeof options.keywords === 'string'
+      ? [options.keywords]
+      : options.keywords;
 
 
-  const lowercasedKeywords = keywords.map(keyword => keyword.toLowerCase());
+  const lowercasedKeywords = keywords.map((keyword) => keyword.toLowerCase());
 
 
   // return rehype-rewrite with hithlighter
   // return rehype-rewrite with hithlighter
   return rehypeRewrite.bind(this)({
   return rehypeRewrite.bind(this)({
     rewrite: (node, index, parent) => {
     rewrite: (node, index, parent) => {
       if (parent != null && index != null && node.type === 'text') {
       if (parent != null && index != null && node.type === 'text') {
-        lowercasedKeywords.forEach(keyword => highlight(keyword, node, index, parent));
+        lowercasedKeywords.forEach((keyword) =>
+          highlight(keyword, node, index, parent),
+        );
       }
       }
     },
     },
   });
   });

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

@@ -9,45 +9,51 @@ import { pukiwikiLikeLinker } from '../remark-plugins/pukiwiki-like-linker';
 import { relativeLinksByPukiwikiLikeLinker } from './relative-links-by-pukiwiki-like-linker';
 import { relativeLinksByPukiwikiLikeLinker } from './relative-links-by-pukiwiki-like-linker';
 
 
 describe('relativeLinksByPukiwikiLikeLinker', () => {
 describe('relativeLinksByPukiwikiLikeLinker', () => {
-
   /* eslint-disable indent */
   /* eslint-disable indent */
   describe.each`
   describe.each`
-    input                                   | expectedHref                        | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                          | ${'/page'}
-    ${'[[./page]]'}                         | ${'/user/admin/page'}               | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}               | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
-    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}             | ${'/page?q=foo#header'}
-    ${'[[./page?q=foo#header]]'}            | ${'/user/admin/page?q=foo#header'}  | ${'./page?q=foo#header'}
-    ${'[[Title>./page?q=foo#header]]'}      | ${'/user/admin/page?q=foo#header'}  | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
-  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker)
-        .use(rehype)
-        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
-
-      // when:
-      const mdast = processor.parse(input);
-      const hast = processor.runSync(mdast) as HastNode;
-      const anchorElement = select('a', hast);
-
-      // then
-      expect(anchorElement).not.toBeNull();
-      expect(anchorElement?.properties).not.toBeNull();
-      expect((anchorElement?.properties?.className as string).startsWith('pukiwiki-like-linker')).toBeTruthy();
-      expect(anchorElement?.properties?.href).toEqual(expectedHref);
-
-      expect(anchorElement?.children[0]).not.toBeNull();
-      expect(anchorElement?.children[0].type).toEqual('text');
-      expect((anchorElement?.children[0] as HastNode as Text).value).toEqual(expectedValue);
-
-    });
-  });
-
+    input                              | expectedHref                       | expectedValue
+    ${'[[/page]]'}                     | ${'/page'}                         | ${'/page'}
+    ${'[[./page]]'}                    | ${'/user/admin/page'}              | ${'./page'}
+    ${'[[Title>./page]]'}              | ${'/user/admin/page'}              | ${'Title'}
+    ${'[[Title>https://example.com]]'} | ${'https://example.com'}           | ${'Title'}
+    ${'[[/page?q=foo#header]]'}        | ${'/page?q=foo#header'}            | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}       | ${'/user/admin/page?q=foo#header'} | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'} | ${'/user/admin/page?q=foo#header'} | ${'Title'}
+    ${'[[Title>https://example.com]]'} | ${'https://example.com'}           | ${'Title'}
+  `(
+    'should convert relative links correctly',
+    ({ input, expectedHref, expectedValue }) => {
+      /* eslint-enable indent */
+
+      test(`when the input is '${input}'`, () => {
+        // setup:
+        const processor = unified()
+          .use(parse)
+          .use(pukiwikiLikeLinker)
+          .use(rehype)
+          .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
+
+        // when:
+        const mdast = processor.parse(input);
+        const hast = processor.runSync(mdast) as HastNode;
+        const anchorElement = select('a', hast);
+
+        // then
+        expect(anchorElement).not.toBeNull();
+        expect(anchorElement?.properties).not.toBeNull();
+        expect(
+          (anchorElement?.properties?.className as string).startsWith(
+            'pukiwiki-like-linker',
+          ),
+        ).toBeTruthy();
+        expect(anchorElement?.properties?.href).toEqual(expectedHref);
+
+        expect(anchorElement?.children[0]).not.toBeNull();
+        expect(anchorElement?.children[0].type).toEqual('text');
+        expect((anchorElement?.children[0] as HastNode as Text).value).toEqual(
+          expectedValue,
+        );
+      });
+    },
+  );
 });
 });

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

@@ -3,8 +3,10 @@ import { selectAll } from 'hast-util-select';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 
 
 import {
 import {
+  type IAnchorsSelector,
+  type IUrlResolver,
+  type RelativeLinksPluginParams,
   relativeLinks,
   relativeLinks,
-  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 } from './relative-links';
 
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
 const customAnchorsSelector: IAnchorsSelector = (node) => {
@@ -17,7 +19,9 @@ const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   return new URL(relativeHref, baseUrl);
   return new URL(relativeHref, baseUrl);
 };
 };
 
 
-export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+export const relativeLinksByPukiwikiLikeLinker: Plugin<
+  [RelativeLinksPluginParams]
+> = (options = {}) => {
   return relativeLinks.bind(this)({
   return relativeLinks.bind(this)({
     ...options,
     ...options,
     anchorsSelector: customAnchorsSelector,
     anchorsSelector: customAnchorsSelector,

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

@@ -1,4 +1,3 @@
-
 import type { Nodes as HastNode } from 'hast';
 import type { Nodes as HastNode } from 'hast';
 import { select } from 'hast-util-select';
 import { select } from 'hast-util-select';
 import parse from 'remark-parse';
 import parse from 'remark-parse';
@@ -8,7 +7,6 @@ import { unified } from 'unified';
 import { relativeLinks } from './relative-links';
 import { relativeLinks } from './relative-links';
 
 
 describe('relativeLinks', () => {
 describe('relativeLinks', () => {
-
   test('do nothing when the options does not have pagePath', () => {
   test('do nothing when the options does not have pagePath', () => {
     // setup
     // setup
     const processor = unified()
     const processor = unified()
@@ -27,10 +25,9 @@ describe('relativeLinks', () => {
 
 
   test.concurrent.each`
   test.concurrent.each`
     originalHref
     originalHref
-      ${'http://example.com/Sandbox'}
-      ${'#header'}
-    `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
-
+    ${'http://example.com/Sandbox'}
+    ${'#header'}
+  `("leaves the original href '$originalHref' as-is", ({ originalHref }) => {
     // setup
     // setup
     const pagePath = '/foo/bar/baz';
     const pagePath = '/foo/bar/baz';
     const processor = unified()
     const processor = unified()
@@ -48,33 +45,34 @@ describe('relativeLinks', () => {
   });
   });
 
 
   test.concurrent.each`
   test.concurrent.each`
-    originalHref                        | expectedHref
-      ${'/Sandbox'}                     | ${'/Sandbox'}
-      ${'/Sandbox?q=foo'}               | ${'/Sandbox?q=foo'}
-      ${'/Sandbox#header'}              | ${'/Sandbox#header'}
-      ${'/Sandbox?q=foo#header'}        | ${'/Sandbox?q=foo#header'}
-      ${'./Sandbox'}                    | ${'/foo/bar/Sandbox'}
-      ${'./Sandbox?q=foo'}              | ${'/foo/bar/Sandbox?q=foo'}
-      ${'./Sandbox#header'}             | ${'/foo/bar/Sandbox#header'}
-      ${'./Sandbox?q=foo#header'}       | ${'/foo/bar/Sandbox?q=foo#header'}
-    `('rewrites the original href \'$originalHref\' to \'$expectedHref\'', ({ originalHref, expectedHref }) => {
+    originalHref                | expectedHref
+    ${'/Sandbox'}               | ${'/Sandbox'}
+    ${'/Sandbox?q=foo'}         | ${'/Sandbox?q=foo'}
+    ${'/Sandbox#header'}        | ${'/Sandbox#header'}
+    ${'/Sandbox?q=foo#header'}  | ${'/Sandbox?q=foo#header'}
+    ${'./Sandbox'}              | ${'/foo/bar/Sandbox'}
+    ${'./Sandbox?q=foo'}        | ${'/foo/bar/Sandbox?q=foo'}
+    ${'./Sandbox#header'}       | ${'/foo/bar/Sandbox#header'}
+    ${'./Sandbox?q=foo#header'} | ${'/foo/bar/Sandbox?q=foo#header'}
+  `(
+    "rewrites the original href '$originalHref' to '$expectedHref'",
+    ({ originalHref, expectedHref }) => {
+      // setup
+      const pagePath = '/foo/bar/baz';
+      const processor = unified()
+        .use(parse)
+        .use(remarkRehype)
+        .use(relativeLinks, { pagePath });
 
 
-    // setup
-    const pagePath = '/foo/bar/baz';
-    const processor = unified()
-      .use(parse)
-      .use(remarkRehype)
-      .use(relativeLinks, { pagePath });
-
-    // when
-    const mdastTree = processor.parse(`[link](${originalHref})`);
-    const hastTree = processor.runSync(mdastTree) as HastNode;
-
-    // then
-    const anchorElement = select('a', hastTree);
-    expect(anchorElement).not.toBeNull();
-    expect(anchorElement?.properties).not.toBeNull();
-    expect(anchorElement?.properties?.href).toBe(expectedHref);
-  });
+      // when
+      const mdastTree = processor.parse(`[link](${originalHref})`);
+      const hastTree = processor.runSync(mdastTree) as HastNode;
 
 
+      // then
+      const anchorElement = select('a', hastTree);
+      expect(anchorElement).not.toBeNull();
+      expect(anchorElement?.properties).not.toBeNull();
+      expect(anchorElement?.properties?.href).toBe(expectedHref);
+    },
+  );
 });
 });

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

@@ -1,11 +1,10 @@
 import assert from 'assert';
 import assert from 'assert';
 
 
-import type { Nodes as HastNode, Element } from 'hast';
+import type { Element, Nodes as HastNode } from 'hast';
 import { selectAll } from 'hast-util-select';
 import { selectAll } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
 import isAbsolute from 'is-absolute-url';
 import type { Plugin } from 'unified';
 import type { Plugin } from 'unified';
 
 
-
 export type IAnchorsSelector = (node: HastNode) => Element[];
 export type IAnchorsSelector = (node: HastNode) => Element[];
 export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
 
@@ -29,12 +28,14 @@ const isAnchorLink = (href: string): boolean => {
 };
 };
 
 
 export type RelativeLinksPluginParams = {
 export type RelativeLinksPluginParams = {
-  pagePath?: string,
-  anchorsSelector?: IAnchorsSelector,
-  urlResolver?: IUrlResolver,
-}
+  pagePath?: string;
+  anchorsSelector?: IAnchorsSelector;
+  urlResolver?: IUrlResolver;
+};
 
 
-export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (
+  options = {},
+) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
   const urlResolver = options.urlResolver ?? defaultUrlResolver;
   const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
 
@@ -50,7 +51,12 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
       assert(anchor.properties != null);
       assert(anchor.properties != null);
 
 
       const href = anchor.properties.href;
       const href = anchor.properties.href;
-      if (href == null || typeof href !== 'string' || isAbsolute(href) || isAnchorLink(href)) {
+      if (
+        href == null ||
+        typeof href !== 'string' ||
+        isAbsolute(href) ||
+        isAnchorLink(href)
+      ) {
         return;
         return;
       }
       }
 
 

Some files were not shown because too many files changed in this diff