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

Merge pull request #9943 from weseek/master

Release v7.2.5
mergify[bot] 10 месяцев назад
Родитель
Сommit
124e9a826a
100 измененных файлов с 1842 добавлено и 933 удалено
  1. 1 0
      .devcontainer/app/devcontainer.json
  2. 1 0
      .devcontainer/pdf-converter/devcontainer.json
  3. 13 0
      .vscode/settings.json
  4. 9 7
      README.md
  5. 16 13
      README_JP.md
  6. 9 2
      apps/app/bin/swagger-jsdoc/definition-apiv1.js
  7. 9 2
      apps/app/bin/swagger-jsdoc/definition-apiv3.js
  8. 4 3
      apps/app/package.json
  9. 0 15
      apps/app/playwright/20-basic-features/presentation.spec.ts
  10. 3 35
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  11. 1 1
      apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx
  12. 4 2
      apps/app/src/client/components/PageHeader/PageHeader.tsx
  13. 28 8
      apps/app/src/client/components/PageHeader/PageTitleHeader.tsx
  14. 19 11
      apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx
  15. 1 0
      apps/app/src/client/components/PageTags/TagsInput.tsx
  16. 1 1
      apps/app/src/client/components/SearchPage/SearchResultContent.tsx
  17. 5 0
      apps/app/src/client/components/SearchTypeahead.tsx
  18. 12 8
      apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx
  19. 1 1
      apps/app/src/components/PageView/PageView.tsx
  20. 1 1
      apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx
  21. 4 0
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  22. 55 17
      apps/app/src/features/openai/server/models/vector-store-file-relation.ts
  23. 4 0
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  24. 1 0
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  25. 4 0
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  26. 221 23
      apps/app/src/features/openai/server/services/openai.ts
  27. 46 0
      apps/app/src/features/openai/server/utils/is-vector-store-compatible.ts
  28. 12 6
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  29. 10 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  30. 12 6
      apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts
  31. 47 0
      apps/app/src/migrations/20250522105040-delete-old-index-for-vector-store-file-relation.js
  32. 10 1
      apps/app/src/server/crowi/index.js
  33. 59 0
      apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts
  34. 18 2
      apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts
  35. 2 1
      apps/app/src/server/routes/apiv3/activity.ts
  36. 6 3
      apps/app/src/server/routes/apiv3/app-settings.js
  37. 3 2
      apps/app/src/server/routes/apiv3/attachment.js
  38. 12 6
      apps/app/src/server/routes/apiv3/bookmark-folder.ts
  39. 4 2
      apps/app/src/server/routes/apiv3/g2g-transfer.ts
  40. 10 5
      apps/app/src/server/routes/apiv3/import.js
  41. 8 4
      apps/app/src/server/routes/apiv3/in-app-notification.ts
  42. 9 5
      apps/app/src/server/routes/apiv3/page-listing.ts
  43. 19 9
      apps/app/src/server/routes/apiv3/slack-integration.js
  44. 60 8
      apps/app/src/server/service/attachment.js
  45. 10 7
      apps/pdf-converter/src/controllers/pdf.ts
  46. 1 1
      apps/pdf-converter/src/server.ts
  47. 23 12
      apps/pdf-converter/src/service/pdf-convert.ts
  48. 1 1
      apps/pdf-converter/tsconfig.json
  49. 2 2
      apps/slackbot-proxy/package.json
  50. 1 0
      apps/slackbot-proxy/src/services/InstallerService.ts
  51. 54 0
      biome.json
  52. 3 3
      package.json
  53. 0 162
      packages/core/src/utils/path-utils.js
  54. 162 11
      packages/core/src/utils/path-utils.spec.ts
  55. 146 0
      packages/core/src/utils/path-utils.ts
  56. 11 4
      packages/pdf-converter-client/src/index.ts
  57. 1 1
      packages/remark-lsx/.eslintignore
  58. 0 18
      packages/remark-lsx/.eslintrc.cjs
  59. 1 1
      packages/remark-lsx/package.json
  60. 147 120
      packages/remark-lsx/src/client/components/Lsx.tsx
  61. 12 13
      packages/remark-lsx/src/client/components/LsxPageList/LsxListView.tsx
  62. 21 14
      packages/remark-lsx/src/client/components/LsxPageList/LsxPage.tsx
  63. 9 7
      packages/remark-lsx/src/client/components/lsx-context.ts
  64. 68 48
      packages/remark-lsx/src/client/services/renderer/lsx.ts
  65. 36 17
      packages/remark-lsx/src/client/stores/lsx/lsx.ts
  66. 10 11
      packages/remark-lsx/src/client/stores/lsx/parse-num-option.spec.ts
  67. 12 5
      packages/remark-lsx/src/client/stores/lsx/parse-num-option.ts
  68. 28 23
      packages/remark-lsx/src/client/utils/page-node.spec.ts
  69. 33 18
      packages/remark-lsx/src/client/utils/page-node.ts
  70. 16 16
      packages/remark-lsx/src/interfaces/api.ts
  71. 4 4
      packages/remark-lsx/src/interfaces/page-node.ts
  72. 18 8
      packages/remark-lsx/src/server/index.ts
  73. 8 7
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.spec.ts
  74. 13 4
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts
  75. 52 51
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts
  76. 5 3
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts
  77. 14 3
      packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts
  78. 11 5
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  79. 2 4
      packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts
  80. 23 16
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  81. 41 28
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  82. 0 2
      packages/remark-lsx/src/utils/depth-utils.spec.ts
  83. 2 6
      packages/remark-lsx/tsconfig.json
  84. 1 3
      packages/remark-lsx/vite.server.config.ts
  85. 1 3
      packages/remark-lsx/vitest.config.ts
  86. 1 1
      packages/slack/.eslintignore
  87. 0 5
      packages/slack/.eslintrc.cjs
  88. 2 2
      packages/slack/package.json
  89. 3 9
      packages/slack/src/consts/index.ts
  90. 3 3
      packages/slack/src/interfaces/channel.ts
  91. 3 3
      packages/slack/src/interfaces/connection-status.ts
  92. 2 2
      packages/slack/src/interfaces/growi-bot-event.ts
  93. 8 2
      packages/slack/src/interfaces/growi-command-processor.ts
  94. 4 4
      packages/slack/src/interfaces/growi-command.ts
  95. 9 6
      packages/slack/src/interfaces/growi-interaction-processor.ts
  96. 12 11
      packages/slack/src/interfaces/request-between-growi-and-proxy.ts
  97. 10 4
      packages/slack/src/interfaces/request-from-slack.ts
  98. 4 4
      packages/slack/src/interfaces/respond-util.ts
  99. 3 3
      packages/slack/src/interfaces/response-url.ts
  100. 1 1
      packages/slack/src/interfaces/slackbot-types.ts

+ 1 - 0
.devcontainer/app/devcontainer.json

@@ -24,6 +24,7 @@
     "vscode": {
       "extensions": [
         "dbaeumer.vscode-eslint",
+        "biomejs.biome",
         "mhutchie.git-graph",
         "eamodio.gitlens",
         "github.vscode-pull-request-github",

+ 1 - 0
.devcontainer/pdf-converter/devcontainer.json

@@ -16,6 +16,7 @@
     "vscode": {
       "extensions": [
         "dbaeumer.vscode-eslint",
+        "biomejs.biome",
         "mhutchie.git-graph",
         "eamodio.gitlens"
       ],

+ 13 - 0
.vscode/settings.json

@@ -3,6 +3,14 @@
 
   "eslint.workingDirectories": [{ "mode": "auto" }],
 
+  "[typescript]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
+  "[javascript]": {
+    "editor.defaultFormatter": "biomejs.biome"
+  },
+
   // use vscode-stylelint
   // see https://github.com/stylelint/vscode-stylelint
   "stylelint.validate": ["css", "less", "scss"],
@@ -13,10 +21,14 @@
 
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": "explicit",
+    "source.fixAll.biome": "explicit",
+    "source.organizeImports.biome": "explicit",
     "source.fixAll.markdownlint": "explicit",
     "source.fixAll.stylelint": "explicit"
   },
 
+  "editor.formatOnSave": true,
+
   "githubPullRequests.ignoredPullRequestBranches": [
     "master"
   ],
@@ -28,4 +40,5 @@
   "typescript.surveys.enabled": false,
 
   "vitest.filesWatcherInclude": "**/*"
+
 }

+ 9 - 7
README.md

@@ -2,12 +2,12 @@
 
 <p align="center">
   <a href="https://growi.org">
-    <img src="https://user-images.githubusercontent.com/1638767/38254268-d4476bbe-3793-11e8-964c-8865d690baff.png" width="240px">
+    <img src="https://github.com/user-attachments/assets/0acf1409-cea7-4f0e-841c-af5bd8be6711" width="360px">
   </a>
 </p>
 <p align="center">
-  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-  <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
 </p>
 
 <p align="center">
@@ -16,12 +16,14 @@
 
 # GROWI
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
+[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+
+## Demonstration
+<video src="https://private-user-images.githubusercontent.com/34241526/333079483-fee540d7-2fa6-46d7-833e-74014c5340e3.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk2OTEsIm5iZiI6MTcxNjQ0OTM5MSwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzk0ODMtZmVlNTQwZDctMmZhNi00NmQ3LTgzM2UtNzQwMTRjNTM0MGUzLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3Mjk1MVomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPTBkYWFkMmYyYmIwMTI2YWE3ZmQzZTFiNWU3ZThkMDc5NDA5N2Q3YWE5ZGM1NDgwNjk0OGNjYjZmOTJkM2IzZGQmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.FAvLseWBzE62yFA7wt26uERamvEVQdIGRVdBwk0uLhE"></video>
 
-|                                                     demonstration                                                     |
-| :-------------------------------------------------------------------------------------------------------------------: |
-| ![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif) |
 
 ## Table Of Contents
 

+ 16 - 13
README_JP.md

@@ -1,13 +1,14 @@
 - [English 🇺🇸](./README.md)
-  <p align="center">
-    <a href="https://growi.org">
-      <img src="https://user-images.githubusercontent.com/1638767/38254268-d4476bbe-3793-11e8-964c-8865d690baff.png" width="240px">
-    </a>
-  </p>
-  <p align="center">
-    <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg"></a>
-    <a href="https://communityinviter.com/apps/wsgrowi/invite/">join our Slack team</a>
-  </p>
+
+<p align="center">
+  <a href="https://growi.org">
+    <img src="https://github.com/user-attachments/assets/0acf1409-cea7-4f0e-841c-af5bd8be6711" width="360px">
+  </a>
+</p>
+<p align="center">
+  <a href="https://github.com/weseek/growi/releases/latest"><img src="https://img.shields.io/github/release/weseek/growi.svg" alt="Latest version"></a>
+  <a href="https://communityinviter.com/apps/wsgrowi/invite/"><img src="https://img.shields.io/badge/Slack-Join%20Us-4A154B?style=flat&logo=slack&logoColor=white" alt="Slack - Join US"></a>
+</p>
 
 <p align="center">
   <a href="https://docs.growi.org">ドキュメント</a> / <a href="https://demo.growi.org">デモ</a>
@@ -15,12 +16,14 @@
 
 # GROWI
 
-[![Actions Status](https://github.com/weseek/growi/workflows/Node%20CI/badge.svg)](https://github.com/weseek/growi/actions)
 [![docker pulls](https://img.shields.io/docker/pulls/weseek/growi.svg)](https://hub.docker.com/r/weseek/growi/)
+[![CodeQL](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/codeql-analysis.yml)
+[![Node CI for app development](https://github.com/weseek/growi/actions/workflows/ci-app.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app.yml)
+[![Node CI for app production](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml/badge.svg)](https://github.com/weseek/growi/actions/workflows/ci-app-prod.yml)
+
+## デモ
+<video src="https://private-user-images.githubusercontent.com/34241526/333079216-cec7f7d8-c3cc-4ee7-bc94-167b056d4ce2.mp4?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJnaXRodWIuY29tIiwiYXVkIjoicmF3LmdpdGh1YnVzZXJjb250ZW50LmNvbSIsImtleSI6ImtleTUiLCJleHAiOjE3MTY0NDk0MDQsIm5iZiI6MTcxNjQ0OTEwNCwicGF0aCI6Ii8zNDI0MTUyNi8zMzMwNzkyMTYtY2VjN2Y3ZDgtYzNjYy00ZWU3LWJjOTQtMTY3YjA1NmQ0Y2UyLm1wND9YLUFtei1BbGdvcml0aG09QVdTNC1ITUFDLVNIQTI1NiZYLUFtei1DcmVkZW50aWFsPUFLSUFWQ09EWUxTQTUzUFFLNFpBJTJGMjAyNDA1MjMlMkZ1cy1lYXN0LTElMkZzMyUyRmF3czRfcmVxdWVzdCZYLUFtei1EYXRlPTIwMjQwNTIzVDA3MjUwNFomWC1BbXotRXhwaXJlcz0zMDAmWC1BbXotU2lnbmF0dXJlPWQ2M2IwZjc0ZGNhOWQxNWE4MGIyZTZlMzQ0ZDQ4MGZlNjEzMWE3MTQ1YmMwYzg3MmY1NWMyZWI2NzQ3NGIwMTkmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmFjdG9yX2lkPTAma2V5X2lkPTAmcmVwb19pZD0wIn0.qLfu5120JrkdfpghXlLG8wCn0p4JNZ7W8AD3zUJTIYY"></video>
 
-|                                                 デモンストレーション                                                 |
-| :-------------------------------------------------------------------------------------------------------------------: |
-| ![sample image](https://user-images.githubusercontent.com/42988650/70600974-6b29cc80-1c34-11ea-94ef-33c39c6a00dc.gif) |
 
 ## 目次
 

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv1.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   security: [
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
   ],
   components: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
     },
   },

+ 9 - 2
apps/app/bin/swagger-jsdoc/definition-apiv3.js

@@ -13,15 +13,22 @@ module.exports = {
   ],
   security: [
     {
-      api_key: [],
+      bearer: [],
+      accessTokenInQuery: [],
     },
   ],
   components: {
     securitySchemes: {
-      api_key: {
+      bearer: {
+        type: 'http',
+        scheme: 'bearer',
+        description: 'Access token generated by each GROWI users',
+      },
+      accessTokenInQuery: {
         type: 'apiKey',
         name: 'access_token',
         in: 'query',
+        description: 'Access token generated by each GROWI users',
       },
       cookieAuth: {
         type: 'apiKey',

+ 4 - 3
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "7.2.4",
+  "version": "7.2.5-RC.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -73,6 +73,7 @@
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
     "@growi/core": "workspace:^",
+    "@growi/pdf-converter-client": "workspace:^",
     "@growi/pluginkit": "workspace:^",
     "@growi/presentation": "workspace:^",
     "@growi/preset-templates": "workspace:^",
@@ -82,20 +83,20 @@
     "@growi/remark-growi-directive": "workspace:^",
     "@growi/remark-lsx": "workspace:^",
     "@growi/slack": "workspace:^",
-    "@growi/pdf-converter-client": "workspace:^",
     "@keycloak/keycloak-admin-client": "^18.0.0",
     "@opentelemetry/api": "^1.9.0",
     "@opentelemetry/auto-instrumentations-node": "^0.55.1",
     "@opentelemetry/exporter-metrics-otlp-grpc": "^0.57.0",
     "@opentelemetry/exporter-trace-otlp-grpc": "^0.57.0",
     "@opentelemetry/resources": "^1.28.0",
-    "@opentelemetry/semantic-conventions": "^1.28.0",
     "@opentelemetry/sdk-metrics": "^1.28.0",
     "@opentelemetry/sdk-node": "^0.57.0",
     "@opentelemetry/sdk-trace-node": "^1.28.0",
+    "@opentelemetry/semantic-conventions": "^1.28.0",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
     "@types/async": "^3.2.24",
+    "@types/multer": "^1.4.12",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",

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

@@ -10,19 +10,4 @@ test('Presentation', async({ page }) => {
   // check the content of the h1
   await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
     .toHaveText(/Welcome to GROWI/);
-
-  // forward the slide with keyboard
-  await page.keyboard.press('ArrowRight');
-
-  // check the content of the h1
-  await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
-    .toHaveText(/What can you do with GROWI?/);
-
-  // forward the slide with button
-  await page.getByRole('application').getByLabel('next slide').click();
-
-  // check the content of the h2
-  await expect(page.getByRole('application').getByRole('heading', { level: 2 }))
-    .toHaveText(/1. Knowledge Management: Create pages to store information and knowledge/);
-
 });

+ 3 - 35
apps/app/playwright/20-basic-features/use-tools.spec.ts

@@ -19,45 +19,13 @@ const openPageItemControl = async(page: Page): Promise<void> => {
   await button.click();
 };
 
-const openPutBackPageModal = async(page: Page): Promise<void> => {
-  const alert = page.getByTestId('trash-page-alert');
-  const button = alert.getByTestId('put-back-button');
-
-  // Wait for alert element to be visible and attached
-  await expect(alert).toBeVisible();
-  await alert.waitFor({ state: 'visible' });
-
-  // Wait for button to be visible, enabled and attached
-  await expect(button).toBeVisible();
-  await expect(button).toBeEnabled();
-  await button.waitFor({ state: 'visible' });
-
-  // Scroll to the top of the page to prevent the subnav hide the button
-  await page.evaluate(() => {
-    document.documentElement.scrollTop = 0;
-    document.body.scrollTop = 0; // For Safari and older browsers
-  });
-
-  // Add a small delay to ensure scrolling is complete and the button is interactive
-  await page.waitForTimeout(200); // Increased delay
-
-  await button.click();
-  await expect(page.getByTestId('put-back-page-modal')).toBeVisible();
-};
-
-test('Page Deletion and PutBack is executed successfully', async({ page }) => {
-  await page.goto('/Sandbox/Bootstrap5');
+test('PageDeleteModal is shown successfully', async({ page }) => {
+  await page.goto('/Sandbox');
 
-  // Delete
   await openPageItemControl(page);
   await page.getByTestId('open-page-delete-modal-btn').click();
-  await expect(page.getByTestId('page-delete-modal')).toBeVisible();
-  await page.getByTestId('delete-page-button').click();
 
-  // PutBack
-  await openPutBackPageModal(page);
-  await page.getByTestId('put-back-execution-button').click();
-  await expect(page.getByTestId('trash-page-alert')).not.toBeVisible();
+  await expect(page.getByTestId('page-delete-modal')).toBeVisible();
 });
 
 test('PageDuplicateModal is shown successfully', async({ page }) => {

+ 1 - 1
apps/app/src/client/components/PageAccessoriesModal/ShareLink/ShareLinkList.tsx

@@ -30,7 +30,7 @@ const ShareLinkTr = (props: ShareLinkTrProps): JSX.Element => {
         { isRelatedPageExists && (
           <CopyDropdown
             pagePath={relatedPage.path}
-            dropdownToggleId={`copydropdown-${shareLinkId}`}
+            dropdownToggleId={`copydropdown-for-share-link-list-${shareLinkId}`}
             pageId={shareLinkId}
             isShareLinkMode
           >

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

@@ -26,8 +26,10 @@ export const PageHeader = (): JSX.Element => {
       setMaxWidth(300);
       return;
     }
-    // At least 10px space between PageHeader and PageControls
-    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x - 10;
+
+    // PageControls.x - PageHeader.x
+    const maxWidth = pageControlsX - pageHeaderRef.current.getBoundingClientRect().x;
+
     setMaxWidth(maxWidth);
   }, [pageControlsX]);
 

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

@@ -1,5 +1,7 @@
 import type { ChangeEvent, JSX } from 'react';
-import { useState, useCallback, useEffect } from 'react';
+import {
+  useState, useCallback, useEffect, useMemo,
+} from 'react';
 
 import nodePath from 'path';
 
@@ -101,9 +103,27 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
 
   const isInvalid = validationResult != null;
 
-  const inputMaxWidth = maxWidth != null
-    ? getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - 16
-    : undefined;
+  // calculate inputMaxWidth as the maximum width of AutoSizeInput minus the width of WIP badge and CopyDropdown
+  const inputMaxWidth = useMemo(() => {
+    if (maxWidth == null) {
+      return undefined;
+    }
+
+    const wipBadgeAndCopyDropdownWidth = 4 // me-1
+      + (currentPage.wip ? 49 : 0) // WIP badge + gap
+      + 24; // CopyDropdown
+
+    return getAdjustedMaxWidthForAutosizeInput(maxWidth, 'md', validationResult != null ? false : undefined) - wipBadgeAndCopyDropdownWidth;
+  }, [currentPage.wip, maxWidth, validationResult]);
+
+  // calculate h1MaxWidth as the inputMaxWidth plus padding
+  const h1MaxWidth = useMemo(() => {
+    if (inputMaxWidth == null) {
+      return undefined;
+    }
+
+    return inputMaxWidth + 16; // plus the padding of px-2 because AutosizeInput has "box-sizing: content-box;"
+  }, [inputMaxWidth]);
 
   return (
     <div className={`d-flex ${moduleClass} ${props.className ?? ''} position-relative`}>
@@ -129,22 +149,22 @@ export const PageTitleHeader = (props: Props): JSX.Element => {
             ${isRenameInputShown ? 'invisible' : ''} text-truncate
             ${isMovable ? 'border border-2 rounded-2' : ''} ${borderColorClass}
           `}
-          style={{ maxWidth: inputMaxWidth }}
+          style={{ maxWidth: h1MaxWidth }}
           onClick={onClickPageTitle}
         >
           {pageTitle}
         </h1>
       </div>
 
-      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center`}>
+      <div className={`${isRenameInputShown ? 'invisible' : ''} d-flex align-items-center gap-2`}>
         { currentPage.wip && (
-          <span className="badge rounded-pill text-bg-secondary ms-2">WIP</span>
+          <span className="badge rounded-pill text-bg-secondary">WIP</span>
         )}
 
         <CopyDropdown
           pageId={currentPage._id}
           pagePath={currentPage.path}
-          dropdownToggleId={`copydropdown-${currentPage._id}`}
+          dropdownToggleId={`copydropdown-in-pagetitleheader-${currentPage._id}`}
           dropdownToggleClassName="p-1"
         >
           <span className="material-symbols-outlined fs-6">content_paste</span>

+ 19 - 11
apps/app/src/client/components/PagePathNavSticky/PagePathNavSticky.tsx

@@ -84,22 +84,30 @@ export const PagePathNavSticky = (props: PagePathNavLayoutProps): JSX.Element =>
     <div ref={pagePathNavRef}>
       <Sticky className={`${moduleClass} mb-4`} innerClass="pe-none" innerActiveClass="active mt-1">
         {({ status }) => {
-          const isCollapseParents = status === Sticky.STATUS_FIXED;
-          return (
-            // Controlling pointer-events
-            //  2. enable pointer-events with 'pe-auto' only against the children
-            //      which width is minimized by 'd-inline-block'
-            //
-            <div className="d-inline-block pe-auto">
-              { !isCollapseParents && <PagePathNav {...props} /> }
-              { isCollapseParents && (
+          const isParentsCollapsed = status === Sticky.STATUS_FIXED;
+
+          // Controlling pointer-events
+          //  2. enable pointer-events with 'pe-auto' only against the children
+          //      which width is minimized by 'd-inline-block'
+          //
+          if (isParentsCollapsed) {
+            return (
+              <div className="d-inline-block pe-auto">
                 <PagePathNavLayout
                   {...props}
                   latterLink={latterLink}
                   latterLinkClassName="fs-3 text-truncate"
-                  maxWidth={isCollapseParents ? navMaxWidth : undefined}
+                  maxWidth={navMaxWidth}
                 />
-              ) }
+              </div>
+            );
+          }
+
+          return (
+            // Use 'd-block' to make the children take the full width
+            // This is to improve UX when opening/closing CopyDropdown
+            <div className="d-block pe-auto">
+              <PagePathNav {...props} inline />
             </div>
           );
         }}

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

@@ -43,6 +43,7 @@ export const TagsInput: FC<Props> = (props: Props) => {
       event.preventDefault();
 
       // fix: https://redmine.weseek.co.jp/issues/140689
+      // "event.isComposing" is not supported
       const isComposing = event.nativeEvent.isComposing;
       if (isComposing) {
         return;

+ 1 - 1
apps/app/src/client/components/SearchPage/SearchResultContent.tsx

@@ -216,7 +216,7 @@ export const SearchResultContent: FC<Props> = (props: Props) => {
       <RightComponent />
 
       <div className="container-lg grw-container-convertible pt-2 pb-2">
-        <PagePathNav pageId={page._id} pagePath={page.path} formerLinkClassName="small" latterLinkClassName="fs-3 text-truncate" />
+        <PagePathNav pageId={page._id} pagePath={page.path} isWipPage={page.wip} formerLinkClassName="small" latterLinkClassName="fs-3 text-truncate" />
       </div>
 
       <div

+ 5 - 0
apps/app/src/client/components/SearchTypeahead.tsx

@@ -148,6 +148,11 @@ const SearchTypeahead: ForwardRefRenderFunction<IFocusable, Props> = (props: Pro
 
   const keyDownHandler = useCallback((event: KeyboardEvent) => {
     if (event.key === 'Enter') {
+      // do nothing while composing
+      // "event.isComposing" is not supported
+      if (event.nativeEvent.isComposing) {
+        return;
+      }
       if (onSubmit != null && input != null && input.length > 0) {
         // schedule to submit with 100ms delay
         timeoutIdRef.current = setTimeout(() => onSubmit(input), DELAY_FOR_SUBMISSION);

+ 12 - 8
apps/app/src/components/Common/PagePathNav/PagePathNavLayout.tsx

@@ -9,8 +9,9 @@ import styles from './PagePathNav.module.scss';
 const moduleClass = styles['grw-page-path-nav-layout'] ?? '';
 
 export type PagePathNavLayoutProps = {
-  className?: string,
   pagePath: string,
+  inline?: boolean,
+  className?: string,
   pageId?: string | null,
   isWipPage?: boolean,
   maxWidth?: number,
@@ -28,6 +29,7 @@ const CopyDropdown = dynamic(() => import('~/client/components/Common/CopyDropdo
 export const PagePathNavLayout = (props: Props): JSX.Element => {
   const {
     className = '',
+    inline = false,
     pageId, pagePath, isWipPage,
     formerLink,
     formerLinkClassName = '',
@@ -38,7 +40,9 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
 
   const { data: isNotFound } = useIsNotFound();
 
-  const copyDropdownId = `copydropdown-${pageId}`;
+  const copyDropdownId = `copydropdown-in-pagepathnavlayout-${pageId}`;
+
+  const containerLayoutClass = inline ? '' : 'd-flex align-items-center';
 
   return (
     <div
@@ -46,21 +50,21 @@ export const PagePathNavLayout = (props: Props): JSX.Element => {
       style={{ maxWidth }}
     >
       <span className={`${formerLinkClassName ?? ''} ${styles['grw-former-link']}`}>{formerLink}</span>
-      <div className="d-flex align-items-center">
-        <h1 className={`m-0 ${latterLinkClassName}`}>
+      <div className={containerLayoutClass}>
+        <h1 className={`m-0 d-inline align-bottom ${latterLinkClassName}`}>
           {latterLink}
         </h1>
         { pageId != null && !isNotFound && (
-          <div className="d-flex align-items-center ms-2">
+          <span className="d-inline-flex align-items-center align-bottom ms-2 gap-2">
             { isWipPage && (
-              <span className="badge text-bg-secondary ms-1 me-1">WIP</span>
+              <span className="badge text-bg-secondary">WIP</span>
             )}
-            <span className=" grw-page-path-nav-copydropdown">
+            <span className="grw-page-path-nav-copydropdown">
               <CopyDropdown pageId={pageId} pagePath={pagePath} dropdownToggleId={copyDropdownId} dropdownToggleClassName="p-2">
                 <span className="material-symbols-outlined">content_paste</span>
               </CopyDropdown>
             </span>
-          </div>
+          </span>
         ) }
       </div>
     </div>

+ 1 - 1
apps/app/src/components/PageView/PageView.tsx

@@ -101,7 +101,7 @@ export const PageView = (props: Props): JSX.Element => {
     }
   }, [isForbidden, isIdenticalPathPage, isNotCreatable]);
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} />;
+  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
 
   const sideContents = !isNotFound && !isNotCreatable
     ? (

+ 1 - 1
apps/app/src/components/ShareLinkPageView/ShareLinkPageView.tsx

@@ -61,7 +61,7 @@ export const ShareLinkPageView = (props: Props): JSX.Element => {
     }
   }, [disableLinkSharing, props.disableLinkSharing]);
 
-  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} />;
+  const headerContents = <PagePathNavTitle pageId={page?._id} pagePath={pagePath} isWipPage={page?.wip} />;
 
   const sideContents = !isNotFound
     ? (

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

@@ -295,6 +295,10 @@ export const useFetchAndSetMessageDataEffect = (
   );
 
   useEffect(() => {
+    if (aiAssistantSidebarData?.isEditorAssistant) {
+      return;
+    }
+
     if (threadId == null) {
       setMessageLogs([]);
       return; // Early return if no threadId

+ 55 - 17
apps/app/src/features/openai/server/models/vector-store-file-relation.ts

@@ -7,6 +7,7 @@ import { getOrCreateModel } from '~/server/util/mongoose-utils';
 export interface VectorStoreFileRelation {
   vectorStoreRelationId: mongoose.Types.ObjectId;
   page: mongoose.Types.ObjectId;
+  attachment?: mongoose.Types.ObjectId;
   fileIds: string[];
   isAttachedToVectorStore: boolean;
 }
@@ -19,10 +20,21 @@ interface VectorStoreFileRelationModel extends Model<VectorStoreFileRelation> {
 }
 
 export const prepareVectorStoreFileRelations = (
-    vectorStoreRelationId: Types.ObjectId, page: Types.ObjectId, fileId: string, relationsMap: Map<string, VectorStoreFileRelation>,
+    vectorStoreRelationId: Types.ObjectId,
+    page: Types.ObjectId,
+    fileId: string,
+    relationsMap: Map<string, VectorStoreFileRelation>,
+    attachment?: Types.ObjectId,
 ): Map<string, VectorStoreFileRelation> => {
-  const pageIdStr = page.toHexString();
-  const existingData = relationsMap.get(pageIdStr);
+
+  const key = (() => {
+    if (attachment == null) {
+      return page.toHexString();
+    }
+    return page.toHexString() + attachment.toHexString();
+  })();
+
+  const existingData = relationsMap.get(key);
 
   // If the data exists, add the fileId to the fileIds array
   if (existingData != null) {
@@ -30,11 +42,12 @@ export const prepareVectorStoreFileRelations = (
   }
   // If the data doesn't exist, create a new one and add it to the map
   else {
-    relationsMap.set(pageIdStr, {
+    relationsMap.set(key, {
       vectorStoreRelationId,
       page,
       fileIds: [fileId],
       isAttachedToVectorStore: false,
+      attachment,
     });
   }
 
@@ -52,6 +65,10 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
     ref: 'Page',
     required: true,
   },
+  attachment: {
+    type: Schema.Types.ObjectId,
+    ref: 'Attachment',
+  },
   fileIds: [{
     type: String,
     required: true,
@@ -64,22 +81,43 @@ const schema = new Schema<VectorStoreFileRelationDocument, VectorStoreFileRelati
 });
 
 // define unique compound index
-schema.index({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
+schema.index({ vectorStoreRelationId: 1, page: 1, attachment: 1 }, { unique: true });
 
 schema.statics.upsertVectorStoreFileRelations = async function(vectorStoreFileRelations: VectorStoreFileRelation[]): Promise<void> {
-  await this.bulkWrite(
-    vectorStoreFileRelations.map((data) => {
-      return {
-        updateOne: {
-          filter: { page: data.page, vectorStoreRelationId: data.vectorStoreRelationId },
-          update: {
-            $addToSet: { fileIds: { $each: data.fileIds } },
-          },
-          upsert: true,
+  const upsertOps = vectorStoreFileRelations
+    .filter(data => data.attachment == null)
+    .map(data => ({
+      updateOne: {
+        filter: {
+          page: data.page,
+          vectorStoreRelationId: data.vectorStoreRelationId,
+          attachment: { $exists: false },
         },
-      };
-    }),
-  );
+        update: {
+          $addToSet: { fileIds: { $each: data.fileIds } },
+        },
+        upsert: true,
+      },
+    }));
+
+  const insertOps = vectorStoreFileRelations
+    .filter(data => data.attachment != null)
+    .map(data => ({
+      insertOne: {
+        document: {
+          vectorStoreRelationId: data.vectorStoreRelationId,
+          page: data.page,
+          attachment: data.attachment,
+          fileIds: data.fileIds,
+          isAttachedToVectorStore: data.isAttachedToVectorStore,
+        },
+      },
+    }));
+
+  const bulkOps = [...upsertOps, ...insertOps];
+  if (bulkOps.length > 0) {
+    await this.bulkWrite(bulkOps);
+  }
 };
 
 // Used when attached to VectorStore

+ 4 - 0
apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts

@@ -78,6 +78,10 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.files.create({ file, purpose: 'assistants' });
   }
 
+  async createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile> {
+    return this.client.vectorStores.files.create(vectorStoreId, { file_id: fileId });
+  }
+
   async createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch> {
     return this.client.vectorStores.fileBatches.create(vectorStoreId, { file_ids: fileIds });
   }

+ 1 - 0
apps/app/src/features/openai/server/services/client-delegator/interfaces.ts

@@ -13,6 +13,7 @@ export interface IOpenaiClientDelegator {
   createVectorStore(name: string): Promise<OpenAI.VectorStores.VectorStore>
   deleteVectorStore(vectorStoreId: string): Promise<OpenAI.VectorStores.VectorStoreDeleted>
   uploadFile(file: Uploadable): Promise<OpenAI.Files.FileObject>
+  createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch>
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
   chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>

+ 4 - 0
apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts

@@ -79,6 +79,10 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.files.create({ file, purpose: 'assistants' });
   }
 
+  async createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile> {
+    return this.client.vectorStores.files.create(vectorStoreId, { file_id: fileId });
+  }
+
   async createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch> {
     return this.client.vectorStores.fileBatches.create(vectorStoreId, { file_ids: fileIds });
   }

+ 221 - 23
apps/app/src/features/openai/server/services/openai.ts

@@ -1,5 +1,6 @@
+import fs from 'fs';
 import assert from 'node:assert';
-import { Readable, Transform } from 'stream';
+import { Readable, Transform, Writable } from 'stream';
 import { pipeline } from 'stream/promises';
 
 import type {
@@ -22,6 +23,8 @@ import VectorStoreFileRelationModel, {
   type VectorStoreFileRelation,
   prepareVectorStoreFileRelations,
 } from '~/features/openai/server/models/vector-store-file-relation';
+import type Crowi from '~/server/crowi';
+import type { IAttachmentDocument, IAttachmentModel } from '~/server/models/attachment';
 import type { PageDocument, PageModel } from '~/server/models/page';
 import UserGroupRelation from '~/server/models/user-group-relation';
 import { configManager } from '~/server/service/config-manager';
@@ -40,6 +43,7 @@ import { removeGlobPath } from '../../utils/remove-glob-path';
 import AiAssistantModel, { type AiAssistantDocument } from '../models/ai-assistant';
 import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 import { generateGlobPatterns } from '../utils/generate-glob-patterns';
+import { isVectorStoreCompatible } from '../utils/is-vector-store-compatible';
 
 import { getClient } from './client-delegator';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
@@ -75,10 +79,8 @@ export interface IOpenaiService {
   deleteObsoletedVectorStoreRelations(): Promise<void> // for CronJob
   deleteVectorStore(vectorStoreRelationId: string): Promise<void>;
   getMessageData(threadId: string, lang?: Lang, options?: MessageListParams): Promise<OpenAI.Beta.Threads.Messages.MessagesPage>;
-  createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: PageDocument[]): Promise<void>;
   createVectorStoreFileOnPageCreate(pages: PageDocument[]): Promise<void>;
   updateVectorStoreFileOnPageUpdate(page: HydratedDocument<PageDocument>): Promise<void>;
-  deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId): Promise<void>;
   deleteVectorStoreFilesByPageIds(pageIds: Types.ObjectId[]): Promise<void>;
   deleteObsoleteVectorStoreFile(limit: number, apiCallInterval: number): Promise<void>; // for CronJob
   isAiAssistantUsable(aiAssistantId: string, user: IUserHasId): Promise<boolean>;
@@ -89,12 +91,24 @@ export interface IOpenaiService {
 }
 class OpenaiService implements IOpenaiService {
 
+  private crowi: Crowi;
+
+  constructor(crowi: Crowi) {
+    this.crowi = crowi;
+
+    this.createVectorStoreFileOnUploadAttachment = this.createVectorStoreFileOnUploadAttachment.bind(this);
+    crowi.attachmentService.addAttachHandler(this.createVectorStoreFileOnUploadAttachment);
+
+    this.deleteVectorStoreFileOnDeleteAttachment = this.deleteVectorStoreFileOnDeleteAttachment.bind(this);
+    crowi.attachmentService.addDetachHandler(this.deleteVectorStoreFileOnDeleteAttachment);
+  }
+
   private get client() {
     const openaiServiceType = configManager.getConfig('openai:serviceType');
     return getClient({ openaiServiceType });
   }
 
-  async generateThreadTitle(message: string): Promise<string | null> {
+  private async generateThreadTitle(message: string): Promise<string | null> {
     const systemMessage = [
       'Create a brief title (max 5 words) from your message.',
       'Respond in the same language the user uses in their input.',
@@ -155,7 +169,7 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  async updateThreads(aiAssistantId: string, vectorStoreId: string): Promise<void> {
+  private async updateThreads(aiAssistantId: string, vectorStoreId: string): Promise<void> {
     const threadRelations = await this.getThreadsByAiAssistantId(aiAssistantId);
     for await (const threadRelation of threadRelations) {
       try {
@@ -231,7 +245,7 @@ class OpenaiService implements IOpenaiService {
   }
 
 
-  async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
+  private async getVectorStoreRelationsByPageIds(pageIds: Types.ObjectId[]): Promise<VectorStoreDocument[]> {
     const pipeline = [
       // Stage 1: Match documents with the given pageId
       {
@@ -309,6 +323,28 @@ class OpenaiService implements IOpenaiService {
     return uploadedFile;
   }
 
+  private async uploadFileForAttachment(fileName: string, readStream?: NodeJS.ReadableStream, filePath?: string): Promise<OpenAI.Files.FileObject> {
+    const streamSource: NodeJS.ReadableStream = (() => {
+      if (readStream != null) {
+        return readStream;
+      }
+
+      if (filePath != null) {
+        return fs.createReadStream(filePath);
+      }
+
+      throw new Error('readStream and filePath are both null');
+    })();
+
+    const uploadableFile = await toFile(
+      streamSource,
+      fileName,
+    );
+
+    const uploadedFile = await this.client.uploadFile(uploadableFile);
+    return uploadedFile;
+  }
+
   async deleteVectorStore(vectorStoreRelationId: string): Promise<void> {
     const vectorStoreDocument: VectorStoreDocument | null = await VectorStoreModel.findOne({ _id: vectorStoreRelationId, isDeleted: false });
     if (vectorStoreDocument == null) {
@@ -326,14 +362,56 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  async createVectorStoreFile(vectorStoreRelation: VectorStoreDocument, pages: Array<HydratedDocument<PageDocument>>): Promise<void> {
-    // const vectorStore = await this.getOrCreateVectorStoreForPublicScope();
+  private async createVectorStoreFileWithStreamForAttachment(
+      pageId: Types.ObjectId, vectorStoreRelationId: Types.ObjectId, vectorStoreFileRelationsMap: VectorStoreFileRelationsMap,
+  ): Promise<void> {
+
+    const Attachment = mongoose.model<HydratedDocument<IAttachmentDocument>, IAttachmentModel>('Attachment');
+    const attachmentsCursor = Attachment.find({ page: pageId }).cursor();
+    const batchStream = createBatchStream(BATCH_SIZE);
+
+    const uploadFileStreamForAttachment = new Writable({
+      objectMode: true,
+      write: async(attachments: HydratedDocument<IAttachmentDocument>[], _encoding, callback) => {
+        for await (const attachment of attachments) {
+          try {
+            if (!isVectorStoreCompatible(attachment.originalName, attachment.fileFormat)) {
+              continue;
+            }
+            const readStream = await this.crowi.fileUploadService.findDeliveryFile(attachment);
+            const uploadedFileForAttachment = await this.uploadFileForAttachment(attachment.originalName, readStream);
+            prepareVectorStoreFileRelations(
+              vectorStoreRelationId, pageId, uploadedFileForAttachment.id, vectorStoreFileRelationsMap, attachment._id,
+            );
+          }
+          catch (err) {
+            logger.error(err);
+          }
+        }
+        callback();
+      },
+      final: (callback) => {
+        logger.debug('Finished uploading attachments');
+        callback();
+      },
+    });
+
+    await pipeline(attachmentsCursor, batchStream, uploadFileStreamForAttachment);
+  }
+
+  private async createVectorStoreFile(
+      vectorStoreRelation: VectorStoreDocument, pages: Array<HydratedDocument<PageDocument>>, ignoreAttachments = false,
+  ): Promise<void> {
     const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
     const processUploadFile = async(page: HydratedDocument<PageDocument>) => {
       if (page._id != null && page.revision != null) {
         if (isPopulated(page.revision) && page.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(page.revision.body, page);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
+
+          if (!ignoreAttachments) {
+            await this.createVectorStoreFileWithStreamForAttachment(page._id, vectorStoreRelation._id, vectorStoreFileRelationsMap);
+          }
           return;
         }
 
@@ -341,6 +419,10 @@ class OpenaiService implements IOpenaiService {
         if (pagePopulatedToShowRevision.revision != null && pagePopulatedToShowRevision.revision.body.length > 0) {
           const uploadedFile = await this.uploadFile(pagePopulatedToShowRevision.revision.body, page);
           prepareVectorStoreFileRelations(vectorStoreRelation._id, page._id, uploadedFile.id, vectorStoreFileRelationsMap);
+
+          if (!ignoreAttachments) {
+            await this.createVectorStoreFileWithStreamForAttachment(page._id, vectorStoreRelation._id, vectorStoreFileRelationsMap);
+          }
         }
       }
     };
@@ -413,7 +495,57 @@ class OpenaiService implements IOpenaiService {
     await VectorStoreModel.deleteMany({ _id: { $nin: currentVectorStoreRelationIds }, isDeleted: true });
   }
 
-  async deleteVectorStoreFile(vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, apiCallInterval?: number): Promise<void> {
+  private async deleteVectorStoreFileForAttachment(vectorStoreFileRelation: VectorStoreFileRelation): Promise<void> {
+    if (vectorStoreFileRelation.attachment == null) {
+      return;
+    }
+
+    const deleteAllAttachmentVectorStoreFileRelations = async() => {
+      await VectorStoreFileRelationModel.deleteMany({ attachment: vectorStoreFileRelation.attachment });
+    };
+
+    try {
+      // Delete entities in VectorStoreFile
+      const fileId = vectorStoreFileRelation.fileIds[0];
+      const deleteFileResponse = await this.client.deleteFile(fileId);
+      logger.debug('Delete vector store file (attachment) ', deleteFileResponse);
+
+      // Delete related VectorStoreFileRelation document
+      const attachmentId = vectorStoreFileRelation.attachment;
+      if (attachmentId != null) {
+        await deleteAllAttachmentVectorStoreFileRelations();
+      }
+    }
+    catch (err) {
+      logger.error(err);
+      await openaiApiErrorHandler(err, {
+        notFoundError: () => deleteAllAttachmentVectorStoreFileRelations(),
+      });
+    }
+  }
+
+  private async deleteVectorStoreFile(
+      vectorStoreRelationId: Types.ObjectId, pageId: Types.ObjectId, ignoreAttachments = false, apiCallInterval?: number,
+  ): Promise<void> {
+
+    if (!ignoreAttachments) {
+      // Get all VectorStoreFIleDocument (attachments) associated with the page
+      const vectorStoreFileRelationsForAttachment = await VectorStoreFileRelationModel.find({
+        vectorStoreRelationId, page: pageId, attachment: { $exists: true },
+      });
+
+      if (vectorStoreFileRelationsForAttachment.length !== 0) {
+        for await (const vectorStoreFileRelation of vectorStoreFileRelationsForAttachment) {
+          try {
+            await this.deleteVectorStoreFileForAttachment(vectorStoreFileRelation);
+          }
+          catch (err) {
+            logger.error(err);
+          }
+        }
+      }
+    }
+
     // Delete vector store file and delete vector store file relation
     const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ vectorStoreRelationId, page: pageId });
     if (vectorStoreFileRelation == null) {
@@ -476,7 +608,7 @@ class OpenaiService implements IOpenaiService {
     // Delete obsolete VectorStoreFile
     for await (const vectorStoreFileRelation of obsoleteVectorStoreFileRelations) {
       try {
-        await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.page, apiCallInterval);
+        await this.deleteVectorStoreFile(vectorStoreFileRelation.vectorStoreRelationId, vectorStoreFileRelation.page, false, apiCallInterval);
       }
       catch (err) {
         logger.error(err);
@@ -484,10 +616,25 @@ class OpenaiService implements IOpenaiService {
     }
   }
 
-  async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
-    const isPublicPage = (page :HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
+  private async deleteVectorStoreFileOnDeleteAttachment(attachmentId: string) {
+    const vectorStoreFileRelation = await VectorStoreFileRelationModel.findOne({ attachment: attachmentId });
+    if (vectorStoreFileRelation == null) {
+      return;
+    }
+
+    try {
+      await this.deleteVectorStoreFileForAttachment(vectorStoreFileRelation);
+    }
+    catch (err) {
+      logger.error(err);
+    }
+  }
+
+
+  private async filterPagesByAccessScope(aiAssistant: AiAssistantDocument, pages: HydratedDocument<PageDocument>[]) {
+    const isPublicPage = (page: HydratedDocument<PageDocument>) => page.grant === PageGrant.GRANT_PUBLIC;
 
-    const isUserGroupAccessible = (page :HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
+    const isUserGroupAccessible = (page: HydratedDocument<PageDocument>, ownerUserGroupIds: string[]) => {
       if (page.grant !== PageGrant.GRANT_USER_GROUP) return false;
       return page.grantedGroups.some(group => ownerUserGroupIds.includes(getIdStringForRef(group.item)));
     };
@@ -576,8 +723,58 @@ class OpenaiService implements IOpenaiService {
       logger.debug('-----------------------------------------------------');
 
       // Do not create a new VectorStoreFile if page is changed to a permission that AiAssistant does not have access to
-      await this.createVectorStoreFile(vectorStoreRelation as VectorStoreDocument, pagesToVectorize);
-      await this.deleteVectorStoreFile((vectorStoreRelation as VectorStoreDocument)._id, page._id);
+      await this.deleteVectorStoreFile(
+        (vectorStoreRelation as VectorStoreDocument)._id,
+        page._id,
+        true, // ignoreAttachments = true
+      );
+      await this.createVectorStoreFile(
+        vectorStoreRelation as VectorStoreDocument,
+        pagesToVectorize,
+        true, // ignoreAttachments = true
+      );
+    }
+  }
+
+  private async createVectorStoreFileOnUploadAttachment(
+      pageId: string, attachment: HydratedDocument<IAttachmentDocument>, file: Express.Multer.File,
+  ): Promise<void> {
+    if (!isVectorStoreCompatible(file.originalname, file.mimetype)) {
+      return;
+    }
+
+    const Page = mongoose.model<HydratedDocument<PageDocument>, PageModel>('Page');
+    const page = await Page.findById(pageId);
+    if (page == null) {
+      return;
+    }
+
+    const aiAssistants = await this.findAiAssistantByPagePath([page.path], { shouldPopulateVectorStore: true });
+    if (aiAssistants.length === 0) {
+      return;
+    }
+
+    const uploadedFile = await this.uploadFileForAttachment(file.originalname, undefined, file.path);
+    logger.debug('Uploaded file', uploadedFile);
+
+    for await (const aiAssistant of aiAssistants) {
+      const pagesToVectorize = await this.filterPagesByAccessScope(aiAssistant, [page]);
+      if (pagesToVectorize.length === 0) {
+        continue;
+      }
+
+      const vectorStoreRelation = aiAssistant.vectorStore;
+      if (vectorStoreRelation == null || !isPopulated(vectorStoreRelation)) {
+        continue;
+      }
+
+      await this.client.createVectorStoreFile(vectorStoreRelation.vectorStoreId, uploadedFile.id);
+
+      const vectorStoreFileRelationsMap: VectorStoreFileRelationsMap = new Map();
+      prepareVectorStoreFileRelations(vectorStoreRelation._id as Types.ObjectId, page._id, uploadedFile.id, vectorStoreFileRelationsMap, attachment._id);
+      const vectorStoreFileRelations = Array.from(vectorStoreFileRelationsMap.values());
+
+      await VectorStoreFileRelationModel.upsertVectorStoreFileRelations(vectorStoreFileRelations);
     }
   }
 
@@ -594,7 +791,7 @@ class OpenaiService implements IOpenaiService {
       objectMode: true,
       async transform(chunk: HydratedDocument<PageDocument>[], encoding, callback) {
         try {
-          logger.debug('Search results of page paths', chunk.map(page => page.path));
+          logger.debug('Target page path for VectorStoreFile generation: ', chunk.map(page => page.path));
           await createVectorStoreFile(vectorStoreRelation, chunk);
           this.push(chunk);
           callback();
@@ -905,7 +1102,7 @@ class OpenaiService implements IOpenaiService {
     return totalPageCount > limitLearnablePageCountPerAssistant;
   }
 
-  async findAiAssistantByPagePath(
+  private async findAiAssistantByPagePath(
       pagePaths: string[], options?: { shouldPopulateOwner?: boolean, shouldPopulateVectorStore?: boolean },
   ): Promise<AiAssistantDocument[]> {
 
@@ -935,15 +1132,16 @@ class OpenaiService implements IOpenaiService {
 }
 
 let instance: OpenaiService;
-export const getOpenaiService = (): IOpenaiService | undefined => {
-  if (instance != null) {
-    return instance;
-  }
-
+export const initializeOpenaiService = (crowi: Crowi): void => {
   const aiEnabled = configManager.getConfig('app:aiEnabled');
   const openaiServiceType = configManager.getConfig('openai:serviceType');
   if (aiEnabled && openaiServiceType != null && OpenaiServiceTypes.includes(openaiServiceType)) {
-    instance = new OpenaiService();
+    instance = new OpenaiService(crowi);
+  }
+};
+
+export const getOpenaiService = (): IOpenaiService | undefined => {
+  if (instance != null) {
     return instance;
   }
 

+ 46 - 0
apps/app/src/features/openai/server/utils/is-vector-store-compatible.ts

@@ -0,0 +1,46 @@
+import path from 'path';
+
+// See: https://platform.openai.com/docs/assistants/tools/file-search#supported-files
+const supportedFormats = {
+  '.c': 'text/x-c',
+  '.cpp': 'text/x-c++',
+  '.cs': 'text/x-csharp',
+  '.css': 'text/css',
+  '.doc': 'application/msword',
+  '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
+  '.go': 'text/x-golang',
+  '.html': 'text/html',
+  '.java': 'text/x-java',
+  '.js': 'text/javascript',
+  '.json': 'application/json',
+  '.md': 'text/markdown',
+  '.pdf': 'application/pdf',
+  '.php': 'text/x-php',
+  '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
+  '.py': ['text/x-python', 'text/x-script.python'],
+  '.rb': 'text/x-ruby',
+  '.sh': 'application/x-sh',
+  '.tex': 'text/x-tex',
+  '.ts': 'application/typescript',
+  '.txt': 'text/plain',
+} as const;
+
+type SupportedExtension = keyof typeof supportedFormats;
+
+export const isVectorStoreCompatible = (originalName: string, mimeType: string): boolean => {
+  // Get extension
+  const extension = path.extname(originalName).toLowerCase();
+
+  // Check if the file extension is supported
+  if (!(extension in supportedFormats)) {
+    return false;
+  }
+
+  // Get Mime Type (At this point, file extension is confirmed to be supported, so type-safe access is possible)
+  const supportedMimeType = supportedFormats[extension as SupportedExtension];
+
+  // Check if the mimeType is supported
+  return Array.isArray(supportedMimeType)
+    ? supportedMimeType.includes(mimeType)
+    : supportedMimeType === mimeType;
+};

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

@@ -62,7 +62,6 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
   compressExtension = 'tar.gz';
 
   // temporal path of local fs to output page files before upload
-  // TODO: If necessary, change to a proper path in https://redmine.weseek.co.jp/issues/149512
   tmpOutputRootDir = '/tmp/page-bulk-export';
 
   // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
@@ -104,10 +103,18 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
    * @param isHtmlPath whether the tmp output path is for html files
    */
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
-    if (isHtmlPath) {
-      return path.join(this.tmpOutputRootDir, 'html', pageBulkExportJob._id.toString());
+    const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
+    const appId = configManager.getConfig('app:growiAppIdForCloud')?.toString();
+    const jobId = pageBulkExportJob._id.toString();
+
+    if (isGrowiCloud) {
+      if (appId == null) {
+        throw new Error('appId is required for bulk export on GROWI.cloud');
+      }
     }
-    return path.join(this.tmpOutputRootDir, pageBulkExportJob._id.toString());
+
+    const basePath = isHtmlPath ? path.join(this.tmpOutputRootDir, 'html') : this.tmpOutputRootDir;
+    return path.join(basePath, appId ?? '', jobId);
   }
 
   /**
@@ -242,13 +249,12 @@ class PageBulkExportJobCronService extends CronService implements IPageBulkExpor
 
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
-      // delete /tmp/page-bulk-export/{jobId} dir
       fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
     ];
 
+    // clean up html files exported for PDF conversion
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
       promises.push(
-        // delete /tmp/page-bulk-export/html/{jobId} dir
         fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
       );
     }

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

@@ -18,6 +18,12 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     throw new Error('createdAt is not set');
   }
 
+  const isGrowiCloud = configManager.getConfig('app:growiCloudUri') != null;
+  const appId = configManager.getConfig('app:growiAppIdForCloud');
+  if (isGrowiCloud && (appId == null)) {
+    throw new Error('appId is required for bulk export on GROWI.cloud');
+  }
+
   const exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
@@ -41,7 +47,10 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     }
 
     const res = await pdfCtrlSyncJobStatus({
-      jobId: pageBulkExportJob._id.toString(), expirationDate: bulkExportJobExpirationDate.toISOString(), status: pdfConvertStatus,
+      appId: appId?.toString(),
+      jobId: pageBulkExportJob._id.toString(),
+      expirationDate: bulkExportJobExpirationDate.toISOString(),
+      status: pdfConvertStatus,
     }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
 
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {

+ 12 - 6
apps/app/src/features/questionnaire/server/routes/apiv3/questionnaire.ts

@@ -68,7 +68,8 @@ module.exports = (crowi: Crowi): Router => {
    *   get:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/orders
    *     description: Get questionnaire orders
    *     responses:
@@ -106,7 +107,8 @@ module.exports = (crowi: Crowi): Router => {
    *   get:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/is-enabled
    *     description: Get questionnaire is enabled
    *     responses:
@@ -132,7 +134,8 @@ module.exports = (crowi: Crowi): Router => {
    *   post:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/proactive/answer
    *     description: Post proactive questionnaire answer
    *     requestBody:
@@ -205,7 +208,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/answer
    *     description: Post questionnaire answer
    *     requestBody:
@@ -284,7 +288,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/skip
    *     description: Skip questionnaire
    *     requestBody:
@@ -332,7 +337,8 @@ module.exports = (crowi: Crowi): Router => {
    *   put:
    *     tags: [Questionnaire]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /questionnaire/deny
    *     description: Deny questionnaire
    *     requestBody:

+ 47 - 0
apps/app/src/migrations/20250522105040-delete-old-index-for-vector-store-file-relation.js

@@ -0,0 +1,47 @@
+import mongoose from 'mongoose';
+
+import { getMongoUri, mongoOptions } from '~/server/util/mongoose-utils';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:migrate:vector-store-file-relation-index-migration');
+
+async function dropIndexIfExists(db, collectionName, indexName) {
+  // check existence of the collection
+  const items = await db.listCollections({ name: collectionName }, { nameOnly: true }).toArray();
+  if (items.length === 0) {
+    return;
+  }
+
+  const collection = await db.collection(collectionName);
+  if (await collection.indexExists(indexName)) {
+    await collection.dropIndex(indexName);
+  }
+}
+
+module.exports = {
+  async up(db) {
+    logger.info('Apply migration');
+
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    // Drop old index
+    await dropIndexIfExists(db, 'vectorstorefilerelations', 'vectorStoreRelationId_1_page_1');
+
+    // Create index
+    const collection = mongoose.connection.collection('vectorstorefilerelations');
+    await collection.createIndex({ vectorStoreRelationId: 1, page: 1, attachment: 1 }, { unique: true });
+
+  },
+  async down(db) {
+    logger.info('Rollback migration');
+
+    await mongoose.connect(getMongoUri(), mongoOptions);
+
+    // Drop new index
+    await dropIndexIfExists(db, 'vectorstorefilerelations', 'vectorStoreRelationId_1_page_1_attachment_1');
+
+    // Recreate old index
+    const collection = mongoose.connection.collection('vectorstorefilerelations');
+    await collection.createIndex({ vectorStoreRelationId: 1, page: 1 }, { unique: true });
+  },
+};

+ 10 - 1
apps/app/src/server/crowi/index.js

@@ -11,6 +11,7 @@ import next from 'next';
 import { KeycloakUserGroupSyncService } from '~/features/external-user-group/server/service/keycloak-user-group-sync';
 import { LdapUserGroupSyncService } from '~/features/external-user-group/server/service/ldap-user-group-sync';
 import { startCronIfEnabled as startOpenaiCronIfEnabled } from '~/features/openai/server/services/cron';
+import { initializeOpenaiService } from '~/features/openai/server/services/openai';
 import { checkPageBulkExportJobInProgressCronService } from '~/features/page-bulk-export/server/service/check-page-bulk-export-job-in-progress-cron';
 import instanciatePageBulkExportJobCleanUpCronService, {
   pageBulkExportJobCleanUpCronService,
@@ -177,7 +178,6 @@ Crowi.prototype.init = async function() {
   this.models = await setupModelsDependentOnCrowi(this);
   await this.setupConfigManager();
   await this.setupSessionConfig();
-  this.setupCron();
 
   // setup messaging services
   await this.setupS2sMessagingService();
@@ -223,8 +223,13 @@ Crowi.prototype.init = async function() {
     // depends on passport service
     this.setupExternalAccountService(),
     this.setupExternalUserGroupSyncService(),
+
+    // depends on AttachmentService
+    this.setupOpenaiService(),
   ]);
 
+  this.setupCron();
+
   await normalizeData();
 };
 
@@ -809,4 +814,8 @@ Crowi.prototype.setupExternalUserGroupSyncService = function() {
   this.keycloakUserGroupSyncService = new KeycloakUserGroupSyncService(this.s2sMessagingService, this.socketIoService);
 };
 
+Crowi.prototype.setupOpenaiService = function() {
+  initializeOpenaiService(this);
+};
+
 export default Crowi;

+ 59 - 0
apps/app/src/server/middlewares/access-token-parser/access-token-parser.integ.ts

@@ -132,4 +132,63 @@ describe('access-token-parser middleware', () => {
     expect(nextMock).toHaveBeenCalled();
   });
 
+  it('should set req.user with a valid Bearer token in Authorization header', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+      headers: {
+        authorization: undefined,
+      },
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // prepare a user with an access token
+    const targetUser = await User.create({
+      name: faker.person.fullName(),
+      username: faker.string.uuid(),
+      password: faker.internet.password(),
+      lang: 'en_US',
+      apiToken: faker.internet.password(),
+    });
+
+    // act
+    reqMock.headers.authorization = `Bearer ${targetUser.apiToken}`;
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeDefined();
+    expect(reqMock.user?._id).toStrictEqual(targetUser._id);
+    expect(serializeUserSecurely).toHaveBeenCalledOnce();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
+  it('should ignore non-Bearer Authorization header', async() => {
+    // arrange
+    const reqMock = mock<AccessTokenParserReq>({
+      user: undefined,
+      headers: {
+        authorization: undefined,
+      },
+    });
+    const resMock = mock<Response>();
+    const nextMock = vi.fn();
+
+    expect(reqMock.user).toBeUndefined();
+
+    // Generate random string that is guaranteed to be invalid for Basic auth (1024 chars)
+    const randomString = faker.string.alpha(1024);
+
+    // act
+    reqMock.headers.authorization = `Basic ${randomString}`; // Basic auth header with random string
+    await accessTokenParser(reqMock, resMock, nextMock);
+
+    // assert
+    expect(reqMock.user).toBeUndefined();
+    expect(serializeUserSecurely).not.toHaveBeenCalled();
+    expect(nextMock).toHaveBeenCalled();
+  });
+
 });

+ 18 - 2
apps/app/src/server/middlewares/access-token-parser/access-token-parser.ts

@@ -11,9 +11,25 @@ import type { AccessTokenParserReq } from './interfaces';
 const logger = loggerFactory('growi:middleware:access-token-parser');
 
 
+const extractBearerToken = (authHeader: string | undefined): string | null => {
+  if (authHeader == null) {
+    return null;
+  }
+
+  if (!authHeader.startsWith('Bearer ')) {
+    return null;
+  }
+
+  return authHeader.substring(7); // Remove 'Bearer ' prefix
+};
+
 export const accessTokenParser = async(req: AccessTokenParserReq, res: Response, next: NextFunction): Promise<void> => {
-  // TODO: comply HTTP header of RFC6750 / Authorization: Bearer
-  const accessToken = req.query.access_token ?? req.body.access_token;
+  // Extract token from Authorization header first
+  const bearerToken = extractBearerToken(req.headers.authorization);
+
+  // Try all possible token sources in order of priority
+  const accessToken = bearerToken ?? req.query.access_token ?? req.body.access_token;
+
   if (accessToken == null || typeof accessToken !== 'string') {
     return next();
   }

+ 2 - 1
apps/app/src/server/routes/apiv3/activity.ts

@@ -185,7 +185,8 @@ module.exports = (crowi: Crowi): Router => {
    *     summary: /activity
    *     tags: [Activity]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     parameters:
    *       - name: limit
    *         in: query

+ 6 - 3
apps/app/src/server/routes/apiv3/app-settings.js

@@ -420,7 +420,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        operationId: getAppSettings
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: /app-settings
    *        description: get app setting params
    *        responses:
@@ -1065,7 +1066,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        operationId: updateAppSettingV5SchemaMigration
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: AccessToken supported.
    *        description: Update V5SchemaMigration
    *        responses:
@@ -1110,7 +1112,8 @@ module.exports = (crowi) => {
    *        tags: [AppSettings]
    *        operationId: updateAppSettingMaintenanceMode
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: AccessToken supported.
    *        description: Update MaintenanceMode
    *        requestBody:

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

@@ -339,8 +339,9 @@ module.exports = (crowi) => {
    *          500:
    *            $ref: '#/components/responses/500'
    */
-  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, uploads.single('file'), autoReap,
+  router.post('/', accessTokenParser, loginRequiredStrictly, excludeReadOnlyUser, uploads.single('file'),
     validator.retrieveAddAttachment, apiV3FormValidator, addActivity,
+    // Removed autoReap middleware to use file data in asynchronous processes. Instead, implemented file deletion after asynchronous processes complete
     async(req, res) => {
 
       const pageId = req.body.page_id;
@@ -360,7 +361,7 @@ module.exports = (crowi) => {
           return res.apiv3Err(`Forbidden to access to the page '${page.id}'`);
         }
 
-        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE);
+        const attachment = await attachmentService.createAttachment(file, req.user, pageId, AttachmentType.WIKI_PAGE, () => autoReap(req, res, () => {}));
 
         const result = {
           page: serializePageSecurely(page),

+ 12 - 6
apps/app/src/server/routes/apiv3/bookmark-folder.ts

@@ -130,7 +130,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: createBookmarkFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Create bookmark folder
    *        description: Create a new bookmark folder
    *        requestBody:
@@ -185,7 +186,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: listBookmarkFolders
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: List bookmark folders of a user
    *        description: List bookmark folders of a user
    *        parameters:
@@ -274,7 +276,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: deleteBookmarkFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Delete bookmark folder
    *        description: Delete a bookmark folder and its children
    *        parameters:
@@ -317,7 +320,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: updateBookmarkFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -374,7 +378,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: addBookmarkToFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark folder
    *        description: Update a bookmark folder
    *        requestBody:
@@ -424,7 +429,8 @@ module.exports = (crowi) => {
    *        tags: [BookmarkFolders]
    *        operationId: updateBookmarkInFolder
    *        security:
-   *          - api_key: []
+   *          - bearer: []
+   *          - accessTokenInQuery: []
    *        summary: Update bookmark in folder
    *        description: Update a bookmark in a folder
    *        requestBody:

+ 4 - 2
apps/app/src/server/routes/apiv3/g2g-transfer.ts

@@ -441,7 +441,8 @@ module.exports = (crowi: Crowi): Router => {
    *      summary: /g2g-transfer/generate-key
    *      tags: [GROWI to GROWI Transfer]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      requestBody:
    *        required: true
    *        content:
@@ -498,7 +499,8 @@ module.exports = (crowi: Crowi): Router => {
    *      summary: /g2g-transfer/transfer
    *      tags: [GROWI to GROWI Transfer]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      requestBody:
    *        required: true
    *        content:

+ 10 - 5
apps/app/src/server/routes/apiv3/import.js

@@ -169,7 +169,8 @@ export default function route(crowi) {
    *    get:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getImportSettingsParams
    *      summary: /import
    *      description: Get import settings params
@@ -221,7 +222,8 @@ export default function route(crowi) {
    *    get:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getImportStatus
    *      summary: /import/status
    *      description: Get properties of stored zip files for import
@@ -252,7 +254,8 @@ export default function route(crowi) {
    *    post:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: executeImport
    *      summary: /import
    *      description: import a collection from a zipped json
@@ -384,7 +387,8 @@ export default function route(crowi) {
    *    post:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: uploadImport
    *      summary: /import/upload
    *      description: upload a zip file
@@ -440,7 +444,8 @@ export default function route(crowi) {
    *    delete:
    *      tags: [Import]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: deleteImportAll
    *      summary: /import/all
    *      description: Delete all zip files

+ 8 - 4
apps/app/src/server/routes/apiv3/in-app-notification.ts

@@ -103,7 +103,8 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getInAppNotificationList
    *      summary: /in-app-notification/list
    *      description: Get the list of in-app notifications
@@ -193,7 +194,8 @@ module.exports = (crowi) => {
    *    get:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: getInAppNotificationStatus
    *      summary: /in-app-notification/status
    *      description: Get the status of in-app notifications
@@ -229,7 +231,8 @@ module.exports = (crowi) => {
    *    post:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: openInAppNotification
    *      summary: /in-app-notification/open
    *      description: Open the in-app notification
@@ -275,7 +278,8 @@ module.exports = (crowi) => {
    *    put:
    *      tags: [NotificationSetting]
    *      security:
-   *        - api_key: []
+   *        - bearer: []
+   *        - accessTokenInQuery: []
    *      operationId: openAllInAppNotification
    *      summary: /in-app-notification/all-statuses-open
    *      description: Open all in-app notifications

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

@@ -72,7 +72,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/root
    *     description: Get the root page
    *     responses:
@@ -107,7 +108,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/ancestors-children
    *     description: Get the ancestors and children of a page
    *     parameters:
@@ -172,7 +174,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/children
    *     description: Get the children of a page
    *     parameters:
@@ -211,7 +214,7 @@ const routerFactory = (crowi: Crowi): Router => {
 
     try {
       const pages = await pageService.findChildrenByParentPathOrIdAndViewer(
-        (id || path)as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
+        (id || path) as string, req.user, undefined, !hideRestrictedByOwner, !hideRestrictedByGroup,
       );
       return res.apiv3({ children: pages });
     }
@@ -228,7 +231,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *   get:
    *     tags: [PageListing]
    *     security:
-   *       - api_key: []
+   *       - bearer: []
+   *       - accessTokenInQuery: []
    *     summary: /page-listing/info
    *     description: Get the information of a page
    *     parameters:

+ 19 - 9
apps/app/src/server/routes/apiv3/slack-integration.js

@@ -5,25 +5,25 @@ import { InvalidGrowiCommandError } from '@growi/slack/dist/models';
 import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder';
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import { generateRespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
+import { isValidResponseUrl } from '@growi/slack/dist/utils/response-url-validator';
 import { parseSlashCommand } from '@growi/slack/dist/utils/slash-command-parser';
+import express from 'express';
+import { body } from 'express-validator';
 import createError from 'http-errors';
+import mongoose from 'mongoose';
 
 import { SlackCommandHandlerError } from '~/server/models/vo/slack-command-handler-error';
 import { configManager } from '~/server/service/config-manager';
 import { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 
-
-const express = require('express');
-const { body } = require('express-validator');
-const mongoose = require('mongoose');
+import { handleError } from '../../service/slack-command-handler/error-handler';
+import { checkPermission } from '../../util/slack-integration';
 
 
 const logger = loggerFactory('growi:routes:apiv3:slack-integration');
 const router = express.Router();
 const SlackAppIntegration = mongoose.model('SlackAppIntegration');
-const { handleError } = require('../../service/slack-command-handler/error-handler');
-const { checkPermission } = require('../../util/slack-integration');
 
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
@@ -221,7 +221,7 @@ module.exports = (crowi) => {
       throw SlackCommandHandlerError('App site url must exist.');
     }
 
-    return generateRespondUtil(responseUrl, proxyUri, appSiteUrl);
+    return generateRespondUtil({ responseUrl, proxyUri, appSiteUrl });
   }
 
   function getGrowiCommand(body) {
@@ -286,6 +286,7 @@ module.exports = (crowi) => {
       await slackIntegrationService.handleCommandRequest(growiCommand, client, body, respondUtil);
     }
     catch (err) {
+      logger.error(err.message);
       return handleError(err, responseUrl);
     }
 
@@ -294,10 +295,18 @@ module.exports = (crowi) => {
   // TODO: this method will be a middleware when typescriptize in the future
   function getResponseUrl(req) {
     const { body } = req;
-    const responseUrl = body?.growiCommand?.responseUrl;
+    const responseUrl = body?.growiCommand?.responseUrl ?? body.response_url;
+
     if (responseUrl == null) {
-      return body.response_url; // may be null
+      return null;
+    }
+
+    const proxyUri = slackIntegrationService.proxyUriForCurrentType;
+
+    if (!isValidResponseUrl(responseUrl, proxyUri)) {
+      throw createError(400, 'Invalid response_url');
     }
+
     return responseUrl;
   }
 
@@ -421,6 +430,7 @@ module.exports = (crowi) => {
       client = await slackIntegrationService.generateClientByTokenPtoG(tokenPtoG);
     }
     catch (err) {
+      logger.error(err.message);
       return handleError(err, responseUrl);
     }
 

+ 60 - 8
apps/app/src/server/service/attachment.js

@@ -10,11 +10,23 @@ const mongoose = require('mongoose');
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AttachmentService');
 
+const createReadStream = (filePath) => {
+  return fs.createReadStream(filePath, {
+    flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
+  });
+};
+
 /**
  * the service class for Attachment and file-uploader
  */
 class AttachmentService {
 
+  /** @type {Array<(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>>} */
+  attachHandlers = [];
+
+  /** @type {Array<(attachmentId: string) => Promise<void>>} */
+  detachHandlers = [];
+
   /** @type {import('~/server/crowi').default} Crowi instance */
   crowi;
 
@@ -23,7 +35,7 @@ class AttachmentService {
     this.crowi = crowi;
   }
 
-  async createAttachment(file, user, pageId = null, attachmentType) {
+  async createAttachment(file, user, pageId = null, attachmentType, disposeTmpFileCallback) {
     const { fileUploadService } = this.crowi;
 
     // check limit
@@ -32,22 +44,36 @@ class AttachmentService {
       throw new Error(res.errorMessage);
     }
 
-    const fileStream = fs.createReadStream(file.path, {
-      flags: 'r', encoding: null, fd: null, mode: '0666', autoClose: true,
-    });
-
     // create an Attachment document and upload file
     let attachment;
+    let readStreamForCreateAttachmentDocument;
     try {
+      readStreamForCreateAttachmentDocument = createReadStream(file.path);
       attachment = Attachment.createWithoutSave(pageId, user, file.originalname, file.mimetype, file.size, attachmentType);
-      await fileUploadService.uploadAttachment(fileStream, attachment);
+      await fileUploadService.uploadAttachment(readStreamForCreateAttachmentDocument, attachment);
       await attachment.save();
+
+      const attachHandlerPromises = this.attachHandlers.map((handler) => {
+        return handler(pageId, attachment, file);
+      });
+
+      // Do not await, run in background
+      Promise.all(attachHandlerPromises)
+        .catch((err) => {
+          logger.error('Error while executing attach handler', err);
+        })
+        .finally(() => {
+          disposeTmpFileCallback?.(file);
+        });
     }
     catch (err) {
-      // delete temporary file
-      fs.unlink(file.path, (err) => { if (err) { logger.error('Error while deleting tmp file.') } });
+      logger.error('Error while creating attachment', err);
+      disposeTmpFileCallback?.(file);
       throw err;
     }
+    finally {
+      readStreamForCreateAttachmentDocument.destroy();
+    }
 
     return attachment;
   }
@@ -78,6 +104,16 @@ class AttachmentService {
     await fileUploadService.deleteFile(attachment);
     await attachment.remove();
 
+    const detachedHandlerPromises = this.detachHandlers.map((handler) => {
+      return handler(attachment._id);
+    });
+
+    // Do not await, run in background
+    Promise.all(detachedHandlerPromises)
+      .catch((err) => {
+        logger.error('Error while executing detached handler', err);
+      });
+
     return;
   }
 
@@ -88,6 +124,22 @@ class AttachmentService {
     return count >= 1;
   }
 
+  /**
+   * Register a handler that will be called after attachment creation
+   * @param {(pageId: string, attachment: Attachment, file: Express.Multer.File) => Promise<void>} handler
+   */
+  addAttachHandler(handler) {
+    this.attachHandlers.push(handler);
+  }
+
+  /**
+   * Register a handler that will be called before attachment deletion
+   * @param {(attachmentId: string) => Promise<void>} handler
+   */
+  addDetachHandler(handler) {
+    this.detachHandlers.push(handler);
+  }
+
 }
 
 module.exports = AttachmentService;

+ 10 - 7
apps/pdf-converter/src/controllers/pdf.ts

@@ -3,7 +3,7 @@ import { Controller } from '@tsed/di';
 import { InternalServerError } from '@tsed/exceptions';
 import { Logger } from '@tsed/logger';
 import {
-  Post, Returns, Enum, Description,
+  Post, Returns, Enum, Description, Required,
 } from '@tsed/schema';
 
 import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
@@ -28,20 +28,23 @@ class PdfCtrl {
     Return resulting status of job to GROWI.
   `)
   async syncJobStatus(
-    @BodyParams('jobId') jobId: string,
-    @BodyParams('expirationDate') expirationDateStr: string,
-    @BodyParams('status') @Enum(Object.values(JobStatusSharedWithGrowi)) growiJobStatus: JobStatusSharedWithGrowi,
-  ): Promise<{ status: JobStatus }> {
+    @Required() @BodyParams('jobId') jobId: string,
+    @Required() @BodyParams('expirationDate') expirationDateStr: string,
+    @Required() @BodyParams('status') @Enum(Object.values(JobStatusSharedWithGrowi)) growiJobStatus: JobStatusSharedWithGrowi,
+    @BodyParams('appId') appId?: string,
+  ): Promise<{ status: JobStatus } | undefined> {
     const expirationDate = new Date(expirationDateStr);
     try {
-      await this.pdfConvertService.registerOrUpdateJob(jobId, expirationDate, growiJobStatus);
+      await this.pdfConvertService.registerOrUpdateJob(jobId, expirationDate, growiJobStatus, appId);
       const status = this.pdfConvertService.getJobStatus(jobId); // get status before cleanup
       this.pdfConvertService.cleanUpJobList();
       return { status };
     }
     catch (err) {
       this.logger.error('Failed to register or update job', err);
-      throw new InternalServerError(err);
+      if (err instanceof Error) {
+        throw new InternalServerError(err.message);
+      }
     }
   }
 

+ 1 - 1
apps/pdf-converter/src/server.ts

@@ -34,7 +34,7 @@ const PORT = Number(process.env.PORT || 3010);
 class Server {
 
   @Inject()
-    app: PlatformApplication;
+    app: PlatformApplication | undefined;
 
 }
 

+ 23 - 12
apps/pdf-converter/src/service/pdf-convert.ts

@@ -59,11 +59,17 @@ class PdfConvertService implements OnInit {
   /**
    * Register or update job inside jobList with given jobId, expirationDate, and status.
    * If job is new, start reading html files and convert them to pdf.
-   * @param jobId id of PageBulkExportJob
+   * @param jobId PageBulkExportJob ID
    * @param expirationDate expiration date of job
    * @param status status of job
+   * @param appId application ID for GROWI.cloud
    */
-  async registerOrUpdateJob(jobId: string, expirationDate: Date, status: JobStatusSharedWithGrowi): Promise<void> {
+  async registerOrUpdateJob(
+      jobId: string,
+      expirationDate: Date,
+      status: JobStatusSharedWithGrowi,
+      appId?: string,
+  ): Promise<void> {
     const isJobNew = !(jobId in this.jobList);
 
     if (isJobNew) {
@@ -83,7 +89,7 @@ class PdfConvertService implements OnInit {
     }
 
     if (isJobNew && status !== JobStatus.FAILED) {
-      this.readHtmlAndConvertToPdfUntilFinish(jobId);
+      this.readHtmlAndConvertToPdfUntilFinish(jobId, appId);
     }
   }
 
@@ -134,9 +140,10 @@ class PdfConvertService implements OnInit {
   /**
    * Read html files from shared fs path, convert them to pdf, and save them to shared fs path.
    * Repeat this until all html files are converted to pdf or job fails.
-   * @param jobId id of PageBulkExportJob
+   * @param jobId PageBulkExportJob ID
+   * @param appId application ID for GROWI.cloud
    */
-  private async readHtmlAndConvertToPdfUntilFinish(jobId: string): Promise<void> {
+  private async readHtmlAndConvertToPdfUntilFinish(jobId: string, appId?: string): Promise<void> {
     while (!this.isJobCompleted(jobId)) {
       // eslint-disable-next-line no-await-in-loop
       await new Promise(resolve => setTimeout(resolve, 10 * 1000));
@@ -146,7 +153,7 @@ class PdfConvertService implements OnInit {
           throw new Error('Job expired');
         }
 
-        const htmlReadable = this.getHtmlReadable(jobId);
+        const htmlReadable = this.getHtmlReadable(jobId, appId);
         const pdfWritable = this.getPdfWritable();
         this.jobList[jobId].currentStream = htmlReadable;
 
@@ -165,11 +172,13 @@ class PdfConvertService implements OnInit {
 
   /**
    * Get readable stream that reads html files from shared fs path
-   * @param jobId id of PageBulkExportJob
+   * @param jobId PageBulkExportJob ID
+   * @param appId application ID for GROWI.cloud
    * @returns readable stream
    */
-  private getHtmlReadable(jobId: string): Readable {
-    const htmlFileEntries = fs.readdirSync(path.join(this.tmpHtmlDir, jobId), { recursive: true, withFileTypes: true }).filter(entry => entry.isFile());
+  private getHtmlReadable(jobId: string, appId?: string): Readable {
+    const jobHtmlDir = path.join(this.tmpHtmlDir, appId ?? '', jobId);
+    const htmlFileEntries = fs.readdirSync(jobHtmlDir, { recursive: true, withFileTypes: true }).filter(entry => entry.isFile());
     let index = 0;
 
     const jobList = this.jobList;
@@ -215,7 +224,9 @@ class PdfConvertService implements OnInit {
           await fs.promises.rm(pageInfo.htmlFilePath, { force: true });
         }
         catch (err) {
-          callback(err);
+          if (err instanceof Error) {
+            callback(err);
+          }
           return;
         }
         callback();
@@ -229,9 +240,9 @@ class PdfConvertService implements OnInit {
    * @returns converted pdf
    */
   private async convertHtmlToPdf(htmlString: string): Promise<Buffer> {
-    const executeConvert = async(retries: number) => {
+    const executeConvert = async(retries: number): Promise<Buffer> => {
       try {
-        return this.puppeteerCluster.execute(htmlString);
+        return this.puppeteerCluster?.execute(htmlString);
       }
       catch (err) {
         if (retries > 0) {

+ 1 - 1
apps/pdf-converter/tsconfig.json

@@ -8,7 +8,7 @@
     "esModuleInterop": true,
     "experimentalDecorators": true,
     "emitDecoratorMetadata": true,
-    "strict": false
+    "strict": true
   },
   "include": ["./src/**/*", "./test/**/*"],
   "exclude": ["node_modules", "dist"]

+ 2 - 2
apps/slackbot-proxy/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/slackbot-proxy",
-  "version": "7.2.4-slackbot-proxy.0",
+  "version": "7.2.5-slackbot-proxy.0",
   "license": "MIT",
   "private": "true",
   "scripts": {
@@ -36,7 +36,7 @@
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@growi/slack": "workspace:^",
-    "@slack/oauth": "^2.0.1",
+    "@slack/oauth": "^3.0.3",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "=6.43.0",
     "@tsed/di": "=6.43.0",

+ 1 - 0
apps/slackbot-proxy/src/services/InstallerService.ts

@@ -32,6 +32,7 @@ export class InstallerService {
       clientId,
       clientSecret,
       stateSecret,
+      legacyStateVerification: true,
       installationStore: {
         // upsert
         storeInstallation: async(slackInstallation: SlackInstallation<'v1' | 'v2', boolean>) => {

+ 54 - 0
biome.json

@@ -0,0 +1,54 @@
+{
+  "$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
+  "files": {
+    "ignore": [
+      "dist/**",
+      "node_modules/**",
+      "coverage/**",
+      "vite.config.ts.timestamp-*",
+      "vite.server.config.ts.timestamp-*",
+      "vite.client.config.ts.timestamp-*",
+      ".pnpm-store/**",
+      ".turbo/**",
+      ".vscode/**",
+      "turbo.json",
+      "./bin/**",
+      "./tsconfig.base.json",
+      ".devcontainer/**",
+      ".eslintrc.js",
+      ".stylelintrc.json",
+      "package.json",
+      "./apps/**",
+      "./packages/core/**",
+      "./packages/core-styles/**",
+      "./packages/custom-icons/**",
+      "./packages/editor/**",
+      "./packages/pdf-converter-client/**",
+      "./packages/pluginkit/**",
+      "./packages/presentation/**",
+      "./packages/preset-templates/**",
+      "./packages/preset-themes/**",
+      "./packages/remark-attachment-refs/**",
+      "./packages/remark-drawio/**",
+      "./packages/remark-growi-directive/**"
+    ]
+  },
+  "formatter": {
+    "enabled": true,
+    "indentStyle": "space"
+  },
+  "organizeImports": {
+    "enabled": true
+  },
+  "linter": {
+    "enabled": true,
+    "rules": {
+      "recommended": true
+    }
+  },
+  "javascript": {
+    "formatter": {
+      "quoteStyle": "single"
+    }
+  }
+}

+ 3 - 3
package.json

@@ -1,6 +1,6 @@
 {
   "name": "growi",
-  "version": "7.2.4",
+  "version": "7.2.5-RC.0",
   "description": "Team collaboration software using markdown",
   "license": "MIT",
   "private": "true",
@@ -38,11 +38,11 @@
     "version:preminor": "pnpm version preminor --preid=RC --no-git-tag-version",
     "version:premajor": "pnpm version premajor --preid=RC --no-git-tag-version"
   },
-  "dependencies": {},
   "// comments for defDependencies": {
     "vite-plugin-dts": "v4.2.1 causes the unexpected error 'Cannot find package 'vue-tsc''"
   },
   "devDependencies": {
+    "@biomejs/biome": "1.9.4",
     "@changesets/changelog-github": "^0.5.0",
     "@changesets/cli": "^2.27.3",
     "@faker-js/faker": "^9.0.1",
@@ -96,7 +96,7 @@
     "turbo": "^2.1.3",
     "typescript": "~5.0.0",
     "typescript-transform-paths": "^3.4.7",
-    "vite": "^5.4.17",
+    "vite": "^5.4.19",
     "vite-plugin-dts": "^3.9.1",
     "vite-tsconfig-paths": "^5.0.1",
     "vitest": "^2.1.1",

+ 0 - 162
packages/core/src/utils/path-utils.js

@@ -1,162 +0,0 @@
-/**
- * @private
- *
- *
- * @param {string} path
- * @returns {RegExpMatchArray}
- * @memberof pathUtils
- */
-function matchSlashes(path) {
-  // https://regex101.com/r/Z21fEd/5
-  return path.match(/^((\/+)?(.+?))(\/+)?$/);
-}
-
-/**
- *
- * @param {string} path
- * @returns {boolean}
- * @memberof pathUtils
- */
-export function hasHeadingSlash(path) {
-  if (path === '') {
-    return false;
-  }
-  const match = matchSlashes(path);
-  return (match[2] != null);
-}
-
-/**
- *
- * @param {string} path
- * @returns {boolean}
- * @memberof pathUtils
- */
-export function hasTrailingSlash(path) {
-  if (path === '') {
-    return false;
-  }
-  const match = matchSlashes(path);
-  return (match[4] != null);
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function addHeadingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  if (!hasHeadingSlash(path)) {
-    return `/${path}`;
-  }
-  return path;
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function addTrailingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  if (!hasTrailingSlash(path)) {
-    return `${path}/`;
-  }
-  return path;
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function removeHeadingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  return hasHeadingSlash(path)
-    ? path.substring(1)
-    : path;
-}
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function removeTrailingSlash(path) {
-  if (path === '/') {
-    return path;
-  }
-
-  const match = matchSlashes(path);
-  return match[1];
-}
-
-/**
- * A short-hand method to add heading slash and remove trailing slash.
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function normalizePath(path) {
-  if (path === '' || path === '/') {
-    return '/';
-  }
-
-  const match = matchSlashes(path);
-  if (match == null) {
-    return '/';
-  }
-  return `/${match[3]}`;
-}
-
-
-/**
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function attachTitleHeader(path) {
-  return `# ${path}`;
-}
-
-/**
- * If the pagePath is top page path, eliminate the pageId from the url path.
- *
- * @param {string} path
- * @param {string} id
- * @returns {string}
- * @memberof pathUtils
- */
-export function returnPathForURL(path, id) {
-  if (path === '/') {
-    return path;
-  }
-
-  return addHeadingSlash(id);
-}
-
-/**
- * Get the parent path of the specified path.
- *
- * @param {string} path
- * @returns {string}
- * @memberof pathUtils
- */
-export function getParentPath(path) {
-  return normalizePath(path.split('/').slice(0, -1).join('/'));
-}

+ 162 - 11
packages/core/src/utils/path-utils.spec.ts

@@ -1,26 +1,177 @@
 import * as pathUtils from './path-utils';
 
-
 describe('page-utils', () => {
+
   describe('.normalizePath', () => {
-    test.concurrent('should return the root path with empty string', () => {
-      expect(pathUtils.normalizePath('')).toBe('/');
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'/path'}
+      ${'/path'}            | ${'/path'}
+      ${'path/'}            | ${'/path'}
+      ${'/path/'}           | ${'/path'}
+      ${'path1/path2'}      | ${'/path1/path2'}
+      ${'/path1/path2'}     | ${'/path1/path2'}
+      ${'path1/path2/'}     | ${'/path1/path2'}
+      ${'/path1/path2/'}    | ${'/path1/path2'}
+      ${'//path1/path2//'}  | ${'/path1/path2'}
+      ${'https://example.com'} | ${'/https://example.com'}
+      ${'https://example.com/'} | ${'/https://example.com'}
+    `('should normalize \'$path\' to \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.normalizePath(path)).toBe(expected);
+    });
+  });
+
+  describe('.hasHeadingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${true}
+      ${''}                 | ${false}
+      ${'path'}             | ${false}
+      ${'/path'}            | ${true}
+      ${'path/'}            | ${false}
+      ${'/path/'}           | ${true}
+      ${'path1/path2'}      | ${false}
+      ${'/path1/path2'}     | ${true}
+      ${'path1/path2/'}     | ${false}
+      ${'/path1/path2/'}    | ${true}
+      ${'//path1/path2//'}  | ${true}
+      ${'https://example.com'} | ${false}
+      ${'https://example.com/'} | ${false}
+    `('should return $expected when checking heading slash for \'$path\'', ({ path, expected }) => {
+      expect(pathUtils.hasHeadingSlash(path)).toBe(expected);
     });
+  });
 
-    test.concurrent('should return the root path as is', () => {
-      expect(pathUtils.normalizePath('/')).toBe('/');
+  describe('.hasTrailingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${true}
+      ${''}                 | ${false}
+      ${'path'}             | ${false}
+      ${'/path'}            | ${false}
+      ${'path/'}            | ${true}
+      ${'/path/'}           | ${true}
+      ${'path1/path2'}      | ${false}
+      ${'/path1/path2'}     | ${false}
+      ${'path1/path2/'}     | ${true}
+      ${'/path1/path2/'}    | ${true}
+      ${'//path1/path2//'}  | ${true}
+      ${'https://example.com'} | ${false}
+      ${'https://example.com/'} | ${true}
+    `('should return $expected when checking trailing slash for \'$path\'', ({ path, expected }) => {
+      expect(pathUtils.hasTrailingSlash(path)).toBe(expected);
     });
+  });
 
-    test.concurrent('should add heading slash', () => {
-      expect(pathUtils.normalizePath('hoge/fuga')).toBe('/hoge/fuga');
+  describe('.addHeadingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'/path'}
+      ${'/path'}            | ${'/path'}
+      ${'path/'}            | ${'/path/'}
+      ${'/path/'}           | ${'/path/'}
+      ${'path1/path2'}      | ${'/path1/path2'}
+      ${'/path1/path2'}     | ${'/path1/path2'}
+      ${'path1/path2/'}     | ${'/path1/path2/'}
+      ${'/path1/path2/'}    | ${'/path1/path2/'}
+      ${'//path1/path2//'}  | ${'//path1/path2//'}
+      ${'https://example.com'} | ${'/https://example.com'}
+      ${'https://example.com/'} | ${'/https://example.com/'}
+    `('should add heading slash to \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.addHeadingSlash(path)).toBe(expected);
     });
+  });
 
-    test.concurrent('should remove trailing slash', () => {
-      expect(pathUtils.normalizePath('/hoge/fuga/')).toBe('/hoge/fuga');
+  describe('.addTrailingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'path/'}
+      ${'/path'}            | ${'/path/'}
+      ${'path/'}            | ${'path/'}
+      ${'/path/'}           | ${'/path/'}
+      ${'path1/path2'}      | ${'path1/path2/'}
+      ${'/path1/path2'}     | ${'/path1/path2/'}
+      ${'path1/path2/'}     | ${'path1/path2/'}
+      ${'/path1/path2/'}    | ${'/path1/path2/'}
+      ${'//path1/path2//'}  | ${'//path1/path2//'}
+      ${'https://example.com'} | ${'https://example.com/'}
+      ${'https://example.com/'} | ${'https://example.com/'}
+    `('should add trailing slash to \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.addTrailingSlash(path)).toBe(expected);
     });
+  });
+
+  describe('.removeHeadingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${''}
+      ${'path'}             | ${'path'}
+      ${'/path'}            | ${'path'}
+      ${'path/'}            | ${'path/'}
+      ${'/path/'}           | ${'path/'}
+      ${'path1/path2'}      | ${'path1/path2'}
+      ${'/path1/path2'}     | ${'path1/path2'}
+      ${'path1/path2/'}     | ${'path1/path2/'}
+      ${'/path1/path2/'}    | ${'path1/path2/'}
+      ${'//path1/path2//'}  | ${'path1/path2//'}
+      ${'https://example.com'} | ${'https://example.com'}
+      ${'https://example.com/'} | ${'https://example.com/'}
+      ${'//'}               | ${'/'}                  // from former specific test
+    `('should remove heading slash from \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.removeHeadingSlash(path)).toBe(expected);
+    });
+  });
+
+  describe('.removeTrailingSlash', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${''}
+      ${'path'}             | ${'path'}
+      ${'/path'}            | ${'/path'}
+      ${'path/'}            | ${'path'}
+      ${'/path/'}           | ${'/path'}
+      ${'path1/path2'}      | ${'path1/path2'}
+      ${'/path1/path2'}     | ${'/path1/path2'}
+      ${'path1/path2/'}     | ${'path1/path2'}
+      ${'/path1/path2/'}    | ${'/path1/path2'}
+      ${'//path1/path2//'}  | ${'//path1/path2'}
+      ${'https://example.com'} | ${'https://example.com'}
+      ${'https://example.com/'} | ${'https://example.com'}
+    `('should remove trailing slash from \'$path\' resulting in \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.removeTrailingSlash(path)).toBe(expected);
+    });
+  });
 
-    test.concurrent('should remove unnecessary slashes', () => {
-      expect(pathUtils.normalizePath('//hoge/fuga//')).toBe('/hoge/fuga');
+  describe('.getParentPath', () => {
+    test.concurrent.each`
+      path                  | expected
+      ${'/'}                | ${'/'}
+      ${''}                 | ${'/'}
+      ${'path'}             | ${'/'}
+      ${'/path'}            | ${'/'}
+      ${'path/'}            | ${'/path'}
+      ${'/path/'}           | ${'/path'}
+      ${'path1/path2'}      | ${'/path1'}
+      ${'/path1/path2'}     | ${'/path1'}
+      ${'path1/path2/'}     | ${'/path1/path2'}
+      ${'/path1/path2/'}    | ${'/path1/path2'}
+      ${'//path1/path2//'}  | ${'/path1/path2'}
+      ${'https://example.com'} | ${'/https:'}
+      ${'https://example.com/'} | ${'/https://example.com'}
+      ${'/page'}            | ${'/'}                  // from former specific test
+      // Note: getParentPath('page') is covered by 'path' -> '/'
+      // Note: getParentPath('/path1/path2') is covered by '/path1/path2' -> '/path1'
+      // Note: getParentPath('/path1/path2/') is covered by '/path1/path2/' -> '/path1/path2'
+    `('should get parent path of \'$path\' as \'$expected\'', ({ path, expected }) => {
+      expect(pathUtils.getParentPath(path)).toBe(expected);
     });
   });
 });

+ 146 - 0
packages/core/src/utils/path-utils.ts

@@ -0,0 +1,146 @@
+interface PathParts {
+  readonly headingSlashes: string;
+  readonly content: string;
+  readonly trailingSlashes: string;
+  readonly hasHeadingSlash: boolean;
+  readonly hasTrailingSlash: boolean;
+}
+
+function parsePath(path: string): PathParts | null {
+  if (typeof path !== 'string' || !path || path === '') return null;
+
+  // Special case for root path
+  if (path === '/') {
+    return {
+      headingSlashes: '/',
+      content: '',
+      trailingSlashes: '',
+      hasHeadingSlash: true,
+      hasTrailingSlash: true,
+    };
+  }
+
+  let startIndex = 0;
+  let endIndex = path.length;
+
+  // Find leading slashes
+  while (startIndex < path.length && path[startIndex] === '/') {
+    startIndex++;
+  }
+
+  // Find trailing slashes
+  while (endIndex > startIndex && path[endIndex - 1] === '/') {
+    endIndex--;
+  }
+
+  const headingSlashes = path.substring(0, startIndex);
+  const content = path.substring(startIndex, endIndex);
+  const trailingSlashes = path.substring(endIndex);
+
+  return {
+    headingSlashes,
+    content,
+    trailingSlashes,
+    hasHeadingSlash: headingSlashes.length > 0,
+    hasTrailingSlash: trailingSlashes.length > 0,
+  };
+}
+
+export function hasHeadingSlash(path: string): boolean {
+  if (path === '/') return true;
+
+  const parts = parsePath(path);
+  return parts?.hasHeadingSlash ?? false;
+}
+
+export function hasTrailingSlash(path: string): boolean {
+  if (path === '/') return true;
+
+  const parts = parsePath(path);
+  return parts?.hasTrailingSlash ?? false;
+}
+
+export function addHeadingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return '/';
+
+  const parts = parsePath(path);
+  if (!parts?.hasHeadingSlash) {
+    return `/${path}`;
+  }
+  return path;
+}
+
+export function addTrailingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return '/';
+
+  const parts = parsePath(path);
+  if (!parts?.hasTrailingSlash) {
+    return `${path}/`;
+  }
+  return path;
+}
+
+export function removeHeadingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return path;
+
+  const parts = parsePath(path);
+  if (!parts?.hasHeadingSlash) return path;
+
+  // Special case for '//' -> '/'
+  if (path === '//') return '/';
+
+  // Remove heading slashes and return content + trailing slashes
+  return parts.content + parts.trailingSlashes;
+}
+
+export function removeTrailingSlash(path: string): string {
+  if (path === '/') return path;
+  if (path === '') return path;
+
+  const parts = parsePath(path);
+  if (parts == null) return path;
+
+  // Return heading slashes + content (without trailing slashes)
+  return parts.headingSlashes + parts.content;
+}
+
+/**
+ * A short-hand method to add heading slash and remove trailing slash.
+ */
+export function normalizePath(path: string): string {
+  if (typeof path !== 'string' || path === '' || path === '/') {
+    return '/';
+  }
+
+  const parts = parsePath(path);
+  if (parts == null) {
+    return '/';
+  }
+  return `/${parts.content}`;
+}
+
+
+export function attachTitleHeader(path: string): string {
+  return `# ${path}`;
+}
+
+/**
+ * If the pagePath is top page path, eliminate the pageId from the url path.
+ */
+export function returnPathForURL(path: string, id: string): string {
+  if (path === '/') {
+    return path;
+  }
+
+  return addHeadingSlash(id);
+}
+
+/**
+ * Get the parent path of the specified path.
+ */
+export function getParentPath(path: string): string {
+  return normalizePath(path.split('/').slice(0, -1).join('/'));
+}

+ 11 - 4
packages/pdf-converter-client/src/index.ts

@@ -24,6 +24,9 @@ export type PdfCtrlSyncJobStatus202 = {
   status: PdfCtrlSyncJobStatus202Status;
 };
 
+/**
+ * @minLength 1
+ */
 export type PdfCtrlSyncJobStatusBodyStatus = typeof PdfCtrlSyncJobStatusBodyStatus[keyof typeof PdfCtrlSyncJobStatusBodyStatus];
 
 
@@ -35,9 +38,13 @@ export const PdfCtrlSyncJobStatusBodyStatus = {
 } as const;
 
 export type PdfCtrlSyncJobStatusBody = {
-  expirationDate?: string;
-  jobId?: string;
-  status?: PdfCtrlSyncJobStatusBodyStatus;
+  appId?: string;
+  /** @minLength 1 */
+  expirationDate: string;
+  /** @minLength 1 */
+  jobId: string;
+  /** @minLength 1 */
+  status: PdfCtrlSyncJobStatusBodyStatus;
 };
 
 export interface GenericError {
@@ -85,7 +92,7 @@ export interface InternalServerError {
   
  */
 export const pdfCtrlSyncJobStatus = <TData = AxiosResponse<PdfCtrlSyncJobStatus202>>(
-    pdfCtrlSyncJobStatusBody?: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig
+    pdfCtrlSyncJobStatusBody: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig
  ): Promise<TData> => {
     return axios.post(
       `/pdf/sync-job`,

+ 1 - 1
packages/remark-lsx/.eslintignore

@@ -1 +1 @@
-/dist/**
+*

+ 0 - 18
packages/remark-lsx/.eslintrc.cjs

@@ -1,18 +0,0 @@
-module.exports = {
-  extends: [
-    'weseek/react',
-    'plugin:vitest/recommended',
-  ],
-  env: {
-  },
-  globals: {
-  },
-  settings: {
-    // resolve path aliases by eslint-import-resolver-typescript
-    'import/resolver': {
-      typescript: {},
-    },
-  },
-  rules: {
-  },
-};

+ 1 - 1
packages/remark-lsx/package.json

@@ -23,7 +23,7 @@
     "watch": "run-p watch:*",
     "watch:client": "pnpm run dev:client -w --emptyOutDir=false",
     "watch:server": "pnpm run dev:server -w --emptyOutDir=false",
-    "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
+    "lint:js": "biome check",
     "lint:styles": "stylelint --allow-empty-input \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "run-p lint:*",

+ 147 - 120
packages/remark-lsx/src/client/components/Lsx.tsx

@@ -11,136 +11,161 @@ import { LsxContext } from './lsx-context';
 import styles from './Lsx.module.scss';
 
 type Props = {
-  children: React.ReactNode,
-  className?: string,
-
-  prefix: string,
-  num?: string,
-  depth?: string,
-  sort?: string,
-  reverse?: string,
-  filter?: string,
-  except?: string,
-
-  isImmutable?: boolean,
-  isSharedPage?: boolean,
+  children: React.ReactNode;
+  className?: string;
+
+  prefix: string;
+  num?: string;
+  depth?: string;
+  sort?: string;
+  reverse?: string;
+  filter?: string;
+  except?: string;
+
+  isImmutable?: boolean;
+  isSharedPage?: boolean;
 };
 
-const LsxSubstance = React.memo(({
-  prefix,
-  num, depth, sort, reverse, filter, except,
-  isImmutable,
-}: Props): JSX.Element => {
-
-  const lsxContext = useMemo(() => {
-    const options = {
-      num, depth, sort, reverse, filter, except,
-    };
-    return new LsxContext(prefix, options);
-  }, [depth, filter, num, prefix, reverse, sort, except]);
-
-  const {
-    data, error, isLoading, setSize,
-  } = useSWRxLsx(lsxContext.pagePath, lsxContext.options, isImmutable);
-
-  const hasError = error != null;
-  const errorMessage = error?.message;
-
-  const Error = useCallback((): JSX.Element => {
-    if (!hasError) {
-      return <></>;
-    }
-
-    return (
-      <details>
-        <summary className="text-warning">
-          <span className="material-symbols-outlined me-1">warning</span> {lsxContext.toString()}
-        </summary>
-        <small className="ms-3 text-muted">{errorMessage}</small>
-      </details>
+const LsxSubstance = React.memo(
+  ({
+    prefix,
+    num,
+    depth,
+    sort,
+    reverse,
+    filter,
+    except,
+    isImmutable,
+  }: Props): JSX.Element => {
+    const lsxContext = useMemo(() => {
+      const options = {
+        num,
+        depth,
+        sort,
+        reverse,
+        filter,
+        except,
+      };
+      return new LsxContext(prefix, options);
+    }, [depth, filter, num, prefix, reverse, sort, except]);
+
+    const { data, error, isLoading, setSize } = useSWRxLsx(
+      lsxContext.pagePath,
+      lsxContext.options,
+      isImmutable,
     );
-  }, [errorMessage, hasError, lsxContext]);
-
-  const Loading = useCallback((): JSX.Element => {
-    if (hasError) {
-      return <></>;
-    }
-    if (!isLoading) {
-      return <></>;
-    }
-
-    return (
-      <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
-        <small>
-          <LoadingSpinner className="me-1" />
-          {lsxContext.toString()}
-        </small>
-      </div>
-    );
-  }, [hasError, isLoading, lsxContext]);
-
-  const contents = useMemo(() => {
-    if (data == null) {
-      return <></>;
-    }
-
-    const depthRange = lsxContext.getOptDepth();
-
-    const nodeTree = generatePageNodeTree(prefix, data.flatMap(d => d.pages), depthRange);
-    const basisViewersCount = data.at(-1)?.toppageViewersCount;
-
-    return <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />;
-  }, [data, lsxContext, prefix]);
 
-
-  const LoadMore = useCallback(() => {
-    const lastResult = data?.at(-1);
-
-    if (lastResult == null) {
-      return <></>;
-    }
-
-    const { cursor, total } = lastResult;
-    const leftItemsNum = total - cursor;
-
-    if (leftItemsNum === 0) {
-      return <></>;
-    }
+    const hasError = error != null;
+    const errorMessage = error?.message;
+
+    const ErrorMessage = useCallback((): JSX.Element => {
+      if (!hasError) {
+        return <></>;
+      }
+
+      return (
+        <details>
+          <summary className="text-warning">
+            <span className="material-symbols-outlined me-1">warning</span>{' '}
+            {lsxContext.toString()}
+          </summary>
+          <small className="ms-3 text-muted">{errorMessage}</small>
+        </details>
+      );
+    }, [errorMessage, hasError, lsxContext]);
+
+    const Loading = useCallback((): JSX.Element => {
+      if (hasError) {
+        return <></>;
+      }
+      if (!isLoading) {
+        return <></>;
+      }
+
+      return (
+        <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
+          <small>
+            <LoadingSpinner className="me-1" />
+            {lsxContext.toString()}
+          </small>
+        </div>
+      );
+    }, [hasError, isLoading, lsxContext]);
+
+    const contents = useMemo(() => {
+      if (data == null) {
+        return <></>;
+      }
+
+      const depthRange = lsxContext.getOptDepth();
+
+      const nodeTree = generatePageNodeTree(
+        prefix,
+        data.flatMap((d) => d.pages),
+        depthRange,
+      );
+      const basisViewersCount = data.at(-1)?.toppageViewersCount;
+
+      return (
+        <LsxListView
+          nodeTree={nodeTree}
+          lsxContext={lsxContext}
+          basisViewersCount={basisViewersCount}
+        />
+      );
+    }, [data, lsxContext, prefix]);
+
+    const LoadMore = useCallback(() => {
+      const lastResult = data?.at(-1);
+
+      if (lastResult == null) {
+        return <></>;
+      }
+
+      const { cursor, total } = lastResult;
+      const leftItemsNum = total - cursor;
+
+      if (leftItemsNum === 0) {
+        return <></>;
+      }
+
+      return (
+        <div className="row justify-content-center lsx-load-more-row">
+          <div className="col-12 col-sm-8 d-flex flex-column align-items-center lsx-load-more-container">
+            <button
+              type="button"
+              className="btn btn btn-outline-secondary btn-load-more"
+              onClick={() => setSize((size) => size + 1)}
+            >
+              Load more
+              <br />
+              <span className="text-muted small start-items-label">
+                {leftItemsNum} pages left
+              </span>
+            </button>
+          </div>
+        </div>
+      );
+    }, [data, setSize]);
 
     return (
-      <div className="row justify-content-center lsx-load-more-row">
-        <div className="col-12 col-sm-8 d-flex flex-column align-items-center lsx-load-more-container">
-          <button
-            type="button"
-            className="btn btn btn-outline-secondary btn-load-more"
-            onClick={() => setSize(size => size + 1)}
-          >
-            Load more<br />
-            <span className="text-muted small start-items-label">
-              {leftItemsNum} pages left
-            </span>
-          </button>
-        </div>
+      <div className={`lsx ${styles.lsx}`}>
+        <ErrorMessage />
+        <Loading />
+        {contents}
+        <LoadMore />
       </div>
     );
-  }, [data, setSize]);
-
-
-  return (
-    <div className={`lsx ${styles.lsx}`}>
-      <Error />
-      <Loading />
-      {contents}
-      <LoadMore />
-    </div>
-  );
-});
+  },
+);
 LsxSubstance.displayName = 'LsxSubstance';
 
 const LsxDisabled = React.memo((): JSX.Element => {
   return (
     <div className="text-muted">
-      <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">info</span>
+      <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">
+        info
+      </span>
       <small>lsx is not available on the share link page</small>
     </div>
   );
@@ -156,7 +181,9 @@ export const Lsx = React.memo((props: Props): JSX.Element => {
 });
 Lsx.displayName = 'Lsx';
 
-export const LsxImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
-  return <Lsx {...props} isImmutable />;
-});
+export const LsxImmutable = React.memo(
+  (props: Omit<Props, 'isImmutable'>): JSX.Element => {
+    return <Lsx {...props} isImmutable />;
+  },
+);
 LsxImmutable.displayName = 'LsxImmutable';

+ 12 - 13
packages/remark-lsx/src/client/components/LsxPageList/LsxListView.tsx

@@ -5,19 +5,15 @@ import type { LsxContext } from '../lsx-context';
 
 import { LsxPage } from './LsxPage';
 
-
 import styles from './LsxListView.module.scss';
 
-
 type Props = {
-  nodeTree?: PageNode[],
-  lsxContext: LsxContext,
-  basisViewersCount?: number,
+  nodeTree?: PageNode[];
+  lsxContext: LsxContext;
+  basisViewersCount?: number;
 };
 
-
 export const LsxListView = React.memo((props: Props): JSX.Element => {
-
   const { nodeTree, lsxContext, basisViewersCount } = props;
 
   const isEmpty = nodeTree == null || nodeTree.length === 0;
@@ -27,8 +23,14 @@ export const LsxListView = React.memo((props: Props): JSX.Element => {
       return (
         <div className="text-muted">
           <small>
-            <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">info</span>
-            $lsx(<a href={lsxContext.pagePath}>{lsxContext.pagePath}</a>) has no contents
+            <span
+              className="material-symbols-outlined fs-5 me-1"
+              aria-hidden="true"
+            >
+              info
+            </span>
+            $lsx(<a href={lsxContext.pagePath}>{lsxContext.pagePath}</a>) has no
+            contents
           </small>
         </div>
       );
@@ -49,11 +51,8 @@ export const LsxListView = React.memo((props: Props): JSX.Element => {
 
   return (
     <div className={`page-list ${styles['page-list']}`}>
-      <ul className="page-list-ul">
-        {contents}
-      </ul>
+      <ul className="page-list-ul">{contents}</ul>
     </div>
   );
-
 });
 LsxListView.displayName = 'LsxListView';

+ 21 - 14
packages/remark-lsx/src/client/components/LsxPageList/LsxPage.tsx

@@ -7,21 +7,17 @@ import Link from 'next/link';
 import type { PageNode } from '../../../interfaces/page-node';
 import type { LsxContext } from '../lsx-context';
 
-
 import styles from './LsxPage.module.scss';
 
-
 type Props = {
-  pageNode: PageNode,
-  lsxContext: LsxContext,
-  depth: number,
-  basisViewersCount?: number,
+  pageNode: PageNode;
+  lsxContext: LsxContext;
+  depth: number;
+  basisViewersCount?: number;
 };
 
 export const LsxPage = React.memo((props: Props): JSX.Element => {
-  const {
-    pageNode, lsxContext, depth, basisViewersCount,
-  } = props;
+  const { pageNode, lsxContext, depth, basisViewersCount } = props;
 
   const pageId = pageNode.page?._id;
   const pagePath = pageNode.pagePath;
@@ -64,9 +60,15 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
 
   const iconElement: JSX.Element = useMemo(() => {
     const isExists = pageId != null;
-    return (isExists)
-      ? <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">description</span>
-      : <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">draft</span>;
+    return isExists ? (
+      <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">
+        description
+      </span>
+    ) : (
+      <span className="material-symbols-outlined fs-5 me-1" aria-hidden="true">
+        draft
+      </span>
+    );
   }, [pageId]);
 
   const pagePathElement: JSX.Element = useMemo(() => {
@@ -78,7 +80,13 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
     }
 
     // create PagePath element
-    let pagePathNode = <PagePathLabel path={pagePath} isLatterOnly additionalClassNames={classNames} />;
+    let pagePathNode = (
+      <PagePathLabel
+        path={pagePath}
+        isLatterOnly
+        additionalClassNames={classNames}
+      />
+    );
     if (isLinkable) {
       const href = isExists
         ? `/${pageId}`
@@ -118,6 +126,5 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
       {childrenElements}
     </li>
   );
-
 });
 LsxPage.displayName = 'LsxPage';

+ 9 - 7
packages/remark-lsx/src/client/components/lsx-context.ts

@@ -1,17 +1,20 @@
-import { OptionParser, type ParseRangeResult } from '@growi/core/dist/remark-plugins';
-
+import {
+  OptionParser,
+  type ParseRangeResult,
+} from '@growi/core/dist/remark-plugins';
 
 export class LsxContext {
-
   pagePath: string;
 
-  options?: Record<string, string|undefined>;
+  options?: Record<string, string | undefined>;
 
-  constructor(pagePath: string, options: Record<string, string|undefined>) {
+  constructor(pagePath: string, options: Record<string, string | undefined>) {
     this.pagePath = pagePath;
 
     // remove undefined keys
-    Object.keys(options).forEach(key => options[key] === undefined && delete options[key]);
+    for (const key in options) {
+      options[key] === undefined && delete options[key];
+    }
 
     this.options = options;
   }
@@ -42,5 +45,4 @@ export class LsxContext {
   toString(): string {
     return `$lsx(${this.getStringifiedAttributes()})`;
   }
-
 }

+ 68 - 48
packages/remark-lsx/src/client/services/renderer/lsx.ts

@@ -1,7 +1,12 @@
-import assert from 'assert';
-
-import { hasHeadingSlash, removeTrailingSlash, addTrailingSlash } from '@growi/core/dist/utils/path-utils';
-import type { TextGrowiPluginDirective, LeafGrowiPluginDirective } from '@growi/remark-growi-directive';
+import {
+  addTrailingSlash,
+  hasHeadingSlash,
+  removeTrailingSlash,
+} from '@growi/core/dist/utils/path-utils';
+import type {
+  LeafGrowiPluginDirective,
+  TextGrowiPluginDirective,
+} from '@growi/remark-growi-directive';
 import { remarkGrowiDirectivePluginType } from '@growi/remark-growi-directive';
 import type { Nodes as HastNode } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
@@ -11,54 +16,67 @@ import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
 const NODE_NAME_PATTERN = new RegExp(/ls|lsx/);
-const SUPPORTED_ATTRIBUTES = ['prefix', 'num', 'depth', 'sort', 'reverse', 'filter', 'except', 'isSharedPage'];
-
-type DirectiveAttributes = Record<string, string>
-type GrowiPluginDirective = TextGrowiPluginDirective | LeafGrowiPluginDirective
-
-export const remarkPlugin: Plugin = function() {
-  return (tree) => {
-    visit(tree, (node: GrowiPluginDirective) => {
-      if (node.type === remarkGrowiDirectivePluginType.Leaf || node.type === remarkGrowiDirectivePluginType.Text) {
-
-        if (typeof node.name !== 'string') {
-          return;
-        }
-        if (!NODE_NAME_PATTERN.test(node.name)) {
-          return;
-        }
-
-        const data = node.data ?? (node.data = {});
-        const attributes = node.attributes as DirectiveAttributes || {};
-
-        // set 'prefix' attribute if the first attribute is only value
-        // e.g.
-        //   case 1: lsx(prefix=/path..., ...)    => prefix="/path"
-        //   case 2: lsx(/path, ...)              => prefix="/path"
-        //   case 3: lsx(/foo, prefix=/bar ...)   => prefix="/bar"
-        if (attributes.prefix == null) {
-          const attrEntries = Object.entries(attributes);
-
-          if (attrEntries.length > 0) {
-            const [firstAttrKey, firstAttrValue] = attrEntries[0];
+const SUPPORTED_ATTRIBUTES = [
+  'prefix',
+  'num',
+  'depth',
+  'sort',
+  'reverse',
+  'filter',
+  'except',
+  'isSharedPage',
+];
+
+type DirectiveAttributes = Record<string, string>;
+type GrowiPluginDirective = TextGrowiPluginDirective | LeafGrowiPluginDirective;
+
+export const remarkPlugin: Plugin = () => (tree) => {
+  visit(tree, (node: GrowiPluginDirective) => {
+    if (
+      node.type === remarkGrowiDirectivePluginType.Leaf ||
+      node.type === remarkGrowiDirectivePluginType.Text
+    ) {
+      if (typeof node.name !== 'string') {
+        return;
+      }
+      if (!NODE_NAME_PATTERN.test(node.name)) {
+        return;
+      }
 
-            if (firstAttrValue === '' && !SUPPORTED_ATTRIBUTES.includes(firstAttrValue)) {
-              attributes.prefix = firstAttrKey;
-            }
+      const data = node.data ?? {};
+      node.data = data;
+      const attributes = (node.attributes as DirectiveAttributes) || {};
+
+      // set 'prefix' attribute if the first attribute is only value
+      // e.g.
+      //   case 1: lsx(prefix=/path..., ...)    => prefix="/path"
+      //   case 2: lsx(/path, ...)              => prefix="/path"
+      //   case 3: lsx(/foo, prefix=/bar ...)   => prefix="/bar"
+      if (attributes.prefix == null) {
+        const attrEntries = Object.entries(attributes);
+
+        if (attrEntries.length > 0) {
+          const [firstAttrKey, firstAttrValue] = attrEntries[0];
+
+          if (
+            firstAttrValue === '' &&
+            !SUPPORTED_ATTRIBUTES.includes(firstAttrValue)
+          ) {
+            attributes.prefix = firstAttrKey;
           }
         }
-
-        data.hName = 'lsx';
-        data.hProperties = attributes;
       }
-    });
-  };
+
+      data.hName = 'lsx';
+      data.hProperties = attributes;
+    }
+  });
 };
 
 export type LsxRehypePluginParams = {
-  pagePath?: string,
-  isSharedPage?: boolean,
-}
+  pagePath?: string;
+  isSharedPage?: boolean;
+};
 
 const pathResolver = (href: string, basePath: string): string => {
   // exclude absolute URL
@@ -75,7 +93,9 @@ const pathResolver = (href: string, basePath: string): string => {
 };
 
 export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
-  assert.notStrictEqual(options.pagePath, null, 'lsx rehype plugin requires \'pagePath\' option');
+  if (options.pagePath == null) {
+    throw new Error("lsx rehype plugin requires 'pagePath' option");
+  }
 
   return (tree) => {
     if (options.pagePath == null) {
@@ -85,7 +105,7 @@ export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
     const basePagePath = options.pagePath;
     const elements = selectAll('lsx', tree as HastNode);
 
-    elements.forEach((lsxElem) => {
+    for (const lsxElem of elements) {
       if (lsxElem.properties == null) {
         return;
       }
@@ -110,7 +130,7 @@ export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
 
       // resolve relative path
       lsxElem.properties.prefix = decodeURI(pathResolver(prefix, basePagePath));
-    });
+    }
   };
 };
 

+ 36 - 17
packages/remark-lsx/src/client/stores/lsx/lsx.ts

@@ -1,51 +1,71 @@
 import axios from 'axios';
 import useSWRInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
 
-import type { LsxApiOptions, LsxApiParams, LsxApiResponseData } from '../../../interfaces/api';
+import type {
+  LsxApiOptions,
+  LsxApiParams,
+  LsxApiResponseData,
+} from '../../../interfaces/api';
 
 import { type ParseNumOptionResult, parseNumOption } from './parse-num-option';
 
-
 const LOADMORE_PAGES_NUM = 10;
 
-
 export const useSWRxLsx = (
-    pagePath: string, options?: Record<string, string|undefined>, isImmutable?: boolean,
+  pagePath: string,
+  options?: Record<string, string | undefined>,
+  isImmutable?: boolean,
 ): SWRInfiniteResponse<LsxApiResponseData, Error> => {
-
   return useSWRInfinite(
     // key generator
     (pageIndex, previousPageData) => {
-      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+      if (previousPageData != null && previousPageData.pages.length === 0)
+        return null;
 
       // parse num option
       let initialOffsetAndLimit: ParseNumOptionResult | null = null;
       let parseError: Error | undefined;
       try {
-        initialOffsetAndLimit = options?.num != null
-          ? parseNumOption(options.num)
-          : null;
-      }
-      catch (err) {
+        initialOffsetAndLimit =
+          options?.num != null ? parseNumOption(options.num) : null;
+      } catch (err) {
         parseError = err as Error;
       }
 
       // the first loading
       if (pageIndex === 0 || previousPageData == null) {
-        return ['/_api/lsx', pagePath, options, initialOffsetAndLimit?.offset, initialOffsetAndLimit?.limit, parseError?.message, isImmutable];
+        return [
+          '/_api/lsx',
+          pagePath,
+          options,
+          initialOffsetAndLimit?.offset,
+          initialOffsetAndLimit?.limit,
+          parseError?.message,
+          isImmutable,
+        ];
       }
 
       // loading more
-      return ['/_api/lsx', pagePath, options, previousPageData.cursor, LOADMORE_PAGES_NUM, parseError?.message, isImmutable];
+      return [
+        '/_api/lsx',
+        pagePath,
+        options,
+        previousPageData.cursor,
+        LOADMORE_PAGES_NUM,
+        parseError?.message,
+        isImmutable,
+      ];
     },
 
     // fetcher
-    async([endpoint, pagePath, options, offset, limit, parseErrorMessage]) => {
+    async ([endpoint, pagePath, options, offset, limit, parseErrorMessage]) => {
       if (parseErrorMessage != null) {
         throw new Error(parseErrorMessage);
       }
 
-      const apiOptions = Object.assign({}, options, { num: undefined }) as LsxApiOptions;
+      const apiOptions = Object.assign({}, options, {
+        num: undefined,
+      }) as LsxApiOptions;
       const params: LsxApiParams = {
         pagePath,
         offset,
@@ -55,8 +75,7 @@ export const useSWRxLsx = (
       try {
         const res = await axios.get<LsxApiResponseData>(endpoint, { params });
         return res.data;
-      }
-      catch (err) {
+      } catch (err) {
         if (axios.isAxiosError(err)) {
           throw new Error(err.response?.data.message);
         }

+ 10 - 11
packages/remark-lsx/src/client/stores/lsx/parse-num-option.spec.ts

@@ -3,7 +3,6 @@ import { OptionParser } from '@growi/core/dist/remark-plugins';
 import { parseNumOption } from './parse-num-option';
 
 describe('addNumCondition()', () => {
-
   it('set limit with the specified number', () => {
     // setup
     const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
@@ -36,7 +35,9 @@ describe('addNumCondition()', () => {
     const caller = () => parseNumOption('-1:10');
 
     // then
-    expect(caller).toThrowError("The specified option 'num' is { start: -1, end: 10 } : the start must be larger or equal than 1");
+    expect(caller).toThrowError(
+      "The specified option 'num' is { start: -1, end: 10 } : the start must be larger or equal than 1",
+    );
     expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
   });
 
@@ -48,20 +49,19 @@ describe('addNumCondition()', () => {
     const caller = () => parseNumOption('3:2');
 
     // then
-    expect(caller).toThrowError("The specified option 'num' is { start: 3, end: 2 } : the end must be larger or equal than the start");
+    expect(caller).toThrowError(
+      "The specified option 'num' is { start: 3, end: 2 } : the end must be larger or equal than the start",
+    );
     expect(parseRangeSpy).toHaveBeenCalledWith('3:2');
   });
-
 });
 
-
 describe('addNumCondition() set skip and limit with the range string', () => {
-
   it.concurrent.each`
-    optionsNum    | expected
-    ${'1:10'}     | ${{ offset: 0, limit: 10 }}
-    ${'2:2'}      | ${{ offset: 1, limit: 1 }}
-    ${'3:'}       | ${{ offset: 2, limit: -1 }}
+    optionsNum | expected
+    ${'1:10'}  | ${{ offset: 0, limit: 10 }}
+    ${'2:2'}   | ${{ offset: 1, limit: 1 }}
+    ${'3:'}    | ${{ offset: 2, limit: -1 }}
   `("'$optionsNum", ({ optionsNum, expected }) => {
     // setup
     const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
@@ -73,5 +73,4 @@ describe('addNumCondition() set skip and limit with the range string', () => {
     expect(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
     expect(result).toEqual(expected);
   });
-
 });

+ 12 - 5
packages/remark-lsx/src/client/stores/lsx/parse-num-option.ts

@@ -1,12 +1,15 @@
 import { OptionParser } from '@growi/core/dist/remark-plugins';
 
-export type ParseNumOptionResult = { offset: number, limit?: number } | { offset?: number, limit: number };
+export type ParseNumOptionResult =
+  | { offset: number; limit?: number }
+  | { offset?: number; limit: number };
 
 /**
  * add num condition that limit fetched pages
  */
-export const parseNumOption = (optionsNum: string): ParseNumOptionResult | null => {
-
+export const parseNumOption = (
+  optionsNum: string,
+): ParseNumOptionResult | null => {
   if (Number.isInteger(Number(optionsNum))) {
     return { limit: Number(optionsNum) };
   }
@@ -22,11 +25,15 @@ export const parseNumOption = (optionsNum: string): ParseNumOptionResult | null
 
   // check start
   if (start < 1) {
-    throw new Error(`The specified option 'num' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`);
+    throw new Error(
+      `The specified option 'num' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`,
+    );
   }
   // check end
   if (start > end && end > 0) {
-    throw new Error(`The specified option 'num' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`);
+    throw new Error(
+      `The specified option 'num' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`,
+    );
   }
 
   const offset = start - 1;

+ 28 - 23
packages/remark-lsx/src/client/utils/page-node.spec.ts

@@ -6,29 +6,27 @@ import type { PageNode } from '../../interfaces/page-node';
 
 import { generatePageNodeTree } from './page-node';
 
-
 function omitPageData(pageNode: PageNode): Omit<PageNode, 'page'> {
-  const obj = Object.assign({}, pageNode);
-  delete obj.page;
-
-  // omit data in children
-  obj.children = obj.children.map(child => omitPageData(child));
-
-  return obj;
+  // Destructure to omit 'page', and recursively process children
+  const { page, children, ...rest } = pageNode;
+  return {
+    ...rest,
+    children: children.map((child) => omitPageData(child)),
+  };
 }
 
 describe('generatePageNodeTree()', () => {
-
   it("returns when the rootPagePath is '/'", () => {
     // setup
-    const pages: IPageHasId[] = [
-      '/',
-      '/Sandbox',
-    ].map(path => mock<IPageHasId>({ path }));
+    const pages: IPageHasId[] = ['/', '/Sandbox'].map((path) =>
+      mock<IPageHasId>({ path }),
+    );
 
     // when
     const result = generatePageNodeTree('/', pages);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
     // then
     expect(resultWithoutPageData).toStrictEqual([
@@ -47,11 +45,13 @@ describe('generatePageNodeTree()', () => {
       '/Sandbox/level2/level3-1',
       '/Sandbox/level2/level3-2',
       '/Sandbox/level2/level3-3',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
     // when
     const result = generatePageNodeTree('/Sandbox', pages);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
     // then
     expect(resultWithoutPageData).toStrictEqual([
@@ -83,11 +83,13 @@ describe('generatePageNodeTree()', () => {
       '/user/bar',
       '/user/bar/memo/2023/06/01',
       '/user/bar/memo/2023/06/02/memo-test',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
     // when
     const result = generatePageNodeTree('/', pages);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
     // then
     expect(resultWithoutPageData).toStrictEqual([
@@ -145,12 +147,14 @@ describe('generatePageNodeTree()', () => {
       '/user',
       '/user/foo',
       '/user/bar',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
     // when
     const depthRange = OptionParser.parseRange('1:2');
     const result = generatePageNodeTree('/', pages, depthRange);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
     // then
     expect(resultWithoutPageData).toStrictEqual([
@@ -190,12 +194,14 @@ describe('generatePageNodeTree()', () => {
       '/foo/level2',
       '/foo/level2/level3-1',
       '/foo/level2/level3-2',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
     // when
     const depthRange = OptionParser.parseRange('2:3');
     const result = generatePageNodeTree('/', pages, depthRange);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
     // then
     expect(resultWithoutPageData).toStrictEqual([
@@ -214,5 +220,4 @@ describe('generatePageNodeTree()', () => {
       },
     ]);
   });
-
 });

+ 33 - 18
packages/remark-lsx/src/client/utils/page-node.ts

@@ -1,15 +1,13 @@
-import * as url from 'url';
-
 import type { IPageHasId } from '@growi/core';
 import type { ParseRangeResult } from '@growi/core/dist/remark-plugins';
+import { getParentPath as getParentPathCore } from '@growi/core/dist/utils/path-utils';
 import { removeTrailingSlash } from '@growi/core/dist/utils/path-utils';
 
 import type { PageNode } from '../../interfaces/page-node';
 import { getDepthOfPath } from '../../utils/depth-utils';
 
-
 function getParentPath(path: string) {
-  return removeTrailingSlash(decodeURIComponent(url.resolve(path, './')));
+  return removeTrailingSlash(decodeURIComponent(getParentPathCore(path)));
 }
 
 /**
@@ -22,15 +20,18 @@ function getParentPath(path: string) {
  * @memberof Lsx
  */
 function generatePageNode(
-    pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string, depthRange?: ParseRangeResult | null,
+  pathToNodeMap: Record<string, PageNode>,
+  rootPagePath: string,
+  pagePath: string,
+  depthRange?: ParseRangeResult | null,
 ): PageNode | null {
-
   // exclude rootPagePath itself
   if (pagePath === rootPagePath) {
     return null;
   }
 
-  const depthStartToProcess = getDepthOfPath(rootPagePath) + (depthRange?.start ?? 0); // at least 1
+  const depthStartToProcess =
+    getDepthOfPath(rootPagePath) + (depthRange?.start ?? 0); // at least 1
   const currentPageDepth = getDepthOfPath(pagePath);
 
   // return by the depth restriction
@@ -49,11 +50,16 @@ function generatePageNode(
   pathToNodeMap[pagePath] = node;
 
   /*
-    * process recursively for ancestors
-    */
+   * process recursively for ancestors
+   */
   // get or create parent node
   const parentPath = getParentPath(pagePath);
-  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath, depthRange);
+  const parentNode = generatePageNode(
+    pathToNodeMap,
+    rootPagePath,
+    parentPath,
+    depthRange,
+  );
   // associate to patent
   if (parentNode != null) {
     parentNode.children.push(node);
@@ -62,30 +68,39 @@ function generatePageNode(
   return node;
 }
 
-export function generatePageNodeTree(rootPagePath: string, pages: IPageHasId[], depthRange?: ParseRangeResult | null): PageNode[] {
+export function generatePageNodeTree(
+  rootPagePath: string,
+  pages: IPageHasId[],
+  depthRange?: ParseRangeResult | null,
+): PageNode[] {
   const pathToNodeMap: Record<string, PageNode> = {};
 
-  pages.forEach((page) => {
-    const node = generatePageNode(pathToNodeMap, rootPagePath, page.path, depthRange); // this will not be null
+  for (const page of pages) {
+    const node = generatePageNode(
+      pathToNodeMap,
+      rootPagePath,
+      page.path,
+      depthRange,
+    ); // this will not be null
 
     // exclude rootPagePath itself
     if (node == null) {
-      return;
+      continue;
     }
 
     // set the Page substance
     node.page = page;
-  });
+  }
 
   // return root objects
   const rootNodes: PageNode[] = [];
-  Object.keys(pathToNodeMap).forEach((pagePath) => {
+  for (const pagePath in pathToNodeMap) {
     const parentPath = getParentPath(pagePath);
 
     // pick up what parent doesn't exist
-    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
+    if (parentPath === '/' || !(parentPath in pathToNodeMap)) {
       rootNodes.push(pathToNodeMap[pagePath]);
     }
-  });
+  }
   return rootNodes;
 }

+ 16 - 16
packages/remark-lsx/src/interfaces/api.ts

@@ -1,23 +1,23 @@
 import type { IPageHasId } from '@growi/core';
 
 export type LsxApiOptions = {
-  depth?: string,
-  filter?: string,
-  except?: string,
-  sort?: string,
-  reverse?: string,
-}
+  depth?: string;
+  filter?: string;
+  except?: string;
+  sort?: string;
+  reverse?: string;
+};
 
 export type LsxApiParams = {
-  pagePath: string,
-  offset?: number,
-  limit?: number,
-  options?: LsxApiOptions,
-}
+  pagePath: string;
+  offset?: number;
+  limit?: number;
+  options?: LsxApiOptions;
+};
 
 export type LsxApiResponseData = {
-  pages: IPageHasId[],
-  cursor: number,
-  total: number,
-  toppageViewersCount: number,
-}
+  pages: IPageHasId[];
+  cursor: number;
+  total: number;
+  toppageViewersCount: number;
+};

+ 4 - 4
packages/remark-lsx/src/interfaces/page-node.ts

@@ -1,7 +1,7 @@
 import type { IPageHasId } from '@growi/core';
 
 export type PageNode = {
-  pagePath: string,
-  children: PageNode[],
-  page?: IPageHasId,
-}
+  pagePath: string;
+  children: PageNode[];
+  page?: IPageHasId;
+};

+ 18 - 8
packages/remark-lsx/src/server/index.ts

@@ -22,13 +22,12 @@ const lsxValidator = [
       try {
         const jsonData: LsxApiOptions = JSON.parse(options);
 
-        Object.keys(jsonData).forEach((key) => {
+        for (const key in jsonData) {
           jsonData[key] = filterXSS.process(jsonData[key]);
-        });
+        }
 
         return jsonData;
-      }
-      catch (err) {
+      } catch (err) {
         throw new Error('Invalid JSON format in options');
       }
     }),
@@ -46,15 +45,26 @@ const paramValidator = (req: Request, res: Response, next: NextFunction) => {
     return new Error(`Invalid lsx parameter: ${err.param}: ${err.msg}`);
   });
 
-  res.status(400).json({ errors: errs.map(err => err.message) });
+  res.status(400).json({ errors: errs.map((err) => err.message) });
 };
 
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
+// biome-ignore lint/suspicious/noExplicitAny: ignore
 const middleware = (crowi: any, app: any): void => {
-  const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
+  const loginRequired = crowi.require('../middlewares/login-required')(
+    crowi,
+    true,
+    loginRequiredFallback,
+  );
   const accessTokenParser = crowi.accessTokenParser;
 
-  app.get('/_api/lsx', accessTokenParser, loginRequired, lsxValidator, paramValidator, listPages);
+  app.get(
+    '/_api/lsx',
+    accessTokenParser,
+    loginRequired,
+    lsxValidator,
+    paramValidator,
+    listPages,
+  );
 };
 
 export default middleware;

+ 8 - 7
packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.spec.ts

@@ -4,7 +4,6 @@ import { mock } from 'vitest-mock-extended';
 import { addDepthCondition } from './add-depth-condition';
 import type { PageQuery } from './generate-base-query';
 
-
 // mocking modules
 const mocks = vi.hoisted(() => {
   return {
@@ -12,11 +11,11 @@ const mocks = vi.hoisted(() => {
   };
 });
 
-vi.mock('../../../utils/depth-utils', () => ({ getDepthOfPath: mocks.getDepthOfPathMock }));
-
+vi.mock('../../../utils/depth-utils', () => ({
+  getDepthOfPath: mocks.getDepthOfPathMock,
+}));
 
 describe('addDepthCondition()', () => {
-
   it('returns query as-is', () => {
     // setup
     const query = mock<PageQuery>();
@@ -29,7 +28,6 @@ describe('addDepthCondition()', () => {
   });
 
   describe('throws http-errors instance', () => {
-
     it('when the start is smaller than 1', () => {
       // setup
       const query = mock<PageQuery>();
@@ -41,9 +39,12 @@ describe('addDepthCondition()', () => {
       const caller = () => addDepthCondition(query, '/', depthRange);
 
       // then
-      expect(caller).toThrowError(new Error("The specified option 'depth' is { start: -1, end: 10 } : the start must be larger or equal than 1"));
+      expect(caller).toThrowError(
+        new Error(
+          "The specified option 'depth' is { start: -1, end: 10 } : the start must be larger or equal than 1",
+        ),
+      );
       expect(mocks.getDepthOfPathMock).not.toHaveBeenCalled();
     });
-
   });
 });

+ 13 - 4
packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts

@@ -5,8 +5,11 @@ import { getDepthOfPath } from '../../../utils/depth-utils';
 
 import type { PageQuery } from './generate-base-query';
 
-export const addDepthCondition = (query: PageQuery, pagePath: string, depthRange: ParseRangeResult | null): PageQuery => {
-
+export const addDepthCondition = (
+  query: PageQuery,
+  pagePath: string,
+  depthRange: ParseRangeResult | null,
+): PageQuery => {
   if (depthRange == null) {
     return query;
   }
@@ -15,11 +18,17 @@ export const addDepthCondition = (query: PageQuery, pagePath: string, depthRange
 
   // check start
   if (start < 1) {
-    throw createError(400, `The specified option 'depth' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`);
+    throw createError(
+      400,
+      `The specified option 'depth' is { start: ${start}, end: ${end} } : the start must be larger or equal than 1`,
+    );
   }
   // check end
   if (start > end && end > 0) {
-    throw createError(400, `The specified option 'depth' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`);
+    throw createError(
+      400,
+      `The specified option 'depth' is { start: ${start}, end: ${end} } : the end must be larger or equal than the start`,
+    );
   }
 
   const depthOfPath = getDepthOfPath(pagePath);

+ 52 - 51
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts

@@ -5,9 +5,7 @@ import { addNumCondition } from './add-num-condition';
 import type { PageQuery } from './generate-base-query';
 
 describe('addNumCondition() throws 400 http-errors instance', () => {
-
   it("when the param 'offset' is a negative value", () => {
-
     // setup
     const queryMock = mock<PageQuery>();
 
@@ -15,64 +13,67 @@ describe('addNumCondition() throws 400 http-errors instance', () => {
     const caller = () => addNumCondition(queryMock, -1, 10);
 
     // then
-    expect(caller).toThrowError(createError(400, "The param 'offset' must be larger or equal than 0"));
+    expect(caller).toThrowError(
+      createError(400, "The param 'offset' must be larger or equal than 0"),
+    );
     expect(queryMock.skip).not.toHaveBeenCalledWith();
     expect(queryMock.limit).not.toHaveBeenCalledWith();
   });
 });
 
-
 describe('addNumCondition() set skip and limit with', () => {
-
   it.concurrent.each`
-    offset        | limit           | expectedSkip   | expectedLimit
-    ${1}          | ${-1}           | ${1}           | ${null}
-    ${0}          | ${0}            | ${null}        | ${0}
-    ${0}          | ${10}           | ${null}        | ${10}
-    ${NaN}        | ${NaN}          | ${null}        | ${null}
-    ${undefined}  | ${undefined}    | ${null}        | ${50}
-  `("{ offset: $offset, limit: $limit }'", ({
-    offset, limit, expectedSkip, expectedLimit,
-  }) => {
-    // setup
-    const queryMock = mock<PageQuery>();
+    offset        | limit         | expectedSkip | expectedLimit
+    ${1}          | ${-1}         | ${1}         | ${null}
+    ${0}          | ${0}          | ${null}      | ${0}
+    ${0}          | ${10}         | ${null}      | ${10}
+    ${Number.NaN} | ${Number.NaN} | ${null}      | ${null}
+    ${undefined}  | ${undefined}  | ${null}      | ${50}
+  `(
+    "{ offset: $offset, limit: $limit }'",
+    ({ offset, limit, expectedSkip, expectedLimit }) => {
+      // setup
+      const queryMock = mock<PageQuery>();
 
-    // result for q.skip()
-    const querySkipResultMock = mock<PageQuery>();
-    queryMock.skip.calledWith(expectedSkip).mockImplementation(() => querySkipResultMock);
-    // result for q.limit()
-    const queryLimitResultMock = mock<PageQuery>();
-    queryMock.limit.calledWith(expectedLimit).mockImplementation(() => queryLimitResultMock);
-    // result for q.skil().limit()
-    const querySkipAndLimitResultMock = mock<PageQuery>();
-    querySkipResultMock.limit.calledWith(expectedLimit).mockImplementation(() => querySkipAndLimitResultMock);
+      // result for q.skip()
+      const querySkipResultMock = mock<PageQuery>();
+      queryMock.skip
+        .calledWith(expectedSkip)
+        .mockImplementation(() => querySkipResultMock);
+      // result for q.limit()
+      const queryLimitResultMock = mock<PageQuery>();
+      queryMock.limit
+        .calledWith(expectedLimit)
+        .mockImplementation(() => queryLimitResultMock);
+      // result for q.skil().limit()
+      const querySkipAndLimitResultMock = mock<PageQuery>();
+      querySkipResultMock.limit
+        .calledWith(expectedLimit)
+        .mockImplementation(() => querySkipAndLimitResultMock);
 
-    // when
-    const result = addNumCondition(queryMock, offset, limit);
+      // when
+      const result = addNumCondition(queryMock, offset, limit);
 
-    // then
-    if (expectedSkip != null) {
-      expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
-      if (expectedLimit != null) {
-        expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
-        expect(result).toEqual(querySkipAndLimitResultMock); // q.skip().limit()
-      }
-      else {
-        expect(querySkipResultMock.limit).not.toHaveBeenCalled();
-        expect(result).toEqual(querySkipResultMock); // q.skil()
-      }
-    }
-    else {
-      expect(queryMock.skip).not.toHaveBeenCalled();
-      if (expectedLimit != null) {
-        expect(queryMock.limit).toHaveBeenCalledWith(expectedLimit);
-        expect(result).toEqual(queryLimitResultMock); // q.limit()
+      // then
+      if (expectedSkip != null) {
+        expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
+        if (expectedLimit != null) {
+          expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
+          expect(result).toEqual(querySkipAndLimitResultMock); // q.skip().limit()
+        } else {
+          expect(querySkipResultMock.limit).not.toHaveBeenCalled();
+          expect(result).toEqual(querySkipResultMock); // q.skil()
+        }
+      } else {
+        expect(queryMock.skip).not.toHaveBeenCalled();
+        if (expectedLimit != null) {
+          expect(queryMock.limit).toHaveBeenCalledWith(expectedLimit);
+          expect(result).toEqual(queryLimitResultMock); // q.limit()
+        } else {
+          expect(queryMock.limit).not.toHaveBeenCalled();
+          expect(result).toEqual(queryMock); // as-is
+        }
       }
-      else {
-        expect(queryMock.limit).not.toHaveBeenCalled();
-        expect(result).toEqual(queryMock); // as-is
-      }
-    }
-  });
-
+    },
+  );
 });

+ 5 - 3
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts

@@ -2,14 +2,16 @@ import createError from 'http-errors';
 
 import type { PageQuery } from './generate-base-query';
 
-
 const DEFAULT_PAGES_NUM = 50;
 
 /**
  * add num condition that limit fetched pages
  */
-export const addNumCondition = (query: PageQuery, offset = 0, limit = DEFAULT_PAGES_NUM): PageQuery => {
-
+export const addNumCondition = (
+  query: PageQuery,
+  offset = 0,
+  limit = DEFAULT_PAGES_NUM,
+): PageQuery => {
   // check offset
   if (offset < 0) {
     throw createError(400, "The param 'offset' must be larger or equal than 0");

+ 14 - 3
packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts

@@ -9,15 +9,26 @@ import type { PageQuery } from './generate-base-query';
  * If only the sort key is specified, the sort order is the ascending order.
  *
  */
-export const addSortCondition = (query: PageQuery, optionsSortArg?: string, optionsReverse?: string): PageQuery => {
+export const addSortCondition = (
+  query: PageQuery,
+  optionsSortArg?: string,
+  optionsReverse?: string,
+): PageQuery => {
   // init sort key
   const optionsSort = optionsSortArg ?? 'path';
 
   // the default sort order
   const isReversed = optionsReverse === 'true';
 
-  if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
-    throw createError(400, `The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
+  if (
+    optionsSort !== 'path' &&
+    optionsSort !== 'createdAt' &&
+    optionsSort !== 'updatedAt'
+  ) {
+    throw createError(
+      400,
+      `The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`,
+    );
   }
 
   const sortOption = {};

+ 11 - 5
packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts

@@ -5,14 +5,20 @@ import type { Document, Query } from 'mongoose';
 export type PageQuery = Query<IPageHasId[], Document>;
 
 export type PageQueryBuilder = {
-  query: PageQuery,
-  addConditionToListOnlyDescendants: (pagePath: string) => PageQueryBuilder,
-  addConditionToFilteringByViewerForList: (builder: PageQueryBuilder, user: IUser) => PageQueryBuilder,
+  query: PageQuery;
+  addConditionToListOnlyDescendants: (pagePath: string) => PageQueryBuilder;
+  addConditionToFilteringByViewerForList: (
+    builder: PageQueryBuilder,
+    user: IUser,
+  ) => PageQueryBuilder;
 };
 
-export const generateBaseQuery = async(pagePath: string, user: IUser): Promise<PageQueryBuilder> => {
+export const generateBaseQuery = async (
+  pagePath: string,
+  user: IUser,
+): Promise<PageQueryBuilder> => {
   const Page = model<IPageHasId>('Page');
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
   const PageAny = Page as any;
 
   const baseQuery = Page.find();

+ 2 - 4
packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts

@@ -1,7 +1,7 @@
 import type { IPage } from '@growi/core';
 import { model } from 'mongoose';
 
-export const getToppageViewersCount = async(): Promise<number> => {
+export const getToppageViewersCount = async (): Promise<number> => {
   const Page = model<IPage>('Page');
 
   const aggRes = await Page.aggregate<{ count: number }>([
@@ -9,7 +9,5 @@ export const getToppageViewersCount = async(): Promise<number> => {
     { $project: { count: { $size: '$seenUsers' } } },
   ]);
 
-  return aggRes.length > 0
-    ? aggRes[0].count
-    : 1;
+  return aggRes.length > 0 ? aggRes[0].count : 1;
 };

+ 23 - 16
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -3,14 +3,15 @@ import type { Request, Response } from 'express';
 import createError from 'http-errors';
 import { mock } from 'vitest-mock-extended';
 
-import type { LsxApiResponseData, LsxApiParams } from '../../../interfaces/api';
+import type { LsxApiParams, LsxApiResponseData } from '../../../interfaces/api';
 
 import type { PageQuery, PageQueryBuilder } from './generate-base-query';
 
 import { listPages } from '.';
 
-interface IListPagesRequest extends Request<undefined, undefined, undefined, LsxApiParams> {
-  user: IUser,
+interface IListPagesRequest
+  extends Request<undefined, undefined, undefined, LsxApiParams> {
+  user: IUser;
 }
 
 // mocking modules
@@ -23,15 +24,21 @@ const mocks = vi.hoisted(() => {
   };
 });
 
-vi.mock('./add-num-condition', () => ({ addNumCondition: mocks.addNumConditionMock }));
-vi.mock('./add-sort-condition', () => ({ addSortCondition: mocks.addSortConditionMock }));
-vi.mock('./generate-base-query', () => ({ generateBaseQuery: mocks.generateBaseQueryMock }));
-vi.mock('./get-toppage-viewers-count', () => ({ getToppageViewersCount: mocks.getToppageViewersCountMock }));
-
+vi.mock('./add-num-condition', () => ({
+  addNumCondition: mocks.addNumConditionMock,
+}));
+vi.mock('./add-sort-condition', () => ({
+  addSortCondition: mocks.addSortConditionMock,
+}));
+vi.mock('./generate-base-query', () => ({
+  generateBaseQuery: mocks.generateBaseQueryMock,
+}));
+vi.mock('./get-toppage-viewers-count', () => ({
+  getToppageViewersCount: mocks.getToppageViewersCountMock,
+}));
 
 describe('listPages', () => {
-
-  it("returns 400 HTTP response when the query 'pagePath' is undefined", async() => {
+  it("returns 400 HTTP response when the query 'pagePath' is undefined", async () => {
     // setup
     const reqMock = mock<IListPagesRequest>();
     const resMock = mock<Response>();
@@ -48,7 +55,6 @@ describe('listPages', () => {
   });
 
   describe('with num option', () => {
-
     const reqMock = mock<IListPagesRequest>();
     reqMock.query = { pagePath: '/Sandbox' };
 
@@ -60,7 +66,7 @@ describe('listPages', () => {
     const queryMock = mock<PageQuery>();
     builderMock.query = queryMock;
 
-    it('returns 200 HTTP response', async() => {
+    it('returns 200 HTTP response', async () => {
       // setup query.clone().count()
       const queryClonedMock = mock<PageQuery>();
       queryMock.clone.mockReturnValue(queryClonedMock);
@@ -98,7 +104,7 @@ describe('listPages', () => {
       expect(resStatusMock.send).toHaveBeenCalledWith(expectedResponseData);
     });
 
-    it('returns 500 HTTP response when an unexpected error occured', async() => {
+    it('returns 500 HTTP response when an unexpected error occured', async () => {
       // setup
       const reqMock = mock<IListPagesRequest>();
       reqMock.query = { pagePath: '/Sandbox' };
@@ -122,10 +128,12 @@ describe('listPages', () => {
       expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
       expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
       expect(resMock.status).toHaveBeenCalledOnce();
-      expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
+      expect(resStatusMock.send).toHaveBeenCalledWith(
+        'An internal server error occurred.',
+      );
     });
 
-    it('returns 400 HTTP response when the value is invalid', async() => {
+    it('returns 400 HTTP response when the value is invalid', async () => {
       // setup
       const reqMock = mock<IListPagesRequest>();
       reqMock.query = { pagePath: '/Sandbox' };
@@ -151,6 +159,5 @@ describe('listPages', () => {
       expect(resMock.status).toHaveBeenCalledOnce();
       expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
     });
-
   });
 });

+ 41 - 28
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -1,4 +1,3 @@
-
 import type { IUser } from '@growi/core';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
 import { pathUtils } from '@growi/core/dist/utils';
@@ -11,34 +10,41 @@ import type { LsxApiParams, LsxApiResponseData } from '../../../interfaces/api';
 import { addDepthCondition } from './add-depth-condition';
 import { addNumCondition } from './add-num-condition';
 import { addSortCondition } from './add-sort-condition';
-import { generateBaseQuery, type PageQuery } from './generate-base-query';
+import { type PageQuery, generateBaseQuery } from './generate-base-query';
 import { getToppageViewersCount } from './get-toppage-viewers-count';
 
-
 const { addTrailingSlash, removeTrailingSlash } = pathUtils;
 
 /**
  * add filter condition that filter fetched pages
  */
-function addFilterCondition(query, pagePath, optionsFilter, isExceptFilter = false): PageQuery {
+function addFilterCondition(
+  query,
+  pagePath,
+  optionsFilter,
+  isExceptFilter = false,
+): PageQuery {
   // when option strings is 'filter=', the option value is true
   if (optionsFilter == null || optionsFilter === true) {
-    throw createError(400, 'filter option require value in regular expression.');
+    throw createError(
+      400,
+      'filter option require value in regular expression.',
+    );
   }
 
   const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
 
-  let filterPath;
+  let filterPath: RegExp;
   try {
     if (optionsFilter.charAt(0) === '^') {
       // move '^' to the first of path
-      filterPath = new RegExp(`^${pagePathForRegexp}${optionsFilter.slice(1, optionsFilter.length)}`);
-    }
-    else {
+      filterPath = new RegExp(
+        `^${pagePathForRegexp}${optionsFilter.slice(1, optionsFilter.length)}`,
+      );
+    } else {
       filterPath = new RegExp(`^${pagePathForRegexp}.*${optionsFilter}`);
     }
-  }
-  catch (err) {
+  } catch (err) {
     throw createError(400, err);
   }
 
@@ -56,12 +62,15 @@ function addExceptCondition(query, pagePath, optionsFilter): PageQuery {
   return addFilterCondition(query, pagePath, optionsFilter, true);
 }
 
-interface IListPagesRequest extends Request<undefined, undefined, undefined, LsxApiParams> {
-  user: IUser,
+interface IListPagesRequest
+  extends Request<undefined, undefined, undefined, LsxApiParams> {
+  user: IUser;
 }
 
-
-export const listPages = async(req: IListPagesRequest, res: Response): Promise<Response> => {
+export const listPages = async (
+  req: IListPagesRequest,
+  res: Response,
+): Promise<Response> => {
   const user = req.user;
 
   if (req.query.pagePath == null) {
@@ -75,25 +84,27 @@ export const listPages = async(req: IListPagesRequest, res: Response): Promise<R
     options: req.query?.options ?? {},
   };
 
-  const {
-    pagePath, offset, limit, options,
-  } = params;
+  const { pagePath, offset, limit, options } = params;
   const builder = await generateBaseQuery(params.pagePath, user);
 
   // count viewers of `/`
-  let toppageViewersCount;
+  let toppageViewersCount: number;
   try {
     toppageViewersCount = await getToppageViewersCount();
-  }
-  catch (error) {
-    return res.status(500).send(error);
+  } catch (error) {
+    console.error('Error occurred in getToppageViewersCount:', error);
+    return res.status(500).send('An internal server error occurred.');
   }
 
   let query = builder.query;
   try {
     // depth
     if (options?.depth != null) {
-      query = addDepthCondition(query, params.pagePath, OptionParser.parseRange(options.depth));
+      query = addDepthCondition(
+        query,
+        params.pagePath,
+        OptionParser.parseRange(options.depth),
+      );
     }
     // filter
     if (options?.filter != null) {
@@ -115,15 +126,17 @@ export const listPages = async(req: IListPagesRequest, res: Response): Promise<R
     const cursor = (offset ?? 0) + pages.length;
 
     const responseData: LsxApiResponseData = {
-      pages, cursor, total, toppageViewersCount,
+      pages,
+      cursor,
+      total,
+      toppageViewersCount,
     };
     return res.status(200).send(responseData);
-  }
-  catch (error) {
+  } catch (error) {
+    console.error('Error occurred while processing listPages request:', error);
     if (isHttpError(error)) {
       return res.status(error.status).send(error.message);
     }
-    return res.status(500).send(error.message);
+    return res.status(500).send('An internal server error occurred.');
   }
-
 };

+ 0 - 2
packages/remark-lsx/src/utils/depth-utils.spec.ts

@@ -1,7 +1,6 @@
 import { getDepthOfPath } from './depth-utils';
 
 describe('getDepthOfPath()', () => {
-
   it('returns 0 when the path does not include slash', () => {
     // when
     const result = getDepthOfPath('Sandbox');
@@ -9,5 +8,4 @@ describe('getDepthOfPath()', () => {
     // then
     expect(result).toBe(0);
   });
-
 });

+ 2 - 6
packages/remark-lsx/tsconfig.json

@@ -4,9 +4,7 @@
   "compilerOptions": {
     "jsx": "react-jsx",
 
-    "types": [
-      "vitest/globals"
-    ],
+    "types": ["vitest/globals"],
 
     /* TODO: remove below flags for strict checking */
     "strict": false,
@@ -15,7 +13,5 @@
     "noImplicitAny": false,
     "noImplicitOverride": true
   },
-  "include": [
-    "src"
-  ]
+  "include": ["src"]
 }

+ 1 - 3
packages/remark-lsx/vite.server.config.ts

@@ -21,9 +21,7 @@ export default defineConfig({
     outDir: 'dist/server',
     sourcemap: true,
     lib: {
-      entry: [
-        'src/server/index.ts',
-      ],
+      entry: ['src/server/index.ts'],
       name: 'remark-lsx-libs',
       formats: ['cjs'],
     },

+ 1 - 3
packages/remark-lsx/vitest.config.ts

@@ -2,9 +2,7 @@ import tsconfigPaths from 'vite-tsconfig-paths';
 import { defineConfig } from 'vitest/config';
 
 export default defineConfig({
-  plugins: [
-    tsconfigPaths(),
-  ],
+  plugins: [tsconfigPaths()],
   test: {
     environment: 'node',
     clearMocks: true,

+ 1 - 1
packages/slack/.eslintignore

@@ -1 +1 @@
-/dist/**
+*

+ 0 - 5
packages/slack/.eslintrc.cjs

@@ -1,5 +0,0 @@
-module.exports = {
-  extends: [
-    'plugin:vitest/recommended',
-  ],
-};

+ 2 - 2
packages/slack/package.json

@@ -43,13 +43,13 @@
     "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
-    "lint:js": "eslint **/*.{js,ts}",
+    "lint:js": "biome check",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "npm-run-all -p lint:*",
     "test": "vitest run --coverage"
   },
   "dependencies": {
-    "@slack/oauth": "^2.0.1",
+    "@slack/oauth": "^3.0.3",
     "@slack/web-api": "^6.2.4",
     "@types/bunyan": "^1.8.10",
     "@types/http-errors": "^2.0.3",

+ 3 - 9
packages/slack/src/consts/index.ts

@@ -2,9 +2,7 @@ export const REQUEST_TIMEOUT_FOR_GTOP = 10000;
 
 export const REQUEST_TIMEOUT_FOR_PTOG = 10000;
 
-export const supportedSlackCommands: string[] = [
-  '/growi',
-];
+export const supportedSlackCommands: string[] = ['/growi'];
 
 export const supportedGrowiCommands: string[] = [
   'search',
@@ -13,17 +11,13 @@ export const supportedGrowiCommands: string[] = [
   'help',
 ];
 
-export const defaultSupportedCommandsNameForBroadcastUse: string[] = [
-  'search',
-];
+export const defaultSupportedCommandsNameForBroadcastUse: string[] = ['search'];
 
 export const defaultSupportedCommandsNameForSingleUse: string[] = [
   'note',
   'keep',
 ];
 
-export const defaultSupportedSlackEventActions: string[] = [
-  'unfurl',
-];
+export const defaultSupportedSlackEventActions: string[] = ['unfurl'];
 
 export * from './required-scopes';

+ 3 - 3
packages/slack/src/interfaces/channel.ts

@@ -1,6 +1,6 @@
 export type IChannel = {
-  id: string,
-  name: string,
-}
+  id: string;
+  name: string;
+};
 
 export type IChannelOptionalId = Omit<IChannel, 'id'> & Partial<IChannel>;

+ 3 - 3
packages/slack/src/interfaces/connection-status.ts

@@ -1,4 +1,4 @@
 export type ConnectionStatus = {
-  error?: Error,
-  workspaceName?: string,
-}
+  error?: Error;
+  workspaceName?: string;
+};

+ 2 - 2
packages/slack/src/interfaces/growi-bot-event.ts

@@ -1,4 +1,4 @@
 export interface GrowiBotEvent<T> {
-  eventType: string,
-  event: T,
+  eventType: string;
+  event: T;
 }

+ 8 - 2
packages/slack/src/interfaces/growi-command-processor.ts

@@ -2,8 +2,14 @@ import type { AuthorizeResult } from '@slack/oauth';
 
 import type { GrowiCommand } from './growi-command';
 
-export interface GrowiCommandProcessor<ProcessCommandContext = {[key: string]: string}> {
+export interface GrowiCommandProcessor<
+  ProcessCommandContext = { [key: string]: string },
+> {
   shouldHandleCommand(growiCommand?: GrowiCommand): boolean;
 
-  processCommand(growiCommand: GrowiCommand, authorizeResult: AuthorizeResult, context?: ProcessCommandContext): Promise<void>
+  processCommand(
+    growiCommand: GrowiCommand,
+    authorizeResult: AuthorizeResult,
+    context?: ProcessCommandContext,
+  ): Promise<void>;
 }

+ 4 - 4
packages/slack/src/interfaces/growi-command.ts

@@ -1,6 +1,6 @@
 export type GrowiCommand = {
-  text: string,
-  responseUrl: string,
-  growiCommandType: string,
-  growiCommandArgs: string[],
+  text: string;
+  responseUrl: string;
+  growiCommandType: string;
+  growiCommandArgs: string[];
 };

+ 9 - 6
packages/slack/src/interfaces/growi-interaction-processor.ts

@@ -1,7 +1,6 @@
 import type { AuthorizeResult } from '@slack/oauth';
 
-import { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
-
+import type { InteractionPayloadAccessor } from '../utils/interaction-payload-accessor';
 
 export interface InteractionHandledResult<V> {
   result?: V;
@@ -9,10 +8,14 @@ export interface InteractionHandledResult<V> {
 }
 
 export interface GrowiInteractionProcessor<V> {
-
-  shouldHandleInteraction(interactionPayloadAccessor: InteractionPayloadAccessor): boolean;
+  shouldHandleInteraction(
+    interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): boolean;
 
   processInteraction(
-    authorizeResult: AuthorizeResult, interactionPayload: any, interactionPayloadAccessor: InteractionPayloadAccessor): Promise<InteractionHandledResult<V>>;
-
+    authorizeResult: AuthorizeResult,
+    // biome-ignore lint/suspicious/noExplicitAny: ignore
+    interactionPayload: any,
+    interactionPayloadAccessor: InteractionPayloadAccessor,
+  ): Promise<InteractionHandledResult<V>>;
 }

+ 12 - 11
packages/slack/src/interfaces/request-between-growi-and-proxy.ts

@@ -3,23 +3,24 @@ import type { Request } from 'express';
 export interface BlockKitRequest {
   // Block Kit properties
   body: {
-    view?: string,
-    blocks?: string
-  },
+    view?: string;
+    blocks?: string;
+  };
 }
 
-export type RequestFromGrowi = Request & BlockKitRequest & {
-  // appended by GROWI
-  headers:{'x-growi-gtop-tokens'?:string},
+export type RequestFromGrowi = Request &
+  BlockKitRequest & {
+    // appended by GROWI
+    headers: { 'x-growi-gtop-tokens'?: string };
 
-  // will be extracted from header
-  tokenGtoPs: string[],
-};
+    // will be extracted from header
+    tokenGtoPs: string[];
+  };
 
 export type RequestFromProxy = Request & {
   // appended by Proxy
-  headers:{'x-growi-ptog-token'?:string},
+  headers: { 'x-growi-ptog-token'?: string };
 
   // will be extracted from header
-  tokenPtoG: string[],
+  tokenPtoG: string[];
 };

+ 10 - 4
packages/slack/src/interfaces/request-from-slack.ts

@@ -1,16 +1,22 @@
 import type { Request } from 'express';
 
 export interface IInteractionPayloadAccessor {
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
   firstAction(): any;
 }
 
 export type RequestFromSlack = Request & {
   // appended by slack
-  headers:{'x-slack-signature'?:string, 'x-slack-request-timestamp':number},
+  headers: {
+    'x-slack-signature'?: string;
+    'x-slack-request-timestamp': number;
+  };
 
   // appended by GROWI or slackbot-proxy
-  slackSigningSecret?:string,
+  slackSigningSecret?: string;
 
-  interactionPayload?: any,
-  interactionPayloadAccessor?: any,
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  interactionPayload?: any;
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  interactionPayloadAccessor?: any;
 };

+ 4 - 4
packages/slack/src/interfaces/respond-util.ts

@@ -1,8 +1,8 @@
 import type { RespondBodyForResponseUrl } from './response-url';
 
 export interface IRespondUtil {
-  respond(body: RespondBodyForResponseUrl): Promise<void>,
-  respondInChannel(body: RespondBodyForResponseUrl): Promise<void>,
-  replaceOriginal(body: RespondBodyForResponseUrl): Promise<void>,
-  deleteOriginal(): Promise<void>,
+  respond(body: RespondBodyForResponseUrl): Promise<void>;
+  respondInChannel(body: RespondBodyForResponseUrl): Promise<void>;
+  replaceOriginal(body: RespondBodyForResponseUrl): Promise<void>;
+  deleteOriginal(): Promise<void>;
 }

+ 3 - 3
packages/slack/src/interfaces/response-url.ts

@@ -1,6 +1,6 @@
-import type { KnownBlock, Block } from '@slack/web-api';
+import type { Block, KnownBlock } from '@slack/web-api';
 
 export type RespondBodyForResponseUrl = {
-  text?: string,
-  blocks?: (KnownBlock | Block)[],
+  text?: string;
+  blocks?: (KnownBlock | Block)[];
 };

+ 1 - 1
packages/slack/src/interfaces/slackbot-types.ts

@@ -4,4 +4,4 @@ export const SlackbotType = {
   CUSTOM_WITH_PROXY: 'customBotWithProxy',
 } as const;
 
-export type SlackbotType = typeof SlackbotType[keyof typeof SlackbotType]
+export type SlackbotType = (typeof SlackbotType)[keyof typeof SlackbotType];

Некоторые файлы не были показаны из-за большого количества измененных файлов