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

Merge branch 'master' into feat/save-attachment-to-vector-store

Shun Miyazawa 10 месяцев назад
Родитель
Сommit
178139d7fd
65 измененных файлов с 1276 добавлено и 932 удалено
  1. 3 0
      .vscode/settings.json
  2. 6 4
      README.md
  3. 13 10
      README_JP.md
  4. 0 15
      apps/app/playwright/20-basic-features/presentation.spec.ts
  5. 3 35
      apps/app/playwright/20-basic-features/use-tools.spec.ts
  6. 12 6
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/index.ts
  7. 10 1
      apps/app/src/features/page-bulk-export/server/service/page-bulk-export-job-cron/request-pdf-converter.ts
  8. 19 9
      apps/app/src/server/routes/apiv3/slack-integration.js
  9. 10 7
      apps/pdf-converter/src/controllers/pdf.ts
  10. 1 1
      apps/pdf-converter/src/server.ts
  11. 23 12
      apps/pdf-converter/src/service/pdf-convert.ts
  12. 1 1
      apps/pdf-converter/tsconfig.json
  13. 1 1
      apps/slackbot-proxy/package.json
  14. 1 0
      apps/slackbot-proxy/src/services/InstallerService.ts
  15. 4 5
      biome.json
  16. 1 1
      package.json
  17. 11 4
      packages/pdf-converter-client/src/index.ts
  18. 1 1
      packages/remark-lsx/.eslintignore
  19. 0 18
      packages/remark-lsx/.eslintrc.cjs
  20. 1 1
      packages/remark-lsx/package.json
  21. 147 120
      packages/remark-lsx/src/client/components/Lsx.tsx
  22. 12 13
      packages/remark-lsx/src/client/components/LsxPageList/LsxListView.tsx
  23. 21 14
      packages/remark-lsx/src/client/components/LsxPageList/LsxPage.tsx
  24. 9 7
      packages/remark-lsx/src/client/components/lsx-context.ts
  25. 68 48
      packages/remark-lsx/src/client/services/renderer/lsx.ts
  26. 36 17
      packages/remark-lsx/src/client/stores/lsx/lsx.ts
  27. 10 11
      packages/remark-lsx/src/client/stores/lsx/parse-num-option.spec.ts
  28. 12 5
      packages/remark-lsx/src/client/stores/lsx/parse-num-option.ts
  29. 28 23
      packages/remark-lsx/src/client/utils/page-node.spec.ts
  30. 33 18
      packages/remark-lsx/src/client/utils/page-node.ts
  31. 16 16
      packages/remark-lsx/src/interfaces/api.ts
  32. 4 4
      packages/remark-lsx/src/interfaces/page-node.ts
  33. 18 8
      packages/remark-lsx/src/server/index.ts
  34. 8 7
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.spec.ts
  35. 13 4
      packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts
  36. 52 51
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts
  37. 5 3
      packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts
  38. 14 3
      packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts
  39. 11 5
      packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts
  40. 2 4
      packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts
  41. 20 15
      packages/remark-lsx/src/server/routes/list-pages/index.spec.ts
  42. 37 26
      packages/remark-lsx/src/server/routes/list-pages/index.ts
  43. 0 2
      packages/remark-lsx/src/utils/depth-utils.spec.ts
  44. 2 6
      packages/remark-lsx/tsconfig.json
  45. 1 3
      packages/remark-lsx/vite.server.config.ts
  46. 1 3
      packages/remark-lsx/vitest.config.ts
  47. 1 1
      packages/slack/package.json
  48. 25 13
      packages/slack/src/utils/respond-util-factory.ts
  49. 43 0
      packages/slack/src/utils/response-url-validator.ts
  50. 1 1
      packages/ui/.eslintignore
  51. 0 5
      packages/ui/.eslintrc.cjs
  52. 3 8
      packages/ui/package.json
  53. 46 28
      packages/ui/src/components/Attachment.tsx
  54. 8 2
      packages/ui/src/components/LoadingSpinner.tsx
  55. 67 38
      packages/ui/src/components/PagePath/PageListMeta.tsx
  56. 42 31
      packages/ui/src/components/PagePath/PagePathLabel.tsx
  57. 80 34
      packages/ui/src/components/UserPicture.tsx
  58. 1 1
      packages/ui/src/interfaces/breakpoints.ts
  59. 4 4
      packages/ui/src/interfaces/popper-data.ts
  60. 12 7
      packages/ui/src/utils/browser-utils.ts
  61. 1 3
      packages/ui/src/utils/use-fullscreen.ts
  62. 7 9
      packages/ui/src/utils/use-rect.ts
  63. 1 3
      packages/ui/tsconfig.json
  64. 1 1
      packages/ui/vite.config.ts
  65. 232 175
      pnpm-lock.yaml

+ 3 - 0
.vscode/settings.json

@@ -14,10 +14,13 @@
   "editor.codeActionsOnSave": {
   "editor.codeActionsOnSave": {
     "source.fixAll.eslint": "explicit",
     "source.fixAll.eslint": "explicit",
     "source.fixAll.biome": "explicit",
     "source.fixAll.biome": "explicit",
+    "source.organizeImports.biome": "explicit",
     "source.fixAll.markdownlint": "explicit",
     "source.fixAll.markdownlint": "explicit",
     "source.fixAll.stylelint": "explicit"
     "source.fixAll.stylelint": "explicit"
   },
   },
 
 
+  "editor.formatOnSave": true,
+
   "githubPullRequests.ignoredPullRequestBranches": [
   "githubPullRequests.ignoredPullRequestBranches": [
     "master"
     "master"
   ],
   ],

+ 6 - 4
README.md

@@ -2,12 +2,12 @@
 
 
 <p align="center">
 <p align="center">
   <a href="https://growi.org">
   <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>
   </a>
 </p>
 </p>
 <p align="center">
 <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>
 
 
 <p align="center">
 <p align="center">
@@ -16,8 +16,10 @@
 
 
 # GROWI
 # 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/)
 [![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                                                     |
 |                                                     demonstration                                                     |
 | :-------------------------------------------------------------------------------------------------------------------: |
 | :-------------------------------------------------------------------------------------------------------------------: |

+ 13 - 10
README_JP.md

@@ -1,13 +1,14 @@
 - [English 🇺🇸](./README.md)
 - [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">
 <p align="center">
   <a href="https://docs.growi.org">ドキュメント</a> / <a href="https://demo.growi.org">デモ</a>
   <a href="https://docs.growi.org">ドキュメント</a> / <a href="https://demo.growi.org">デモ</a>
@@ -15,8 +16,10 @@
 
 
 # GROWI
 # 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/)
 [![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)
 
 
 |                                                 デモンストレーション                                                 |
 |                                                 デモンストレーション                                                 |
 | :-------------------------------------------------------------------------------------------------------------------: |
 | :-------------------------------------------------------------------------------------------------------------------: |

+ 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
   // check the content of the h1
   await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
   await expect(page.getByRole('application').getByRole('heading', { level: 1 }))
     .toHaveText(/Welcome to GROWI/);
     .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();
   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 openPageItemControl(page);
   await page.getByTestId('open-page-delete-modal-btn').click();
   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 }) => {
 test('PageDuplicateModal is shown successfully', async({ page }) => {

+ 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';
   compressExtension = 'tar.gz';
 
 
   // temporal path of local fs to output page files before upload
   // 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';
   tmpOutputRootDir = '/tmp/page-bulk-export';
 
 
   // Keep track of the stream executed for PageBulkExportJob to destroy it on job failure.
   // 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
    * @param isHtmlPath whether the tmp output path is for html files
    */
    */
   getTmpOutputDir(pageBulkExportJob: PageBulkExportJobDocument, isHtmlPath = false): string {
   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 = [
     const promises = [
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
       PageBulkExportPageSnapshot.deleteMany({ pageBulkExportJob }),
-      // delete /tmp/page-bulk-export/{jobId} dir
       fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
       fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob), { recursive: true, force: true }),
     ];
     ];
 
 
+    // clean up html files exported for PDF conversion
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
     if (pageBulkExportJob.format === PageBulkExportFormat.pdf) {
       promises.push(
       promises.push(
-        // delete /tmp/page-bulk-export/html/{jobId} dir
         fs.promises.rm(this.getTmpOutputDir(pageBulkExportJob, true), { recursive: true, force: true }),
         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');
     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 exportJobExpirationSeconds = configManager.getConfig('app:bulkExportJobExpirationSeconds');
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   const bulkExportJobExpirationDate = new Date(jobCreatedAt.getTime() + exportJobExpirationSeconds * 1000);
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
   let pdfConvertStatus: PdfCtrlSyncJobStatusBodyStatus = PdfCtrlSyncJobStatusBodyStatus.HTML_EXPORT_IN_PROGRESS;
@@ -41,7 +47,10 @@ export async function requestPdfConverter(pageBulkExportJob: PageBulkExportJobDo
     }
     }
 
 
     const res = await pdfCtrlSyncJobStatus({
     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') });
     }, { baseURL: configManager.getConfig('app:pageBulkExportPdfConverterUri') });
 
 
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {
     if (res.data.status === PdfCtrlSyncJobStatus202Status.PDF_EXPORT_DONE) {

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

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

@@ -3,7 +3,7 @@ import { Controller } from '@tsed/di';
 import { InternalServerError } from '@tsed/exceptions';
 import { InternalServerError } from '@tsed/exceptions';
 import { Logger } from '@tsed/logger';
 import { Logger } from '@tsed/logger';
 import {
 import {
-  Post, Returns, Enum, Description,
+  Post, Returns, Enum, Description, Required,
 } from '@tsed/schema';
 } from '@tsed/schema';
 
 
 import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
 import PdfConvertService, { JobStatusSharedWithGrowi, JobStatus } from '../service/pdf-convert.js';
@@ -28,20 +28,23 @@ class PdfCtrl {
     Return resulting status of job to GROWI.
     Return resulting status of job to GROWI.
   `)
   `)
   async syncJobStatus(
   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);
     const expirationDate = new Date(expirationDateStr);
     try {
     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
       const status = this.pdfConvertService.getJobStatus(jobId); // get status before cleanup
       this.pdfConvertService.cleanUpJobList();
       this.pdfConvertService.cleanUpJobList();
       return { status };
       return { status };
     }
     }
     catch (err) {
     catch (err) {
       this.logger.error('Failed to register or update job', 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 {
 class Server {
 
 
   @Inject()
   @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.
    * 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.
    * 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 expirationDate expiration date of job
    * @param status status 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);
     const isJobNew = !(jobId in this.jobList);
 
 
     if (isJobNew) {
     if (isJobNew) {
@@ -83,7 +89,7 @@ class PdfConvertService implements OnInit {
     }
     }
 
 
     if (isJobNew && status !== JobStatus.FAILED) {
     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.
    * 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.
    * 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)) {
     while (!this.isJobCompleted(jobId)) {
       // eslint-disable-next-line no-await-in-loop
       // eslint-disable-next-line no-await-in-loop
       await new Promise(resolve => setTimeout(resolve, 10 * 1000));
       await new Promise(resolve => setTimeout(resolve, 10 * 1000));
@@ -146,7 +153,7 @@ class PdfConvertService implements OnInit {
           throw new Error('Job expired');
           throw new Error('Job expired');
         }
         }
 
 
-        const htmlReadable = this.getHtmlReadable(jobId);
+        const htmlReadable = this.getHtmlReadable(jobId, appId);
         const pdfWritable = this.getPdfWritable();
         const pdfWritable = this.getPdfWritable();
         this.jobList[jobId].currentStream = htmlReadable;
         this.jobList[jobId].currentStream = htmlReadable;
 
 
@@ -165,11 +172,13 @@ class PdfConvertService implements OnInit {
 
 
   /**
   /**
    * Get readable stream that reads html files from shared fs path
    * 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
    * @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;
     let index = 0;
 
 
     const jobList = this.jobList;
     const jobList = this.jobList;
@@ -215,7 +224,9 @@ class PdfConvertService implements OnInit {
           await fs.promises.rm(pageInfo.htmlFilePath, { force: true });
           await fs.promises.rm(pageInfo.htmlFilePath, { force: true });
         }
         }
         catch (err) {
         catch (err) {
-          callback(err);
+          if (err instanceof Error) {
+            callback(err);
+          }
           return;
           return;
         }
         }
         callback();
         callback();
@@ -229,9 +240,9 @@ class PdfConvertService implements OnInit {
    * @returns converted pdf
    * @returns converted pdf
    */
    */
   private async convertHtmlToPdf(htmlString: string): Promise<Buffer> {
   private async convertHtmlToPdf(htmlString: string): Promise<Buffer> {
-    const executeConvert = async(retries: number) => {
+    const executeConvert = async(retries: number): Promise<Buffer> => {
       try {
       try {
-        return this.puppeteerCluster.execute(htmlString);
+        return this.puppeteerCluster?.execute(htmlString);
       }
       }
       catch (err) {
       catch (err) {
         if (retries > 0) {
         if (retries > 0) {

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

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

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

@@ -36,7 +36,7 @@
   "dependencies": {
   "dependencies": {
     "@godaddy/terminus": "^4.9.0",
     "@godaddy/terminus": "^4.9.0",
     "@growi/slack": "workspace:^",
     "@growi/slack": "workspace:^",
-    "@slack/oauth": "^2.0.1",
+    "@slack/oauth": "^3.0.3",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@tsed/common": "=6.43.0",
     "@tsed/common": "=6.43.0",
     "@tsed/di": "=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,
       clientId,
       clientSecret,
       clientSecret,
       stateSecret,
       stateSecret,
+      legacyStateVerification: true,
       installationStore: {
       installationStore: {
         // upsert
         // upsert
         storeInstallation: async(slackInstallation: SlackInstallation<'v1' | 'v2', boolean>) => {
         storeInstallation: async(slackInstallation: SlackInstallation<'v1' | 'v2', boolean>) => {

+ 4 - 5
biome.json

@@ -6,6 +6,8 @@
       "node_modules/**",
       "node_modules/**",
       "coverage/**",
       "coverage/**",
       "vite.config.ts.timestamp-*",
       "vite.config.ts.timestamp-*",
+      "vite.server.config.ts.timestamp-*",
+      "vite.client.config.ts.timestamp-*",
       ".pnpm-store/**",
       ".pnpm-store/**",
       ".turbo/**",
       ".turbo/**",
       ".vscode/**",
       ".vscode/**",
@@ -16,7 +18,6 @@
       ".eslintrc.js",
       ".eslintrc.js",
       ".stylelintrc.json",
       ".stylelintrc.json",
       "package.json",
       "package.json",
-
       "./apps/**",
       "./apps/**",
       "./packages/core/**",
       "./packages/core/**",
       "./packages/core-styles/**",
       "./packages/core-styles/**",
@@ -29,9 +30,7 @@
       "./packages/preset-themes/**",
       "./packages/preset-themes/**",
       "./packages/remark-attachment-refs/**",
       "./packages/remark-attachment-refs/**",
       "./packages/remark-drawio/**",
       "./packages/remark-drawio/**",
-      "./packages/remark-growi-directive/**",
-      "./packages/remark-lsx/**",
-      "./packages/ui/**"
+      "./packages/remark-growi-directive/**"
     ]
     ]
   },
   },
   "formatter": {
   "formatter": {
@@ -52,4 +51,4 @@
       "quoteStyle": "single"
       "quoteStyle": "single"
     }
     }
   }
   }
-}
+}

+ 1 - 1
package.json

@@ -96,7 +96,7 @@
     "turbo": "^2.1.3",
     "turbo": "^2.1.3",
     "typescript": "~5.0.0",
     "typescript": "~5.0.0",
     "typescript-transform-paths": "^3.4.7",
     "typescript-transform-paths": "^3.4.7",
-    "vite": "^5.4.17",
+    "vite": "^5.4.19",
     "vite-plugin-dts": "^3.9.1",
     "vite-plugin-dts": "^3.9.1",
     "vite-tsconfig-paths": "^5.0.1",
     "vite-tsconfig-paths": "^5.0.1",
     "vitest": "^2.1.1",
     "vitest": "^2.1.1",

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

@@ -24,6 +24,9 @@ export type PdfCtrlSyncJobStatus202 = {
   status: PdfCtrlSyncJobStatus202Status;
   status: PdfCtrlSyncJobStatus202Status;
 };
 };
 
 
+/**
+ * @minLength 1
+ */
 export type PdfCtrlSyncJobStatusBodyStatus = typeof PdfCtrlSyncJobStatusBodyStatus[keyof typeof PdfCtrlSyncJobStatusBodyStatus];
 export type PdfCtrlSyncJobStatusBodyStatus = typeof PdfCtrlSyncJobStatusBodyStatus[keyof typeof PdfCtrlSyncJobStatusBodyStatus];
 
 
 
 
@@ -35,9 +38,13 @@ export const PdfCtrlSyncJobStatusBodyStatus = {
 } as const;
 } as const;
 
 
 export type PdfCtrlSyncJobStatusBody = {
 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 {
 export interface GenericError {
@@ -85,7 +92,7 @@ export interface InternalServerError {
   
   
  */
  */
 export const pdfCtrlSyncJobStatus = <TData = AxiosResponse<PdfCtrlSyncJobStatus202>>(
 export const pdfCtrlSyncJobStatus = <TData = AxiosResponse<PdfCtrlSyncJobStatus202>>(
-    pdfCtrlSyncJobStatusBody?: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig
+    pdfCtrlSyncJobStatusBody: PdfCtrlSyncJobStatusBody, options?: AxiosRequestConfig
  ): Promise<TData> => {
  ): Promise<TData> => {
     return axios.post(
     return axios.post(
       `/pdf/sync-job`,
       `/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": "run-p watch:*",
     "watch:client": "pnpm run dev:client -w --emptyOutDir=false",
     "watch:client": "pnpm run dev:client -w --emptyOutDir=false",
     "watch:server": "pnpm run dev:server -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:styles": "stylelint --allow-empty-input \"src/**/*.scss\" \"src/**/*.css\"",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "run-p lint:*",
     "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';
 import styles from './Lsx.module.scss';
 
 
 type Props = {
 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 (
     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>
       </div>
     );
     );
-  }, [data, setSize]);
-
-
-  return (
-    <div className={`lsx ${styles.lsx}`}>
-      <Error />
-      <Loading />
-      {contents}
-      <LoadMore />
-    </div>
-  );
-});
+  },
+);
 LsxSubstance.displayName = 'LsxSubstance';
 LsxSubstance.displayName = 'LsxSubstance';
 
 
 const LsxDisabled = React.memo((): JSX.Element => {
 const LsxDisabled = React.memo((): JSX.Element => {
   return (
   return (
     <div className="text-muted">
     <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>
       <small>lsx is not available on the share link page</small>
     </div>
     </div>
   );
   );
@@ -156,7 +181,9 @@ export const Lsx = React.memo((props: Props): JSX.Element => {
 });
 });
 Lsx.displayName = 'Lsx';
 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';
 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 { LsxPage } from './LsxPage';
 
 
-
 import styles from './LsxListView.module.scss';
 import styles from './LsxListView.module.scss';
 
 
-
 type Props = {
 type Props = {
-  nodeTree?: PageNode[],
-  lsxContext: LsxContext,
-  basisViewersCount?: number,
+  nodeTree?: PageNode[];
+  lsxContext: LsxContext;
+  basisViewersCount?: number;
 };
 };
 
 
-
 export const LsxListView = React.memo((props: Props): JSX.Element => {
 export const LsxListView = React.memo((props: Props): JSX.Element => {
-
   const { nodeTree, lsxContext, basisViewersCount } = props;
   const { nodeTree, lsxContext, basisViewersCount } = props;
 
 
   const isEmpty = nodeTree == null || nodeTree.length === 0;
   const isEmpty = nodeTree == null || nodeTree.length === 0;
@@ -27,8 +23,14 @@ export const LsxListView = React.memo((props: Props): JSX.Element => {
       return (
       return (
         <div className="text-muted">
         <div className="text-muted">
           <small>
           <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>
           </small>
         </div>
         </div>
       );
       );
@@ -49,11 +51,8 @@ export const LsxListView = React.memo((props: Props): JSX.Element => {
 
 
   return (
   return (
     <div className={`page-list ${styles['page-list']}`}>
     <div className={`page-list ${styles['page-list']}`}>
-      <ul className="page-list-ul">
-        {contents}
-      </ul>
+      <ul className="page-list-ul">{contents}</ul>
     </div>
     </div>
   );
   );
-
 });
 });
 LsxListView.displayName = 'LsxListView';
 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 { PageNode } from '../../../interfaces/page-node';
 import type { LsxContext } from '../lsx-context';
 import type { LsxContext } from '../lsx-context';
 
 
-
 import styles from './LsxPage.module.scss';
 import styles from './LsxPage.module.scss';
 
 
-
 type Props = {
 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 => {
 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 pageId = pageNode.page?._id;
   const pagePath = pageNode.pagePath;
   const pagePath = pageNode.pagePath;
@@ -64,9 +60,15 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
 
 
   const iconElement: JSX.Element = useMemo(() => {
   const iconElement: JSX.Element = useMemo(() => {
     const isExists = pageId != null;
     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]);
   }, [pageId]);
 
 
   const pagePathElement: JSX.Element = useMemo(() => {
   const pagePathElement: JSX.Element = useMemo(() => {
@@ -78,7 +80,13 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
     }
     }
 
 
     // create PagePath element
     // create PagePath element
-    let pagePathNode = <PagePathLabel path={pagePath} isLatterOnly additionalClassNames={classNames} />;
+    let pagePathNode = (
+      <PagePathLabel
+        path={pagePath}
+        isLatterOnly
+        additionalClassNames={classNames}
+      />
+    );
     if (isLinkable) {
     if (isLinkable) {
       const href = isExists
       const href = isExists
         ? `/${pageId}`
         ? `/${pageId}`
@@ -118,6 +126,5 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
       {childrenElements}
       {childrenElements}
     </li>
     </li>
   );
   );
-
 });
 });
 LsxPage.displayName = 'LsxPage';
 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 {
 export class LsxContext {
-
   pagePath: string;
   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;
     this.pagePath = pagePath;
 
 
     // remove undefined keys
     // 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;
     this.options = options;
   }
   }
@@ -42,5 +45,4 @@ export class LsxContext {
   toString(): string {
   toString(): string {
     return `$lsx(${this.getStringifiedAttributes()})`;
     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 { remarkGrowiDirectivePluginType } from '@growi/remark-growi-directive';
 import type { Nodes as HastNode } from 'hast';
 import type { Nodes as HastNode } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
@@ -11,54 +16,67 @@ import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 import { visit } from 'unist-util-visit';
 
 
 const NODE_NAME_PATTERN = new RegExp(/ls|lsx/);
 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 = {
 export type LsxRehypePluginParams = {
-  pagePath?: string,
-  isSharedPage?: boolean,
-}
+  pagePath?: string;
+  isSharedPage?: boolean;
+};
 
 
 const pathResolver = (href: string, basePath: string): string => {
 const pathResolver = (href: string, basePath: string): string => {
   // exclude absolute URL
   // exclude absolute URL
@@ -75,7 +93,9 @@ const pathResolver = (href: string, basePath: string): string => {
 };
 };
 
 
 export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
 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) => {
   return (tree) => {
     if (options.pagePath == null) {
     if (options.pagePath == null) {
@@ -85,7 +105,7 @@ export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
     const basePagePath = options.pagePath;
     const basePagePath = options.pagePath;
     const elements = selectAll('lsx', tree as HastNode);
     const elements = selectAll('lsx', tree as HastNode);
 
 
-    elements.forEach((lsxElem) => {
+    for (const lsxElem of elements) {
       if (lsxElem.properties == null) {
       if (lsxElem.properties == null) {
         return;
         return;
       }
       }
@@ -110,7 +130,7 @@ export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
 
 
       // resolve relative path
       // resolve relative path
       lsxElem.properties.prefix = decodeURI(pathResolver(prefix, basePagePath));
       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 axios from 'axios';
 import useSWRInfinite, { type SWRInfiniteResponse } from 'swr/infinite';
 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';
 import { type ParseNumOptionResult, parseNumOption } from './parse-num-option';
 
 
-
 const LOADMORE_PAGES_NUM = 10;
 const LOADMORE_PAGES_NUM = 10;
 
 
-
 export const useSWRxLsx = (
 export const useSWRxLsx = (
-    pagePath: string, options?: Record<string, string|undefined>, isImmutable?: boolean,
+  pagePath: string,
+  options?: Record<string, string | undefined>,
+  isImmutable?: boolean,
 ): SWRInfiniteResponse<LsxApiResponseData, Error> => {
 ): SWRInfiniteResponse<LsxApiResponseData, Error> => {
-
   return useSWRInfinite(
   return useSWRInfinite(
     // key generator
     // key generator
     (pageIndex, previousPageData) => {
     (pageIndex, previousPageData) => {
-      if (previousPageData != null && previousPageData.pages.length === 0) return null;
+      if (previousPageData != null && previousPageData.pages.length === 0)
+        return null;
 
 
       // parse num option
       // parse num option
       let initialOffsetAndLimit: ParseNumOptionResult | null = null;
       let initialOffsetAndLimit: ParseNumOptionResult | null = null;
       let parseError: Error | undefined;
       let parseError: Error | undefined;
       try {
       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;
         parseError = err as Error;
       }
       }
 
 
       // the first loading
       // the first loading
       if (pageIndex === 0 || previousPageData == null) {
       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
       // 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
     // fetcher
-    async([endpoint, pagePath, options, offset, limit, parseErrorMessage]) => {
+    async ([endpoint, pagePath, options, offset, limit, parseErrorMessage]) => {
       if (parseErrorMessage != null) {
       if (parseErrorMessage != null) {
         throw new Error(parseErrorMessage);
         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 = {
       const params: LsxApiParams = {
         pagePath,
         pagePath,
         offset,
         offset,
@@ -55,8 +75,7 @@ export const useSWRxLsx = (
       try {
       try {
         const res = await axios.get<LsxApiResponseData>(endpoint, { params });
         const res = await axios.get<LsxApiResponseData>(endpoint, { params });
         return res.data;
         return res.data;
-      }
-      catch (err) {
+      } catch (err) {
         if (axios.isAxiosError(err)) {
         if (axios.isAxiosError(err)) {
           throw new Error(err.response?.data.message);
           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';
 import { parseNumOption } from './parse-num-option';
 
 
 describe('addNumCondition()', () => {
 describe('addNumCondition()', () => {
-
   it('set limit with the specified number', () => {
   it('set limit with the specified number', () => {
     // setup
     // setup
     const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
     const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
@@ -36,7 +35,9 @@ describe('addNumCondition()', () => {
     const caller = () => parseNumOption('-1:10');
     const caller = () => parseNumOption('-1:10');
 
 
     // then
     // 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');
     expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
   });
   });
 
 
@@ -48,20 +49,19 @@ describe('addNumCondition()', () => {
     const caller = () => parseNumOption('3:2');
     const caller = () => parseNumOption('3:2');
 
 
     // then
     // 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');
     expect(parseRangeSpy).toHaveBeenCalledWith('3:2');
   });
   });
-
 });
 });
 
 
-
 describe('addNumCondition() set skip and limit with the range string', () => {
 describe('addNumCondition() set skip and limit with the range string', () => {
-
   it.concurrent.each`
   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 }) => {
   `("'$optionsNum", ({ optionsNum, expected }) => {
     // setup
     // setup
     const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
     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(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
     expect(result).toEqual(expected);
     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';
 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
  * 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))) {
   if (Number.isInteger(Number(optionsNum))) {
     return { limit: Number(optionsNum) };
     return { limit: Number(optionsNum) };
   }
   }
@@ -22,11 +25,15 @@ export const parseNumOption = (optionsNum: string): ParseNumOptionResult | null
 
 
   // check start
   // check start
   if (start < 1) {
   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
   // check end
   if (start > end && end > 0) {
   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;
   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';
 import { generatePageNodeTree } from './page-node';
 
 
-
 function omitPageData(pageNode: PageNode): Omit<PageNode, 'page'> {
 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()', () => {
 describe('generatePageNodeTree()', () => {
-
   it("returns when the rootPagePath is '/'", () => {
   it("returns when the rootPagePath is '/'", () => {
     // setup
     // setup
-    const pages: IPageHasId[] = [
-      '/',
-      '/Sandbox',
-    ].map(path => mock<IPageHasId>({ path }));
+    const pages: IPageHasId[] = ['/', '/Sandbox'].map((path) =>
+      mock<IPageHasId>({ path }),
+    );
 
 
     // when
     // when
     const result = generatePageNodeTree('/', pages);
     const result = generatePageNodeTree('/', pages);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
 
     // then
     // then
     expect(resultWithoutPageData).toStrictEqual([
     expect(resultWithoutPageData).toStrictEqual([
@@ -47,11 +45,13 @@ describe('generatePageNodeTree()', () => {
       '/Sandbox/level2/level3-1',
       '/Sandbox/level2/level3-1',
       '/Sandbox/level2/level3-2',
       '/Sandbox/level2/level3-2',
       '/Sandbox/level2/level3-3',
       '/Sandbox/level2/level3-3',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
 
     // when
     // when
     const result = generatePageNodeTree('/Sandbox', pages);
     const result = generatePageNodeTree('/Sandbox', pages);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
 
     // then
     // then
     expect(resultWithoutPageData).toStrictEqual([
     expect(resultWithoutPageData).toStrictEqual([
@@ -83,11 +83,13 @@ describe('generatePageNodeTree()', () => {
       '/user/bar',
       '/user/bar',
       '/user/bar/memo/2023/06/01',
       '/user/bar/memo/2023/06/01',
       '/user/bar/memo/2023/06/02/memo-test',
       '/user/bar/memo/2023/06/02/memo-test',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
 
     // when
     // when
     const result = generatePageNodeTree('/', pages);
     const result = generatePageNodeTree('/', pages);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
 
     // then
     // then
     expect(resultWithoutPageData).toStrictEqual([
     expect(resultWithoutPageData).toStrictEqual([
@@ -145,12 +147,14 @@ describe('generatePageNodeTree()', () => {
       '/user',
       '/user',
       '/user/foo',
       '/user/foo',
       '/user/bar',
       '/user/bar',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
 
     // when
     // when
     const depthRange = OptionParser.parseRange('1:2');
     const depthRange = OptionParser.parseRange('1:2');
     const result = generatePageNodeTree('/', pages, depthRange);
     const result = generatePageNodeTree('/', pages, depthRange);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
 
     // then
     // then
     expect(resultWithoutPageData).toStrictEqual([
     expect(resultWithoutPageData).toStrictEqual([
@@ -190,12 +194,14 @@ describe('generatePageNodeTree()', () => {
       '/foo/level2',
       '/foo/level2',
       '/foo/level2/level3-1',
       '/foo/level2/level3-1',
       '/foo/level2/level3-2',
       '/foo/level2/level3-2',
-    ].map(path => mock<IPageHasId>({ path }));
+    ].map((path) => mock<IPageHasId>({ path }));
 
 
     // when
     // when
     const depthRange = OptionParser.parseRange('2:3');
     const depthRange = OptionParser.parseRange('2:3');
     const result = generatePageNodeTree('/', pages, depthRange);
     const result = generatePageNodeTree('/', pages, depthRange);
-    const resultWithoutPageData = result.map(pageNode => omitPageData(pageNode));
+    const resultWithoutPageData = result.map((pageNode) =>
+      omitPageData(pageNode),
+    );
 
 
     // then
     // then
     expect(resultWithoutPageData).toStrictEqual([
     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 { IPageHasId } from '@growi/core';
 import type { ParseRangeResult } from '@growi/core/dist/remark-plugins';
 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 { removeTrailingSlash } from '@growi/core/dist/utils/path-utils';
 
 
 import type { PageNode } from '../../interfaces/page-node';
 import type { PageNode } from '../../interfaces/page-node';
 import { getDepthOfPath } from '../../utils/depth-utils';
 import { getDepthOfPath } from '../../utils/depth-utils';
 
 
-
 function getParentPath(path: string) {
 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
  * @memberof Lsx
  */
  */
 function generatePageNode(
 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 {
 ): PageNode | null {
-
   // exclude rootPagePath itself
   // exclude rootPagePath itself
   if (pagePath === rootPagePath) {
   if (pagePath === rootPagePath) {
     return null;
     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);
   const currentPageDepth = getDepthOfPath(pagePath);
 
 
   // return by the depth restriction
   // return by the depth restriction
@@ -49,11 +50,16 @@ function generatePageNode(
   pathToNodeMap[pagePath] = node;
   pathToNodeMap[pagePath] = node;
 
 
   /*
   /*
-    * process recursively for ancestors
-    */
+   * process recursively for ancestors
+   */
   // get or create parent node
   // get or create parent node
   const parentPath = getParentPath(pagePath);
   const parentPath = getParentPath(pagePath);
-  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath, depthRange);
+  const parentNode = generatePageNode(
+    pathToNodeMap,
+    rootPagePath,
+    parentPath,
+    depthRange,
+  );
   // associate to patent
   // associate to patent
   if (parentNode != null) {
   if (parentNode != null) {
     parentNode.children.push(node);
     parentNode.children.push(node);
@@ -62,30 +68,39 @@ function generatePageNode(
   return node;
   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> = {};
   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
     // exclude rootPagePath itself
     if (node == null) {
     if (node == null) {
-      return;
+      continue;
     }
     }
 
 
     // set the Page substance
     // set the Page substance
     node.page = page;
     node.page = page;
-  });
+  }
 
 
   // return root objects
   // return root objects
   const rootNodes: PageNode[] = [];
   const rootNodes: PageNode[] = [];
-  Object.keys(pathToNodeMap).forEach((pagePath) => {
+  for (const pagePath in pathToNodeMap) {
     const parentPath = getParentPath(pagePath);
     const parentPath = getParentPath(pagePath);
 
 
     // pick up what parent doesn't exist
     // pick up what parent doesn't exist
-    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
+    if (parentPath === '/' || !(parentPath in pathToNodeMap)) {
       rootNodes.push(pathToNodeMap[pagePath]);
       rootNodes.push(pathToNodeMap[pagePath]);
     }
     }
-  });
+  }
   return rootNodes;
   return rootNodes;
 }
 }

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

@@ -1,23 +1,23 @@
 import type { IPageHasId } from '@growi/core';
 import type { IPageHasId } from '@growi/core';
 
 
 export type LsxApiOptions = {
 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 = {
 export type LsxApiParams = {
-  pagePath: string,
-  offset?: number,
-  limit?: number,
-  options?: LsxApiOptions,
-}
+  pagePath: string;
+  offset?: number;
+  limit?: number;
+  options?: LsxApiOptions;
+};
 
 
 export type LsxApiResponseData = {
 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';
 import type { IPageHasId } from '@growi/core';
 
 
 export type PageNode = {
 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 {
       try {
         const jsonData: LsxApiOptions = JSON.parse(options);
         const jsonData: LsxApiOptions = JSON.parse(options);
 
 
-        Object.keys(jsonData).forEach((key) => {
+        for (const key in jsonData) {
           jsonData[key] = filterXSS.process(jsonData[key]);
           jsonData[key] = filterXSS.process(jsonData[key]);
-        });
+        }
 
 
         return jsonData;
         return jsonData;
-      }
-      catch (err) {
+      } catch (err) {
         throw new Error('Invalid JSON format in options');
         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}`);
     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 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;
   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;
 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 { addDepthCondition } from './add-depth-condition';
 import type { PageQuery } from './generate-base-query';
 import type { PageQuery } from './generate-base-query';
 
 
-
 // mocking modules
 // mocking modules
 const mocks = vi.hoisted(() => {
 const mocks = vi.hoisted(() => {
   return {
   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()', () => {
 describe('addDepthCondition()', () => {
-
   it('returns query as-is', () => {
   it('returns query as-is', () => {
     // setup
     // setup
     const query = mock<PageQuery>();
     const query = mock<PageQuery>();
@@ -29,7 +28,6 @@ describe('addDepthCondition()', () => {
   });
   });
 
 
   describe('throws http-errors instance', () => {
   describe('throws http-errors instance', () => {
-
     it('when the start is smaller than 1', () => {
     it('when the start is smaller than 1', () => {
       // setup
       // setup
       const query = mock<PageQuery>();
       const query = mock<PageQuery>();
@@ -41,9 +39,12 @@ describe('addDepthCondition()', () => {
       const caller = () => addDepthCondition(query, '/', depthRange);
       const caller = () => addDepthCondition(query, '/', depthRange);
 
 
       // then
       // 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();
       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';
 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) {
   if (depthRange == null) {
     return query;
     return query;
   }
   }
@@ -15,11 +18,17 @@ export const addDepthCondition = (query: PageQuery, pagePath: string, depthRange
 
 
   // check start
   // check start
   if (start < 1) {
   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
   // check end
   if (start > end && end > 0) {
   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);
   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';
 import type { PageQuery } from './generate-base-query';
 
 
 describe('addNumCondition() throws 400 http-errors instance', () => {
 describe('addNumCondition() throws 400 http-errors instance', () => {
-
   it("when the param 'offset' is a negative value", () => {
   it("when the param 'offset' is a negative value", () => {
-
     // setup
     // setup
     const queryMock = mock<PageQuery>();
     const queryMock = mock<PageQuery>();
 
 
@@ -15,64 +13,67 @@ describe('addNumCondition() throws 400 http-errors instance', () => {
     const caller = () => addNumCondition(queryMock, -1, 10);
     const caller = () => addNumCondition(queryMock, -1, 10);
 
 
     // then
     // 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.skip).not.toHaveBeenCalledWith();
     expect(queryMock.limit).not.toHaveBeenCalledWith();
     expect(queryMock.limit).not.toHaveBeenCalledWith();
   });
   });
 });
 });
 
 
-
 describe('addNumCondition() set skip and limit with', () => {
 describe('addNumCondition() set skip and limit with', () => {
-
   it.concurrent.each`
   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';
 import type { PageQuery } from './generate-base-query';
 
 
-
 const DEFAULT_PAGES_NUM = 50;
 const DEFAULT_PAGES_NUM = 50;
 
 
 /**
 /**
  * add num condition that limit fetched pages
  * 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
   // check offset
   if (offset < 0) {
   if (offset < 0) {
     throw createError(400, "The param 'offset' must be larger or equal than 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.
  * 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
   // init sort key
   const optionsSort = optionsSortArg ?? 'path';
   const optionsSort = optionsSortArg ?? 'path';
 
 
   // the default sort order
   // the default sort order
   const isReversed = optionsReverse === 'true';
   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 = {};
   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 PageQuery = Query<IPageHasId[], Document>;
 
 
 export type PageQueryBuilder = {
 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');
   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 PageAny = Page as any;
 
 
   const baseQuery = Page.find();
   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 type { IPage } from '@growi/core';
 import { model } from 'mongoose';
 import { model } from 'mongoose';
 
 
-export const getToppageViewersCount = async(): Promise<number> => {
+export const getToppageViewersCount = async (): Promise<number> => {
   const Page = model<IPage>('Page');
   const Page = model<IPage>('Page');
 
 
   const aggRes = await Page.aggregate<{ count: number }>([
   const aggRes = await Page.aggregate<{ count: number }>([
@@ -9,7 +9,5 @@ export const getToppageViewersCount = async(): Promise<number> => {
     { $project: { count: { $size: '$seenUsers' } } },
     { $project: { count: { $size: '$seenUsers' } } },
   ]);
   ]);
 
 
-  return aggRes.length > 0
-    ? aggRes[0].count
-    : 1;
+  return aggRes.length > 0 ? aggRes[0].count : 1;
 };
 };

+ 20 - 15
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 createError from 'http-errors';
 import { mock } from 'vitest-mock-extended';
 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 type { PageQuery, PageQueryBuilder } from './generate-base-query';
 
 
 import { listPages } from '.';
 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
 // 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', () => {
 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
     // setup
     const reqMock = mock<IListPagesRequest>();
     const reqMock = mock<IListPagesRequest>();
     const resMock = mock<Response>();
     const resMock = mock<Response>();
@@ -48,7 +55,6 @@ describe('listPages', () => {
   });
   });
 
 
   describe('with num option', () => {
   describe('with num option', () => {
-
     const reqMock = mock<IListPagesRequest>();
     const reqMock = mock<IListPagesRequest>();
     reqMock.query = { pagePath: '/Sandbox' };
     reqMock.query = { pagePath: '/Sandbox' };
 
 
@@ -60,7 +66,7 @@ describe('listPages', () => {
     const queryMock = mock<PageQuery>();
     const queryMock = mock<PageQuery>();
     builderMock.query = queryMock;
     builderMock.query = queryMock;
 
 
-    it('returns 200 HTTP response', async() => {
+    it('returns 200 HTTP response', async () => {
       // setup query.clone().count()
       // setup query.clone().count()
       const queryClonedMock = mock<PageQuery>();
       const queryClonedMock = mock<PageQuery>();
       queryMock.clone.mockReturnValue(queryClonedMock);
       queryMock.clone.mockReturnValue(queryClonedMock);
@@ -98,7 +104,7 @@ describe('listPages', () => {
       expect(resStatusMock.send).toHaveBeenCalledWith(expectedResponseData);
       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
       // setup
       const reqMock = mock<IListPagesRequest>();
       const reqMock = mock<IListPagesRequest>();
       reqMock.query = { pagePath: '/Sandbox' };
       reqMock.query = { pagePath: '/Sandbox' };
@@ -125,7 +131,7 @@ describe('listPages', () => {
       expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
       expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
     });
     });
 
 
-    it('returns 400 HTTP response when the value is invalid', async() => {
+    it('returns 400 HTTP response when the value is invalid', async () => {
       // setup
       // setup
       const reqMock = mock<IListPagesRequest>();
       const reqMock = mock<IListPagesRequest>();
       reqMock.query = { pagePath: '/Sandbox' };
       reqMock.query = { pagePath: '/Sandbox' };
@@ -151,6 +157,5 @@ describe('listPages', () => {
       expect(resMock.status).toHaveBeenCalledOnce();
       expect(resMock.status).toHaveBeenCalledOnce();
       expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
       expect(resStatusMock.send).toHaveBeenCalledWith('error for test');
     });
     });
-
   });
   });
 });
 });

+ 37 - 26
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -1,4 +1,3 @@
-
 import type { IUser } from '@growi/core';
 import type { IUser } from '@growi/core';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
 import { OptionParser } from '@growi/core/dist/remark-plugins';
 import { pathUtils } from '@growi/core/dist/utils';
 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 { addDepthCondition } from './add-depth-condition';
 import { addNumCondition } from './add-num-condition';
 import { addNumCondition } from './add-num-condition';
 import { addSortCondition } from './add-sort-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';
 import { getToppageViewersCount } from './get-toppage-viewers-count';
 
 
-
 const { addTrailingSlash, removeTrailingSlash } = pathUtils;
 const { addTrailingSlash, removeTrailingSlash } = pathUtils;
 
 
 /**
 /**
  * add filter condition that filter fetched pages
  * 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
   // when option strings is 'filter=', the option value is true
   if (optionsFilter == null || optionsFilter === 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));
   const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
 
 
-  let filterPath;
+  let filterPath: RegExp;
   try {
   try {
     if (optionsFilter.charAt(0) === '^') {
     if (optionsFilter.charAt(0) === '^') {
       // move '^' to the first of path
       // 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}`);
       filterPath = new RegExp(`^${pagePathForRegexp}.*${optionsFilter}`);
     }
     }
-  }
-  catch (err) {
+  } catch (err) {
     throw createError(400, err);
     throw createError(400, err);
   }
   }
 
 
@@ -56,12 +62,15 @@ function addExceptCondition(query, pagePath, optionsFilter): PageQuery {
   return addFilterCondition(query, pagePath, optionsFilter, true);
   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;
   const user = req.user;
 
 
   if (req.query.pagePath == null) {
   if (req.query.pagePath == null) {
@@ -75,17 +84,14 @@ export const listPages = async(req: IListPagesRequest, res: Response): Promise<R
     options: req.query?.options ?? {},
     options: req.query?.options ?? {},
   };
   };
 
 
-  const {
-    pagePath, offset, limit, options,
-  } = params;
+  const { pagePath, offset, limit, options } = params;
   const builder = await generateBaseQuery(params.pagePath, user);
   const builder = await generateBaseQuery(params.pagePath, user);
 
 
   // count viewers of `/`
   // count viewers of `/`
-  let toppageViewersCount;
+  let toppageViewersCount: number;
   try {
   try {
     toppageViewersCount = await getToppageViewersCount();
     toppageViewersCount = await getToppageViewersCount();
-  }
-  catch (error) {
+  } catch (error) {
     return res.status(500).send(error);
     return res.status(500).send(error);
   }
   }
 
 
@@ -93,7 +99,11 @@ export const listPages = async(req: IListPagesRequest, res: Response): Promise<R
   try {
   try {
     // depth
     // depth
     if (options?.depth != null) {
     if (options?.depth != null) {
-      query = addDepthCondition(query, params.pagePath, OptionParser.parseRange(options.depth));
+      query = addDepthCondition(
+        query,
+        params.pagePath,
+        OptionParser.parseRange(options.depth),
+      );
     }
     }
     // filter
     // filter
     if (options?.filter != null) {
     if (options?.filter != null) {
@@ -115,15 +125,16 @@ export const listPages = async(req: IListPagesRequest, res: Response): Promise<R
     const cursor = (offset ?? 0) + pages.length;
     const cursor = (offset ?? 0) + pages.length;
 
 
     const responseData: LsxApiResponseData = {
     const responseData: LsxApiResponseData = {
-      pages, cursor, total, toppageViewersCount,
+      pages,
+      cursor,
+      total,
+      toppageViewersCount,
     };
     };
     return res.status(200).send(responseData);
     return res.status(200).send(responseData);
-  }
-  catch (error) {
+  } catch (error) {
     if (isHttpError(error)) {
     if (isHttpError(error)) {
       return res.status(error.status).send(error.message);
       return res.status(error.status).send(error.message);
     }
     }
     return res.status(500).send(error.message);
     return res.status(500).send(error.message);
   }
   }
-
 };
 };

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

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

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

@@ -4,9 +4,7 @@
   "compilerOptions": {
   "compilerOptions": {
     "jsx": "react-jsx",
     "jsx": "react-jsx",
 
 
-    "types": [
-      "vitest/globals"
-    ],
+    "types": ["vitest/globals"],
 
 
     /* TODO: remove below flags for strict checking */
     /* TODO: remove below flags for strict checking */
     "strict": false,
     "strict": false,
@@ -15,7 +13,5 @@
     "noImplicitAny": false,
     "noImplicitAny": false,
     "noImplicitOverride": true
     "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',
     outDir: 'dist/server',
     sourcemap: true,
     sourcemap: true,
     lib: {
     lib: {
-      entry: [
-        'src/server/index.ts',
-      ],
+      entry: ['src/server/index.ts'],
       name: 'remark-lsx-libs',
       name: 'remark-lsx-libs',
       formats: ['cjs'],
       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';
 import { defineConfig } from 'vitest/config';
 
 
 export default defineConfig({
 export default defineConfig({
-  plugins: [
-    tsconfigPaths(),
-  ],
+  plugins: [tsconfigPaths()],
   test: {
   test: {
     environment: 'node',
     environment: 'node',
     clearMocks: true,
     clearMocks: true,

+ 1 - 1
packages/slack/package.json

@@ -49,7 +49,7 @@
     "test": "vitest run --coverage"
     "test": "vitest run --coverage"
   },
   },
   "dependencies": {
   "dependencies": {
-    "@slack/oauth": "^2.0.1",
+    "@slack/oauth": "^3.0.3",
     "@slack/web-api": "^6.2.4",
     "@slack/web-api": "^6.2.4",
     "@types/bunyan": "^1.8.10",
     "@types/bunyan": "^1.8.10",
     "@types/http-errors": "^2.0.3",
     "@types/http-errors": "^2.0.3",

+ 25 - 13
packages/slack/src/utils/respond-util-factory.ts

@@ -3,6 +3,7 @@ import urljoin from 'url-join';
 
 
 import type { IRespondUtil } from '../interfaces/respond-util';
 import type { IRespondUtil } from '../interfaces/respond-util';
 import type { RespondBodyForResponseUrl } from '../interfaces/response-url';
 import type { RespondBodyForResponseUrl } from '../interfaces/response-url';
+import { isValidResponseUrl } from './response-url-validator';
 
 
 type AxiosOptions = {
 type AxiosOptions = {
   headers?: {
   headers?: {
@@ -14,22 +15,35 @@ function getResponseUrlForProxy(proxyUri: string, responseUrl: string): string {
   return urljoin(proxyUri, `/g2s/respond?response_url=${responseUrl}`);
   return urljoin(proxyUri, `/g2s/respond?response_url=${responseUrl}`);
 }
 }
 
 
-function getUrl(responseUrl: string, proxyUri: string | null): string {
-  return proxyUri == null
-    ? responseUrl
-    : getResponseUrlForProxy(proxyUri, responseUrl);
+function getUrl(responseUrl: string, proxyUri?: string): string {
+  const finalUrl =
+    proxyUri === undefined
+      ? responseUrl
+      : getResponseUrlForProxy(proxyUri, responseUrl);
+
+  if (!isValidResponseUrl(responseUrl, proxyUri)) {
+    throw new Error('Invalid final response URL');
+  }
+
+  return finalUrl;
 }
 }
 
 
+type RespondUtilConstructorArgs = {
+  responseUrl: string;
+  appSiteUrl: string;
+  proxyUri?: string;
+};
+
 export class RespondUtil implements IRespondUtil {
 export class RespondUtil implements IRespondUtil {
   url!: string;
   url!: string;
 
 
   options!: AxiosOptions;
   options!: AxiosOptions;
 
 
-  constructor(
-    responseUrl: string,
-    proxyUri: string | null,
-    appSiteUrl: string,
-  ) {
+  constructor({
+    responseUrl,
+    appSiteUrl,
+    proxyUri,
+  }: RespondUtilConstructorArgs) {
     this.url = getUrl(responseUrl, proxyUri);
     this.url = getUrl(responseUrl, proxyUri);
 
 
     this.options = {
     this.options = {
@@ -88,9 +102,7 @@ export class RespondUtil implements IRespondUtil {
 }
 }
 
 
 export function generateRespondUtil(
 export function generateRespondUtil(
-  responseUrl: string,
-  proxyUri: string | null,
-  appSiteUrl: string,
+  args: RespondUtilConstructorArgs,
 ): RespondUtil {
 ): RespondUtil {
-  return new RespondUtil(responseUrl, proxyUri, appSiteUrl);
+  return new RespondUtil(args);
 }
 }

+ 43 - 0
packages/slack/src/utils/response-url-validator.ts

@@ -0,0 +1,43 @@
+import { URL } from 'node:url';
+
+const ALLOWED_SLACK_HOST = 'hooks.slack.com';
+
+export function isValidResponseUrl(
+  responseUrl: string,
+  slackbotProxyUri?: string,
+): boolean {
+  try {
+    const parsedUrl = new URL(responseUrl);
+
+    // Case 1: Direct to Slack
+    if (
+      parsedUrl.protocol === 'https:' &&
+      parsedUrl.hostname === ALLOWED_SLACK_HOST
+    ) {
+      return true;
+    }
+
+    // Case 2: Via slackbot-proxy
+    if (slackbotProxyUri) {
+      const parsedProxyUri = new URL(slackbotProxyUri);
+
+      if (
+        (parsedUrl.protocol === 'http:' || parsedUrl.protocol === 'https:') &&
+        parsedUrl.hostname === parsedProxyUri.hostname &&
+        parsedUrl.pathname === '/g2s/respond'
+      ) {
+        const slackResponseUrlParam =
+          parsedUrl.searchParams.get('response_url');
+        if (slackResponseUrlParam) {
+          // Recursively validate the response_url parameter
+          return isValidResponseUrl(slackResponseUrlParam); // No proxy URI for the inner check
+        }
+      }
+    }
+
+    return false;
+  } catch (error) {
+    // Invalid URL format
+    return false;
+  }
+}

+ 1 - 1
packages/ui/.eslintignore

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

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

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

+ 3 - 8
packages/ui/package.json

@@ -4,14 +4,9 @@
   "description": "GROWI UI Libraries",
   "description": "GROWI UI Libraries",
   "license": "MIT",
   "license": "MIT",
   "private": "true",
   "private": "true",
-  "keywords": [
-    "growi"
-  ],
+  "keywords": ["growi"],
   "type": "module",
   "type": "module",
-  "files": [
-    "dist",
-    "scss"
-  ],
+  "files": ["dist", "scss"],
   "exports": {
   "exports": {
     "./dist/components": {
     "./dist/components": {
       "import": "./dist/components/index.js"
       "import": "./dist/components/index.js"
@@ -32,7 +27,7 @@
     "clean": "shx rm -rf dist",
     "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
     "watch": "pnpm run dev -w --emptyOutDir=false",
-    "lint:js": "eslint **/*.{js,ts}",
+    "lint:js": "biome check",
     "lint:styles": "stylelint \"./scss/**/*\"",
     "lint:styles": "stylelint \"./scss/**/*\"",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "npm-run-all -p lint:*"
     "lint": "npm-run-all -p lint:*"

+ 46 - 28
packages/ui/src/components/Attachment.tsx

@@ -6,17 +6,15 @@ import { format } from 'date-fns/format';
 import { UserPicture } from './UserPicture';
 import { UserPicture } from './UserPicture';
 
 
 type AttachmentProps = {
 type AttachmentProps = {
-  attachment: IAttachmentHasId,
-  inUse: boolean,
-  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void,
-  isUserLoggedIn?: boolean,
+  attachment: IAttachmentHasId;
+  inUse: boolean;
+  onAttachmentDeleteClicked?: (attachment: IAttachmentHasId) => void;
+  isUserLoggedIn?: boolean;
 };
 };
 
 
 export const Attachment = (props: AttachmentProps): JSX.Element => {
 export const Attachment = (props: AttachmentProps): JSX.Element => {
-
-  const {
-    attachment, inUse, isUserLoggedIn, onAttachmentDeleteClicked,
-  } = props;
+  const { attachment, inUse, isUserLoggedIn, onAttachmentDeleteClicked } =
+    props;
 
 
   const _onAttachmentDeleteClicked = () => {
   const _onAttachmentDeleteClicked = () => {
     if (onAttachmentDeleteClicked != null) {
     if (onAttachmentDeleteClicked != null) {
@@ -24,23 +22,37 @@ export const Attachment = (props: AttachmentProps): JSX.Element => {
     }
     }
   };
   };
 
 
-  const formatIcon = (attachment.fileFormat.match(/image\/.+/i)) ? 'image' : 'description';
-  const btnDownload = (isUserLoggedIn)
-    ? (
-      <a className="attachment-download" href={attachment.downloadPathProxied}>
-        <span className="material-symbols-outlined">cloud_download</span>
-      </a>
-    )
-    : '';
-  const btnTrash = (isUserLoggedIn)
-    ? (
-      <a className="text-danger attachment-delete" onClick={_onAttachmentDeleteClicked}>
-        <span className="material-symbols-outlined">delete</span>
-      </a>
-    )
-    : '';
-  const fileType = <span className="attachment-filetype badge bg-secondary rounded-pill">{attachment.fileFormat}</span>;
-  const fileInUse = (inUse) ? <span className="attachment-in-use badge bg-info rounded-pill">In Use</span> : '';
+  const formatIcon = attachment.fileFormat.match(/image\/.+/i)
+    ? 'image'
+    : 'description';
+  const btnDownload = isUserLoggedIn ? (
+    <a className="attachment-download" href={attachment.downloadPathProxied}>
+      <span className="material-symbols-outlined">cloud_download</span>
+    </a>
+  ) : (
+    ''
+  );
+  const btnTrash = isUserLoggedIn ? (
+    <button
+      className="text-danger attachment-delete btn btn-link p-0"
+      onClick={_onAttachmentDeleteClicked}
+      type="button"
+    >
+      <span className="material-symbols-outlined">delete</span>
+    </button>
+  ) : (
+    ''
+  );
+  const fileType = (
+    <span className="attachment-filetype badge bg-secondary rounded-pill">
+      {attachment.fileFormat}
+    </span>
+  );
+  const fileInUse = inUse ? (
+    <span className="attachment-in-use badge bg-info rounded-pill">In Use</span>
+  ) : (
+    ''
+  );
   // Should UserDate be used like PageRevisionTable ?
   // Should UserDate be used like PageRevisionTable ?
   const formatType = 'yyyy/MM/dd HH:mm:ss';
   const formatType = 'yyyy/MM/dd HH:mm:ss';
   const createdAt = format(new Date(attachment.createdAt), formatType);
   const createdAt = format(new Date(attachment.createdAt), formatType);
@@ -48,10 +60,16 @@ export const Attachment = (props: AttachmentProps): JSX.Element => {
   return (
   return (
     <div className="attachment mb-2">
     <div className="attachment mb-2">
       <span className="me-1 attachment-userpicture">
       <span className="me-1 attachment-userpicture">
-        <UserPicture user={attachment.creator} size="sm"></UserPicture>
+        <UserPicture user={attachment.creator} size="sm" />
       </span>
       </span>
-      <a className="me-2" href={attachment.filePathProxied} target="_blank" rel="noopener noreferrer">
-        <span className="material-symbols-outlined ms-1">{formatIcon}</span> {attachment.originalName}
+      <a
+        className="me-2"
+        href={attachment.filePathProxied}
+        target="_blank"
+        rel="noopener noreferrer"
+      >
+        <span className="material-symbols-outlined ms-1">{formatIcon}</span>{' '}
+        {attachment.originalName}
       </a>
       </a>
       <span className="me-2">{fileType}</span>
       <span className="me-2">{fileType}</span>
       <span className="me-2">{createdAt}</span>
       <span className="me-2">{createdAt}</span>

+ 8 - 2
packages/ui/src/components/LoadingSpinner.tsx

@@ -4,6 +4,12 @@ import styles from './LoadingSpinner.module.scss';
 
 
 const moduleClass = styles.spinner ?? '';
 const moduleClass = styles.spinner ?? '';
 
 
-export const LoadingSpinner = ({ className = '' }: ComponentPropsWithoutRef<'span'>): JSX.Element => (
-  <span className={`material-symbols-outlined pb-0 ${moduleClass} ${className}`}>progress_activity</span>
+export const LoadingSpinner = ({
+  className = '',
+}: ComponentPropsWithoutRef<'span'>): JSX.Element => (
+  <span
+    className={`material-symbols-outlined pb-0 ${moduleClass} ${className}`}
+  >
+    progress_activity
+  </span>
 );
 );

+ 67 - 38
packages/ui/src/components/PagePath/PageListMeta.tsx

@@ -1,99 +1,125 @@
 import type { FC, JSX } from 'react';
 import type { FC, JSX } from 'react';
 
 
-import assert from 'assert';
-
 import type { IPageHasId } from '@growi/core';
 import type { IPageHasId } from '@growi/core';
-import { templateChecker, pagePathUtils } from '@growi/core/dist/utils';
-
+import { pagePathUtils, templateChecker } from '@growi/core/dist/utils';
 
 
 const { isTopPage } = pagePathUtils;
 const { isTopPage } = pagePathUtils;
 const { checkTemplatePath } = templateChecker;
 const { checkTemplatePath } = templateChecker;
 
 
-
 const SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT = 5;
 const SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT = 5;
 const MAX_STRENGTH_LEVEL = 4;
 const MAX_STRENGTH_LEVEL = 4;
 
 
 type SeenUsersCountProps = {
 type SeenUsersCountProps = {
-  count: number,
-  basisViewersCount?: number,
-  shouldSpaceOutIcon?: boolean,
-}
+  count: number;
+  basisViewersCount?: number;
+  shouldSpaceOutIcon?: boolean;
+};
 
 
 const SeenUsersCount = (props: SeenUsersCountProps): JSX.Element => {
 const SeenUsersCount = (props: SeenUsersCountProps): JSX.Element => {
-
   const { count, shouldSpaceOutIcon, basisViewersCount } = props;
   const { count, shouldSpaceOutIcon, basisViewersCount } = props;
 
 
   if (count === 0) {
   if (count === 0) {
     return <></>;
     return <></>;
   }
   }
 
 
-  if (basisViewersCount != null && basisViewersCount <= SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT) {
+  if (
+    basisViewersCount != null &&
+    basisViewersCount <= SEEN_USERS_HIDE_THRES__ACTIVE_USERS_COUNT
+  ) {
     return <></>;
     return <></>;
   }
   }
 
 
   const strengthLevel = Math.ceil(
   const strengthLevel = Math.ceil(
-    Math.min(0, Math.log(count / (basisViewersCount ?? count))) // Max: 0
-    * 2 * -1,
+    Math.min(0, Math.log(count / (basisViewersCount ?? count))) * // Max: 0
+      2 *
+      -1,
   );
   );
 
 
   if (strengthLevel > MAX_STRENGTH_LEVEL) {
   if (strengthLevel > MAX_STRENGTH_LEVEL) {
     return <></>;
     return <></>;
   }
   }
 
 
-  assert(strengthLevel >= 0 && strengthLevel <= MAX_STRENGTH_LEVEL); // [0, MAX_STRENGTH_LEVEL)
+  if (!(strengthLevel >= 0 && strengthLevel <= MAX_STRENGTH_LEVEL)) {
+    throw new Error('strengthLevel out of range');
+  } // [0, MAX_STRENGTH_LEVEL)
 
 
   const strengthClass = `strength-${strengthLevel}`; // strength-{0, 1, 2, 3, 4}
   const strengthClass = `strength-${strengthLevel}`; // strength-{0, 1, 2, 3, 4}
 
 
   return (
   return (
-    <span className={`seen-users-count ${shouldSpaceOutIcon ? 'me-2' : ''} ${strengthClass}`}>
+    <span
+      className={`seen-users-count ${shouldSpaceOutIcon ? 'me-2' : ''} ${strengthClass}`}
+    >
       <span className="material-symbols-outlined">footprint</span>
       <span className="material-symbols-outlined">footprint</span>
       {count}
       {count}
     </span>
     </span>
   );
   );
-
 };
 };
 
 
-
 type PageListMetaProps = {
 type PageListMetaProps = {
-  page: IPageHasId,
-  likerCount?: number,
-  bookmarkCount?: number,
-  shouldSpaceOutIcon?: boolean,
-  basisViewersCount?: number,
-}
-
-export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) => {
+  page: IPageHasId;
+  likerCount?: number;
+  bookmarkCount?: number;
+  shouldSpaceOutIcon?: boolean;
+  basisViewersCount?: number;
+};
 
 
+export const PageListMeta: FC<PageListMetaProps> = (
+  props: PageListMetaProps,
+) => {
   const { page, shouldSpaceOutIcon, basisViewersCount } = props;
   const { page, shouldSpaceOutIcon, basisViewersCount } = props;
 
 
   // top check
   // top check
-  let topLabel;
+  let topLabel: JSX.Element | undefined;
   if (isTopPage(page.path)) {
   if (isTopPage(page.path)) {
-    topLabel = <span className={`badge bg-info ${shouldSpaceOutIcon ? 'me-2' : ''} top-label`}>TOP</span>;
+    topLabel = (
+      <span
+        className={`badge bg-info ${shouldSpaceOutIcon ? 'me-2' : ''} top-label`}
+      >
+        TOP
+      </span>
+    );
   }
   }
 
 
   // template check
   // template check
-  let templateLabel;
+  let templateLabel: JSX.Element | undefined;
   if (checkTemplatePath(page.path)) {
   if (checkTemplatePath(page.path)) {
-    templateLabel = <span className={`badge bg-info ${shouldSpaceOutIcon ? 'me-2' : ''}`}>TMPL</span>;
+    templateLabel = (
+      <span className={`badge bg-info ${shouldSpaceOutIcon ? 'me-2' : ''}`}>
+        TMPL
+      </span>
+    );
   }
   }
 
 
-  let commentCount;
+  let commentCount: JSX.Element | undefined;
   if (page.commentCount > 0) {
   if (page.commentCount > 0) {
-    commentCount = <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}><span className="material-symbols-outlined">comment</span>{page.commentCount}</span>;
+    commentCount = (
+      <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}>
+        <span className="material-symbols-outlined">comment</span>
+        {page.commentCount}
+      </span>
+    );
   }
   }
 
 
-  let likerCount;
+  let likerCount: JSX.Element | undefined;
   if (props.likerCount != null && props.likerCount > 0) {
   if (props.likerCount != null && props.likerCount > 0) {
-    likerCount = <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}><span className="material-symbols-outlined">favorite</span>{props.likerCount}</span>;
+    likerCount = (
+      <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}>
+        <span className="material-symbols-outlined">favorite</span>
+        {props.likerCount}
+      </span>
+    );
   }
   }
 
 
-  let locked;
+  let locked: JSX.Element | undefined;
   if (page.grant !== 1) {
   if (page.grant !== 1) {
-    locked = <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}><span className="material-symbols-outlined">lock</span></span>;
+    locked = (
+      <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}>
+        <span className="material-symbols-outlined">lock</span>
+      </span>
+    );
   }
   }
 
 
-  let bookmarkCount;
+  let bookmarkCount: JSX.Element | undefined;
   if (props.bookmarkCount != null && props.bookmarkCount > 0) {
   if (props.bookmarkCount != null && props.bookmarkCount > 0) {
     bookmarkCount = (
     bookmarkCount = (
       <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}>
       <span className={`${shouldSpaceOutIcon ? 'me-2' : ''}`}>
@@ -107,12 +133,15 @@ export const PageListMeta: FC<PageListMetaProps> = (props: PageListMetaProps) =>
     <span className="page-list-meta">
     <span className="page-list-meta">
       {topLabel}
       {topLabel}
       {templateLabel}
       {templateLabel}
-      <SeenUsersCount count={page.seenUsers.length} basisViewersCount={basisViewersCount} shouldSpaceOutIcon={shouldSpaceOutIcon} />
+      <SeenUsersCount
+        count={page.seenUsers.length}
+        basisViewersCount={basisViewersCount}
+        shouldSpaceOutIcon={shouldSpaceOutIcon}
+      />
       {commentCount}
       {commentCount}
       {likerCount}
       {likerCount}
       {locked}
       {locked}
       {bookmarkCount}
       {bookmarkCount}
     </span>
     </span>
   );
   );
-
 };
 };

+ 42 - 31
packages/ui/src/components/PagePath/PagePathLabel.tsx

@@ -2,54 +2,65 @@ import type { FC, ReactNode } from 'react';
 
 
 import { DevidedPagePath } from '@growi/core/dist/models';
 import { DevidedPagePath } from '@growi/core/dist/models';
 
 
-
 type TextElemProps = {
 type TextElemProps = {
-  children?: ReactNode
-  isHTML?: boolean,
-}
+  children?: ReactNode;
+  isHTML?: boolean;
+};
 
 
 const TextElement: FC<TextElemProps> = (props: TextElemProps) => (
 const TextElement: FC<TextElemProps> = (props: TextElemProps) => (
   <>
   <>
-    { props.isHTML
-      // eslint-disable-next-line react/no-danger
-      ? <span dangerouslySetInnerHTML={{ __html: props.children?.toString() || '' }}></span>
-      : <>{props.children}</>
-    }
+    {props.isHTML ? (
+      <span
+        // biome-ignore lint/security/noDangerouslySetInnerHtml: ignore
+        dangerouslySetInnerHTML={{ __html: props.children?.toString() || '' }}
+      />
+    ) : (
+      <>{props.children}</>
+    )}
   </>
   </>
 );
 );
 
 
-
 type Props = {
 type Props = {
-  path: string,
-  isLatterOnly?: boolean,
-  isFormerOnly?: boolean,
-  isPathIncludedHtml?: boolean,
-  additionalClassNames?: string[],
-}
-
-export const PagePathLabel: FC<Props> = (props:Props) => {
+  path: string;
+  isLatterOnly?: boolean;
+  isFormerOnly?: boolean;
+  isPathIncludedHtml?: boolean;
+  additionalClassNames?: string[];
+};
+
+export const PagePathLabel: FC<Props> = (props: Props) => {
   const {
   const {
-    isLatterOnly, isFormerOnly, isPathIncludedHtml, additionalClassNames, path,
+    isLatterOnly,
+    isFormerOnly,
+    isPathIncludedHtml,
+    additionalClassNames,
+    path,
   } = props;
   } = props;
 
 
   const dPagePath = new DevidedPagePath(path, false, true);
   const dPagePath = new DevidedPagePath(path, false, true);
 
 
   const classNames = additionalClassNames || [];
   const classNames = additionalClassNames || [];
 
 
-  let textElem;
+  let textElem: JSX.Element | undefined;
 
 
   if (isLatterOnly) {
   if (isLatterOnly) {
-    textElem = <TextElement isHTML={isPathIncludedHtml}>{dPagePath.latter}</TextElement>;
-  }
-  else if (isFormerOnly) {
-    textElem = dPagePath.isFormerRoot
-      ? <>/</>
-      : <TextElement isHTML={isPathIncludedHtml}>{dPagePath.former}</TextElement>;
-  }
-  else {
-    textElem = dPagePath.isRoot
-      ? <strong>/</strong>
-      : <TextElement isHTML={isPathIncludedHtml}>{dPagePath.former}/<strong>{dPagePath.latter}</strong></TextElement>;
+    textElem = (
+      <TextElement isHTML={isPathIncludedHtml}>{dPagePath.latter}</TextElement>
+    );
+  } else if (isFormerOnly) {
+    textElem = dPagePath.isFormerRoot ? (
+      <>/</>
+    ) : (
+      <TextElement isHTML={isPathIncludedHtml}>{dPagePath.former}</TextElement>
+    );
+  } else {
+    textElem = dPagePath.isRoot ? (
+      <strong>/</strong>
+    ) : (
+      <TextElement isHTML={isPathIncludedHtml}>
+        {dPagePath.former}/<strong>{dPagePath.latter}</strong>
+      </TextElement>
+    );
   }
   }
 
 
   return <span className={classNames.join(' ')}>{textElem}</span>;
   return <span className={classNames.join(' ')}>{textElem}</span>;

+ 80 - 34
packages/ui/src/components/UserPicture.tsx

@@ -1,9 +1,13 @@
 import {
 import {
-  type ReactNode, type JSX,
-  memo, forwardRef, useCallback, useRef,
+  type JSX,
+  type ReactNode,
+  forwardRef,
+  memo,
+  useCallback,
+  useRef,
 } from 'react';
 } from 'react';
 
 
-import type { Ref, IUser } from '@growi/core';
+import type { IUser, Ref } from '@growi/core';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import { pagePathUtils } from '@growi/core/dist/utils';
 import dynamic from 'next/dynamic';
 import dynamic from 'next/dynamic';
 import { useRouter } from 'next/router';
 import { useRouter } from 'next/router';
@@ -14,31 +18,43 @@ import styles from './UserPicture.module.scss';
 const moduleClass = styles['user-picture'];
 const moduleClass = styles['user-picture'];
 const moduleTooltipClass = styles['user-picture-tooltip'];
 const moduleTooltipClass = styles['user-picture-tooltip'];
 
 
-const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(() => import('reactstrap').then(mod => mod.UncontrolledTooltip), { ssr: false });
+const UncontrolledTooltip = dynamic<UncontrolledTooltipProps>(
+  () => import('reactstrap').then((mod) => mod.UncontrolledTooltip),
+  { ssr: false },
+);
 
 
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 const DEFAULT_IMAGE = '/images/icons/user.svg';
 
 
-
 type UserPictureSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
 type UserPictureSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
 
 
 type BaseUserPictureRootProps = {
 type BaseUserPictureRootProps = {
-  displayName: string,
-  children: ReactNode,
-  size?: UserPictureSize,
-  className?: string,
-}
+  displayName: string;
+  children: ReactNode;
+  size?: UserPictureSize;
+  className?: string;
+};
 
 
 type UserPictureRootWithoutLinkProps = BaseUserPictureRootProps;
 type UserPictureRootWithoutLinkProps = BaseUserPictureRootProps;
 
 
 type UserPictureRootWithLinkProps = BaseUserPictureRootProps & {
 type UserPictureRootWithLinkProps = BaseUserPictureRootProps & {
-  username: string,
-}
+  username: string;
+};
 
 
-const UserPictureRootWithoutLink = forwardRef<HTMLSpanElement, UserPictureRootWithoutLinkProps>((props, ref) => {
-  return <span ref={ref} className={props.className}>{props.children}</span>;
+const UserPictureRootWithoutLink = forwardRef<
+  HTMLSpanElement,
+  UserPictureRootWithoutLinkProps
+>((props, ref) => {
+  return (
+    <span ref={ref} className={props.className}>
+      {props.children}
+    </span>
+  );
 });
 });
 
 
-const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootWithLinkProps>((props, ref) => {
+const UserPictureRootWithLink = forwardRef<
+  HTMLSpanElement,
+  UserPictureRootWithLinkProps
+>((props, ref) => {
   const router = useRouter();
   const router = useRouter();
 
 
   const { username } = props;
   const { username } = props;
@@ -51,14 +67,27 @@ const UserPictureRootWithLink = forwardRef<HTMLSpanElement, UserPictureRootWithL
   // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
   // Using <span> tag here instead of <a> tag because UserPicture is used in SearchResultList which is essentially a anchor tag.
   // Nested anchor tags causes a warning.
   // Nested anchor tags causes a warning.
   // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
   // https://stackoverflow.com/questions/13052598/creating-anchor-tag-inside-anchor-taga
-  return <span ref={ref} className={props.className} onClick={clickHandler} style={{ cursor: 'pointer' }}>{props.children}</span>;
+  return (
+    <span
+      ref={ref}
+      className={props.className}
+      onClick={clickHandler}
+      onKeyDown={() => {}}
+      style={{ cursor: 'pointer' }}
+    >
+      {props.children}
+    </span>
+  );
 });
 });
 
 
-
 // wrapper with Tooltip
 // wrapper with Tooltip
-const withTooltip = <P extends BaseUserPictureRootProps>(
-  UserPictureSpanElm: React.ForwardRefExoticComponent<P & React.RefAttributes<HTMLSpanElement>>,
-) => (props: P): JSX.Element => {
+const withTooltip =
+  <P extends BaseUserPictureRootProps>(
+    UserPictureSpanElm: React.ForwardRefExoticComponent<
+      P & React.RefAttributes<HTMLSpanElement>
+    >,
+  ) =>
+  (props: P): JSX.Element => {
     const { displayName, size } = props;
     const { displayName, size } = props;
     const username = 'username' in props ? props.username : undefined;
     const username = 'username' in props ? props.username : undefined;
 
 
@@ -75,53 +104,68 @@ const withTooltip = <P extends BaseUserPictureRootProps>(
           delay={0}
           delay={0}
           fade={false}
           fade={false}
         >
         >
-          {username ? <>{`@${username}`}<br /></> : null}
+          {username ? (
+            <>
+              {`@${username}`}
+              <br />
+            </>
+          ) : null}
           {displayName}
           {displayName}
         </UncontrolledTooltip>
         </UncontrolledTooltip>
       </>
       </>
     );
     );
   };
   };
 
 
-
 /**
 /**
  * type guard to determine whether the specified object is IUser
  * type guard to determine whether the specified object is IUser
  */
  */
-const hasUsername = (obj: Partial<IUser> | Ref<IUser> | null | undefined): obj is { username: string } => {
+const hasUsername = (
+  obj: Partial<IUser> | Ref<IUser> | null | undefined,
+): obj is { username: string } => {
   return obj != null && typeof obj !== 'string' && 'username' in obj;
   return obj != null && typeof obj !== 'string' && 'username' in obj;
 };
 };
 
 
 /**
 /**
  * Type guard to determine whether tooltip should be shown
  * Type guard to determine whether tooltip should be shown
  */
  */
-const hasName = (obj: Partial<IUser> | Ref<IUser> | null | undefined): obj is { name: string } => {
+const hasName = (
+  obj: Partial<IUser> | Ref<IUser> | null | undefined,
+): obj is { name: string } => {
   return obj != null && typeof obj === 'object' && 'name' in obj;
   return obj != null && typeof obj === 'object' && 'name' in obj;
 };
 };
 
 
 /**
 /**
  * type guard to determine whether the specified object is IUser
  * type guard to determine whether the specified object is IUser
  */
  */
-const hasProfileImage = (obj: Partial<IUser> | Ref<IUser> | null | undefined): obj is { imageUrlCached: string } => {
+const hasProfileImage = (
+  obj: Partial<IUser> | Ref<IUser> | null | undefined,
+): obj is { imageUrlCached: string } => {
   return obj != null && typeof obj === 'object' && 'imageUrlCached' in obj;
   return obj != null && typeof obj === 'object' && 'imageUrlCached' in obj;
 };
 };
 
 
-
 type Props = {
 type Props = {
-  user?: Partial<IUser> | Ref<IUser> | null,
-  size?: UserPictureSize,
-  noLink?: boolean,
-  noTooltip?: boolean,
-  className?: string
+  user?: Partial<IUser> | Ref<IUser> | null;
+  size?: UserPictureSize;
+  noLink?: boolean;
+  noTooltip?: boolean;
+  className?: string;
 };
 };
 
 
 export const UserPicture = memo((userProps: Props): JSX.Element => {
 export const UserPicture = memo((userProps: Props): JSX.Element => {
   const {
   const {
-    user, size, noLink, noTooltip, className: additionalClassName,
+    user,
+    size,
+    noLink,
+    noTooltip,
+    className: additionalClassName,
   } = userProps;
   } = userProps;
 
 
   // Extract user information
   // Extract user information
   const username = hasUsername(user) ? user.username : undefined;
   const username = hasUsername(user) ? user.username : undefined;
   const displayName = hasName(user) ? user.name : 'someone';
   const displayName = hasName(user) ? user.name : 'someone';
-  const src = hasProfileImage(user) ? user.imageUrlCached ?? DEFAULT_IMAGE : DEFAULT_IMAGE;
+  const src = hasProfileImage(user)
+    ? (user.imageUrlCached ?? DEFAULT_IMAGE)
+    : DEFAULT_IMAGE;
   const showTooltip = !noTooltip && hasName(user);
   const showTooltip = !noTooltip && hasName(user);
 
 
   // Build className
   // Build className
@@ -131,7 +175,9 @@ export const UserPicture = memo((userProps: Props): JSX.Element => {
     'rounded-circle',
     'rounded-circle',
     size && `user-picture-${size}`,
     size && `user-picture-${size}`,
     additionalClassName,
     additionalClassName,
-  ].filter(Boolean).join(' ');
+  ]
+    .filter(Boolean)
+    .join(' ');
 
 
   const imgElement = <img src={src} alt={displayName} className={className} />;
   const imgElement = <img src={src} alt={displayName} className={className} />;
   const baseProps = { displayName, size, children: imgElement };
   const baseProps = { displayName, size, children: imgElement };

+ 1 - 1
packages/ui/src/interfaces/breakpoints.ts

@@ -6,4 +6,4 @@ export const Breakpoint = {
   XL: 'xl',
   XL: 'xl',
   XXL: 'xxl',
   XXL: 'xxl',
 } as const;
 } as const;
-export type Breakpoint = typeof Breakpoint[keyof typeof Breakpoint];
+export type Breakpoint = (typeof Breakpoint)[keyof typeof Breakpoint];

+ 4 - 4
packages/ui/src/interfaces/popper-data.ts

@@ -1,8 +1,8 @@
 interface Rect {
 interface Rect {
-  top: number
-  left: number
-  width: number
-  height: number
+  top: number;
+  left: number;
+  width: number;
+  height: number;
 }
 }
 
 
 export interface PopperData {
 export interface PopperData {

+ 12 - 7
packages/ui/src/utils/browser-utils.ts

@@ -3,12 +3,17 @@ import type { Breakpoint } from '../interfaces/breakpoints';
 const EVENT_TYPE_CHANGE = 'change';
 const EVENT_TYPE_CHANGE = 'change';
 
 
 export const addBreakpointListener = (
 export const addBreakpointListener = (
-    breakpoint: Breakpoint,
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    listener: (this: MediaQueryList, ev: MediaQueryListEvent) => any,
+  breakpoint: Breakpoint,
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  listener: (this: MediaQueryList, ev: MediaQueryListEvent) => any,
 ): MediaQueryList => {
 ): MediaQueryList => {
   // get the value of '--bs-breakpoint-*'
   // get the value of '--bs-breakpoint-*'
-  const breakpointPixel = parseInt(window.getComputedStyle(document.documentElement).getPropertyValue(`--bs-breakpoint-${breakpoint}`), 10);
+  const breakpointPixel = Number.parseInt(
+    window
+      .getComputedStyle(document.documentElement)
+      .getPropertyValue(`--bs-breakpoint-${breakpoint}`),
+    10,
+  );
 
 
   const mediaQueryList = window.matchMedia(`(min-width: ${breakpointPixel}px)`);
   const mediaQueryList = window.matchMedia(`(min-width: ${breakpointPixel}px)`);
 
 
@@ -19,9 +24,9 @@ export const addBreakpointListener = (
 };
 };
 
 
 export const cleanupBreakpointListener = (
 export const cleanupBreakpointListener = (
-    mediaQueryList: MediaQueryList,
-    // eslint-disable-next-line @typescript-eslint/no-explicit-any
-    listener: (this: MediaQueryList, ev: MediaQueryListEvent) => any,
+  mediaQueryList: MediaQueryList,
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  listener: (this: MediaQueryList, ev: MediaQueryListEvent) => any,
 ): void => {
 ): void => {
   mediaQueryList.removeEventListener(EVENT_TYPE_CHANGE, listener);
   mediaQueryList.removeEventListener(EVENT_TYPE_CHANGE, listener);
 };
 };

+ 1 - 3
packages/ui/src/utils/use-fullscreen.ts

@@ -1,6 +1,4 @@
-import {
-  useCallback, useEffect, useMemo, useState,
-} from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
 
 
 export interface FullScreenHandle {
 export interface FullScreenHandle {
   active: boolean;
   active: boolean;

+ 7 - 9
packages/ui/src/utils/use-rect.ts

@@ -1,20 +1,18 @@
 // based on https://gist.github.com/morajabi/523d7a642d8c0a2f71fcfa0d8b3d2846?permalink_comment_id=4688158#gistcomment-4688158
 // based on https://gist.github.com/morajabi/523d7a642d8c0a2f71fcfa0d8b3d2846?permalink_comment_id=4688158#gistcomment-4688158
 
 
 import type { RefObject } from 'react';
 import type { RefObject } from 'react';
-import {
-  useState, useEffect, useCallback,
-} from 'react';
+import { useCallback, useEffect, useState } from 'react';
 
 
 type MutableRefObject<T> = {
 type MutableRefObject<T> = {
-  current: T
-}
+  current: T;
+};
 
 
-type EventType = 'resize' | 'scroll'
+type EventType = 'resize' | 'scroll';
 
 
 const useEffectInEvent = (
 const useEffectInEvent = (
-    event: EventType,
-    useCapture?: boolean,
-    set?: () => void,
+  event: EventType,
+  useCapture?: boolean,
+  set?: () => void,
 ) => {
 ) => {
   useEffect(() => {
   useEffect(() => {
     if (set) {
     if (set) {

+ 1 - 3
packages/ui/tsconfig.json

@@ -9,7 +9,5 @@
       "~/*": ["./src/*"]
       "~/*": ["./src/*"]
     }
     }
   },
   },
-  "include": [
-    "src"
-  ]
+  "include": ["src"]
 }
 }

+ 1 - 1
packages/ui/vite.config.ts

@@ -1,4 +1,4 @@
-import path from 'path';
+import path from 'node:path';
 
 
 import react from '@vitejs/plugin-react';
 import react from '@vitejs/plugin-react';
 import glob from 'glob';
 import glob from 'glob';

Разница между файлами не показана из-за своего большого размера
+ 232 - 175
pnpm-lock.yaml


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