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

Merge branch 'master' into feat/97800-159429-add-shortcuts-when-editing

WNomunomu 9 месяцев назад
Родитель
Сommit
4538a0ca33
100 измененных файлов с 1755 добавлено и 929 удалено
  1. 15 3
      .roo/mcp.json
  2. 49 1
      .vscode/settings.json
  3. 1 1
      apps/app/package.json
  4. 1 1
      apps/app/src/client/components/Admin/UserGroup/UserGroupForm.tsx
  5. 1 1
      apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx
  6. 2 2
      apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserTable.tsx
  7. 1 1
      apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx
  8. 1 1
      apps/app/src/client/components/Admin/Users/UserTable.tsx
  9. 1 1
      apps/app/src/client/components/Me/ExternalAccountRow.jsx
  10. 37 6
      apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx
  11. 10 2
      apps/app/src/features/openai/client/services/knowledge-assistant.tsx
  12. 6 0
      apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts
  13. 3 3
      apps/app/src/features/openai/server/routes/edit/index.ts
  14. 21 0
      apps/app/src/features/openai/server/routes/message/post-message.ts
  15. 4 1
      apps/app/src/features/openai/server/services/client-delegator/azure-openai-client-delegator.ts
  16. 1 0
      apps/app/src/features/openai/server/services/client-delegator/index.ts
  17. 4 1
      apps/app/src/features/openai/server/services/client-delegator/interfaces.ts
  18. 12 0
      apps/app/src/features/openai/server/services/client-delegator/is-stream-response.ts
  19. 4 1
      apps/app/src/features/openai/server/services/client-delegator/openai-client-delegator.ts
  20. 0 1
      apps/app/src/features/openai/server/services/editor-assistant/llm-response-stream-processor.ts
  21. 39 5
      apps/app/src/features/openai/server/services/openai.ts
  22. 1 1
      apps/app/src/pages/utils/commons.ts
  23. 92 0
      apps/app/src/server/models/openapi/page.ts
  24. 5 37
      apps/app/src/server/routes/apiv3/page-listing.ts
  25. 10 49
      apps/app/src/server/routes/apiv3/page/index.ts
  26. 1 1
      apps/app/src/server/routes/apiv3/pages/index.js
  27. 2 1
      apps/app/src/server/routes/page.js
  28. 2 1
      apps/app/src/server/service/global-notification/global-notification-mail.js
  29. 285 0
      apps/app/src/utils/axios-date-conversion.spec.ts
  30. 46 0
      apps/app/src/utils/axios.ts
  31. 1 7
      biome.json
  32. 1 1
      packages/core/.eslintignore
  33. 0 5
      packages/core/.eslintrc.cjs
  34. 1 1
      packages/core/package.json
  35. 2 1
      packages/core/src/consts/accepted-upload-file-type.ts
  36. 2 1
      packages/core/src/consts/growi-plugin.ts
  37. 4 2
      packages/core/src/consts/system.ts
  38. 1 1
      packages/core/src/consts/ydoc-status.ts
  39. 14 14
      packages/core/src/interfaces/attachment.ts
  40. 1 1
      packages/core/src/interfaces/color-scheme.ts
  41. 1 6
      packages/core/src/interfaces/common.spec.ts
  42. 18 14
      packages/core/src/interfaces/common.ts
  43. 23 12
      packages/core/src/interfaces/config-manager.ts
  44. 4 4
      packages/core/src/interfaces/external-account.ts
  45. 17 17
      packages/core/src/interfaces/growi-app-info.ts
  46. 10 4
      packages/core/src/interfaces/growi-facade.ts
  47. 42 26
      packages/core/src/interfaces/growi-theme-metadata.ts
  48. 1 1
      packages/core/src/interfaces/has-object-id.ts
  49. 1 1
      packages/core/src/interfaces/lang.ts
  50. 122 89
      packages/core/src/interfaces/page.ts
  51. 121 99
      packages/core/src/interfaces/primitive/string.spec.ts
  52. 16 6
      packages/core/src/interfaces/primitive/string.ts
  53. 18 18
      packages/core/src/interfaces/revision.ts
  54. 9 8
      packages/core/src/interfaces/subscription.ts
  55. 3 3
      packages/core/src/interfaces/tag.ts
  56. 33 33
      packages/core/src/interfaces/user.ts
  57. 7 7
      packages/core/src/interfaces/vite.ts
  58. 6 8
      packages/core/src/models/devided-page-path.ts
  59. 28 17
      packages/core/src/models/serializers/attachment-serializer.ts
  60. 22 11
      packages/core/src/models/serializers/user-serializer.ts
  61. 8 3
      packages/core/src/models/vo/error-apiv3.ts
  62. 3 3
      packages/core/src/remark-plugins/interfaces/option-parser.ts
  63. 14 13
      packages/core/src/remark-plugins/util/option-parser.spec.ts
  64. 6 9
      packages/core/src/remark-plugins/util/option-parser.ts
  65. 23 10
      packages/core/src/swr/use-swr-static.ts
  66. 6 1
      packages/core/src/swr/with-utils.ts
  67. 4 1
      packages/core/src/utils/browser-utils.ts
  68. 0 3
      packages/core/src/utils/env-utils.spec.ts
  69. 6 3
      packages/core/src/utils/growi-theme-metadata.ts
  70. 11 2
      packages/core/src/utils/is-deep-equals.ts
  71. 1 3
      packages/core/src/utils/objectid-utils.spec.ts
  72. 3 1
      packages/core/src/utils/objectid-utils.ts
  73. 28 12
      packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts
  74. 80 33
      packages/core/src/utils/page-path-utils/index.spec.ts
  75. 30 12
      packages/core/src/utils/page-path-utils/index.ts
  76. 1 1
      packages/core/src/utils/page-utils.ts
  77. 149 132
      packages/core/src/utils/path-utils.spec.ts
  78. 0 1
      packages/core/src/utils/path-utils.ts
  79. 2 6
      packages/core/tsconfig.json
  80. 1 1
      packages/core/vite.config.ts
  81. 1 3
      packages/core/vitest.config.ts
  82. 1 0
      packages/pdf-converter-client/.eslintignore
  83. 0 11
      packages/pdf-converter-client/.eslintrc.cjs
  84. 1 1
      packages/pdf-converter-client/package.json
  85. 1 1
      packages/pluginkit/.eslintignore
  86. 0 5
      packages/pluginkit/.eslintrc.cjs
  87. 1 1
      packages/pluginkit/package.json
  88. 9 7
      packages/pluginkit/src/model/growi-plugin-package-data.ts
  89. 9 9
      packages/pluginkit/src/model/growi-plugin-validation-data.ts
  90. 4 4
      packages/pluginkit/src/model/growi-plugin-validation-error.ts
  91. 6 2
      packages/pluginkit/src/v4/client/utils/growi-facade/growi-react.spec.ts
  92. 1 2
      packages/pluginkit/src/v4/client/utils/growi-facade/growi-react.ts
  93. 18 16
      packages/pluginkit/src/v4/interfaces/template.ts
  94. 8 3
      packages/pluginkit/src/v4/server/utils/common/import-package-json.spec.ts
  95. 5 3
      packages/pluginkit/src/v4/server/utils/common/import-package-json.ts
  96. 36 17
      packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.spec.ts
  97. 29 8
      packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.ts
  98. 7 4
      packages/pluginkit/src/v4/server/utils/template/get-markdown.ts
  99. 9 9
      packages/pluginkit/src/v4/server/utils/template/get-status.ts
  100. 47 33
      packages/pluginkit/src/v4/server/utils/template/scan.ts

+ 15 - 3
.roo/mcp.json

@@ -2,8 +2,20 @@
   "mcpServers": {
     "fetch": {
       "command": "uvx",
-      "args": ["mcp-server-fetch"],
-      "alwaysAllow": ["fetch"]
+      "args": [
+        "mcp-server-fetch"
+      ],
+      "alwaysAllow": [
+        "fetch"
+      ]
+    },
+    "context7": {
+      "type": "streamable-http",
+      "url": "https://mcp.context7.com/mcp",
+      "alwaysAllow": [
+        "resolve-library-id",
+        "get-library-docs"
+      ]
     }
   }
-}
+}

+ 49 - 1
.vscode/settings.json

@@ -39,6 +39,54 @@
   "typescript.validate.enable": true,
   "typescript.surveys.enabled": false,
 
-  "vitest.filesWatcherInclude": "**/*"
+  "vitest.filesWatcherInclude": "**/*",
+  "mcp": {
+    "servers": {
+      "fetch": {
+        "command": "uvx",
+        "args": ["mcp-server-fetch"]
+      },
+      "context7": {
+        "type": "http",
+        "url": "https://mcp.context7.com/mcp"
+      }
+    }
+  },
+  "github.copilot.chat.codeGeneration.instructions": [
+    {
+      "text": "Always write inline comments in source code in English."
+    }
+  ],
+  "github.copilot.chat.testGeneration.instructions": [
+    {
+      "text": "Use vitest as the test framework"
+    },
+    {
+      "text": "The vitest configuration file is `apps/app/vitest.workspace.mts`"
+    },
+    {
+      "text": "Place test modules in the same directory as the module being tested. For example, if testing `mymodule.ts`, place `mymodule.spec.ts` in the same directory as `mymodule.ts`"
+    },
+    {
+      "text": "Run tests with the command: `cd /growi/apps/app && pnpm vitest run {test file path}`"
+    },
+    {
+      "text": "When creating new test modules, start with small files. First write a small number of intentionally failing tests, then fix them to pass. After that, add more tests while maintaining a passing state and increase coverage."
+    },
+    {
+      "text": "Write essential tests. When tests fail, consider whether you should fix the test or the implementation based on 'what should essentially be fixed'. If you're not confident in your reasoning, ask the user for guidance."
+    },
+    {
+      "text": "After writing tests, make sure they pass before moving on. Do not proceed to write tests for module B without first ensuring that tests for module A are passing"
+    },
+    {
+      "text": "Don't worry about lint errors - fix them after tests are passing"
+    }
+  ],
+  "github.copilot.chat.commitMessageGeneration.instructions": [
+    {
+      "text": "Always write commit messages in English."
+    }
+  ]
 
 }

+ 1 - 1
apps/app/package.json

@@ -170,7 +170,7 @@
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
     "mustache": "^4.2.0",
-    "next": "^14.2.26",
+    "next": "^14.2.30",
     "next-dynamic-loading-props": "^0.1.1",
     "next-i18next": "^15.3.1",
     "next-superjson": "^0.0.4",

+ 1 - 1
apps/app/src/client/components/Admin/UserGroup/UserGroupForm.tsx

@@ -76,7 +76,7 @@ export const UserGroupForm: FC<Props> = (props: Props) => {
           userGroup?.createdAt != null && (
             <div className="row mb-3">
               <p className="col-md-2 col-form-label">{t('Created')}</p>
-              <p className="col-md-6 my-auto">{dateFnsFormat(new Date(userGroup.createdAt), 'yyyy-MM-dd')}</p>
+              <p className="col-md-6 my-auto">{dateFnsFormat(userGroup.createdAt, 'yyyy-MM-dd')}</p>
             </div>
           )
         }

+ 1 - 1
apps/app/src/client/components/Admin/UserGroup/UserGroupTable.tsx

@@ -218,7 +218,7 @@ export const UserGroupTable: FC<Props> = ({
                     })}
                   </ul>
                 </td>
-                <td>{dateFnsFormat(new Date(group.createdAt), 'yyyy-MM-dd')}</td>
+                <td>{dateFnsFormat(group.createdAt, 'yyyy-MM-dd')}</td>
                 {isAclEnabled
                   ? (
                     <td>

+ 2 - 2
apps/app/src/client/components/Admin/UserGroupDetail/UserGroupUserTable.tsx

@@ -43,8 +43,8 @@ export const UserGroupUserTable = (props: Props): JSX.Element => {
                 <strong>{relatedUser.username}</strong>
               </td>
               <td>{relatedUser.name}</td>
-              <td>{relatedUser.createdAt ? dateFnsFormat(new Date(relatedUser.createdAt), 'yyyy-MM-dd') : ''}</td>
-              <td>{relatedUser.lastLoginAt ? dateFnsFormat(new Date(relatedUser.lastLoginAt), 'yyyy-MM-dd HH:mm:ss') : ''}</td>
+              <td>{relatedUser.createdAt ? dateFnsFormat(relatedUser.createdAt, 'yyyy-MM-dd') : ''}</td>
+              <td>{relatedUser.lastLoginAt ? dateFnsFormat(relatedUser.lastLoginAt, 'yyyy-MM-dd HH:mm:ss') : ''}</td>
               {!props.isExternalGroup && (
                 <td>
                   <div className="btn-group admin-user-menu">

+ 1 - 1
apps/app/src/client/components/Admin/Users/ExternalAccountTable.tsx

@@ -89,7 +89,7 @@ const ExternalAccountTable = (props: ExternalAccountTableProps): JSX.Element =>
                     : (<span className="badge bg-warning text-dark">{t('user_management.unset')}</span>)
                   }
                 </td>
-                <td>{dateFnsFormat(new Date(ea.createdAt), 'yyyy-MM-dd')}</td>
+                <td>{dateFnsFormat(ea.createdAt, 'yyyy-MM-dd')}</td>
                 <td>
                   <div className="btn-group admin-user-menu">
                     <button type="button" className="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown">

+ 1 - 1
apps/app/src/client/components/Admin/Users/UserTable.tsx

@@ -168,7 +168,7 @@ const UserTable = (props: UserTableProps) => {
                 </td>
                 <td>{user.name}</td>
                 <td>{user.email}</td>
-                <td>{dateFnsFormat(new Date(user.createdAt), 'yyyy-MM-dd')}</td>
+                <td>{dateFnsFormat(user.createdAt, 'yyyy-MM-dd')}</td>
                 <td>
                   {user.lastLoginAt && <span>{dateFnsFormat(new Date(user.lastLoginAt), 'yyyy-MM-dd HH:mm')}</span>}
                 </td>

+ 1 - 1
apps/app/src/client/components/Me/ExternalAccountRow.jsx

@@ -15,7 +15,7 @@ const ExternalAccountRow = (props) => {
       <td>
         <strong>{ account.accountId }</strong>
       </td>
-      <td>{dateFnsFormat(new Date(account.createdAt), 'yyyy-MM-dd')}</td>
+      <td>{dateFnsFormat(account.createdAt, 'yyyy-MM-dd')}</td>
       <td className="text-center">
         <button
           type="button"

+ 37 - 6
apps/app/src/features/openai/client/components/AiAssistant/AiAssistantSidebar/AiAssistantSidebar.tsx

@@ -241,7 +241,10 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
 
         const chunk = decoder.decode(value);
 
-        const textValues: string[] = [];
+        let isPreMessageGenerated = false;
+        let isMainMessageGenerationStarted = false;
+        const preMessages: string[] = [];
+        const mainMessages: string[] = [];
         const lines = chunk.split('\n\n');
         lines.forEach((line) => {
           const trimmedLine = line.trim();
@@ -249,14 +252,37 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
             const data = JSON.parse(line.replace('data: ', ''));
 
             processMessageForKnowledgeAssistant(data, {
+              onPreMessage: (data) => {
+                // When main message is sent while pre-message is being transmitted
+                if (isMainMessageGenerationStarted) {
+                  preMessages.length = 0;
+                  return;
+                }
+                if (data.finished) {
+                  isPreMessageGenerated = true;
+                  return;
+                }
+                if (data.text == null) {
+                  return;
+                }
+                preMessages.push(data.text);
+              },
               onMessage: (data) => {
-                textValues.push(data.content[0].text.value);
+                if (!isMainMessageGenerationStarted) {
+                  isMainMessageGenerationStarted = true;
+                }
+
+                // When main message is sent while pre-message is being transmitted
+                if (!isPreMessageGenerated) {
+                  preMessages.length = 0;
+                }
+                mainMessages.push(data.content[0].text.value);
               },
             });
 
             processMessageForEditorAssistant(data, {
               onMessage: (data) => {
-                textValues.push(data.appendedMessage);
+                mainMessages.push(data.appendedMessage);
               },
               onDetectedDiff: (data) => {
                 logger.debug('sse diff', { data });
@@ -277,13 +303,12 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
           }
         });
 
-
         // append text values to the assistant message
         setGeneratingAnswerMessage((prevMessage) => {
           if (prevMessage == null) return;
           return {
             ...prevMessage,
-            content: prevMessage.content + textValues.join(''),
+            content: prevMessage.content + preMessages.join('') + mainMessages.join(''),
           };
         });
 
@@ -316,7 +341,13 @@ const AiAssistantSidebarSubstance: React.FC<AiAssistantSidebarSubstanceProps> =
   }, [isEditorAssistant, isTextSelected, submitSubstance]);
 
   const keyDownHandler = (event: KeyboardEvent<HTMLTextAreaElement>) => {
-    if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
+    // Do nothing while composing
+    if (event.nativeEvent.isComposing) {
+      return;
+    }
+
+    if (event.key === 'Enter' && !event.shiftKey) {
+      event.preventDefault();
       form.handleSubmit(submit)();
     }
   };

+ 10 - 2
apps/app/src/features/openai/client/services/knowledge-assistant.tsx

@@ -10,7 +10,9 @@ import {
 } from 'reactstrap';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
-import { SseMessageSchema, type SseMessage } from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
+import {
+  SseMessageSchema, type SseMessage, SsePreMessageSchema, type SsePreMessage,
+} from '~/features/openai/interfaces/knowledge-assistant/sse-schemas';
 import { handleIfSuccessfullyParsed } from '~/features/openai/utils/handle-if-successfully-parsed';
 
 import type { MessageLog, MessageWithCustomMetaData } from '../../interfaces/message';
@@ -31,7 +33,9 @@ interface PostMessage {
 
 interface ProcessMessage {
   (data: unknown, handler: {
-    onMessage: (data: SseMessage) => void}
+    onMessage: (data: SseMessage) => void
+    onPreMessage: (data: SsePreMessage) => void
+  }
   ): void;
 }
 
@@ -121,6 +125,10 @@ export const useKnowledgeAssistant: UseKnowledgeAssistant = () => {
     handleIfSuccessfullyParsed(data, SseMessageSchema, (data: SseMessage) => {
       handler.onMessage(data);
     });
+
+    handleIfSuccessfullyParsed(data, SsePreMessageSchema, (data: SsePreMessage) => {
+      handler.onPreMessage(data);
+    });
   }, []);
 
   // Views

+ 6 - 0
apps/app/src/features/openai/interfaces/knowledge-assistant/sse-schemas.ts

@@ -11,6 +11,12 @@ export const SseMessageSchema = z.object({
   })),
 });
 
+export const SsePreMessageSchema = z.object({
+  text: z.string().nullish().describe('The pre-message that should be appended to the chat window'),
+  finished: z.boolean().describe('Indicates if the pre-message generation is finished'),
+});
+
 
 // Type definitions
 export type SseMessage = z.infer<typeof SseMessageSchema>;
+export type SsePreMessage = z.infer<typeof SsePreMessageSchema>;

+ 3 - 3
apps/app/src/features/openai/server/routes/edit/index.ts

@@ -107,9 +107,9 @@ function instruction(withMarkdown: boolean): string {
   Respond with a JSON object in the following format:
   {
     "contents": [
-      { "message": "Your brief message about the upcoming change or proposal.\n\n" },
+      { "message": "Your brief message about the upcoming changes or proposals.\n\n" },
       { "replace": "New text 1" },
-      { "message": "Additional explanation if needed" },
+      { "message": "Additional explanation if needed." },
       { "replace": "New text 2" },
       ...more items if needed
       { "message": "Your friendly message explaining what changes were made or suggested." }
@@ -117,7 +117,7 @@ function instruction(withMarkdown: boolean): string {
   }
 
   The array should contain:
-  - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure to add two consecutive line feeds ('\n\n') at the end.
+  - [At the beginning of the list] A "message" object that has your brief message about the upcoming change or proposal. Be sure that should be written in the present or future tense and add two consecutive line feeds ('\n\n') at the end.
   - Objects with a "message" key for explanatory text to the user if needed.
   - Edit markdown according to user instructions and include it line by line in the 'replace' object. ${withMarkdown ? 'Return original text for lines that do not need editing.' : ''}
   - [At the end of the list] A "message" object that contains your friendly message explaining that the operation was completed and what changes were made.

+ 21 - 0
apps/app/src/features/openai/server/routes/message/post-message.ts

@@ -5,6 +5,7 @@ import type { ValidationChain } from 'express-validator';
 import { body } from 'express-validator';
 import type { AssistantStream } from 'openai/lib/AssistantStream';
 import type { MessageDelta } from 'openai/resources/beta/threads/messages.mjs';
+import { type ChatCompletionChunk } from 'openai/resources/chat/completions';
 
 import { getOrCreateChatAssistant } from '~/features/openai/server/services/assistant';
 import type Crowi from '~/server/crowi';
@@ -115,11 +116,25 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         return res.status(500).send(err.message);
       }
 
+      /**
+      * Create SSE (Server-Sent Events) Responses
+      */
       res.writeHead(200, {
         'Content-Type': 'text/event-stream;charset=utf-8',
         'Cache-Control': 'no-cache, no-transform',
       });
 
+      const preMessageChunkHandler = (chunk: ChatCompletionChunk) => {
+        const chunkChoice = chunk.choices[0];
+
+        const content = {
+          text: chunkChoice.delta.content,
+          finished: chunkChoice.finish_reason != null,
+        };
+
+        res.write(`data: ${JSON.stringify(content)}\n\n`);
+      };
+
       const messageDeltaHandler = async(delta: MessageDelta) => {
         const content = delta.content?.[0];
 
@@ -135,6 +150,12 @@ export const postMessageHandlersFactory: PostMessageHandlersFactory = (crowi) =>
         res.write(`error: ${JSON.stringify({ code, message })}\n\n`);
       };
 
+      // Don't add await since SSE is performed asynchronously with main message
+      openaiService.generateAndProcessPreMessage(req.body.userMessage, preMessageChunkHandler)
+        .catch((err) => {
+          logger.error(err);
+        });
+
       stream.on('event', (delta) => {
         if (delta.event === 'thread.run.failed') {
           const errorMessage = delta.data.last_error?.message;

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

@@ -1,6 +1,7 @@
 import { DefaultAzureCredential, getBearerTokenProvider } from '@azure/identity';
 import type OpenAI from 'openai';
 import { AzureOpenAI } from 'openai';
+import { type Stream } from 'openai/streaming';
 import { type Uploadable } from 'openai/uploads';
 
 import type { MessageListParams } from '../../../interfaces/message';
@@ -94,7 +95,9 @@ export class AzureOpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
   }
 
-  async chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
+  async chatCompletion(
+      body: OpenAI.Chat.Completions.ChatCompletionCreateParams,
+  ): Promise<OpenAI.Chat.Completions.ChatCompletion | Stream<OpenAI.Chat.Completions.ChatCompletionChunk>> {
     return this.client.chat.completions.create(body);
   }
 

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

@@ -1 +1,2 @@
 export * from './get-client';
+export * from './is-stream-response';

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

@@ -1,4 +1,5 @@
 import type OpenAI from 'openai';
+import { type Stream } from 'openai/streaming';
 import type { Uploadable } from 'openai/uploads';
 
 import type { MessageListParams } from '../../../interfaces/message';
@@ -16,5 +17,7 @@ export interface IOpenaiClientDelegator {
   createVectorStoreFile(vectorStoreId: string, fileId: string): Promise<OpenAI.VectorStores.Files.VectorStoreFile>
   createVectorStoreFileBatch(vectorStoreId: string, fileIds: string[]): Promise<OpenAI.VectorStores.FileBatches.VectorStoreFileBatch>
   deleteFile(fileId: string): Promise<OpenAI.Files.FileDeleted>;
-  chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion>
+  chatCompletion(
+    body: OpenAI.Chat.Completions.ChatCompletionCreateParams
+  ): Promise<OpenAI.Chat.Completions.ChatCompletion | Stream<OpenAI.Chat.Completions.ChatCompletionChunk>>
 }

+ 12 - 0
apps/app/src/features/openai/server/services/client-delegator/is-stream-response.ts

@@ -0,0 +1,12 @@
+import type OpenAI from 'openai';
+import { type Stream } from 'openai/streaming';
+
+type ChatCompletionResponse = OpenAI.Chat.Completions.ChatCompletion;
+type ChatCompletionStreamResponse = Stream<OpenAI.Chat.Completions.ChatCompletionChunk>
+
+// Type guard function
+export const isStreamResponse = (result: ChatCompletionResponse | ChatCompletionStreamResponse): result is ChatCompletionStreamResponse => {
+  // Type assertion is safe due to the constrained input types
+  const assertedResult = result as any;
+  return assertedResult.tee != null && assertedResult.toReadableStream != null;
+};

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

@@ -1,4 +1,5 @@
 import OpenAI from 'openai';
+import { type Stream } from 'openai/streaming';
 import { type Uploadable } from 'openai/uploads';
 
 import { configManager } from '~/server/service/config-manager';
@@ -95,7 +96,9 @@ export class OpenaiClientDelegator implements IOpenaiClientDelegator {
     return this.client.vectorStores.fileBatches.uploadAndPoll(vectorStoreId, { files });
   }
 
-  async chatCompletion(body: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming): Promise<OpenAI.Chat.Completions.ChatCompletion> {
+  async chatCompletion(
+      body: OpenAI.Chat.Completions.ChatCompletionCreateParams,
+  ): Promise<OpenAI.Chat.Completions.ChatCompletion | Stream<OpenAI.Chat.Completions.ChatCompletionChunk>> {
     return this.client.chat.completions.create(body);
   }
 

+ 0 - 1
apps/app/src/features/openai/server/services/editor-assistant/llm-response-stream-processor.ts

@@ -1,5 +1,4 @@
 import { jsonrepair } from 'jsonrepair';
-import type { z } from 'zod';
 
 import loggerFactory from '~/utils/logger';
 

+ 39 - 5
apps/app/src/features/openai/server/services/openai.ts

@@ -4,7 +4,7 @@ import { Readable, Transform, Writable } from 'stream';
 import { pipeline } from 'stream/promises';
 
 import type {
-  IUser, Ref, Lang, IPage,
+  IUser, Ref, Lang, IPage, Nullable,
 } from '@growi/core';
 import {
   PageGrant, getIdForRef, getIdStringForRef, isPopulated, type IUserHasId,
@@ -15,6 +15,7 @@ import escapeStringRegexp from 'escape-string-regexp';
 import createError from 'http-errors';
 import mongoose, { type HydratedDocument, type Types } from 'mongoose';
 import { type OpenAI, toFile } from 'openai';
+import { type ChatCompletionChunk } from 'openai/resources/chat/completions';
 
 import ExternalUserGroupRelation from '~/features/external-user-group/server/models/external-user-group-relation';
 import ThreadRelationModel, { type ThreadRelationDocument } from '~/features/openai/server/models/thread-relation';
@@ -45,7 +46,7 @@ import { convertMarkdownToHtml } from '../utils/convert-markdown-to-html';
 import { generateGlobPatterns } from '../utils/generate-glob-patterns';
 import { isVectorStoreCompatible } from '../utils/is-vector-store-compatible';
 
-import { getClient } from './client-delegator';
+import { getClient, isStreamResponse } from './client-delegator';
 import { openaiApiErrorHandler } from './openai-api-error-handler';
 import { replaceAnnotationWithPageLink } from './replace-annotation-with-page-link';
 
@@ -72,6 +73,7 @@ const convertPathPatternsToRegExp = (pagePathPatterns: string[]): Array<string |
 };
 
 export interface IOpenaiService {
+  generateAndProcessPreMessage(message: string, chunkProcessor: (chunk: ChatCompletionChunk) => void): Promise<void>
   createThread(userId: string, type: ThreadType, aiAssistantId?: string, initialUserMessage?: string): Promise<ThreadRelationDocument>;
   getThreadsByAiAssistantId(aiAssistantId: string): Promise<ThreadRelationDocument[]>
   deleteThread(threadRelationId: string): Promise<ThreadRelationDocument>;
@@ -108,7 +110,37 @@ class OpenaiService implements IOpenaiService {
     return getClient({ openaiServiceType });
   }
 
-  private async generateThreadTitle(message: string): Promise<string | null> {
+  async generateAndProcessPreMessage(message: string, chunkProcessor: (delta: ChatCompletionChunk) => void): Promise<void> {
+    const systemMessage = [
+      "Generate a message briefly confirming the user's question.",
+      'Please generate up to 20 characters',
+    ].join('');
+
+    const preMessageCompletion = await this.client.chatCompletion({
+      stream: true,
+      model: 'gpt-4.1-nano',
+      messages: [
+        {
+          role: 'system',
+          content: systemMessage,
+        },
+        {
+          role: 'user',
+          content: message,
+        },
+      ],
+    });
+
+    if (!isStreamResponse(preMessageCompletion)) {
+      return;
+    }
+
+    for await (const chunk of preMessageCompletion) {
+      chunkProcessor(chunk);
+    }
+  }
+
+  private async generateThreadTitle(message: string): Promise<Nullable<string>> {
     const systemMessage = [
       'Create a brief title (max 5 words) from your message.',
       'Respond in the same language the user uses in their input.',
@@ -129,8 +161,10 @@ class OpenaiService implements IOpenaiService {
       ],
     });
 
-    const threadTitle = threadTitleCompletion.choices[0].message.content;
-    return threadTitle;
+    if (!isStreamResponse(threadTitleCompletion)) {
+      const threadTitle = threadTitleCompletion.choices[0].message.content;
+      return threadTitle;
+    }
   }
 
   async createThread(userId: string, type: ThreadType, aiAssistantId?: string, initialUserMessage?: string): Promise<ThreadRelationDocument> {

+ 1 - 1
apps/app/src/pages/utils/commons.ts

@@ -88,7 +88,7 @@ export const getServerSideCommonProps: GetServerSideProps<CommonProps> = async(c
     namespacesRequired: ['translation'],
     currentPathname,
     appTitle: appService.getAppTitle(),
-    siteUrl: configManager.getConfig('app:siteUrl'), // DON'T USE appService.getSiteUrl()
+    siteUrl: configManager.getConfig('app:siteUrl'), // DON'T USE growiInfoService.getSiteUrl()
     confidential: appService.getAppConfidential() || '',
     customTitleTemplate: customizeService.customTitleTemplate,
     csrfToken: req.csrfToken(),

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

@@ -11,6 +11,98 @@
  *        description: Grant for page
  *        type: number
  *        example: 1
+ *      PageInfo:
+ *        description: Basic page information
+ *        type: object
+ *        properties:
+ *          isV5Compatible:
+ *            type: boolean
+ *            description: Whether the page is compatible with v5
+ *          isEmpty:
+ *            type: boolean
+ *            description: Whether the page is empty
+ *          isMovable:
+ *            type: boolean
+ *            description: Whether the page is movable
+ *          isDeletable:
+ *            type: boolean
+ *            description: Whether the page is deletable
+ *          isAbleToDeleteCompletely:
+ *            type: boolean
+ *            description: Whether the page is able to delete completely
+ *          isRevertible:
+ *            type: boolean
+ *            description: Whether the page is revertible
+ *      PageInfoForEntity:
+ *        description: Page information for entity (extends IPageInfo)
+ *        allOf:
+ *          - $ref: '#/components/schemas/PageInfo'
+ *          - type: object
+ *            properties:
+ *              bookmarkCount:
+ *                type: number
+ *                description: Number of bookmarks
+ *              sumOfLikers:
+ *                type: number
+ *                description: Number of users who have liked the page
+ *              likerIds:
+ *                type: array
+ *                items:
+ *                  type: string
+ *                description: Ids of users who have liked the page
+ *                example: ["5e07345972560e001761fa63"]
+ *              sumOfSeenUsers:
+ *                type: number
+ *                description: Number of users who have seen the page
+ *              seenUserIds:
+ *                type: array
+ *                items:
+ *                  type: string
+ *                description: Ids of users who have seen the page
+ *                example: ["5e07345972560e001761fa63"]
+ *              contentAge:
+ *                type: number
+ *                description: Content age
+ *              descendantCount:
+ *                type: number
+ *                description: Number of descendant pages
+ *              commentCount:
+ *                type: number
+ *                description: Number of comments
+ *      PageInfoForOperation:
+ *        description: Page information for operation (extends IPageInfoForEntity)
+ *        allOf:
+ *          - $ref: '#/components/schemas/PageInfoForEntity'
+ *          - type: object
+ *            properties:
+ *              isBookmarked:
+ *                type: boolean
+ *                description: Whether the page is bookmarked by the logged in user
+ *              isLiked:
+ *                type: boolean
+ *                description: Whether the page is liked by the logged in user
+ *              subscriptionStatus:
+ *                type: string
+ *                description: Subscription status
+ *                enum:
+ *                  - 'SUBSCRIBE'
+ *                  - 'UNSUBSCRIBE'
+ *      PageInfoForListing:
+ *        description: Page information for listing (extends IPageInfoForEntity with revision short body)
+ *        allOf:
+ *          - $ref: '#/components/schemas/PageInfoForEntity'
+ *          - type: object
+ *            properties:
+ *              revisionShortBody:
+ *                type: string
+ *                description: Short body of the revision
+ *      PageInfoAll:
+ *        description: Page information (union of all page info types)
+ *        oneOf:
+ *          - $ref: '#/components/schemas/PageInfo'
+ *          - $ref: '#/components/schemas/PageInfoForEntity'
+ *          - $ref: '#/components/schemas/PageInfoForOperation'
+ *          - $ref: '#/components/schemas/PageInfoForListing'
  *      Page:
  *        description: Page
  *        type: object

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

@@ -234,16 +234,18 @@ const routerFactory = (crowi: Crowi): Router => {
    *       - bearer: []
    *       - accessTokenInQuery: []
    *     summary: /page-listing/info
-   *     description: Get the information of a page
+   *     description: Get summary information of pages
    *     parameters:
    *       - name: pageIds
    *         in: query
+   *         description: Array of page IDs to retrieve information for (One of pageIds or path is required)
    *         schema:
    *           type: array
    *           items:
    *             type: string
    *       - name: path
    *         in: query
+   *         description: Path of the page to retrieve information for (One of pageIds or path is required)
    *         schema:
    *           type: string
    *       - name: attachBookmarkCount
@@ -261,42 +263,8 @@ const routerFactory = (crowi: Crowi): Router => {
    *           application/json:
    *             schema:
    *               type: object
-   *               properties:
-   *                 idToPageInfoMap:
-   *                   type: object
-   *                   additionalProperties:
-   *                     type: object
-   *                     properties:
-   *                       commentCount:
-   *                         type: integer
-   *                       contentAge:
-   *                         type: integer
-   *                       descendantCount:
-   *                         type: integer
-   *                       isAbleToDeleteCompletely:
-   *                         type: boolean
-   *                       isDeletable:
-   *                         type: boolean
-   *                       isEmpty:
-   *                         type: boolean
-   *                       isMovable:
-   *                         type: boolean
-   *                       isRevertible:
-   *                         type: boolean
-   *                       isV5Compatible:
-   *                         type: boolean
-   *                       likerIds:
-   *                         type: array
-   *                         items:
-   *                           type: string
-   *                       seenUserIds:
-   *                         type: array
-   *                         items:
-   *                           type: string
-   *                       sumOfLikers:
-   *                         type: integer
-   *                       sumOfSeenUsers:
-   *                         type: integer
+   *               additionalProperties:
+   *                 $ref: '#/components/schemas/PageInfoAll'
    */
   // eslint-disable-next-line max-len
   router.get('/info', accessTokenParser, loginRequired, validator.pageIdsOrPathRequired, validator.infoParams, apiV3FormValidator, async(req: AuthorizedRequest, res: ApiV3Response) => {

+ 10 - 49
apps/app/src/server/routes/apiv3/page/index.ts

@@ -69,47 +69,6 @@ const router = express.Router();
  *            type: boolean
  *            description: boolean for like status
  *
- *      PageInfo:
- *        description: PageInfo
- *        type: object
- *        required:
- *          - sumOfLikers
- *          - likerIds
- *          - sumOfSeenUsers
- *          - seenUserIds
- *        properties:
- *          isLiked:
- *            type: boolean
- *            description: Whether the page is liked by the logged in user
- *          sumOfLikers:
- *            type: number
- *            description: Number of users who have liked the page
- *          likerIds:
- *            type: array
- *            items:
- *              type: string
- *            description: Ids of users who have liked the page
- *            example: ["5e07345972560e001761fa63"]
- *          sumOfSeenUsers:
- *            type: number
- *            description: Number of users who have seen the page
- *          seenUserIds:
- *            type: array
- *            items:
- *              type: string
- *            description: Ids of users who have seen the page
- *            example: ["5e07345972560e001761fa63"]
- *
- *      PageParams:
- *        description: PageParams
- *        type: object
- *        required:
- *          - pageId
- *        properties:
- *          pageId:
- *            type: string
- *            description: page ID
- *            example: 5e07345972560e001761fa63
  */
 /** @param {import('~/server/crowi').default} crowi Crowi instance */
 module.exports = (crowi) => {
@@ -488,20 +447,22 @@ module.exports = (crowi) => {
    *    /page/info:
    *      get:
    *        tags: [Page]
-   *        summary: Get page info
-   *        description: Retrieve current page info
-   *        requestBody:
-   *          content:
-   *            application/json:
-   *              schema:
-   *                $ref: '#/components/schemas/PageParams'
+   *        summary: /page/info
+   *        description: Get summary informations for a page
+   *        parameters:
+   *          - name: pageId
+   *            in: query
+   *            required: true
+   *            description: page id
+   *            schema:
+   *              $ref: '#/components/schemas/ObjectId'
    *        responses:
    *          200:
    *            description: Successfully retrieved current page info.
    *            content:
    *              application/json:
    *                schema:
-   *                  $ref: '#/components/schemas/PageInfo'
+   *                  $ref: '#/components/schemas/PageInfoAll'
    *          500:
    *            description: Internal server error.
    */

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

@@ -196,7 +196,7 @@ module.exports = (crowi) => {
    *
    *
    *    /pages/rename:
-   *      post:
+   *      put:
    *        tags: [Pages]
    *        description: Rename page
    *        requestBody:

+ 2 - 1
apps/app/src/server/routes/page.js

@@ -258,10 +258,11 @@ module.exports = function(crowi, app) {
    *   schemas:
    *     PageRemoveData:
    *       type: object
+   *       required:
+   *         - path
    *       properties:
    *         path:
    *           type: string
-   *           required: true
    *           description: Path of the deleted page
    *           example: "/user/example"
    *         isRecursively:

+ 2 - 1
apps/app/src/server/service/global-notification/global-notification-mail.js

@@ -2,6 +2,7 @@ import nodePath from 'path';
 
 import { GlobalNotificationSettingEvent, GlobalNotificationSettingType } from '~/server/models/GlobalNotificationSetting';
 import { configManager } from '~/server/service/config-manager';
+import { growiInfoService } from '~/server/service/growi-info';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:service:GlobalNotificationMailService'); // eslint-disable-line no-unused-vars
@@ -65,7 +66,7 @@ class GlobalNotificationMailService {
 
     const path = page.path;
     const appTitle = this.crowi.appService.getAppTitle();
-    const siteUrl = this.crowi.appService.getSiteUrl();
+    const siteUrl = growiInfoService.getSiteUrl();
     const pageUrl = new URL(page._id, siteUrl);
 
     let subject;

+ 285 - 0
apps/app/src/utils/axios-date-conversion.spec.ts

@@ -0,0 +1,285 @@
+import { convertDateStringsToDates } from './axios';
+
+describe('convertDateStringsToDates', () => {
+
+  // Test case 1: Basic conversion in a flat object
+  test('should convert ISO date strings to Date objects in a flat object', () => {
+    const dateString = '2023-01-15T10:00:00.000Z';
+    const input = {
+      id: 1,
+      createdAt: dateString,
+      name: 'Test Item',
+    };
+    const expected = {
+      id: 1,
+      createdAt: new Date(dateString),
+      name: 'Test Item',
+    };
+    const result = convertDateStringsToDates(input);
+    expect(result.createdAt).toBeInstanceOf(Date);
+    expect(result.createdAt.toISOString()).toEqual(dateString);
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 2: Nested objects
+  test('should recursively convert ISO date strings in nested objects', () => {
+    const dateString1 = '2023-02-20T12:30:00.000Z';
+    const dateString2 = '2023-03-01T08:00:00.000Z';
+    const input = {
+      data: {
+        item1: {
+          updatedAt: dateString1,
+          value: 10,
+        },
+        item2: {
+          nested: {
+            deletedAt: dateString2,
+            isActive: false,
+          },
+        },
+      },
+    };
+    const expected = {
+      data: {
+        item1: {
+          updatedAt: new Date(dateString1),
+          value: 10,
+        },
+        item2: {
+          nested: {
+            deletedAt: new Date(dateString2),
+            isActive: false,
+          },
+        },
+      },
+    };
+    const result = convertDateStringsToDates(input);
+    expect(result.data.item1.updatedAt).toBeInstanceOf(Date);
+    expect(result.data.item1.updatedAt.toISOString()).toEqual(dateString1);
+    expect(result.data.item2.nested.deletedAt).toBeInstanceOf(Date);
+    expect(result.data.item2.nested.deletedAt.toISOString()).toEqual(dateString2);
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 3: Arrays of objects
+  test('should recursively convert ISO date strings in arrays of objects', () => {
+    const dateString1 = '2023-04-05T14:15:00.000Z';
+    const dateString2 = '2023-05-10T16:00:00.000Z';
+    const input = [
+      { id: 1, eventDate: dateString1 },
+      { id: 2, eventDate: dateString2, data: { nestedProp: 'value' } },
+    ];
+    const expected = [
+      { id: 1, eventDate: new Date(dateString1) },
+      { id: 2, eventDate: new Date(dateString2), data: { nestedProp: 'value' } },
+    ];
+    const result = convertDateStringsToDates(input);
+    expect(result[0].eventDate).toBeInstanceOf(Date);
+    expect(result[0].eventDate.toISOString()).toEqual(dateString1);
+    expect(result[1].eventDate).toBeInstanceOf(Date);
+    expect(result[1].eventDate.toISOString()).toEqual(dateString2);
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 4: Array containing date strings directly (though less common for this function)
+  test('should handle arrays containing date strings directly', () => {
+    const dateString = '2023-06-20T18:00:00.000Z';
+    const input = ['text', dateString, 123];
+    const expected = ['text', new Date(dateString), 123];
+    const result = convertDateStringsToDates(input);
+    expect(result[1]).toBeInstanceOf(Date);
+    expect(result[1].toISOString()).toEqual(dateString);
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 5: Data without date strings should remain unchanged
+  test('should not modify data without ISO date strings', () => {
+    const input = {
+      name: 'Product A',
+      price: 99.99,
+      tags: ['electronic', 'sale'],
+      description: 'Some text',
+    };
+    const originalInput = JSON.parse(JSON.stringify(input)); // Deep copy to ensure no mutation
+    const result = convertDateStringsToDates(input);
+    expect(result).toEqual(originalInput); // Should be deeply equal
+    expect(result).toBe(input); // Confirm it mutated the original object
+  });
+
+  // Test case 6: Null, undefined, and primitive values
+  test('should return primitive values as is', () => {
+    expect(convertDateStringsToDates(null)).toBeNull();
+    expect(convertDateStringsToDates(undefined)).toBeUndefined();
+    expect(convertDateStringsToDates(123)).toBe(123);
+    expect(convertDateStringsToDates('hello')).toBe('hello');
+    expect(convertDateStringsToDates(true)).toBe(true);
+  });
+
+  // Test case 7: Edge case - empty objects/arrays
+  test('should handle empty objects and arrays correctly', () => {
+    const emptyObject = {};
+    const emptyArray = [];
+    expect(convertDateStringsToDates(emptyObject)).toEqual({});
+    expect(convertDateStringsToDates(emptyArray)).toEqual([]);
+    expect(convertDateStringsToDates(emptyObject)).toBe(emptyObject);
+    expect(convertDateStringsToDates(emptyArray)).toEqual(emptyArray);
+  });
+
+  // Test case 8: Date string with different milliseconds (isoDateRegex without .000)
+  test('should handle date strings with varied milliseconds', () => {
+    const dateString = '2023-01-15T10:00:00Z'; // No milliseconds
+    const input = { createdAt: dateString };
+    const expected = { createdAt: new Date(dateString) };
+    const result = convertDateStringsToDates(input);
+    expect(result.createdAt).toBeInstanceOf(Date);
+    expect(result.createdAt.toISOString()).toEqual('2023-01-15T10:00:00.000Z');
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 9: Object with null properties
+  test('should handle objects with null properties', () => {
+    const dateString = '2023-07-01T00:00:00.000Z';
+    const input = {
+      prop1: dateString,
+      prop2: null,
+      prop3: {
+        nestedNull: null,
+        nestedDate: dateString,
+      },
+    };
+    const expected = {
+      prop1: new Date(dateString),
+      prop2: null,
+      prop3: {
+        nestedNull: null,
+        nestedDate: new Date(dateString),
+      },
+    };
+    const result = convertDateStringsToDates(input);
+    expect(result.prop1).toBeInstanceOf(Date);
+    expect(result.prop3.nestedDate).toBeInstanceOf(Date);
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 10: Date string with UTC offset (e.g., +09:00)
+  test('should convert ISO date strings with UTC offset to Date objects', () => {
+    const dateStringWithOffset = '2025-06-12T14:00:00+09:00';
+    const input = {
+      id: 2,
+      eventTime: dateStringWithOffset,
+      details: {
+        lastActivity: '2025-06-12T05:00:00-04:00',
+      },
+    };
+    const expected = {
+      id: 2,
+      eventTime: new Date(dateStringWithOffset),
+      details: {
+        lastActivity: new Date('2025-06-12T05:00:00-04:00'),
+      },
+    };
+
+    const result = convertDateStringsToDates(input);
+
+    expect(result.eventTime).toBeInstanceOf(Date);
+    expect(result.eventTime.toISOString()).toEqual(new Date(dateStringWithOffset).toISOString());
+    expect(result.details.lastActivity).toBeInstanceOf(Date);
+    expect(result.details.lastActivity.toISOString()).toEqual(new Date('2025-06-12T05:00:00-04:00').toISOString());
+
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 11: Date string with negative UTC offset
+  test('should convert ISO date strings with negative UTC offset (-05:00) to Date objects', () => {
+    const dateStringWithNegativeOffset = '2025-01-01T10:00:00-05:00';
+    const input = {
+      startTime: dateStringWithNegativeOffset,
+    };
+    const expected = {
+      startTime: new Date(dateStringWithNegativeOffset),
+    };
+
+    const result = convertDateStringsToDates(input);
+
+    expect(result.startTime).toBeInstanceOf(Date);
+    expect(result.startTime.toISOString()).toEqual(new Date(dateStringWithNegativeOffset).toISOString());
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 12: Date string with zero UTC offset (+00:00)
+  test('should convert ISO date strings with explicit zero UTC offset (+00:00) to Date objects', () => {
+    const dateStringWithZeroOffset = '2025-03-15T12:00:00+00:00';
+    const input = {
+      zeroOffsetDate: dateStringWithZeroOffset,
+    };
+    const expected = {
+      zeroOffsetDate: new Date(dateStringWithZeroOffset),
+    };
+
+    const result = convertDateStringsToDates(input);
+
+    expect(result.zeroOffsetDate).toBeInstanceOf(Date);
+    expect(result.zeroOffsetDate.toISOString()).toEqual(new Date(dateStringWithZeroOffset).toISOString());
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 13: Date string with milliseconds and UTC offset
+  test('should convert ISO date strings with milliseconds and UTC offset to Date objects', () => {
+    const dateStringWithMsAndOffset = '2025-10-20T23:59:59.999-07:00';
+    const input = {
+      detailedTime: dateStringWithMsAndOffset,
+    };
+    const expected = {
+      detailedTime: new Date(dateStringWithMsAndOffset),
+    };
+
+    const result = convertDateStringsToDates(input);
+
+    expect(result.detailedTime).toBeInstanceOf(Date);
+    expect(result.detailedTime.toISOString()).toEqual(new Date(dateStringWithMsAndOffset).toISOString());
+    expect(result).toEqual(expected);
+  });
+
+  // Test case 14: Should NOT convert strings that look like dates but are NOT ISO 8601 or missing timezone
+  test('should NOT convert non-ISO 8601 date-like strings or strings missing timezone', () => {
+    const nonIsoDate1 = '2025/06/12 14:00:00Z'; // Wrong separator
+    const nonIsoDate2 = '2025-06-12T14:00:00'; // Missing timezone
+    const nonIsoDate3 = 'June 12, 2025 14:00:00 GMT'; // Different format
+    const nonIsoDate4 = '2025-06-12T14:00:00+0900'; // Missing colon in offset
+    const nonIsoDate5 = '2025-06-12'; // Date only
+
+    const input = {
+      date1: nonIsoDate1,
+      date2: nonIsoDate2,
+      date3: nonIsoDate3,
+      date4: nonIsoDate4,
+      date5: nonIsoDate5,
+      someOtherString: 'hello world',
+    };
+
+    // Deep copy to ensure comparison is accurate since the function modifies in place
+    const expected = JSON.parse(JSON.stringify(input));
+
+    const result = convertDateStringsToDates(input);
+
+    // Assert that they remain strings (or whatever their original type was)
+    expect(typeof result.date1).toBe('string');
+    expect(typeof result.date2).toBe('string');
+    expect(typeof result.date3).toBe('string');
+    expect(typeof result.date4).toBe('string');
+    expect(typeof result.date5).toBe('string');
+    expect(typeof result.someOtherString).toBe('string');
+
+    // Ensure the entire object is unchanged for these properties
+    expect(result.date1).toEqual(nonIsoDate1);
+    expect(result.date2).toEqual(nonIsoDate2);
+    expect(result.date3).toEqual(nonIsoDate3);
+    expect(result.date4).toEqual(nonIsoDate4);
+    expect(result.date5).toEqual(nonIsoDate5);
+    expect(result.someOtherString).toEqual('hello world');
+
+    // Finally, assert that the overall result is identical to the input for these non-matching strings
+    expect(result).toEqual(expected);
+  });
+
+});

+ 46 - 0
apps/app/src/utils/axios.ts

@@ -6,11 +6,57 @@ import qs from 'qs';
 // eslint-disable-next-line no-restricted-imports
 export * from 'axios';
 
+const isoDateRegex = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?(Z|[+-]\d{2}:\d{2})$/;
+
+export function convertDateStringsToDates(data: any): any {
+  if (typeof data !== 'object' || data === null) {
+    if (typeof data === 'string' && isoDateRegex.test(data)) {
+      return new Date(data);
+    }
+    return data;
+  }
+
+  if (Array.isArray(data)) {
+    return data.map(item => convertDateStringsToDates(item));
+  }
+
+  for (const key of Object.keys(data)) {
+    const value = data[key];
+    if (typeof value === 'string' && isoDateRegex.test(value)) {
+      data[key] = new Date(value);
+    }
+
+    else if (typeof value === 'object' && value !== null) {
+      data[key] = convertDateStringsToDates(value);
+    }
+  }
+  return data;
+}
+
+// Determine the base array of transformers
+let baseTransformers = axios.defaults.transformResponse;
+
+if (baseTransformers == null) {
+  baseTransformers = [];
+}
+
+else if (!Array.isArray(baseTransformers)) {
+  // If it's a single transformer function, wrap it in an array
+  baseTransformers = [baseTransformers];
+}
+
+
 const customAxios = axios.create({
   headers: {
     'X-Requested-With': 'XMLHttpRequest',
     'Content-Type': 'application/json',
   },
+
+  transformResponse: baseTransformers.concat(
+    (data) => {
+      return convertDateStringsToDates(data);
+    },
+  ),
 });
 
 // serialize Date config: https://github.com/axios/axios/issues/1548#issuecomment-548306666

+ 1 - 7
biome.json

@@ -19,14 +19,8 @@
       ".stylelintrc.json",
       "package.json",
       "./apps/**",
-      "./packages/core/**",
-      "./packages/core-styles/**",
-      "./packages/custom-icons/**",
       "./packages/editor/**",
-      "./packages/pdf-converter-client/**",
-      "./packages/pluginkit/**",
-      "./packages/presentation/**",
-      "./packages/remark-attachment-refs/**"
+      "./packages/pdf-converter-client/src/index.ts"
     ]
   },
   "formatter": {

+ 1 - 1
packages/core/.eslintignore

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

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

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

+ 1 - 1
packages/core/package.json

@@ -60,7 +60,7 @@
     "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
-    "lint:js": "eslint **/*.{js,ts}",
+    "lint:js": "biome check",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "npm-run-all -p lint:*",
     "test": "vitest run --coverage"

+ 2 - 1
packages/core/src/consts/accepted-upload-file-type.ts

@@ -3,4 +3,5 @@ export const AcceptedUploadFileType = {
   IMAGE: 'image',
   NONE: 'none',
 } as const;
-export type AcceptedUploadFileType = typeof AcceptedUploadFileType[keyof typeof AcceptedUploadFileType];
+export type AcceptedUploadFileType =
+  (typeof AcceptedUploadFileType)[keyof typeof AcceptedUploadFileType];

+ 2 - 1
packages/core/src/consts/growi-plugin.ts

@@ -4,4 +4,5 @@ export const GrowiPluginType = {
   Theme: 'theme',
   Script: 'script',
 } as const;
-export type GrowiPluginType = typeof GrowiPluginType[keyof typeof GrowiPluginType];
+export type GrowiPluginType =
+  (typeof GrowiPluginType)[keyof typeof GrowiPluginType];

+ 4 - 2
packages/core/src/consts/system.ts

@@ -12,6 +12,8 @@ export const GrowiDeploymentType = {
   others: 'others',
 } as const;
 
-export type GrowiServiceType = typeof GrowiServiceType[keyof typeof GrowiServiceType]
+export type GrowiServiceType =
+  (typeof GrowiServiceType)[keyof typeof GrowiServiceType];
 
-export type GrowiDeploymentType = typeof GrowiDeploymentType[keyof typeof GrowiDeploymentType]
+export type GrowiDeploymentType =
+  (typeof GrowiDeploymentType)[keyof typeof GrowiDeploymentType];

+ 1 - 1
packages/core/src/consts/ydoc-status.ts

@@ -12,4 +12,4 @@ export const YDocStatus = {
   OUTDATED: 'outdated',
   ISOLATED: 'isolated',
 } as const;
-export type YDocStatus = typeof YDocStatus[keyof typeof YDocStatus]
+export type YDocStatus = (typeof YDocStatus)[keyof typeof YDocStatus];

+ 14 - 14
packages/core/src/interfaces/attachment.ts

@@ -1,25 +1,25 @@
 import type { Ref } from './common';
-import { HasObjectId } from './has-object-id';
+import type { HasObjectId } from './has-object-id';
 import type { IPage } from './page';
 import type { IUser } from './user';
 
 export type IAttachment = {
-  page?: Ref<IPage>,
-  creator?: Ref<IUser>,
-  filePath?: string, // DEPRECATED: remains for backward compatibility for v3.3.x or below
-  fileName: string,
-  fileFormat: string,
-  fileSize: number,
-  originalName: string,
-  temporaryUrlCached?: string,
-  temporaryUrlExpiredAt?: Date,
-  attachmentType: string,
+  page?: Ref<IPage>;
+  creator?: Ref<IUser>;
+  filePath?: string; // DEPRECATED: remains for backward compatibility for v3.3.x or below
+  fileName: string;
+  fileFormat: string;
+  fileSize: number;
+  originalName: string;
+  temporaryUrlCached?: string;
+  temporaryUrlExpiredAt?: Date;
+  attachmentType: string;
 
-  createdAt: Date,
+  createdAt: Date;
 
   // virtual property
-  filePathProxied: string,
-  downloadPathProxied: string,
+  filePathProxied: string;
+  downloadPathProxied: string;
 };
 
 export type IAttachmentHasId = IAttachment & HasObjectId;

+ 1 - 1
packages/core/src/interfaces/color-scheme.ts

@@ -2,4 +2,4 @@ export const ColorScheme = {
   LIGHT: 'light',
   DARK: 'dark',
 } as const;
-export type ColorScheme = typeof ColorScheme[keyof typeof ColorScheme];
+export type ColorScheme = (typeof ColorScheme)[keyof typeof ColorScheme];

+ 1 - 6
packages/core/src/interfaces/common.spec.ts

@@ -4,10 +4,9 @@ import { mock } from 'vitest-mock-extended';
 
 import { getIdForRef, isPopulated } from './common';
 import type { IPageHasId } from './page';
-import { type IPage } from './page';
+import type { IPage } from './page';
 
 describe('isPopulated', () => {
-
   it('should return true when the argument implements HasObjectId', () => {
     // Arrange
     const ref = mock<IPageHasId>();
@@ -51,12 +50,9 @@ describe('isPopulated', () => {
     // Assert
     expect(result).toBe(false);
   });
-
 });
 
-
 describe('getIdForRef', () => {
-
   it('should return the id string when the argument is populated', () => {
     // Arrange
     const id = new Types.ObjectId();
@@ -106,5 +102,4 @@ describe('getIdForRef', () => {
     // Assert
     expect(result).toStrictEqual(ref);
   });
-
 });

+ 18 - 14
packages/core/src/interfaces/common.ts

@@ -9,29 +9,33 @@ import { isValidObjectId } from '../utils/objectid-utils';
 type ObjectId = Types.ObjectId;
 
 // Foreign key field
-export type Ref<T> = string | ObjectId | T & { _id: string | ObjectId };
+export type Ref<T> = string | ObjectId | (T & { _id: string | ObjectId });
 
 export type Nullable<T> = T | null | undefined;
 
 export const isRef = <T>(obj: unknown): obj is Ref<T> => {
-  return obj != null
-    && (
-      (typeof obj === 'string' && isValidObjectId(obj))
-        || (typeof obj === 'object' && '_bsontype' in obj && obj._bsontype === 'ObjectID')
-        || (typeof obj === 'object' && '_id' in obj)
-    );
+  return (
+    obj != null &&
+    ((typeof obj === 'string' && isValidObjectId(obj)) ||
+      (typeof obj === 'object' &&
+        '_bsontype' in obj &&
+        obj._bsontype === 'ObjectID') ||
+      (typeof obj === 'object' && '_id' in obj))
+  );
 };
 
-export const isPopulated = <T>(ref: Ref<T>): ref is T & { _id: string | ObjectId } => {
-  return ref != null
-    && typeof ref !== 'string'
-    && !('_bsontype' in ref && ref._bsontype === 'ObjectID');
+export const isPopulated = <T>(
+  ref: Ref<T>,
+): ref is T & { _id: string | ObjectId } => {
+  return (
+    ref != null &&
+    typeof ref !== 'string' &&
+    !('_bsontype' in ref && ref._bsontype === 'ObjectID')
+  );
 };
 
 export const getIdForRef = <T>(ref: Ref<T>): string | ObjectId => {
-  return isPopulated(ref)
-    ? ref._id
-    : ref;
+  return isPopulated(ref) ? ref._id : ref;
 };
 
 export const getIdStringForRef = <T>(ref: Ref<T>): string => {

+ 23 - 12
packages/core/src/interfaces/config-manager.ts

@@ -5,7 +5,7 @@ export const ConfigSource = {
   env: 'env',
   db: 'db',
 } as const;
-export type ConfigSource = typeof ConfigSource[keyof typeof ConfigSource];
+export type ConfigSource = (typeof ConfigSource)[keyof typeof ConfigSource];
 
 /**
  * Metadata for a configuration value
@@ -19,12 +19,14 @@ export interface ConfigDefinition<T> {
 /**
  * Helper function for defining configurations with type safety
  */
-export const defineConfig = <T>(config: ConfigDefinition<T>): ConfigDefinition<T> => config;
+export const defineConfig = <T>(
+  config: ConfigDefinition<T>,
+): ConfigDefinition<T> => config;
 
 /**
  * Interface for loading configuration values
  */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
+// biome-ignore lint/suspicious/noExplicitAny: ignore
 export interface IConfigLoader<K extends string, V extends Record<K, any>> {
   /**
    * Load configurations from environment variables
@@ -37,11 +39,14 @@ export interface IConfigLoader<K extends string, V extends Record<K, any>> {
   loadFromDB(): Promise<RawConfigData<K, V>>;
 }
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export type RawConfigData<K extends string, V extends Record<K, any>> = Record<K, {
-  value: V[K];
-  definition?: ConfigDefinition<V[K]>;
-}>;
+// biome-ignore lint/suspicious/noExplicitAny: ignore
+export type RawConfigData<K extends string, V extends Record<K, any>> = Record<
+  K,
+  {
+    value: V[K];
+    definition?: ConfigDefinition<V[K]>;
+  }
+>;
 
 export type UpdateConfigOptions = {
   skipPubsub?: boolean;
@@ -51,7 +56,7 @@ export type UpdateConfigOptions = {
 /**
  * Interface for managing configuration values
  */
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
+// biome-ignore lint/suspicious/noExplicitAny: ignore
 export interface IConfigManager<K extends string, V extends Record<K, any>> {
   /**
    * Load configurations
@@ -67,12 +72,19 @@ export interface IConfigManager<K extends string, V extends Record<K, any>> {
   /**
    * Update a configuration value
    */
-  updateConfig<T extends K>(key: T, value: V[T], options?: UpdateConfigOptions): Promise<void>;
+  updateConfig<T extends K>(
+    key: T,
+    value: V[T],
+    options?: UpdateConfigOptions,
+  ): Promise<void>;
 
   /**
    * Update multiple configuration values
    */
-  updateConfigs(updates: Partial<{ [T in K]: V[T] }>, options?: UpdateConfigOptions): Promise<void>;
+  updateConfigs(
+    updates: Partial<{ [T in K]: V[T] }>,
+    options?: UpdateConfigOptions,
+  ): Promise<void>;
 
   /**
    * Remove multiple configuration values
@@ -83,5 +95,4 @@ export interface IConfigManager<K extends string, V extends Record<K, any>> {
    * Get environment variables managed with ConfigDefinitions
    */
   getManagedEnvVars(showSecretValues: boolean): Record<string, string>;
-
 }

+ 4 - 4
packages/core/src/interfaces/external-account.ts

@@ -2,7 +2,7 @@ import type { Ref } from './common';
 import type { IUser } from './user';
 
 export type IExternalAccount<P> = {
-  providerType: P,
-  accountId: string,
-  user: Ref<IUser>,
-}
+  providerType: P;
+  accountId: string;
+  user: Ref<IUser>;
+};

+ 17 - 17
packages/core/src/interfaces/growi-app-info.ts

@@ -3,29 +3,29 @@ import type * as os from 'node:os';
 import type { GrowiDeploymentType, GrowiServiceType } from '../consts/system';
 
 export const GrowiWikiType = { open: 'open', closed: 'closed' } as const;
-type GrowiWikiType = typeof GrowiWikiType[keyof typeof GrowiWikiType]
+type GrowiWikiType = (typeof GrowiWikiType)[keyof typeof GrowiWikiType];
 
 interface IGrowiOSInfo {
-  type?: ReturnType<typeof os.type>
-  platform?: ReturnType<typeof os.platform>
-  arch?: ReturnType<typeof os.arch>
-  totalmem?: ReturnType<typeof os.totalmem>
+  type?: ReturnType<typeof os.type>;
+  platform?: ReturnType<typeof os.platform>;
+  arch?: ReturnType<typeof os.arch>;
+  totalmem?: ReturnType<typeof os.totalmem>;
 }
 
 export interface IGrowiAdditionalInfo {
-  installedAt: Date
-  installedAtByOldestUser: Date | null
-  currentUsersCount: number
-  currentActiveUsersCount: number
+  installedAt: Date;
+  installedAtByOldestUser: Date | null;
+  currentUsersCount: number;
+  currentActiveUsersCount: number;
 }
 
 export interface IGrowiInfo<A extends object = IGrowiAdditionalInfo> {
-  serviceInstanceId: string
-  appSiteUrl: string
-  osInfo: IGrowiOSInfo
-  version: string
-  type: GrowiServiceType
-  wikiType: GrowiWikiType
-  deploymentType: GrowiDeploymentType
-  additionalInfo?: A
+  serviceInstanceId: string;
+  appSiteUrl: string;
+  osInfo: IGrowiOSInfo;
+  version: string;
+  type: GrowiServiceType;
+  wikiType: GrowiWikiType;
+  deploymentType: GrowiDeploymentType;
+  additionalInfo?: A;
 }

+ 10 - 4
packages/core/src/interfaces/growi-facade.ts

@@ -1,12 +1,18 @@
 export type GrowiFacade = {
   markdownRenderer?: {
     optionsGenerators?: {
+      // biome-ignore lint/suspicious/noExplicitAny: ignore
       generateViewOptions?: any;
+      // biome-ignore lint/suspicious/noExplicitAny: ignore
       customGenerateViewOptions?: any;
+      // biome-ignore lint/suspicious/noExplicitAny: ignore
       generatePreviewOptions?: any;
+      // biome-ignore lint/suspicious/noExplicitAny: ignore
       customGeneratePreviewOptions?: any;
-    },
-    optionsMutators?: any,
-  },
-  react?: any,
+    };
+    // biome-ignore lint/suspicious/noExplicitAny: ignore
+    optionsMutators?: any;
+  };
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  react?: any;
 };

+ 42 - 26
packages/core/src/interfaces/growi-theme-metadata.ts

@@ -4,36 +4,52 @@ export const GrowiThemeSchemeType = {
   ...ColorScheme,
   BOTH: 'both',
 } as const;
-export type GrowiThemeSchemeType = typeof GrowiThemeSchemeType[keyof typeof GrowiThemeSchemeType];
+export type GrowiThemeSchemeType =
+  (typeof GrowiThemeSchemeType)[keyof typeof GrowiThemeSchemeType];
 
 export type GrowiThemeMetadata = {
-  name: string,
-  manifestKey: string,
-  schemeType: GrowiThemeSchemeType,
-  lightBg: string,
-  darkBg: string,
-  lightSidebar: string,
-  darkSidebar: string,
-  lightIcon: string,
-  darkIcon: string,
-  createBtn: string,
-  isPresetTheme?: boolean,
+  name: string;
+  manifestKey: string;
+  schemeType: GrowiThemeSchemeType;
+  lightBg: string;
+  darkBg: string;
+  lightSidebar: string;
+  darkSidebar: string;
+  lightIcon: string;
+  darkIcon: string;
+  createBtn: string;
+  isPresetTheme?: boolean;
 };
 
-export const isGrowiThemeMetadata = (obj: unknown): obj is GrowiThemeMetadata => {
+export const isGrowiThemeMetadata = (
+  obj: unknown,
+): obj is GrowiThemeMetadata => {
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
   const objAny = obj as any;
 
-  return objAny != null
-    && typeof objAny === 'object'
-    && Array.isArray(objAny) === false
-    && 'name' in objAny && typeof objAny.name === 'string'
-    && 'manifestKey' in objAny && typeof objAny.manifestKey === 'string'
-    && 'schemeType' in objAny && typeof objAny.schemeType === 'string'
-    && 'lightBg' in objAny && typeof objAny.lightBg === 'string'
-    && 'darkBg' in objAny && typeof objAny.darkBg === 'string'
-    && 'lightSidebar' in objAny && typeof objAny.lightSidebar === 'string'
-    && 'darkSidebar' in objAny && typeof objAny.darkSidebar === 'string'
-    && 'lightIcon' in objAny && typeof objAny.lightIcon === 'string'
-    && 'darkIcon' in objAny && typeof objAny.darkIcon === 'string'
-    && 'createBtn' in objAny && typeof objAny.createBtn === 'string';
+  return (
+    objAny != null &&
+    typeof objAny === 'object' &&
+    Array.isArray(objAny) === false &&
+    'name' in objAny &&
+    typeof objAny.name === 'string' &&
+    'manifestKey' in objAny &&
+    typeof objAny.manifestKey === 'string' &&
+    'schemeType' in objAny &&
+    typeof objAny.schemeType === 'string' &&
+    'lightBg' in objAny &&
+    typeof objAny.lightBg === 'string' &&
+    'darkBg' in objAny &&
+    typeof objAny.darkBg === 'string' &&
+    'lightSidebar' in objAny &&
+    typeof objAny.lightSidebar === 'string' &&
+    'darkSidebar' in objAny &&
+    typeof objAny.darkSidebar === 'string' &&
+    'lightIcon' in objAny &&
+    typeof objAny.lightIcon === 'string' &&
+    'darkIcon' in objAny &&
+    typeof objAny.darkIcon === 'string' &&
+    'createBtn' in objAny &&
+    typeof objAny.createBtn === 'string'
+  );
 };

+ 1 - 1
packages/core/src/interfaces/has-object-id.ts

@@ -1,3 +1,3 @@
 export type HasObjectId = {
-  _id: string,
+  _id: string;
 };

+ 1 - 1
packages/core/src/interfaces/lang.ts

@@ -5,4 +5,4 @@ export const Lang = {
   fr_FR: 'fr_FR',
 } as const;
 export const AllLang = Object.values(Lang);
-export type Lang = typeof Lang[keyof typeof Lang];
+export type Lang = (typeof Lang)[keyof typeof Lang];

+ 122 - 89
packages/core/src/interfaces/page.ts

@@ -1,56 +1,69 @@
 import type { Ref } from './common';
 import type { HasObjectId } from './has-object-id';
-import type { IRevision, HasRevisionShortbody, IRevisionHasId } from './revision';
+import type {
+  HasRevisionShortbody,
+  IRevision,
+  IRevisionHasId,
+} from './revision';
 import type { SubscriptionStatusType } from './subscription';
 import type { ITag } from './tag';
-import type {
-  IUser, IUserGroup, IUserGroupHasId, IUserHasId,
-} from './user';
+import type { IUser, IUserGroup, IUserGroupHasId, IUserHasId } from './user';
 
-export const GroupType = { userGroup: 'UserGroup', externalUserGroup: 'ExternalUserGroup' } as const;
-export type GroupType = typeof GroupType[keyof typeof GroupType];
+export const GroupType = {
+  userGroup: 'UserGroup',
+  externalUserGroup: 'ExternalUserGroup',
+} as const;
+export type GroupType = (typeof GroupType)[keyof typeof GroupType];
 
 export type IGrantedGroup = {
-  type: GroupType,
-  item: Ref<IUserGroup>,
-}
+  type: GroupType;
+  item: Ref<IUserGroup>;
+};
 
 export type IPage = {
-  path: string,
-  status: string,
-  revision?: Ref<IRevision>,
-  tags: Ref<ITag>[],
-  creator?: Ref<IUser>,
-  createdAt: Date,
-  updatedAt: Date,
-  seenUsers: Ref<IUser>[],
-  parent: Ref<IPage> | null,
-  descendantCount: number,
-  isEmpty: boolean,
-  grant: PageGrant,
-  grantedUsers: Ref<IUser>[],
-  grantedGroups: IGrantedGroup[],
-  lastUpdateUser?: Ref<IUser>,
-  liker: Ref<IUser>[],
-  commentCount: number
-  slackChannels: string,
-  deleteUser: Ref<IUser>,
-  deletedAt: Date,
-  latestRevision?: Ref<IRevision>,
-  latestRevisionBodyLength?: number,
-  expandContentWidth?: boolean,
-  wip?: boolean,
-  ttlTimestamp?: Date
-}
-
-export type IPagePopulatedToShowRevision = Omit<IPageHasId, 'lastUpdateUser'|'creator'|'deleteUser'|'grantedGroups'|'revision'|'author'> & {
-  lastUpdateUser?: IUserHasId,
-  creator?: IUserHasId,
-  deleteUser: IUserHasId,
-  grantedGroups: { type: GroupType, item: IUserGroupHasId }[],
-  revision?: IRevisionHasId,
-  author: IUserHasId,
-}
+  path: string;
+  status: string;
+  revision?: Ref<IRevision>;
+  tags: Ref<ITag>[];
+  creator?: Ref<IUser>;
+  createdAt: Date;
+  updatedAt: Date;
+  seenUsers: Ref<IUser>[];
+  parent: Ref<IPage> | null;
+  descendantCount: number;
+  isEmpty: boolean;
+  grant: PageGrant;
+  grantedUsers: Ref<IUser>[];
+  grantedGroups: IGrantedGroup[];
+  lastUpdateUser?: Ref<IUser>;
+  liker: Ref<IUser>[];
+  commentCount: number;
+  slackChannels: string;
+  deleteUser: Ref<IUser>;
+  deletedAt: Date;
+  latestRevision?: Ref<IRevision>;
+  latestRevisionBodyLength?: number;
+  expandContentWidth?: boolean;
+  wip?: boolean;
+  ttlTimestamp?: Date;
+};
+
+export type IPagePopulatedToShowRevision = Omit<
+  IPageHasId,
+  | 'lastUpdateUser'
+  | 'creator'
+  | 'deleteUser'
+  | 'grantedGroups'
+  | 'revision'
+  | 'author'
+> & {
+  lastUpdateUser?: IUserHasId;
+  creator?: IUserHasId;
+  deleteUser: IUserHasId;
+  grantedGroups: { type: GroupType; item: IUserGroupHasId }[];
+  revision?: IRevisionHasId;
+  author: IUserHasId;
+};
 
 export const PageGrant = {
   GRANT_PUBLIC: 1,
@@ -60,68 +73,83 @@ export const PageGrant = {
   GRANT_USER_GROUP: 5,
 } as const;
 type UnionPageGrantKeys = keyof typeof PageGrant;
-export type PageGrant = typeof PageGrant[UnionPageGrantKeys];
+export type PageGrant = (typeof PageGrant)[UnionPageGrantKeys];
 
 export const PageStatus = {
   STATUS_PUBLISHED: 'published',
   STATUS_DELETED: 'deleted',
 } as const;
-export type PageStatus = typeof PageStatus[keyof typeof PageStatus];
+export type PageStatus = (typeof PageStatus)[keyof typeof PageStatus];
 
 export type IPageHasId = IPage & HasObjectId;
 
 export type IPageInfo = {
-  isV5Compatible: boolean,
-  isEmpty: boolean,
-  isMovable: boolean,
-  isDeletable: boolean,
-  isAbleToDeleteCompletely: boolean,
-  isRevertible: boolean,
-}
+  isV5Compatible: boolean;
+  isEmpty: boolean;
+  isMovable: boolean;
+  isDeletable: boolean;
+  isAbleToDeleteCompletely: boolean;
+  isRevertible: boolean;
+};
 
 export type IPageInfoForEntity = IPageInfo & {
-  bookmarkCount: number,
-  sumOfLikers: number,
-  likerIds: string[],
-  sumOfSeenUsers: number,
-  seenUserIds: string[],
-  contentAge: number,
-  descendantCount: number,
-  commentCount: number,
-}
+  bookmarkCount: number;
+  sumOfLikers: number;
+  likerIds: string[];
+  sumOfSeenUsers: number;
+  seenUserIds: string[];
+  contentAge: number;
+  descendantCount: number;
+  commentCount: number;
+};
 
 export type IPageInfoForOperation = IPageInfoForEntity & {
-  isBookmarked?: boolean,
-  isLiked?: boolean,
-  subscriptionStatus?: SubscriptionStatusType,
-}
+  isBookmarked?: boolean;
+  isLiked?: boolean;
+  subscriptionStatus?: SubscriptionStatusType;
+};
 
 export type IPageInfoForListing = IPageInfoForEntity & HasRevisionShortbody;
 
-export type IPageInfoAll = IPageInfo | IPageInfoForEntity | IPageInfoForOperation | IPageInfoForListing;
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const isIPageInfo = (pageInfo: any | undefined): pageInfo is IPageInfo => {
-  return pageInfo != null && pageInfo instanceof Object
-    && ('isEmpty' in pageInfo);
+export type IPageInfoAll =
+  | IPageInfo
+  | IPageInfoForEntity
+  | IPageInfoForOperation
+  | IPageInfoForListing;
+
+export const isIPageInfo = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageInfo => {
+  return (
+    pageInfo != null && pageInfo instanceof Object && 'isEmpty' in pageInfo
+  );
 };
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const isIPageInfoForEntity = (pageInfo: any | undefined): pageInfo is IPageInfoForEntity => {
-  return isIPageInfo(pageInfo)
-    && pageInfo.isEmpty === false;
+export const isIPageInfoForEntity = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageInfoForEntity => {
+  return isIPageInfo(pageInfo) && pageInfo.isEmpty === false;
 };
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const isIPageInfoForOperation = (pageInfo: any | undefined): pageInfo is IPageInfoForOperation => {
-  return isIPageInfoForEntity(pageInfo)
-    && ('isBookmarked' in pageInfo || 'isLiked' in pageInfo || 'subscriptionStatus' in pageInfo);
+export const isIPageInfoForOperation = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageInfoForOperation => {
+  return (
+    isIPageInfoForEntity(pageInfo) &&
+    ('isBookmarked' in pageInfo ||
+      'isLiked' in pageInfo ||
+      'subscriptionStatus' in pageInfo)
+  );
 };
 
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-export const isIPageInfoForListing = (pageInfo: any | undefined): pageInfo is IPageInfoForListing => {
-  return isIPageInfoForEntity(pageInfo)
-    && 'revisionShortBody' in pageInfo;
+export const isIPageInfoForListing = (
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  pageInfo: any | undefined,
+): pageInfo is IPageInfoForListing => {
+  return isIPageInfoForEntity(pageInfo) && 'revisionShortBody' in pageInfo;
 };
 
 // export type IPageInfoTypeResolver<T extends IPageInfo> =
@@ -141,11 +169,16 @@ export const isIPageInfoForListing = (pageInfo: any | undefined): pageInfo is IP
 // };
 
 export type IDataWithMeta<D = unknown, M = unknown> = {
-  data: D,
-  meta?: M,
-}
+  data: D;
+  meta?: M;
+};
 
 export type IPageWithMeta<M = IPageInfoAll> = IDataWithMeta<IPageHasId, M>;
 
-export type IPageToDeleteWithMeta<T = IPageInfoForEntity | unknown> = IDataWithMeta<HasObjectId & (IPage | { path: string, revision: string | null}), T>;
-export type IPageToRenameWithMeta<T = IPageInfoForEntity | unknown> = IPageToDeleteWithMeta<T>;
+export type IPageToDeleteWithMeta<T = IPageInfoForEntity | unknown> =
+  IDataWithMeta<
+    HasObjectId & (IPage | { path: string; revision: string | null }),
+    T
+  >;
+export type IPageToRenameWithMeta<T = IPageInfoForEntity | unknown> =
+  IPageToDeleteWithMeta<T>;

+ 121 - 99
packages/core/src/interfaces/primitive/string.spec.ts

@@ -1,147 +1,169 @@
 import { describe, expect, it } from 'vitest';
 
 import {
-  isNonEmptyString,
-  toNonEmptyString,
-  toNonEmptyStringOrUndefined,
   isNonBlankString,
+  isNonEmptyString,
   toNonBlankString,
   toNonBlankStringOrUndefined,
+  toNonEmptyString,
+  toNonEmptyStringOrUndefined,
 } from './string';
 
 describe('isNonEmptyString', () => {
   /* eslint-disable indent */
   it.each`
-    input         | expected      | description
-    ${'hello'}    | ${true}       | ${'non-empty string'}
-    ${'world'}    | ${true}       | ${'non-empty string'}
-    ${'a'}        | ${true}       | ${'single character'}
-    ${'1'}        | ${true}       | ${'numeric string'}
-    ${' '}        | ${true}       | ${'space character'}
-    ${'   '}      | ${true}       | ${'multiple spaces'}
-    ${''}         | ${false}      | ${'empty string'}
-    ${null}       | ${false}      | ${'null'}
-    ${undefined}  | ${false}      | ${'undefined'}
-  `('should return $expected for $description: $input', ({ input, expected }) => {
-  /* eslint-enable indent */
-    expect(isNonEmptyString(input)).toBe(expected);
-  });
+    input        | expected | description
+    ${'hello'}   | ${true}  | ${'non-empty string'}
+    ${'world'}   | ${true}  | ${'non-empty string'}
+    ${'a'}       | ${true}  | ${'single character'}
+    ${'1'}       | ${true}  | ${'numeric string'}
+    ${' '}       | ${true}  | ${'space character'}
+    ${'   '}     | ${true}  | ${'multiple spaces'}
+    ${''}        | ${false} | ${'empty string'}
+    ${null}      | ${false} | ${'null'}
+    ${undefined} | ${false} | ${'undefined'}
+  `(
+    'should return $expected for $description: $input',
+    ({ input, expected }) => {
+      /* eslint-enable indent */
+      expect(isNonEmptyString(input)).toBe(expected);
+    },
+  );
 });
 
 describe('isNonBlankString', () => {
   /* eslint-disable indent */
   it.each`
-    input         | expected      | description
-    ${'hello'}    | ${true}       | ${'non-blank string'}
-    ${'world'}    | ${true}       | ${'non-blank string'}
-    ${'a'}        | ${true}       | ${'single character'}
-    ${'1'}        | ${true}       | ${'numeric string'}
-    ${' '}        | ${false}      | ${'space character'}
-    ${'   '}      | ${false}      | ${'multiple spaces'}
-    ${'\t'}       | ${false}      | ${'tab character'}
-    ${'\n'}       | ${false}      | ${'newline character'}
-    ${''}         | ${false}      | ${'empty string'}
-    ${null}       | ${false}      | ${'null'}
-    ${undefined}  | ${false}      | ${'undefined'}
-  `('should return $expected for $description: $input', ({ input, expected }) => {
-  /* eslint-enable indent */
-    expect(isNonBlankString(input)).toBe(expected);
-  });
+    input        | expected | description
+    ${'hello'}   | ${true}  | ${'non-blank string'}
+    ${'world'}   | ${true}  | ${'non-blank string'}
+    ${'a'}       | ${true}  | ${'single character'}
+    ${'1'}       | ${true}  | ${'numeric string'}
+    ${' '}       | ${false} | ${'space character'}
+    ${'   '}     | ${false} | ${'multiple spaces'}
+    ${'\t'}      | ${false} | ${'tab character'}
+    ${'\n'}      | ${false} | ${'newline character'}
+    ${''}        | ${false} | ${'empty string'}
+    ${null}      | ${false} | ${'null'}
+    ${undefined} | ${false} | ${'undefined'}
+  `(
+    'should return $expected for $description: $input',
+    ({ input, expected }) => {
+      /* eslint-enable indent */
+      expect(isNonBlankString(input)).toBe(expected);
+    },
+  );
 });
 
 describe('toNonEmptyStringOrUndefined', () => {
   /* eslint-disable indent */
   it.each`
-    input         | expected      | description
-    ${'hello'}    | ${'hello'}    | ${'non-empty string'}
-    ${'world'}    | ${'world'}    | ${'non-empty string'}
-    ${'a'}        | ${'a'}        | ${'single character'}
-    ${'1'}        | ${'1'}        | ${'numeric string'}
-    ${' '}        | ${' '}        | ${'space character'}
-    ${'   '}      | ${'   '}      | ${'multiple spaces'}
-    ${''}         | ${undefined}  | ${'empty string'}
-    ${null}       | ${undefined}  | ${'null'}
-    ${undefined}  | ${undefined}  | ${'undefined'}
-  `('should return $expected for $description: $input', ({ input, expected }) => {
-  /* eslint-enable indent */
-    expect(toNonEmptyStringOrUndefined(input)).toBe(expected);
-  });
+    input        | expected     | description
+    ${'hello'}   | ${'hello'}   | ${'non-empty string'}
+    ${'world'}   | ${'world'}   | ${'non-empty string'}
+    ${'a'}       | ${'a'}       | ${'single character'}
+    ${'1'}       | ${'1'}       | ${'numeric string'}
+    ${' '}       | ${' '}       | ${'space character'}
+    ${'   '}     | ${'   '}     | ${'multiple spaces'}
+    ${''}        | ${undefined} | ${'empty string'}
+    ${null}      | ${undefined} | ${'null'}
+    ${undefined} | ${undefined} | ${'undefined'}
+  `(
+    'should return $expected for $description: $input',
+    ({ input, expected }) => {
+      /* eslint-enable indent */
+      expect(toNonEmptyStringOrUndefined(input)).toBe(expected);
+    },
+  );
 });
 
 describe('toNonBlankStringOrUndefined', () => {
   /* eslint-disable indent */
   it.each`
-    input         | expected      | description
-    ${'hello'}    | ${'hello'}    | ${'non-blank string'}
-    ${'world'}    | ${'world'}    | ${'non-blank string'}
-    ${'a'}        | ${'a'}        | ${'single character'}
-    ${'1'}        | ${'1'}        | ${'numeric string'}
-    ${' '}        | ${undefined}  | ${'space character'}
-    ${'   '}      | ${undefined}  | ${'multiple spaces'}
-    ${'\t'}       | ${undefined}  | ${'tab character'}
-    ${'\n'}       | ${undefined}  | ${'newline character'}
-    ${''}         | ${undefined}  | ${'empty string'}
-    ${null}       | ${undefined}  | ${'null'}
-    ${undefined}  | ${undefined}  | ${'undefined'}
-  `('should return $expected for $description: $input', ({ input, expected }) => {
-  /* eslint-enable indent */
-    expect(toNonBlankStringOrUndefined(input)).toBe(expected);
-  });
+    input        | expected     | description
+    ${'hello'}   | ${'hello'}   | ${'non-blank string'}
+    ${'world'}   | ${'world'}   | ${'non-blank string'}
+    ${'a'}       | ${'a'}       | ${'single character'}
+    ${'1'}       | ${'1'}       | ${'numeric string'}
+    ${' '}       | ${undefined} | ${'space character'}
+    ${'   '}     | ${undefined} | ${'multiple spaces'}
+    ${'\t'}      | ${undefined} | ${'tab character'}
+    ${'\n'}      | ${undefined} | ${'newline character'}
+    ${''}        | ${undefined} | ${'empty string'}
+    ${null}      | ${undefined} | ${'null'}
+    ${undefined} | ${undefined} | ${'undefined'}
+  `(
+    'should return $expected for $description: $input',
+    ({ input, expected }) => {
+      /* eslint-enable indent */
+      expect(toNonBlankStringOrUndefined(input)).toBe(expected);
+    },
+  );
 });
 
 describe('toNonEmptyString', () => {
   /* eslint-disable indent */
   it.each`
-    input         | expected      | description
-    ${'hello'}    | ${'hello'}    | ${'non-empty string'}
-    ${'world'}    | ${'world'}    | ${'non-empty string'}
-    ${'a'}        | ${'a'}        | ${'single character'}
-    ${'1'}        | ${'1'}        | ${'numeric string'}
-    ${' '}        | ${' '}        | ${'space character'}
-    ${'   '}      | ${'   '}      | ${'multiple spaces'}
-  `('should return $expected for valid $description: $input', ({ input, expected }) => {
-  /* eslint-enable indent */
-    expect(toNonEmptyString(input)).toBe(expected);
-  });
+    input      | expected   | description
+    ${'hello'} | ${'hello'} | ${'non-empty string'}
+    ${'world'} | ${'world'} | ${'non-empty string'}
+    ${'a'}     | ${'a'}     | ${'single character'}
+    ${'1'}     | ${'1'}     | ${'numeric string'}
+    ${' '}     | ${' '}     | ${'space character'}
+    ${'   '}   | ${'   '}   | ${'multiple spaces'}
+  `(
+    'should return $expected for valid $description: $input',
+    ({ input, expected }) => {
+      /* eslint-enable indent */
+      expect(toNonEmptyString(input)).toBe(expected);
+    },
+  );
 
   /* eslint-disable indent */
   it.each`
-    input         | description
-    ${''}         | ${'empty string'}
-    ${null}       | ${'null'}
-    ${undefined}  | ${'undefined'}
+    input        | description
+    ${''}        | ${'empty string'}
+    ${null}      | ${'null'}
+    ${undefined} | ${'undefined'}
   `('should throw error for invalid $description: $input', ({ input }) => {
-  /* eslint-enable indent */
-    expect(() => toNonEmptyString(input)).toThrow('Expected a non-empty string, but received:');
+    /* eslint-enable indent */
+    expect(() => toNonEmptyString(input)).toThrow(
+      'Expected a non-empty string, but received:',
+    );
   });
 });
 
 describe('toNonBlankString', () => {
   /* eslint-disable indent */
   it.each`
-    input         | expected      | description
-    ${'hello'}    | ${'hello'}    | ${'non-blank string'}
-    ${'world'}    | ${'world'}    | ${'non-blank string'}
-    ${'a'}        | ${'a'}        | ${'single character'}
-    ${'1'}        | ${'1'}        | ${'numeric string'}
-  `('should return $expected for valid $description: $input', ({ input, expected }) => {
-  /* eslint-enable indent */
-    expect(toNonBlankString(input)).toBe(expected);
-  });
+    input      | expected   | description
+    ${'hello'} | ${'hello'} | ${'non-blank string'}
+    ${'world'} | ${'world'} | ${'non-blank string'}
+    ${'a'}     | ${'a'}     | ${'single character'}
+    ${'1'}     | ${'1'}     | ${'numeric string'}
+  `(
+    'should return $expected for valid $description: $input',
+    ({ input, expected }) => {
+      /* eslint-enable indent */
+      expect(toNonBlankString(input)).toBe(expected);
+    },
+  );
 
   /* eslint-disable indent */
   it.each`
-    input         | description
-    ${' '}        | ${'space character'}
-    ${'   '}      | ${'multiple spaces'}
-    ${'\t'}       | ${'tab character'}
-    ${'\n'}       | ${'newline character'}
-    ${''}         | ${'empty string'}
-    ${null}       | ${'null'}
-    ${undefined}  | ${'undefined'}
+    input        | description
+    ${' '}       | ${'space character'}
+    ${'   '}     | ${'multiple spaces'}
+    ${'\t'}      | ${'tab character'}
+    ${'\n'}      | ${'newline character'}
+    ${''}        | ${'empty string'}
+    ${null}      | ${'null'}
+    ${undefined} | ${'undefined'}
   `('should throw error for invalid $description: $input', ({ input }) => {
-  /* eslint-enable indent */
-    expect(() => toNonBlankString(input)).toThrow('Expected a non-blank string, but received:');
+    /* eslint-enable indent */
+    expect(() => toNonBlankString(input)).toThrow(
+      'Expected a non-blank string, but received:',
+    );
   });
 });
 

+ 16 - 6
packages/core/src/interfaces/primitive/string.ts

@@ -9,7 +9,9 @@ export type NonEmptyString = string & { readonly __brand: unique symbol };
  * @param value - The value to check
  * @returns True if the value is a string with length > 0, false otherwise
  */
-export const isNonEmptyString = (value: string | null | undefined): value is NonEmptyString => {
+export const isNonEmptyString = (
+  value: string | null | undefined,
+): value is NonEmptyString => {
   return value != null && value.length > 0;
 };
 
@@ -21,7 +23,8 @@ export const isNonEmptyString = (value: string | null | undefined): value is Non
  */
 export const toNonEmptyString = (value: string): NonEmptyString => {
   // throw Error if the value is null, undefined or empty
-  if (!isNonEmptyString(value)) throw new Error(`Expected a non-empty string, but received: ${value}`);
+  if (!isNonEmptyString(value))
+    throw new Error(`Expected a non-empty string, but received: ${value}`);
   return value;
 };
 
@@ -30,7 +33,9 @@ export const toNonEmptyString = (value: string): NonEmptyString => {
  * @param value - The string to convert
  * @returns The string as NonEmptyString type, or undefined if the value is null, undefined, or empty
  */
-export const toNonEmptyStringOrUndefined = (value: string | null | undefined): NonEmptyString | undefined => {
+export const toNonEmptyStringOrUndefined = (
+  value: string | null | undefined,
+): NonEmptyString | undefined => {
   // return undefined if the value is null, undefined or empty
   if (!isNonEmptyString(value)) return undefined;
   return value;
@@ -49,7 +54,9 @@ export type NonBlankString = string & { readonly __brand: unique symbol };
  * @param value - The value to check
  * @returns True if the value is a string with trimmed length > 0, false otherwise
  */
-export const isNonBlankString = (value: string | null | undefined): value is NonBlankString => {
+export const isNonBlankString = (
+  value: string | null | undefined,
+): value is NonBlankString => {
   return value != null && value.trim().length > 0;
 };
 
@@ -61,7 +68,8 @@ export const isNonBlankString = (value: string | null | undefined): value is Non
  */
 export const toNonBlankString = (value: string): NonBlankString => {
   // throw Error if the value is null, undefined or empty
-  if (!isNonBlankString(value)) throw new Error(`Expected a non-blank string, but received: ${value}`);
+  if (!isNonBlankString(value))
+    throw new Error(`Expected a non-blank string, but received: ${value}`);
   return value;
 };
 
@@ -70,7 +78,9 @@ export const toNonBlankString = (value: string): NonBlankString => {
  * @param value - The string to convert
  * @returns The string as NonBlankString type, or undefined if the value is null, undefined, empty, or contains only whitespace characters
  */
-export const toNonBlankStringOrUndefined = (value: string | null | undefined): NonBlankString | undefined => {
+export const toNonBlankStringOrUndefined = (
+  value: string | null | undefined,
+): NonBlankString | undefined => {
   // return undefined if the value is null, undefined or blank (empty or whitespace only)
   if (!isNonBlankString(value)) return undefined;
   return value;

+ 18 - 18
packages/core/src/interfaces/revision.ts

@@ -8,33 +8,33 @@ export const Origin = {
   Editor: 'editor',
 } as const;
 
-export type Origin = typeof Origin[keyof typeof Origin];
+export type Origin = (typeof Origin)[keyof typeof Origin];
 
 export const allOrigin = Object.values(Origin);
 
 export type IRevision = {
-  pageId: Ref<IPage>,
-  body: string,
-  author: Ref<IUser>,
-  format: string,
+  pageId: Ref<IPage>;
+  body: string;
+  author: Ref<IUser>;
+  format: string;
   hasDiffToPrev?: boolean;
-  origin?: Origin,
-  createdAt: Date,
-  updatedAt: Date,
-}
+  origin?: Origin;
+  createdAt: Date;
+  updatedAt: Date;
+};
 
 export type IRevisionHasId = IRevision & HasObjectId;
 
 export type IRevisionsForPagination = {
-  revisions: IRevisionHasId[], // revisions in one pagination
-  totalCounts: number // total counts
-}
+  revisions: IRevisionHasId[]; // revisions in one pagination
+  totalCounts: number; // total counts
+};
 export type HasRevisionShortbody = {
-  revisionShortBody?: string,
-}
+  revisionShortBody?: string;
+};
 
 export type SWRInfinitePageRevisionsResponse = {
-  revisions: IRevisionHasId[],
-  totalCount: number,
-  offset: number,
-}
+  revisions: IRevisionHasId[];
+  totalCount: number;
+  offset: number;
+};

+ 9 - 8
packages/core/src/interfaces/subscription.ts

@@ -7,15 +7,16 @@ export const SubscriptionStatusType = {
   UNSUBSCRIBE: 'UNSUBSCRIBE',
 } as const;
 export const AllSubscriptionStatusType = Object.values(SubscriptionStatusType);
-export type SubscriptionStatusType = typeof SubscriptionStatusType[keyof typeof SubscriptionStatusType];
+export type SubscriptionStatusType =
+  (typeof SubscriptionStatusType)[keyof typeof SubscriptionStatusType];
 
 export interface ISubscription {
-  user: Ref<IUser>
-  targetModel: string
-  target: Ref<IPage>
-  status: string
-  createdAt: Date
+  user: Ref<IUser>;
+  targetModel: string;
+  target: Ref<IPage>;
+  status: string;
+  createdAt: Date;
 
-  isSubscribing(): boolean
-  isUnsubscribing(): boolean
+  isSubscribing(): boolean;
+  isUnsubscribing(): boolean;
 }

+ 3 - 3
packages/core/src/interfaces/tag.ts

@@ -1,4 +1,4 @@
 export type ITag<ID = string> = {
-  _id: ID
-  name: string,
-}
+  _id: ID;
+  name: string;
+};

+ 33 - 33
packages/core/src/interfaces/user.ts

@@ -4,40 +4,40 @@ import type { HasObjectId } from './has-object-id';
 import type { Lang } from './lang';
 
 export type IUser = {
-  name: string,
-  username: string,
-  email: string,
-  password: string,
-  image?: string, // for backward conpatibility
-  imageAttachment?: Ref<IAttachment>,
-  imageUrlCached: string,
-  isGravatarEnabled: boolean,
-  admin: boolean,
-  readOnly: boolean,
-  apiToken?: string,
-  isEmailPublished: boolean,
-  isInvitationEmailSended: boolean,
-  lang: Lang,
-  slackMemberId?: string,
-  createdAt: Date,
-  lastLoginAt?: Date,
-  introduction: string,
-  status: IUserStatus,
-  isQuestionnaireEnabled: boolean,
-}
+  name: string;
+  username: string;
+  email: string;
+  password: string;
+  image?: string; // for backward conpatibility
+  imageAttachment?: Ref<IAttachment>;
+  imageUrlCached: string;
+  isGravatarEnabled: boolean;
+  admin: boolean;
+  readOnly: boolean;
+  apiToken?: string;
+  isEmailPublished: boolean;
+  isInvitationEmailSended: boolean;
+  lang: Lang;
+  slackMemberId?: string;
+  createdAt: Date;
+  lastLoginAt?: Date;
+  introduction: string;
+  status: IUserStatus;
+  isQuestionnaireEnabled: boolean;
+};
 
 export type IUserGroupRelation = {
-  relatedGroup: Ref<IUserGroup>,
-  relatedUser: Ref<IUser>,
-  createdAt: Date,
-}
+  relatedGroup: Ref<IUserGroup>;
+  relatedUser: Ref<IUser>;
+  createdAt: Date;
+};
 
 export type IUserGroup = {
   name: string;
   createdAt: Date;
   description: string;
   parent: Ref<IUserGroup> | null;
-}
+};
 
 export const USER_STATUS = {
   REGISTERED: 1,
@@ -46,16 +46,16 @@ export const USER_STATUS = {
   DELETED: 4,
   INVITED: 5,
 } as const;
-export type IUserStatus = typeof USER_STATUS[keyof typeof USER_STATUS]
+export type IUserStatus = (typeof USER_STATUS)[keyof typeof USER_STATUS];
 
 export type IUserHasId = IUser & HasObjectId;
 export type IUserGroupHasId = IUserGroup & HasObjectId;
 export type IUserGroupRelationHasId = IUserGroupRelation & HasObjectId;
 
 export type IAdminExternalAccount<P> = {
-  _id: string,
-  providerType: P,
-  accountId: string,
-  user: IUser,
-  createdAt: Date,
-}
+  _id: string;
+  providerType: P;
+  accountId: string;
+  user: IUser;
+  createdAt: Date;
+};

+ 7 - 7
packages/core/src/interfaces/vite.ts

@@ -1,10 +1,10 @@
 export type ViteManifestValue = {
-  file: string,
-  src: string,
-  isEntry?: boolean,
-  css?: string[],
-}
+  file: string;
+  src: string;
+  isEntry?: boolean;
+  css?: string[];
+};
 
 export type ViteManifest = {
-  [key: string]: ViteManifestValue,
-}
+  [key: string]: ViteManifestValue;
+};

+ 6 - 8
packages/core/src/models/devided-page-path.ts

@@ -1,10 +1,10 @@
 import * as pathUtils from '../utils/path-utils';
 
 // https://regex101.com/r/BahpKX/2
-const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d{2})$/;
+const PATTERN_INCLUDE_DATE =
+  /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d{2})$/;
 
 export class DevidedPagePath {
-
   isRoot: boolean;
 
   isFormerRoot: boolean;
@@ -14,7 +14,6 @@ export class DevidedPagePath {
   latter: string;
 
   constructor(path: string, skipNormalize = false, evalDatePath = false) {
-
     this.isRoot = false;
     this.isFormerRoot = false;
     this.former = '';
@@ -40,11 +39,11 @@ export class DevidedPagePath {
     }
 
     let PATTERN_DEFAULT = /^((.*)\/(?!em>))?(.+)$/; // this will ignore em's end tags
-    try { // for non-chrome browsers
-      // eslint-disable-next-line regex/invalid
+    try {
+      // for non-chrome browsers
+      // biome-ignore lint/complexity/useRegexLiterals: ignore
       PATTERN_DEFAULT = new RegExp('^((.*)(?<!<)\\/)?(.+)$'); // https://regex101.com/r/HJNvMW/1
-    }
-    catch (err) {
+    } catch (err) {
       // lookbehind regex is not supported on non-chrome browsers
     }
 
@@ -55,5 +54,4 @@ export class DevidedPagePath {
       this.latter = matchDefault[3];
     }
   }
-
 }

+ 28 - 17
packages/core/src/models/serializers/attachment-serializer.ts

@@ -2,23 +2,28 @@ import { Document } from 'mongoose';
 
 import type { IAttachment, IUser } from '~/interfaces';
 
-import { isPopulated, isRef, type Ref } from '../../interfaces/common';
+import { type Ref, isPopulated, isRef } from '../../interfaces/common';
 
-import { serializeUserSecurely, type IUserSerializedSecurely } from './user-serializer';
+import {
+  type IUserSerializedSecurely,
+  serializeUserSecurely,
+} from './user-serializer';
 
-export type IAttachmentSerializedSecurely<A extends IAttachment> = Omit<A, 'creator'> & { creator?: Ref<IUserSerializedSecurely<IUser>> };
+export type IAttachmentSerializedSecurely<A extends IAttachment> = Omit<
+  A,
+  'creator'
+> & { creator?: Ref<IUserSerializedSecurely<IUser>> };
 
-const omitInsecureAttributes = <A extends IAttachment>(attachment: A): IAttachmentSerializedSecurely<A> => {
-
-  const leanDoc = (attachment instanceof Document)
-    ? attachment.toObject<A>()
-    : attachment;
+const omitInsecureAttributes = <A extends IAttachment>(
+  attachment: A,
+): IAttachmentSerializedSecurely<A> => {
+  const leanDoc =
+    attachment instanceof Document ? attachment.toObject<A>() : attachment;
 
   const { creator, ...rest } = leanDoc;
 
-  const secureCreator = creator == null
-    ? undefined
-    : serializeUserSecurely(creator);
+  const secureCreator =
+    creator == null ? undefined : serializeUserSecurely(creator);
 
   return {
     creator: secureCreator,
@@ -26,12 +31,18 @@ const omitInsecureAttributes = <A extends IAttachment>(attachment: A): IAttachme
   };
 };
 
-
-export function serializeAttachmentSecurely<A extends IAttachment>(attachment?: A): IAttachmentSerializedSecurely<A>;
-export function serializeAttachmentSecurely<A extends IAttachment>(attachment?: Ref<A>): Ref<IAttachmentSerializedSecurely<A>>;
-export function serializeAttachmentSecurely<A extends IAttachment>(attachment?: A | Ref<A>)
-    : undefined | IAttachmentSerializedSecurely<A> | Ref<IAttachmentSerializedSecurely<A>> {
-
+export function serializeAttachmentSecurely<A extends IAttachment>(
+  attachment?: A,
+): IAttachmentSerializedSecurely<A>;
+export function serializeAttachmentSecurely<A extends IAttachment>(
+  attachment?: Ref<A>,
+): Ref<IAttachmentSerializedSecurely<A>>;
+export function serializeAttachmentSecurely<A extends IAttachment>(
+  attachment?: A | Ref<A>,
+):
+  | undefined
+  | IAttachmentSerializedSecurely<A>
+  | Ref<IAttachmentSerializedSecurely<A>> {
   if (attachment == null) return attachment;
 
   if (isRef(attachment) && !isPopulated(attachment)) return attachment;

+ 22 - 11
packages/core/src/models/serializers/user-serializer.ts

@@ -1,19 +1,24 @@
 import { Document } from 'mongoose';
 
-import { isPopulated, isRef, type Ref } from '../../interfaces/common';
+import { type Ref, isPopulated, isRef } from '../../interfaces/common';
 import type { IUser } from '../../interfaces/user';
 
-export type IUserSerializedSecurely<U extends IUser> = Omit<U, 'password' | 'apiToken' | 'email'> & { email?: string };
+export type IUserSerializedSecurely<U extends IUser> = Omit<
+  U,
+  'password' | 'apiToken' | 'email'
+> & { email?: string };
 
-export const omitInsecureAttributes = <U extends IUser>(user: U): IUserSerializedSecurely<U> => {
-
-  const leanDoc = (user instanceof Document)
-    ? user.toObject<U>()
-    : user;
+export const omitInsecureAttributes = <U extends IUser>(
+  user: U,
+): IUserSerializedSecurely<U> => {
+  const leanDoc = user instanceof Document ? user.toObject<U>() : user;
 
   const {
     // eslint-disable-next-line @typescript-eslint/no-unused-vars
-    password, apiToken, email, ...rest
+    password,
+    apiToken,
+    email,
+    ...rest
   } = leanDoc;
 
   const secureUser: IUserSerializedSecurely<U> = rest;
@@ -26,9 +31,15 @@ export const omitInsecureAttributes = <U extends IUser>(user: U): IUserSerialize
   return secureUser;
 };
 
-export function serializeUserSecurely<U extends IUser>(user?: U): IUserSerializedSecurely<U>;
-export function serializeUserSecurely<U extends IUser>(user?: Ref<U>): Ref<IUserSerializedSecurely<U>>;
-export function serializeUserSecurely<U extends IUser>(user?: U | Ref<U>): undefined | IUserSerializedSecurely<U> | Ref<IUserSerializedSecurely<U>> {
+export function serializeUserSecurely<U extends IUser>(
+  user?: U,
+): IUserSerializedSecurely<U>;
+export function serializeUserSecurely<U extends IUser>(
+  user?: Ref<U>,
+): Ref<IUserSerializedSecurely<U>>;
+export function serializeUserSecurely<U extends IUser>(
+  user?: U | Ref<U>,
+): undefined | IUserSerializedSecurely<U> | Ref<IUserSerializedSecurely<U>> {
   if (user == null) return user;
 
   if (isRef(user) && !isPopulated(user)) return user;

+ 8 - 3
packages/core/src/models/vo/error-apiv3.ts

@@ -1,15 +1,20 @@
 export class ErrorV3 extends Error {
-
   code: string;
 
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
   args?: any;
 
-  constructor(message = '', code = '', stack = undefined, args: any = undefined) {
+  constructor(
+    message = '',
+    code = '',
+    stack = undefined,
+    // biome-ignore lint/suspicious/noExplicitAny: ignore
+    args: any = undefined,
+  ) {
     super(); // do not provide message to the super constructor
     this.message = message;
     this.code = code;
     this.stack = stack;
     this.args = args;
   }
-
 }

+ 3 - 3
packages/core/src/remark-plugins/interfaces/option-parser.ts

@@ -1,4 +1,4 @@
 export type ParseRangeResult = {
-  start: number,
-  end: number,
-}
+  start: number;
+  end: number;
+};

+ 14 - 13
packages/core/src/remark-plugins/util/option-parser.spec.ts

@@ -1,26 +1,27 @@
 import { OptionParser } from './option-parser';
 
 describe('option-parser', () => {
-
   test.concurrent.each`
     arg
     ${'aaa'}
     ${'5++2'}
     ${'5:+2'}
-  `('.parseRange(\'$arg\') returns null', ({ arg }) => {
+  `(".parseRange('$arg') returns null", ({ arg }) => {
     expect(OptionParser.parseRange(arg)).toBeNull();
   });
 
   test.concurrent.each`
-    arg       | start | end
-    ${'1'}    | ${1} | ${1}
-    ${'2:1'}  | ${2} | ${1}
-    ${'2:'}   | ${2} | ${-1}
-    ${'10:-3'}   | ${10} | ${-3}
-    ${'5+2'}   | ${5} | ${7}
-    ${'5+'}   | ${5} | ${5}
-  `('.parseRange(\'$arg\') returns { start: $start, end : $end }', ({ arg, start, end }) => {
-    expect(OptionParser.parseRange(arg)).toEqual({ start, end });
-  });
-
+    arg        | start | end
+    ${'1'}     | ${1}  | ${1}
+    ${'2:1'}   | ${2}  | ${1}
+    ${'2:'}    | ${2}  | ${-1}
+    ${'10:-3'} | ${10} | ${-3}
+    ${'5+2'}   | ${5}  | ${7}
+    ${'5+'}    | ${5}  | ${5}
+  `(
+    ".parseRange('$arg') returns { start: $start, end : $end }",
+    ({ arg, start, end }) => {
+      expect(OptionParser.parseRange(arg)).toEqual({ start, end });
+    },
+  );
 });

+ 6 - 9
packages/core/src/remark-plugins/util/option-parser.ts

@@ -3,8 +3,7 @@ import type { ParseRangeResult } from '../interfaces/option-parser';
 /**
  * Options parser for custom tag
  */
-export class OptionParser {
-
+export const OptionParser = {
   /**
    * Parse range expression
    *
@@ -23,7 +22,7 @@ export class OptionParser {
    * @param {string} str
    * @returns {ParseRangeResult}
    */
-  static parseRange(str: string): ParseRangeResult | null {
+  parseRange(str: string): ParseRangeResult | null {
     if (str == null) {
       return null;
     }
@@ -35,7 +34,7 @@ export class OptionParser {
     }
 
     // determine start
-    let start;
+    let start: number;
     let end = -1;
 
     // has operator
@@ -46,8 +45,7 @@ export class OptionParser {
       // determine end
       if (operator === ':') {
         end = +match[4] || -1; // set last(-1) if undefined
-      }
-      else if (operator === '+') {
+      } else if (operator === '+') {
         end = +match[4] || 0; // plus zero if undefined
         end += start;
       }
@@ -59,6 +57,5 @@ export class OptionParser {
     }
 
     return { start, end };
-  }
-
-}
+  },
+};

+ 23 - 10
packages/core/src/swr/use-swr-static.ts

@@ -1,18 +1,31 @@
 import {
-  Key, SWRConfiguration, SWRResponse, useSWRConfig,
+  type Key,
+  type SWRConfiguration,
+  type SWRResponse,
+  useSWRConfig,
 } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
-
 export function useSWRStatic<Data, Error>(key: Key): SWRResponse<Data, Error>;
-export function useSWRStatic<Data, Error>(key: Key, data: Data | undefined): SWRResponse<Data, Error>;
-export function useSWRStatic<Data, Error>(key: Key, data: Data | undefined,
-  configuration: SWRConfiguration<Data, Error> | undefined): SWRResponse<Data, Error>;
+export function useSWRStatic<Data, Error>(
+  key: Key,
+  data: Data | undefined,
+): SWRResponse<Data, Error>;
+export function useSWRStatic<Data, Error>(
+  key: Key,
+  data: Data | undefined,
+  configuration: SWRConfiguration<Data, Error> | undefined,
+): SWRResponse<Data, Error>;
 
 export function useSWRStatic<Data, Error>(
-    ...args: readonly [Key]
+  ...args:
+    | readonly [Key]
     | readonly [Key, Data | undefined]
-    | readonly [Key, Data | undefined, SWRConfiguration<Data, Error> | undefined]
+    | readonly [
+        Key,
+        Data | undefined,
+        SWRConfiguration<Data, Error> | undefined,
+      ]
 ): SWRResponse<Data, Error> {
   const [key, data, configuration] = args;
 
@@ -23,9 +36,9 @@ export function useSWRStatic<Data, Error>(
   const { cache } = useSWRConfig();
   const swrResponse = useSWRImmutable(key, null, {
     ...configuration,
-    fallbackData: configuration?.fallbackData ?? (
-      key != null ? cache.get(key?.toString())?.data : undefined
-    ),
+    fallbackData:
+      configuration?.fallbackData ??
+      (key != null ? cache.get(key?.toString())?.data : undefined),
   });
 
   // update data

+ 6 - 1
packages/core/src/swr/with-utils.ts

@@ -1,7 +1,12 @@
 import type { SWRResponse } from 'swr';
 
+// biome-ignore lint/suspicious/noExplicitAny: ignore
 export type SWRResponseWithUtils<U, D = any, E = any> = SWRResponse<D, E> & U;
 
-export const withUtils = <U, D = any, E = any>(response: SWRResponse<D, E>, utils: U): SWRResponseWithUtils<U, D, E> => {
+// biome-ignore lint/suspicious/noExplicitAny: ignore
+export const withUtils = <U, D = any, E = any>(
+  response: SWRResponse<D, E>,
+  utils: U,
+): SWRResponseWithUtils<U, D, E> => {
   return Object.assign(response, utils);
 };

+ 4 - 1
packages/core/src/utils/browser-utils.ts

@@ -1,5 +1,8 @@
 export const isClient = (): boolean => {
-  return (typeof window !== 'undefined') || (typeof navigator !== 'undefined' && navigator.webdriver);
+  return (
+    typeof window !== 'undefined' ||
+    (typeof navigator !== 'undefined' && navigator.webdriver)
+  );
 };
 
 export const isServer = (): boolean => {

+ 0 - 3
packages/core/src/utils/env-utils.spec.ts

@@ -1,9 +1,7 @@
 import { toBoolean } from './env-utils';
 
-
 describe('env-utils', () => {
   describe('.toBoolean', () => {
-
     it('should convert to true', () => {
       expect(toBoolean('true')).toBe(true);
       expect(toBoolean('True')).toBe(true);
@@ -16,6 +14,5 @@ describe('env-utils', () => {
       expect(toBoolean('false')).toBe(false);
       expect(toBoolean('0')).toBe(false);
     });
-
   });
 });

+ 6 - 3
packages/core/src/utils/growi-theme-metadata.ts

@@ -1,8 +1,11 @@
 import type { ColorScheme } from '../interfaces/color-scheme';
 import { GrowiThemeSchemeType } from '../interfaces/growi-theme-metadata';
 
-export const getForcedColorScheme = (growiThemeSchemeType?: GrowiThemeSchemeType): ColorScheme | undefined => {
-  return growiThemeSchemeType == null || growiThemeSchemeType === GrowiThemeSchemeType.BOTH
+export const getForcedColorScheme = (
+  growiThemeSchemeType?: GrowiThemeSchemeType,
+): ColorScheme | undefined => {
+  return growiThemeSchemeType == null ||
+    growiThemeSchemeType === GrowiThemeSchemeType.BOTH
     ? undefined
-    : growiThemeSchemeType as ColorScheme;
+    : (growiThemeSchemeType as ColorScheme);
 };

+ 11 - 2
packages/core/src/utils/is-deep-equals.ts

@@ -1,8 +1,17 @@
 const isPrimitiveComparison = (value1: unknown, value2: unknown): boolean => {
-  return value1 === null || value2 === null || typeof value1 !== 'object' || typeof value2 !== 'object';
+  return (
+    value1 === null ||
+    value2 === null ||
+    typeof value1 !== 'object' ||
+    typeof value2 !== 'object'
+  );
 };
 
-export const isDeepEquals = <T extends object>(obj1: T, obj2: T, visited = new WeakMap()): boolean => {
+export const isDeepEquals = <T extends object>(
+  obj1: T,
+  obj2: T,
+  visited = new WeakMap(),
+): boolean => {
   // If references are identical, return true
   if (obj1 === obj2) {
     return true;

+ 1 - 3
packages/core/src/utils/objectid-utils.spec.ts

@@ -3,7 +3,6 @@ import ObjectId from 'bson-objectid';
 import { isValidObjectId } from './objectid-utils';
 
 describe('isValidObjectId', () => {
-
   /* eslint-disable indent */
   describe.concurrent.each`
     arg                                           | expected
@@ -15,7 +14,7 @@ describe('isValidObjectId', () => {
     ${'594ced02ed345b2b049222c5'}                 | ${true}
     ${new ObjectId('594ced02ed345b2b049222c5')}   | ${true}
   `('should return $expected', ({ arg, expected }) => {
-    test(`when the argument is '${arg}'`, async() => {
+    test(`when the argument is '${arg}'`, async () => {
       // when:
       const result = isValidObjectId(arg);
 
@@ -23,5 +22,4 @@ describe('isValidObjectId', () => {
       expect(result).toBe(expected);
     });
   });
-
 });

+ 3 - 1
packages/core/src/utils/objectid-utils.ts

@@ -1,6 +1,8 @@
 import ObjectId from 'bson-objectid';
 
-export function isValidObjectId(id: string | ObjectId | null | undefined): boolean {
+export function isValidObjectId(
+  id: string | ObjectId | null | undefined,
+): boolean {
   if (id == null) {
     return false;
   }

+ 28 - 12
packages/core/src/utils/page-path-utils/generate-children-regexp.spec.ts

@@ -19,24 +19,37 @@ describe('generateChildrenRegExp', () => {
     {
       path: '/parent (with brackets)',
       expected: '^\\/parent \\(with brackets\\)(\\/[^/]+)\\/?$',
-      validPaths: ['/parent (with brackets)/child', '/parent (with brackets)/test'],
-      invalidPaths: ['/parent (with brackets)', '/parent (with brackets)/child/grandchild'],
+      validPaths: [
+        '/parent (with brackets)/child',
+        '/parent (with brackets)/test',
+      ],
+      invalidPaths: [
+        '/parent (with brackets)',
+        '/parent (with brackets)/child/grandchild',
+      ],
     },
     {
       path: '/parent[with square]',
       expected: '^\\/parent\\[with square\\](\\/[^/]+)\\/?$',
       validPaths: ['/parent[with square]/child', '/parent[with square]/test'],
-      invalidPaths: ['/parent[with square]', '/parent[with square]/child/grandchild'],
+      invalidPaths: [
+        '/parent[with square]',
+        '/parent[with square]/child/grandchild',
+      ],
     },
     {
       path: '/parent*with+special?chars',
       expected: '^\\/parent\\*with\\+special\\?chars(\\/[^/]+)\\/?$',
-      validPaths: ['/parent*with+special?chars/child', '/parent*with+special?chars/test'],
-      invalidPaths: ['/parent*with+special?chars', '/parent*with+special?chars/child/grandchild'],
+      validPaths: [
+        '/parent*with+special?chars/child',
+        '/parent*with+special?chars/test',
+      ],
+      invalidPaths: [
+        '/parent*with+special?chars',
+        '/parent*with+special?chars/child/grandchild',
+      ],
     },
-  ])('with path: $path', ({
-    path, expected, validPaths, invalidPaths,
-  }) => {
+  ])('with path: $path', ({ path, expected, validPaths, invalidPaths }) => {
     test('should generate correct regexp pattern', () => {
       const result = generateChildrenRegExp(path);
       expect(result.source).toBe(expected);
@@ -47,9 +60,12 @@ describe('generateChildrenRegExp', () => {
       expect(validPath).toMatch(result);
     });
 
-    test.each(invalidPaths)('should not match invalid path: %s', (invalidPath) => {
-      const result = generateChildrenRegExp(path);
-      expect(invalidPath).not.toMatch(result);
-    });
+    test.each(invalidPaths)(
+      'should not match invalid path: %s',
+      (invalidPath) => {
+        const result = generateChildrenRegExp(path);
+        expect(invalidPath).not.toMatch(result);
+      },
+    );
   });
 });

+ 80 - 33
packages/core/src/utils/page-path-utils/index.spec.ts

@@ -1,5 +1,11 @@
 import {
-  isMovablePage, isTopPage, isUsersProtectedPages, convertToNewAffiliationPath, isCreatablePage, omitDuplicateAreaPathFromPaths, getUsernameByPath,
+  convertToNewAffiliationPath,
+  getUsernameByPath,
+  isCreatablePage,
+  isMovablePage,
+  isTopPage,
+  isUsersProtectedPages,
+  omitDuplicateAreaPathFromPaths,
 } from './index';
 
 describe.concurrent('isMovablePage test', () => {
@@ -33,19 +39,34 @@ describe.concurrent('isUsersProtectedPages test', () => {
 
 describe.concurrent('convertToNewAffiliationPath test', () => {
   test.concurrent('Child path is not converted normally', () => {
-    const result = convertToNewAffiliationPath('parent/', 'parent2/', 'parent/child');
+    const result = convertToNewAffiliationPath(
+      'parent/',
+      'parent2/',
+      'parent/child',
+    );
     expect(result).toBe('parent2/child');
   });
 
   test.concurrent('Parent path is not converted normally', () => {
-    const result = convertToNewAffiliationPath('parent/', 'parent3/', 'parent/child');
+    const result = convertToNewAffiliationPath(
+      'parent/',
+      'parent3/',
+      'parent/child',
+    );
     expect(result === 'parent/child').toBe(false);
   });
 
-  test.concurrent('Parent and Child path names are switched unexpectedly', () => {
-    const result = convertToNewAffiliationPath('parent/', 'parent4/', 'parent/child');
-    expect(result === 'child/parent4').toBe(false);
-  });
+  test.concurrent(
+    'Parent and Child path names are switched unexpectedly',
+    () => {
+      const result = convertToNewAffiliationPath(
+        'parent/',
+        'parent4/',
+        'parent/child',
+      );
+      expect(result === 'child/parent4').toBe(false);
+    },
+  );
 });
 
 describe.concurrent('isCreatablePage test', () => {
@@ -99,8 +120,17 @@ describe.concurrent('isCreatablePage test', () => {
 
     expect(isCreatablePage('/ the / path / with / space')).toBeFalsy();
 
-    const forbidden = ['installer', 'register', 'login', 'logout',
-                       'admin', 'files', 'trash', 'paste', 'comments'];
+    const forbidden = [
+      'installer',
+      'register',
+      'login',
+      'logout',
+      'admin',
+      'files',
+      'trash',
+      'paste',
+      'comments',
+    ];
     for (let i = 0; i < forbidden.length; i++) {
       const pn = forbidden[i];
       expect(isCreatablePage(`/${pn}`)).toBeFalsy();
@@ -114,32 +144,48 @@ describe.concurrent('isCreatablePage test', () => {
       const paths = ['/A', '/B/A', '/C/B/A', '/D'];
       const expectedPaths = paths;
 
-      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
-    });
-
-    test.concurrent('Should omit when some paths are at duplicated area', () => {
-      const paths = ['/A', '/A/A', '/A/B/A', '/B', '/B/A', '/AA'];
-      const expectedPaths = ['/A', '/B', '/AA'];
-
-      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
+      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+        expectedPaths,
+      );
     });
 
-    test.concurrent('Should omit when some long paths are at duplicated area', () => {
-      const paths = ['/A/B/C', '/A/B/C/D', '/A/B/C/D/E'];
-      const expectedPaths = ['/A/B/C'];
-
-      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
-    });
-
-    test.concurrent('Should omit when some long paths are at duplicated area [case insensitivity]', () => {
-      const paths = ['/a/B/C', '/A/b/C/D', '/A/B/c/D/E'];
-      const expectedPaths = ['/a/B/C'];
-
-      expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(expectedPaths);
-    });
+    test.concurrent(
+      'Should omit when some paths are at duplicated area',
+      () => {
+        const paths = ['/A', '/A/A', '/A/B/A', '/B', '/B/A', '/AA'];
+        const expectedPaths = ['/A', '/B', '/AA'];
+
+        expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+          expectedPaths,
+        );
+      },
+    );
+
+    test.concurrent(
+      'Should omit when some long paths are at duplicated area',
+      () => {
+        const paths = ['/A/B/C', '/A/B/C/D', '/A/B/C/D/E'];
+        const expectedPaths = ['/A/B/C'];
+
+        expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+          expectedPaths,
+        );
+      },
+    );
+
+    test.concurrent(
+      'Should omit when some long paths are at duplicated area [case insensitivity]',
+      () => {
+        const paths = ['/a/B/C', '/A/b/C/D', '/A/B/c/D/E'];
+        const expectedPaths = ['/a/B/C'];
+
+        expect(omitDuplicateAreaPathFromPaths(paths)).toStrictEqual(
+          expectedPaths,
+        );
+      },
+    );
   });
 
-
   describe.concurrent('Test getUsernameByPath', () => {
     test.concurrent('found', () => {
       const username = getUsernameByPath('/user/sotarok');
@@ -152,9 +198,10 @@ describe.concurrent('isCreatablePage test', () => {
     });
 
     test.concurrent('not found', () => {
-      const username = getUsernameByPath('/the/page/is/not/related/to/user/page');
+      const username = getUsernameByPath(
+        '/the/page/is/not/related/to/user/page',
+      );
       expect(username).toBeNull();
     });
   });
-
 });

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

@@ -121,14 +121,16 @@ const restrictedPatternsToCreate: Array<RegExp> = [
   /^(\/.+){130,}$/, // avoid deep layer path. see: https://regex101.com/r/L0kzOD/1
 ];
 export const isCreatablePage = (path: string): boolean => {
-  return !restrictedPatternsToCreate.some(pattern => path.match(pattern));
+  return !restrictedPatternsToCreate.some((pattern) => path.match(pattern));
 };
 
 /**
  * return user's homepage path
  * @param user
  */
-export const userHomepagePath = (user: { username: string } | null | undefined): string => {
+export const userHomepagePath = (
+  user: { username: string } | null | undefined,
+): string => {
   if (user?.username == null) {
     return '';
   }
@@ -141,7 +143,11 @@ export const userHomepagePath = (user: { username: string } | null | undefined):
  * @param childPath
  * @param newPath
  */
-export const convertToNewAffiliationPath = (oldPath: string, newPath: string, childPath: string): string => {
+export const convertToNewAffiliationPath = (
+  oldPath: string,
+  newPath: string,
+  childPath: string,
+): string => {
   if (newPath == null) {
     throw new Error('Please input the new page path');
   }
@@ -154,7 +160,7 @@ export const convertToNewAffiliationPath = (oldPath: string, newPath: string, ch
  * @param {string} path
  * @returns {string}
  */
-export const encodeSpaces = (path?:string): string | undefined => {
+export const encodeSpaces = (path?: string): string | undefined => {
   if (path == null) {
     return undefined;
   }
@@ -178,8 +184,7 @@ export const generateEditorPath = (...paths: string[]): string => {
   try {
     const url = new URL(joinedPath, 'https://dummy');
     return `${url.pathname}#edit`;
-  }
-  catch (err) {
+  } catch (err) {
     throw new Error('Invalid path format');
   }
 };
@@ -193,7 +198,9 @@ export const generateEditorPath = (...paths: string[]): string => {
 export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
   const uniquePaths = Array.from(new Set(paths));
   return uniquePaths.filter((path) => {
-    const isDuplicate = uniquePaths.filter(p => (new RegExp(`^${p}\\/.+`, 'i')).test(path)).length > 0;
+    const isDuplicate =
+      uniquePaths.filter((p) => new RegExp(`^${p}\\/.+`, 'i').test(path))
+        .length > 0;
 
     return !isDuplicate;
   });
@@ -205,9 +212,12 @@ export const omitDuplicateAreaPathFromPaths = (paths: string[]): string[] => {
  * @param paths paths to be tested
  * @returns omitted paths
  */
+// biome-ignore lint/suspicious/noExplicitAny: ignore
 export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
   return pages.filter((page) => {
-    const isDuplicate = pages.some(p => (new RegExp(`^${p.path}\\/.+`, 'i')).test(page.path));
+    const isDuplicate = pages.some((p) =>
+      new RegExp(`^${p.path}\\/.+`, 'i').test(page.path),
+    );
 
     return !isDuplicate;
   });
@@ -220,7 +230,10 @@ export const omitDuplicateAreaPageFromPages = (pages: any[]): any[] => {
  * @param pathToBeTested string
  * @returns boolean
  */
-export const isEitherOfPathAreaOverlap = (path1: string, path2: string): boolean => {
+export const isEitherOfPathAreaOverlap = (
+  path1: string,
+  path2: string,
+): boolean => {
   if (path1 === path2) {
     return true;
   }
@@ -245,14 +258,20 @@ export const isEitherOfPathAreaOverlap = (path1: string, path2: string): boolean
  * @param pathToBeTested string
  * @returns boolean
  */
-export const isPathAreaOverlap = (pathToTest: string, pathToBeTested: string): boolean => {
+export const isPathAreaOverlap = (
+  pathToTest: string,
+  pathToBeTested: string,
+): boolean => {
   if (pathToTest === pathToBeTested) {
     return true;
   }
 
   const pathWithSlash = addTrailingSlash(pathToTest);
 
-  const pathAreaToTest = new RegExp(`^${escapeStringRegexp(pathWithSlash)}`, 'i');
+  const pathAreaToTest = new RegExp(
+    `^${escapeStringRegexp(pathWithSlash)}`,
+    'i',
+  );
   if (pathAreaToTest.test(pathToBeTested)) {
     return true;
   }
@@ -299,5 +318,4 @@ export const isGlobPatternPath = (path: string): boolean => {
   return globPattern.test(path);
 };
 
-
 export * from './is-top-page';

+ 1 - 1
packages/core/src/utils/page-utils.ts

@@ -1,4 +1,4 @@
-import { IPage } from '..';
+import type { IPage } from '..';
 
 import { isTopPage } from './page-path-utils/is-top-page';
 

+ 149 - 132
packages/core/src/utils/path-utils.spec.ts

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

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

@@ -122,7 +122,6 @@ export function normalizePath(path: string): string {
   return `/${parts.content}`;
 }
 
-
 export function attachTitleHeader(path: string): string {
   return `# ${path}`;
 }

+ 2 - 6
packages/core/tsconfig.json

@@ -6,11 +6,7 @@
     "paths": {
       "~/*": ["./src/*"]
     },
-    "types": [
-      "vitest/globals"
-    ]
+    "types": ["vitest/globals"]
   },
-  "include": [
-    "src", "test"
-  ]
+  "include": ["src", "test"]
 }

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

@@ -1,4 +1,4 @@
-import path from 'path';
+import path from 'node:path';
 
 import glob from 'glob';
 import { nodeExternals } from 'rollup-plugin-node-externals';

+ 1 - 3
packages/core/vitest.config.ts

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

+ 1 - 0
packages/pdf-converter-client/.eslintignore

@@ -0,0 +1 @@
+*

+ 0 - 11
packages/pdf-converter-client/.eslintrc.cjs

@@ -1,11 +0,0 @@
-/**
- * @type {import('eslint').Linter.Config}
- */
-module.exports = {
-  extends: '../../.eslintrc.js',
-  ignorePatterns: [
-    'src/index.ts',
-    'dist/index.d.ts',
-    'dist/index.js',
-  ],
-};

+ 1 - 1
packages/pdf-converter-client/package.json

@@ -6,7 +6,7 @@
   "license": "MIT",
   "private": true,
   "scripts": {
-    "lint": "pnpm eslint **/*.{js,ts}",
+    "lint": "biome check",
     "gen:client-code": "orval",
     "dev": "pnpm gen:client-code && tsc -p tsconfig.json",
     "build": "pnpm gen:client-code && tsc -p tsconfig.json"

+ 1 - 1
packages/pluginkit/.eslintignore

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

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

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

+ 1 - 1
packages/pluginkit/package.json

@@ -15,7 +15,7 @@
     "clean": "shx rm -rf dist",
     "dev": "vite build --mode dev",
     "watch": "pnpm run dev -w --emptyOutDir=false",
-    "lint:js": "eslint **/*.{js,ts}",
+    "lint:js": "biome check",
     "lint:typecheck": "vue-tsc --noEmit",
     "lint": "npm-run-all -p lint:*",
     "test": "vitest run --coverage"

+ 9 - 7
packages/pluginkit/src/model/growi-plugin-package-data.ts

@@ -1,12 +1,14 @@
 import type { GrowiPluginType } from '@growi/core';
 
 export type GrowiPluginDirective = {
-  [key: string]: any,
-  schemaVersion: number,
-  types: GrowiPluginType[],
-}
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  [key: string]: any;
+  schemaVersion: number;
+  types: GrowiPluginType[];
+};
 
 export type GrowiPluginPackageData = {
-  [key: string]: any,
-  growiPlugin: GrowiPluginDirective,
-}
+  // biome-ignore lint/suspicious/noExplicitAny: ignore
+  [key: string]: any;
+  growiPlugin: GrowiPluginDirective;
+};

+ 9 - 9
packages/pluginkit/src/model/growi-plugin-validation-data.ts

@@ -3,17 +3,17 @@ import type { GrowiPluginType, GrowiThemeMetadata } from '@growi/core';
 import type { GrowiPluginDirective } from './growi-plugin-package-data';
 
 export type GrowiPluginValidationData = {
-  projectDirRoot: string,
-  growiPlugin: GrowiPluginDirective,
-  schemaVersion: number,
-  expectedPluginType?: GrowiPluginType,
-  actualPluginTypes?: GrowiPluginType[],
+  projectDirRoot: string;
+  growiPlugin: GrowiPluginDirective;
+  schemaVersion: number;
+  expectedPluginType?: GrowiPluginType;
+  actualPluginTypes?: GrowiPluginType[];
 };
 
 export type GrowiTemplatePluginValidationData = GrowiPluginValidationData & {
-  supportingLocales: string[],
-}
+  supportingLocales: string[];
+};
 
 export type GrowiThemePluginValidationData = GrowiPluginValidationData & {
-  themes: GrowiThemeMetadata[],
-}
+  themes: GrowiThemeMetadata[];
+};

+ 4 - 4
packages/pluginkit/src/model/growi-plugin-validation-error.ts

@@ -2,14 +2,14 @@ import ExtensibleCustomError from 'extensible-custom-error';
 
 import type { GrowiPluginValidationData } from './growi-plugin-validation-data';
 
-
-export class GrowiPluginValidationError<E extends Partial<GrowiPluginValidationData> = Partial<GrowiPluginValidationData>> extends ExtensibleCustomError {
-
+export class GrowiPluginValidationError<
+  E extends
+    Partial<GrowiPluginValidationData> = Partial<GrowiPluginValidationData>,
+> extends ExtensibleCustomError {
   data?: E;
 
   constructor(message: string, data?: E) {
     super(message);
     this.data = data;
   }
-
 }

+ 6 - 2
packages/pluginkit/src/v4/client/utils/growi-facade/growi-react.spec.ts

@@ -8,14 +8,18 @@ describe('growiReact()', () => {
 
   afterEach(() => {
     process.env.NODE_ENV = originalNodeEnv;
-    delete (global as any).window.growiFacade;
+    // biome-ignore lint/suspicious/noExplicitAny: ignore
+    (global as any).window.growiFacade = undefined;
   });
 
   it('returns window.growiFacade.react in production mode', () => {
     // given
     process.env.NODE_ENV = 'production';
-    const mockProductionReact = { useEffect: () => {} } as unknown as typeof React;
+    const mockProductionReact = {
+      useEffect: () => {},
+    } as unknown as typeof React;
 
+    // biome-ignore lint/suspicious/noExplicitAny: ignore
     (global as any).window = {
       growiFacade: {
         react: mockProductionReact,

+ 1 - 2
packages/pluginkit/src/v4/client/utils/growi-facade/growi-react.ts

@@ -2,10 +2,9 @@ import type React from 'react';
 
 import type { GrowiFacade } from '@growi/core';
 
-
 declare global {
   interface Window {
-    growiFacade: GrowiFacade
+    growiFacade: GrowiFacade;
   }
 }
 

+ 18 - 16
packages/pluginkit/src/v4/interfaces/template.ts

@@ -1,25 +1,27 @@
 export type TemplateStatusBasis = {
-  id: string,
-  locale: string,
-  pluginId?: string,
-}
+  id: string;
+  locale: string;
+  pluginId?: string;
+};
 export type TemplateStatusValid = TemplateStatusBasis & {
-  isValid: true,
-  isDefault: boolean,
-  title: string,
-  desc?: string,
-}
+  isValid: true;
+  isDefault: boolean;
+  title: string;
+  desc?: string;
+};
 export type TemplateStatusInvalid = TemplateStatusBasis & {
-  isValid: false,
-  invalidReason: string,
-}
+  isValid: false;
+  invalidReason: string;
+};
 export type TemplateStatus = TemplateStatusValid | TemplateStatusInvalid;
 
-export function isTemplateStatusValid(status: TemplateStatus): status is TemplateStatusValid {
+export function isTemplateStatusValid(
+  status: TemplateStatus,
+): status is TemplateStatusValid {
   return status.isValid;
 }
 
 export type TemplateSummary = {
-  default: TemplateStatusValid,
-  [locale: string]: TemplateStatus,
-}
+  default: TemplateStatusValid;
+  [locale: string]: TemplateStatus;
+};

+ 8 - 3
packages/pluginkit/src/v4/server/utils/common/import-package-json.spec.ts

@@ -1,10 +1,15 @@
-import path from 'path';
+import path from 'node:path';
 
 import { importPackageJson } from './import-package-json';
 
-it('importPackageJson() returns an object', async() => {
+it('importPackageJson() returns an object', async () => {
   // when
-  const pkg = importPackageJson(path.resolve(__dirname, '../../../../../test/fixtures/example-package/template1'));
+  const pkg = importPackageJson(
+    path.resolve(
+      __dirname,
+      '../../../../../test/fixtures/example-package/template1',
+    ),
+  );
 
   // then
   expect(pkg).not.toBeNull();

+ 5 - 3
packages/pluginkit/src/v4/server/utils/common/import-package-json.ts

@@ -1,9 +1,11 @@
-import { readFileSync } from 'fs';
-import path from 'path';
+import { readFileSync } from 'node:fs';
+import path from 'node:path';
 
 import type { GrowiPluginPackageData } from '../../../../model';
 
-export const importPackageJson = (projectDirRoot: string): GrowiPluginPackageData => {
+export const importPackageJson = (
+  projectDirRoot: string,
+): GrowiPluginPackageData => {
   const packageJsonUrl = path.resolve(projectDirRoot, 'package.json');
   return JSON.parse(readFileSync(packageJsonUrl, 'utf-8'));
 };

+ 36 - 17
packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.spec.ts

@@ -2,7 +2,6 @@ import { GrowiPluginType } from '@growi/core';
 
 import examplePkg from '../../../../../test/fixtures/example-package/template1/package.json';
 
-
 import { validateGrowiDirective } from './validate-growi-plugin-directive';
 
 const mocks = vi.hoisted(() => {
@@ -16,8 +15,7 @@ vi.mock('./import-package-json', () => {
 });
 
 describe('validateGrowiDirective()', () => {
-
-  it('returns a data object', async() => {
+  it('returns a data object', async () => {
     // setup
     mocks.importPackageJsonMock.mockReturnValue(examplePkg);
 
@@ -28,28 +26,34 @@ describe('validateGrowiDirective()', () => {
     expect(data).not.toBeNull();
   });
 
-  it("with the 'expectedPluginType' argument returns a data object", async() => {
+  it("with the 'expectedPluginType' argument returns a data object", async () => {
     // setup
     mocks.importPackageJsonMock.mockReturnValue(examplePkg);
 
     // when
-    const data = validateGrowiDirective('package.json', GrowiPluginType.Template);
+    const data = validateGrowiDirective(
+      'package.json',
+      GrowiPluginType.Template,
+    );
 
     // then
     expect(data).not.toBeNull();
   });
 
   describe('should throw an GrowiPluginValidationError', () => {
-
     it("when the pkg does not have 'growiPlugin' directive", () => {
       // setup
       mocks.importPackageJsonMock.mockReturnValue({});
 
       // when
-      const caller = () => { validateGrowiDirective('package.json') };
+      const caller = () => {
+        validateGrowiDirective('package.json');
+      };
 
       // then
-      expect(caller).toThrow("The package.json does not have 'growiPlugin' directive.");
+      expect(caller).toThrow(
+        "The package.json does not have 'growiPlugin' directive.",
+      );
     });
 
     it("when the 'schemaVersion' is NaN", () => {
@@ -61,10 +65,14 @@ describe('validateGrowiDirective()', () => {
       });
 
       // when
-      const caller = () => { validateGrowiDirective('package.json') };
+      const caller = () => {
+        validateGrowiDirective('package.json');
+      };
 
       // then
-      expect(caller).toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
+      expect(caller).toThrow(
+        "The growiPlugin directive must have a valid 'schemaVersion' directive.",
+      );
     });
 
     it("when the 'schemaVersion' is less than 4", () => {
@@ -76,10 +84,14 @@ describe('validateGrowiDirective()', () => {
       });
 
       // when
-      const caller = () => { validateGrowiDirective('package.json') };
+      const caller = () => {
+        validateGrowiDirective('package.json');
+      };
 
       // then
-      expect(caller).toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
+      expect(caller).toThrow(
+        "The growiPlugin directive must have a valid 'schemaVersion' directive.",
+      );
     });
 
     it("when the 'types' directive does not exist", () => {
@@ -91,10 +103,14 @@ describe('validateGrowiDirective()', () => {
       });
 
       // when
-      const caller = () => { validateGrowiDirective('package.json') };
+      const caller = () => {
+        validateGrowiDirective('package.json');
+      };
 
       // then
-      expect(caller).toThrow("The growiPlugin directive does not have 'types' directive.");
+      expect(caller).toThrow(
+        "The growiPlugin directive does not have 'types' directive.",
+      );
     });
 
     it("when the 'types' directive does not have expected plugin type", () => {
@@ -107,11 +123,14 @@ describe('validateGrowiDirective()', () => {
       });
 
       // when
-      const caller = () => { validateGrowiDirective('package.json', GrowiPluginType.Script) };
+      const caller = () => {
+        validateGrowiDirective('package.json', GrowiPluginType.Script);
+      };
 
       // then
-      expect(caller).toThrow("The growiPlugin directive does not have expected plugin type in 'types' directive.");
+      expect(caller).toThrow(
+        "The growiPlugin directive does not have expected plugin type in 'types' directive.",
+      );
     });
   });
-
 });

+ 29 - 8
packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.ts

@@ -1,32 +1,50 @@
 import type { GrowiPluginType } from '@growi/core';
 
-import { type GrowiPluginValidationData, GrowiPluginValidationError } from '../../../../model';
+import {
+  type GrowiPluginValidationData,
+  GrowiPluginValidationError,
+} from '../../../../model';
 
 import { importPackageJson } from './import-package-json';
 
-
-export const validateGrowiDirective = (projectDirRoot: string, expectedPluginType?: GrowiPluginType): GrowiPluginValidationData => {
+export const validateGrowiDirective = (
+  projectDirRoot: string,
+  expectedPluginType?: GrowiPluginType,
+): GrowiPluginValidationData => {
   const pkg = importPackageJson(projectDirRoot);
 
   const { growiPlugin } = pkg;
 
-  const data: GrowiPluginValidationData = { projectDirRoot, schemaVersion: NaN, growiPlugin };
+  const data: GrowiPluginValidationData = {
+    projectDirRoot,
+    schemaVersion: Number.NaN,
+    growiPlugin,
+  };
 
   if (growiPlugin == null) {
-    throw new GrowiPluginValidationError("The package.json does not have 'growiPlugin' directive.", data);
+    throw new GrowiPluginValidationError(
+      "The package.json does not have 'growiPlugin' directive.",
+      data,
+    );
   }
 
   // schema version checking
   const schemaVersion = Number(growiPlugin.schemaVersion);
   data.schemaVersion = schemaVersion;
   if (Number.isNaN(schemaVersion) || schemaVersion < 4) {
-    throw new GrowiPluginValidationError("The growiPlugin directive must have a valid 'schemaVersion' directive.", data);
+    throw new GrowiPluginValidationError(
+      "The growiPlugin directive must have a valid 'schemaVersion' directive.",
+      data,
+    );
   }
 
   const types: GrowiPluginType[] = growiPlugin.types;
   data.actualPluginTypes = types;
   if (types == null) {
-    throw new GrowiPluginValidationError("The growiPlugin directive does not have 'types' directive.", data);
+    throw new GrowiPluginValidationError(
+      "The growiPlugin directive does not have 'types' directive.",
+      data,
+    );
   }
 
   // type checking
@@ -34,7 +52,10 @@ export const validateGrowiDirective = (projectDirRoot: string, expectedPluginTyp
     data.expectedPluginType = expectedPluginType;
 
     if (!types.includes(expectedPluginType)) {
-      throw new GrowiPluginValidationError("The growiPlugin directive does not have expected plugin type in 'types' directive.", data);
+      throw new GrowiPluginValidationError(
+        "The growiPlugin directive does not have expected plugin type in 'types' directive.",
+        data,
+      );
     }
   }
 

+ 7 - 4
packages/pluginkit/src/v4/server/utils/template/get-markdown.ts

@@ -1,10 +1,13 @@
-import fs from 'fs';
-import path from 'path';
+import fs from 'node:fs';
+import path from 'node:path';
 
 import { getStatus } from './get-status';
 
-
-export const getMarkdown = async(projectDirRoot: string, templateId: string, locale: string): Promise<string> => {
+export const getMarkdown = async (
+  projectDirRoot: string,
+  templateId: string,
+  locale: string,
+): Promise<string> => {
   const tplDir = path.resolve(projectDirRoot, 'dist', templateId, locale);
 
   const { isTemplateExists } = await getStatus(tplDir);

+ 9 - 9
packages/pluginkit/src/v4/server/utils/template/get-status.ts

@@ -1,15 +1,13 @@
-import fs, { readFileSync } from 'fs';
-import path from 'path';
-import { promisify } from 'util';
-
+import fs, { readFileSync } from 'node:fs';
+import path from 'node:path';
+import { promisify } from 'node:util';
 
 const statAsync = promisify(fs.stat);
 
-
 type TemplateDirStatus = {
-  isTemplateExists: boolean,
-  meta?: { [key: string]: string },
-}
+  isTemplateExists: boolean;
+  meta?: { [key: string]: string };
+};
 
 export async function getStatus(tplDir: string): Promise<TemplateDirStatus> {
   const markdownPath = path.resolve(tplDir, 'template.md');
@@ -22,7 +20,9 @@ export async function getStatus(tplDir: string): Promise<TemplateDirStatus> {
 
   const result: TemplateDirStatus = {
     isTemplateExists,
-    meta: isMetaDataFileExists ? JSON.parse(readFileSync(metaDataPath, 'utf-8')) : undefined,
+    meta: isMetaDataFileExists
+      ? JSON.parse(readFileSync(metaDataPath, 'utf-8'))
+      : undefined,
   };
 
   return result;

+ 47 - 33
packages/pluginkit/src/v4/server/utils/template/scan.ts

@@ -1,20 +1,23 @@
-import fs from 'fs';
-import path from 'path';
+import fs from 'node:fs';
+import path from 'node:path';
 
 import type { GrowiTemplatePluginValidationData } from '../../../../model';
-import { isTemplateStatusValid, type TemplateStatus, type TemplateSummary } from '../../../interfaces';
+import {
+  type TemplateStatus,
+  type TemplateSummary,
+  isTemplateStatusValid,
+} from '../../../interfaces';
 
 import { getStatus } from './get-status';
 import { validateTemplatePluginGrowiDirective } from './validate-growi-plugin-directive';
 
-
-export const scanTemplate = async(
-    projectDirRoot: string,
-    templateId: string,
-    data: GrowiTemplatePluginValidationData,
-    opts?: {
-      pluginId?: string,
-    },
+export const scanTemplate = async (
+  projectDirRoot: string,
+  templateId: string,
+  data: GrowiTemplatePluginValidationData,
+  opts?: {
+    pluginId?: string;
+  },
 ): Promise<TemplateStatus[]> => {
   const status: TemplateStatus[] = [];
 
@@ -26,13 +29,12 @@ export const scanTemplate = async(
 
     try {
       const stats = await getStatus(tplDir);
-      const {
-        isTemplateExists, meta,
-      } = stats;
+      const { isTemplateExists, meta } = stats;
 
       if (!isTemplateExists) throw new Error("'template.md does not exist.");
       if (meta == null) throw new Error("'meta.md does not exist.");
-      if (meta?.title == null) throw new Error("'meta.md does not contain the title.");
+      if (meta?.title == null)
+        throw new Error("'meta.md does not contain the title.");
 
       const isDefault = !isDefaultPushed;
       status.push({
@@ -45,8 +47,7 @@ export const scanTemplate = async(
         desc: meta.desc,
       });
       isDefaultPushed = true;
-    }
-    catch (err) {
+    } catch (err) {
       status.push({
         pluginId: opts?.pluginId,
         id: templateId,
@@ -58,21 +59,23 @@ export const scanTemplate = async(
   }
 
   // eslint-disable-next-line no-console
-  console.debug(`Template directory (${projectDirRoot}) has scanned`, { status });
+  console.debug(`Template directory (${projectDirRoot}) has scanned`, {
+    status,
+  });
 
   return status;
 };
 
-export const scanAllTemplates = async(
-    projectDirRoot: string,
-    opts?: {
-      data?: GrowiTemplatePluginValidationData,
-      pluginId?: string,
-      returnsInvalidTemplates?: boolean,
-    },
+export const scanAllTemplates = async (
+  projectDirRoot: string,
+  opts?: {
+    data?: GrowiTemplatePluginValidationData;
+    pluginId?: string;
+    returnsInvalidTemplates?: boolean;
+  },
 ): Promise<TemplateSummary[]> => {
-
-  const data = opts?.data ?? validateTemplatePluginGrowiDirective(projectDirRoot);
+  const data =
+    opts?.data ?? validateTemplatePluginGrowiDirective(projectDirRoot);
 
   const summaries: TemplateSummary[] = [];
 
@@ -80,14 +83,23 @@ export const scanAllTemplates = async(
   const distDirFiles = fs.readdirSync(distDirPath);
 
   for await (const templateId of distDirFiles) {
-    const status = (await scanTemplate(projectDirRoot, templateId, data, { pluginId: opts?.pluginId }))
+    const status = (
+      await scanTemplate(projectDirRoot, templateId, data, {
+        pluginId: opts?.pluginId,
+      })
+    )
       // omit invalid templates if `returnsInvalidTemplates` is true
-      .filter(s => (opts?.returnsInvalidTemplates ? true : s.isValid));
+      .filter((s) => (opts?.returnsInvalidTemplates ? true : s.isValid));
 
     // determine default locale
-    const defaultTemplateStatus = status.find(s => 'isDefault' in s && s.isDefault);
-
-    if (defaultTemplateStatus == null || !isTemplateStatusValid(defaultTemplateStatus)) {
+    const defaultTemplateStatus = status.find(
+      (s) => 'isDefault' in s && s.isDefault,
+    );
+
+    if (
+      defaultTemplateStatus == null ||
+      !isTemplateStatusValid(defaultTemplateStatus)
+    ) {
       continue;
     }
 
@@ -95,7 +107,9 @@ export const scanAllTemplates = async(
       // for the 'default' key
       default: defaultTemplateStatus,
       // for each locale keys
-      ...Object.fromEntries(status.map(templateStatus => [templateStatus.locale, templateStatus])),
+      ...Object.fromEntries(
+        status.map((templateStatus) => [templateStatus.locale, templateStatus]),
+      ),
     });
   }
 

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