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

Merge pull request #7830 from weseek/feat/pluginkit

feat: Plugin kit
Yuki Takei 2 лет назад
Родитель
Сommit
4470bda02b
30 измененных файлов с 586 добавлено и 0 удалено
  1. 1 0
      packages/pluginkit/.eslintignore
  2. 5 0
      packages/pluginkit/.eslintrc.js
  3. 1 0
      packages/pluginkit/.gitignore
  4. 23 0
      packages/pluginkit/package.json
  5. 1 0
      packages/pluginkit/src/consts/index.ts
  6. 6 0
      packages/pluginkit/src/consts/types.ts
  7. 2 0
      packages/pluginkit/src/index.ts
  8. 12 0
      packages/pluginkit/src/model/growi-plugin-validation-data.ts
  9. 15 0
      packages/pluginkit/src/model/growi-plugin-validation-error.ts
  10. 2 0
      packages/pluginkit/src/model/index.ts
  11. 2 0
      packages/pluginkit/src/server/utils/v4/index.ts
  12. 11 0
      packages/pluginkit/src/server/utils/v4/package-json/import.spec.ts
  13. 6 0
      packages/pluginkit/src/server/utils/v4/package-json/import.ts
  14. 2 0
      packages/pluginkit/src/server/utils/v4/package-json/index.ts
  15. 116 0
      packages/pluginkit/src/server/utils/v4/package-json/validate.spec.ts
  16. 41 0
      packages/pluginkit/src/server/utils/v4/package-json/validate.ts
  17. 162 0
      packages/pluginkit/src/server/utils/v4/template.ts
  18. 0 0
      packages/pluginkit/test/fixtures/example-package/template1/index.js
  19. 14 0
      packages/pluginkit/test/fixtures/example-package/template1/package.json
  20. 19 0
      packages/pluginkit/tsconfig.json
  21. 36 0
      packages/pluginkit/vite.config.ts
  22. 19 0
      packages/pluginkit/vitest.config.ts
  23. 3 0
      packages/preset-templates/dist/example/ja_JP/meta.json
  24. 1 0
      packages/preset-templates/dist/example/ja_JP/template.md
  25. 22 0
      packages/preset-templates/package.json
  26. 36 0
      packages/preset-templates/test/index.test.ts
  27. 10 0
      packages/preset-templates/tsconfig.json
  28. 9 0
      packages/preset-templates/vitest.config.ts
  29. 4 0
      turbo.json
  30. 5 0
      yarn.lock

+ 1 - 0
packages/pluginkit/.eslintignore

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

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

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

+ 1 - 0
packages/pluginkit/.gitignore

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

+ 23 - 0
packages/pluginkit/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "@growi/pluginkit",
+  "version": "0.1.0",
+  "license": "MIT",
+  "main": "dist/index.js",
+  "module": "dist/index.mjs",
+  "types": "dist/index.d.ts",
+  "scripts": {
+    "build": "vite build",
+    "clean": "npx -y shx rm -rf dist",
+    "dev": "vite build --mode dev",
+    "watch": "yarn dev -w --emptyOutDir=false",
+    "lint:js": "yarn eslint **/*.{js,ts}",
+    "lint:typecheck": "tsc",
+    "lint": "npm-run-all -p lint:*",
+    "test": "vitest run --coverage"
+  },
+  "dependencies": {
+    "extensible-custom-error": "^0.0.7"
+  },
+  "devDependencies": {
+  }
+}

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

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

+ 6 - 0
packages/pluginkit/src/consts/types.ts

@@ -0,0 +1,6 @@
+export const GrowiPluginType = {
+  SCRIPT: 'script',
+  TEMPLATE: 'template',
+  THEME: 'theme',
+} as const;
+export type GrowiPluginType = typeof GrowiPluginType[keyof typeof GrowiPluginType];

+ 2 - 0
packages/pluginkit/src/index.ts

@@ -0,0 +1,2 @@
+export * from './consts';
+export * from './model';

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

@@ -0,0 +1,12 @@
+import { GrowiPluginType } from '../consts/types';
+
+export type GrowiPluginValidationData = {
+  projectDirRoot: string,
+  schemaVersion?: number,
+  expectedPluginType?: GrowiPluginType,
+  actualPluginTypes?: GrowiPluginType[],
+};
+
+export type GrowiTemplatePluginValidationData = GrowiPluginValidationData & {
+  supportingLocales: string[],
+}

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

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

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

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

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

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

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

@@ -0,0 +1,11 @@
+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();
+});

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

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

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

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

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

@@ -0,0 +1,116 @@
+import examplePkg from '^/test/fixtures/example-package/template1/package.json';
+
+import { GrowiPluginType } from '~/consts';
+
+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.");
+    });
+  });
+
+});

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

@@ -0,0 +1,41 @@
+import { GrowiPluginType } from '~/consts';
+import { type GrowiPluginValidationData, GrowiPluginValidationError } from '~/model';
+
+import { importPackageJson } from './import';
+
+
+export const validatePackageJson = async(projectDirRoot: string, expectedPluginType?: GrowiPluginType): Promise<GrowiPluginValidationData> => {
+  const pkg = await importPackageJson(projectDirRoot);
+
+  const data: GrowiPluginValidationData = { projectDirRoot };
+
+  const { growiPlugin } = pkg;
+
+  if (growiPlugin == null) {
+    throw new GrowiPluginValidationError("The package.json does not have 'growiPlugin' directive.", data);
+  }
+
+  // schema version checking
+  const schemaVersion = Number(growiPlugin.schemaVersion);
+  data.schemaVersion = schemaVersion;
+  if (Number.isNaN(schemaVersion) || schemaVersion < 4) {
+    throw new GrowiPluginValidationError("The growiPlugin directive must have a valid 'schemaVersion' directive.", data);
+  }
+
+  const types: GrowiPluginType[] = growiPlugin.types;
+  data.actualPluginTypes = types;
+  if (types == null) {
+    throw new GrowiPluginValidationError("The growiPlugin directive does not have 'types' directive.", data);
+  }
+
+  // type checking
+  if (expectedPluginType != null) {
+    data.expectedPluginType = expectedPluginType;
+
+    if (!types.includes(expectedPluginType)) {
+      throw new GrowiPluginValidationError("The growiPlugin directive does not have expected plugin type in 'types' directive.", data);
+    }
+  }
+
+  return data;
+};

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

@@ -0,0 +1,162 @@
+import assert from 'assert';
+import fs from 'fs';
+import path from 'path';
+import { promisify } from 'util';
+
+import { GrowiPluginType } from '~/consts';
+import type { GrowiPluginValidationData, GrowiTemplatePluginValidationData } from '~/model';
+import { GrowiPluginValidationError } from '~/model';
+
+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,
+  };
+};
+
+export type TemplateStatus = {
+  id: string,
+  locale: string,
+  isValid: boolean,
+  invalidReason?: string,
+}
+
+type TemplateDirStatus = {
+  isTemplateExists: boolean,
+  isMetaDataFileExists: boolean,
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  meta?: any,
+}
+
+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,
+    isMetaDataFileExists,
+  };
+
+  if (isMetaDataFileExists) {
+    result.meta = await import(metaDataPath);
+  }
+
+  return result;
+}
+
+export const scanTemplateStatus = async(projectDirRoot: string, templateId: string, data: GrowiTemplatePluginValidationData): Promise<TemplateStatus[]> => {
+  const status: TemplateStatus[] = [];
+
+  const tplRootDirPath = path.resolve(projectDirRoot, 'dist', templateId);
+
+  for await (const locale of data.supportingLocales) {
+    const tplDir = path.resolve(tplRootDirPath, locale);
+
+    try {
+      const {
+        isTemplateExists, isMetaDataFileExists, meta,
+      } = await getStats(tplDir);
+
+      if (!isTemplateExists) throw new Error("'template.md does not exist.");
+      if (!isMetaDataFileExists) throw new Error("'meta.md does not exist.");
+      if (meta?.title == null) throw new Error("'meta.md does not contain the title.");
+
+      status.push({ id: templateId, locale, isValid: true });
+    }
+    catch (err) {
+      status.push({
+        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, data: GrowiTemplatePluginValidationData): Promise<TemplateStatus[]> => {
+  const status: TemplateStatus[] = [];
+
+  const distDirPath = path.resolve(projectDirRoot, 'dist');
+  const distDirFiles = fs.readdirSync(distDirPath);
+
+  for await (const templateId of distDirFiles) {
+    status.push(...await scanTemplateStatus(projectDirRoot, templateId, data));
+  }
+
+  return status;
+};
+
+
+export const validateTemplatePlugin = async(projectDirRoot: string): Promise<boolean> => {
+  const data = await validateTemplatePluginPackageJson(projectDirRoot);
+
+  const results = await scanAllTemplateStatus(projectDirRoot, data);
+
+  if (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((status) => {
+    const validMap = idValidMap[status.id] ?? [];
+    validMap.push(status.isValid);
+    idValidMap[status.id] = validMap;
+  });
+
+  for (const [id, validMap] of Object.entries(idValidMap)) {
+    assert(validMap.length === data.supportingLocales.length);
+
+    // warn
+    if (!validMap.every(bool => bool)) {
+      // eslint-disable-next-line no-console
+      console.warn(`[WARN] Template '${id}' has invalid status`);
+    }
+
+    // This means the template directory does not have any valid template
+    if (!validMap.some(bool => bool)) {
+      return false;
+    }
+  }
+
+  return true;
+};

+ 0 - 0
packages/pluginkit/test/fixtures/example-package/template1/index.js


+ 14 - 0
packages/pluginkit/test/fixtures/example-package/template1/package.json

@@ -0,0 +1,14 @@
+{
+  "name": "example-package-template1",
+  "version": "1.0.0",
+  "main": "index.js",
+  "growiPlugin": {
+    "schemaVersion": "4",
+    "types": [
+      "template"
+    ],
+    "locales": [
+      "en_US", "ja_JP", "zh_CN"
+    ]
+  }
+}

+ 19 - 0
packages/pluginkit/tsconfig.json

@@ -0,0 +1,19 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "module": "CommonJS",
+    "types": [
+      "node",
+      "vitest/globals"
+    ],
+    "baseUrl": ".",
+    "paths": {
+      "^/*": ["./*"],
+      "~/*": ["./src/*"]
+    }
+  },
+  "include": [
+    "src"
+  ]
+}

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

@@ -0,0 +1,36 @@
+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',
+    sourcemap: true,
+    lib: {
+      entry: glob.sync(path.resolve(__dirname, 'src/**/*.ts')),
+      name: 'pluginkit-libs',
+      formats: ['es', 'cjs'],
+    },
+    rollupOptions: {
+      output: {
+        preserveModules: true,
+        preserveModulesRoot: 'src',
+      },
+      external: [
+        'assert',
+        'fs',
+        'path',
+        'util',
+      ],
+    },
+  },
+});

+ 19 - 0
packages/pluginkit/vitest.config.ts

@@ -0,0 +1,19 @@
+import tsconfigPaths from 'vite-tsconfig-paths';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  plugins: [
+    tsconfigPaths(),
+  ],
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+    coverage: {
+      lines: 100,
+      functions: 100,
+      branches: 100,
+      statements: 100,
+    },
+  },
+});

+ 3 - 0
packages/preset-templates/dist/example/ja_JP/meta.json

@@ -0,0 +1,3 @@
+{
+  "title": "Example"
+}

+ 1 - 0
packages/preset-templates/dist/example/ja_JP/template.md

@@ -0,0 +1 @@
+# Example

+ 22 - 0
packages/preset-templates/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "@growi/preset-templates",
+  "version": "6.1.4-RC.0",
+  "scripts": {
+    "test": "vitest run",
+    "version": "yarn version --no-git-tag-version"
+  },
+  "dependencies": {
+  },
+  "devDependencies": {
+    "@growi/pluginkit": "link:../pluginkit"
+  },
+  "growiPlugin": {
+    "schemaVersion": "4",
+    "types": [
+      "template"
+    ],
+    "locales": [
+      "en_US", "ja_JP", "zh_CN"
+    ]
+  }
+}

+ 36 - 0
packages/preset-templates/test/index.test.ts

@@ -0,0 +1,36 @@
+import path from 'node:path';
+
+import { scanAllTemplateStatus, validateTemplatePluginPackageJson, validateTemplatePlugin } from '@growi/pluginkit/dist/server/utils/v4';
+
+
+const projectDirRoot = path.resolve(__dirname, '../');
+
+
+it('Validation for package.json should be passed', () => {
+
+  // when
+  const caller = () => validateTemplatePluginPackageJson(projectDirRoot);
+
+  // then
+  expect(caller).not.toThrow();
+});
+
+it('Scanning the templates ends up with no errors', async() => {
+
+  // setup
+  const data = await validateTemplatePluginPackageJson(projectDirRoot);
+
+  // when
+  const caller = () => scanAllTemplateStatus(projectDirRoot, data);
+
+  // then
+  expect(caller).not.toThrow();
+});
+
+it('Validation templates returns true', async() => {
+  // when
+  const result = await validateTemplatePlugin(projectDirRoot);
+
+  // then
+  expect(result).toBeTruthy();
+});

+ 10 - 0
packages/preset-templates/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "$schema": "http://json.schemastore.org/tsconfig",
+  "compilerOptions": {
+    "esModuleInterop": true,
+    "resolveJsonModule": true,
+    "types": [
+      "vitest/globals"
+    ]
+  }
+}

+ 9 - 0
packages/preset-templates/vitest.config.ts

@@ -0,0 +1,9 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    environment: 'node',
+    clearMocks: true,
+    globals: true,
+  },
+});

+ 4 - 0
turbo.json

@@ -160,6 +160,10 @@
       "dependsOn": ["@growi/slack#dev"],
       "outputMode": "new-only"
     },
+    "@growi/preset-templates#test": {
+      "dependsOn": ["@growi/pluginkit#dev"],
+      "outputMode": "new-only"
+    },
     "@growi/remark-lsx#test": {
       "dependsOn": ["@growi/core#dev"],
       "outputMode": "new-only"

+ 5 - 0
yarn.lock

@@ -2320,6 +2320,11 @@
 "@growi/hackmd@link:packages/hackmd":
   version "6.1.4-RC.0"
 
+"@growi/pluginkit@link:packages/pluginkit":
+  version "0.1.0"
+  dependencies:
+    extensible-custom-error "^0.0.7"
+
 "@growi/presentation@link:packages/presentation":
   version "6.1.4-RC.0"
   dependencies: