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

Merge pull request #7701 from weseek/master

Release v6.1.3
Yuki Takei 2 лет назад
Родитель
Сommit
31d3407f2d
100 измененных файлов с 1263 добавлено и 957 удалено
  1. 0 1
      .devcontainer/devcontainer.json
  2. 0 16
      .eslintrc.js
  3. 1 2
      .github/ISSUE_TEMPLATE/bug-report.md
  4. 1 1
      .github/release-drafter.yml
  5. 22 11
      .vscode/launch.json
  6. 4 3
      CHANGELOG.md
  7. 9 0
      apps/app/.env.test
  8. 0 11
      apps/app/.eslintrc.js
  9. 1 4
      apps/app/config/migrate-mongo-config.js
  10. 65 0
      apps/app/config/migrate-mongo-config.spec.ts
  11. 0 20
      apps/app/jest.config.js
  12. 24 12
      apps/app/package.json
  13. 1 0
      apps/app/public/static/locales/en_US/commons.json
  14. 1 0
      apps/app/public/static/locales/en_US/translation.json
  15. 1 0
      apps/app/public/static/locales/ja_JP/commons.json
  16. 1 0
      apps/app/public/static/locales/ja_JP/translation.json
  17. 1 0
      apps/app/public/static/locales/zh_CN/commons.json
  18. 1 0
      apps/app/public/static/locales/zh_CN/translation.json
  19. 57 57
      apps/app/src/client/services/renderer/renderer.tsx
  20. 8 5
      apps/app/src/components/BookmarkButtons.tsx
  21. 4 4
      apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx
  22. 44 51
      apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx
  23. 11 5
      apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx
  24. 19 13
      apps/app/src/components/Bookmarks/BookmarkItem.tsx
  25. 4 3
      apps/app/src/components/InstallerForm.tsx
  26. 0 6
      apps/app/src/components/Layout/NoLoginLayout.module.scss
  27. 6 0
      apps/app/src/components/LoginForm.module.scss
  28. 1 1
      apps/app/src/components/LoginForm.tsx
  29. 10 4
      apps/app/src/components/PageEditor.tsx
  30. 2 2
      apps/app/src/components/PageEditor/CodeMirrorEditor.jsx
  31. 0 75
      apps/app/src/components/Sidebar/InfiniteScroll.tsx
  32. 39 21
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  33. 1 0
      apps/app/src/components/TemplateModal/index.tsx
  34. 101 0
      apps/app/src/components/TemplateModal/use-formatter.spec.tsx
  35. 48 0
      apps/app/src/components/TemplateModal/use-formatter.tsx
  36. 0 2
      apps/app/src/features/activate-plugin/index.ts
  37. 0 1
      apps/app/src/features/activate-plugin/utils/index.ts
  38. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss
  39. 0 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx
  40. 2 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  41. 1 1
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx
  42. 1 0
      apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts
  43. 1 1
      apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx
  44. 0 0
      apps/app/src/features/growi-plugin/components/index.ts
  45. 6 6
      apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts
  46. 1 0
      apps/app/src/features/growi-plugin/interfaces/index.ts
  47. 16 15
      apps/app/src/features/growi-plugin/models/growi-plugin.ts
  48. 1 0
      apps/app/src/features/growi-plugin/models/index.ts
  49. 12 33
      apps/app/src/features/growi-plugin/routes/growi-plugins.ts
  50. 22 26
      apps/app/src/features/growi-plugin/services/growi-plugin.ts
  51. 1 0
      apps/app/src/features/growi-plugin/services/index.ts
  52. 3 2
      apps/app/src/features/growi-plugin/stores/growi-plugin.tsx
  53. 0 0
      apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts
  54. 0 0
      apps/app/src/features/mermaid/components/MermaidViewer.tsx
  55. 0 0
      apps/app/src/features/mermaid/components/index.ts
  56. 0 0
      apps/app/src/features/mermaid/index.ts
  57. 0 0
      apps/app/src/features/mermaid/services/index.ts
  58. 0 0
      apps/app/src/features/mermaid/services/mermaid.ts
  59. 3 2
      apps/app/src/interfaces/bookmark-info.ts
  60. 1 1
      apps/app/src/pages/[[...path]].page.tsx
  61. 3 3
      apps/app/src/pages/_document.page.tsx
  62. 1 1
      apps/app/src/pages/admin/plugins.page.tsx
  63. 1 3
      apps/app/src/server/console.js
  64. 3 1
      apps/app/src/server/crowi/express-init.js
  65. 10 28
      apps/app/src/server/crowi/index.js
  66. 3 3
      apps/app/src/server/middlewares/exclude-read-only-user.spec.ts
  67. 18 30
      apps/app/src/server/middlewares/safe-redirect.spec.ts
  68. 6 4
      apps/app/src/server/middlewares/safe-redirect.ts
  69. 0 2
      apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts
  70. 1 0
      apps/app/src/server/routes/apiv3/bookmarks.js
  71. 6 8
      apps/app/src/server/routes/apiv3/customize-setting.js
  72. 1 1
      apps/app/src/server/routes/apiv3/index.js
  73. 51 38
      apps/app/src/server/service/acl.integ.ts
  74. 19 27
      apps/app/src/server/service/acl.ts
  75. 23 20
      apps/app/src/server/service/config-manager.spec.ts
  76. 75 85
      apps/app/src/server/service/config-manager.ts
  77. 4 7
      apps/app/src/server/service/customize.ts
  78. 4 3
      apps/app/src/server/service/file-uploader-switch.ts
  79. 47 12
      apps/app/src/server/service/file-uploader/aws.ts
  80. 151 0
      apps/app/src/server/service/file-uploader/file-uploader.ts
  81. 3 2
      apps/app/src/server/service/file-uploader/gcs.js
  82. 49 12
      apps/app/src/server/service/file-uploader/gridfs.ts
  83. 3 2
      apps/app/src/server/service/file-uploader/local.js
  84. 3 2
      apps/app/src/server/service/file-uploader/none.js
  85. 0 123
      apps/app/src/server/service/file-uploader/uploader.js
  86. 1 1
      apps/app/src/server/service/g2g-transfer.ts
  87. 3 3
      apps/app/src/server/service/installer.ts
  88. 8 7
      apps/app/src/server/service/slack-integration.ts
  89. 1 5
      apps/app/src/server/util/mongoose-utils.ts
  90. 0 64
      apps/app/src/server/util/slack-legacy.js
  91. 67 0
      apps/app/src/server/util/slack-legacy.ts
  92. 0 2
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  93. 18 3
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  94. 4 3
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  95. 0 2
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts
  96. 1 0
      apps/app/src/stores/bookmark.ts
  97. 9 5
      apps/app/src/stores/modal.tsx
  98. 1 1
      apps/app/src/stores/renderer.tsx
  99. 103 23
      apps/app/src/stores/template.tsx
  100. 3 2
      apps/app/src/utils/page-delete-config.test.ts

+ 0 - 1
.devcontainer/devcontainer.json

@@ -19,7 +19,6 @@
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
-    "firsttris.vscode-jest-runner",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",

+ 0 - 16
.eslintrc.js

@@ -3,15 +3,8 @@ module.exports = {
   extends: [
     'weseek',
     'weseek/typescript',
-    'plugin:jest/recommended',
   ],
-  env: {
-    'jest/globals': true,
-  },
-  globals: {
-  },
   plugins: [
-    'jest',
     'regex',
   ],
   rules: {
@@ -20,11 +13,6 @@ module.exports = {
       'warn',
       {
         pathGroups: [
-          {
-            pattern: 'vitest',
-            group: 'builtin',
-            position: 'before',
-          },
           {
             pattern: 'react',
             group: 'builtin',
@@ -72,10 +60,6 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
       },
     ],
-    'jest/no-standalone-expect': [
-      'error',
-      { additionalTestBlockFunctions: ['each.test'] },
-    ],
     'regex/invalid': ['error', [
       {
         regex: '\\?\\<\\!',

+ 1 - 2
.github/ISSUE_TEMPLATE/bug-report.md

@@ -1,8 +1,7 @@
 ---
 name: Bug report
 about: Create a report to help us improve
-title: 'Bug:' 
-labels: bug
+labels: ['phase/new']
 ---
 
 Environment

+ 1 - 1
.github/release-drafter.yml

@@ -18,7 +18,7 @@ categories:
 category-template: '### $TITLE'
 change-title-escapes: '\<*_&' # You can add # and @ to disable mentions, and add ` to disable code blocks.
 autolabeler:
-  - label: 'feature'
+  - label: 'type/feature'
     branch:
       - '/^feat\/.+/'
   - label: 'type/improvement'

+ 22 - 11
.vscode/launch.json

@@ -2,17 +2,7 @@
     "version": "0.2.0",
     "configurations": [
       {
-        "type": "pwa-node",
-        "request": "attach",
-        "name": "Debug: Attach Debugger to Server",
-        "port": 9229,
-        "cwd": "${workspaceFolder}/apps/app",
-        "sourceMapPathOverrides": {
-          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
-        }
-      },
-      {
-        "type": "pwa-node",
+        "type": "node",
         "request": "launch",
         "name": "Debug: Current File",
         "skipFiles": [
@@ -26,6 +16,27 @@
           "${file}"
         ]
       },
+      {
+        "type": "node",
+        "request": "launch",
+        "name": "Debug: Current File with Vitest",
+        "autoAttachChildProcesses": true,
+        "skipFiles": ["<node_internals>/**", "**/node_modules/**"],
+        "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs",
+        "args": ["run", "${relativeFile}"],
+        "smartStep": true,
+        "console": "integratedTerminal"
+      },
+      {
+        "type": "pwa-node",
+        "request": "attach",
+        "name": "Debug: Attach Debugger to Server",
+        "port": 9229,
+        "cwd": "${workspaceFolder}/apps/app",
+        "sourceMapPathOverrides": {
+          "webpack://@growi/app/*": "${workspaceFolder}/apps/app/*"
+        }
+      },
       {
         "type": "pwa-node",
         "request": "launch",

+ 4 - 3
CHANGELOG.md

@@ -12,12 +12,13 @@
 - imprv: Refactoring migration script (#7694) @miya
 - imprv: Implement infinite scroll into PageTimeline (#7679) @reiji-h
 
-### 🧰 Maintenance
-
 ### 🐛 Bug Fixes
 
-- support: Restrict the 'populate' method in model modules (#7689) @yuki-takei
 - fix: Hash and search query in the relative link are omitted wrongly (#7697) @yuki-takei
+
+### 🧰 Maintenance
+
+- support: Restrict the 'populate' method in model modules (#7689) @yuki-takei
 - support: Refactor LinkEditModal (#7654) @yukendev
 - ci(deps): bump aws-actions/configure-aws-credentials from 1 to 2 (#7620) @dependabot
 - ci(deps): bump hugo19941994/delete-draft-releases from 1.0.0 to 1.0.1 (#7448) @dependabot

+ 9 - 0
apps/app/.env.test

@@ -0,0 +1,9 @@
+##
+## Handled by vite
+## https://vitejs.dev/guide/env-and-mode.html
+##
+## > To prevent accidentally leaking env variables to the client, only variables prefixed with
+## > VITE_ are exposed to your Vite-processed code. e.g. for the following env variables:
+##
+VITE_MONGOMS_VERSION="6.0.6"
+# VITE_MONGOMS_DEBUG=1

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

@@ -5,16 +5,6 @@ module.exports = {
   plugins: [
     'regex',
   ],
-  env: {
-    jquery: true,
-  },
-  globals: {
-    $: true,
-    jquery: true,
-    hljs: true,
-    ScrollPosStyler: true,
-    window: true,
-  },
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript
     'import/resolver': {
@@ -40,7 +30,6 @@ module.exports = {
     // set 'warn' temporarily -- 2021.08.02 Yuki Takei
     '@typescript-eslint/no-use-before-define': ['warn'],
     '@typescript-eslint/no-this-alias': ['warn'],
-    'jest/no-done-callback': ['warn'],
   },
   overrides: [
     {

+ 1 - 4
apps/app/config/migrate-mongo-config.js

@@ -8,7 +8,7 @@ const isProduction = process.env.NODE_ENV === 'production';
 
 const { URL } = require('url');
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = isProduction
+const { getMongoUri, mongoOptions } = isProduction
   // eslint-disable-next-line import/extensions, import/no-unresolved
   ? require('../dist/server/util/mongoose-utils')
   : require('../src/server/util/mongoose-utils');
@@ -19,9 +19,6 @@ if (migrationsDir == null) {
   throw new Error('An env var MIGRATIONS_DIR must be set.');
 }
 
-
-initMongooseGlobalSettings();
-
 const mongoUri = getMongoUri();
 
 // parse url

+ 65 - 0
apps/app/config/migrate-mongo-config.spec.ts

@@ -0,0 +1,65 @@
+import mockRequire from 'mock-require';
+
+const { reRequire } = mockRequire;
+
+
+describe('config/migrate-mongo-config.js', () => {
+
+  test.concurrent('throws an error when MIGRATIONS_DIR is not set', () => {
+
+    const getMongoUriMock = vi.fn();
+    const mongoOptionsMock = vi.fn();
+
+    // mock for mongoose-utils
+    mockRequire('../src/server/util/mongoose-utils', {
+      getMongoUri: getMongoUriMock,
+      mongoOptions: mongoOptionsMock,
+    });
+
+    // use reRequire to avoid using module cache
+    const caller = () => reRequire('./migrate-mongo-config');
+
+    expect(caller).toThrow('An env var MIGRATIONS_DIR must be set.');
+
+    mockRequire.stop('../src/server/util/mongoose-utils');
+
+    expect(getMongoUriMock).not.toHaveBeenCalled();
+  });
+
+  describe.concurrent.each`
+    MONGO_URI                                         | expectedDbName
+    ${'mongodb://example.com/growi'}                  | ${'growi'}
+    ${'mongodb://user:pass@example.com/growi'}        | ${'growi'}
+    ${'mongodb://example.com/growi?replicaSet=mySet'} | ${'growi'}
+  `('returns', ({ MONGO_URI, expectedDbName }) => {
+
+    beforeEach(async() => {
+      process.env.MIGRATIONS_DIR = 'testdir/migrations';
+    });
+
+    test(`when 'MONGO_URI' is '${MONGO_URI}`, () => {
+
+      const getMongoUriMock = vi.fn(() => MONGO_URI);
+      const mongoOptionsMock = vi.fn();
+
+      // mock for mongoose-utils
+      mockRequire('../src/server/util/mongoose-utils', {
+        getMongoUri: getMongoUriMock,
+        mongoOptions: mongoOptionsMock,
+      });
+
+      // use reRequire to avoid using module cache
+      const { mongodb, migrationsDir, changelogCollectionName } = reRequire('./migrate-mongo-config');
+
+      mockRequire.stop('../src/server/util/mongoose-utils');
+
+      // expect(getMongoUriMock).toHaveBeenCalledOnce();
+      expect(mongodb.url).toBe(MONGO_URI);
+      expect(mongodb.databaseName).toBe(expectedDbName);
+      expect(mongodb.options).toBe(mongoOptionsMock);
+      expect(migrationsDir).toBe('testdir/migrations');
+      expect(changelogCollectionName).toBe('migrations');
+    });
+  });
+
+});

+ 0 - 20
apps/app/jest.config.js

@@ -13,26 +13,6 @@ module.exports = {
   moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'],
 
   projects: [
-    {
-      displayName: 'unit',
-
-      transform: {
-        '^.+\\.(t|j)sx?$': '@swc/jest',
-      },
-      // transform ESM to CJS (includes all packages in node_modules)
-      transformIgnorePatterns: [],
-
-      rootDir: '.',
-      roots: ['<rootDir>'],
-      testMatch: ['<rootDir>/test/unit/**/*.test.ts', '<rootDir>/test/unit/**/*.test.js'],
-
-      testEnvironment: 'node',
-
-      // Automatically clear mock calls and instances between every test
-      clearMocks: true,
-      moduleNameMapper: MODULE_NAME_MAPPING,
-
-    },
     {
       displayName: 'server',
 

+ 24 - 12
apps/app/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/app",
-  "version": "6.1.2",
+  "version": "6.1.3-RC.0",
   "license": "MIT",
   "scripts": {
     "//// for production": "",
@@ -35,9 +35,11 @@
     "prelint:swagger2openapi": "yarn openapi:v3",
     "test": "run-p test:*",
     "test:jest": "cross-env NODE_ENV=test NODE_OPTIONS=\"--max-old-space-size=4096\" jest --logHeapUsage",
-    "test:vitest": "cross-env NODE_ENV=test vitest run src",
+    "test:vitest": "run-p vitest:run vitest:run:integ",
     "jest:run": "cross-env NODE_ENV=test jest --passWithNoTests -- ",
     "reg:run": "reg-suit run",
+    "vitest:run": "vitest run config src --coverage",
+    "vitest:run:integ": "vitest run -c vitest.config.integ.ts src --coverage",
     "//// misc": "",
     "console": "yarn cross-env NODE_ENV=development yarn ts-node --experimental-repl-await src/server/console.js",
     "swagger-jsdoc": "swagger-jsdoc -o tmp/swagger.json -d config/swagger-definition.js",
@@ -61,18 +63,19 @@
     "@elastic/elasticsearch8": "npm:@elastic/elasticsearch@^8.7.0",
     "@godaddy/terminus": "^4.9.0",
     "@google-cloud/storage": "^5.8.5",
-    "@growi/core": "^6.1.2",
-    "@growi/hackmd": "^6.1.2",
-    "@growi/preset-themes": "^6.1.2",
-    "@growi/remark-attachment-refs": "^6.1.2",
-    "@growi/remark-drawio": "^6.1.2",
-    "@growi/remark-growi-directive": "^6.1.2",
-    "@growi/remark-lsx": "^6.1.2",
-    "@growi/slack": "^6.1.2",
+    "@growi/core": "^6.1.3-RC.0",
+    "@growi/hackmd": "^6.1.3-RC.0",
+    "@growi/preset-themes": "^6.1.3-RC.0",
+    "@growi/remark-attachment-refs": "^6.1.3-RC.0",
+    "@growi/remark-drawio": "^6.1.3-RC.0",
+    "@growi/remark-growi-directive": "^6.1.3-RC.0",
+    "@growi/remark-lsx": "^6.1.3-RC.0",
+    "@growi/slack": "^6.1.3-RC.0",
     "@promster/express": "^7.0.6",
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@types/jest": "^29.5.2",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -131,6 +134,7 @@
     "mongoose-unique-validator": "^2.0.3",
     "multer": "~1.4.0",
     "multer-autoreap": "^1.0.3",
+    "mustache": "^4.2.0",
     "next": "^13.3.0",
     "next-i18next": "^13.2.1",
     "next-superjson": "^0.0.4",
@@ -204,12 +208,15 @@
     "handsontable": "v7.0.0 or above is no loger MIT lisence."
   },
   "devDependencies": {
-    "@growi/presentation": "^6.1.2",
-    "@growi/ui": "^6.1.2",
+    "@growi/presentation": "^6.1.3-RC.0",
+    "@growi/ui": "^6.1.3-RC.0",
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
+    "@swc-node/jest": "^1.6.2",
+    "@swc/jest": "^0.2.24",
     "@types/express": "^4.17.11",
+    "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
@@ -221,14 +228,19 @@
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.11.0",
+    "jest": "^29.5.0",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
     "jquery-slimscroll": "^1.3.8",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",
     "material-icons": "^1.11.3",
+    "mongodb-memory-server": "^8.12.2",
     "morgan": "^1.10.0",
     "null-loader": "^4.0.1",
     "penpal": "^4.0.0",

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

@@ -2,6 +2,7 @@
   "Show": "Show",
   "Hide": "Hide",
   "Add": "Add",
+  "Insert": "Insert",
   "Reset": "Reset",
   "Sign out": "Logout",
   "New": "New",

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

@@ -454,6 +454,7 @@
   },
   "template": {
     "modal_label": {
+      "Select template": "Select template",
       "Create/Edit Template Page": "Create/Edit template page",
       "Create template under": "Create template page under this page"
     },

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

@@ -2,6 +2,7 @@
   "Show": "公開",
   "Hide": "非公開",
   "Add": "追加",
+  "Insert": "挿入",
   "Reset": "リセット",
   "Sign out": "ログアウト",
   "New": "作成",

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

@@ -487,6 +487,7 @@
   },
   "template": {
     "modal_label": {
+      "Select template": "テンプレートの選択",
       "Create/Edit Template Page": "テンプレートページの作成/編集",
       "Create template under": "配下にテンプレートページを作成"
     },

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

@@ -2,6 +2,7 @@
 	"Show": "显示",
 	"Hide": "隐藏",
   "Add": "添加",
+  "Insert": "插入",
   "Reset": "重启",
 	"Sign out": "退出",
   "New": "新建",

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

@@ -441,6 +441,7 @@
   },
 	"template": {
 		"modal_label": {
+      "Select template": "选择模板",
 			"Create/Edit Template Page": "创建/编辑模板页",
 			"Create template under": "在下面创建模板页"
 		},

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

@@ -1,10 +1,10 @@
 import assert from 'assert';
 
 import { isClient } from '@growi/core/dist/utils/browser-utils';
-import * as refsGrowiPlugin from '@growi/remark-attachment-refs/dist/client/index.mjs';
-import * as drawioPlugin from '@growi/remark-drawio';
+import * as refsGrowiDirective from '@growi/remark-attachment-refs/dist/client/index.mjs';
+import * as drawio from '@growi/remark-drawio';
 // eslint-disable-next-line import/extensions
-import * as lsxGrowiPlugin from '@growi/remark-lsx/dist/client/index.mjs';
+import * as lsxGrowiDirective from '@growi/remark-lsx/dist/client/index.mjs';
 import katex from 'rehype-katex';
 import sanitize from 'rehype-sanitize';
 import slug from 'rehype-slug';
@@ -18,7 +18,7 @@ import type { Pluggable } from 'unified';
 import { DrawioViewerWithEditButton } from '~/components/ReactMarkdownComponents/DrawioViewerWithEditButton';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { TableWithEditButton } from '~/components/ReactMarkdownComponents/TableWithEditButton';
-import * as mermaidPlugin from '~/features/mermaid-plugin';
+import * as mermaid from '~/features/mermaid';
 import { RehypeSanitizeOption } from '~/interfaces/rehype';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 import type { RendererConfig } from '~/interfaces/services/renderer';
@@ -58,11 +58,11 @@ export const generateViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -75,18 +75,18 @@ export const generateViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
     slug,
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     rehypeSanitizePlugin,
     katex,
     [relocateToc.rehypePluginStore, { storeTocNode }],
@@ -100,15 +100,15 @@ export const generateViewOptions = (
     components.h4 = Header;
     components.h5 = Header;
     components.h6 = Header;
-    components.lsx = lsxGrowiPlugin.Lsx;
-    components.ref = refsGrowiPlugin.Ref;
-    components.refs = refsGrowiPlugin.Refs;
-    components.refimg = refsGrowiPlugin.RefImg;
-    components.refsimg = refsGrowiPlugin.RefsImg;
-    components.gallery = refsGrowiPlugin.Gallery;
+    components.lsx = lsxGrowiDirective.Lsx;
+    components.ref = refsGrowiDirective.Ref;
+    components.refs = refsGrowiDirective.Refs;
+    components.refimg = refsGrowiDirective.RefImg;
+    components.refsimg = refsGrowiDirective.RefsImg;
+    components.gallery = refsGrowiDirective.Gallery;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -164,11 +164,11 @@ export const generateSimpleViewOptions = (
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
 
   const isEnabledLinebreaks = overrideIsEnabledLinebreaks ?? config.isEnabledLinebreaks;
@@ -185,17 +185,17 @@ export const generateSimpleViewOptions = (
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      drawioPlugin.sanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     [keywordHighlighter.rehypePlugin, { keywords: highlightKeywords }],
     rehypeSanitizePlugin,
     katex,
@@ -203,14 +203,14 @@ export const generateSimpleViewOptions = (
 
   // add components
   if (components != null) {
-    components.lsx = lsxGrowiPlugin.LsxImmutable;
-    components.ref = refsGrowiPlugin.RefImmutable;
-    components.refs = refsGrowiPlugin.RefsImmutable;
-    components.refimg = refsGrowiPlugin.RefImgImmutable;
-    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
-    components.gallery = refsGrowiPlugin.GalleryImmutable;
-    components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {
@@ -241,11 +241,11 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   remarkPlugins.push(
     math,
     [plantuml.remarkPlugin, { plantumlUri: config.plantumlUri }],
-    drawioPlugin.remarkPlugin,
+    drawio.remarkPlugin,
+    mermaid.remarkPlugin,
     xsvToTable.remarkPlugin,
-    lsxGrowiPlugin.remarkPlugin,
-    refsGrowiPlugin.remarkPlugin,
-    mermaidPlugin.remarkPlugin,
+    lsxGrowiDirective.remarkPlugin,
+    refsGrowiDirective.remarkPlugin,
   );
   if (config.isEnabledLinebreaks) {
     remarkPlugins.push(breaks);
@@ -258,18 +258,18 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
   const rehypeSanitizePlugin: Pluggable<any[]> | (() => void) = config.isEnabledXssPrevention
     ? [sanitize, deepmerge(
       commonSanitizeOption,
-      lsxGrowiPlugin.sanitizeOption,
-      refsGrowiPlugin.sanitizeOption,
-      drawioPlugin.sanitizeOption,
+      drawio.sanitizeOption,
+      mermaid.sanitizeOption,
+      lsxGrowiDirective.sanitizeOption,
+      refsGrowiDirective.sanitizeOption,
       addLineNumberAttribute.sanitizeOption,
-      mermaidPlugin.sanitizeOption,
     )]
     : () => {};
 
   // add rehype plugins
   rehypePlugins.push(
-    [lsxGrowiPlugin.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
-    [refsGrowiPlugin.rehypePlugin, { pagePath }],
+    [lsxGrowiDirective.rehypePlugin, { pagePath, isSharedPage: config.isSharedPage }],
+    [refsGrowiDirective.rehypePlugin, { pagePath }],
     addLineNumberAttribute.rehypePlugin,
     rehypeSanitizePlugin,
     katex,
@@ -277,14 +277,14 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
   // add components
   if (components != null) {
-    components.lsx = lsxGrowiPlugin.LsxImmutable;
-    components.ref = refsGrowiPlugin.RefImmutable;
-    components.refs = refsGrowiPlugin.RefsImmutable;
-    components.refimg = refsGrowiPlugin.RefImgImmutable;
-    components.refsimg = refsGrowiPlugin.RefsImgImmutable;
-    components.gallery = refsGrowiPlugin.GalleryImmutable;
-    components.drawio = drawioPlugin.DrawioViewer;
-    components.mermaid = mermaidPlugin.MermaidViewer;
+    components.lsx = lsxGrowiDirective.LsxImmutable;
+    components.ref = refsGrowiDirective.RefImmutable;
+    components.refs = refsGrowiDirective.RefsImmutable;
+    components.refimg = refsGrowiDirective.RefImgImmutable;
+    components.refsimg = refsGrowiDirective.RefsImgImmutable;
+    components.gallery = refsGrowiDirective.GalleryImmutable;
+    components.drawio = drawio.DrawioViewer;
+    components.mermaid = mermaid.MermaidViewer;
   }
 
   if (config.isEnabledXssPrevention) {

+ 8 - 5
apps/app/src/components/BookmarkButtons.tsx

@@ -45,13 +45,16 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
     return 'tooltip.bookmark';
   }, [isGuestUser]);
 
+  if (bookmarkInfo == null) {
+    return <></>;
+  }
 
   return (
     <div className={`btn-group btn-group-bookmark ${styles['btn-group-bookmark']}`} role="group" aria-label="Bookmark buttons">
-      <BookmarkFolderMenu >
+      <BookmarkFolderMenu bookmarkInfo={bookmarkInfo}>
         <DropdownToggle id='bookmark-dropdown-btn' color="transparent" className={`shadow-none btn btn-bookmark border-0
-          ${bookmarkInfo?.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
-          <i className={`fa ${bookmarkInfo?.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
+          ${bookmarkInfo.isBookmarked ? 'active' : ''} ${isGuestUser ? 'disabled' : ''}`}>
+          <i className={`fa ${bookmarkInfo.isBookmarked ? 'fa-bookmark' : 'fa-bookmark-o'}`}></i>
         </DropdownToggle>
       </BookmarkFolderMenu>
 
@@ -65,9 +68,9 @@ export const BookmarkButtons: FC<Props> = (props: Props) => {
             type="button"
             id="po-total-bookmarks"
             className={`shadow-none btn btn-bookmark border-0
-              total-bookmarks ${bookmarkInfo?.isBookmarked ? 'active' : ''}`}
+              total-bookmarks ${bookmarkInfo.isBookmarked ? 'active' : ''}`}
           >
-            {bookmarkInfo?.sumOfBookmarks ?? 0}
+            {bookmarkInfo.sumOfBookmarks ?? 0}
           </button>
           { bookmarkedUsers != null && (
             <Popover placement="bottom" isOpen={isPopoverOpen} target="po-total-bookmarks" toggle={togglePopover} trigger="legacy">

+ 4 - 4
apps/app/src/components/Bookmarks/BookmarkFolderItem.tsx

@@ -30,7 +30,7 @@ type BookmarkFolderItemProps = {
   level: number
   root: string
   isUserHomePage?: boolean
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -39,7 +39,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
   const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const {
     isReadOnlyUser, bookmarkFolder, isOpen: _isOpen = false, isOperable, level, root, isUserHomePage,
-    onClickDeleteBookmarkHandler, bookmarkFolderTreeMutation,
+    onClickDeleteMenuItemHandler, bookmarkFolderTreeMutation,
   } = props;
 
   const {
@@ -155,7 +155,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
             level={level + 1}
             root={root}
             isUserHomePage={isUserHomePage}
-            onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+            onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
             bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
           />
         </div>
@@ -174,7 +174,7 @@ export const BookmarkFolderItem: FC<BookmarkFolderItemProps> = (props: BookmarkF
           level={level + 1}
           parentFolder={bookmarkFolder}
           canMoveToRoot={true}
-          onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+          onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
           bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
         />
       );

+ 44 - 51
apps/app/src/components/Bookmarks/BookmarkFolderMenu.tsx

@@ -6,14 +6,15 @@ import { DropdownItem, DropdownMenu, UncontrolledDropdown } from 'reactstrap';
 
 import { addBookmarkToFolder, toggleBookmark } from '~/client/util/bookmark-utils';
 import { toastError } from '~/client/util/toastr';
+import { IBookmarkInfo } from '~/interfaces/bookmark-info';
 import { useSWRBookmarkInfo, useSWRxUserBookmarks } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
 import { useCurrentUser } from '~/stores/context';
-import { useSWRxCurrentPage, useSWRxPageInfo } from '~/stores/page';
+import { useSWRxPageInfo } from '~/stores/page';
 
 import { BookmarkFolderMenuItem } from './BookmarkFolderMenuItem';
 
-export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ children }): JSX.Element => {
+export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode, bookmarkInfo: IBookmarkInfo }> = ({ children, bookmarkInfo }): JSX.Element => {
   const { t } = useTranslation();
 
   const [selectedItem, setSelectedItem] = useState<string | null>(null);
@@ -21,13 +22,12 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
 
   const { data: currentUser } = useCurrentUser();
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(currentUser?._id);
-  const { data: currentPage } = useSWRxCurrentPage();
-  const { data: bookmarkInfo, mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
+  const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(bookmarkInfo.pageId);
 
   const { mutate: mutateUserBookmarks } = useSWRxUserBookmarks(currentUser?._id);
-  const { mutate: mutatePageInfo } = useSWRxPageInfo(currentPage?._id);
+  const { mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkInfo.pageId);
 
-  const isBookmarked = bookmarkInfo?.isBookmarked ?? false;
+  const isBookmarked = bookmarkInfo.isBookmarked ?? false;
 
   const isBookmarkFolderExists = useMemo((): boolean => {
     return bookmarkFolders != null && bookmarkFolders.length > 0;
@@ -35,14 +35,12 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
 
   const toggleBookmarkHandler = useCallback(async() => {
     try {
-      if (currentPage != null) {
-        await toggleBookmark(currentPage._id, isBookmarked);
-      }
+      await toggleBookmark(bookmarkInfo.pageId, isBookmarked);
     }
     catch (err) {
       toastError(err);
     }
-  }, [currentPage, isBookmarked]);
+  }, [bookmarkInfo.pageId, isBookmarked]);
 
   const onUnbookmarkHandler = useCallback(async() => {
     await toggleBookmarkHandler();
@@ -60,7 +58,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     if (isOpen && bookmarkFolders != null) {
       bookmarkFolders.forEach((bookmarkFolder) => {
         bookmarkFolder.bookmarks.forEach((bookmark) => {
-          if (bookmark.page._id === currentPage?._id) {
+          if (bookmark.page._id === bookmarkInfo.pageId) {
             setSelectedItem(bookmarkFolder._id);
           }
         });
@@ -83,7 +81,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
       }
     }
   },
-  [isOpen, bookmarkFolders, selectedItem, isBookmarked, currentPage?._id, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
+  [isOpen, bookmarkFolders, selectedItem, isBookmarked, bookmarkInfo.pageId, toggleBookmarkHandler, mutateUserBookmarks, mutateBookmarkInfo, mutatePageInfo]);
 
   const onMenuItemClickHandler = useCallback(async(e, itemId: string) => {
     e.stopPropagation();
@@ -91,9 +89,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     setSelectedItem(itemId);
 
     try {
-      if (currentPage != null) {
-        await addBookmarkToFolder(currentPage._id, itemId === 'root' ? null : itemId);
-      }
+      await addBookmarkToFolder(bookmarkInfo.pageId, itemId === 'root' ? null : itemId);
       mutateUserBookmarks();
       mutateBookmarkFolders();
       mutateBookmarkInfo();
@@ -101,7 +97,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
     catch (err) {
       toastError(err);
     }
-  }, [mutateBookmarkFolders, currentPage, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [bookmarkInfo.pageId, mutateUserBookmarks, mutateBookmarkFolders, mutateBookmarkInfo]);
 
   const renderBookmarkMenuItem = () => {
     return (
@@ -120,7 +116,7 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
         {isBookmarkFolderExists && (
           <>
             <DropdownItem divider />
-            <div key='root'>
+            <div key="root">
               <div
                 className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
                 tabIndex={0}
@@ -128,48 +124,45 @@ export const BookmarkFolderMenu: React.FC<{children?: React.ReactNode}> = ({ chi
                 onClick={e => onMenuItemClickHandler(e, 'root')}
               >
                 <BookmarkFolderMenuItem
-                  itemId='root'
+                  itemId="root"
                   itemName={t('bookmark_folder.root')}
                   isSelected={selectedItem === 'root'}
                 />
               </div>
             </div>
             {bookmarkFolders?.map(folder => (
-              <>
-                <div key={folder._id}>
-                  <div
-                    className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
-                    style={{ paddingLeft: '40px' }}
-                    tabIndex={0}
-                    role="menuitem"
-                    onClick={e => onMenuItemClickHandler(e, folder._id)}
-                  >
-                    <BookmarkFolderMenuItem
-                      itemId={folder._id}
-                      itemName={folder.name}
-                      isSelected={selectedItem === folder._id}
-                    />
-                  </div>
+              <div key={folder._id}>
+                <div
+                  className="dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0"
+                  style={{ paddingLeft: '40px' }}
+                  tabIndex={0}
+                  role="menuitem"
+                  onClick={e => onMenuItemClickHandler(e, folder._id)}
+                >
+                  <BookmarkFolderMenuItem
+                    itemId={folder._id}
+                    itemName={folder.name}
+                    isSelected={selectedItem === folder._id}
+                  />
                 </div>
-                <>
-                  {folder.children?.map(child => (
-                    <div key={child._id}>
-                      <div
-                        className='dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0'
-                        style={{ paddingLeft: '60px' }}
-                        tabIndex={0}
-                        role="menuitem"
-                        onClick={e => onMenuItemClickHandler(e, child._id)}>
-                        <BookmarkFolderMenuItem
-                          itemId={child._id}
-                          itemName={child.name}
-                          isSelected={selectedItem === child._id}
-                        />
-                      </div>
+                {folder.children?.map(child => (
+                  <div key={child._id}>
+                    <div
+                      className='dropdown-item grw-bookmark-folder-menu-item list-group-item list-group-item-action border-0 py-0'
+                      style={{ paddingLeft: '60px' }}
+                      tabIndex={0}
+                      role="menuitem"
+                      onClick={e => onMenuItemClickHandler(e, child._id)}
+                    >
+                      <BookmarkFolderMenuItem
+                        itemId={child._id}
+                        itemName={child.name}
+                        isSelected={selectedItem === child._id}
+                      />
                     </div>
-                  ))}
-                </>
-              </>
+                  </div>
+                ))}
+              </div>
             ))}
           </>
         )}

+ 11 - 5
apps/app/src/components/Bookmarks/BookmarkFolderTree.tsx

@@ -8,7 +8,7 @@ import { IPageToDeleteWithMeta } from '~/interfaces/page';
 import { OnDeletedFunction } from '~/interfaces/ui';
 import { useSWRxUserBookmarks, useSWRBookmarkInfo } from '~/stores/bookmark';
 import { useSWRxBookmarkFolderAndChild } from '~/stores/bookmark-folder';
-import { useIsReadOnlyUser } from '~/stores/context';
+import { useIsReadOnlyUser, useCurrentUser } from '~/stores/context';
 import { usePageDeleteModal } from '~/stores/modal';
 import { useSWRxCurrentPage } from '~/stores/page';
 
@@ -35,20 +35,26 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
   // const acceptedTypes: DragItemType[] = [DRAG_ITEM_TYPE.FOLDER, DRAG_ITEM_TYPE.BOOKMARK];
   const { t } = useTranslation();
 
+  // In order to update the bookmark information in the sidebar when bookmarking or unbookmarking a page on someone else's user homepage
+  const { data: currentUser } = useCurrentUser();
+  const shouldMutateCurrentUserbookmarks = currentUser?._id !== userId;
+
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: currentPage } = useSWRxCurrentPage();
   const { mutate: mutateBookmarkInfo } = useSWRBookmarkInfo(currentPage?._id);
   const { data: bookmarkFolders, mutate: mutateBookmarkFolders } = useSWRxBookmarkFolderAndChild(userId);
   const { data: userBookmarks, mutate: mutateUserBookmarks } = useSWRxUserBookmarks(userId);
+  const { mutate: mutateCurrentUserBookmarks } = useSWRxUserBookmarks(shouldMutateCurrentUserbookmarks ? currentUser?._id : undefined);
   const { open: openDeleteModal } = usePageDeleteModal();
 
   const bookmarkFolderTreeMutation = useCallback(() => {
     mutateUserBookmarks();
+    mutateCurrentUserBookmarks();
     mutateBookmarkInfo();
     mutateBookmarkFolders();
-  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateUserBookmarks]);
+  }, [mutateBookmarkFolders, mutateBookmarkInfo, mutateCurrentUserBookmarks, mutateUserBookmarks]);
 
-  const onClickDeleteBookmarkHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
+  const onClickDeleteMenuItemHandler = useCallback((pageToDelete: IPageToDeleteWithMeta) => {
     const pageDeletedHandler: OnDeletedFunction = (pathOrPathsToDelete, _isRecursively, isCompletely) => {
       if (typeof pathOrPathsToDelete !== 'string') return;
 
@@ -107,7 +113,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               root={bookmarkFolder._id}
               isUserHomePage={isUserHomePage}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           );
@@ -122,7 +128,7 @@ export const BookmarkFolderTree: React.FC<Props> = (props: Props) => {
               level={0}
               parentFolder={null}
               canMoveToRoot={false}
-              onClickDeleteBookmarkHandler={onClickDeleteBookmarkHandler}
+              onClickDeleteMenuItemHandler={onClickDeleteMenuItemHandler}
               bookmarkFolderTreeMutation={bookmarkFolderTreeMutation}
             />
           </div>

+ 19 - 13
apps/app/src/components/Bookmarks/BookmarkItem.tsx

@@ -6,7 +6,7 @@ import { DevidedPagePath, pathUtils } from '@growi/core';
 import { useTranslation } from 'react-i18next';
 import { UncontrolledTooltip, DropdownToggle } from 'reactstrap';
 
-import { unbookmark } from '~/client/services/page-operation';
+import { bookmark, unbookmark } from '~/client/services/page-operation';
 import { addBookmarkToFolder, renamePage } from '~/client/util/bookmark-utils';
 import { ValidationTarget } from '~/client/util/input-validator';
 import { toastError } from '~/client/util/toastr';
@@ -28,7 +28,7 @@ type Props = {
   level: number,
   parentFolder: BookmarkFolderItems | null,
   canMoveToRoot: boolean,
-  onClickDeleteBookmarkHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
+  onClickDeleteMenuItemHandler: (pageToDelete: IPageToDeleteWithMeta) => void,
   bookmarkFolderTreeMutation: () => void
 }
 
@@ -39,14 +39,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
   const { t } = useTranslation();
 
   const {
-    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteBookmarkHandler,
+    isReadOnlyUser, isOperable, bookmarkedPage, onClickDeleteMenuItemHandler,
     parentFolder, level, canMoveToRoot, bookmarkFolderTreeMutation,
   } = props;
 
   const [isRenameInputShown, setRenameInputShown] = useState(false);
 
-  const { data: fetchedPageInfo } = useSWRxPageInfo(bookmarkedPage._id);
-
+  const { data: pageInfo, mutate: mutatePageInfo } = useSWRxPageInfo(bookmarkedPage._id);
   const dPagePath = new DevidedPagePath(bookmarkedPage.path, false, true);
   const { latter: pageTitle, former: formerPagePath } = dPagePath;
   const bookmarkItemId = `bookmark-item-${bookmarkedPage._id}`;
@@ -65,10 +64,16 @@ export const BookmarkItem = (props: Props): JSX.Element => {
     }
   }, [bookmarkFolderTreeMutation, bookmarkedPage._id]);
 
-  const bookmarkMenuItemClickHandler = useCallback(async() => {
-    await unbookmark(bookmarkedPage._id);
+  const bookmarkMenuItemClickHandler = useCallback(async(pageId: string, shouldBookmark: boolean) => {
+    if (shouldBookmark) {
+      await bookmark(pageId);
+    }
+    else {
+      await unbookmark(pageId);
+    }
     bookmarkFolderTreeMutation();
-  }, [bookmarkedPage._id, bookmarkFolderTreeMutation]);
+    mutatePageInfo();
+  }, [bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const renameMenuItemClickHandler = useCallback(() => {
     setRenameInputShown(true);
@@ -86,12 +91,13 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       setRenameInputShown(false);
       await renamePage(bookmarkedPage._id, bookmarkedPage.revision, newPagePath);
       bookmarkFolderTreeMutation();
+      mutatePageInfo();
     }
     catch (err) {
       setRenameInputShown(true);
       toastError(err);
     }
-  }, [bookmarkedPage, bookmarkFolderTreeMutation]);
+  }, [bookmarkedPage.path, bookmarkedPage._id, bookmarkedPage.revision, bookmarkFolderTreeMutation, mutatePageInfo]);
 
   const deleteMenuItemClickHandler = useCallback(async(_pageId: string, pageInfo: IPageInfoAll | undefined): Promise<void> => {
     if (bookmarkedPage._id == null || bookmarkedPage.path == null) {
@@ -107,8 +113,8 @@ export const BookmarkItem = (props: Props): JSX.Element => {
       meta: pageInfo,
     };
 
-    onClickDeleteBookmarkHandler(pageToDelete);
-  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteBookmarkHandler]);
+    onClickDeleteMenuItemHandler(pageToDelete);
+  }, [bookmarkedPage._id, bookmarkedPage.path, bookmarkedPage.revision, onClickDeleteMenuItemHandler]);
 
   return (
     <DragAndDropWrapper
@@ -137,12 +143,12 @@ export const BookmarkItem = (props: Props): JSX.Element => {
             pageId={bookmarkedPage._id}
             isEnableActions
             isReadOnlyUser={isReadOnlyUser}
-            pageInfo={fetchedPageInfo}
+            pageInfo={pageInfo}
             forceHideMenuItems={[MenuItemType.DUPLICATE]}
             onClickBookmarkMenuItem={bookmarkMenuItemClickHandler}
             onClickRenameMenuItem={renameMenuItemClickHandler}
             onClickDeleteMenuItem={deleteMenuItemClickHandler}
-            additionalMenuItemOnTopRenderer={canMoveToRoot
+            additionalMenuItemOnTopRenderer={canMoveToRoot && isOperable
               ? () => <BookmarkMoveToRootBtn pageId={bookmarkedPage._id} onClickMoveToRootHandler={onClickMoveToRootHandler}/>
               : undefined}
           >

+ 4 - 3
apps/app/src/components/InstallerForm.tsx

@@ -214,7 +214,7 @@ const InstallerForm = memo((): JSX.Element => {
             />
           </div>
 
-          <div className="input-group mt-4 mb-3 d-flex justify-content-center">
+          <div className="input-group mt-4 d-flex justify-content-center">
             <button
               data-testid="btnSubmit"
               type="submit"
@@ -228,11 +228,12 @@ const InstallerForm = memo((): JSX.Element => {
             </button>
           </div>
 
-          <div className="input-group mt-4 d-flex justify-content-center">
+          <div>
             <a href="https://growi.org" className="link-growi-org">
-              <span className="growi">GROWI</span>.<span className="org">ORG</span>
+              <span className="growi">GROWI</span>.<span className="org">org</span>
             </a>
           </div>
+
         </form>
       </div>
     </div>

+ 0 - 6
apps/app/src/components/Layout/NoLoginLayout.module.scss

@@ -23,12 +23,6 @@
       }
     }
 
-    .link-growi-org {
-      position: absolute;
-      bottom: 9px;
-      z-index: 3;
-    }
-
   }
 
   // styles

+ 6 - 0
apps/app/src/components/LoginForm.module.scss

@@ -9,4 +9,10 @@
   .collapse-external-auth {
     overflow: hidden;
   }
+
+  .link-growi-org {
+    position: absolute;
+    bottom: 9px;
+    z-index: 3;
+  }
 }

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

@@ -543,7 +543,7 @@ export const LoginForm = (props: LoginFormProps): JSX.Element => {
           </div>
         </div>
         <a href="https://growi.org" className="link-growi-org pl-3">
-          <span className="growi">GROWI</span>.<span className="org">ORG</span>
+          <span className="growi">GROWI</span>.<span className="org">org</span>
         </a>
       </div>
     </div>

+ 10 - 4
apps/app/src/components/PageEditor.tsx

@@ -132,6 +132,8 @@ const PageEditor = React.memo((): JSX.Element => {
   const markdownToSave = useRef<string>(initialValue);
   const [markdownToPreview, setMarkdownToPreview] = useState<string>(initialValue);
 
+  const [isPageCreatedWithAttachmentUpload, setIsPageCreatedWithAttachmentUpload] = useState(false);
+
   const { data: socket } = useGlobalSocket();
 
   const { mutate: mutateIsConflict } = useIsConflict();
@@ -322,10 +324,11 @@ const PageEditor = React.memo((): JSX.Element => {
       editorRef.current.insertText(insertText);
 
       // when if created newly
+      // Not using 'mutateGrant' to inherit the grant of the parent page
       if (res.pageCreated) {
         logger.info('Page is created', res.page._id);
+        setIsPageCreatedWithAttachmentUpload(true);
         globalEmitter.emit('resetInitializedHackMdStatus');
-        mutateGrant(res.page.grant);
         mutateIsLatestRevision(true);
         await mutateCurrentPageId(res.page._id);
         await mutateCurrentPage();
@@ -338,7 +341,7 @@ const PageEditor = React.memo((): JSX.Element => {
     finally {
       editorRef.current.terminateUploadingState();
     }
-  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateGrant, mutateIsLatestRevision, pageId]);
+  }, [currentPagePath, mutateCurrentPage, mutateCurrentPageId, mutateIsLatestRevision, pageId]);
 
 
   const scrollPreviewByEditorLine = useCallback((line: number) => {
@@ -519,11 +522,14 @@ const PageEditor = React.memo((): JSX.Element => {
 
   // when transitioning to a different page, if the initialValue is the same,
   // UnControlled CodeMirror value does not reset, so explicitly set the value to initialValue
+  // Also, if an attachment is uploaded and a new page is created,
+  // "useCurrentPagePath" changes, but no page transition is made, so nothing is done.
   useEffect(() => {
-    if (currentPagePath != null) {
+    if (currentPagePath != null && !isPageCreatedWithAttachmentUpload) {
       editorRef.current?.setValue(initialValue);
     }
-  }, [currentPagePath, initialValue]);
+    setIsPageCreatedWithAttachmentUpload(false);
+  }, [currentPagePath, initialValue, isPageCreatedWithAttachmentUpload]);
 
   if (!isEditable) {
     return <></>;

+ 2 - 2
apps/app/src/components/PageEditor/CodeMirrorEditor.jsx

@@ -848,8 +848,8 @@ class CodeMirrorEditor extends AbstractEditor {
   // }
 
   showTemplateModal() {
-    const onSubmit = templateText => this.setValue(templateText);
-    this.props.onClickTemplateBtn(onSubmit);
+    const onSubmit = templateText => this.insertText(templateText);
+    this.props.onClickTemplateBtn({ onSubmit });
   }
 
   showLinkEditModal() {

+ 0 - 75
apps/app/src/components/Sidebar/InfiniteScroll.tsx

@@ -1,75 +0,0 @@
-import React, {
-  Ref, useEffect, useState,
-} from 'react';
-
-import type { SWRInfiniteResponse } from 'swr/infinite';
-
-type Props<T> = {
-  swrInifiniteResponse : SWRInfiniteResponse<T>
-  children: React.ReactNode,
-  loadingIndicator?: React.ReactNode
-  endingIndicator?: React.ReactNode
-  isReachingEnd?: boolean,
-  offset?: number
-}
-
-const useIntersection = <E extends HTMLElement>(): [boolean, Ref<E>] => {
-  const [intersecting, setIntersecting] = useState<boolean>(false);
-  const [element, setElement] = useState<HTMLElement>();
-  useEffect(() => {
-    if (element != null) {
-      const observer = new IntersectionObserver((entries) => {
-        setIntersecting(entries[0]?.isIntersecting);
-      });
-      observer.observe(element);
-      return () => observer.unobserve(element);
-    }
-    return;
-  }, [element]);
-  return [intersecting, el => el && setElement(el)];
-};
-
-const LoadingIndicator = (): React.ReactElement => {
-  return (
-    <div className="text-muted text-center">
-      <i className="fa fa-2x fa-spinner fa-pulse mr-1"></i>
-    </div>
-  );
-};
-
-const InfiniteScroll = <E, >(props: Props<E>): React.ReactElement<Props<E>> => {
-  const {
-    swrInifiniteResponse: {
-      setSize, isValidating,
-    },
-    children,
-    loadingIndicator,
-    endingIndicator,
-    isReachingEnd,
-    offset = 0,
-  } = props;
-
-  const [intersecting, ref] = useIntersection<HTMLDivElement>();
-
-  useEffect(() => {
-    if (intersecting && !isValidating && !isReachingEnd) {
-      setSize(size => size + 1);
-    }
-  }, [setSize, intersecting, isValidating, isReachingEnd]);
-
-  return (
-    <>
-      { children }
-
-      <div style={{ position: 'relative' }}>
-        <div ref={ref} style={{ position: 'absolute', top: offset }}></div>
-        {isReachingEnd
-          ? endingIndicator
-          : loadingIndicator || <LoadingIndicator />
-        }
-      </div>
-    </>
-  );
-};
-
-export default InfiniteScroll;

+ 39 - 21
apps/app/src/components/TemplateModal.tsx → apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -1,6 +1,8 @@
-import React, { useCallback, useState } from 'react';
+import React, {
+  useCallback, useEffect, useState,
+} from 'react';
 
-import { ITemplate } from '@growi/core';
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
 import { useTranslation } from 'next-i18next';
 import {
   Modal,
@@ -12,8 +14,13 @@ import {
 import { useTemplateModal } from '~/stores/modal';
 import { usePreviewOptions } from '~/stores/renderer';
 import { useTemplates } from '~/stores/template';
+import loggerFactory from '~/utils/logger';
 
-import Preview from './PageEditor/Preview';
+import Preview from '../PageEditor/Preview';
+
+import { useFormatter } from './use-formatter';
+
+const logger = loggerFactory('growi:components:TemplateModal');
 
 
 type TemplateRadioButtonProps = {
@@ -42,7 +49,8 @@ const TemplateRadioButton = ({ template, onChange, isSelected }: TemplateRadioBu
 };
 
 export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation();
+  const { t } = useTranslation(['translation', 'commons']);
+
 
   const { data: templateModalStatus, close } = useTemplateModal();
 
@@ -51,16 +59,27 @@ export const TemplateModal = (): JSX.Element => {
 
   const [selectedTemplate, setSelectedTemplate] = useState<ITemplate>();
 
+  const { format } = useFormatter();
+
   const submitHandler = useCallback((template?: ITemplate) => {
-    if (templateModalStatus == null) { return }
+    if (templateModalStatus == null || selectedTemplate == null) {
+      return;
+    }
+
     if (templateModalStatus.onSubmit == null || template == null) {
       close();
       return;
     }
 
-    templateModalStatus.onSubmit(template.markdown);
+    templateModalStatus.onSubmit(format(selectedTemplate));
     close();
-  }, [close, templateModalStatus]);
+  }, [close, format, selectedTemplate, templateModalStatus]);
+
+  useEffect(() => {
+    if (!templateModalStatus?.isOpened) {
+      setSelectedTemplate(undefined);
+    }
+  }, [templateModalStatus?.isOpened]);
 
   if (templates == null || templateModalStatus == null) {
     return <></>;
@@ -69,7 +88,7 @@ export const TemplateModal = (): JSX.Element => {
   return (
     <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="lg" autoFocus={false}>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
-        Template
+        {t('template.modal_label.Select template')}
       </ModalHeader>
 
       <ModalBody className="container">
@@ -79,24 +98,23 @@ export const TemplateModal = (): JSX.Element => {
               <TemplateRadioButton
                 key={template.id}
                 template={template}
-                onChange={t => setSelectedTemplate(t)}
+                onChange={selected => setSelectedTemplate(selected)}
                 isSelected={template.id === selectedTemplate?.id}
               />
             )) }
           </div>
         </div>
 
-        { rendererOptions != null && (
-          <>
-            <hr />
-            <h3>Preview</h3>
-            <div className='card'>
-              <div className="card-body" style={{ maxHeight: '60vh', overflowY: 'auto' }}>
-                <Preview rendererOptions={rendererOptions} markdown={selectedTemplate?.markdown}/>
-              </div>
-            </div>
-          </>
-        ) }
+        <hr />
+
+        <h3>{t('Preview')}</h3>
+        <div className='card'>
+          <div className="card-body" style={{ height: '400px', overflowY: 'auto' }}>
+            { rendererOptions != null && selectedTemplate != null && (
+              <Preview rendererOptions={rendererOptions} markdown={format(selectedTemplate)}/>
+            ) }
+          </div>
+        </div>
 
       </ModalBody>
       <ModalFooter>
@@ -104,7 +122,7 @@ export const TemplateModal = (): JSX.Element => {
           {t('Cancel')}
         </button>
         <button type="submit" className="btn btn-sm btn-primary mx-1" onClick={() => submitHandler(selectedTemplate)} disabled={selectedTemplate == null}>
-          {t('Update')}
+          {t('commons:Insert')}
         </button>
       </ModalFooter>
     </Modal>

+ 1 - 0
apps/app/src/components/TemplateModal/index.tsx

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

+ 101 - 0
apps/app/src/components/TemplateModal/use-formatter.spec.tsx

@@ -0,0 +1,101 @@
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import { mock } from 'vitest-mock-extended';
+
+import { useFormatter } from './use-formatter';
+
+
+const mocks = vi.hoisted(() => {
+  return {
+    useCurrentPagePathMock: vi.fn(() => { return {} }),
+  };
+});
+
+vi.mock('~/stores/page', () => {
+  return { useCurrentPagePath: mocks.useCurrentPagePathMock };
+});
+
+
+describe('useFormatter', () => {
+
+  describe('format()', () => {
+
+    it('returns an empty string when the argument is undefined', () => {
+      // setup
+      const mastacheMock = {
+        render: vi.fn(),
+      };
+      vi.doMock('mustache', () => mastacheMock);
+
+      // when
+      const { format } = useFormatter();
+      // call with undefined
+      const markdown = format(undefined);
+
+      // then
+      expect(markdown).toBe('');
+      expect(mastacheMock.render).not.toHaveBeenCalled();
+    });
+
+  });
+
+  it('returns markdown as-is when mustache.render throws an error', () => {
+    // setup
+    const mastacheMock = {
+      render: vi.fn(() => { throw new Error() }),
+    };
+    vi.doMock('mustache', () => mastacheMock);
+
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = 'markdown body';
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe('markdown body');
+  });
+
+  it('returns markdown formatted when currentPagePath is undefined', () => {
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = `
+title: {{{title}}}{{^title}}(empty){{/title}}
+path: {{{path}}}
+`;
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe(`
+title: (empty)
+path: /
+`);
+  });
+
+  it('returns markdown formatted', () => {
+    // setup
+    mocks.useCurrentPagePathMock.mockImplementation(() => {
+      return { data: '/Sandbox' };
+    });
+    // 2023/5/31 15:01:xx
+    vi.setSystemTime(new Date(2023, 4, 31, 15, 1));
+
+    // when
+    const { format } = useFormatter();
+    const template = mock<ITemplate>();
+    template.markdown = `
+title: {{{title}}}
+path: {{{path}}}
+date: {{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}
+`;
+    const markdown = format(template);
+
+    // then
+    expect(markdown).toBe(`
+title: Sandbox
+path: /Sandbox
+date: 2023/05/31 15:01
+`);
+  });
+
+});

+ 48 - 0
apps/app/src/components/TemplateModal/use-formatter.tsx

@@ -0,0 +1,48 @@
+import path from 'path';
+
+import type { ITemplate } from '@growi/core/dist/interfaces/template';
+import dateFnsFormat from 'date-fns/format';
+import mustache from 'mustache';
+
+import { useCurrentPagePath } from '~/stores/page';
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:components:TemplateModal:use-formatter');
+
+
+type FormatMethod = (selectedTemplate?: ITemplate) => string;
+type FormatterData = {
+  format: FormatMethod,
+}
+
+export const useFormatter = (): FormatterData => {
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const format: FormatMethod = (selectedTemplate) => {
+    if (selectedTemplate == null) {
+      return '';
+    }
+
+    // replace placeholder
+    let markdown = selectedTemplate.markdown;
+    const now = new Date();
+    try {
+      markdown = mustache.render(selectedTemplate.markdown, {
+        title: path.basename(currentPagePath ?? '/'),
+        path: currentPagePath ?? '/',
+        yyyy: dateFnsFormat(now, 'yyyy'),
+        MM: dateFnsFormat(now, 'MM'),
+        dd: dateFnsFormat(now, 'dd'),
+        HH: dateFnsFormat(now, 'HH'),
+        mm: dateFnsFormat(now, 'mm'),
+      });
+    }
+    catch (err) {
+      logger.warn('An error occured while ejs processing.', err);
+    }
+
+    return markdown;
+  };
+
+  return { format };
+};

+ 0 - 2
apps/app/src/features/activate-plugin/index.ts

@@ -1,2 +0,0 @@
-export * from './components';
-export * from './utils';

+ 0 - 1
apps/app/src/features/activate-plugin/utils/index.ts

@@ -1 +0,0 @@
-export { getGrowiFacade } from './growi-facade-utils';

+ 0 - 0
apps/app/src/components/Admin/PluginsExtension/PluginCard.module.scss → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.module.scss


+ 0 - 0
apps/app/src/components/Admin/PluginsExtension/PluginCard.tsx → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginCard.tsx


+ 2 - 1
apps/app/src/components/Admin/PluginsExtension/PluginInstallerForm.tsx → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx

@@ -4,7 +4,8 @@ import { useTranslation } from 'next-i18next';
 
 import { apiv3Post } from '~/client/util/apiv3-client';
 import { toastSuccess, toastError } from '~/client/util/toastr';
-import { useSWRxPlugins } from '~/stores/plugin';
+
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 export const PluginInstallerForm = (): JSX.Element => {
   const { mutate } = useSWRxPlugins();

+ 1 - 1
apps/app/src/components/Admin/PluginsExtension/PluginsExtensionPageContents.tsx → apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/PluginsExtensionPageContents.tsx

@@ -3,7 +3,7 @@ import React from 'react';
 import { useTranslation } from 'next-i18next';
 import { Spinner } from 'reactstrap';
 
-import { useSWRxPlugins } from '~/stores/plugin';
+import { useSWRxPlugins } from '../../../stores/growi-plugin';
 
 import { PluginCard } from './PluginCard';
 import { PluginInstallerForm } from './PluginInstallerForm';

+ 1 - 0
apps/app/src/features/growi-plugin/components/Admin/PluginsExtensionPageContents/index.ts

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

+ 1 - 1
apps/app/src/features/activate-plugin/components/GrowiPluginsActivator.client.tsx → apps/app/src/features/growi-plugin/components/GrowiPluginsActivator.client.tsx

@@ -1,6 +1,6 @@
 import { useEffect } from 'react';
 
-import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils';
+import { initializeGrowiFacade, registerGrowiFacade } from '../utils/growi-facade-utils.client';
 
 declare global {
   // eslint-disable-next-line vars-on-top, no-var

+ 0 - 0
apps/app/src/features/activate-plugin/components/index.ts → apps/app/src/features/growi-plugin/components/index.ts


+ 6 - 6
apps/app/src/interfaces/plugin.ts → apps/app/src/features/growi-plugin/interfaces/growi-plugin.ts

@@ -8,29 +8,29 @@ export const GrowiPluginResourceType = {
 } as const;
 export type GrowiPluginResourceType = typeof GrowiPluginResourceType[keyof typeof GrowiPluginResourceType];
 
-export type GrowiPluginOrigin = {
+export type IGrowiPluginOrigin = {
   url: string,
   ghBranch?: string,
   ghTag?: string,
 }
 
-export type GrowiPlugin<M extends GrowiPluginMeta = GrowiPluginMeta> = {
+export type IGrowiPlugin<M extends IGrowiPluginMeta = IGrowiPluginMeta> = {
   isEnabled: boolean,
   installedPath: string,
   organizationName: string,
-  origin: GrowiPluginOrigin,
+  origin: IGrowiPluginOrigin,
   meta: M,
 }
 
-export type GrowiPluginMeta = {
+export type IGrowiPluginMeta = {
   name: string,
   types: GrowiPluginResourceType[],
   desc?: string,
   author?: string,
 }
 
-export type GrowiThemePluginMeta = GrowiPluginMeta & {
+export type IGrowiThemePluginMeta = IGrowiPluginMeta & {
   themes: GrowiThemeMetadata[]
 }
 
-export type GrowiPluginHasId = GrowiPlugin & HasObjectId;
+export type IGrowiPluginHasId = IGrowiPlugin & HasObjectId;

+ 1 - 0
apps/app/src/features/growi-plugin/interfaces/index.ts

@@ -0,0 +1 @@
+export * from './growi-plugin';

+ 16 - 15
apps/app/src/server/models/growi-plugin.ts → apps/app/src/features/growi-plugin/models/growi-plugin.ts

@@ -1,19 +1,20 @@
 import { GrowiThemeMetadata, GrowiThemeSchemeType } from '@growi/core';
 import {
-  Schema, Model, Document, Types,
+  Schema, type Model, type Document, type Types,
 } from 'mongoose';
 
-import {
-  GrowiPlugin, GrowiPluginMeta, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta,
-} from '~/interfaces/plugin';
+import { getOrCreateModel } from '~/server/util/mongoose-utils';
 
-import { getOrCreateModel } from '../util/mongoose-utils';
+import { GrowiPluginResourceType } from '../interfaces';
+import type {
+  IGrowiPlugin, IGrowiPluginMeta, IGrowiPluginOrigin, IGrowiThemePluginMeta,
+} from '../interfaces';
 
-export interface GrowiPluginDocument extends GrowiPlugin, Document {
+export interface IGrowiPluginDocument extends IGrowiPlugin, Document {
 }
-export interface GrowiPluginModel extends Model<GrowiPluginDocument> {
-  findEnabledPlugins(): Promise<GrowiPlugin[]>
-  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<GrowiPlugin[]>
+export interface IGrowiPluginModel extends Model<IGrowiPluginDocument> {
+  findEnabledPlugins(): Promise<IGrowiPlugin[]>
+  findEnabledPluginsIncludingAnyTypes(includingTypes: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]>
   activatePlugin(id: Types.ObjectId): Promise<string>
   deactivatePlugin(id: Types.ObjectId): Promise<string>
 }
@@ -32,7 +33,7 @@ const growiThemeMetadataSchema = new Schema<GrowiThemeMetadata>({
   accent: { type: String, required: true },
 });
 
-const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
+const growiPluginMetaSchema = new Schema<IGrowiPluginMeta|IGrowiThemePluginMeta>({
   name: { type: String, required: true },
   types: {
     type: [String],
@@ -44,13 +45,13 @@ const growiPluginMetaSchema = new Schema<GrowiPluginMeta|GrowiThemePluginMeta>({
   themes: [growiThemeMetadataSchema],
 });
 
-const growiPluginOriginSchema = new Schema<GrowiPluginOrigin>({
+const growiPluginOriginSchema = new Schema<IGrowiPluginOrigin>({
   url: { type: String },
   ghBranch: { type: String },
   ghTag: { type: String },
 });
 
-const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
+const growiPluginSchema = new Schema<IGrowiPluginDocument, IGrowiPluginModel>({
   isEnabled: { type: Boolean },
   installedPath: { type: String },
   organizationName: { type: String },
@@ -58,11 +59,11 @@ const growiPluginSchema = new Schema<GrowiPluginDocument, GrowiPluginModel>({
   meta: growiPluginMetaSchema,
 });
 
-growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<GrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPlugins = async function(): Promise<IGrowiPlugin[]> {
   return this.find({ isEnabled: true });
 };
 
-growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<GrowiPlugin[]> {
+growiPluginSchema.statics.findEnabledPluginsIncludingAnyTypes = async function(types: GrowiPluginResourceType[]): Promise<IGrowiPlugin[]> {
   return this.find({
     isEnabled: true,
     'meta.types': { $in: types },
@@ -89,4 +90,4 @@ growiPluginSchema.statics.deactivatePlugin = async function(id: Types.ObjectId):
   return pluginName;
 };
 
-export default getOrCreateModel<GrowiPluginDocument, GrowiPluginModel>('GrowiPlugin', growiPluginSchema);
+export const GrowiPlugin = getOrCreateModel<IGrowiPluginDocument, IGrowiPluginModel>('GrowiPlugin', growiPluginSchema);

+ 1 - 0
apps/app/src/features/growi-plugin/models/index.ts

@@ -0,0 +1 @@
+export * from './growi-plugin';

+ 12 - 33
apps/app/src/server/routes/apiv3/plugins.ts → apps/app/src/features/growi-plugin/routes/growi-plugins.ts

@@ -2,10 +2,12 @@ import express, { Request, Router } from 'express';
 import { body, query } from 'express-validator';
 import mongoose from 'mongoose';
 
-import Crowi from '../../crowi';
-import type { GrowiPluginModel } from '../../models/growi-plugin';
+import Crowi from '~/server/crowi';
+import { ApiV3Response } from '~/server/routes/apiv3/interfaces/apiv3-response';
+
+import { GrowiPlugin } from '../models';
+import { growiPluginService } from '../services';
 
-import { ApiV3Response } from './interfaces/apiv3-response';
 
 const ObjectID = mongoose.Types.ObjectId;
 
@@ -22,20 +24,14 @@ const validator = {
 };
 
 module.exports = (crowi: Crowi): Router => {
-  const loginRequiredStrictly = require('../../middlewares/login-required')(crowi);
-  const adminRequired = require('../../middlewares/admin-required')(crowi);
+  const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
+  const adminRequired = require('~/server/middlewares/admin-required')(crowi);
 
   const router = express.Router();
-  const { pluginService } = crowi;
 
   router.get('/', loginRequiredStrictly, adminRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const data = await GrowiPluginModel.find({});
+      const data = await GrowiPlugin.find({});
       return res.apiv3({ plugins: data });
     }
     catch (err) {
@@ -44,14 +40,10 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.post('/', loginRequiredStrictly, adminRequired, validator.pluginFormValueisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { pluginInstallerForm: formValue } = req.body;
 
     try {
-      const pluginName = await pluginService.install(formValue);
+      const pluginName = await growiPluginService.install(formValue);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -60,15 +52,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.put('/:id/activate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const pluginName = await GrowiPluginModel.activatePlugin(pluginId);
+      const pluginName = await GrowiPlugin.activatePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -77,16 +65,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.put('/:id/deactivate', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const GrowiPluginModel = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-      const pluginName = await GrowiPluginModel.deactivatePlugin(pluginId);
+      const pluginName = await GrowiPlugin.deactivatePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {
@@ -95,15 +78,11 @@ module.exports = (crowi: Crowi): Router => {
   });
 
   router.delete('/:id/remove', loginRequiredStrictly, adminRequired, validator.pluginIdisRequired, async(req: Request, res: ApiV3Response) => {
-    if (pluginService == null) {
-      return res.apiv3Err('\'pluginService\' is not set up', 500);
-    }
-
     const { id } = req.params;
     const pluginId = new ObjectID(id);
 
     try {
-      const pluginName = await pluginService.deletePlugin(pluginId);
+      const pluginName = await growiPluginService.deletePlugin(pluginId);
       return res.apiv3({ pluginName });
     }
     catch (err) {

+ 22 - 26
apps/app/src/server/service/plugin.ts → apps/app/src/features/growi-plugin/services/growi-plugin.ts

@@ -8,13 +8,14 @@ import mongoose from 'mongoose';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
-import {
-  GrowiPlugin, GrowiPluginOrigin, GrowiPluginResourceType, GrowiThemePluginMeta, GrowiPluginMeta,
-} from '~/interfaces/plugin';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import type { GrowiPluginModel } from '../models/growi-plugin';
+import { GrowiPluginResourceType } from '../interfaces';
+import type {
+  IGrowiPlugin, IGrowiPluginOrigin, IGrowiThemePluginMeta, IGrowiPluginMeta,
+} from '../interfaces';
+import { GrowiPlugin } from '../models';
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
@@ -27,7 +28,7 @@ const PLUGINS_STATIC_DIR = '/static/plugins'; // configured by express.static
 
 export type GrowiPluginResourceEntries = [installedPath: string, href: string][];
 
-function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
+function retrievePluginManifest(growiPlugin: IGrowiPlugin): ViteManifest {
   const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
   const manifestStr: string = readFileSync(manifestPath, 'utf-8');
   return JSON.parse(manifestStr);
@@ -35,19 +36,19 @@ function retrievePluginManifest(growiPlugin: GrowiPlugin): ViteManifest {
 
 
 type FindThemePluginResult = {
-  growiPlugin: GrowiPlugin,
+  growiPlugin: IGrowiPlugin,
   themeMetadata: GrowiThemeMetadata,
   themeHref: string,
 }
 
-export interface IPluginService {
-  install(origin: GrowiPluginOrigin): Promise<string>
+export interface IGrowiPluginService {
+  install(origin: IGrowiPluginOrigin): Promise<string>
   findThemePlugin(theme: string): Promise<FindThemePluginResult | null>
   retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries>
   downloadNotExistPluginRepositories(): Promise<void>
 }
 
-export class PluginService implements IPluginService {
+export class GrowiPluginService implements IGrowiPluginService {
 
   /*
   * Downloading a non-existent repository to the file system
@@ -55,7 +56,6 @@ export class PluginService implements IPluginService {
   async downloadNotExistPluginRepositories(): Promise<void> {
     try {
       // find all growi plugin documents
-      const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
       const growiPlugins = await GrowiPlugin.find({});
 
       // if not exists repository in file system, download latest plugin repository
@@ -113,7 +113,7 @@ export class PluginService implements IPluginService {
   /*
   * Install a plugin from URL and save it in the DB and file system.
   */
-  async install(origin: GrowiPluginOrigin): Promise<string> {
+  async install(origin: IGrowiPluginOrigin): Promise<string> {
     const ghUrl = new URL(origin.url);
     const ghPathname = ghUrl.pathname;
     // TODO: Branch names can be specified.
@@ -137,7 +137,7 @@ export class PluginService implements IPluginService {
     const organizationPath = path.join(pluginStoringPath, ghOrganizationName);
 
 
-    let plugins: GrowiPlugin<GrowiPluginMeta>[];
+    let plugins: IGrowiPlugin<IGrowiPluginMeta>[];
 
     try {
       // download github repository to file system's temporary path
@@ -146,7 +146,7 @@ export class PluginService implements IPluginService {
       fs.renameSync(unzippedReposPath, temporaryReposPath);
 
       // detect plugins
-      plugins = await PluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
+      plugins = await GrowiPluginService.detectPlugins(origin, ghOrganizationName, ghReposName);
 
       if (!fs.existsSync(organizationPath)) fs.mkdirSync(organizationPath);
 
@@ -184,7 +184,6 @@ export class PluginService implements IPluginService {
   }
 
   private async deleteOldPluginDocument(path: string): Promise<void> {
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     await GrowiPlugin.deleteMany({ installedPath: path });
   }
 
@@ -230,13 +229,12 @@ export class PluginService implements IPluginService {
     }
   }
 
-  private async savePluginMetaData(plugins: GrowiPlugin[]): Promise<void> {
-    const GrowiPlugin = mongoose.model('GrowiPlugin');
+  private async savePluginMetaData(plugins: IGrowiPlugin[]): Promise<void> {
     await GrowiPlugin.insertMany(plugins);
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
-  private static async detectPlugins(origin: GrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<GrowiPlugin[]> {
+  private static async detectPlugins(origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string, parentPackageJson?: any): Promise<IGrowiPlugin[]> {
     const packageJsonPath = path.resolve(pluginStoringPath, ghReposName, 'package.json');
     const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
 
@@ -279,7 +277,7 @@ export class PluginService implements IPluginService {
 
     // add theme metadata
     if (growiPlugin.types.includes(GrowiPluginResourceType.Theme)) {
-      (plugin as GrowiPlugin<GrowiThemePluginMeta>).meta = {
+      (plugin as IGrowiPlugin<IGrowiThemePluginMeta>).meta = {
         ...plugin.meta,
         themes: growiPlugin.themes,
       };
@@ -290,7 +288,7 @@ export class PluginService implements IPluginService {
     return [plugin];
   }
 
-  async listPlugins(): Promise<GrowiPlugin[]> {
+  async listPlugins(): Promise<IGrowiPlugin[]> {
     return [];
   }
 
@@ -302,7 +300,6 @@ export class PluginService implements IPluginService {
       return fs.promises.rm(path, { recursive: true });
     };
 
-    const GrowiPlugin = mongoose.model<GrowiPlugin>('GrowiPlugin');
     const growiPlugins = await GrowiPlugin.findById(pluginId);
 
     if (growiPlugins == null) {
@@ -330,14 +327,12 @@ export class PluginService implements IPluginService {
   }
 
   async findThemePlugin(theme: string): Promise<FindThemePluginResult | null> {
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
-    let matchedPlugin: GrowiPlugin | undefined;
+    let matchedPlugin: IGrowiPlugin | undefined;
     let matchedThemeMetadata: GrowiThemeMetadata | undefined;
 
     try {
       // retrieve plugin manifests
-      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as GrowiPlugin<GrowiThemePluginMeta>[];
+      const growiPlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]) as IGrowiPlugin<IGrowiThemePluginMeta>[];
 
       growiPlugins
         .forEach(async(growiPlugin) => {
@@ -373,8 +368,6 @@ export class PluginService implements IPluginService {
 
   async retrieveAllPluginResourceEntries(): Promise<GrowiPluginResourceEntries> {
 
-    const GrowiPlugin = mongoose.model('GrowiPlugin') as GrowiPluginModel;
-
     const entries: GrowiPluginResourceEntries = [];
 
     try {
@@ -409,3 +402,6 @@ export class PluginService implements IPluginService {
   }
 
 }
+
+
+export const growiPluginService = new GrowiPluginService();

+ 1 - 0
apps/app/src/features/growi-plugin/services/index.ts

@@ -0,0 +1 @@
+export * from './growi-plugin';

+ 3 - 2
apps/app/src/stores/plugin.tsx → apps/app/src/features/growi-plugin/stores/growi-plugin.tsx

@@ -1,10 +1,11 @@
 import useSWR, { SWRResponse } from 'swr';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
-import { GrowiPluginHasId } from '~/interfaces/plugin';
+
+import type { IGrowiPluginHasId } from '../interfaces';
 
 type Plugins = {
-  plugins: GrowiPluginHasId[]
+  plugins: IGrowiPluginHasId[]
 }
 
 const pluginsFetcher = () => {

+ 0 - 0
apps/app/src/features/activate-plugin/utils/growi-facade-utils.ts → apps/app/src/features/growi-plugin/utils/growi-facade-utils.client.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/components/MermaidViewer.tsx → apps/app/src/features/mermaid/components/MermaidViewer.tsx


+ 0 - 0
apps/app/src/features/mermaid-plugin/components/index.ts → apps/app/src/features/mermaid/components/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/index.ts → apps/app/src/features/mermaid/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/services/index.ts → apps/app/src/features/mermaid/services/index.ts


+ 0 - 0
apps/app/src/features/mermaid-plugin/services/mermaid.ts → apps/app/src/features/mermaid/services/mermaid.ts


+ 3 - 2
apps/app/src/interfaces/bookmark-info.ts

@@ -4,9 +4,10 @@ import { IPageHasId } from '~/interfaces/page';
 import { IUser } from '~/interfaces/user';
 
 export interface IBookmarkInfo {
-  sumOfBookmarks: number;
+  sumOfBookmarks: number,
   isBookmarked: boolean,
-  bookmarkedUsers: IUser[]
+  bookmarkedUsers: IUser[],
+  pageId: string,
 }
 
 export interface BookmarkedPage {

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

@@ -67,7 +67,7 @@ declare global {
 }
 
 
-const GrowiPluginsActivator = dynamic(() => import('~/features/activate-plugin').then(mod => mod.GrowiPluginsActivator), { ssr: false });
+const GrowiPluginsActivator = dynamic(() => import('~/features/growi-plugin/components').then(mod => mod.GrowiPluginsActivator), { ssr: false });
 const DescendantsPageListModal = dynamic(() => import('../components/DescendantsPageListModal').then(mod => mod.DescendantsPageListModal), { ssr: false });
 const UnsavedAlertDialog = dynamic(() => import('../components/UnsavedAlertDialog'), { ssr: false });
 const GrowiSubNavigationSwitcher = dynamic<GrowiSubNavigationSwitcherProps>(() => import('../components/Navbar/GrowiSubNavigationSwitcher')

+ 3 - 3
apps/app/src/pages/_document.page.tsx

@@ -6,8 +6,8 @@ import Document, {
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { growiPluginService, type GrowiPluginResourceEntries } from '~/features/growi-plugin/services';
 import type { CrowiRequest } from '~/interfaces/crowi-request';
-import type { IPluginService, GrowiPluginResourceEntries } from '~/server/service/plugin';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:page:_document');
@@ -49,7 +49,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
     const { crowi } = ctx.req as CrowiRequest<any>;
-    const { customizeService, pluginService } = crowi;
+    const { customizeService } = crowi;
 
     const { themeHref } = customizeService;
     const customScript: string | null = customizeService.getCustomScript();
@@ -57,7 +57,7 @@ class GrowiDocument extends Document<GrowiDocumentInitialProps> {
     const customNoscript: string | null = customizeService.getCustomNoscript();
 
     // retrieve plugin manifests
-    const pluginResourceEntries = await (pluginService as IPluginService).retrieveAllPluginResourceEntries();
+    const pluginResourceEntries = await growiPluginService.retrieveAllPluginResourceEntries();
 
     return {
       ...initialProps,

+ 1 - 1
apps/app/src/pages/admin/plugins.page.tsx

@@ -18,7 +18,7 @@ import { retrieveServerSideProps } from '../../utils/admin-page-util';
 
 const AdminLayout = dynamic(() => import('~/components/Layout/AdminLayout'), { ssr: false });
 const PluginsExtensionPageContents = dynamic(
-  () => import('~/components/Admin/PluginsExtension/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
+  () => import('~/features/growi-plugin/components/Admin/PluginsExtensionPageContents').then(mod => mod.PluginsExtensionPageContents),
   { ssr: false },
 );
 

+ 1 - 3
apps/app/src/server/console.js

@@ -4,7 +4,7 @@ const repl = require('repl');
 
 const mongoose = require('mongoose');
 
-const { initMongooseGlobalSettings, getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
+const { getMongoUri, mongoOptions } = require('~/server/util/mongoose-utils');
 
 const models = require('./models');
 
@@ -34,8 +34,6 @@ fs.readFile(replHistoryPath, 'utf8', (err, data) => {
 replServer.context.mongoose = mongoose;
 replServer.context.models = models;
 
-initMongooseGlobalSettings();
-
 mongoose.connect(getMongoUri(), mongoOptions)
   .then(() => {
     replServer.context.db = mongoose.connection.db;

+ 3 - 1
apps/app/src/server/crowi/express-init.js

@@ -5,6 +5,8 @@ import qs from 'qs';
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
+import registerSafeRedirectFactory from '../middlewares/safe-redirect';
+
 const logger = loggerFactory('growi:crowi:express-init');
 
 module.exports = function(crowi, app) {
@@ -22,7 +24,7 @@ module.exports = function(crowi, app) {
   const mongoSanitize = require('express-mongo-sanitize');
 
   const promster = require('../middlewares/promster')(crowi, app);
-  const registerSafeRedirect = require('../middlewares/safe-redirect')();
+  const registerSafeRedirect = registerSafeRedirectFactory();
   const injectCurrentuserToLocalvars = require('../middlewares/inject-currentuser-to-localvars')();
   const autoReconnectToS2sMsgServer = require('../middlewares/auto-reconnect-to-s2s-msg-server')(crowi);
 

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

@@ -19,25 +19,22 @@ import { projectRoot } from '~/utils/project-dir-utils';
 
 
 import Activity from '../models/activity';
-import GrowiPlugin from '../models/growi-plugin';
 import PageRedirect from '../models/page-redirect';
 import Tag from '../models/tag';
 import UserGroup from '../models/user-group';
-import AclService from '../service/acl';
+import { aclService as aclServiceSingletonInstance } from '../service/acl';
 import AppService from '../service/app';
 import AttachmentService from '../service/attachment';
-import ConfigManager from '../service/config-manager';
+import { configManager as configManagerSingletonInstance } from '../service/config-manager';
 import { G2GTransferPusherService, G2GTransferReceiverService } from '../service/g2g-transfer';
 import { InstallerService } from '../service/installer';
 import PageService from '../service/page';
 import PageGrantService from '../service/page-grant';
 import PageOperationService from '../service/page-operation';
-// eslint-disable-next-line import/no-cycle
-import { PluginService } from '../service/plugin';
 import SearchService from '../service/search';
 import { SlackIntegrationService } from '../service/slack-integration';
 import { UserNotificationService } from '../service/user-notification';
-import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/mongoose-utils';
+import { getMongoUri, mongoOptions } from '../util/mongoose-utils';
 
 
 const logger = loggerFactory('growi:crowi');
@@ -134,7 +131,6 @@ Crowi.prototype.init = async function() {
     this.scanRuntimeVersions(),
     this.setupPassport(),
     this.setupSearcher(),
-    this.setupPluginer(),
     this.setupMailer(),
     this.setupSlackIntegrationService(),
     this.setupG2GTransferService(),
@@ -146,7 +142,7 @@ Crowi.prototype.init = async function() {
     this.setupUserGroupService(),
     this.setupExport(),
     this.setupImport(),
-    this.setupPluginService(),
+    this.setupGrowiPluginService(),
     this.setupPageService(),
     this.setupInAppNotificationService(),
     this.setupActivityService(),
@@ -224,8 +220,6 @@ Crowi.prototype.setupDatabase = function() {
   // mongoUri = mongodb://user:password@host/dbname
   const mongoUri = getMongoUri();
 
-  initMongooseGlobalSettings();
-
   return mongoose.connect(mongoUri, mongoOptions);
 };
 
@@ -276,7 +270,7 @@ Crowi.prototype.setupSessionConfig = async function() {
 };
 
 Crowi.prototype.setupConfigManager = async function() {
-  this.configManager = new ConfigManager();
+  this.configManager = configManagerSingletonInstance;
   return this.configManager.loadConfigs();
 };
 
@@ -310,7 +304,6 @@ Crowi.prototype.setupModels = async function() {
   allModels.Tag = Tag;
   allModels.UserGroup = UserGroup;
   allModels.PageRedirect = PageRedirect;
-  allModels.growiPlugin = GrowiPlugin;
 
   Object.keys(allModels).forEach((key) => {
     return this.model(key, models[key](this));
@@ -396,13 +389,6 @@ Crowi.prototype.setupSearcher = async function() {
   this.searchService = new SearchService(this);
 };
 
-/**
- * setup PluginService
- */
-Crowi.prototype.setupPluginer = async function() {
-  this.pluginService = new PluginService(this);
-};
-
 Crowi.prototype.setupMailer = async function() {
   const MailService = require('~/server/service/mail');
   this.mailService = new MailService(this);
@@ -614,9 +600,7 @@ Crowi.prototype.setUpXss = async function() {
  * setup AclService
  */
 Crowi.prototype.setUpAcl = async function() {
-  if (this.aclService == null) {
-    this.aclService = new AclService(this.configManager);
-  }
+  this.aclService = aclServiceSingletonInstance;
 };
 
 /**
@@ -721,14 +705,12 @@ Crowi.prototype.setupImport = async function() {
   }
 };
 
-Crowi.prototype.setupPluginService = async function() {
-  const { PluginService } = require('../service/plugin');
-  if (this.pluginService == null) {
-    this.pluginService = new PluginService(this);
-  }
+Crowi.prototype.setupGrowiPluginService = async function() {
+  const { growiPluginService } = require('~/features/growi-plugin/services');
+
   // download plugin repositories, if document exists but there is no repository
   // TODO: Cannot download unless connected to the Internet at setup.
-  await this.pluginService.downloadNotExistPluginRepositories();
+  await growiPluginService.downloadNotExistPluginRepositories();
 };
 
 Crowi.prototype.setupPageService = async function() {

+ 3 - 3
apps/app/test/unit/middlewares/exclude-read-only-user.test.ts → apps/app/src/server/middlewares/exclude-read-only-user.spec.ts

@@ -1,6 +1,6 @@
 import { ErrorV3 } from '@growi/core';
 
-import { excludeReadOnlyUser } from '../../../src/server/middlewares/exclude-read-only-user';
+import { excludeReadOnlyUser } from './exclude-read-only-user';
 
 describe('excludeReadOnlyUser', () => {
   let req;
@@ -12,9 +12,9 @@ describe('excludeReadOnlyUser', () => {
       user: {},
     };
     res = {
-      apiv3Err: jest.fn(),
+      apiv3Err: vi.fn(),
     };
-    next = jest.fn();
+    next = vi.fn();
   });
 
   test('should call next if user is not found', () => {

+ 18 - 30
apps/app/test/unit/middlewares/safe-redirect.test.js → apps/app/src/server/middlewares/safe-redirect.spec.ts

@@ -1,105 +1,93 @@
-/* eslint-disable arrow-body-style */
+import type { Request } from 'express';
 
-describe('safeRedirect', () => {
-  let registerSafeRedirect;
+import registerSafeRedirectFactory, { type ResWithSafeRedirect } from './safe-redirect';
 
+describe('safeRedirect', () => {
   const whitelistOfHosts = [
     'white1.example.com:8080',
     'white2.example.com',
   ];
-
-  beforeEach(async() => {
-    registerSafeRedirect = require('~/server/middlewares/safe-redirect')(whitelistOfHosts);
-  });
+  const registerSafeRedirect = registerSafeRedirectFactory(whitelistOfHosts);
 
   describe('res.safeRedirect', () => {
     // setup req/res/next
+    const getFunc = vi.fn().mockReturnValue('example.com');
     const req = {
       protocol: 'http',
       hostname: 'example.com',
-      get: jest.fn().mockReturnValue('example.com'),
-    };
+      get: getFunc,
+    } as any as Request;
+
+    const redirect = vi.fn();
     const res = {
-      redirect: jest.fn().mockReturnValue('redirect'),
-    };
-    const next = jest.fn();
+      redirect,
+    } as any as ResWithSafeRedirect;
+    const next = vi.fn();
 
     test('redirects to \'/\' because specified url causes open redirect vulnerability', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('//evil.example.com');
+      res.safeRedirect('//evil.example.com');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to \'/\' because specified host without port is not in whitelist', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://white1.example.com/path/to/page');
+      res.safeRedirect('http://white1.example.com/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('/');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified local url', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('/path/to/page');
+      res.safeRedirect('/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified local url (fqdn)', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://example.com/path/to/page');
+      res.safeRedirect('http://example.com/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://example.com/path/to/page');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified whitelisted url (white1.example.com:8080)', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://white1.example.com:8080/path/to/page');
+      res.safeRedirect('http://white1.example.com:8080/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://white1.example.com:8080/path/to/page');
-      expect(result).toBe('redirect');
     });
 
     test('redirects to the specified whitelisted url (white2.example.com:8080)', () => {
       registerSafeRedirect(req, res, next);
 
-      const result = res.safeRedirect('http://white2.example.com:8080/path/to/page');
+      res.safeRedirect('http://white2.example.com:8080/path/to/page');
 
       expect(next).toHaveBeenCalledTimes(1);
-      expect(req.get).toHaveBeenCalledTimes(1);
       expect(req.get).toHaveBeenCalledWith('host');
       expect(res.redirect).toHaveBeenCalledTimes(1);
       expect(res.redirect).toHaveBeenCalledWith('http://white2.example.com:8080/path/to/page');
-      expect(result).toBe('redirect');
     });
 
   });

+ 6 - 4
apps/app/src/server/middlewares/safe-redirect.ts

@@ -4,7 +4,7 @@
  * Usage: app.use(require('middlewares/safe-redirect')(['example.com', 'some.example.com:8080']))
  */
 
-import {
+import type {
   Request, Response, NextFunction,
 } from 'express';
 
@@ -31,13 +31,13 @@ function isInWhitelist(whitelistOfHosts: string[], redirectToFqdn: string): bool
 }
 
 
-type ResWithSafeRedirect = Response & {
+export type ResWithSafeRedirect = Response & {
   safeRedirect: (redirectTo?: string) => void,
 }
 
-module.exports = (whitelistOfHosts: string[]) => {
+const factory = (whitelistOfHosts: string[]) => {
 
-  return function(req: Request, res: ResWithSafeRedirect, next: NextFunction) {
+  return (req: Request, res: ResWithSafeRedirect, next: NextFunction): void => {
 
     // extend res object
     res.safeRedirect = function(redirectTo?: string) {
@@ -75,3 +75,5 @@ module.exports = (whitelistOfHosts: string[]) => {
   };
 
 };
+
+export default factory;

+ 0 - 2
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -1,5 +1,3 @@
-import { test } from 'vitest';
-
 import { RuleTester } from 'eslint';
 
 import noPopulate from '../no-populate';

+ 1 - 0
apps/app/src/server/routes/apiv3/bookmarks.js

@@ -128,6 +128,7 @@ module.exports = (crowi) => {
       }
       responsesParams.sumOfBookmarks = bookmarks.length;
       responsesParams.bookmarkedUsers = users;
+      responsesParams.pageId = pageId;
     }
     catch (err) {
       logger.error('get-bookmark-document-failed', err);

+ 6 - 8
apps/app/src/server/routes/apiv3/customize-setting.js

@@ -1,10 +1,14 @@
 /* eslint-disable no-unused-vars */
 
 import { ErrorV3 } from '@growi/core';
+import express from 'express';
+import { body } from 'express-validator';
 import mongoose from 'mongoose';
+import multer from 'multer';
 
+import { GrowiPluginResourceType } from '~/features/growi-plugin/interfaces';
+import { GrowiPlugin } from '~/features/growi-plugin/models';
 import { SupportedAction } from '~/interfaces/activity';
-import { GrowiPluginResourceType } from '~/interfaces/plugin';
 import { AttachmentType } from '~/server/interfaces/attachment';
 import loggerFactory from '~/utils/logger';
 
@@ -14,13 +18,8 @@ import { apiV3FormValidator } from '../../middlewares/apiv3-form-validator';
 
 const logger = loggerFactory('growi:routes:apiv3:customize-setting');
 
-const express = require('express');
-
 const router = express.Router();
 
-const { body, query } = require('express-validator');
-const multer = require('multer');
-
 
 /**
  * @swagger
@@ -276,8 +275,7 @@ module.exports = (crowi) => {
       const currentTheme = await crowi.configManager.getConfig('crowi', 'customize:theme');
 
       // retrieve plugin manifests
-      const GrowiPluginModel = mongoose.model('GrowiPlugin');
-      const themePlugins = await GrowiPluginModel.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
+      const themePlugins = await GrowiPlugin.findEnabledPluginsIncludingAnyTypes([GrowiPluginResourceType.Theme]);
 
       const pluginThemesMetadatas = themePlugins
         .map(themePlugin => themePlugin.meta.themes)

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

@@ -108,7 +108,7 @@ module.exports = (crowi, app) => {
     userActivation.validateCompleteRegistration,
     userActivation.completeRegistrationAction(crowi));
 
-  router.use('/plugins', require('./plugins')(crowi));
+  router.use('/plugins', require('~/features/growi-plugin/routes/growi-plugins')(crowi));
 
   router.use('/user-ui-settings', require('./user-ui-settings')(crowi));
 

+ 51 - 38
apps/app/test/integration/service/acl.test.js → apps/app/src/server/service/acl.integ.ts

@@ -1,15 +1,28 @@
-const { getInstance } = require('../setup-crowi');
+import { aclService } from './acl';
+import { configManager } from './config-manager';
+
+
+describe('AclService', () => {
+  test("has consts 'isLabeledStatement'", () => {
+    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_DENY).toBe('Deny');
+    expect(aclService.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY).toBe('Readonly');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_OPEN).toBe('Open');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_RESTRICTED).toBe('Restricted');
+    expect(aclService.labels.SECURITY_REGISTRATION_MODE_CLOSED).toBe('Closed');
+  });
+});
 
 describe('AclService test', () => {
-  let crowi;
 
   const initialEnv = process.env;
 
-  beforeEach(async() => {
-    crowi = await getInstance();
-    process.env = initialEnv;
+  beforeAll(async() => {
+    await configManager.loadConfigs();
   });
 
+  afterEach(() => {
+    process.env = initialEnv;
+  });
 
   describe('isAclEnabled()', () => {
 
@@ -17,11 +30,11 @@ describe('AclService test', () => {
       delete process.env.FORCE_WIKI_MODE;
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe(undefined);
       expect(result).toBe(true);
     });
@@ -30,11 +43,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('dummy string');
       expect(result).toBe(true);
     });
@@ -43,11 +56,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(result).toBe(true);
     });
@@ -56,11 +69,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isAclEnabled();
+      const result = aclService.isAclEnabled();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(result).toBe(false);
     });
@@ -74,11 +87,11 @@ describe('AclService test', () => {
       delete process.env.FORCE_WIKI_MODE;
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe(undefined);
       expect(result).toBe(false);
     });
@@ -87,11 +100,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'dummy string';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('dummy string');
       expect(result).toBe(false);
     });
@@ -100,11 +113,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(result).toBe(true);
     });
@@ -113,11 +126,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isWikiModeForced();
+      const result = aclService.isWikiModeForced();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(result).toBe(true);
     });
@@ -130,18 +143,18 @@ describe('AclService test', () => {
 
     beforeEach(async() => {
       // prepare spy for ConfigManager.getConfig
-      getConfigSpy = jest.spyOn(crowi.configManager, 'getConfig');
+      getConfigSpy = vi.spyOn(configManager, 'getConfig');
     });
 
     test('to be false when FORCE_WIKI_MODE=private', async() => {
       process.env.FORCE_WIKI_MODE = 'private';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isGuestAllowedToRead();
+      const result = aclService.isGuestAllowedToRead();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('private');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(result).toBe(false);
@@ -151,11 +164,11 @@ describe('AclService test', () => {
       process.env.FORCE_WIKI_MODE = 'public';
 
       // reload
-      await crowi.configManager.loadConfigs();
+      await configManager.loadConfigs();
 
-      const result = crowi.aclService.isGuestAllowedToRead();
+      const result = aclService.isGuestAllowedToRead();
 
-      const wikiMode = crowi.configManager.getConfig('crowi', 'security:wikiMode');
+      const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
       expect(wikiMode).toBe('public');
       expect(getConfigSpy).not.toHaveBeenCalledWith('crowi', 'security:restrictGuestMode');
       expect(result).toBe(true);
@@ -174,7 +187,7 @@ describe('AclService test', () => {
       test(`when FORCE_WIKI_MODE is undefined and 'security:restrictGuestMode' is '${restrictGuestMode}`, async() => {
 
         // reload
-        await crowi.configManager.loadConfigs();
+        await configManager.loadConfigs();
 
         // setup mock implementation
         getConfigSpy.mockImplementation((ns, key) => {
@@ -187,7 +200,7 @@ describe('AclService test', () => {
           throw new Error('Unexpected behavior.');
         });
 
-        const result = crowi.aclService.isGuestAllowedToRead();
+        const result = aclService.isGuestAllowedToRead();
 
         expect(getConfigSpy).toHaveBeenCalledTimes(2);
         expect(getConfigSpy).toHaveBeenCalledWith('crowi', 'security:wikiMode');

+ 19 - 27
apps/app/src/server/service/acl.js → apps/app/src/server/service/acl.ts

@@ -1,16 +1,25 @@
 import loggerFactory from '~/utils/logger';
 
-// eslint-disable-next-line no-unused-vars
+import { configManager } from './config-manager';
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:service:AclService');
 
+export interface AclService {
+  get labels(): { [key: string]: string },
+  isAclEnabled(): boolean,
+  isWikiModeForced(): boolean,
+  isGuestAllowedToRead(): boolean,
+  getGuestModeValue(): string,
+}
+
 /**
  * the service class of AclService
  */
-class AclService {
+class AclServiceImpl implements AclService {
 
-  constructor(configManager) {
-    this.configManager = configManager;
-    this.labels = {
+  get labels() {
+    return {
       SECURITY_RESTRICT_GUEST_MODE_DENY: 'Deny',
       SECURITY_RESTRICT_GUEST_MODE_READONLY: 'Readonly',
       SECURITY_REGISTRATION_MODE_OPEN: 'Open',
@@ -23,7 +32,7 @@ class AclService {
    * @returns Whether Access Control is enabled or not
    */
   isAclEnabled() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
     return wikiMode !== 'public';
   }
 
@@ -31,7 +40,7 @@ class AclService {
    * @returns Whether wiki mode is set
    */
   isWikiModeForced() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
     const isPrivateOrPublic = wikiMode === 'private' || wikiMode === 'public';
 
     return isPrivateOrPublic;
@@ -41,7 +50,7 @@ class AclService {
    * @returns Whether guest users are allowed to read public pages
    */
   isGuestAllowedToRead() {
-    const wikiMode = this.configManager.getConfig('crowi', 'security:wikiMode');
+    const wikiMode = configManager.getConfig('crowi', 'security:wikiMode');
 
     // return false if private wiki mode
     if (wikiMode === 'private') {
@@ -52,7 +61,7 @@ class AclService {
       return true;
     }
 
-    const guestMode = this.configManager.getConfig('crowi', 'security:restrictGuestMode');
+    const guestMode = configManager.getConfig('crowi', 'security:restrictGuestMode');
 
     // 'Readonly' => returns true (allow access to guests)
     // 'Deny', null, undefined, '', ... everything else => returns false (requires login)
@@ -65,23 +74,6 @@ class AclService {
       : this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY;
   }
 
-  getRestrictGuestModeLabels() {
-    const labels = {};
-    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_DENY] = 'security_settings.guest_mode.deny';
-    labels[this.labels.SECURITY_RESTRICT_GUEST_MODE_READONLY] = 'security_settings.guest_mode.readonly';
-
-    return labels;
-  }
-
-  getRegistrationModeLabels() {
-    const labels = {};
-    labels[this.labels.SECURITY_REGISTRATION_MODE_OPEN] = 'security_settings.registration_mode.open';
-    labels[this.labels.SECURITY_REGISTRATION_MODE_RESTRICTED] = 'security_settings.registration_mode.restricted';
-    labels[this.labels.SECURITY_REGISTRATION_MODE_CLOSED] = 'security_settings.registration_mode.closed';
-
-    return labels;
-  }
-
 }
 
-module.exports = AclService;
+export const aclService = new AclServiceImpl();

+ 23 - 20
apps/app/test/integration/service/config-manager.test.js → apps/app/src/server/service/config-manager.spec.ts

@@ -1,46 +1,49 @@
-import ConfigModel from '~/server/models/config';
+import { mock } from 'vitest-mock-extended';
 
-const { getInstance } = require('../setup-crowi');
+import ConfigModel from '../models/config';
+
+import { configManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
 
 describe('ConfigManager test', () => {
-  let crowi;
-  let configManager;
 
-  beforeEach(async() => {
-    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+  const s2sMessagingServiceMock = mock<S2sMessagingService>();
 
-    crowi = await getInstance();
-    configManager = crowi.configManager;
+  beforeAll(async() => {
+    process.env.CONFIG_PUBSUB_SERVER_TYPE = 'nchan';
+    configManager.setS2sMessagingService(s2sMessagingServiceMock);
   });
 
 
   describe('updateConfigsInTheSameNamespace()', () => {
 
-    beforeEach(async() => {
-      configManager.s2sMessagingService = {};
-    });
-
-    test('invoke publishUpdateMessage()', async() => {
-      ConfigModel.bulkWrite = jest.fn();
-      configManager.loadConfigs = jest.fn();
-      configManager.publishUpdateMessage = jest.fn();
+    test.concurrent('invoke publishUpdateMessage()', async() => {
+      // setup
+      ConfigModel.bulkWrite = vi.fn();
+      configManager.loadConfigs = vi.fn();
+      configManager.publishUpdateMessage = vi.fn();
 
+      // when
       const dummyConfig = { dummyKey: 'dummyValue' };
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig);
 
+      // then
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).toHaveBeenCalledTimes(1);
     });
 
-    test('does not invoke publishUpdateMessage()', async() => {
-      ConfigModel.bulkWrite = jest.fn();
-      configManager.loadConfigs = jest.fn();
-      configManager.publishUpdateMessage = jest.fn();
+    test.concurrent('does not invoke publishUpdateMessage()', async() => {
+      // setup
+      ConfigModel.bulkWrite = vi.fn();
+      configManager.loadConfigs = vi.fn();
+      configManager.publishUpdateMessage = vi.fn();
 
+      // when
       const dummyConfig = { dummyKey: 'dummyValue' };
       await configManager.updateConfigsInTheSameNamespace('dummyNs', dummyConfig, true);
 
+      // then
       expect(ConfigModel.bulkWrite).toHaveBeenCalledTimes(1);
       expect(configManager.loadConfigs).toHaveBeenCalledTimes(1);
       expect(configManager.publishUpdateMessage).not.toHaveBeenCalled();

+ 75 - 85
apps/app/src/server/service/config-manager.ts

@@ -6,8 +6,8 @@ import ConfigModel from '../models/config';
 import S2sMessage from '../models/vo/s2s-message';
 
 import ConfigLoader, { ConfigObject } from './config-loader';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:ConfigManager');
 
@@ -36,7 +36,17 @@ const KEYS_FOR_GCS_USE_ONLY_ENV_OPTION = [
   'gcs:uploadNamespace',
 ];
 
-export default class ConfigManager implements S2sMessageHandlable {
+
+export interface ConfigManager {
+  loadConfigs(): Promise<void>,
+  getConfig(namespace: string, key: string): any,
+  getConfigFromDB(namespace: string, key: string): any,
+  getConfigFromEnvVars(namespace: string, key: string): any,
+  updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage?: boolean): Promise<void>
+  removeConfigsInTheSameNamespace(namespace: string, configKeys: string[], withoutPublishingS2sMessage?: boolean): Promise<void>
+}
+
+class ConfigManagerImpl implements ConfigManager, S2sMessageHandlable {
 
   private configLoader: ConfigLoader = new ConfigLoader();
 
@@ -48,6 +58,16 @@ export default class ConfigManager implements S2sMessageHandlable {
 
   private lastLoadedAt?: Date;
 
+  private get isInitialized() {
+    return this.lastLoadedAt != null;
+  }
+
+  private validateInitialized() {
+    if (!this.isInitialized) {
+      throw new Error('The config data has not loaded yet.');
+    }
+  }
+
   /**
    * load configs from the database and the environment variables
    */
@@ -61,68 +81,10 @@ export default class ConfigManager implements S2sMessageHandlable {
     this.lastLoadedAt = new Date();
   }
 
-  /**
-   * Set S2sMessagingServiceDelegator instance
-   * @param s2sMessagingService
-   */
-  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
-    this.s2sMessagingService = s2sMessagingService;
-  }
-
-  /**
-   * get a config specified by namespace & key
-   *
-   * Basically, this searches a specified config from configs loaded from the database at first
-   * and then from configs loaded from the environment variables.
-   *
-   * In some case, this search method changes.
-   *
-   * the followings are the meanings of each special return value.
-   * - null:      a specified config is not set.
-   * - undefined: a specified config does not exist.
-   */
-  getConfig(namespace, key) {
-    let value;
-
-    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
-      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
-    }
-    else {
-      value = this.defaultSearch(namespace, key);
-    }
-
-    logger.debug(key, value);
-    return value;
-  }
-
-  /**
-   * get a config specified by namespace and regular expression
-   */
-  getConfigByRegExp(namespace, regexp) {
-    const result = {};
-
-    for (const key of this.configKeys) {
-      if (regexp.test(key)) {
-        result[key] = this.getConfig(namespace, key);
-      }
-    }
-
-    return result;
-  }
-
-  /**
-   * get a config specified by namespace and prefix
-   */
-  getConfigByPrefix(namespace, prefix) {
-    const regexp = new RegExp(`^${prefix}`);
-
-    return this.getConfigByRegExp(namespace, regexp);
-  }
-
   /**
    * generate an array of config keys from this.configObject
    */
-  getConfigKeys() {
+  private getConfigKeys() {
     // type: fromDB, fromEnvVars
     const types = Object.keys(this.configObject);
     let namespaces: string[] = [];
@@ -152,16 +114,46 @@ export default class ConfigManager implements S2sMessageHandlable {
     return keys;
   }
 
-  reloadConfigKeys() {
+  private reloadConfigKeys() {
     this.configKeys = this.getConfigKeys();
   }
 
+
+  /**
+   * get a config specified by namespace & key
+   *
+   * Basically, this searches a specified config from configs loaded from the database at first
+   * and then from configs loaded from the environment variables.
+   *
+   * In some case, this search method changes.
+   *
+   * the followings are the meanings of each special return value.
+   * - null:      a specified config is not set.
+   * - undefined: a specified config does not exist.
+   */
+  getConfig(namespace, key) {
+    this.validateInitialized();
+
+    let value;
+
+    if (this.shouldSearchedFromEnvVarsOnly(namespace, key)) {
+      value = this.searchOnlyFromEnvVarConfigs(namespace, key);
+    }
+    else {
+      value = this.defaultSearch(namespace, key);
+    }
+
+    logger.debug(key, value);
+    return value;
+  }
+
   /**
    * get a config specified by namespace & key from configs loaded from the database
    *
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
   getConfigFromDB(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromDBConfigs(namespace, key);
   }
 
@@ -171,6 +163,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * **Do not use this unless absolutely necessary. Use getConfig instead.**
    */
   getConfigFromEnvVars(namespace, key) {
+    this.validateInitialized();
     return this.searchOnlyFromEnvVarConfigs(namespace, key);
   }
 
@@ -192,7 +185,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    *  );
    * ```
    */
-  async updateConfigsInTheSameNamespace(namespace, configs, withoutPublishingS2sMessage?) {
+  async updateConfigsInTheSameNamespace(namespace: string, configs, withoutPublishingS2sMessage = false): Promise<void> {
     const queries: any[] = [];
     for (const key of Object.keys(configs)) {
       queries.push({
@@ -235,7 +228,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * return whether the specified namespace/key should be retrieved only from env vars
    */
-  shouldSearchedFromEnvVarsOnly(namespace, key) {
+  private shouldSearchedFromEnvVarsOnly(namespace, key) {
     return (namespace === 'crowi' && (
       // siteUrl
       (
@@ -273,7 +266,7 @@ export default class ConfigManager implements S2sMessageHandlable {
    * search a specified config from configs loaded from the database at first
    * and then from configs loaded from the environment variables
    */
-  defaultSearch(namespace, key) {
+  private defaultSearch(namespace, key) {
     // does not exist neither in db nor in env vars
     if (!this.configExistsInDB(namespace, key) && !this.configExistsInEnvVars(namespace, key)) {
       logger.debug(`${namespace}.${key} does not exist neither in db nor in env vars`);
@@ -309,7 +302,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * search a specified config from configs loaded from the database
    */
-  searchOnlyFromDBConfigs(namespace, key) {
+  private searchOnlyFromDBConfigs(namespace, key) {
     if (!this.configExistsInDB(namespace, key)) {
       return undefined;
     }
@@ -320,7 +313,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * search a specified config from configs loaded from the environment variables
    */
-  searchOnlyFromEnvVarConfigs(namespace, key) {
+  private searchOnlyFromEnvVarConfigs(namespace, key) {
     if (!this.configExistsInEnvVars(namespace, key)) {
       return undefined;
     }
@@ -331,7 +324,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * check whether a specified config exists in configs loaded from the database
    */
-  configExistsInDB(namespace, key) {
+  private configExistsInDB(namespace, key) {
     if (this.configObject.fromDB[namespace] === undefined) {
       return false;
     }
@@ -342,7 +335,7 @@ export default class ConfigManager implements S2sMessageHandlable {
   /**
    * check whether a specified config exists in configs loaded from the environment variables
    */
-  configExistsInEnvVars(namespace, key) {
+  private configExistsInEnvVars(namespace, key) {
     if (this.configObject.fromEnvVars[namespace] === undefined) {
       return false;
     }
@@ -350,10 +343,18 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.configObject.fromEnvVars[namespace][key] !== undefined;
   }
 
-  convertInsertValue(value) {
+  private convertInsertValue(value) {
     return JSON.stringify(value === '' ? null : value);
   }
 
+  /**
+   * Set S2sMessagingServiceDelegator instance
+   * @param s2sMessagingService
+   */
+  setS2sMessagingService(s2sMessagingService: S2sMessagingService): void {
+    this.s2sMessagingService = s2sMessagingService;
+  }
+
   async publishUpdateMessage() {
     const s2sMessage = new S2sMessage('configUpdated', { updatedAt: new Date() });
 
@@ -385,18 +386,7 @@ export default class ConfigManager implements S2sMessageHandlable {
     return this.loadConfigs();
   }
 
-  /**
-   * Returns file upload total limit in bytes.
-   * Reference to previous implementation is
-   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
-   * @returns file upload total limit in bytes
-   */
-  getFileUploadTotalLimit(): number {
-    const fileUploadTotalLimit = this.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
-      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
-      ? this.getConfig('crowi', 'gridfs:totalLimit') ?? this.getConfig('crowi', 'app:fileUploadTotalLimit')
-      : this.getConfig('crowi', 'app:fileUploadTotalLimit');
-    return fileUploadTotalLimit;
-  }
-
 }
+
+// export the singleton instance
+export const configManager = new ConfigManagerImpl();

+ 4 - 7
apps/app/src/server/service/customize.ts

@@ -3,13 +3,13 @@ import { ColorScheme, DevidedPagePath, getForcedColorScheme } from '@growi/core'
 import { DefaultThemeMetadata, PresetThemesMetadatas } from '@growi/preset-themes';
 import uglifycss from 'uglifycss';
 
+import { growiPluginService } from '~/features/growi-plugin/services';
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
 
-import ConfigManager from './config-manager';
-import type { IPluginService } from './plugin';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { ConfigManager } from './config-manager';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 
 const logger = loggerFactory('growi:service:CustomizeService');
@@ -28,8 +28,6 @@ class CustomizeService implements S2sMessageHandlable {
 
   xssService: any;
 
-  pluginService: IPluginService;
-
   lastLoadedAt?: Date;
 
   customCss?: string;
@@ -47,7 +45,6 @@ class CustomizeService implements S2sMessageHandlable {
     this.s2sMessagingService = crowi.s2sMessagingService;
     this.appService = crowi.appService;
     this.xssService = crowi.xssService;
-    this.pluginService = crowi.pluginService;
   }
 
   /**
@@ -155,7 +152,7 @@ class CustomizeService implements S2sMessageHandlable {
 
     this.theme = theme;
 
-    const resultForThemePlugin = await this.pluginService.findThemePlugin(theme);
+    const resultForThemePlugin = await growiPluginService.findThemePlugin(theme);
 
     if (resultForThemePlugin != null) {
       this.forcedColorScheme = getForcedColorScheme(resultForThemePlugin.themeMetadata.schemeType);

+ 4 - 3
apps/app/src/server/service/file-uploader-switch.ts

@@ -1,9 +1,10 @@
 import loggerFactory from '~/utils/logger';
 
 import S2sMessage from '../models/vo/s2s-message';
-import ConfigManager from './config-manager';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+
+import type { ConfigManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 
 const logger = loggerFactory('growi:service:FileUploaderSwitch');
 

+ 47 - 12
apps/app/src/server/service/file-uploader/aws.ts

@@ -13,6 +13,10 @@ import urljoin from 'url-join';
 
 import loggerFactory from '~/utils/logger';
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
@@ -37,10 +41,41 @@ type AwsConfig = {
   forcePathStyle?: boolean
 }
 
+// TODO: rewrite this module to be a type-safe implementation
+class AwsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
 module.exports = (crowi) => {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AwsFileUploader(crowi);
 
   const getAwsConfig = (): AwsConfig => {
     return {
@@ -100,7 +135,7 @@ module.exports = (crowi) => {
     return !configManager.getConfig('crowi', 'aws:referenceFileWithRelayMode');
   };
 
-  lib.respond = async function(res, attachment) {
+  (lib as any).respond = async function(res, attachment) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -134,12 +169,12 @@ module.exports = (crowi) => {
 
   };
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     const filePath = getFilePathOnStorage(attachment);
-    return lib.deleteFileByFilePath(filePath);
+    return (lib as any).deleteFileByFilePath(filePath);
   };
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -157,7 +192,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectsCommand(totalParams));
   };
 
-  lib.deleteFileByFilePath = async function(filePath) {
+  (lib as any).deleteFileByFilePath = async function(filePath) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -179,7 +214,7 @@ module.exports = (crowi) => {
     return s3.send(new DeleteObjectCommand(params));
   };
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     if (!lib.getIsUploadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -216,7 +251,7 @@ module.exports = (crowi) => {
     return s3.send(new PutObjectCommand(params));
   };
 
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
     }
@@ -249,7 +284,7 @@ module.exports = (crowi) => {
     return stream;
   };
 
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
     const totalLimit = configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
@@ -258,7 +293,7 @@ module.exports = (crowi) => {
   /**
    * List files in storage
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     if (!lib.getIsReadable()) {
       throw new Error('AWS is not configured.');
     }

+ 151 - 0
apps/app/src/server/service/file-uploader/file-uploader.ts

@@ -0,0 +1,151 @@
+import { randomUUID } from 'crypto';
+
+import loggerFactory from '~/utils/logger';
+
+import { configManager } from '../config-manager';
+
+const logger = loggerFactory('growi:service:fileUploader');
+
+
+export type SaveFileParam = {
+  filePath: string,
+  contentType: string,
+  data,
+}
+
+export type CheckLimitResult = {
+  isUploadable: boolean,
+  errorMessage?: string,
+}
+
+export interface FileUploader {
+  getIsUploadable(): boolean,
+  isWritable(): Promise<boolean>,
+  getIsReadable(): boolean,
+  isValidUploadSettings(): boolean,
+  getFileUploadEnabled(): boolean,
+  saveFile(param: SaveFileParam): Promise<any>,
+  deleteFiles(): void,
+  getFileUploadTotalLimit(): number,
+  getTotalFileSize(): Promise<number>,
+  doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult>,
+  canRespond(): boolean
+  respond(res: Response, attachment: Response): void,
+}
+
+export abstract class AbstractFileUploader implements FileUploader {
+
+  private crowi;
+
+  constructor(crowi) {
+    this.crowi = crowi;
+  }
+
+  getIsUploadable() {
+    return !configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
+  }
+
+  /**
+   * Returns whether write opration to the storage is permitted
+   * @returns Whether write opration to the storage is permitted
+   */
+  async isWritable() {
+    const filePath = `${randomUUID()}.growi`;
+    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
+
+    try {
+      await this.saveFile({
+        filePath,
+        contentType: 'text/plain',
+        data,
+      });
+      // TODO: delete tmp file in background
+
+      return true;
+    }
+    catch (err) {
+      logger.error(err);
+      return false;
+    }
+  }
+
+  // File reading is possible even if uploading is disabled
+  getIsReadable() {
+    return this.isValidUploadSettings();
+  }
+
+  abstract isValidUploadSettings(): boolean;
+
+  getFileUploadEnabled() {
+    if (!this.getIsUploadable()) {
+      return false;
+    }
+
+    return !!configManager.getConfig('crowi', 'app:fileUpload');
+  }
+
+  abstract saveFile(param: SaveFileParam);
+
+  abstract deleteFiles();
+
+  /**
+   * Returns file upload total limit in bytes.
+   * Reference to previous implementation is
+   * {@link https://github.com/weseek/growi/blob/798e44f14ad01544c1d75ba83d4dfb321a94aa0b/src/server/service/file-uploader/gridfs.js#L86-L88}
+   * @returns file upload total limit in bytes
+   */
+  getFileUploadTotalLimit() {
+    const fileUploadTotalLimit = configManager.getConfig('crowi', 'app:fileUploadType') === 'mongodb'
+      // Use app:fileUploadTotalLimit if gridfs:totalLimit is null (default for gridfs:totalLimit is null)
+      ? configManager.getConfig('crowi', 'gridfs:totalLimit') ?? configManager.getConfig('crowi', 'app:fileUploadTotalLimit')
+      : configManager.getConfig('crowi', 'app:fileUploadTotalLimit');
+    return fileUploadTotalLimit;
+  }
+
+  /**
+   * Get total file size
+   * @returns Total file size
+   */
+  async getTotalFileSize() {
+    const Attachment = this.crowi.model('Attachment');
+
+    // Get attachment total file size
+    const res = await Attachment.aggregate().group({
+      _id: null,
+      total: { $sum: '$fileSize' },
+    });
+
+    // res is [] if not using
+    return res.length === 0 ? 0 : res[0].total;
+  }
+
+  /**
+   * Check files size limits for all uploaders
+   *
+   */
+  async doCheckLimit(uploadFileSize: number, maxFileSize: number, totalLimit: number): Promise<CheckLimitResult> {
+    if (uploadFileSize > maxFileSize) {
+      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
+    }
+
+    const usingFilesSize = await this.getTotalFileSize();
+    if (usingFilesSize + uploadFileSize > totalLimit) {
+      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
+    }
+
+    return { isUploadable: true };
+  }
+
+  /**
+   * Checks if Uploader can respond to the HTTP request.
+   */
+  canRespond(): boolean {
+    return false;
+  }
+
+  /**
+   * Respond to the HTTP request.
+   */
+  abstract respond(res: Response, attachment: Response): void;
+
+}

+ 3 - 2
apps/app/src/server/service/file-uploader/gcs.js

@@ -1,5 +1,7 @@
 import loggerFactory from '~/utils/logger';
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderAws');
 
 const { Storage } = require('@google-cloud/storage');
@@ -9,9 +11,8 @@ let _instance;
 
 
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
   function getGcsBucket() {
     return configManager.getConfig('crowi', 'gcs:bucket');

+ 49 - 12
apps/app/src/server/service/file-uploader/gridfs.js → apps/app/src/server/service/file-uploader/gridfs.ts

@@ -1,16 +1,53 @@
 import { Readable } from 'stream';
+import util from 'util';
+
+import mongoose from 'mongoose';
 
 import loggerFactory from '~/utils/logger';
 
+import { configManager } from '../config-manager';
+
+import { AbstractFileUploader, type SaveFileParam } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderGridfs');
-const util = require('util');
 
-const mongoose = require('mongoose');
+
+// TODO: rewrite this module to be a type-safe implementation
+class GridfsFileUploader extends AbstractFileUploader {
+
+  /**
+   * @inheritdoc
+   */
+  override isValidUploadSettings(): boolean {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override saveFile(param: SaveFileParam) {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override deleteFiles() {
+    throw new Error('Method not implemented.');
+  }
+
+  /**
+   * @inheritdoc
+   */
+  override respond(res: Response, attachment: Response): void {
+    throw new Error('Method not implemented.');
+  }
+
+}
+
 
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
-  const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new GridfsFileUploader(crowi);
   const COLLECTION_NAME = 'attachmentFiles';
   const CHUNK_COLLECTION_NAME = `${COLLECTION_NAME}.chunks`;
 
@@ -33,7 +70,7 @@ module.exports = function(crowi) {
     return true;
   };
 
-  lib.deleteFile = async function(attachment) {
+  (lib as any).deleteFile = async function(attachment) {
     let filenameValue = attachment.fileName;
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -49,7 +86,7 @@ module.exports = function(crowi) {
     return AttachmentFile.promisifiedUnlink({ _id: attachmentFile._id });
   };
 
-  lib.deleteFiles = async function(attachments) {
+  (lib as any).deleteFiles = async function(attachments) {
     const filenameValues = attachments.map((attachment) => {
       return attachment.fileName;
     });
@@ -87,13 +124,13 @@ module.exports = function(crowi) {
    * - per-file size limit (specified by MAX_FILE_SIZE)
    * - mongodb(gridfs) size limit (specified by MONGO_GRIDFS_TOTAL_LIMIT)
    */
-  lib.checkLimit = async function(uploadFileSize) {
+  (lib as any).checkLimit = async function(uploadFileSize) {
     const maxFileSize = configManager.getConfig('crowi', 'app:maxFileSize');
-    const totalLimit = configManager.getFileUploadTotalLimit();
+    const totalLimit = lib.getFileUploadTotalLimit();
     return lib.doCheckLimit(uploadFileSize, maxFileSize, totalLimit);
   };
 
-  lib.uploadAttachment = async function(fileStream, attachment) {
+  (lib as any).uploadAttachment = async function(fileStream, attachment) {
     logger.debug(`File uploading: fileName=${attachment.fileName}`);
 
     return AttachmentFile.promisifiedWrite(
@@ -125,7 +162,7 @@ module.exports = function(crowi) {
    * @param {Attachment} attachment
    * @return {stream.Readable} readable stream
    */
-  lib.findDeliveryFile = async function(attachment) {
+  (lib as any).findDeliveryFile = async function(attachment) {
     let filenameValue = attachment.fileName;
 
     if (attachment.filePath != null) { // backward compatibility for v3.3.x or below
@@ -145,7 +182,7 @@ module.exports = function(crowi) {
   /**
    * List files in storage
    */
-  lib.listFiles = async function() {
+  (lib as any).listFiles = async function() {
     const attachmentFiles = await AttachmentFile.find();
     return attachmentFiles.map(({ filename: name, length: size }) => ({
       name, size,

+ 3 - 2
apps/app/src/server/service/file-uploader/local.js

@@ -2,6 +2,8 @@ import { Readable } from 'stream';
 
 import loggerFactory from '~/utils/logger';
 
+import { AbstractFileUploader } from './file-uploader';
+
 const logger = loggerFactory('growi:service:fileUploaderLocal');
 
 const fs = require('fs');
@@ -13,9 +15,8 @@ const streamToPromise = require('stream-to-promise');
 const urljoin = require('url-join');
 
 module.exports = function(crowi) {
-  const Uploader = require('./uploader');
   const { configManager } = crowi;
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
   const basePath = path.posix.join(crowi.publicDir, 'uploads');
 
   function getFilePathOnStorage(attachment) {

+ 3 - 2
apps/app/src/server/service/file-uploader/none.js

@@ -1,9 +1,10 @@
 // crowi-fileupload-none
 
+const { AbstractFileUploader } = require('./file-uploader');
+
 module.exports = function(crowi) {
   const debug = require('debug')('growi:service:fileUploaderNone');
-  const Uploader = require('./uploader');
-  const lib = new Uploader(crowi);
+  const lib = new AbstractFileUploader(crowi);
 
   lib.getIsUploadable = function() {
     return false;

+ 0 - 123
apps/app/src/server/service/file-uploader/uploader.js

@@ -1,123 +0,0 @@
-import { randomUUID } from 'crypto';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:service:fileUploader');
-
-// file uploader virtual class
-// 各アップローダーで共通のメソッドはここで定義する
-
-class Uploader {
-
-  constructor(crowi) {
-    this.crowi = crowi;
-    this.configManager = crowi.configManager;
-  }
-
-  getIsUploadable() {
-    return !this.configManager.getConfig('crowi', 'app:fileUploadDisabled') && this.isValidUploadSettings();
-  }
-
-  /**
-   * Returns whether write opration to the storage is permitted
-   * @returns Whether write opration to the storage is permitted
-   */
-  async isWritable() {
-    const filePath = `${randomUUID()}.growi`;
-    const data = 'This file was created during g2g transfer to check write permission. You can safely remove this file.';
-
-    try {
-      await this.saveFile({
-        filePath,
-        contentType: 'text/plain',
-        data,
-      });
-      // TODO: delete tmp file in background
-
-      return true;
-    }
-    catch (err) {
-      logger.error(err);
-      return false;
-    }
-  }
-
-  // File reading is possible even if uploading is disabled
-  getIsReadable() {
-    return this.isValidUploadSettings();
-  }
-
-  isValidUploadSettings() {
-    throw new Error('Implement this');
-  }
-
-  getFileUploadEnabled() {
-    if (!this.getIsUploadable()) {
-      return false;
-    }
-
-    return !!this.configManager.getConfig('crowi', 'app:fileUpload');
-  }
-
-  deleteFiles() {
-    throw new Error('Implemnt this');
-  }
-
-  /**
-   * Get total file size
-   * @returns Total file size
-   */
-  async getTotalFileSize() {
-    const Attachment = this.crowi.model('Attachment');
-
-    // Get attachment total file size
-    const res = await Attachment.aggregate().group({
-      _id: null,
-      total: { $sum: '$fileSize' },
-    });
-
-    // res is [] if not using
-    return res.length === 0 ? 0 : res[0].total;
-  }
-
-  /**
-   * Check files size limits for all uploaders
-   *
-   * @param {*} uploadFileSize
-   * @param {*} maxFileSize
-   * @param {*} totalLimit
-   * @returns
-   * @memberof Uploader
-   */
-  async doCheckLimit(uploadFileSize, maxFileSize, totalLimit) {
-    if (uploadFileSize > maxFileSize) {
-      return { isUploadable: false, errorMessage: 'File size exceeds the size limit per file' };
-    }
-
-    const usingFilesSize = await this.getTotalFileSize();
-    if (usingFilesSize + uploadFileSize > totalLimit) {
-      return { isUploadable: false, errorMessage: 'Uploading files reaches limit' };
-    }
-
-    return { isUploadable: true };
-  }
-
-  /**
-   * Checks if Uploader can respond to the HTTP request.
-   */
-  canRespond() {
-    return false;
-  }
-
-  /**
-   * Respond to the HTTP request.
-   * @param {Response} res
-   * @param {Response} attachment
-   */
-  respond(res, attachment) {
-    throw new Error('Implement this');
-  }
-
-}
-
-module.exports = Uploader;

+ 1 - 1
apps/app/src/server/service/g2g-transfer.ts

@@ -535,7 +535,7 @@ export class G2GTransferReceiverService implements Receiver {
     const { version, configManager, fileUploadService } = this.crowi;
     const userUpperLimit = configManager.getConfig('crowi', 'security:userUpperLimit');
     const fileUploadDisabled = configManager.getConfig('crowi', 'app:fileUploadDisabled');
-    const fileUploadTotalLimit = configManager.getFileUploadTotalLimit();
+    const fileUploadTotalLimit = fileUploadService.getFileUploadTotalLimit();
     const isWritable = await fileUploadService.isWritable();
 
     const attachmentInfo = {

+ 3 - 3
apps/app/src/server/service/installer.ts

@@ -6,13 +6,13 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import fs from 'graceful-fs';
 import mongoose from 'mongoose';
 
-import { IPage } from '~/interfaces/page';
-import { IUser } from '~/interfaces/user';
+import type { IPage } from '~/interfaces/page';
+import type { IUser } from '~/interfaces/user';
 import loggerFactory from '~/utils/logger';
 
 import { generateConfigsForInstalling } from '../models/config';
 
-import ConfigManager from './config-manager';
+import type { ConfigManager } from './config-manager';
 import SearchService from './search';
 
 const logger = loggerFactory('growi:service:installer');

+ 8 - 7
apps/app/src/server/service/slack-integration.ts

@@ -5,20 +5,21 @@ import { markdownSectionBlock } from '@growi/slack/dist/utils/block-kit-builder'
 import { InteractionPayloadAccessor } from '@growi/slack/dist/utils/interaction-payload-accessor';
 import type { RespondUtil } from '@growi/slack/dist/utils/respond-util-factory';
 import { generateWebClient } from '@growi/slack/dist/utils/webclient-factory';
-import { ChatPostMessageArguments, WebClient } from '@slack/web-api';
-import { IncomingWebhookSendArguments } from '@slack/webhook';
+import { type ChatPostMessageArguments, WebClient } from '@slack/web-api';
+import type { IncomingWebhookSendArguments } from '@slack/webhook';
 import mongoose from 'mongoose';
 
 
 import loggerFactory from '~/utils/logger';
 
-import { EventActionsPermission } from '../interfaces/slack-integration/events';
+import type { EventActionsPermission } from '../interfaces/slack-integration/events';
 import S2sMessage from '../models/vo/s2s-message';
 import { SlackCommandHandlerError } from '../models/vo/slack-command-handler-error';
+import { slackLegacyUtilFactory } from '../util/slack-legacy';
 
-import ConfigManager from './config-manager';
-import { S2sMessagingService } from './s2s-messaging/base';
-import { S2sMessageHandlable } from './s2s-messaging/handlable';
+import type { ConfigManager } from './config-manager';
+import type { S2sMessagingService } from './s2s-messaging/base';
+import type { S2sMessageHandlable } from './s2s-messaging/handlable';
 import { LinkSharedEventHandler } from './slack-event-handler/link-shared';
 
 const logger = loggerFactory('growi:service:SlackBotService');
@@ -230,7 +231,7 @@ export class SlackIntegrationService implements S2sMessageHandlable {
   }
 
   private async postMessageWithLegacyUtil(messageArgs: ChatPostMessageArguments | IncomingWebhookSendArguments): Promise<void> {
-    const slackLegacyUtil = require('../util/slack-legacy')(this.crowi);
+    const slackLegacyUtil = slackLegacyUtilFactory(this.configManager);
 
     try {
       await slackLegacyUtil.postMessage(messageArgs);

+ 1 - 5
apps/app/src/server/util/mongoose-utils.ts

@@ -1,16 +1,12 @@
 import mongoose from 'mongoose';
 import type {
-  Model, Document, Schema, ConnectOptions,
+  Model, Document, ConnectOptions,
 } from 'mongoose';
 
 // suppress DeprecationWarning: current Server Discovery and Monitoring engine is deprecated, and will be removed in a future version
 type ConnectionOptionsExtend = {
   useUnifiedTopology: boolean
 }
-// No More Deprecation Warning Options
-// Removed useFindAndModify and useCreateIndex option
-// see: https://mongoosejs.com/docs/migrating_to_6.html#no-more-deprecation-warning-options
-export const initMongooseGlobalSettings = (): void => {};
 
 export const getMongoUri = (): string => {
   const { env } = process;

+ 0 - 64
apps/app/src/server/util/slack-legacy.js

@@ -1,64 +0,0 @@
-import { IncomingWebhook } from '@slack/webhook';
-import { WebClient } from '@slack/web-api';
-
-import loggerFactory from '~/utils/logger';
-
-const logger = loggerFactory('growi:util:slack-legacy');
-
-module.exports = function(crowi) {
-
-  const { configManager } = crowi;
-
-  const slackUtilLegacy = {};
-
-  const postWithIwh = async(messageObj) => {
-    const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
-    try {
-      await webhook.send(messageObj);
-    }
-    catch (error) {
-      logger.debug('Post error', error);
-      logger.debug('Sent data to slack is:', messageObj);
-      throw error;
-    }
-  };
-
-  const postWithWebApi = async(messageObj) => {
-    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
-    try {
-      await client.chat.postMessage(messageObj);
-    }
-    catch (error) {
-      logger.debug('Post error', error);
-      logger.debug('Sent data to slack is:', messageObj);
-      throw error;
-    }
-  };
-
-  slackUtilLegacy.postMessage = async(messageObj) => {
-    // when incoming Webhooks is prioritized
-    if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
-      if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        logger.debug('posting message with IncomingWebhook');
-        return postWithIwh(messageObj);
-      }
-      if (configManager.getConfig('notification', 'slack:token')) {
-        logger.debug('posting message with Web API');
-        return postWithWebApi(messageObj);
-      }
-    }
-    // else
-    else {
-      if (configManager.getConfig('notification', 'slack:token')) {
-        logger.debug('posting message with Web API');
-        return postWithWebApi(messageObj);
-      }
-      if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
-        logger.debug('posting message with IncomingWebhook');
-        return postWithIwh(messageObj);
-      }
-    }
-  };
-
-  return slackUtilLegacy;
-};

+ 67 - 0
apps/app/src/server/util/slack-legacy.ts

@@ -0,0 +1,67 @@
+import type { ChatPostMessageArguments } from '@slack/web-api';
+import { WebClient } from '@slack/web-api';
+import { IncomingWebhook, type IncomingWebhookSendArguments } from '@slack/webhook';
+
+import loggerFactory from '~/utils/logger';
+
+const logger = loggerFactory('growi:util:slack-legacy');
+
+
+interface SlackLegacyUtil {
+  postMessage(messageObj: IncomingWebhookSendArguments | ChatPostMessageArguments): Promise<void>,
+}
+
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
+export const slackLegacyUtilFactory = (configManager: any): SlackLegacyUtil => {
+
+  const postWithIwh = async(messageObj: IncomingWebhookSendArguments) => {
+    const webhook = new IncomingWebhook(configManager.getConfig('notification', 'slack:incomingWebhookUrl'));
+    try {
+      await webhook.send(messageObj);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
+      throw error;
+    }
+  };
+
+  const postWithWebApi = async(messageObj?: ChatPostMessageArguments) => {
+    const client = new WebClient(configManager.getConfig('notification', 'slack:token'));
+    try {
+      await client.chat.postMessage(messageObj);
+    }
+    catch (error) {
+      logger.debug('Post error', error);
+      logger.debug('Sent data to slack is:', messageObj);
+      throw error;
+    }
+  };
+
+  return {
+    postMessage: async(messageObj) => {
+      // when incoming Webhooks is prioritized
+      if (configManager.getConfig('notification', 'slack:isIncomingWebhookPrioritized')) {
+        if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
+          logger.debug('posting message with IncomingWebhook');
+          return postWithIwh(messageObj as IncomingWebhookSendArguments);
+        }
+        if (configManager.getConfig('notification', 'slack:token')) {
+          logger.debug('posting message with Web API');
+          return postWithWebApi(messageObj as ChatPostMessageArguments);
+        }
+      }
+      // else
+      else {
+        if (configManager.getConfig('notification', 'slack:token')) {
+          logger.debug('posting message with Web API');
+          return postWithWebApi(messageObj as ChatPostMessageArguments);
+        }
+        if (configManager.getConfig('notification', 'slack:incomingWebhookUrl')) {
+          logger.debug('posting message with IncomingWebhook');
+          return postWithIwh(messageObj as IncomingWebhookSendArguments);
+        }
+      }
+    },
+  };
+};

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

@@ -1,5 +1,3 @@
-import { describe, test, expect } from 'vitest';
-
 import { type HastNode, select } from 'hast-util-select';
 import parse from 'remark-parse';
 import rehype from 'remark-rehype';

+ 18 - 3
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -1,6 +1,4 @@
 
-import { describe, test, expect } from 'vitest';
-
 import { select, type HastNode } from 'hast-util-select';
 import parse from 'remark-parse';
 import remarkRehype from 'remark-rehype';
@@ -10,6 +8,22 @@ import { relativeLinks } from './relative-links';
 
 describe('relativeLinks', () => {
 
+  test('do nothing when the options does not have pagePath', () => {
+    // setup
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, {});
+
+    // when
+    const mdastTree = processor.parse('[link](/Sandbox)');
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement?.properties?.href).toBe('/Sandbox');
+  });
+
   test.concurrent.each`
     originalHref
       ${'http://example.com/Sandbox'}
@@ -17,10 +31,11 @@ describe('relativeLinks', () => {
     `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
 
     // setup
+    const pagePath = '/foo/bar/baz';
     const processor = unified()
       .use(parse)
       .use(remarkRehype)
-      .use(relativeLinks, {});
+      .use(relativeLinks, { pagePath });
 
     // when
     const mdastTree = processor.parse(`[link](${originalHref})`);

+ 4 - 3
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,7 +1,10 @@
+import assert from 'assert';
+
 import { selectAll, type HastNode, type Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
 import type { Plugin } from 'unified';
 
+
 export type IAnchorsSelector = (node: HastNode) => Element[];
 export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
@@ -43,9 +46,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
     const anchors = anchorsSelector(tree as HastNode);
 
     anchors.forEach((anchor) => {
-      if (anchor.properties == null) {
-        return;
-      }
+      assert(anchor.properties != null);
 
       const href = anchor.properties.href;
       if (href == null || typeof href !== 'string' || isAbsolute(href) || isAnchorLink(href)) {

+ 0 - 2
apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts

@@ -1,5 +1,3 @@
-import { describe, test, expect } from 'vitest';
-
 import parse from 'remark-parse';
 import { unified } from 'unified';
 import { visit } from 'unist-util-visit';

+ 1 - 0
apps/app/src/stores/bookmark.ts

@@ -14,6 +14,7 @@ export const useSWRBookmarkInfo = (pageId: string | null | undefined): SWRRespon
         sumOfBookmarks: response.data.sumOfBookmarks,
         isBookmarked: response.data.isBookmarked,
         bookmarkedUsers: response.data.bookmarkedUsers,
+        pageId: response.data.pageId,
       };
     }),
   );

+ 9 - 5
apps/app/src/stores/modal.tsx

@@ -627,13 +627,17 @@ export const useBookmarkFolderDeleteModal = (status?: DeleteBookmarkFolderModalS
 /*
  * TemplateModal
  */
-type TemplateModalStatus = {
+
+type TemplateSelectedCallback = (templateText: string) => void;
+type TemplateModalOptions = {
+  onSubmit?: TemplateSelectedCallback,
+}
+type TemplateModalStatus = TemplateModalOptions & {
   isOpened: boolean,
-  onSubmit?: (templateText: string) => void
 }
 
 type TemplateModalUtils = {
-  open(onSubmit: (templateText: string) => void): void,
+  open(opts: TemplateModalOptions): void,
   close(): void,
 }
 
@@ -643,8 +647,8 @@ export const useTemplateModal = (): SWRResponse<TemplateModalStatus, Error> & Te
   const swrResponse = useStaticSWR<TemplateModalStatus, Error>('templateModal', undefined, { fallbackData: initialStatus });
 
   return Object.assign(swrResponse, {
-    open: (onSubmit: (templateText: string) => void) => {
-      swrResponse.mutate({ isOpened: true, onSubmit });
+    open: (opts: TemplateModalOptions) => {
+      swrResponse.mutate({ isOpened: true, onSubmit: opts.onSubmit });
     },
     close: () => {
       swrResponse.mutate({ isOpened: false });

+ 1 - 1
apps/app/src/stores/renderer.tsx

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
 import type { HtmlElementNode } from 'rehype-toc';
 import useSWR, { type SWRResponse } from 'swr';
 
-import { getGrowiFacade } from '~/features/activate-plugin';
+import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
 import type { RendererOptions } from '~/interfaces/renderer-options';
 
 

+ 103 - 23
apps/app/src/stores/template.tsx

@@ -1,49 +1,129 @@
-import { ITemplate } from '@growi/core';
-import useSWR, { SWRResponse } from 'swr';
+import type { ITemplate } from '@growi/core';
+import useSWR, { type SWRResponse } from 'swr';
 
-import { getGrowiFacade } from '~/features/activate-plugin';
+import { getGrowiFacade } from '~/features/growi-plugin/utils/growi-facade-utils.client';
 
 const presetTemplates: ITemplate[] = [
   // preset 1
   {
     id: '__preset1__',
-    name: '[Preset] WESEEK Inner Wiki Style',
-    markdown: `# 関連ページ
+    name: '日報',
+    markdown: `# {{yyyy}}/{{MM}}/{{dd}} 日報
+
+## 今日の目標
+- 目標1
+    - 〇〇の完了
+- 目標2
+    - 〇〇を〇件達成
+
+
+## 内容
+- 10:00 ~ 10:20 今日のタスク確認
+- 10:20 ~ 11:00 全体会議
+
 
-$lsx()
+## 進捗
+- 目標1
+    - 完了
+- 目標2
+    - 〇〇件達成
 
-# `,
+
+## メモ
+- 改善できることの振り返り
+
+
+## 翌営業日の目標
+- 目標1
+    - 〇〇の完了
+- 目標2
+    - 〇〇を〇件達成
+`,
   },
 
   // preset 2
   {
     id: '__preset2__',
-    name: '[Preset] Qiita Style',
-    markdown: `# <会議体名>
+    name: '議事録',
+    markdown: `# {{{title}}}{{^title}}<会議名>{{/title}}
+
 ## 日時
-yyyy/mm/dd hh:mm〜hh:mm
+{{yyyy}}/{{MM}}/{{dd}} {{HH}}:{{mm}}〜hh:mm
 
-## 場所
 
-## 出席
+## 参加
 -
 
 ## 議題
-1. [議題1](#link)
+1.
 2.
-3.
 
-## 議事内容
-### <a name="link"></a>議題1
 
-## 決定事項
-- 決定事項1
+## 1.
+### 内容
+
+
+### 決定事項
+
+
+### Next Action
+
+
+## 2.
+### 内容
+
+
+### 決定事項
+
+
+### Next Action
+
 
-## アクション事項
-- [ ] アクション
+## 次回会議
+- 会議内容
+- 会議時間
+    - {{yyyy}}/{{MM}}/dd
+`,
+  },
+
+  // preset 3
+  {
+    id: '__preset3__',
+    name: '企画書',
+    markdown: `# {{{title}}}{{^title}}<企画タイトル>{{/title}}
+
+## 目的
+
+
+## 現状の課題
+
+
+## 概要
+#### 企画の内容
+
+#### スケジュール
+
+
+## 効果
+#### メリット
+
+#### 数値目標
+
+
+## 参考資料
+
+`,
+  },
+
+  // preset 4
+  {
+    id: '__preset4__',
+    name: '関連ページの一覧表示',
+    markdown: `# 関連ページ
 
-## 次回
-yyyy/mm/dd (予定、時間は追って連絡)`,
+## 子ページ一覧
+$lsx(depth=1)
+`,
   },
 ];
 
@@ -52,7 +132,7 @@ export const useTemplates = (): SWRResponse<ITemplate[], Error> => {
     'templates',
     () => [
       ...presetTemplates,
-      ...Object.values(getGrowiFacade().customTemplates ?? {}),
+      ...Object.values<ITemplate>(getGrowiFacade().customTemplates ?? {}),
     ],
     {
       fallbackData: presetTemplates,

+ 3 - 2
apps/app/test/unit/utils/page-delete-config.test.ts → apps/app/src/utils/page-delete-config.test.ts

@@ -1,5 +1,6 @@
-import { PageDeleteConfigValue } from '../../../src/interfaces/page-delete-config';
-import { validateDeleteConfigs } from '../../../src/utils/page-delete-config';
+import { PageDeleteConfigValue } from '../interfaces/page-delete-config';
+
+import { validateDeleteConfigs } from './page-delete-config';
 
 describe('validateDeleteConfigs utility function', () => {
   test('Should validate delete configs', () => {

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