Sfoglia il codice sorgente

Merge pull request #4116 from weseek/support/import-plugins

Support/import plugins
Yuki Takei 4 anni fa
parent
commit
015773047c
100 ha cambiato i file con 1407 aggiunte e 169 eliminazioni
  1. 1 0
      .devcontainer/devcontainer.json
  2. 0 1
      .devcontainer/docker-compose.yml
  3. 4 0
      .eslintrc.js
  4. 2 2
      .github/workflows/ci-slackbot-proxy.yml
  5. 12 9
      .github/workflows/ci.yml
  6. 4 0
      .markdownlint.yml
  7. 20 0
      .vscode/launch.json
  8. 21 3
      package.json
  9. 2 2
      packages/app/.env.development
  10. 1 1
      packages/app/.env.production
  11. 1 0
      packages/app/.eslintignore
  12. 1 1
      packages/app/.gitignore
  13. 22 14
      packages/app/bin/generate-plugin-definitions-source.ts
  14. 2 0
      packages/app/config/ci/.env.local.for-ci
  15. 1 0
      packages/app/jest.config.js
  16. 3 14
      packages/app/package.json
  17. 4 2
      packages/app/src/client/plugin.js
  18. 1 1
      packages/app/src/client/services/AdminGitHubSecurityContainer.js
  19. 1 1
      packages/app/src/client/services/AdminGoogleSecurityContainer.js
  20. 1 1
      packages/app/src/client/services/AdminOidcSecurityContainer.js
  21. 1 1
      packages/app/src/client/services/AdminSamlSecurityContainer.js
  22. 1 1
      packages/app/src/client/services/AdminTwitterSecurityContainer.js
  23. 3 1
      packages/app/src/client/services/PageContainer.js
  24. 1 1
      packages/app/src/components/Admin/Common/AdminNavigation.jsx
  25. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx
  26. 1 1
      packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx
  27. 1 1
      packages/app/src/components/Admin/Users/UserTable.jsx
  28. 3 1
      packages/app/src/components/ComparePathsTable.jsx
  29. 3 1
      packages/app/src/components/ContentLinkButtons.jsx
  30. 1 1
      packages/app/src/components/CreateTemplateModal.jsx
  31. 3 1
      packages/app/src/components/DuplicatedPathsTable.jsx
  32. 4 2
      packages/app/src/components/Navbar/AuthorInfo.jsx
  33. 1 1
      packages/app/src/components/Navbar/GrowiSubNavigation.jsx
  34. 1 1
      packages/app/src/components/Navbar/PersonalDropdown.jsx
  35. 3 1
      packages/app/src/components/Page/CopyDropdown.jsx
  36. 3 1
      packages/app/src/components/Page/PageManagement.jsx
  37. 1 1
      packages/app/src/components/Page/TrashPageAlert.jsx
  38. 1 1
      packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx
  39. 1 1
      packages/app/src/components/PageAttachment/PageAttachmentList.jsx
  40. 1 1
      packages/app/src/components/PageComment/Comment.jsx
  41. 1 1
      packages/app/src/components/PageComment/CommentEditor.jsx
  42. 1 1
      packages/app/src/components/PageComment/DeleteCommentModal.jsx
  43. 6 4
      packages/app/src/components/PageCreateModal.jsx
  44. 1 1
      packages/app/src/components/PageHistory/Revision.jsx
  45. 2 3
      packages/app/src/components/PageList/Page.jsx
  46. 0 24
      packages/app/src/components/PageList/PagePath.jsx
  47. 1 1
      packages/app/src/components/PagePathAutoComplete.jsx
  48. 3 1
      packages/app/src/components/RevisionComparer/RevisionComparer.jsx
  49. 2 3
      packages/app/src/components/SearchTypeahead.jsx
  50. 2 2
      packages/app/src/components/Sidebar/RecentChanges.jsx
  51. 1 1
      packages/app/src/components/User/UserInfo.jsx
  52. 1 1
      packages/app/src/components/User/UserPictureList.jsx
  53. 1 1
      packages/app/src/migrations/20191126173016-adjust-pages-path.js
  54. 3 6
      packages/app/src/models/linked-page-path.js
  55. 1 1
      packages/app/src/server/crowi/index.js
  56. 5 3
      packages/app/src/server/models/page.js
  57. 38 0
      packages/app/src/server/plugins/plugin-utils-v4.ts
  58. 15 7
      packages/app/src/server/plugins/plugin-utils.js
  59. 16 11
      packages/app/src/server/plugins/plugin.service.js
  60. 3 2
      packages/app/src/server/routes/apiv3/page.js
  61. 7 2
      packages/app/src/server/routes/apiv3/pages.js
  62. 2 1
      packages/app/src/server/routes/page.js
  63. 2 2
      packages/app/src/server/service/app.ts
  64. 1 1
      packages/app/src/server/service/customize.ts
  65. 3 1
      packages/app/src/server/service/global-notification/global-notification-slack.js
  66. 3 1
      packages/app/src/server/service/page.js
  67. 3 1
      packages/app/src/server/util/createGrowiPagesFromImports.js
  68. 2 5
      packages/app/tsconfig.base.json
  69. 0 1
      packages/app/tsconfig.build.client.json
  70. 1 0
      packages/core/.eslintignore
  71. 1 0
      packages/core/.gitignore
  72. 27 0
      packages/core/README.md
  73. 60 0
      packages/core/jest.config.js
  74. 24 0
      packages/core/package.json
  75. 21 0
      packages/core/src/index.js
  76. 2 4
      packages/core/src/models/devided-page-path.js
  77. 11 0
      packages/core/src/plugin/interfaces/plugin-definition-v4.ts
  78. 14 0
      packages/core/src/plugin/model/tag-context.js
  79. 71 0
      packages/core/src/plugin/service/tag-cache-manager.js
  80. 59 0
      packages/core/src/plugin/util/args-parser.js
  81. 88 0
      packages/core/src/plugin/util/custom-tag-utils.js
  82. 70 0
      packages/core/src/plugin/util/option-parser.js
  83. 56 0
      packages/core/src/service/localstorage-manager.js
  84. 125 0
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  85. 42 0
      packages/core/src/test/plugin/util/args-parser.test.js
  86. 72 0
      packages/core/src/test/plugin/util/custom-tag-utils.test.js
  87. 32 0
      packages/core/src/test/plugin/util/option-parser.test.js
  88. 86 0
      packages/core/src/test/service/localstorage-manager.test.js
  89. 21 0
      packages/core/src/test/util/env-utils.test.js
  90. 2 1
      packages/core/src/test/util/page-path-utils.js
  91. 26 0
      packages/core/src/test/util/path-utils.test.js
  92. 47 0
      packages/core/src/utils/basic-interceptor.js
  93. 17 0
      packages/core/src/utils/env-utils.js
  94. 2 1
      packages/core/src/utils/page-path-utils.ts
  95. 108 0
      packages/core/src/utils/path-utils.js
  96. 1 1
      packages/core/src/utils/template-checker.ts
  97. 11 0
      packages/core/tsconfig.base.json
  98. 17 0
      packages/core/tsconfig.build.cjs.json
  99. 19 0
      packages/core/tsconfig.build.esm.json
  100. 9 0
      packages/core/tsconfig.json

+ 1 - 0
.devcontainer/devcontainer.json

@@ -16,6 +16,7 @@
 	"extensions": [
 		"dbaeumer.vscode-eslint",
 		"eamodio.gitlens",
+    "firsttris.vscode-jest-runner",
 		"msjsdiag.debugger-for-chrome",
 		"firefox-devtools.vscode-firefox-debug",
 		"editorconfig.editorconfig",

+ 0 - 1
.devcontainer/docker-compose.yml

@@ -25,7 +25,6 @@ services:
       - ..:/workspace/growi:delegated
       - node_modules:/workspace/growi/node_modules
       - ../../growi-docker-compose:/workspace/growi-docker-compose:delegated
-      - ../../node_modules:/workspace/node_modules:delegated
 
     tty: true
 

+ 4 - 0
.eslintrc.js

@@ -26,5 +26,9 @@ module.exports = {
         FunctionExpression: { body: 1, parameters: 2 },
       },
     ],
+    'jest/no-standalone-expect': [
+      'error',
+      { additionalTestBlockFunctions: ['each.test'] },
+    ],
   },
 };

+ 2 - 2
.github/workflows/ci-slackbot-proxy.yml

@@ -120,7 +120,7 @@ jobs:
     - name: yarn dev:ci
       working-directory: ./packages/slackbot-proxy
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
       env:
         TYPEORM_CONNECTION: mysql
@@ -211,7 +211,7 @@ jobs:
     - name: yarn start:prod:ci
       working-directory: ./packages/slackbot-proxy
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn start:prod:ci
       env:
         TYPEORM_CONNECTION: mysql

+ 12 - 9
.github/workflows/ci.yml

@@ -51,10 +51,10 @@ jobs:
         yarn list --depth=0
     - name: lerna run lint for plugins
       run: |
-        yarn lerna run lint --scope @growi/plugin-pukiwiki-like-linker
+        yarn lerna run lint --scope @growi/plugin-*
     - name: lerna run lint for app
       run: |
-        yarn lerna run lint --scope @growi/app
+        yarn lerna run lint --scope @growi/app --scope @growi/core --scope @growi/ui
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
@@ -130,6 +130,12 @@ jobs:
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi_test
 
+    - name: Upload coverage report as artifact
+      uses: actions/upload-artifact@v2
+      with:
+        name: Coverage Report
+        path: packages/app/coverage
+
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master
       if: failure()
@@ -206,7 +212,7 @@ jobs:
     - name: yarn dev:ci
       working-directory: ./packages/app
       run: |
-        cp config/ci/.env.local.for-ci .env.local
+        cp config/ci/.env.local.for-ci .env.development.local
         yarn dev:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi_dev
@@ -281,7 +287,7 @@ jobs:
         yarn list --depth=0
     - name: Build
       run: |
-        yarn lerna run build --scope @growi/slack --scope @growi/app
+        yarn lerna run build --scope @growi/core --scope @growi/slack --scope @growi/plugin-* --scope @growi/app
     - name: lerna bootstrap --production
       run: |
         npx lerna bootstrap -- --production
@@ -297,20 +303,17 @@ jobs:
     - name: yarn server:ci
       working-directory: ./packages/app
       run: |
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn server:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
     - name: yarn server:ci with MongoDB 3.6
       working-directory: ./packages/app
       run: |
+        cp config/ci/.env.local.for-ci .env.production.local
         yarn server:ci
       env:
         MONGO_URI: mongodb://localhost:${{ job.services.mongodb36.ports['27017'] }}/growi-${{ steps.getdbname.outputs.suffix }}
-    - name: Upload report as artifact
-      uses: actions/upload-artifact@v2
-      with:
-        name: Report
-        path: report
 
     - name: Slack Notification
       uses: weseek/ghaction-slack-notification@master

+ 4 - 0
.markdownlint.yml

@@ -7,3 +7,7 @@ no-multiple-blanks: false
 no-duplicate-heading: false
 no-inline-html: false
 no-trailing-punctuation: false
+
+MD002: false
+MD012: false
+MD041: false

+ 20 - 0
.vscode/launch.json

@@ -44,6 +44,26 @@
         "url": "http://localhost:3000",
         "webRoot": "${workspaceFolder}/packages/app/public",
         "pathMappings": [
+          {
+            "url": "webpack:///core",
+            "path": "${workspaceFolder}/packages/core"
+          },
+          {
+            "url": "webpack:///plugin-attachment-refs",
+            "path": "${workspaceFolder}/packages/plugin-attachment-refs"
+          },
+          {
+            "url": "webpack:///plugin-pukiwiki-like-linker",
+            "path": "${workspaceFolder}/packages/plugin-pukiwiki-like-linker"
+          },
+          {
+            "url": "webpack:///plugin-lsx",
+            "path": "${workspaceFolder}/packages/plugin-lsx"
+          },
+          {
+            "url": "webpack:///ui",
+            "path": "${workspaceFolder}/packages/ui"
+          },
           {
             "url": "webpack:///src",
             "path": "${workspaceFolder}/packages/app/src"

+ 21 - 3
package.json

@@ -21,8 +21,12 @@
   },
   "private": true,
   "workspaces": {
-    "packages": ["packages/*"],
-    "nohoist": ["**/slackbot-proxy/bootstrap"]
+    "packages": [
+      "packages/*"
+    ],
+    "nohoist": [
+      "**/slackbot-proxy/bootstrap"
+    ]
   },
   "scripts": {
     "start": "yarn app:server",
@@ -37,8 +41,17 @@
     "server:prod": "echo !!! CAUTION !!! ==> The script 'server:prod' is deprecated. Use 'yarn app:build' instead. && yarn app:server"
   },
   "dependencies": {
+    "cross-env": "^7.0.0",
+    "dotenv-flow": "^3.2.0",
+    "npm-run-all": "^4.1.5",
+    "ts-node": "^9.1.1",
+    "tsconfig-paths": "^3.9.0",
+    "typescript": "^4.2.3"
   },
   "devDependencies": {
+    "@types/jest": "^26.0.22",
+    "@types/node": "^14.14.35",
+    "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^4.28.5",
     "@typescript-eslint/parser": "^4.28.5",
     "eslint": "^7.31.0",
@@ -48,7 +61,12 @@
     "eslint-plugin-jest": "^24.3.2",
     "eslint-plugin-react": "^7.24.0",
     "eslint-plugin-react-hooks": "^4.2.0",
-    "lerna": "^4.0.0"
+    "jest": "^27.0.6",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
+    "lerna": "^4.0.0",
+    "rewire": "^5.0.0",
+    "ts-jest": "^27.0.4"
   },
   "engines": {
     "node": "^12 || ^14",

+ 2 - 2
packages/app/.env.development

@@ -3,7 +3,7 @@
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
 FILE_UPLOAD=mongodb
-# MONGO_GRIDFS_TOTAL_LIMIT=10485760 # 10MB
+# MONGO_GRIDFS_TOTAL_LIMIT=10485760
 MATHJAX=1
 # NO_CDN=true
 MONGO_URI="mongodb://mongo:27017/growi"
@@ -17,7 +17,7 @@ HACKMD_URI_FOR_SERVER="http://hackmd:3000"
 # PUBLISH_OPEN_API=true
 # USER_UPPER_LIMIT=0
 # DEV_HTTPS=true
-# FORCE_WIKI_MODE=private # 'public', 'private', undefined
+# FORCE_WIKI_MODE=private
 # PROMSTER_ENABLED=true
 # SLACK_SIGNING_SECRET=''
 # SLACK_BOT_TOKEN=''

+ 1 - 1
packages/app/.env.production

@@ -2,4 +2,4 @@
 ## Handled by Next.js with dotenv or dotenv-flow
 ## https://nextjs.org/docs/basic-features/environment-variables
 ##
-FORMAT_NODE_LOG=false # default: true
+FORMAT_NODE_LOG=false

+ 1 - 0
packages/app/.eslintignore

@@ -1,3 +1,4 @@
+/dist/**
 /public/**
 /src/client/legacy/thirdparty-js/**
 /src/client/util/reveal/plugins/markdown.js

+ 1 - 1
packages/app/.gitignore

@@ -13,7 +13,7 @@
 
 # dist (for GROWI v4.x and below)
 /public/*.chunk.js
-/public/*.chunk.js.LICENSE
+/public/*.chunk.js.LICENSE.txt
 /public/*.bundle.js
 /public/manifest.json
 /public/dll

+ 22 - 14
packages/app/bin/generate-plugin-definitions-source.ts

@@ -7,6 +7,8 @@ import fs from 'graceful-fs';
 import normalize from 'normalize-path';
 import swig from 'swig-templates';
 
+import { PluginDefinitionV4 } from '@growi/core';
+
 import PluginUtils from '../src/server/plugins/plugin-utils';
 import loggerFactory from '../src/utils/logger';
 import { resolveFromRoot } from '../src/utils/project-dir-utils';
@@ -23,26 +25,32 @@ const OUT = resolveFromRoot('tmp/plugins/plugin-definitions.js');
 const pluginNames: string[] = pluginUtils.listPluginNames();
 logger.info('Detected plugins: ', pluginNames);
 
-// get definitions
-const definitions = pluginNames
-  .map((name) => {
-    return pluginUtils.generatePluginDefinition(name, true);
-  })
-  .map((definition) => {
-    if (definition == null) {
-      return null;
+async function main(): Promise<void> {
+
+  // get definitions
+  const definitions: PluginDefinitionV4[] = [];
+  for (const pluginName of pluginNames) {
+    // eslint-disable-next-line no-await-in-loop
+    const definition = await pluginUtils.generatePluginDefinition(pluginName, true);
+    if (definition != null) {
+      definitions.push(definition);
     }
+  }
 
+  definitions.map((definition) => {
     // convert backslash to slash
     definition.entries = definition.entries.map((entryPath) => {
       return normalize(entryPath);
     });
     return definition;
-  })
-  .filter(definition => definition != null);
+  });
+
+  const compiledTemplate = swig.compileFile(TEMPLATE);
+  const code = compiledTemplate({ definitions });
+
+  // write
+  fs.writeFileSync(OUT, code);
 
-const compiledTemplate = swig.compileFile(TEMPLATE);
-const code = compiledTemplate({ definitions });
+}
 
-// write
-fs.writeFileSync(OUT, code);
+main();

+ 2 - 0
packages/app/config/ci/.env.local.for-ci

@@ -1,2 +1,4 @@
+FORMAT_NODE_LOG=true
+
 # disable Elasticsearch
 ELASTICSEARCH_URI=

+ 1 - 0
packages/app/jest.config.js

@@ -5,6 +5,7 @@
 const MODULE_NAME_MAPPING = {
   '^\\^/(.+)$': '<rootDir>/$1',
   '^~/(.+)$': '<rootDir>/src/$1',
+  '^@growi/(.+)$': '<rootDir>/../$1/src',
 };
 
 module.exports = {

+ 3 - 14
packages/app/package.json

@@ -55,7 +55,9 @@
   "dependencies": {
     "@browser-bunyan/console-formatted-stream": "^1.6.2",
     "@google-cloud/storage": "^5.8.5",
+    "@growi/plugin-attachment-refs": "^4.3.3-RC",
     "@growi/plugin-pukiwiki-like-linker": "^4.3.3-RC",
+    "@growi/plugin-lsx": "^4.3.3-RC",
     "@growi/slack": "^4.3.3-RC",
     "@kobalab/socket.io-session": "^1.0.3",
     "@promster/express": "^5.0.1",
@@ -77,12 +79,10 @@
     "connect-mongo": "^4.4.1",
     "connect-redis": "^4.0.4",
     "cookie-parser": "^1.4.5",
-    "cross-env": "^7.0.0",
     "csrf": "^3.1.0",
     "date-fns": "^2.0.0",
     "detect-indent": "^6.0.0",
     "diff": "^5.0.0",
-    "dotenv-flow": "^3.2.0",
     "elasticsearch": "^16.0.0",
     "entities": "^2.0.0",
     "esa-nodejs": "^0.0.7",
@@ -96,8 +96,6 @@
     "express-webpack-assets": "^0.1.0",
     "graceful-fs": "^4.1.11",
     "growi-commons": "^5.0.4",
-    "growi-plugin-attachment-refs": "^2.0.2",
-    "growi-plugin-lsx": "^4.0.3",
     "helmet": "^3.13.0",
     "http-errors": "~1.6.2",
     "i18next": "^20.3.2",
@@ -118,7 +116,6 @@
     "multer-autoreap": "^1.0.3",
     "nodemailer": "^6.6.2",
     "nodemailer-ses-transport": "~1.5.0",
-    "npm-run-all": "^4.1.2",
     "openid-client": "=2.5.0",
     "passport": "^0.4.0",
     "passport-github": "^1.1.0",
@@ -139,9 +136,6 @@
     "string-width": "=4.2.2",
     "swagger-jsdoc": "^3.4.0",
     "swig-templates": "^2.0.2",
-    "ts-node": "^9.1.1",
-    "tsconfig-paths": "^3.9.0",
-    "typescript": "^4.2.3",
     "uglifycss": "^0.0.29",
     "universal-bunyan": "^0.9.2",
     "unzipper": "^0.10.5",
@@ -159,11 +153,11 @@
     "@alienfast/i18next-loader": "^1.0.16",
     "@atlaskit/drawer": "^5.3.7",
     "@atlaskit/navigation-next": "^8.0.5",
+    "@growi/ui": "^4.3.3-RC",
     "@handsontable/react": "=2.1.0",
     "@types/compression": "^1.7.0",
     "@types/express": "^4.17.11",
     "@types/multer": "^1.4.5",
-    "@types/node": "^14.14.35",
     "@types/react-dom": "^17.0.9",
     "autoprefixer": "^9.0.0",
     "bootstrap": "^4.5.0",
@@ -183,8 +177,6 @@
     "hard-source-webpack-plugin": "^0.13.1",
     "i18next-browser-languagedetector": "^4.0.1",
     "imports-loader": "^0.8.0",
-    "jest": "^27.0.6",
-    "jest-date-mock": "^1.0.8",
     "jquery-slimscroll": "^1.3.8",
     "jquery-ui": "^1.12.1",
     "jquery.cookie": "~1.4.1",
@@ -222,8 +214,6 @@
     "react-frame-component": "^4.0.0",
     "react-hotkeys": "^2.0.0",
     "react-i18next": "^11.1.0",
-    "react-images": "1.0.0",
-    "react-motion": "^0.5.2",
     "react-waypoint": "^10.1.0",
     "reactstrap": "^8.9.0",
     "replacestream": "^4.0.3",
@@ -240,7 +230,6 @@
     "terser-webpack-plugin": "^4.1.0",
     "throttle-debounce": "^2.0.0",
     "toastr": "^2.1.2",
-    "ts-jest": "^27.0.4",
     "ts-loader": "^8.3.0",
     "ts-node-dev": "^1.1.6",
     "tsc-alias": "^1.2.9",

+ 4 - 2
packages/app/src/client/plugin.js

@@ -29,15 +29,17 @@ export default class GrowiPlugin {
       const meta = definition.meta;
 
       switch (meta.pluginSchemaVersion) {
-        // v1 is deprecated
+        // v1, v2 and v3 is deprecated
         case 1:
           logger.warn('pluginSchemaVersion 1 is deprecated', definition);
           break;
-        // v2 is deprecated
         case 2:
           logger.warn('pluginSchemaVersion 2 is deprecated', definition);
           break;
         case 3:
+          logger.warn('pluginSchemaVersion 2 is deprecated', definition);
+          break;
+        case 4:
           definition.entries.forEach((entry) => {
             entry(appContainer);
           });

+ 1 - 1
packages/app/src/client/services/AdminGitHubSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminGoogleSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminOidcSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminSamlSecurityContainer.js

@@ -1,7 +1,7 @@
 import { Container } from 'unstated';
 
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 1 - 1
packages/app/src/client/services/AdminTwitterSecurityContainer.js

@@ -1,6 +1,6 @@
 import { Container } from 'unstated';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import loggerFactory from '~/utils/logger';
 import { removeNullPropertyFromObject } from '~/utils/object-utils';

+ 3 - 1
packages/app/src/client/services/PageContainer.js

@@ -3,8 +3,8 @@ import { Container } from 'unstated';
 
 import * as entities from 'entities';
 import * as toastr from 'toastr';
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import { isTrashPage } from '~/utils/path-utils';
 import { toastError } from '../util/apiNotification';
 
 import {
@@ -16,6 +16,8 @@ import {
   DrawioInterceptor,
 } from '../util/interceptor/drawio-interceptor';
 
+const { isTrashPage } = pagePathUtils;
+
 const logger = loggerFactory('growi:services:PageContainer');
 
 /**

+ 1 - 1
packages/app/src/components/Admin/Common/AdminNavigation.jsx

@@ -6,7 +6,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserFormByInput.jsx

@@ -4,11 +4,11 @@ import { withTranslation } from 'react-i18next';
 
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 import { debounce } from 'throttle-debounce';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';
 import { toastSuccess, toastError } from '~/client/util/apiNotification';
-import UserPicture from '../../User/UserPicture';
 
 class UserGroupUserFormByInput extends React.Component {
 

+ 1 - 1
packages/app/src/components/Admin/UserGroupDetail/UserGroupUserTable.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import AdminUserGroupDetailContainer from '~/client/services/AdminUserGroupDetailContainer';

+ 1 - 1
packages/app/src/components/Admin/Users/UserTable.jsx

@@ -3,7 +3,7 @@ import PropTypes from 'prop-types';
 import { withTranslation } from 'react-i18next';
 import dateFnsFormat from 'date-fns/format';
 
-import UserPicture from '../../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import UserMenu from './UserMenu';
 
 import { withUnstatedContainers } from '../../UnstatedUtils';

+ 3 - 1
packages/app/src/components/ComparePathsTable.jsx

@@ -2,10 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 function ComparePathsTable(props) {
   const {

+ 3 - 1
packages/app/src/components/ContentLinkButtons.jsx

@@ -1,7 +1,7 @@
 import React, { useMemo } from 'react';
 import PropTypes from 'prop-types';
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -10,6 +10,8 @@ import { withUnstatedContainers } from './UnstatedUtils';
 
 import RecentlyCreatedIcon from './Icons/RecentlyCreatedIcon';
 
+const { isTopPage } = pagePathUtils;
+
 const WIKI_HEADER_LINK = 120;
 
 /**

+ 1 - 1
packages/app/src/components/CreateTemplateModal.jsx

@@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
 import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 
 import { withTranslation } from 'react-i18next';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 import urljoin from 'url-join';
 import { withUnstatedContainers } from './UnstatedUtils';
 

+ 3 - 1
packages/app/src/components/DuplicatedPathsTable.jsx

@@ -2,10 +2,12 @@ import React from 'react';
 import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from './UnstatedUtils';
 
 import PageContainer from '~/client/services/PageContainer';
-import { convertToNewAffiliationPath } from '~/utils/path-utils';
+
+const { convertToNewAffiliationPath } = pagePathUtils;
 
 function DuplicatedPathsTable(props) {
   const {

+ 4 - 2
packages/app/src/components/Navbar/AuthorInfo.jsx

@@ -1,9 +1,11 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 import { format } from 'date-fns';
-import { userPageRoot } from '~/utils/path-utils';
+import { UserPicture } from '@growi/ui';
+import { pagePathUtils } from '@growi/core';
+
+const { userPageRoot } = pagePathUtils;
 
-import UserPicture from '../User/UserPicture';
 
 const formatType = 'yyyy/MM/dd HH:mm';
 const AuthorInfo = (props) => {

+ 1 - 1
packages/app/src/components/Navbar/GrowiSubNavigation.jsx

@@ -3,8 +3,8 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { withUnstatedContainers } from '../UnstatedUtils';

+ 1 - 1
packages/app/src/components/Navbar/PersonalDropdown.jsx

@@ -5,6 +5,7 @@ import { withTranslation } from 'react-i18next';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -18,7 +19,6 @@ import {
   updateUserPreferenceWithOsSettings,
 } from '~/client/util/color-scheme';
 
-import UserPicture from '../User/UserPicture';
 
 import SidebarDrawerIcon from '../Icons/SidebarDrawerIcon';
 import SidebarDockIcon from '../Icons/SidebarDockIcon';

+ 3 - 1
packages/app/src/components/Page/CopyDropdown.jsx

@@ -12,7 +12,9 @@ import {
 
 import { CopyToClipboard } from 'react-copy-to-clipboard';
 
-import { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
+
+const { encodeSpaces } = pagePathUtils;
 
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (

+ 3 - 1
packages/app/src/components/Page/PageManagement.jsx

@@ -4,7 +4,7 @@ import { UncontrolledTooltip } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import urljoin from 'url-join';
 
-import { isTopPage } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
@@ -15,6 +15,8 @@ import CreateTemplateModal from '../CreateTemplateModal';
 import PagePresentationModal from '../PagePresentationModal';
 import PresentationIcon from '../Icons/PresentationIcon';
 
+const { isTopPage } = pagePathUtils;
+
 
 const PageManagement = (props) => {
   const {

+ 1 - 1
packages/app/src/components/Page/TrashPageAlert.jsx

@@ -3,10 +3,10 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UserPicture } from '@growi/ui';
 import { withUnstatedContainers } from '../UnstatedUtils';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
-import UserPicture from '../User/UserPicture';
 import PutbackPageModal from '../PutbackPageModal';
 import EmptyTrashModal from '../EmptyTrashModal';
 import PageDeleteModal from '../PageDeleteModal';

+ 1 - 1
packages/app/src/components/PageAttachment/DeleteAttachmentModal.jsx

@@ -6,7 +6,7 @@ import {
   Modal, ModalHeader, ModalBody, ModalFooter,
 } from 'reactstrap';
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 
 export default class DeleteAttachmentModal extends React.Component {

+ 1 - 1
packages/app/src/components/PageAttachment/PageAttachmentList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import Attachment from './Attachment';
+import { Attachment } from '@growi/ui';
 
 export default class PageAttachmentList extends React.Component {
 

+ 1 - 1
packages/app/src/components/PageComment/Comment.jsx

@@ -6,6 +6,7 @@ import { format } from 'date-fns';
 
 import { UncontrolledTooltip } from 'reactstrap';
 
+import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 
@@ -13,7 +14,6 @@ import { withUnstatedContainers } from '../UnstatedUtils';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
 import RevisionBody from '../Page/RevisionBody';
-import UserPicture from '../User/UserPicture';
 import Username from '../User/Username';
 import CommentEditor from './CommentEditor';
 import CommentControl from './CommentControl';

+ 1 - 1
packages/app/src/components/PageComment/CommentEditor.jsx

@@ -8,6 +8,7 @@ import {
 
 import * as toastr from 'toastr';
 
+import { UserPicture } from '@growi/ui';
 import AppContainer from '~/client/services/AppContainer';
 import PageContainer from '~/client/services/PageContainer';
 import CommentContainer from '~/client/services/CommentContainer';
@@ -15,7 +16,6 @@ import EditorContainer from '~/client/services/EditorContainer';
 import GrowiRenderer from '~/client/util/GrowiRenderer';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
-import UserPicture from '../User/UserPicture';
 import Editor from '../PageEditor/Editor';
 import SlackNotification from '../SlackNotification';
 

+ 1 - 1
packages/app/src/components/PageComment/DeleteCommentModal.jsx

@@ -7,7 +7,7 @@ import {
 
 import { format } from 'date-fns';
 
-import UserPicture from '../User/UserPicture';
+import { UserPicture } from '@growi/ui';
 import Username from '../User/Username';
 
 export default class DeleteCommentModal extends React.Component {

+ 6 - 4
packages/app/src/components/PageCreateModal.jsx

@@ -7,10 +7,8 @@ import { Modal, ModalHeader, ModalBody } from 'reactstrap';
 import { withTranslation } from 'react-i18next';
 import { format } from 'date-fns';
 
-import { pathUtils } from 'growi-commons';
-import {
-  userPageRoot, isCreatablePage, generateEditorPath,
-} from '~/utils/path-utils';
+import { pagePathUtils, pathUtils } from '@growi/core';
+
 
 import AppContainer from '~/client/services/AppContainer';
 import NavigationContainer from '~/client/services/NavigationContainer';
@@ -19,6 +17,10 @@ import { toastError } from '~/client/util/apiNotification';
 
 import PagePathAutoComplete from './PagePathAutoComplete';
 
+const {
+  userPageRoot, isCreatablePage, generateEditorPath,
+} = pagePathUtils;
+
 const PageCreateModal = (props) => {
   const { t, appContainer, navigationContainer } = props;
 

+ 1 - 1
packages/app/src/components/PageHistory/Revision.jsx

@@ -1,9 +1,9 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
+import { UserPicture } from '@growi/ui';
 import UserDate from '../User/UserDate';
 import Username from '../User/Username';
-import UserPicture from '../User/UserPicture';
 
 export default class Revision extends React.Component {
 

+ 2 - 3
packages/app/src/components/PageList/Page.jsx

@@ -1,9 +1,8 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from '../User/UserPicture';
-import PageListMeta from './PageListMeta';
-import PagePathLabel from './PagePathLabel';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
 
 export default class Page extends React.Component {
 

+ 0 - 24
packages/app/src/components/PageList/PagePath.jsx

@@ -1,24 +0,0 @@
-import React from 'react';
-import PropTypes from 'prop-types';
-
-import PagePathLabel from './PagePathLabel';
-
-/**
- * !!DEPRECATED!!
- *
- * maintained for backward compatibility for growi-lsx-plugin(<= 3.1.1)
- */
-const PagePath = props => (
-  <PagePathLabel isLatterOnly={props.isShortPathOnly} {...props} />
-);
-
-PagePath.propTypes = {
-  isShortPathOnly: PropTypes.bool,
-  ...PagePathLabel.propTypes,
-};
-
-PagePath.defaultProps = {
-  ...PagePathLabel.defaultProps,
-};
-
-export default PagePath;

+ 1 - 1
packages/app/src/components/PagePathAutoComplete.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import SearchTypeahead from './SearchTypeahead';
 

+ 3 - 1
packages/app/src/components/RevisionComparer/RevisionComparer.jsx

@@ -6,7 +6,7 @@ import {
   Dropdown, DropdownToggle, DropdownMenu, DropdownItem,
 } from 'reactstrap';
 
-import { encodeSpaces } from '~/utils/path-utils';
+import { pagePathUtils } from '@growi/core';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
 
@@ -14,6 +14,8 @@ import RevisionComparerContainer from '~/client/services/RevisionComparerContain
 
 import RevisionDiff from '../PageHistory/RevisionDiff';
 
+const { encodeSpaces } = pagePathUtils;
+
 /* eslint-disable react/prop-types */
 const DropdownItemContents = ({ title, contents }) => (
   <>

+ 2 - 3
packages/app/src/components/SearchTypeahead.jsx

@@ -4,9 +4,8 @@ import PropTypes from 'prop-types';
 import { noop } from 'lodash/noop';
 import { AsyncTypeahead } from 'react-bootstrap-typeahead';
 
-import UserPicture from './User/UserPicture';
-import PageListMeta from './PageList/PageListMeta';
-import PagePathLabel from './PageList/PagePathLabel';
+import { UserPicture, PageListMeta, PagePathLabel } from '@growi/ui';
+
 import AppContainer from '~/client/services/AppContainer';
 import { withUnstatedContainers } from './UnstatedUtils';
 

+ 2 - 2
packages/app/src/components/Sidebar/RecentChanges.jsx

@@ -3,10 +3,11 @@ import PropTypes from 'prop-types';
 
 import { withTranslation } from 'react-i18next';
 
+import { UserPicture } from '@growi/ui';
+import { DevidedPagePath } from '@growi/core';
 import PagePathHierarchicalLink from '~/components/PagePathHierarchicalLink';
 import loggerFactory from '~/utils/logger';
 
-import DevidedPagePath from '~/models/devided-page-path';
 import LinkedPagePath from '~/models/linked-page-path';
 
 import { withUnstatedContainers } from '../UnstatedUtils';
@@ -14,7 +15,6 @@ import AppContainer from '~/client/services/AppContainer';
 import { toastError } from '~/client/util/apiNotification';
 
 import FormattedDistanceDate from '../FormattedDistanceDate';
-import UserPicture from '../User/UserPicture';
 
 const logger = loggerFactory('growi:History');
 class RecentChanges extends React.Component {

+ 1 - 1
packages/app/src/components/User/UserInfo.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 const UserInfo = (props) => {
   const { pageUser } = props;

+ 1 - 1
packages/app/src/components/User/UserPictureList.jsx

@@ -1,7 +1,7 @@
 import React from 'react';
 import PropTypes from 'prop-types';
 
-import UserPicture from './UserPicture';
+import { UserPicture } from '@growi/ui';
 
 export default class UserPictureList extends React.Component {
 

+ 1 - 1
packages/app/src/migrations/20191126173016-adjust-pages-path.js

@@ -1,5 +1,5 @@
 import mongoose from 'mongoose';
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import config from '^/config/migrate';
 import loggerFactory from '~/utils/logger';

+ 3 - 6
packages/app/src/models/linked-page-path.js

@@ -1,12 +1,11 @@
-const { pathUtils } = require('growi-commons');
-const { isTrashPage } = require('~/utils/path-utils');
+import { pagePathUtils, DevidedPagePath, pathUtils } from '@growi/core';
 
-const DevidedPagePath = require('./devided-page-path');
+const { isTrashPage } = pagePathUtils;
 
 /**
  * Linked Array Structured PagePath Model
  */
-class LinkedPagePath {
+export default class LinkedPagePath {
 
   constructor(path, skipNormalize = false) {
 
@@ -34,5 +33,3 @@ class LinkedPagePath {
   }
 
 }
-
-module.exports = LinkedPagePath;

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -427,7 +427,7 @@ Crowi.prototype.start = async function() {
 
   // setup plugins
   this.pluginService = new PluginService(this, express);
-  this.pluginService.autoDetectAndLoadPlugins();
+  await this.pluginService.autoDetectAndLoadPlugins();
 
   const server = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 

+ 5 - 3
packages/app/src/server/models/page.js

@@ -1,3 +1,4 @@
+import { templateChecker, pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 // disable no-return-await for model functions
@@ -15,8 +16,9 @@ const differenceInYears = require('date-fns/differenceInYears');
 
 const { pathUtils } = require('growi-commons');
 const escapeStringRegexp = require('escape-string-regexp');
-const templateChecker = require('~/utils/template-checker');
-const { isTopPage, isTrashPage } = require('~/utils/path-utils');
+
+const { isTopPage, isTrashPage } = pagePathUtils;
+const { checkTemplatePath } = templateChecker;
 
 const logger = loggerFactory('growi:models:page');
 
@@ -311,7 +313,7 @@ module.exports = function(crowi) {
   };
 
   pageSchema.methods.isTemplate = function() {
-    return templateChecker(this.path);
+    return checkTemplatePath(this.path);
   };
 
   pageSchema.methods.isLatestRevision = function() {

+ 38 - 0
packages/app/src/server/plugins/plugin-utils-v4.ts

@@ -0,0 +1,38 @@
+import path from 'path';
+
+import { PluginMetaV4, PluginDefinitionV4 } from '@growi/core';
+
+export class PluginUtilsV4 {
+
+  /**
+   * return a definition objects that has following structure:
+   *
+   * {
+   *   name: 'crowi-plugin-X',
+   *   meta: require('crowi-plugin-X'),
+   *   entries: [
+   *     'crowi-plugin-X/lib/client-entry'
+   *   ]
+   * }
+   *
+   *
+   * @param {string} pluginName
+   * @return
+   * @memberOf PluginService
+   */
+  async generatePluginDefinition(name: string, isForClient = false): Promise<PluginDefinitionV4> {
+    const meta: PluginMetaV4 = await import(name);
+    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
+
+    entries = entries.map((entryPath) => {
+      return path.join(name, entryPath);
+    });
+
+    return {
+      name,
+      meta,
+      entries,
+    };
+  }
+
+}

+ 15 - 7
packages/app/src/server/plugins/plugin-utils.js

@@ -1,13 +1,13 @@
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-const fs = require('graceful-fs');
+import { PluginUtilsV4 } from './plugin-utils-v4';
 
-const PluginUtilsV2 = require('./plugin-utils-v2');
+const fs = require('graceful-fs');
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginUtilsV2 = new PluginUtilsV2();
+const pluginUtilsV4 = new PluginUtilsV4();
 
 class PluginUtils {
 
@@ -26,19 +26,27 @@ class PluginUtils {
    * @return
    * @memberOf PluginService
    */
-  generatePluginDefinition(name, isForClient = false) {
+  async generatePluginDefinition(name, isForClient = false) {
     const meta = require(name);
     let definition;
 
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
         logger.debug('pluginSchemaVersion 1 is deprecated');
         break;
-      // v2 or above
       case 2:
+        logger.debug('pluginSchemaVersion 2 is deprecated');
+        break;
+      case 3:
+        logger.debug('pluginSchemaVersion 3 is deprecated');
+        break;
+      // v4 or above
+      case 4:
+        definition = await pluginUtilsV4.generatePluginDefinition(name, isForClient);
+        break;
       default:
-        definition = pluginUtilsV2.generatePluginDefinition(name, isForClient);
+        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
     }
 
     return definition;

+ 16 - 11
packages/app/src/server/plugins/plugin.service.js

@@ -12,13 +12,13 @@ class PluginService {
     this.pluginUtils = new PluginUtils();
   }
 
-  autoDetectAndLoadPlugins() {
+  async autoDetectAndLoadPlugins() {
     const isEnabledPlugins = this.crowi.configManager.getConfig('crowi', 'plugin:isEnabledPlugins');
 
     // import plugins
     if (isEnabledPlugins) {
       logger.debug('Plugins are enabled');
-      this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
+      return this.loadPlugins(this.pluginUtils.listPluginNames(this.crowi.rootDir));
     }
 
   }
@@ -28,29 +28,34 @@ class PluginService {
    *
    * @memberOf PluginService
    */
-  loadPlugins(pluginNames) {
-    pluginNames
-      .map((name) => {
-        return this.pluginUtils.generatePluginDefinition(name);
-      })
-      .forEach((definition) => {
+  async loadPlugins(pluginNames) {
+    // get definitions
+    const definitions = [];
+    for (const pluginName of pluginNames) {
+      // eslint-disable-next-line no-await-in-loop
+      const definition = await this.pluginUtils.generatePluginDefinition(pluginName);
+      if (definition != null) {
         this.loadPlugin(definition);
-      });
+      }
+    }
   }
 
   loadPlugin(definition) {
     const meta = definition.meta;
 
     switch (meta.pluginSchemaVersion) {
-      // v1 is deprecated
+      // v1, v2 and v3 is deprecated
       case 1:
         logger.warn('pluginSchemaVersion 1 is deprecated', definition);
         break;
-      // v2 is deprecated
       case 2:
         logger.warn('pluginSchemaVersion 2 is deprecated', definition);
         break;
       case 3:
+        logger.warn('pluginSchemaVersion 3 is deprecated', definition);
+        break;
+      // v4 or above
+      case 4:
         logger.info(`load plugin '${definition.name}'`);
         definition.entries.forEach((entryPath) => {
           const entry = require(entryPath);

+ 3 - 2
packages/app/src/server/routes/apiv3/page.js

@@ -1,13 +1,14 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:routes:apiv3:page'); // eslint-disable-line no-unused-vars
 
 const express = require('express');
 const { body, query } = require('express-validator');
 
 const router = express.Router();
-
-const { convertToNewAffiliationPath } = require('~/utils/path-utils');
+const { convertToNewAffiliationPath } = pagePathUtils;
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
 

+ 7 - 2
packages/app/src/server/routes/apiv3/pages.js

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const logger = loggerFactory('growi:routes:apiv3:pages'); // eslint-disable-line no-unused-vars
@@ -6,9 +7,11 @@ const pathUtils = require('growi-commons').pathUtils;
 
 const { body } = require('express-validator');
 const { query } = require('express-validator');
-const { isCreatablePage } = require('~/utils/path-utils');
+
 const ErrorV3 = require('../../models/vo/error-apiv3');
 
+const { isCreatablePage } = pagePathUtils;
+
 const router = express.Router();
 
 const LIMIT_FOR_LIST = 10;
@@ -458,7 +461,9 @@ module.exports = (crowi) => {
   ];
 
   router.get('/list', accessTokenParser, loginRequired, validator.displayList, apiV3FormValidator, async(req, res) => {
-    const { isTrashPage } = require('~/utils/path-utils');
+
+
+    const { isTrashPage } = pagePathUtils;
 
     const { path } = req.query;
     const limit = parseInt(req.query.limit) || await crowi.configManager.getConfig('crowi', 'customize:showPageLimitationS') || 10;

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

@@ -1,6 +1,7 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
-const { isCreatablePage } = require('~/utils/path-utils');
+const { isCreatablePage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 const { serializeRevisionSecurely } = require('../models/serializers/revision-serializer');
 const { serializeUserSecurely } = require('../models/serializers/user-serializer');

+ 2 - 2
packages/app/src/server/service/app.ts

@@ -1,4 +1,4 @@
-import { pathUtils } from 'growi-commons';
+import { pathUtils } from '@growi/core';
 
 import loggerFactory from '~/utils/logger';
 
@@ -119,7 +119,7 @@ class AppService implements S2sMessageHandlable {
   }
 
   async setupAfterInstall() {
-    this.crowi.pluginService.autoDetectAndLoadPlugins();
+    await this.crowi.pluginService.autoDetectAndLoadPlugins();
     this.crowi.setupRoutesAtLast();
     this.crowi.setupGlobalErrorHandlers();
 

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

@@ -1,8 +1,8 @@
 // eslint-disable-next-line no-unused-vars
 import uglifycss from 'uglifycss';
 
+import { DevidedPagePath } from '@growi/core';
 import loggerFactory from '~/utils/logger';
-import DevidedPagePath from '~/models/devided-page-path';
 
 import S2sMessage from '../models/vo/s2s-message';
 import { S2sMessageHandlable } from './s2s-messaging/handlable';

+ 3 - 1
packages/app/src/server/service/global-notification/global-notification-slack.js

@@ -1,9 +1,11 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
+
 const logger = loggerFactory('growi:service:GlobalNotificationSlackService'); // eslint-disable-line no-unused-vars
 const urljoin = require('url-join');
 
-const { encodeSpaces } = require('~/utils/path-utils');
+const { encodeSpaces } = pagePathUtils;
 
 /**
  * sub service class of GlobalNotificationSetting

+ 3 - 1
packages/app/src/server/service/page.js

@@ -1,3 +1,4 @@
+import { pagePathUtils } from '@growi/core';
 import loggerFactory from '~/utils/logger';
 
 const mongoose = require('mongoose');
@@ -7,7 +8,8 @@ const logger = loggerFactory('growi:models:page');
 const debug = require('debug')('growi:models:page');
 const { Writable } = require('stream');
 const { createBatchStream } = require('~/server/util/batch-stream');
-const { isTrashPage } = require('~/utils/path-utils');
+
+const { isTrashPage } = pagePathUtils;
 const { serializePageSecurely } = require('../models/serializers/page-serializer');
 
 const BULK_REINDEX_SIZE = 100;

+ 3 - 1
packages/app/src/server/util/createGrowiPagesFromImports.js

@@ -1,4 +1,6 @@
-const { isCreatablePage } = require('~/utils/path-utils');
+import { pagePathUtils } from '@growi/core';
+
+const { isCreatablePage } = pagePathUtils;
 
 module.exports = (crowi) => {
   const Page = crowi.model('Page');

+ 2 - 5
packages/app/tsconfig.base.json

@@ -1,9 +1,6 @@
 {
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
-  },
-  "exclude": [
-    "node_modules",
-    "**/*.test.ts"
-  ]
+    "jsx": "react"
+  }
 }

+ 0 - 1
packages/app/tsconfig.build.client.json

@@ -2,7 +2,6 @@
   "extends": "./tsconfig.base.json",
   "compilerOptions": {
     "module": "esnext",
-    "jsx": "react",
     "noFallthroughCasesInSwitch": true,
     "noUnusedLocals": true,
     "noUnusedParameters": true,

+ 1 - 0
packages/core/.eslintignore

@@ -0,0 +1 @@
+/dist/**

+ 1 - 0
packages/core/.gitignore

@@ -0,0 +1 @@
+/dist

+ 27 - 0
packages/core/README.md

@@ -0,0 +1,27 @@
+# growi-commons
+
+[![dependencies status](https://david-dm.org/weseek/growi-commons.svg)](https://david-dm.org/weseek/growi-commons)
+[![devDependencies Status](https://david-dm.org/weseek/growi-commons/dev-status.svg)](https://david-dm.org/weseek/growi-commons?type=dev)
+[![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE)
+
+[GROWI](https://growi.org) Commons Libraries to develop GROWI and plugins
+
+
+Overview
+--------
+
+growi-commons package is includes some functions, classes and modules to develop GROWI substance and GROWI plugins.
+
+Install
+--------
+
+1. install plugin
+
+    ```
+    $ npm install --save growi-commons
+    ```
+
+Documentation
+------------
+
+See https://docs.growi.org/api/commons/

+ 60 - 0
packages/core/jest.config.js

@@ -0,0 +1,60 @@
+// For a detailed explanation regarding each configuration property, visit:
+// https://jestjs.io/docs/en/configuration.html
+
+const MODULE_NAME_MAPPING = {
+  '^~/(.+)$': '<rootDir>/src/$1',
+};
+
+module.exports = {
+
+  preset: 'ts-jest/presets/js-with-ts',
+
+  moduleNameMapper: MODULE_NAME_MAPPING,
+
+  // Automatically clear mock calls and instances between every test
+  clearMocks: true,
+
+  // Indicates whether the coverage information should be collected while executing the test
+  collectCoverage: true,
+
+  // An array of glob patterns indicating a set of files for which coverage information should be collected
+  // collectCoverageFrom: undefined,
+
+  // The directory where Jest should output its coverage files
+  coverageDirectory: 'coverage',
+
+  // An array of regexp pattern strings used to skip coverage collection
+  coveragePathIgnorePatterns: [
+    '/node_modules/',
+  ],
+
+  // An object that configures minimum threshold enforcement for coverage results
+  // TODO: activate -- 2020.03.24 Yuki Takei
+  // coverageThreshold: {
+  //   global: {
+  //     branches: 70,
+  //     functions: 70,
+  //     lines: 70,
+  //     statements: 70,
+  //   },
+  // },
+
+  // An array of file extensions your modules use
+  moduleFileExtensions: [
+    'js',
+    'json',
+    'jsx',
+    'ts',
+    'tsx',
+    'node',
+  ],
+
+  // The test environment that will be used for testing
+  testEnvironment: 'node',
+
+  // The glob patterns Jest uses to detect test files
+  testMatch: [
+    '**/src/**/__tests__/**/*.[jt]s?(x)',
+    '**/src/**/?(*.)+(spec|test).[tj]s?(x)',
+  ],
+};

+ 24 - 0
packages/core/package.json

@@ -0,0 +1,24 @@
+{
+  "name": "@growi/core",
+  "version": "4.3.3-RC",
+  "description": "GROWI Core Libraries",
+  "license": "MIT",
+  "keywords": [
+    "growi"
+  ],
+  "main": "dist/cjs/index.js",
+  "module": "dist/esm/index.js",
+  "files": ["dist"],
+  "scripts": {
+    "build": "run-p build:*",
+    "build:cjs": "tsc -p tsconfig.build.cjs.json && tsc-alias -p tsconfig.build.cjs.json",
+    "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
+    "lint:js": "eslint **/*.{js,ts}",
+    "lint:styles": "stylelint src/styles/scss/**/*.scss",
+    "lint": "npm-run-all -p lint:*",
+    "test": "jest --verbose"
+  },
+  "dependencies": {},
+  "devDependencies": {
+  }
+}

+ 21 - 0
packages/core/src/index.js

@@ -0,0 +1,21 @@
+import * as _pathUtils from './utils/path-utils';
+import * as _pagePathUtils from './utils/page-path-utils';
+import * as _templateChecker from './utils/template-checker';
+
+// module.exports = {
+//   BasicInterceptor: require('./utils/basic-interceptor'),
+//   envUtils: require('./utils/env-utils'),
+//   // plugin
+//   customTagUtils: require('./plugin/util/custom-tag-utils'),
+//   TagCacheManager: require('./plugin/service/tag-cache-manager'),
+//   // service
+//   LocalStorageManager: require('./service/localstorage-manager'),
+// };
+
+export * from './plugin/interfaces/plugin-definition-v4';
+export * from './models/devided-page-path';
+
+// export utils
+export const pathUtils = _pathUtils;
+export const pagePathUtils = _pagePathUtils;
+export const templateChecker = _templateChecker;

+ 2 - 4
packages/app/src/models/devided-page-path.js → packages/core/src/models/devided-page-path.js

@@ -1,11 +1,11 @@
-const { pathUtils } = require('growi-commons');
+import * as pathUtils from '../utils/path-utils';
 
 // https://regex101.com/r/BahpKX/2
 const PATTERN_INCLUDE_DATE = /^(.+\/[^/]+)\/(\d{4}|\d{4}\/\d{2}|\d{4}\/\d{2}\/\d{2})$/;
 // https://regex101.com/r/WVpPpY/1
 const PATTERN_DEFAULT = /^((.*)\/)?([^/]+)$/;
 
-class DevidedPagePath {
+export class DevidedPagePath {
 
   constructor(path, skipNormalize = false, evalDatePath = false) {
 
@@ -43,5 +43,3 @@ class DevidedPagePath {
   }
 
 }
-
-module.exports = DevidedPagePath;

+ 11 - 0
packages/core/src/plugin/interfaces/plugin-definition-v4.ts

@@ -0,0 +1,11 @@
+export type PluginMetaV4 = {
+  pluginSchemaVersion: number,
+  serverEntries: string[],
+  clientEntries: string[],
+};
+
+export type PluginDefinitionV4 = {
+  name: string,
+  meta: PluginMetaV4,
+  entries: string[],
+};

+ 14 - 0
packages/core/src/plugin/model/tag-context.js

@@ -0,0 +1,14 @@
+/**
+ * Context class for custom-tag-utils#findTagAndReplace
+ */
+class TagContext {
+
+  constructor(initArgs = {}) {
+    this.tagExpression = initArgs.tagExpression || null;
+    this.method = initArgs.method || null;
+    this.args = initArgs.args || null;
+  }
+
+}
+
+module.exports = TagContext;

+ 71 - 0
packages/core/src/plugin/service/tag-cache-manager.js

@@ -0,0 +1,71 @@
+const LocalStorageManager = require('../../service/localstorage-manager');
+
+/**
+ * Service Class for caching React state and TagContext
+ */
+class TagCacheManager {
+
+  /**
+   * @callback generateCacheKey
+   * @param {TagContext} tagContext - TagContext instance
+   * @returns {string} Cache key from TagContext
+   *
+   */
+
+  /**
+   * Constructor
+   * @param {string} cacheNs Used as LocalStorageManager namespace
+   * @param {generateCacheKey} generateCacheKey
+   */
+  constructor(cacheNs, generateCacheKey) {
+    if (cacheNs == null) {
+      throw new Error('args \'cacheNs\' is required.');
+    }
+    if (generateCacheKey == null) {
+      throw new Error('args \'generateCacheKey\' is required.');
+    }
+    if (typeof generateCacheKey !== 'function') {
+      throw new Error('args \'generateCacheKey\' should be function.');
+    }
+
+    this.cacheNs = cacheNs;
+    this.generateCacheKey = generateCacheKey;
+  }
+
+  /**
+   * Retrieve state cache object from local storage
+   * @param {TagContext} tagContext
+   * @returns {object} a cache object that correspont to the specified `tagContext`
+   */
+  getStateCache(tagContext) {
+    const localStorageManager = LocalStorageManager.getInstance();
+
+    const key = this.generateCacheKey(tagContext);
+    const stateCache = localStorageManager.retrieveFromSessionStorage(this.cacheNs, key);
+
+    return stateCache;
+  }
+
+  /**
+   * store state object of React Component with specified key
+   *
+   * @param {TagContext} tagContext
+   * @param {object} state state object of React Component
+   */
+  cacheState(tagContext, state) {
+    const localStorageManager = LocalStorageManager.getInstance();
+    const key = this.generateCacheKey(tagContext);
+    localStorageManager.saveToSessionStorage(this.cacheNs, key, state);
+  }
+
+  /**
+   * clear all state caches
+   */
+  clearAllStateCaches() {
+    const localStorageManager = LocalStorageManager.getInstance();
+    localStorageManager.clearAllStateCaches(this.cacheNs);
+  }
+
+}
+
+module.exports = TagCacheManager;

+ 59 - 0
packages/core/src/plugin/util/args-parser.js

@@ -0,0 +1,59 @@
+/**
+ * Arguments parser for custom tag
+ */
+class ArgsParser {
+
+  /**
+   * @typedef ParseArgsResult
+   * @property {string} firstArgsKey - key of the first argument
+   * @property {string} firstArgsValue - value of the first argument
+   * @property {object} options - key of the first argument
+   */
+
+  /**
+   * parse plugin argument strings
+   *
+   * @static
+   * @param {string} str
+   * @returns {ParseArgsResult}
+   */
+  static parse(str) {
+    let firstArgsKey = null;
+    let firstArgsValue = null;
+    const options = {};
+
+    if (str != null && str.length > 0) {
+      const splittedArgs = str.split(',');
+
+      splittedArgs.forEach((rawArg, index) => {
+        const arg = rawArg.trim();
+
+        // parse string like 'key1=value1, key2=value2, ...'
+        // see https://regex101.com/r/pYHcOM/1
+        const match = arg.match(/([^=]+)=?(.+)?/);
+
+        if (match == null) {
+          return;
+        }
+
+        const key = match[1];
+        const value = match[2] || true;
+        options[key] = value;
+
+        if (index === 0) {
+          firstArgsKey = key;
+          firstArgsValue = value;
+        }
+      });
+    }
+
+    return {
+      firstArgsKey,
+      firstArgsValue,
+      options,
+    };
+  }
+
+}
+
+module.exports = ArgsParser;

+ 88 - 0
packages/core/src/plugin/util/custom-tag-utils.js

@@ -0,0 +1,88 @@
+const TagContext = require('../model/tag-context');
+
+/**
+ * @private
+ *
+ * create random strings
+ * @see http://qiita.com/ryounagaoka/items/4736c225bdd86a74d59c
+ *
+ * @param {number} length
+ * @return {string} random strings
+ */
+function createRandomStr(length) {
+  const bag = 'abcdefghijklmnopqrstuvwxyz0123456789';
+  let generated = '';
+  for (let i = 0; i < length; i++) {
+    generated += bag[Math.floor(Math.random() * bag.length)];
+  }
+  return generated;
+}
+
+/**
+ * @typedef FindTagAndReplaceResult
+ * @property {string} html - HTML string
+ * @property {Object} tagContextMap - Object.<string, [TagContext]{@link ../model/tag-context.html#TagContext}>
+ *
+ * @memberof customTagUtils
+ */
+/**
+ * @param {RegExp} tagPattern
+ * @param {string} html
+ * @param {function} replace replace function
+ * @return {FindTagAndReplaceResult}
+ *
+ * @memberof customTagUtils
+ */
+function findTagAndReplace(tagPattern, html, replace) {
+  let replacedHtml = html;
+  const tagContextMap = {};
+
+  if (tagPattern == null || html == null) {
+    return { html: replacedHtml, tagContextMap };
+  }
+
+  // see: https://regex101.com/r/NQq3s9/9
+  const pattern = new RegExp(`\\$(${tagPattern.source})\\((.*?)\\)(?=[<\\[\\s\\$])|\\$(${tagPattern.source})\\((.*)\\)(?![<\\[\\s\\$])`, 'g');
+
+  replacedHtml = html.replace(pattern, (all, group1, group2, group3, group4) => {
+    const tagExpression = all;
+    const method = (group1 || group3).trim();
+    const args = (group2 || group4 || '').trim();
+
+    // create contexts
+    const tagContext = new TagContext({ tagExpression, method, args });
+
+    if (replace != null) {
+      return replace(tagContext);
+    }
+
+    // replace with empty dom
+    const domId = `${method}-${createRandomStr(8)}`;
+    tagContextMap[domId] = tagContext;
+    return `<div id="${domId}"></div>`;
+  });
+
+  return { html: replacedHtml, tagContextMap };
+}
+
+/**
+ * @namespace customTagUtils
+ */
+module.exports = {
+  findTagAndReplace,
+  /**
+   * Context class used by findTagAndReplace
+   * @memberof customTagUtils
+   */
+  TagContext,
+  /**
+   * [ArgsParser]{@link ./args-parser#ArgsParser}
+   * @memberof customTagUtils
+   */
+  ArgsParser: require('./args-parser'),
+  /**
+   * [OptionParser]{@link ./option-parser#OptionParser}
+   * @memberof customTagUtils
+   */
+  OptionParser: require('./option-parser'),
+};

+ 70 - 0
packages/core/src/plugin/util/option-parser.js

@@ -0,0 +1,70 @@
+/**
+ * Options parser for custom tag
+ */
+class OptionParser {
+
+  /**
+   * @typedef ParseRangeResult
+   * @property {number} start - start index
+   * @property {number} end - end index
+   */
+
+  /**
+   * Parse range expression
+   *
+   * <ul>
+   *  <li>ex:</li>
+   *  <ul>
+   *    <li>1:2 -> { start: 1, end: 2 }</li>
+   *    <li>1:  -> { start: 1, end: -1 }</li>
+   *    <li>2+3 -> { start: 1, end: 5 }</li>
+   *  </ul>
+   * </ul>
+   *
+   * @see https://regex101.com/r/w4KCwC/4
+   *
+   * @static
+   * @param {string} str
+   * @returns {ParseRangeResult}
+   */
+  static parseRange(str) {
+    if (str == null) {
+      return null;
+    }
+
+    // see: https://regex101.com/r/w4KCwC/4
+    const match = str.match(/^(-?[0-9]+)(([:+]{1})(-?[0-9]+)?)?$/);
+    if (!match) {
+      return null;
+    }
+
+    // determine start
+    let start;
+    let end;
+
+    // has operator
+    if (match[3] != null) {
+      start = +match[1];
+      const operator = match[3];
+
+      // determine end
+      if (operator === ':') {
+        end = +match[4] || -1; // set last(-1) if undefined
+      }
+      else if (operator === '+') {
+        end = +match[4] || 0; // plus zero if undefined
+        end += start;
+      }
+    }
+    // don't have operator
+    else {
+      start = 1;
+      end = +match[1];
+    }
+
+    return { start, end };
+  }
+
+}
+
+module.exports = OptionParser;

+ 56 - 0
packages/core/src/service/localstorage-manager.js

@@ -0,0 +1,56 @@
+let _instance = null;
+class LocalStorageManager {
+
+  static getInstance() {
+    if (_instance == null) {
+      _instance = new LocalStorageManager();
+    }
+
+    return _instance;
+  }
+
+  /**
+   * retrieve and return parsed JSON object
+   * @param {string} namespace
+   * @param {string} key
+   * @returns {object}
+   */
+  retrieveFromSessionStorage(namespace, key) {
+    const item = JSON.parse(sessionStorage.getItem(namespace)) || {};
+    if (key != null) {
+      return item[key];
+    }
+    return item;
+  }
+
+  /**
+   * save JavaScript object as stringified JSON object
+   *
+   * @param {string} namespace
+   * @param {string | object} cacheObjOrKey cache object or key (if third param is undefined)
+   * @param {object} cacheObj
+   */
+  saveToSessionStorage(namespace, cacheObjOrKey, cacheObj) {
+    let item = JSON.parse(sessionStorage.getItem(namespace)) || {};
+    if (cacheObj !== undefined) {
+      const key = cacheObjOrKey;
+      item[key] = cacheObj;
+    }
+    else {
+      item = cacheObjOrKey;
+    }
+    sessionStorage.setItem(namespace, JSON.stringify(item));
+  }
+
+  /**
+   * clear all state caches
+   *
+   * @param {string} namespace
+   */
+  clearAllStateCaches(namespace) {
+    sessionStorage.removeItem(namespace);
+  }
+
+}
+
+module.exports = LocalStorageManager;

+ 125 - 0
packages/core/src/test/plugin/service/tag-cache-manager.test.js

@@ -0,0 +1,125 @@
+/* eslint-disable import/first */
+
+// import each from 'jest-each';
+jest.mock('~/service/localstorage-manager');
+
+import LocalStorageManager from '~/service/localstorage-manager';
+import TagCacheManager from '~/plugin/service/tag-cache-manager';
+/* eslint-enable import/first */
+
+describe('TagCacheManager.constructor', () => {
+
+  test('throws Exception when \'cacheNs\' is null', () => {
+    const generateCacheKeyMock = jest.fn();
+
+    expect(() => {
+      // eslint-disable-next-line no-new
+      new TagCacheManager(null, generateCacheKeyMock);
+    }).toThrowError(/cacheNs/);
+  });
+
+  test('throws Exception when \'generateCacheKey\' is null', () => {
+    expect(() => {
+      // eslint-disable-next-line no-new
+      new TagCacheManager('dummy ns', null);
+    }).toThrowError(/generateCacheKey/);
+  });
+
+  test('throws Exception when \'generateCacheKey\' is not function', () => {
+    expect(() => {
+      // eslint-disable-next-line no-new
+      new TagCacheManager('dummy ns', {});
+    }).toThrowError(/generateCacheKey/);
+  });
+
+  test('set params', () => {
+    const generateCacheKeyMock = jest.fn();
+
+    const instance = new TagCacheManager('dummy ns', generateCacheKeyMock);
+    expect(instance).not.toBeNull();
+    expect(instance.cacheNs).toBe('dummy ns');
+    expect(instance.generateCacheKey).toBe(generateCacheKeyMock);
+  });
+
+});
+
+describe('TagCacheManager', () => {
+
+  let generateCacheKeyMock = null;
+  let localStorageManagerMock = null;
+
+  let tagCacheManager = null;
+
+
+  beforeEach(() => {
+    generateCacheKeyMock = jest.fn();
+    localStorageManagerMock = jest.fn();
+
+    // mock for LocalStorageManager.getInstance
+    LocalStorageManager.getInstance = jest.fn();
+    LocalStorageManager.getInstance.mockReturnValue(localStorageManagerMock);
+
+    tagCacheManager = new TagCacheManager('dummy ns', generateCacheKeyMock);
+  });
+
+  test('.getStateCache', () => {
+    // partial mock
+    tagCacheManager.generateCacheKey = jest.fn().mockReturnValue('dummy key');
+
+    // mock for LocalStorageManager
+    const stateCacheMock = jest.fn();
+    localStorageManagerMock.retrieveFromSessionStorage = jest.fn();
+    localStorageManagerMock.retrieveFromSessionStorage
+      .mockReturnValue(stateCacheMock);
+
+    const tagContextMock = jest.fn();
+
+    // when
+    const result = tagCacheManager.getStateCache(tagContextMock);
+    // then
+    expect(result).not.toBeNull();
+    expect(result).toBe(stateCacheMock);
+    const generateCacheKeyMockCalls = tagCacheManager.generateCacheKey.mock.calls;
+    expect(generateCacheKeyMockCalls.length).toBe(1);
+    expect(generateCacheKeyMockCalls[0][0]).toBe(tagContextMock);
+    const retrieveFromSessionStorageMockCalls = localStorageManagerMock.retrieveFromSessionStorage.mock.calls;
+    expect(retrieveFromSessionStorageMockCalls.length).toBe(1);
+    expect(retrieveFromSessionStorageMockCalls[0][0]).toBe('dummy ns');
+    expect(retrieveFromSessionStorageMockCalls[0][1]).toBe('dummy key');
+  });
+
+  test('.getStateCache with state object', () => {
+    // partial mock
+    tagCacheManager.generateCacheKey = jest.fn().mockReturnValue('dummy key');
+
+    // mock for LocalStorageManager
+    localStorageManagerMock.saveToSessionStorage = jest.fn();
+
+    const tagContextMock = jest.fn();
+    const stateMock = jest.fn();
+
+    // when
+    tagCacheManager.cacheState(tagContextMock, stateMock);
+    // then
+    const generateCacheKeyMockCalls = tagCacheManager.generateCacheKey.mock.calls;
+    expect(generateCacheKeyMockCalls.length).toBe(1);
+    expect(generateCacheKeyMockCalls[0][0]).toBe(tagContextMock);
+    const saveToSessionStorageMockCalls = localStorageManagerMock.saveToSessionStorage.mock.calls;
+    expect(saveToSessionStorageMockCalls.length).toBe(1);
+    expect(saveToSessionStorageMockCalls[0][0]).toBe('dummy ns');
+    expect(saveToSessionStorageMockCalls[0][1]).toBe('dummy key');
+    expect(saveToSessionStorageMockCalls[0][2]).toBe(stateMock);
+  });
+
+  test('.clearAllStateCaches', () => {
+    // mock for LocalStorageManager
+    localStorageManagerMock.clearAllStateCaches = jest.fn();
+
+    // when
+    tagCacheManager.clearAllStateCaches();
+    // then
+    const clearAllStateCachesMockCalls = localStorageManagerMock.clearAllStateCaches.mock.calls;
+    expect(clearAllStateCachesMockCalls.length).toBe(1);
+    expect(clearAllStateCachesMockCalls[0][0]).toBe('dummy ns');
+  });
+});

+ 42 - 0
packages/core/src/test/plugin/util/args-parser.test.js

@@ -0,0 +1,42 @@
+import ArgsParser from '~/plugin/util/args-parser';
+
+describe('args-parser', () => {
+
+  test('.parse(null) returns default object', () => {
+    const result = ArgsParser.parse(null);
+
+    expect(result.firstArgsKey).toBeNull();
+    expect(result.firstArgsValue).toBeNull();
+    expect(result.options).toEqual({});
+  });
+
+  test('.parse(\'prefix=/Level1\') returns a valid results', () => {
+    const result = ArgsParser.parse('prefix=/Level1');
+
+    expect(result.firstArgsKey).toBe('prefix');
+    expect(result.firstArgsValue).toBe('/Level1');
+  });
+
+  test('.parse(\'key, opt1=1, opt2=2\') returns a valid results', () => {
+    const result = ArgsParser.parse('key, opt1=1, opt2=2');
+
+    expect(result.firstArgsKey).toBe('key');
+    expect(result.firstArgsValue).toBeTruthy();
+
+    expect(Object.keys(result.options).length).toBe(3);
+    expect(result.options.key).toBeTruthy();
+    expect(result.options.opt1).toBe('1');
+    expect(result.options.opt2).toBe('2');
+  });
+
+  test('.parse(\'key, \') returns a valid results', () => {
+    const result = ArgsParser.parse('key, ');
+
+    expect(result.firstArgsKey).toBe('key');
+    expect(result.firstArgsValue).toBeTruthy();
+
+    expect(Object.keys(result.options).length).toBe(1);
+    expect(result.options.key).toBeTruthy();
+  });
+
+});

+ 72 - 0
packages/core/src/test/plugin/util/custom-tag-utils.test.js

@@ -0,0 +1,72 @@
+import rewire from 'rewire';
+
+import customTagUtils from '~/plugin/util/custom-tag-utils';
+
+const rewiredCustomTagUtils = rewire('../../../plugin/util/custom-tag-utils');
+
+describe('customTagUtils', () => {
+
+  test('exports TagContext', () => {
+    expect(customTagUtils.TagContext).not.toBeNull();
+    expect(typeof customTagUtils.TagContext).toBe('function');
+  });
+
+  test('exports ArgsParser', () => {
+    expect(customTagUtils.ArgsParser).not.toBeNull();
+    expect(typeof customTagUtils.ArgsParser).toBe('function');
+  });
+
+  test('exports OptionParser', () => {
+    expect(customTagUtils.OptionParser).not.toBeNull();
+    expect(typeof customTagUtils.OptionParser).toBe('function');
+  });
+
+  test('.createRandomStr(10) returns random string', () => {
+    // get private resource
+    const createRandomStr = rewiredCustomTagUtils.__get__('createRandomStr');
+    expect(createRandomStr(10)).toMatch(/^[a-z0-9]{10}$/);
+  });
+
+  test('.findTagAndReplace() returns default object when tagPattern is null', () => {
+    const htmlMock = jest.fn();
+    htmlMock.replace = jest.fn();
+
+    const result = customTagUtils.findTagAndReplace(null, '');
+
+    expect(result).toEqual({ html: '', tagContextMap: {} });
+    expect(htmlMock.replace).not.toHaveBeenCalled();
+  });
+
+  test('.findTagAndReplace() returns default object when html is null', () => {
+    const tagPatternMock = jest.fn();
+    tagPatternMock.source = jest.fn();
+
+    const result = customTagUtils.findTagAndReplace(tagPatternMock, null);
+
+    expect(result).toEqual({ html: null, tagContextMap: {} });
+    expect(tagPatternMock.source).not.toHaveBeenCalled();
+  });
+
+  test('.findTagAndReplace() works correctly', () => {
+    // setup mocks for private function
+    rewiredCustomTagUtils.__set__('createRandomStr', (length) => {
+      return 'dummyDomId';
+    });
+
+    const tagPattern = /ls|lsx/;
+    const html = '<section><h1>header</h1>\n$ls(/)</section>';
+
+    const result = rewiredCustomTagUtils.findTagAndReplace(tagPattern, html);
+
+    expect(result.html).toMatch(/<section><h1>header<\/h1>\n<div id="ls-dummyDomId"><\/div>/);
+    expect(result.tagContextMap).toEqual({
+      'ls-dummyDomId': {
+        tagExpression: '$ls(/)',
+        method: 'ls',
+        args: '/',
+      },
+    });
+  });
+
+
+});

+ 32 - 0
packages/core/src/test/plugin/util/option-parser.test.js

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

+ 86 - 0
packages/core/src/test/service/localstorage-manager.test.js

@@ -0,0 +1,86 @@
+// eslint-disable-next-line import/no-unresolved
+import 'jest-localstorage-mock';
+
+import LocalStorageManager from '~/service/localstorage-manager';
+
+let localStorageManager = null;
+
+beforeEach(() => {
+  localStorageManager = LocalStorageManager.getInstance();
+
+  // == init jest-localstorage-mock
+  // reset the storage
+  localStorage.clear();
+  sessionStorage.clear();
+  // set preset data
+  sessionStorage.setItem('localstorage-manager-test', JSON.stringify({ foo: 'bar' }));
+  // reset mocks
+  localStorage.setItem.mockClear();
+  sessionStorage.setItem.mockClear();
+});
+
+describe('LocalStorageManager', () => {
+  test('.getInstance() returns the same instance', () => {
+    expect(LocalStorageManager.getInstance()).toBe(localStorageManager);
+  });
+
+  test('.retrieveFromSessionStorage() with unknown namespace returns the empty object', () => {
+    const item = localStorageManager.retrieveFromSessionStorage('unknown namespace');
+    expect(item).toEqual({});
+  });
+
+  test('.retrieveFromSessionStorage() without key returns the preset data', () => {
+    const item = localStorageManager.retrieveFromSessionStorage('localstorage-manager-test');
+    expect(item).toEqual({ foo: 'bar' });
+  });
+
+  test('.retrieveFromSessionStorage() with key returns the preset data', () => {
+    const item = localStorageManager.retrieveFromSessionStorage('localstorage-manager-test', 'foo');
+    expect(item).toBe('bar');
+  });
+
+  test('.saveToSessionStorage() without key works fine', () => {
+    localStorageManager.saveToSessionStorage(
+      'localstorage-manager-test',
+      { foo: { qux: 'quux' } },
+    );
+
+    expect(sessionStorage.__STORE__.length).toBe(1);
+    expect(sessionStorage.setItem)
+      .toHaveBeenLastCalledWith(
+        'localstorage-manager-test',
+        JSON.stringify({ foo: { qux: 'quux' } }),
+      );
+  });
+
+  test('.saveToSessionStorage() with key works fine', () => {
+    localStorageManager.saveToSessionStorage(
+      'localstorage-manager-test',
+      'baz',
+      { qux: 'quux' },
+    );
+
+    expect(sessionStorage.__STORE__.length).toBe(1);
+    expect(sessionStorage.setItem)
+      .toHaveBeenLastCalledWith(
+        'localstorage-manager-test',
+        JSON.stringify({ foo: 'bar', baz: { qux: 'quux' } }),
+      );
+  });
+
+  test('.saveToSessionStorage() with unknown key works fine', () => {
+    localStorageManager.saveToSessionStorage(
+      'localstorage-manager-test-unknown-key',
+      'baz',
+      { qux: 'quux' },
+    );
+
+    expect(sessionStorage.__STORE__.length).toBe(2);
+    expect(sessionStorage.setItem)
+      .toHaveBeenLastCalledWith(
+        'localstorage-manager-test-unknown-key',
+        JSON.stringify({ baz: { qux: 'quux' } }),
+      );
+  });
+
+});

+ 21 - 0
packages/core/src/test/util/env-utils.test.js

@@ -0,0 +1,21 @@
+import envUtils from '~/utils/env-utils';
+
+
+describe('env-utils', () => {
+  describe('.toBoolean', () => {
+
+    test('should convert to true', () => {
+      expect(envUtils.toBoolean('true')).toBe(true);
+      expect(envUtils.toBoolean('True')).toBe(true);
+      expect(envUtils.toBoolean(1)).toBe(true);
+    });
+
+    test('should convert to false', () => {
+      expect(envUtils.toBoolean(undefined)).toBe(false);
+      expect(envUtils.toBoolean(null)).toBe(false);
+      expect(envUtils.toBoolean('false')).toBe(false);
+      expect(envUtils.toBoolean(0)).toBe(false);
+    });
+
+  });
+});

+ 2 - 1
packages/app/src/test/utils/path-utils.test.js → packages/core/src/test/util/page-path-utils.js

@@ -1,5 +1,6 @@
-const { isTopPage, convertToNewAffiliationPath, isCreatablePage } = require('~/utils/path-utils');
+import { pagePathUtils } from '~/utils/page-path-utils';
 
+const { isTopPage, convertToNewAffiliationPath, isCreatablePage } = pagePathUtils;
 
 describe('TopPage Path test', () => {
   test('Path is only "/"', () => {

+ 26 - 0
packages/core/src/test/util/path-utils.test.js

@@ -0,0 +1,26 @@
+import * as pathUtils from '~/utils/path-utils';
+
+
+describe('page-utils', () => {
+  describe('.normalizePath', () => {
+    test('should return the root path with empty string', () => {
+      expect(pathUtils.normalizePath('')).toBe('/');
+    });
+
+    test('should return the root path as is', () => {
+      expect(pathUtils.normalizePath('/')).toBe('/');
+    });
+
+    test('should add heading slash', () => {
+      expect(pathUtils.normalizePath('hoge/fuga')).toBe('/hoge/fuga');
+    });
+
+    test('should remove trailing slash', () => {
+      expect(pathUtils.normalizePath('/hoge/fuga/')).toBe('/hoge/fuga');
+    });
+
+    test('should remove unnecessary slashes', () => {
+      expect(pathUtils.normalizePath('//hoge/fuga//')).toBe('/hoge/fuga');
+    });
+  });
+});

+ 47 - 0
packages/core/src/utils/basic-interceptor.js

@@ -0,0 +1,47 @@
+/**
+ * Basic Interceptor class
+ */
+class BasicInterceptor {
+
+  /**
+   * getter for id
+   */
+  getId() {
+    return this.constructor.name;
+  }
+
+  /**
+   * return whether this interceptor works by specified contextName
+   *
+   * @param {string} contextName
+   * @return {boolean}
+   */
+  isInterceptWhen(contextName) {
+    // implement this
+    return false;
+  }
+
+  /**
+   * return whether this interceptor processes in parallel mode or sequencial mode
+   * @return {boolean}
+   */
+  isProcessableParallel() {
+    // implement this
+    return true;
+  }
+
+  /**
+   * process method
+   *
+   * @param {string} contextName
+   * @param {any} args
+   * @return {Promise<any>}
+   */
+  process(contextName, ...args) {
+    // override this
+    return Promise.resolve(...args);
+  }
+
+}
+
+module.exports = BasicInterceptor;

+ 17 - 0
packages/core/src/utils/env-utils.js

@@ -0,0 +1,17 @@
+/**
+ * convert to boolean
+ *
+ * @param {string} value
+ * @returns {boolean}
+ * @memberof envUtils
+ */
+function toBoolean(value) {
+  return /^(true|1)$/i.test(value);
+}
+
+/**
+ * @namespace envUtils
+ */
+module.exports = {
+  toBoolean,
+};

+ 2 - 1
packages/app/src/utils/path-utils.ts → packages/core/src/utils/page-path-utils.ts

@@ -76,6 +76,7 @@ export const isCreatablePage = (path: string): boolean => {
  * return user path
  * @param user
  */
+// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
 export const userPageRoot = (user: any): string => {
   if (!user || !user.username) {
     return '';
@@ -116,7 +117,7 @@ export const encodeSpaces = (path?:string): string | undefined => {
  * @param {string} paths
  * @returns {string}
  */
-export const generateEditorPath = (...paths) => {
+export const generateEditorPath = (...paths: string[]): string => {
   const joinedPath = [...paths].join('/');
 
   if (!isCreatablePage(joinedPath)) {

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

@@ -0,0 +1,108 @@
+/**
+ * @private
+ *
+ *
+ * @param {string} path
+ * @returns {RegExpMatchArray}
+ * @memberof pathUtils
+ */
+function matchSlashes(path) {
+  // https://regex101.com/r/Z21fEd/5
+  return path.match(/^((\/+)?(.+?))(\/+)?$/);
+}
+
+/**
+ *
+ * @param {string} path
+ * @returns {boolean}
+ * @memberof pathUtils
+ */
+export function hasHeadingSlash(path) {
+  if (path === '') {
+    return false;
+  }
+  const match = matchSlashes(path);
+  return (match[2] != null);
+}
+
+/**
+ *
+ * @param {string} path
+ * @returns {boolean}
+ * @memberof pathUtils
+ */
+export function hasTrailingSlash(path) {
+  if (path === '') {
+    return false;
+  }
+  const match = matchSlashes(path);
+  return (match[4] != null);
+}
+
+/**
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function addHeadingSlash(path) {
+  if (path === '/') {
+    return path;
+  }
+
+  if (!hasHeadingSlash(path)) {
+    return `/${path}`;
+  }
+  return path;
+}
+
+/**
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function addTrailingSlash(path) {
+  if (path === '/') {
+    return path;
+  }
+
+  if (!hasTrailingSlash(path)) {
+    return `${path}/`;
+  }
+  return path;
+}
+
+/**
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function removeTrailingSlash(path) {
+  if (path === '/') {
+    return path;
+  }
+
+  const match = matchSlashes(path);
+  return match[1];
+}
+
+/**
+ * A short-hand method to add heading slash and remove trailing slash.
+ *
+ * @param {string} path
+ * @returns {string}
+ * @memberof pathUtils
+ */
+export function normalizePath(path) {
+  if (path === '' || path === '/') {
+    return '/';
+  }
+
+  const match = matchSlashes(path);
+  if (match == null) {
+    return '/';
+  }
+  return `/${match[3]}`;
+}

+ 1 - 1
packages/app/src/utils/template-checker.ts → packages/core/src/utils/template-checker.ts

@@ -2,7 +2,7 @@
  * templateChecker
  */
 
-export default function checkTemplatePath(path: string): boolean {
+export function checkTemplatePath(path: string): boolean {
   if (path.match(/.*\/_{1,2}template$/)) {
     return true;
   }

+ 11 - 0
packages/core/tsconfig.base.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+  },
+  "include": [
+    "src"
+  ],
+  "exclude": [
+    "src/test"
+  ]
+}

+ 17 - 0
packages/core/tsconfig.build.cjs.json

@@ -0,0 +1,17 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "./src",
+    "outDir": "dist/cjs",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": true,
+    "noEmit": false,
+    "inlineSources": true,
+
+    "baseUrl": ".",
+    "paths": {
+    }
+  }
+}

+ 19 - 0
packages/core/tsconfig.build.esm.json

@@ -0,0 +1,19 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "module": "esnext",
+
+    "rootDir": "./src",
+    "outDir": "dist/esm",
+    "declaration": true,
+    "noResolve": false,
+    "preserveConstEnums": true,
+    "sourceMap": true,
+    "noEmit": false,
+    "inlineSources": true,
+
+    "baseUrl": ".",
+    "paths": {
+    }
+  }
+}

+ 9 - 0
packages/core/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.base.json",
+  "compilerOptions": {
+    "baseUrl": ".",
+    "paths": {
+      "~/*": ["./src/*"]
+    }
+  }
+}

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