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

Merge branch 'imprv/load-template-plugins-on-server-2' into imprv/123476-125850-responseive-template-modal

ryoji-s 2 лет назад
Родитель
Сommit
825e441367
30 измененных файлов с 485 добавлено и 399 удалено
  1. 27 6
      apps/app/src/components/TemplateModal/TemplateModal.tsx
  2. 1 1
      apps/app/src/features/growi-plugin/client/components/Admin/PluginsExtensionPageContents/PluginInstallerForm.tsx
  3. 26 20
      apps/app/src/features/growi-plugin/server/services/growi-plugin.ts
  4. 18 5
      apps/app/src/features/templates/server/routes/apiv3/index.ts
  5. 4 3
      apps/app/src/features/templates/stores/template.tsx
  6. 1 1
      apps/app/src/stores/modal.tsx
  7. 12 0
      packages/pluginkit/src/model/growi-plugin-package-data.ts
  8. 4 1
      packages/pluginkit/src/model/growi-plugin-validation-data.ts
  9. 1 1
      packages/pluginkit/src/model/growi-plugin-validation-error.ts
  10. 1 0
      packages/pluginkit/src/model/index.ts
  11. 11 0
      packages/pluginkit/src/v4/server/utils/common/import-package-json.spec.ts
  12. 9 0
      packages/pluginkit/src/v4/server/utils/common/import-package-json.ts
  13. 2 0
      packages/pluginkit/src/v4/server/utils/common/index.ts
  14. 117 0
      packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.spec.ts
  15. 6 6
      packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.ts
  16. 1 1
      packages/pluginkit/src/v4/server/utils/index.ts
  17. 0 11
      packages/pluginkit/src/v4/server/utils/package-json/import.spec.ts
  18. 0 6
      packages/pluginkit/src/v4/server/utils/package-json/import.ts
  19. 0 2
      packages/pluginkit/src/v4/server/utils/package-json/index.ts
  20. 0 117
      packages/pluginkit/src/v4/server/utils/package-json/validate.spec.ts
  21. 0 205
      packages/pluginkit/src/v4/server/utils/template.ts
  22. 16 0
      packages/pluginkit/src/v4/server/utils/template/get-markdown.ts
  23. 29 0
      packages/pluginkit/src/v4/server/utils/template/get-status.ts
  24. 4 0
      packages/pluginkit/src/v4/server/utils/template/index.ts
  25. 103 0
      packages/pluginkit/src/v4/server/utils/template/scan.ts
  26. 36 0
      packages/pluginkit/src/v4/server/utils/template/validate-all-locales.ts
  27. 33 0
      packages/pluginkit/src/v4/server/utils/template/validate-growi-plugin-directive.ts
  28. 1 6
      packages/pluginkit/tsconfig.json
  29. 0 2
      packages/pluginkit/vite.config.ts
  30. 22 5
      packages/preset-templates/test/index.test.ts

+ 27 - 6
apps/app/src/components/TemplateModal/TemplateModal.tsx

@@ -21,7 +21,7 @@ import {
 } from 'reactstrap';
 
 import { useSWRxTemplate, useSWRxTemplates } from '~/features/templates/stores';
-import { useTemplateModal } from '~/stores/modal';
+import { useTemplateModal, type TemplateModalStatus } from '~/stores/modal';
 import { usePersonalSettings } from '~/stores/personal-settings';
 import { usePreviewOptions } from '~/stores/renderer';
 import loggerFactory from '~/utils/logger';
@@ -109,11 +109,15 @@ const TemplateMenu: React.FC<TemplateMenuProps> = ({
   );
 };
 
-export const TemplateModal = (): JSX.Element => {
-  const { t } = useTranslation(['translation', 'commons']);
+type TemplateModalSubstanceProps = {
+  templateModalStatus: TemplateModalStatus,
+  close: () => void,
+}
 
+const TemplateModalSubstance = (props: TemplateModalSubstanceProps): JSX.Element => {
+  const { templateModalStatus, close } = props;
 
-  const { data: templateModalStatus, close } = useTemplateModal();
+  const { t } = useTranslation(['translation', 'commons']);
 
   const { data: personalSettingsInfo } = usePersonalSettings();
   const { data: rendererOptions } = usePreviewOptions();
@@ -170,12 +174,12 @@ export const TemplateModal = (): JSX.Element => {
     }
   }, [templateModalStatus?.isOpened]);
 
-  if (templateSummaries == null || templateModalStatus == null) {
+  if (templateSummaries == null) {
     return <></>;
   }
 
   return (
-    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="xl" autoFocus={false}>
+    <>
       <ModalHeader tag="h4" toggle={close} className="bg-primary text-light">
         {t('template.modal_label.Select template')}
       </ModalHeader>
@@ -258,6 +262,23 @@ export const TemplateModal = (): JSX.Element => {
           {t('commons:Insert')}
         </button>
       </ModalFooter>
+    </>
+  );
+};
+
+
+export const TemplateModal = (): JSX.Element => {
+  const { data: templateModalStatus, close } = useTemplateModal();
+
+  if (templateModalStatus == null) {
+    return <></>;
+  }
+
+  return (
+    <Modal className="link-edit-modal" isOpen={templateModalStatus.isOpened} toggle={close} size="xl" autoFocus={false}>
+      { templateModalStatus.isOpened && (
+        <TemplateModalSubstance templateModalStatus={templateModalStatus} close={close} />
+      ) }
     </Modal>
   );
 };

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

@@ -25,7 +25,7 @@ export const PluginInstallerForm = (): JSX.Element => {
 
     const pluginInstallerForm: IGrowiPluginOrigin = {
       url,
-      ghBranch,
+      ghBranch: ghBranch || 'main',
       // ghTag,
     };
 

+ 26 - 20
apps/app/src/features/growi-plugin/server/services/growi-plugin.ts

@@ -1,7 +1,9 @@
 import fs, { readFileSync } from 'fs';
 import path from 'path';
 
-import { GrowiPluginType, GrowiThemeMetadata, ViteManifest } from '@growi/core';
+import { GrowiPluginType, type GrowiThemeMetadata, type ViteManifest } from '@growi/core';
+import type { GrowiPluginPackageData } from '@growi/pluginkit';
+import { importPackageJson, validateGrowiDirective } from '@growi/pluginkit/dist/v4/server';
 // eslint-disable-next-line no-restricted-imports
 import axios from 'axios';
 import mongoose from 'mongoose';
@@ -188,7 +190,7 @@ export class GrowiPluginService implements IGrowiPluginService {
         }).catch((err) => {
           logger.error(err);
           // eslint-disable-next-line prefer-promise-reject-errors
-          rejects('Filed to download file.');
+          rejects('Failed to download file.');
         });
     });
   }
@@ -203,7 +205,7 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to unzip.');
+      throw new Error('Failed to unzip.');
     }
   }
 
@@ -212,34 +214,38 @@ export class GrowiPluginService implements IGrowiPluginService {
   }
 
   // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, max-len
-  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'));
+  private static async detectPlugins(
+      origin: IGrowiPluginOrigin, ghOrganizationName: string, ghReposName: string,
+      opts?: {
+        packageRootPath: string,
+        parentPackageData: GrowiPluginPackageData,
+      },
+  ): Promise<IGrowiPlugin[]> {
+    const packageRootPath = opts?.packageRootPath ?? path.resolve(pluginStoringPath, ghOrganizationName, ghReposName);
 
-    const { growiPlugin } = packageJson;
-    const {
-      name: packageName, description: packageDesc, author: packageAuthor,
-    } = parentPackageJson ?? packageJson;
+    // validate
+    const data = await validateGrowiDirective(packageRootPath);
 
+    const packageData = opts?.parentPackageData ?? importPackageJson(packageRootPath);
 
-    if (growiPlugin == null) {
-      throw new Error('This package does not include \'growiPlugin\' section.');
-    }
+    const { growiPlugin } = data;
+    const {
+      name: packageName, description: packageDesc, author: packageAuthor,
+    } = packageData;
 
     // detect sub plugins for monorepo
     if (growiPlugin.isMonorepo && growiPlugin.packages != null) {
       const plugins = await Promise.all(
         growiPlugin.packages.map(async(subPackagePath) => {
-          const subPackageInstalledPath = path.join(ghReposName, subPackagePath);
-          return this.detectPlugins(origin, subPackageInstalledPath, packageJson);
+          return this.detectPlugins(origin, ghOrganizationName, ghReposName, {
+            packageRootPath: path.join(packageRootPath, subPackagePath),
+            parentPackageData: packageData,
+          });
         }),
       );
       return plugins.flat();
     }
 
-    if (growiPlugin.types == null) {
-      throw new Error('\'growiPlugin\' section must have a \'types\' property.');
-    }
     const plugin = {
       isEnabled: true,
       installedPath: `${ghOrganizationName}/${ghReposName}`,
@@ -290,7 +296,7 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to delete plugin repository.');
+      throw new Error('Failed to delete plugin repository.');
     }
 
     try {
@@ -298,7 +304,7 @@ export class GrowiPluginService implements IGrowiPluginService {
     }
     catch (err) {
       logger.error(err);
-      throw new Error('Filed to delete plugin from GrowiPlugin documents.');
+      throw new Error('Failed to delete plugin from GrowiPlugin documents.');
     }
 
     return growiPlugins.meta.name;

+ 18 - 5
apps/app/src/features/templates/server/routes/apiv3/index.ts

@@ -1,3 +1,4 @@
+import { TemplateSummary } from '@growi/pluginkit/dist/v4';
 import { scanAllTemplateStatus, getMarkdown } from '@growi/pluginkit/dist/v4/server';
 import express from 'express';
 import { param, query } from 'express-validator';
@@ -21,18 +22,30 @@ const validator = {
   ],
 };
 
+
+// cache object
+let presetTemplateSummaries: TemplateSummary[];
+
+
 module.exports = (crowi) => {
   const loginRequiredStrictly = require('~/server/middlewares/login-required')(crowi);
 
   router.get('/', loginRequiredStrictly, validator.list, apiV3FormValidator, async(req, res: ApiV3Response) => {
     const { includeInvalidTemplates } = req.query;
 
-    const presetTemplatesRoot = resolveFromRoot('../../node_modules/@growi/preset-templates');
-    const summaries = await scanAllTemplateStatus(presetTemplatesRoot, {
-      returnsInvalidTemplates: includeInvalidTemplates,
-    });
+    // scan preset templates
+    if (presetTemplateSummaries == null) {
+      const presetTemplatesRoot = resolveFromRoot('../../node_modules/@growi/preset-templates');
+      presetTemplateSummaries = await scanAllTemplateStatus(presetTemplatesRoot, {
+        returnsInvalidTemplates: includeInvalidTemplates,
+      });
+    }
 
-    return res.apiv3({ summaries });
+    return res.apiv3({
+      summaries: [
+        ...presetTemplateSummaries,
+      ],
+    });
   });
 
   router.get('/preset-templates/:templateId/:locale', loginRequiredStrictly, validator.get, apiV3FormValidator, async(req, res: ApiV3Response) => {

+ 4 - 3
apps/app/src/features/templates/stores/template.tsx

@@ -1,10 +1,11 @@
 import { getLocalizedTemplate, type TemplateSummary } from '@growi/pluginkit/dist/v4';
-import useSWR, { type SWRResponse } from 'swr';
+import type { SWRResponse } from 'swr';
+import useSWRImmutable from 'swr/immutable';
 
 import { apiv3Get } from '~/client/util/apiv3-client';
 
 export const useSWRxTemplates = (): SWRResponse<TemplateSummary[], Error> => {
-  return useSWR(
+  return useSWRImmutable(
     '/templates',
     endpoint => apiv3Get<{ summaries: TemplateSummary[] }>(endpoint).then(res => res.data.summaries),
   );
@@ -14,7 +15,7 @@ export const useSWRxTemplate = (summary: TemplateSummary | undefined, locale?: s
   const pluginId = summary?.default.pluginId;
   const targetTemplate = getLocalizedTemplate(summary, locale);
 
-  return useSWR(
+  return useSWRImmutable(
     () => {
       if (targetTemplate == null) {
         return null;

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

@@ -633,7 +633,7 @@ type TemplateSelectedCallback = (templateText: string) => void;
 type TemplateModalOptions = {
   onSubmit?: TemplateSelectedCallback,
 }
-type TemplateModalStatus = TemplateModalOptions & {
+export type TemplateModalStatus = TemplateModalOptions & {
   isOpened: boolean,
 }
 

+ 12 - 0
packages/pluginkit/src/model/growi-plugin-package-data.ts

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

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

@@ -1,8 +1,11 @@
 import { GrowiPluginType } from '@growi/core/dist/consts';
 
+import { GrowiPluginDirective } from './growi-plugin-package-data';
+
 export type GrowiPluginValidationData = {
   projectDirRoot: string,
-  schemaVersion?: number,
+  growiPlugin: GrowiPluginDirective,
+  schemaVersion: number,
   expectedPluginType?: GrowiPluginType,
   actualPluginTypes?: GrowiPluginType[],
 };

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

@@ -3,7 +3,7 @@ import ExtensibleCustomError from 'extensible-custom-error';
 import type { GrowiPluginValidationData } from './growi-plugin-validation-data';
 
 
-export class GrowiPluginValidationError<E extends GrowiPluginValidationData = GrowiPluginValidationData> extends ExtensibleCustomError {
+export class GrowiPluginValidationError<E extends Partial<GrowiPluginValidationData> = Partial<GrowiPluginValidationData>> extends ExtensibleCustomError {
 
   data?: E;
 

+ 1 - 0
packages/pluginkit/src/model/index.ts

@@ -1,2 +1,3 @@
+export * from './growi-plugin-package-data';
 export * from './growi-plugin-validation-data';
 export * from './growi-plugin-validation-error';

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

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

+ 9 - 0
packages/pluginkit/src/v4/server/utils/common/import-package-json.ts

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

+ 2 - 0
packages/pluginkit/src/v4/server/utils/common/index.ts

@@ -0,0 +1,2 @@
+export * from './import-package-json';
+export * from './validate-growi-plugin-directive';

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

@@ -0,0 +1,117 @@
+import { GrowiPluginType } from '@growi/core/dist/consts';
+
+import examplePkg from '../../../../../test/fixtures/example-package/template1/package.json';
+
+
+import { validateGrowiDirective } from './validate-growi-plugin-directive';
+
+const mocks = vi.hoisted(() => {
+  return {
+    importPackageJsonMock: vi.fn(),
+  };
+});
+
+vi.mock('./import-package-json', () => {
+  return { importPackageJson: mocks.importPackageJsonMock };
+});
+
+describe('validateGrowiDirective()', () => {
+
+  it('returns a data object', async() => {
+    // setup
+    mocks.importPackageJsonMock.mockReturnValue(examplePkg);
+
+    // when
+    const data = validateGrowiDirective('package.json');
+
+    // then
+    expect(data).not.toBeNull();
+  });
+
+  it("with the 'expectedPluginType' argument returns a data object", async() => {
+    // setup
+    mocks.importPackageJsonMock.mockReturnValue(examplePkg);
+
+    // when
+    const data = validateGrowiDirective('package.json', GrowiPluginType.Template);
+
+    // then
+    expect(data).not.toBeNull();
+  });
+
+  describe('should throw an GrowiPluginValidationError', () => {
+
+    it("when the pkg does not have 'growiPlugin' directive", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({});
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The package.json does not have 'growiPlugin' directive.");
+    });
+
+    it("when the 'schemaVersion' is NaN", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 'foo',
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
+    });
+
+    it("when the 'schemaVersion' is less than 4", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 3,
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
+    });
+
+    it("when the 'types' directive does not exist", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 4,
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json') };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive does not have 'types' directive.");
+    });
+
+    it("when the 'types' directive does not have expected plugin type", () => {
+      // setup
+      mocks.importPackageJsonMock.mockReturnValue({
+        growiPlugin: {
+          schemaVersion: 4,
+          types: [GrowiPluginType.Template],
+        },
+      });
+
+      // when
+      const caller = () => { validateGrowiDirective('package.json', GrowiPluginType.Script) };
+
+      // then
+      expect(caller).toThrow("The growiPlugin directive does not have expected plugin type in 'types' directive.");
+    });
+  });
+
+});

+ 6 - 6
packages/pluginkit/src/v4/server/utils/package-json/validate.ts → packages/pluginkit/src/v4/server/utils/common/validate-growi-plugin-directive.ts

@@ -1,17 +1,17 @@
 import { GrowiPluginType } from '@growi/core/dist/consts';
 
-import { type GrowiPluginValidationData, GrowiPluginValidationError } from '~/model';
+import { type GrowiPluginValidationData, GrowiPluginValidationError } from '../../../../model';
 
-import { importPackageJson } from './import';
+import { importPackageJson } from './import-package-json';
 
 
-export const validatePackageJson = async(projectDirRoot: string, expectedPluginType?: GrowiPluginType): Promise<GrowiPluginValidationData> => {
-  const pkg = await importPackageJson(projectDirRoot);
-
-  const data: GrowiPluginValidationData = { projectDirRoot };
+export const validateGrowiDirective = (projectDirRoot: string, expectedPluginType?: GrowiPluginType): GrowiPluginValidationData => {
+  const pkg = importPackageJson(projectDirRoot);
 
   const { growiPlugin } = pkg;
 
+  const data: GrowiPluginValidationData = { projectDirRoot, schemaVersion: NaN, growiPlugin };
+
   if (growiPlugin == null) {
     throw new GrowiPluginValidationError("The package.json does not have 'growiPlugin' directive.", data);
   }

+ 1 - 1
packages/pluginkit/src/v4/server/utils/index.ts

@@ -1,2 +1,2 @@
-export * from './package-json';
+export * from './common';
 export * from './template';

+ 0 - 11
packages/pluginkit/src/v4/server/utils/package-json/import.spec.ts

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

+ 0 - 6
packages/pluginkit/src/v4/server/utils/package-json/import.ts

@@ -1,6 +0,0 @@
-import path from 'path';
-
-export const importPackageJson = async(projectDirRoot: string): Promise<any> => {
-  const packageJsonUrl = path.resolve(projectDirRoot, 'package.json');
-  return import(packageJsonUrl);
-};

+ 0 - 2
packages/pluginkit/src/v4/server/utils/package-json/index.ts

@@ -1,2 +0,0 @@
-export * from './import';
-export * from './validate';

+ 0 - 117
packages/pluginkit/src/v4/server/utils/package-json/validate.spec.ts

@@ -1,117 +0,0 @@
-import { GrowiPluginType } from '@growi/core/dist/consts';
-
-import examplePkg from '^/test/fixtures/example-package/template1/package.json';
-
-
-import { validatePackageJson } from './validate';
-
-const mocks = vi.hoisted(() => {
-  return {
-    importPackageJsonMock: vi.fn(),
-  };
-});
-
-vi.mock('./import', () => {
-  return { importPackageJson: mocks.importPackageJsonMock };
-});
-
-describe('validatePackageJson()', () => {
-
-  it('returns a data object', async() => {
-    // setup
-    mocks.importPackageJsonMock.mockResolvedValue(examplePkg);
-
-    // when
-    const data = await validatePackageJson('package.json');
-
-    // then
-    expect(data).not.toBeNull();
-  });
-
-  it("with the 'expectedPluginType' argument returns a data object", async() => {
-    // setup
-    mocks.importPackageJsonMock.mockResolvedValue(examplePkg);
-
-    // when
-    const data = await validatePackageJson('package.json', GrowiPluginType.Template);
-
-    // then
-    expect(data).not.toBeNull();
-  });
-
-  describe('should throw an GrowiPluginValidationError', () => {
-
-    it("when the pkg does not have 'growiPlugin' directive", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({});
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The package.json does not have 'growiPlugin' directive.");
-    });
-
-    it("when the 'schemaVersion' is NaN", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 'foo',
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
-    });
-
-    it("when the 'schemaVersion' is less than 4", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 3,
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive must have a valid 'schemaVersion' directive.");
-    });
-
-    it("when the 'types' directive does not exist", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 4,
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json') };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive does not have 'types' directive.");
-    });
-
-    it("when the 'types' directive does not have expected plugin type", async() => {
-      // setup
-      mocks.importPackageJsonMock.mockResolvedValue({
-        growiPlugin: {
-          schemaVersion: 4,
-          types: [GrowiPluginType.Template],
-        },
-      });
-
-      // when
-      const caller = async() => { await validatePackageJson('package.json', GrowiPluginType.Script) };
-
-      // then
-      await expect(caller).rejects.toThrow("The growiPlugin directive does not have expected plugin type in 'types' directive.");
-    });
-  });
-
-});

+ 0 - 205
packages/pluginkit/src/v4/server/utils/template.ts

@@ -1,205 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-import { promisify } from 'util';
-
-import { GrowiPluginType } from '@growi/core/dist/consts';
-
-import type { GrowiPluginValidationData, GrowiTemplatePluginValidationData } from '~/model';
-import { GrowiPluginValidationError } from '~/model';
-
-import { isTemplateStatusValid, type TemplateStatus, type TemplateSummary } from '../../interfaces';
-
-import { importPackageJson, validatePackageJson } from './package-json';
-
-
-const statAsync = promisify(fs.stat);
-
-
-/**
- * An utility for template plugin which wrap 'validatePackageJson' of './package-json.ts' module
- * @param projectDirRoot
- */
-export const validateTemplatePluginPackageJson = async(projectDirRoot: string): Promise<GrowiTemplatePluginValidationData> => {
-  const data = await validatePackageJson(projectDirRoot, GrowiPluginType.Template);
-
-  const pkg = await importPackageJson(projectDirRoot);
-
-  // check supporting locales
-  const supportingLocales: string[] | undefined = pkg.growiPlugin.locales;
-  if (supportingLocales == null || supportingLocales.length === 0) {
-    throw new GrowiPluginValidationError<GrowiPluginValidationData & { supportingLocales?: string[] }>(
-      "Template plugin must have 'supportingLocales' and that must have one or more locales",
-      {
-        ...data,
-        supportingLocales,
-      },
-    );
-  }
-
-  return {
-    ...data,
-    supportingLocales,
-  };
-};
-
-
-type TemplateDirStatus = {
-  isTemplateExists: boolean,
-  meta?: { [key: string]: string },
-}
-
-async function getStats(tplDir: string): Promise<TemplateDirStatus> {
-  const markdownPath = path.resolve(tplDir, 'template.md');
-  const statForMarkdown = await statAsync(markdownPath);
-  const isTemplateExists = statForMarkdown.isFile();
-
-  const metaDataPath = path.resolve(tplDir, 'meta.json');
-  const statForMetaDataFile = await statAsync(metaDataPath);
-  const isMetaDataFileExists = statForMetaDataFile.isFile();
-
-  const result: TemplateDirStatus = {
-    isTemplateExists,
-    meta: isMetaDataFileExists ? await import(metaDataPath) : undefined,
-  };
-
-  return result;
-}
-
-
-export const scanTemplateStatus = async(
-    projectDirRoot: string,
-    templateId: string,
-    data: GrowiTemplatePluginValidationData,
-    opts?: {
-      pluginId?: string,
-    },
-): Promise<TemplateStatus[]> => {
-  const status: TemplateStatus[] = [];
-
-  const tplRootDirPath = path.resolve(projectDirRoot, 'dist', templateId);
-
-  let isDefaultPushed = false;
-  for await (const locale of data.supportingLocales) {
-    const tplDir = path.resolve(tplRootDirPath, locale);
-
-    try {
-      const stats = await getStats(tplDir);
-      const {
-        isTemplateExists, meta,
-      } = stats;
-
-      if (!isTemplateExists) throw new Error("'template.md does not exist.");
-      if (meta == null) throw new Error("'meta.md does not exist.");
-      if (meta?.title == null) throw new Error("'meta.md does not contain the title.");
-
-      const isDefault = !isDefaultPushed;
-      status.push({
-        pluginId: opts?.pluginId,
-        id: templateId,
-        locale,
-        isValid: true,
-        isDefault,
-        title: meta.title,
-        desc: meta.desc,
-      });
-      isDefaultPushed = true;
-    }
-    catch (err) {
-      status.push({
-        pluginId: opts?.pluginId,
-        id: templateId,
-        locale,
-        isValid: false,
-        invalidReason: err.message,
-      });
-    }
-  }
-
-  // eslint-disable-next-line no-console
-  console.debug({ status });
-
-  return status;
-};
-
-export const scanAllTemplateStatus = async(
-    projectDirRoot: string,
-    opts?: {
-      data?: GrowiTemplatePluginValidationData,
-      pluginId?: string,
-      returnsInvalidTemplates?: boolean,
-    },
-): Promise<TemplateSummary[]> => {
-
-  const data = opts?.data ?? await validateTemplatePluginPackageJson(projectDirRoot);
-
-  const summaries: TemplateSummary[] = [];
-
-  const distDirPath = path.resolve(projectDirRoot, 'dist');
-  const distDirFiles = fs.readdirSync(distDirPath);
-
-  for await (const templateId of distDirFiles) {
-    const status = (await scanTemplateStatus(projectDirRoot, templateId, data, { pluginId: opts?.pluginId }))
-      // omit invalid templates if `returnsInvalidTemplates` is true
-      .filter(s => (opts?.returnsInvalidTemplates ? true : s.isValid));
-
-    // determine default locale
-    const defaultTemplateStatus = status.find(s => 'isDefault' in s && s.isDefault);
-
-    if (defaultTemplateStatus == null || !isTemplateStatusValid(defaultTemplateStatus)) {
-      continue;
-    }
-
-    summaries.push({
-      // for the 'default' key
-      default: defaultTemplateStatus,
-      // for each locale keys
-      ...Object.fromEntries(status.map(templateStatus => [templateStatus.locale, templateStatus])),
-    });
-  }
-
-  return summaries;
-};
-
-export const validateTemplatePlugin = async(projectDirRoot: string): Promise<boolean> => {
-  const data = await validateTemplatePluginPackageJson(projectDirRoot);
-
-  const results = await scanAllTemplateStatus(projectDirRoot, { data, returnsInvalidTemplates: true });
-
-  if (Object.keys(results).length === 0) {
-    throw new Error('This plugin does not have any templates');
-  }
-
-  // construct map
-  // key: id
-  // value: isValid properties
-  const idValidMap: { [id: string]: boolean[] } = {};
-  results.forEach((summary) => {
-    idValidMap[summary.default.id] = Object.values(summary).map(s => s?.isValid ?? false);
-  });
-
-  for (const [id, validMap] of Object.entries(idValidMap)) {
-    // warn
-    if (!validMap.every(bool => bool)) {
-      // eslint-disable-next-line no-console
-      console.warn(`[WARN] Template '${id}' has some locales that status is invalid`);
-    }
-
-    // This means the template directory does not have any valid template
-    if (!validMap.some(bool => bool)) {
-      return false;
-    }
-  }
-
-  return true;
-};
-
-export const getMarkdown = async(projectDirRoot: string, templateId: string, locale: string): Promise<string> => {
-  const tplDir = path.resolve(projectDirRoot, 'dist', templateId, locale);
-
-  const { isTemplateExists } = await getStats(tplDir);
-
-  if (!isTemplateExists) throw new Error("'template.md does not exist.");
-
-  const markdownPath = path.resolve(tplDir, 'template.md');
-  return fs.readFileSync(markdownPath, { encoding: 'utf-8' });
-};

+ 16 - 0
packages/pluginkit/src/v4/server/utils/template/get-markdown.ts

@@ -0,0 +1,16 @@
+import fs from 'fs';
+import path from 'path';
+
+import { getStatus } from './get-status';
+
+
+export const getMarkdown = async(projectDirRoot: string, templateId: string, locale: string): Promise<string> => {
+  const tplDir = path.resolve(projectDirRoot, 'dist', templateId, locale);
+
+  const { isTemplateExists } = await getStatus(tplDir);
+
+  if (!isTemplateExists) throw new Error("'template.md does not exist.");
+
+  const markdownPath = path.resolve(tplDir, 'template.md');
+  return fs.readFileSync(markdownPath, { encoding: 'utf-8' });
+};

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

@@ -0,0 +1,29 @@
+import fs, { readFileSync } from 'fs';
+import path from 'path';
+import { promisify } from 'util';
+
+
+const statAsync = promisify(fs.stat);
+
+
+type TemplateDirStatus = {
+  isTemplateExists: boolean,
+  meta?: { [key: string]: string },
+}
+
+export async function getStatus(tplDir: string): Promise<TemplateDirStatus> {
+  const markdownPath = path.resolve(tplDir, 'template.md');
+  const statForMarkdown = await statAsync(markdownPath);
+  const isTemplateExists = statForMarkdown.isFile();
+
+  const metaDataPath = path.resolve(tplDir, 'meta.json');
+  const statForMetaDataFile = await statAsync(metaDataPath);
+  const isMetaDataFileExists = statForMetaDataFile.isFile();
+
+  const result: TemplateDirStatus = {
+    isTemplateExists,
+    meta: isMetaDataFileExists ? JSON.parse(readFileSync(metaDataPath, 'utf-8')) : undefined,
+  };
+
+  return result;
+}

+ 4 - 0
packages/pluginkit/src/v4/server/utils/template/index.ts

@@ -0,0 +1,4 @@
+export * from './get-markdown';
+export * from './scan';
+export * from './validate-all-locales';
+export * from './validate-growi-plugin-directive';

+ 103 - 0
packages/pluginkit/src/v4/server/utils/template/scan.ts

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

+ 36 - 0
packages/pluginkit/src/v4/server/utils/template/validate-all-locales.ts

@@ -0,0 +1,36 @@
+import { scanAllTemplates } from './scan';
+import { validateTemplatePluginGrowiDirective } from './validate-growi-plugin-directive';
+
+
+export const validateAllTemplateLocales = async(projectDirRoot: string): Promise<boolean> => {
+  const data = validateTemplatePluginGrowiDirective(projectDirRoot);
+
+  const results = await scanAllTemplates(projectDirRoot, { data, returnsInvalidTemplates: true });
+
+  if (Object.keys(results).length === 0) {
+    throw new Error('This plugin does not have any templates');
+  }
+
+  // construct map
+  // key: id
+  // value: isValid properties
+  const idValidMap: { [id: string]: boolean[] } = {};
+  results.forEach((summary) => {
+    idValidMap[summary.default.id] = Object.values(summary).map(s => s?.isValid ?? false);
+  });
+
+  for (const [id, validMap] of Object.entries(idValidMap)) {
+    // warn
+    if (!validMap.every(bool => bool)) {
+      // eslint-disable-next-line no-console
+      console.warn(`[WARN] Template '${id}' has some locales that status is invalid`);
+    }
+
+    // This means the template directory does not have any valid template
+    if (!validMap.some(bool => bool)) {
+      return false;
+    }
+  }
+
+  return true;
+};

+ 33 - 0
packages/pluginkit/src/v4/server/utils/template/validate-growi-plugin-directive.ts

@@ -0,0 +1,33 @@
+import { GrowiPluginType } from '@growi/core/dist/consts';
+
+import type { GrowiPluginValidationData, GrowiTemplatePluginValidationData } from '../../../../model';
+import { GrowiPluginValidationError } from '../../../../model';
+import { validateGrowiDirective } from '../common';
+
+
+/**
+ * An utility for template plugin which wrap 'validateGrowiDirective' of './common' module
+ * @param projectDirRoot
+ */
+export const validateTemplatePluginGrowiDirective = (projectDirRoot: string): GrowiTemplatePluginValidationData => {
+  const data = validateGrowiDirective(projectDirRoot, GrowiPluginType.Template);
+
+  const { growiPlugin } = data;
+
+  // check supporting locales
+  const supportingLocales: string[] | undefined = growiPlugin.locales;
+  if (supportingLocales == null || supportingLocales.length === 0) {
+    throw new GrowiPluginValidationError<GrowiPluginValidationData & { supportingLocales?: string[] }>(
+      "Template plugin must have 'supportingLocales' and that must have one or more locales",
+      {
+        ...data,
+        supportingLocales,
+      },
+    );
+  }
+
+  return {
+    ...data,
+    supportingLocales,
+  };
+};

+ 1 - 6
packages/pluginkit/tsconfig.json

@@ -6,12 +6,7 @@
     "types": [
       "node",
       "vitest/globals"
-    ],
-    "baseUrl": ".",
-    "paths": {
-      "^/*": ["./*"],
-      "~/*": ["./src/*"]
-    }
+    ]
   },
   "include": [
     "src"

+ 0 - 2
packages/pluginkit/vite.config.ts

@@ -4,13 +4,11 @@ import path from 'path';
 import glob from 'glob';
 import { defineConfig } from 'vite';
 import dts from 'vite-plugin-dts';
-import tsconfigPaths from 'vite-tsconfig-paths';
 
 // https://vitejs.dev/config/
 export default defineConfig({
   plugins: [
     dts(),
-    tsconfigPaths(),
   ],
   build: {
     outDir: 'dist',

+ 22 - 5
packages/preset-templates/test/index.test.ts

@@ -15,21 +15,38 @@ it('Validation for package.json should be passed', () => {
   expect(caller).not.toThrow();
 });
 
+it('Validation for package.json should be return data', () => {
+
+  // when
+  const data = validateTemplatePluginPackageJson(projectDirRoot);
+
+  // then
+  expect(data).not.toBeNull();
+});
+
 it('Scanning the templates ends up with no errors', async() => {
+  // when
+  const results = await scanAllTemplateStatus(projectDirRoot);
+
+  // then
+  expect(results).not.toBeNull();
+});
+
+it('Scanning the templates ends up with no errors with opts.data', async() => {
 
   // setup
-  const data = await validateTemplatePluginPackageJson(projectDirRoot);
+  const data = validateTemplatePluginPackageJson(projectDirRoot);
 
   // when
-  const caller = () => scanAllTemplateStatus(projectDirRoot, data);
+  const results = await scanAllTemplateStatus(projectDirRoot, { data });
 
   // then
-  expect(caller).not.toThrow();
+  expect(results).not.toBeNull();
 });
 
-it('Validation templates returns true', async() => {
+it('Validation templates returns true', () => {
   // when
-  const result = await validateTemplatePlugin(projectDirRoot);
+  const result = validateTemplatePlugin(projectDirRoot);
 
   // then
   expect(result).toBeTruthy();