فهرست منبع

Merge branch 'main' into support/save-growi-plugin-documents

takuya 3 سال پیش
والد
کامیت
fb0bcbde68

BIN
packages/app/master.zip


+ 0 - 1
packages/app/package.json

@@ -112,7 +112,6 @@
     "graceful-fs": "^4.1.11",
     "hast-util-select": "^5.0.2",
     "helmet": "^4.6.0",
-    "node-wget-js": "^1.0.1",
     "http-errors": "^2.0.0",
     "i18next-chained-backend": "^3.0.2",
     "i18next-http-backend": "^1.4.1",

+ 0 - 4
packages/app/public/static/locales/ja_JP/admin.json

@@ -847,10 +847,6 @@
       "force_update_parents_description": "このオプションを有効化すると、親グループ変更後に祖先グループに足りないユーザーが存在した場合にそれらのユーザーを強制的に追加することができます"
     }
   },
-  "plugins-extention": {
-    "title": "プラグイン拡張",
-    "plugin_url": "プラグイン URL",
-  },
   "audit_log_management": {
     "audit_log": "監査ログ",
     "audit_log_settings": "監査ログ設定",

+ 38 - 0
packages/app/src/client/services/activate-plugin.ts

@@ -0,0 +1,38 @@
+import { readFileSync } from 'fs';
+import path from 'path';
+
+import { CustomWindow } from '~/interfaces/global';
+import { GrowiPlugin } from '~/interfaces/plugin';
+import { resolveFromRoot } from '~/utils/project-dir-utils';
+
+
+export type GrowiPluginManifestEntries = [growiPlugin: GrowiPlugin, manifest: any][];
+
+
+export class ActivatePluginService {
+
+  static async retrievePluginManifests(growiPlugins: GrowiPlugin[]): Promise<GrowiPluginManifestEntries> {
+    const entries: GrowiPluginManifestEntries = [];
+
+    growiPlugins.forEach(async(growiPlugin) => {
+      const manifestPath = resolveFromRoot(path.join('tmp/plugins', growiPlugin.installedPath, 'dist/manifest.json'));
+      const customManifestStr: string = await readFileSync(manifestPath, 'utf-8');
+      entries.push([growiPlugin, JSON.parse(customManifestStr)]);
+    });
+
+    return entries;
+  }
+
+  static activateAll(): void {
+    const { pluginActivators } = window as CustomWindow;
+
+    if (pluginActivators == null) {
+      return;
+    }
+
+    Object.entries(pluginActivators).forEach(([, activator]) => {
+      activator.activate();
+    });
+  }
+
+}

+ 2 - 2
packages/app/src/components/Admin/AdminHome/InstalledPluginTable.jsx

@@ -27,7 +27,7 @@ const InstalledPluginTable = (props) => {
         </tr>
       </thead>
       <tbody>
-        {/* {adminHomeContainer.state.installedPlugins.map((plugin) => {
+        {adminHomeContainer.state.installedPlugins.map((plugin) => {
           return (
             <tr key={plugin.name}>
               <td>{plugin.name}</td>
@@ -35,7 +35,7 @@ const InstalledPluginTable = (props) => {
               <td data-hide-in-vrt className="text-center">{plugin.installedVersion}</td>
             </tr>
           );
-        })} */}
+        })}
       </tbody>
     </table>
   );

+ 16 - 2
packages/app/src/components/Page.tsx

@@ -22,6 +22,7 @@ import { useViewOptions } from '~/stores/renderer';
 import {
   useEditorMode, useIsMobile,
 } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 import RevisionRenderer from './Page/RevisionRenderer';
@@ -216,14 +217,27 @@ export const Page = (props) => {
   const { data: slackChannelsData } = useSWRxSlackChannels(currentPage?.path);
   const { data: isSlackEnabled } = useIsSlackEnabled();
   const { data: pageTags } = usePageTagsForEditors(null); // TODO: pass pageId
-  const { data: rendererOptions } = useViewOptions(storeTocNodeHandler);
+  const { data: rendererOptions, mutate: mutateRendererOptions } = useViewOptions(storeTocNodeHandler);
   const { mutate: mutateIsEnabledUnsavedWarning } = useIsEnabledUnsavedWarning();
   const { mutate: mutateCurrentPageTocNode } = useCurrentPageTocNode();
 
   const pageRef = useRef(null);
 
+  // register to facade
   useEffect(() => {
-    mutateCurrentPageTocNode(tocRef.current);
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          viewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
+  useEffect(() => {
+    if (tocRef.current != null) {
+      mutateCurrentPageTocNode(tocRef.current);
+    }
   // eslint-disable-next-line react-hooks/exhaustive-deps
   }, [mutateCurrentPageTocNode, tocRef.current]); // include tocRef.current to call mutateCurrentPageTocNode when tocRef.current changes
 

+ 13 - 1
packages/app/src/components/PageEditor.tsx

@@ -26,6 +26,7 @@ import {
   EditorMode,
   useEditorMode, useIsMobile, useSelectedGrant,
 } from '~/stores/ui';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 
@@ -68,7 +69,7 @@ const PageEditor = React.memo((): JSX.Element => {
   const { data: isUploadableFile } = useIsUploadableFile();
   const { data: isUploadableImage } = useIsUploadableImage();
 
-  const { data: rendererOptions } = usePreviewOptions();
+  const { data: rendererOptions, mutate: mutateRendererOptions } = usePreviewOptions();
 
   const currentRevisionId = currentPage?.revision?._id;
   const initialValue = editingMarkdown ?? '';
@@ -82,6 +83,17 @@ const PageEditor = React.memo((): JSX.Element => {
   const editorRef = useRef<IEditorMethods>(null);
   const previewRef = useRef<HTMLDivElement>(null);
 
+  // register to facade
+  useEffect(() => {
+    registerGrowiFacade({
+      markdownRenderer: {
+        optionsMutators: {
+          previewOptionsMutator: mutateRendererOptions,
+        },
+      },
+    });
+  }, [mutateRendererOptions]);
+
   const setMarkdownWithDebounce = useMemo(() => debounce(100, throttle(150, (value: string, isClean: boolean) => {
     markdownToSave.current = value;
     setMarkdownToPreview(value);

+ 14 - 3
packages/app/src/interfaces/global.ts

@@ -1,8 +1,19 @@
 import EventEmitter from 'events';
 
+import { GrowiFacade } from '@growi/core';
+
 import { IGraphViewer } from './graph-viewer';
 
 export type CustomWindow = Window
-                         & typeof globalThis
-                         & { globalEmitter: EventEmitter }
-                         & { GraphViewer: IGraphViewer };
+                        & typeof globalThis
+                        & {
+                          pluginActivators: {
+                            [key: string]: {
+                              activate: () => void,
+                              deactivate: () => void,
+                            },
+                          },
+                          growiFacade: GrowiFacade,
+                          globalEmitter: EventEmitter,
+                          GraphViewer: IGraphViewer,
+                        }

+ 5 - 0
packages/app/src/pages/_app.page.tsx

@@ -11,6 +11,7 @@ import '~/styles/style-themes.scss';
 
 import * as nextI18nConfig from '^/config/next-i18next.config';
 
+import { ActivatePluginService } from '~/client/services/activate-plugin';
 import { NextThemesProvider } from '~/stores/use-next-themes';
 
 import { useI18nextHMR } from '../services/i18next-hmr';
@@ -37,6 +38,10 @@ function GrowiApp({ Component, pageProps }: GrowiAppProps): JSX.Element {
     import('bootstrap/dist/js/bootstrap');
   }, []);
 
+  useEffect(() => {
+    ActivatePluginService.activateAll();
+  }, []);
+
   const commonPageProps = pageProps as CommonProps;
   // useInterceptorManager(new InterceptorManager());
   useAppTitle(commonPageProps.appTitle);

+ 61 - 5
packages/app/src/pages/_document.page.tsx

@@ -5,22 +5,77 @@ import Document, {
   Html, Head, Main, NextScript,
 } from 'next/document';
 
+import { ActivatePluginService, GrowiPluginManifestEntries } from '~/client/services/activate-plugin';
+import { GrowiPlugin, GrowiPluginResourceType } from '~/interfaces/plugin';
 
-// type GrowiDocumentProps = {};
-// declare type GrowiDocumentInitialProps = GrowiDocumentProps & DocumentInitialProps;
-declare type GrowiDocumentInitialProps = DocumentInitialProps;
 
+// FIXME: dummy data
+// ------------------
+const growiPluginsExample: GrowiPlugin[] = [
+  {
+    isEnabled: true,
+    installedPath: 'weseek/growi-plugin-copy-code-to-clipboard',
+    origin: {
+      url: 'https://github.com/weseek/growi-plugin-copy-code-to-clipboard',
+    },
+    meta: {
+      name: 'weseek/growi-plugin-copy-code-to-clipboard',
+      types: [GrowiPluginResourceType.Script],
+    },
+  },
+];
+// ------------------
 
-class GrowiDocument extends Document {
+
+type HeadersForGrowiPluginProps = {
+  pluginManifestEntries: GrowiPluginManifestEntries;
+}
+
+const HeadersForGrowiPlugin = (props: HeadersForGrowiPluginProps): JSX.Element => {
+  const { pluginManifestEntries } = props;
+
+  return (
+    <>
+      { pluginManifestEntries.map(([growiPlugin, manifest]) => {
+        // type: script
+        if (growiPlugin.meta.types.includes(GrowiPluginResourceType.Script)) {
+          return (
+            <>
+              <link rel="stylesheet" key={`link_${growiPlugin.installedPath}`}
+                href={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].css}`} />
+              {/* eslint-disable-next-line @next/next/no-sync-scripts */ }
+              <script type="module" key={`script_${growiPlugin.installedPath}`}
+                src={`/plugins/${growiPlugin.installedPath}/dist/${manifest['client-entry.tsx'].file}`} />
+            </>
+          );
+        }
+        return <></>;
+      }) }
+    </>
+  );
+};
+
+interface GrowiDocumentProps {
+  pluginManifestEntries: GrowiPluginManifestEntries;
+}
+declare type GrowiDocumentInitialProps = DocumentInitialProps & GrowiDocumentProps;
+
+class GrowiDocument extends Document<GrowiDocumentProps> {
 
   static override async getInitialProps(ctx: DocumentContext): Promise<GrowiDocumentInitialProps> {
     const initialProps: DocumentInitialProps = await Document.getInitialProps(ctx);
 
-    return initialProps;
+    // TODO: load GrowiPlugin documents from DB
+    // const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests(growiPluginsExample);
+    const pluginManifestEntries: GrowiPluginManifestEntries = await ActivatePluginService.retrievePluginManifests([]);
+
+    return { ...initialProps, pluginManifestEntries };
   }
 
   override render(): JSX.Element {
 
+    const { pluginManifestEntries } = this.props;
+
     return (
       <Html>
         <Head>
@@ -28,6 +83,7 @@ class GrowiDocument extends Document {
           {renderScriptTagsByGroup('basis')}
           {renderStyleTagsByGroup('basis')}
           */}
+          <HeadersForGrowiPlugin pluginManifestEntries={pluginManifestEntries} />
         </Head>
         <body>
           <Main />

+ 2 - 2
packages/app/src/pages/admin/[[...path]].page.tsx

@@ -109,7 +109,7 @@ const AdminMarkdownSettingsPage: NextPage<Props> = (props: Props) => {
         nodeVersion={props.nodeVersion}
         npmVersion={props.npmVersion}
         yarnVersion={props.yarnVersion}
-        // installedPlugins={props.installedPlugins}
+        installedPlugins={props.installedPlugins}
       />,
     },
     app: {
@@ -290,7 +290,7 @@ async function injectServerConfigurations(context: GetServerSidePropsContext, pr
   props.nodeVersion = crowi.runtimeVersions.versions.node ? crowi.runtimeVersions.versions.node.version.version : null;
   props.npmVersion = crowi.runtimeVersions.versions.npm ? crowi.runtimeVersions.versions.npm.version.version : null;
   props.yarnVersion = crowi.runtimeVersions.versions.yarn ? crowi.runtimeVersions.versions.yarn.version.version : null;
-  // props.installedPlugins= crowi.pluginService.listPlugins(crowi.rootDir);
+  props.installedPlugins = crowi.pluginService.listPlugins(crowi.rootDir);
   props.envVars = await ConfigLoader.getEnvVarsForDisplay(true);
   props.isAclEnabled = aclService.isAclEnabled();
 

+ 3 - 0
packages/app/src/server/crowi/express-init.js

@@ -112,8 +112,11 @@ module.exports = function(crowi, app) {
   });
 
   app.set('port', crowi.port);
+
   const staticOption = (crowi.node_env === 'production') ? { maxAge: '30d' } : {};
   app.use(express.static(crowi.publicDir, staticOption));
+  app.use('/plugins', express.static(path.resolve(__dirname, '../../../tmp/plugins')));
+
   app.engine('html', swig.renderFile);
   // app.set('view cache', false);  // Default: true in production, otherwise undefined. -- 2017.07.04 Yuki Takei
   app.set('view engine', 'html');

+ 39 - 21
packages/app/src/server/service/plugin.ts

@@ -1,9 +1,9 @@
 
+import { execSync } from 'child_process';
 import fs from 'fs';
 import path from 'path';
 
 import mongoose from 'mongoose';
-import wget from 'node-wget-js';
 import streamToPromise from 'stream-to-promise';
 import unzipper from 'unzipper';
 
@@ -40,17 +40,17 @@ export class PluginService {
 
   async install(crowi: Crowi, origin: GrowiPluginOrigin): Promise<void> {
     // download
-    // const ghUrl = origin.url;
-    // const downloadDir = path.join(process.cwd(), 'tmp/plugins/');
-    // try {
-    //   await this.downloadZipFile(`${ghUrl}/archive/refs/heads/master.zip`, downloadDir);
-    // }
-    // catch (err) {
-    //   // TODO: error handling
-    // }
+    const ghUrl = origin.url;
+    const downloadDir = path.join(process.cwd(), 'tmp/plugins/');
+    try {
+      await this.downloadZipFile(`${ghUrl}/archive/refs/heads/master.zip`, downloadDir);
+    }
+    catch (err) {
+      // TODO: error handling
+    }
 
     // TODO: detect plugins
-    // TODO: save documents
+    // save plugin metadata
     const installedSamplePath = '/workspace/growi/packages/app/tmp/plugins/hogerepository/meta.json';
     await this.savePluginMetaData(installedSamplePath);
 
@@ -129,15 +129,33 @@ export class PluginService {
     return [];
   }
 
-  async downloadZipFile(ghUrl: string, filename:string): Promise<void> {
-    wget({ url: ghUrl, dest: filename });
-    try {
-      const zipFile = await this.getFile('master.zip');
-      await this.unzip(zipFile);
-    }
-    catch (err) {
-      // console.log('fail');
-    }
+  sleep(waitMsec) {
+    const startMsec = new Date();
+
+    while (new Date() - startMsec < waitMsec);
+  }
+
+  async downloadZipFile(ghUrl: string, filePath:string): Promise<void> {
+
+    console.log(`rm ${filePath}master.zip`);
+
+    const stdout1 = execSync(`wget ${ghUrl} -O ${filePath}master.zip`);
+    console.log(`wget ${ghUrl} -O ${filePath}master.zip`);
+    console.log(`unzip ${filePath}master.zip -d ${filePath}`);
+    this.sleep(5000);
+    const stdout2 = execSync(`unzip ${filePath}master.zip -d ${filePath}`);
+    console.log(`unzip ${filePath}master.zip -d ${filePath}`);
+    const stdout3 = execSync(`rm ${filePath}master.zip`);
+
+    // try {
+    //   const zipFile = await this.getFile('master.zip');
+
+    //   // await this.unzip('/workspace/growi/packages/app/tmp/plugins/master.zip');
+
+    // }
+    // catch (err) {
+    //   console.log(err);
+    // }
     return;
   }
 
@@ -149,7 +167,7 @@ export class PluginService {
    * @return {Array.<string>} array of absolute paths to extracted files
    */
   async unzip(zipFile) {
-    // const stream = fs.createReadStream(zipFile).pipe(unzipper.Extract({ path: '/workspace/growi/packages/app/tmp/plugins' }));
+    // const stream = fs.createReadStream(zipFile).pipe(unzipper.Extract({ path: '/workspace/growi/packages/app/tmp/plugins/master' }));
     // try {
     //   await streamToPromise(stream);
     // }
@@ -157,7 +175,7 @@ export class PluginService {
     //   console.log('err', err);
     // }
     const readStream = fs.createReadStream(zipFile);
-    const unzipStream = readStream.pipe(unzipper.parse());
+    const unzipStream = readStream.pipe(unzipper.Parse());
     const files: any = [];
 
 

+ 15 - 0
packages/app/src/services/renderer/renderer.tsx

@@ -1,6 +1,7 @@
 // allow only types to import from react
 import { ComponentType } from 'react';
 
+import { isClient } from '@growi/core';
 import { Lsx } from '@growi/plugin-lsx/components';
 import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
 import growiPlugin from '@growi/remark-growi-plugin';
@@ -25,6 +26,7 @@ import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
 import { Header } from '~/components/ReactMarkdownComponents/Header';
 import { NextLink } from '~/components/ReactMarkdownComponents/NextLink';
 import { RendererConfig } from '~/interfaces/services/renderer';
+import { registerGrowiFacade } from '~/utils/growi-facade';
 import loggerFactory from '~/utils/logger';
 
 import * as addClass from './rehype-plugins/add-class';
@@ -480,3 +482,16 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   verifySanitizePlugin(options);
   return options;
 };
+
+
+// register to facade
+if (isClient()) {
+  registerGrowiFacade({
+    markdownRenderer: {
+      optionsGenerators: {
+        generateViewOptions,
+        generatePreviewOptions,
+      },
+    },
+  });
+}

+ 14 - 5
packages/app/src/stores/renderer.tsx

@@ -1,5 +1,5 @@
 import { HtmlElementNode } from 'rehype-toc';
-import { Key, SWRResponse } from 'swr';
+import useSWR, { Key, SWRResponse } from 'swr';
 import useSWRImmutable from 'swr/immutable';
 
 import { RendererConfig } from '~/interfaces/services/renderer';
@@ -8,6 +8,7 @@ import {
   generateSimpleViewOptions, generatePreviewOptions, generateOthersOptions,
   generateViewOptions, generateTocOptions,
 } from '~/services/renderer/renderer';
+import { getGrowiFacade } from '~/utils/growi-facade';
 
 
 import {
@@ -50,9 +51,13 @@ export const useViewOptions = (storeTocNodeHandler: (toc: HtmlElementNode) => vo
     ? ['viewOptions', currentPagePath, rendererConfig]
     : null;
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     key,
-    (rendererId, currentPagePath, rendererConfig) => generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler),
+    (rendererId, currentPagePath, rendererConfig) => {
+      // determine options generator
+      const optionsGenerator = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGenerateViewOptions ?? generateViewOptions;
+      return optionsGenerator(currentPagePath, rendererConfig, storeTocNodeHandler);
+    },
     {
       fallbackData: isAllDataValid ? generateViewOptions(currentPagePath, rendererConfig, storeTocNodeHandler) : undefined,
     },
@@ -86,9 +91,13 @@ export const usePreviewOptions = (): SWRResponse<RendererOptions, Error> => {
     ? ['previewOptions', rendererConfig, currentPagePath]
     : null;
 
-  return useSWRImmutable<RendererOptions, Error>(
+  return useSWR<RendererOptions, Error>(
     key,
-    (rendererId, rendererConfig, currentPagePath) => generatePreviewOptions(rendererConfig, currentPagePath),
+    (rendererId, rendererConfig, pagePath, highlightKeywords) => {
+      // determine options generator
+      const optionsGenerator = getGrowiFacade().markdownRenderer?.optionsGenerators?.customGeneratePreviewOptions ?? generatePreviewOptions;
+      return optionsGenerator(rendererConfig, pagePath, highlightKeywords);
+    },
     {
       fallbackData: isAllDataValid ? generatePreviewOptions(rendererConfig, currentPagePath) : undefined,
     },

+ 27 - 0
packages/app/src/utils/growi-facade.ts

@@ -0,0 +1,27 @@
+import { GrowiFacade, isServer } from '@growi/core';
+import deepmerge from 'ts-deepmerge';
+
+import { CustomWindow } from '~/interfaces/global';
+
+export const getGrowiFacade = (): GrowiFacade => {
+  if (isServer()) {
+    return {};
+  }
+
+  if ((window as CustomWindow).growiFacade == null) {
+    (window as CustomWindow).growiFacade = {};
+  }
+
+  return (window as CustomWindow).growiFacade;
+};
+
+export const registerGrowiFacade = (addedFacade: GrowiFacade): void => {
+  if (isServer()) {
+    throw new Error('This method is available only in client.');
+  }
+
+  (window as CustomWindow).growiFacade = deepmerge(
+    getGrowiFacade(),
+    addedFacade,
+  );
+};

+ 1 - 0
packages/core/src/index.ts

@@ -15,6 +15,7 @@ export * as pageUtils from './utils/page-utils';
 export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
+export * from './interfaces/growi-facade';
 export * from './interfaces/has-object-id';
 export * from './interfaces/lang';
 export * from './interfaces/page';

+ 11 - 0
packages/core/src/interfaces/growi-facade.ts

@@ -0,0 +1,11 @@
+export type GrowiFacade = {
+  markdownRenderer?: {
+    optionsGenerators?: {
+      generateViewOptions?: any;
+      customGenerateViewOptions?: any;
+      generatePreviewOptions?: any;
+      customGeneratePreviewOptions?: any;
+    },
+    optionsMutators?: any,
+  }
+};

BIN
packages/master.zip


BIN
packages/master.zip.1


+ 0 - 7
yarn.lock

@@ -16364,13 +16364,6 @@ node-releases@^2.0.6:
   resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503"
   integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==
 
-node-wget-js@^1.0.1:
-  version "1.0.1"
-  resolved "https://registry.yarnpkg.com/node-wget-js/-/node-wget-js-1.0.1.tgz#2390bf9c9f99f280cc7a221d07d096103161e78c"
-  integrity sha512-SXzjefvZvJc5kn9kqsZhs0es8aQ1o9pnnIpzA6CPeHb7CaIfl+7OkO1n8uqyVawMzzUfhEXxW6vbqUsWEgSaFw==
-  dependencies:
-    request "^2.88.0"
-
 nodemailer-ses-transport@~1.5.0:
   version "1.5.1"
   resolved "https://registry.yarnpkg.com/nodemailer-ses-transport/-/nodemailer-ses-transport-1.5.1.tgz#dc0598c1bf53e8652e632e8f31692ce022d7dea9"