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

Merge pull request #14 from weseek/feat/custom-renderer-options

feat: Custom Renderer Options
Yuki Takei 3 лет назад
Родитель
Сommit
129d2ed6e6

+ 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();
+    });
+  }
+
+}

+ 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-jstest',
+    origin: {
+      url: 'https://github.com/weseek/growi-plugin-jstest',
+    },
+    meta: {
+      name: 'weseek/growi-plugin-jstest',
+      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 />

+ 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,
+  }
+};