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

Merge pull request #6455 from weseek/feat/lsx-plugin

feat: lsx plugin
Yuki Takei 3 лет назад
Родитель
Сommit
f7ada8b9bd
60 измененных файлов с 1010 добавлено и 1044 удалено
  1. 2 0
      package.json
  2. 0 56
      packages/app/bin/generate-plugin-definitions-source.ts
  3. 2 1
      packages/app/jest.config.js
  4. 2 2
      packages/app/package.json
  5. 1 1
      packages/app/src/components/ReactMarkdownComponents/NextLink.tsx
  6. 6 5
      packages/app/src/server/crowi/index.js
  7. 0 40
      packages/app/src/server/plugins/plugin-utils-v2.js
  8. 0 38
      packages/app/src/server/plugins/plugin-utils-v4.ts
  9. 1 44
      packages/app/src/server/plugins/plugin-utils.js
  10. 0 72
      packages/app/src/server/plugins/plugin.service.js
  11. 118 39
      packages/app/src/services/renderer/renderer.tsx
  12. 1 0
      packages/app/tsconfig.build.client.json
  13. 10 0
      packages/app/tsconfig.build.server-tsc-alias.json
  14. 2 1
      packages/app/tsconfig.build.server.json
  15. 1 0
      packages/app/tsconfig.json
  16. 2 3
      packages/core/src/index.ts
  17. 4 0
      packages/core/src/plugin/interfaces/option-parser.ts
  18. 0 11
      packages/core/src/plugin/interfaces/plugin-definition-v4.ts
  19. 8 4
      packages/core/src/plugin/model/tag-context.ts
  20. 2 4
      packages/core/src/plugin/util/args-parser.js
  21. 0 88
      packages/core/src/plugin/util/custom-tag-utils.js
  22. 5 0
      packages/core/src/plugin/util/custom-tag-utils.ts
  23. 4 10
      packages/core/src/plugin/util/option-parser.ts
  24. 2 2
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  25. 1 1
      packages/core/src/test/plugin/util/args-parser.test.js
  26. 50 48
      packages/core/src/test/plugin/util/custom-tag-utils.test.js
  27. 1 1
      packages/core/src/test/plugin/util/option-parser.test.js
  28. 1 1
      packages/core/src/test/service/localstorage-manager.test.js
  29. 18 0
      packages/plugin-lsx/.eslintrc.js
  30. 10 2
      packages/plugin-lsx/package.json
  31. 0 12
      packages/plugin-lsx/src/client-entry.js
  32. 0 20
      packages/plugin-lsx/src/client/css/index.css
  33. 0 246
      packages/plugin-lsx/src/client/js/components/Lsx.jsx
  34. 0 33
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxLogoutInterceptor.js
  35. 0 58
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js
  36. 0 68
      packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js
  37. 0 61
      packages/plugin-lsx/src/client/js/util/LsxContext.js
  38. 0 22
      packages/plugin-lsx/src/client/js/util/TagCacheManagerFactory.js
  39. 26 0
      packages/plugin-lsx/src/components/Lsx.module.scss
  40. 265 0
      packages/plugin-lsx/src/components/Lsx.tsx
  41. 1 1
      packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx
  42. 2 2
      packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx
  43. 0 0
      packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx
  44. 2 0
      packages/plugin-lsx/src/components/PageNode.js
  45. 1 0
      packages/plugin-lsx/src/components/index.ts
  46. 48 0
      packages/plugin-lsx/src/components/lsx-context.ts
  47. 21 0
      packages/plugin-lsx/src/components/tag-cache-manager.ts
  48. 0 11
      packages/plugin-lsx/src/index.js
  49. 6 0
      packages/plugin-lsx/src/index.ts
  50. 0 4
      packages/plugin-lsx/src/server-entry.js
  51. 18 20
      packages/plugin-lsx/src/server/routes/lsx.js
  52. 1 0
      packages/plugin-lsx/src/services/renderer/index.ts
  53. 108 0
      packages/plugin-lsx/src/services/renderer/lsx.ts
  54. 1 0
      packages/plugin-lsx/tsconfig.base.json
  55. 13 12
      packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js
  56. 12 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.d.ts
  57. 51 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.js
  58. 142 0
      packages/remark-growi-plugin/src/micromark-factory-attributes-devider/readme.md
  59. 28 0
      packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js
  60. 10 0
      yarn.lock

+ 2 - 0
package.json

@@ -48,10 +48,12 @@
     "cross-env": "^7.0.0",
     "dotenv-flow": "^3.2.0",
     "npm-run-all": "^4.1.5",
+    "ts-deepmerge": "^3.0.0",
     "tslib": "^2.3.1"
   },
   "devDependencies": {
     "@testing-library/cypress": "^8.0.2",
+    "@types/css-modules": "^1.0.2",
     "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",

+ 0 - 56
packages/app/bin/generate-plugin-definitions-source.ts

@@ -1,56 +0,0 @@
-/**
- * the tool for genetion of plugin definitions source code
- *
- * @author Yuki Takei <yuki@weseek.co.jp>
- */
-import fs from 'graceful-fs';
-import normalize from 'normalize-path';
-import swig from 'swig-templates';
-
-import { PluginDefinitionV4 } from '@growi/core';
-
-import PluginUtils from '../src/server/plugins/plugin-utils';
-import loggerFactory from '../src/utils/logger';
-import { resolveFromRoot } from '../src/utils/project-dir-utils';
-
-const logger = loggerFactory('growi:bin:generate-plugin-definitions-source');
-
-
-const pluginUtils = new PluginUtils();
-
-const TEMPLATE = resolveFromRoot('bin/templates/plugin-definitions.js.swig');
-const OUT = resolveFromRoot('tmp/plugins/plugin-definitions.js');
-
-// list plugin names
-const pluginNames: string[] = pluginUtils.listPluginNames();
-logger.info('Detected plugins: ', pluginNames);
-
-async function main(): Promise<void> {
-
-  // get definitions
-  const definitions: PluginDefinitionV4[] = [];
-  for (const pluginName of pluginNames) {
-    // eslint-disable-next-line no-await-in-loop
-    const definition = await pluginUtils.generatePluginDefinition(pluginName, true);
-    if (definition != null) {
-      definitions.push(definition);
-    }
-  }
-
-  definitions.map((definition) => {
-    // convert backslash to slash
-    definition.entries = definition.entries.map((entryPath) => {
-      return normalize(entryPath);
-    });
-    return definition;
-  });
-
-  const compiledTemplate = swig.compileFile(TEMPLATE);
-  const code = compiledTemplate({ definitions });
-
-  // write
-  fs.writeFileSync(OUT, code);
-
-}
-
-main();

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

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

+ 2 - 2
packages/app/package.json

@@ -8,8 +8,8 @@
     "start": "yarn next start",
     "build:client": "yarn next build",
     "prebuild:client": "tsc -p tsconfig.build.next.config.json",
-    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server.json",
-    "postbuild:server": "npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
+    "build:server": "yarn cross-env NODE_ENV=production tsc -p tsconfig.build.server.json && tsc-alias -p tsconfig.build.server-tsc-alias.json",
+    "postbuild:server": "npx -y shx echo \"Listing files under transpiled\" && npx -y shx ls transpiled && npx -y shx mv transpiled/src dist && npx -y shx cp -r transpiled/config/* config && npx -y shx cp -r src/server/views dist/server/ && npx -y shx rm -rf transpiled",
     "clean": "npx -y shx rm -rf dist transpiled",
     "prebuild": "yarn cross-env NODE_ENV=production run-p clean resources:*",
     "server": "yarn cross-env NODE_ENV=production node -r dotenv-flow/config dist/server/app.js",

+ 1 - 1
packages/app/src/components/ReactMarkdownComponents/NextLink.tsx

@@ -17,7 +17,7 @@ type Props = Omit<LinkProps, 'href'> & {
   children: React.ReactNode,
   href?: string,
   className?: string,
-} ;
+};
 
 export const NextLink = ({
   href, children, className, ...props

+ 6 - 5
packages/app/src/server/crowi/index.js

@@ -3,6 +3,7 @@ import http from 'http';
 import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
+import lsxRoutes from '@growi/plugin-lsx/server/routes';
 import mongoose from 'mongoose';
 import next from 'next';
 
@@ -33,7 +34,6 @@ import { initMongooseGlobalSettings, getMongoUri, mongoOptions } from '../util/m
 const logger = loggerFactory('growi:crowi');
 const httpErrorHandler = require('../middlewares/http-error-handler');
 const models = require('../models');
-const PluginService = require('../plugins/plugin.service');
 
 const sep = path.sep;
 
@@ -434,10 +434,6 @@ Crowi.prototype.start = async function() {
 
   const { express, configManager } = this;
 
-  // setup plugins
-  this.pluginService = new PluginService(this, express);
-  await this.pluginService.autoDetectAndLoadPlugins();
-
   const app = (this.node_env === 'development') ? this.crowiDev.setupServer(express) : express;
 
   const httpServer = http.createServer(app);
@@ -465,6 +461,7 @@ Crowi.prototype.start = async function() {
   }
 
   // setup Express Routes
+  this.setupRoutesForPlugins();
   this.setupRoutesAtLast();
 
   // setup Global Error Handlers
@@ -515,6 +512,10 @@ Crowi.prototype.setupTerminus = function(server) {
   });
 };
 
+Crowi.prototype.setupRoutesForPlugins = function() {
+  lsxRoutes(this, this.express);
+};
+
 /**
  * setup Express Routes
  * !! this must be at last because it includes '/*' route !!

+ 0 - 40
packages/app/src/server/plugins/plugin-utils-v2.js

@@ -1,40 +0,0 @@
-const path = require('path');
-
-class PluginUtilsV2 {
-
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'crowi-plugin-X',
-   *   meta: require('crowi-plugin-X'),
-   *   entries: [
-   *     'crowi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  generatePluginDefinition(name, isForClient = false) {
-    const meta = require(name);
-    let entries = (isForClient) ? meta.clientEntries : meta.serverEntries;
-
-    entries = entries.map((entryPath) => {
-      const moduleRoot = path.resolve(require.resolve(`${name}/package.json`), '..');
-      const entryRelativePath = path.relative(moduleRoot, entryPath);
-      return path.join(name, entryRelativePath);
-    });
-
-    return {
-      name,
-      meta,
-      entries,
-    };
-  }
-
-}
-
-module.exports = PluginUtilsV2;

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

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

+ 1 - 44
packages/app/src/server/plugins/plugin-utils.js

@@ -1,57 +1,14 @@
 import loggerFactory from '~/utils/logger';
 import { resolveFromRoot } from '~/utils/project-dir-utils';
 
-import { PluginUtilsV4 } from './plugin-utils-v4';
+// import { PluginUtilsV4 } from './plugin-utils-v4';
 
 const fs = require('graceful-fs');
 
 const logger = loggerFactory('growi:plugins:plugin-utils');
 
-const pluginUtilsV4 = new PluginUtilsV4();
-
 class PluginUtils {
 
-  /**
-   * return a definition objects that has following structure:
-   *
-   * {
-   *   name: 'growi-plugin-X',
-   *   meta: require('growi-plugin-X'),
-   *   entries: [
-   *     'growi-plugin-X/lib/client-entry'
-   *   ]
-   * }
-   *
-   * @param {string} pluginName
-   * @return
-   * @memberOf PluginService
-   */
-  async generatePluginDefinition(name, isForClient = false) {
-    const meta = require(name);
-    let definition;
-
-    switch (meta.pluginSchemaVersion) {
-      // v1, v2 and v3 is deprecated
-      case 1:
-        logger.debug('pluginSchemaVersion 1 is deprecated');
-        break;
-      case 2:
-        logger.debug('pluginSchemaVersion 2 is deprecated');
-        break;
-      case 3:
-        logger.debug('pluginSchemaVersion 3 is deprecated');
-        break;
-      // v4 or above
-      case 4:
-        definition = await pluginUtilsV4.generatePluginDefinition(name, isForClient);
-        break;
-      default:
-        logger.warn('Unsupported schema version', meta.pluginSchemaVersion);
-    }
-
-    return definition;
-  }
-
   /**
    * list plugin module objects
    *  that starts with 'growi-plugin-' or 'crowi-plugin-'

+ 0 - 72
packages/app/src/server/plugins/plugin.service.js

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

+ 118 - 39
packages/app/src/services/renderer/renderer.ts → packages/app/src/services/renderer/renderer.tsx

@@ -1,4 +1,12 @@
+// allow only types to import from react
+import { ComponentType } from 'react';
+
+import { Lsx } from '@growi/plugin-lsx/components';
+import * as lsxGrowiPlugin from '@growi/plugin-lsx/services/renderer';
 import growiPlugin from '@growi/remark-growi-plugin';
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { SpecialComponents } from 'react-markdown/lib/ast-to-react';
+import { NormalComponents } from 'react-markdown/lib/complex-types';
 import { ReactMarkdownOptions } from 'react-markdown/lib/react-markdown';
 import katex from 'rehype-katex';
 import raw from 'rehype-raw';
@@ -9,6 +17,8 @@ import breaks from 'remark-breaks';
 import emoji from 'remark-emoji';
 import gfm from 'remark-gfm';
 import math from 'remark-math';
+import deepmerge from 'ts-deepmerge';
+import { PluggableList, Pluggable, PluginTuple } from 'unified';
 
 
 import { CodeBlock } from '~/components/ReactMarkdownComponents/CodeBlock';
@@ -216,8 +226,57 @@ const logger = loggerFactory('growi:util:GrowiRenderer');
 
 // }
 
-export type RendererOptions = Partial<ReactMarkdownOptions>;
+type SanitizePlugin = PluginTuple<[SanitizeOption]>;
+export type RendererOptions = Omit<ReactMarkdownOptions, 'remarkPlugins' | 'rehypePlugins' | 'components' | 'children'> & {
+  remarkPlugins: PluggableList,
+  rehypePlugins: PluggableList,
+  components?:
+    | Partial<
+        Omit<NormalComponents, keyof SpecialComponents>
+        & SpecialComponents
+        & {
+          [elem: string]: ComponentType<any>,
+        }
+      >
+    | undefined
+};
+
+const commonSanitizeOption: SanitizeOption = deepmerge(
+  sanitizeDefaultSchema,
+  {
+    attributes: {
+      '*': ['class', 'className'],
+    },
+  },
+);
+
+const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
+  if (!Array.isArray(pluggable) || pluggable.length < 2) {
+    return false;
+  }
+  const sanitizeOption = pluggable[1];
+  return 'tagNames' in sanitizeOption && 'attributes' in sanitizeOption;
+};
+
+const hasSanitizePluginAtTheLast = (options: RendererOptions): boolean => {
+  const { rehypePlugins } = options;
+  if (rehypePlugins == null || rehypePlugins.length === 0) {
+    return false;
+  }
+
+  // get the last element
+  const lastPluggableElem = rehypePlugins.slice(-1)[0];
 
+  return isSanitizePlugin(lastPluggableElem);
+};
+
+const verifySanitizePlugin = (options: RendererOptions): void => {
+  if (hasSanitizePluginAtTheLast(options)) {
+    return;
+  }
+
+  throw new Error('The specified options does not have sanitize plugin in \'rehypePlugins\'');
+};
 
 const generateCommonOptions = (pagePath: string|undefined, config: RendererConfig): RendererOptions => {
   return {
@@ -231,15 +290,6 @@ const generateCommonOptions = (pagePath: string|undefined, config: RendererConfi
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
-      [sanitize, {
-        ...sanitizeDefaultSchema,
-        attributes: {
-          ...sanitizeDefaultSchema.attributes,
-          '*': sanitizeDefaultSchema.attributes != null
-            ? sanitizeDefaultSchema.attributes['*'].concat('class', 'className')
-            : ['class', 'className'],
-        },
-      }],
       [addClass, {
         table: 'table table-bordered',
       }],
@@ -262,18 +312,19 @@ export const generateViewOptions = (
   const { remarkPlugins, rehypePlugins, components } = options;
 
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-    remarkPlugins.push(math);
-    if (config.isEnabledLinebreaks) {
-      remarkPlugins.push(breaks);
-    }
+  remarkPlugins.push(
+    emoji,
+    math,
+    lsxGrowiPlugin.remarkPlugin,
+  );
+  if (config.isEnabledLinebreaks) {
+    remarkPlugins.push(breaks);
   }
 
-  // store toc node
-  if (rehypePlugins != null) {
-    rehypePlugins.push(katex);
-    rehypePlugins.push([toc, {
+  // add rehype plugins
+  rehypePlugins.push(
+    katex,
+    [toc, {
       nav: false,
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: (toc: HtmlElementNode) => {
@@ -296,17 +347,25 @@ export const generateViewOptions = (
 
         return false; // not show toc in body
       },
-    }]);
-  }
-  // renderer.rehypePlugins.push([autoLinkHeadings, {
-  //   behavior: 'append',
-  // }]);
+    }],
+    [lsxGrowiPlugin.rehypePlugin, { pagePath }],
+    // [autoLinkHeadings, {
+    //   behavior: 'append',
+    // }]
+  );
+
+  const sanitizeOption = deepmerge(
+    commonSanitizeOption,
+    lsxGrowiPlugin.sanitizeOption,
+  );
+  rehypePlugins.push([sanitize, sanitizeOption]);
 
   // add components
   if (components != null) {
     components.h1 = Header;
     components.h2 = Header;
     components.h3 = Header;
+    components.lsx = props => <Lsx {...props} forceToFetchData />;
   }
 
   // // Add configurers for viewer
@@ -321,6 +380,7 @@ export const generateViewOptions = (
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  verifySanitizePlugin(options);
   return options;
 };
 
@@ -331,25 +391,27 @@ export const generateTocOptions = (config: RendererConfig, tocNode: HtmlElementN
   const { remarkPlugins, rehypePlugins } = options;
 
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-  }
-  // set toc node
-  if (rehypePlugins != null) {
-    rehypePlugins.push([toc, {
+  remarkPlugins.push(emoji);
+
+  // add rehype plugins
+  rehypePlugins.push(
+    [toc, {
       headings: ['h1', 'h2', 'h3'],
       customizeTOC: () => tocNode,
-    }]);
-  }
+    }],
+    [sanitize, commonSanitizeOption],
+  );
   // renderer.rehypePlugins.push([autoLinkHeadings, {
   //   behavior: 'append',
   // }]);
 
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generatePreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
   // // Add configurers for preview
   // renderer.addConfigurers([
@@ -361,19 +423,23 @@ export const generatePreviewOptions = (config: RendererConfig): RendererOptions
   // renderer.setMarkdownSettings({ breaks: rendererSettings?.isEnabledLinebreaks });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generateCommentPreviewOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
-  const { remarkPlugins } = options;
+  const { remarkPlugins, rehypePlugins } = options;
 
   // add remark plugins
-  if (remarkPlugins != null) {
-    remarkPlugins.push(emoji);
-    if (config.isEnabledLinebreaksInComments) {
-      remarkPlugins.push(breaks);
-    }
+  remarkPlugins.push(emoji);
+  if (config.isEnabledLinebreaksInComments) {
+    remarkPlugins.push(breaks);
   }
 
   // renderer.addConfigurers([
@@ -383,11 +449,18 @@ export const generateCommentPreviewOptions = (config: RendererConfig): RendererO
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaksInComments });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };
 
 export const generateOthersOptions = (config: RendererConfig): RendererOptions => {
   const options = generateCommonOptions(undefined, config);
+  const { rehypePlugins } = options;
 
   // renderer.addConfigurers([
   //   new TableConfigurer(),
@@ -396,5 +469,11 @@ export const generateOthersOptions = (config: RendererConfig): RendererOptions =
   // renderer.setMarkdownSettings({ breaks: rendererSettings.isEnabledLinebreaks });
   // renderer.configure();
 
+  // add rehype plugins
+  rehypePlugins.push(
+    [sanitize, commonSanitizeOption],
+  );
+
+  verifySanitizePlugin(options);
   return options;
 };

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

@@ -8,6 +8,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }

+ 10 - 0
packages/app/tsconfig.build.server-tsc-alias.json

@@ -0,0 +1,10 @@
+{
+  "extends": "./tsconfig.build.server.json",
+  "compilerOptions": {
+    "paths": {
+      "~/*": ["./src/*"],
+      "^/*": ["./*"],
+      "debug": ["./src/utils/logger/alias-for-debug"]
+    }
+  }
+}

+ 2 - 1
packages/app/tsconfig.build.server.json

@@ -11,6 +11,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/dist/cjs/*"],
       "debug": ["./src/utils/logger/alias-for-debug"]
     }
   },
@@ -21,6 +22,6 @@
     "src/linter-checker",
     "src/stores",
     "src/styles",
-    "src/styles-hackmd",
+    "src/styles-hackmd"
   ]
 }

+ 1 - 0
packages/app/tsconfig.json

@@ -5,6 +5,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
+      "@growi/plugin-lsx/*": ["../plugin-lsx/src/*"],
       "@growi/*": ["../*/src"],
       "debug": ["./src/server/utils/logger/alias-for-debug"]
     }

+ 2 - 3
packages/core/src/index.ts

@@ -1,11 +1,10 @@
-import * as _customTagUtils from './plugin/util/custom-tag-utils';
 import * as _envUtils from './utils/env-utils';
 
 // export utils by *.js
 export const envUtils = _envUtils;
-export const customTagUtils = _customTagUtils;
 
 // export utils with namespace
+export * as customTagUtils from './plugin/util/custom-tag-utils';
 export * as templateChecker from './utils/template-checker';
 export * as objectIdUtils from './utils/objectid-utils';
 export * as pagePathUtils from './utils/page-path-utils';
@@ -13,6 +12,7 @@ export * as pathUtils from './utils/path-utils';
 export * as pageUtils from './utils/page-utils';
 
 // export all
+export * from './plugin/interfaces/option-parser';
 export * from './interfaces/attachment';
 export * from './interfaces/common';
 export * from './interfaces/has-object-id';
@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/user';
-export * from './plugin/interfaces/plugin-definition-v4';
 export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './service/localstorage-manager';

+ 4 - 0
packages/core/src/plugin/interfaces/option-parser.ts

@@ -0,0 +1,4 @@
+export type ParseRangeResult = {
+  start: number,
+  end: number,
+}

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

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

+ 8 - 4
packages/core/src/plugin/model/tag-context.js → packages/core/src/plugin/model/tag-context.ts

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

+ 2 - 4
packages/core/src/plugin/util/args-parser.js

@@ -1,12 +1,12 @@
 /**
  * Arguments parser for custom tag
  */
-class ArgsParser {
+export class ArgsParser {
 
   /**
    * @typedef ParseArgsResult
    * @property {string} firstArgsKey - key of the first argument
-   * @property {string} firstArgsValue - value of the first argument
+   * @property {string|boolean} firstArgsValue - value of the first argument
    * @property {object} options - key of the first argument
    */
 
@@ -55,5 +55,3 @@ class ArgsParser {
   }
 
 }
-
-module.exports = ArgsParser;

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

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

+ 5 - 0
packages/core/src/plugin/util/custom-tag-utils.ts

@@ -0,0 +1,5 @@
+export * from '../model/tag-context';
+
+export * from './args-parser';
+
+export * from './option-parser';

+ 4 - 10
packages/core/src/plugin/util/option-parser.js → packages/core/src/plugin/util/option-parser.ts

@@ -1,13 +1,9 @@
+import { ParseRangeResult } from '../interfaces/option-parser';
+
 /**
  * Options parser for custom tag
  */
-class OptionParser {
-
-  /**
-   * @typedef ParseRangeResult
-   * @property {number} start - start index
-   * @property {number} end - end index
-   */
+export class OptionParser {
 
   /**
    * Parse range expression
@@ -27,7 +23,7 @@ class OptionParser {
    * @param {string} str
    * @returns {ParseRangeResult}
    */
-  static parseRange(str) {
+  static parseRange(str: string): ParseRangeResult | null {
     if (str == null) {
       return null;
     }
@@ -66,5 +62,3 @@ class OptionParser {
   }
 
 }
-
-module.exports = OptionParser;

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

@@ -3,8 +3,8 @@
 // import each from 'jest-each';
 jest.mock('~/service/localstorage-manager');
 
-import * as TagCacheManager from '~/plugin/service/tag-cache-manager';
-import * as LocalStorageManager from '~/service/localstorage-manager';
+import { TagCacheManager } from '~/plugin/service/tag-cache-manager';
+import { LocalStorageManager } from '~/service/localstorage-manager';
 /* eslint-enable import/first */
 
 describe('TagCacheManager.constructor', () => {

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

@@ -1,4 +1,4 @@
-import ArgsParser from '~/plugin/util/args-parser';
+import { ArgsParser } from '~/plugin/util/args-parser';
 
 describe('args-parser', () => {
 

+ 50 - 48
packages/core/src/test/plugin/util/custom-tag-utils.test.js

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

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

@@ -1,6 +1,6 @@
 import each from 'jest-each';
 
-import OptionParser from '~/plugin/util/option-parser';
+import { OptionParser } from '~/plugin/util/option-parser';
 
 describe('option-parser', () => {
 

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

@@ -1,7 +1,7 @@
 // eslint-disable-next-line import/no-unresolved
 import 'jest-localstorage-mock';
 
-import * as LocalStorageManager from '~/service/localstorage-manager';
+import { LocalStorageManager } from '~/service/localstorage-manager';
 
 let localStorageManager = null;
 

+ 18 - 0
packages/plugin-lsx/.eslintrc.js

@@ -0,0 +1,18 @@
+module.exports = {
+  extends: [
+    'weseek/react',
+    'weseek/typescript',
+  ],
+  env: {
+  },
+  globals: {
+  },
+  settings: {
+    // resolve path aliases by eslint-import-resolver-typescript
+    'import/resolver': {
+      typescript: {},
+    },
+  },
+  rules: {
+  },
+};

+ 10 - 2
packages/plugin-lsx/package.json

@@ -9,6 +9,11 @@
   ],
   "main": "dist/cjs/index.js",
   "module": "dist/esm/index.js",
+  "exports": {
+    "./components": "./dist/cjs/components/index.js",
+    "./services/renderer": "./dist/cjs/services/renderer/index.js",
+    "./server/routes": "./dist/cjs/server/routes/index.js"
+  },
   "files": [
     "dist"
   ],
@@ -18,11 +23,14 @@
     "build:esm": "tsc -p tsconfig.build.esm.json && tsc-alias -p tsconfig.build.esm.json",
     "clean": "npx -y shx rm -rf dist",
     "lint:js": "eslint **/*.{js,jsx,ts,tsx}",
-    "lint:styles": "stylelint src/**/*.scss src/**/*.css",
+    "lint:styles": "stylelint --allow-empty-input src/**/*.scss src/**/*.css",
     "lint": "run-p lint:*",
     "test": ""
   },
-  "dependencies": {},
+  "dependencies": {
+    "@growi/core": "^5.1.3-RC.0",
+    "@growi/remark-growi-plugin": "^5.1.3-RC.0"
+  },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",
     "react": "^18.2.0",

+ 0 - 12
packages/plugin-lsx/src/client-entry.js

@@ -1,12 +0,0 @@
-import { LsxLogoutInterceptor } from './client/js/util/Interceptor/LsxLogoutInterceptor';
-import { LsxPostRenderInterceptor } from './client/js/util/Interceptor/LsxPostRenderInterceptor';
-import { LsxPreRenderInterceptor } from './client/js/util/Interceptor/LsxPreRenderInterceptor';
-
-export default () => {
-  // add interceptors
-  global.interceptorManager.addInterceptors([
-    new LsxLogoutInterceptor(),
-    new LsxPreRenderInterceptor(),
-    new LsxPostRenderInterceptor(),
-  ]);
-};

+ 0 - 20
packages/plugin-lsx/src/client/css/index.css

@@ -1,20 +0,0 @@
-.lsx .page-list-ul > li > a:not(:hover) {
-  text-decoration: none;
-}
-
-.lsx .lsx-page-not-exist {
-  opacity: 0.6;
-}
-
-.lsx .lsx-blink {
-  animation: lsx-fadeIn 1s ease 0s infinite alternate;
-}
-
-@keyframes lsx-fadeIn {
-  0% {
-    opacity: 0.2;
-  }
-  100% {
-    opacity: 0.9;
-  }
-}

+ 0 - 246
packages/plugin-lsx/src/client/js/components/Lsx.jsx

@@ -1,246 +0,0 @@
-
-import React from 'react';
-
-import * as url from 'url';
-
-import { pathUtils } from '@growi/core';
-import axios from 'axios';
-import PropTypes from 'prop-types';
-
-// eslint-disable-next-line no-unused-vars
-import { LsxContext } from '../util/LsxContext';
-import { TagCacheManagerFactory } from '../util/TagCacheManagerFactory';
-
-import { LsxListView } from './LsxPageList/LsxListView';
-import { PageNode } from './PageNode';
-
-import styles from '../../css/index.css';
-
-export class Lsx extends React.Component {
-
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      isLoading: false,
-      isError: false,
-      isCacheExists: false,
-      nodeTree: undefined,
-      basisViewersCount: undefined,
-      errorMessage: '',
-    };
-
-    this.tagCacheManager = TagCacheManagerFactory.getInstance();
-  }
-
-  async componentDidMount() {
-    const { lsxContext, forceToFetchData } = this.props;
-
-    // get state object cache
-    const stateCache = this.retrieveDataFromCache();
-
-    if (stateCache != null) {
-      this.setState({
-        isCacheExists: true,
-        nodeTree: stateCache.nodeTree,
-        isError: stateCache.isError,
-        errorMessage: stateCache.errorMessage,
-      });
-
-      // switch behavior by forceToFetchData
-      if (!forceToFetchData) {
-        return; // go to render()
-      }
-    }
-
-    lsxContext.parse();
-    this.setState({ isLoading: true });
-
-    // add slash ensure not to forward match to another page
-    // ex: '/Java/' not to match to '/JavaScript'
-    const pagePath = pathUtils.addTrailingSlash(lsxContext.pagePath);
-
-    try {
-      const res = await axios.get('/_api/plugins/lsx', {
-        params: {
-          pagePath,
-          options: lsxContext.options,
-        },
-      });
-
-      if (res.data.ok) {
-        const basisViewersCount = res.data.toppageViewersCount;
-        const nodeTree = this.generatePageNodeTree(pagePath, res.data.pages);
-        this.setState({ nodeTree, basisViewersCount });
-      }
-    }
-    catch (error) {
-      this.setState({ isError: true, errorMessage: error.message });
-    }
-    finally {
-      this.setState({ isLoading: false });
-
-      // store to sessionStorage
-      this.tagCacheManager.cacheState(lsxContext, this.state);
-    }
-  }
-
-  retrieveDataFromCache() {
-    const { lsxContext } = this.props;
-
-    // get state object cache
-    const stateCache = this.tagCacheManager.getStateCache(lsxContext);
-
-    // instanciate PageNode
-    if (stateCache != null && stateCache.nodeTree != null) {
-      stateCache.nodeTree = stateCache.nodeTree.map((obj) => {
-        return PageNode.instanciateFrom(obj);
-      });
-    }
-
-    return stateCache;
-  }
-
-  /**
-   * generate tree structure
-   *
-   * @param {string} rootPagePath
-   * @param {Page[]} pages Array of Page model
-   *
-   * @memberOf Lsx
-   */
-  generatePageNodeTree(rootPagePath, pages) {
-    const pathToNodeMap = {};
-
-    pages.forEach((page) => {
-      // add slash ensure not to forward match to another page
-      // e.g. '/Java/' not to match to '/JavaScript'
-      const pagePath = pathUtils.addTrailingSlash(page.path);
-
-      // exclude rootPagePath itself
-      if (this.isEquals(pagePath, rootPagePath)) {
-        return;
-      }
-
-      const node = this.generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
-      // set the Page substance
-      node.page = page;
-    });
-
-    // return root objects
-    const rootNodes = [];
-    Object.keys(pathToNodeMap).forEach((pagePath) => {
-      // exclude '/'
-      if (pagePath === '/') {
-        return;
-      }
-
-      const parentPath = this.getParentPath(pagePath);
-
-      // pick up what parent doesn't exist
-      if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
-        rootNodes.push(pathToNodeMap[pagePath]);
-      }
-    });
-    return rootNodes;
-  }
-
-  /**
-   * generate PageNode instances for target page and the ancestors
-   *
-   * @param {any} pathToNodeMap
-   * @param {any} rootPagePath
-   * @param {any} pagePath
-   * @returns
-   * @memberof Lsx
-   */
-  generatePageNode(pathToNodeMap, rootPagePath, pagePath) {
-    // exclude rootPagePath itself
-    if (this.isEquals(pagePath, rootPagePath)) {
-      return null;
-    }
-
-    // return when already registered
-    if (pathToNodeMap[pagePath] != null) {
-      return pathToNodeMap[pagePath];
-    }
-
-    // generate node
-    const node = new PageNode(pagePath);
-    pathToNodeMap[pagePath] = node;
-
-    /*
-     * process recursively for ancestors
-     */
-    // get or create parent node
-    const parentPath = this.getParentPath(pagePath);
-    const parentNode = this.generatePageNode(pathToNodeMap, rootPagePath, parentPath);
-    // associate to patent
-    if (parentNode != null) {
-      parentNode.children.push(node);
-    }
-
-    return node;
-  }
-
-  /**
-   * compare whether path1 and path2 is the same
-   *
-   * @param {string} path1
-   * @param {string} path2
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  isEquals(path1, path2) {
-    return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
-  }
-
-  getParentPath(path) {
-    return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
-  }
-
-  renderContents() {
-    const lsxContext = this.props.lsxContext;
-    const {
-      isLoading, isError, isCacheExists, nodeTree,
-    } = this.state;
-
-    if (isError) {
-      return (
-        <div className="text-warning">
-          <i className="fa fa-exclamation-triangle fa-fw"></i>
-          {lsxContext.tagExpression} (-&gt; <small>{this.state.errorMessage}</small>)
-        </div>
-      );
-    }
-
-
-    return (
-      <div className={isLoading ? 'lsx-blink' : ''}>
-        { isLoading && (
-          <div className="text-muted">
-            <i className="fa fa-spinner fa-pulse mr-1"></i>
-            {lsxContext.tagExpression}
-            { isCacheExists && <small>&nbsp;(Showing cache..)</small> }
-          </div>
-        ) }
-        { nodeTree && (
-          <LsxListView nodeTree={this.state.nodeTree} lsxContext={this.props.lsxContext} basisViewersCount={this.state.basisViewersCount} />
-        ) }
-      </div>
-    );
-
-  }
-
-  render() {
-    return <div className="lsx">{this.renderContents()}</div>;
-  }
-
-}
-
-Lsx.propTypes = {
-  lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
-
-  forceToFetchData: PropTypes.bool,
-};

+ 0 - 33
packages/plugin-lsx/src/client/js/util/Interceptor/LsxLogoutInterceptor.js

@@ -1,33 +0,0 @@
-import { BasicInterceptor } from '@growi/core';
-
-import { TagCacheManagerFactory } from '../TagCacheManagerFactory';
-
-/**
- * The interceptor for lsx
- *
- *  replace lsx tag to a React target element
- */
-export class LsxLogoutInterceptor extends BasicInterceptor {
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'logout'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    TagCacheManagerFactory.getInstance().clearAllStateCaches();
-
-    // resolve
-    return context;
-  }
-
-}

+ 0 - 58
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPostRenderInterceptor.js

@@ -1,58 +0,0 @@
-import React from 'react';
-
-import { BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-
-import { Lsx } from '../../components/Lsx';
-import { LsxContext } from '../LsxContext';
-
-/**
- * The interceptor for lsx
- *
- *  render React DOM
- */
-export class LsxPostRenderInterceptor extends BasicInterceptor {
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'postRenderHtml'
-      || contextName === 'postRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-
-    const isPreview = (contextName === 'postRenderPreviewHtml');
-
-    // forEach keys of lsxContextMap
-    Object.keys(context.lsxContextMap).forEach((domId) => {
-      const elem = document.getElementById(domId);
-
-      if (elem) {
-        // instanciate LsxContext from context
-        const lsxContext = new LsxContext(context.lsxContextMap[domId] || {});
-        lsxContext.fromPagePath = context.pagePath ?? context.currentPathname;
-
-        this.renderReactDOM(lsxContext, elem, isPreview);
-      }
-    });
-
-    return;
-  }
-
-  renderReactDOM(lsxContext, elem, isPreview) {
-    ReactDOM.render(
-      <Lsx lsxContext={lsxContext} forceToFetchData={!isPreview} />,
-      elem,
-    );
-  }
-
-}

+ 0 - 68
packages/plugin-lsx/src/client/js/util/Interceptor/LsxPreRenderInterceptor.js

@@ -1,68 +0,0 @@
-import { customTagUtils, BasicInterceptor } from '@growi/core';
-import ReactDOM from 'react-dom';
-
-/**
- * The interceptor for lsx
- *
- *  replace lsx tag to a React target element
- */
-export class LsxPreRenderInterceptor extends BasicInterceptor {
-
-  constructor() {
-    super();
-
-    this.previousPreviewContext = null;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isInterceptWhen(contextName) {
-    return (
-      contextName === 'preRenderHtml'
-      || contextName === 'preRenderPreviewHtml'
-    );
-  }
-
-  /**
-   * @inheritdoc
-   */
-  isProcessableParallel() {
-    return false;
-  }
-
-  /**
-   * @inheritdoc
-   */
-  async process(contextName, ...args) {
-    const context = Object.assign(args[0]); // clone
-    const parsedHTML = context.parsedHTML;
-
-    const tagPattern = /ls|lsx/;
-    const result = customTagUtils.findTagAndReplace(tagPattern, parsedHTML);
-
-    context.parsedHTML = result.html;
-    context.lsxContextMap = result.tagContextMap;
-
-    // unmount
-    if (contextName === 'preRenderPreviewHtml') {
-      this.unmountPreviousReactDOMs(context);
-    }
-
-    // resolve
-    return context;
-  }
-
-  unmountPreviousReactDOMs(newContext) {
-    if (this.previousPreviewContext != null) {
-      // forEach keys of lsxContextMap
-      Object.keys(this.previousPreviewContext.lsxContextMap).forEach((domId) => {
-        const elem = document.getElementById(domId);
-        ReactDOM.unmountComponentAtNode(elem);
-      });
-    }
-
-    this.previousPreviewContext = newContext;
-  }
-
-}

+ 0 - 61
packages/plugin-lsx/src/client/js/util/LsxContext.js

@@ -1,61 +0,0 @@
-import * as url from 'url';
-
-import { customTagUtils, pathUtils } from '@growi/core';
-
-const { TagContext, ArgsParser, OptionParser } = customTagUtils;
-
-export class LsxContext extends TagContext {
-
-  /**
-   * @param {object|TagContext|LsxContext} initArgs
-   */
-  constructor(initArgs) {
-    super(initArgs);
-
-    this.fromPagePath = null;
-
-    // initialized after parse()
-    this.isParsed = null;
-    this.pagePath = null;
-    this.options = {};
-  }
-
-  parse() {
-    if (this.isParsed) {
-      return;
-    }
-
-    const parsedResult = ArgsParser.parse(this.args);
-    this.options = parsedResult.options;
-
-    // determine specifiedPath
-    // order:
-    //   1: lsx(prefix=..., ...)
-    //   2: lsx(firstArgs, ...)
-    //   3: fromPagePath
-    const specifiedPath = this.options.prefix
-        || ((parsedResult.firstArgsValue === true) ? parsedResult.firstArgsKey : undefined)
-        || this.fromPagePath;
-
-    // resolve pagePath
-    //   when `fromPagePath`=/hoge and `specifiedPath`=./fuga,
-    //        `pagePath` to be /hoge/fuga
-    //   when `fromPagePath`=/hoge and `specifiedPath`=/fuga,
-    //        `pagePath` to be /fuga
-    //   when `fromPagePath`=/hoge and `specifiedPath`=undefined,
-    //        `pagePath` to be /hoge
-    this.pagePath = (specifiedPath !== undefined)
-      ? decodeURIComponent(url.resolve(pathUtils.addTrailingSlash(this.fromPagePath), specifiedPath))
-      : this.fromPagePath;
-
-    this.isParsed = true;
-  }
-
-  getOptDepth() {
-    if (this.options.depth === undefined) {
-      return undefined;
-    }
-    return OptionParser.parseRange(this.options.depth);
-  }
-
-}

+ 0 - 22
packages/plugin-lsx/src/client/js/util/TagCacheManagerFactory.js

@@ -1,22 +0,0 @@
-import { TagCacheManager } from '@growi/core';
-
-const LSX_STATE_CACHE_NS = 'lsx-state-cache';
-
-
-let _instance;
-export class TagCacheManagerFactory {
-
-  static getInstance() {
-    if (_instance == null) {
-      // create generateCacheKey implementation
-      const generateCacheKey = (lsxContext) => {
-        return `${lsxContext.fromPagePath}__${lsxContext.args}`;
-      };
-
-      _instance = new TagCacheManager(LSX_STATE_CACHE_NS, generateCacheKey);
-    }
-
-    return _instance;
-  }
-
-}

+ 26 - 0
packages/plugin-lsx/src/components/Lsx.module.scss

@@ -0,0 +1,26 @@
+.lsx :global {
+  page-list-ul > li > a:not(:hover) {
+    text-decoration: none;
+  }
+
+  .lsx-page-not-exist {
+    opacity: 0.6;
+  }
+
+  // workaround
+  // https://stackoverflow.com/a/57667536
+  .lsx-blink {
+    & :local {
+      animation: lsx-fadeIn 1s ease 0s infinite alternate;
+    }
+  }
+}
+
+@keyframes lsx-fadeIn {
+  0% {
+    opacity: 0.2;
+  }
+  100% {
+    opacity: 0.9;
+  }
+}

+ 265 - 0
packages/plugin-lsx/src/components/Lsx.tsx

@@ -0,0 +1,265 @@
+import React, {
+  useCallback, useEffect, useMemo, useState,
+} from 'react';
+
+import * as url from 'url';
+
+import { IPage, pathUtils } from '@growi/core';
+import axios from 'axios';
+
+import { LsxListView } from './LsxPageList/LsxListView';
+import { PageNode } from './PageNode';
+import { LsxContext } from './lsx-context';
+import { getInstance as getTagCacheManager } from './tag-cache-manager';
+
+import styles from './Lsx.module.scss';
+
+
+const tagCacheManager = getTagCacheManager();
+
+
+/**
+ * compare whether path1 and path2 is the same
+ *
+ * @param {string} path1
+ * @param {string} path2
+ * @returns
+ *
+ * @memberOf Lsx
+ */
+function isEquals(path1: string, path2: string) {
+  return pathUtils.removeTrailingSlash(path1) === pathUtils.removeTrailingSlash(path2);
+}
+
+function getParentPath(path: string) {
+  return pathUtils.addTrailingSlash(decodeURIComponent(url.resolve(path, '../')));
+}
+
+/**
+ * generate PageNode instances for target page and the ancestors
+ *
+ * @param {any} pathToNodeMap
+ * @param {any} rootPagePath
+ * @param {any} pagePath
+ * @returns
+ * @memberof Lsx
+ */
+function generatePageNode(pathToNodeMap: Record<string, PageNode>, rootPagePath: string, pagePath: string): PageNode | null {
+  // exclude rootPagePath itself
+  if (isEquals(pagePath, rootPagePath)) {
+    return null;
+  }
+
+  // return when already registered
+  if (pathToNodeMap[pagePath] != null) {
+    return pathToNodeMap[pagePath];
+  }
+
+  // generate node
+  const node = new PageNode(pagePath);
+  pathToNodeMap[pagePath] = node;
+
+  /*
+    * process recursively for ancestors
+    */
+  // get or create parent node
+  const parentPath = getParentPath(pagePath);
+  const parentNode = generatePageNode(pathToNodeMap, rootPagePath, parentPath);
+  // associate to patent
+  if (parentNode != null) {
+    parentNode.children.push(node);
+  }
+
+  return node;
+}
+
+function generatePageNodeTree(rootPagePath: string, pages: IPage[]) {
+  const pathToNodeMap: Record<string, PageNode> = {};
+
+  pages.forEach((page) => {
+    // add slash ensure not to forward match to another page
+    // e.g. '/Java/' not to match to '/JavaScript'
+    const pagePath = pathUtils.addTrailingSlash(page.path);
+
+    const node = generatePageNode(pathToNodeMap, rootPagePath, pagePath); // this will not be null
+
+    // exclude rootPagePath itself
+    if (node == null) {
+      return;
+    }
+
+    // set the Page substance
+    node.page = page;
+  });
+
+  // return root objects
+  const rootNodes: PageNode[] = [];
+  Object.keys(pathToNodeMap).forEach((pagePath) => {
+    // exclude '/'
+    if (pagePath === '/') {
+      return;
+    }
+
+    const parentPath = getParentPath(pagePath);
+
+    // pick up what parent doesn't exist
+    if ((parentPath === '/') || !(parentPath in pathToNodeMap)) {
+      rootNodes.push(pathToNodeMap[pagePath]);
+    }
+  });
+  return rootNodes;
+}
+
+
+type Props = {
+  children: React.ReactNode,
+  className?: string,
+
+  prefix: string,
+  num?: string,
+  depth?: string,
+  sort?: string,
+  reverse?: string,
+  filter?: string,
+
+  forceToFetchData?: boolean,
+};
+
+type StateCache = {
+  isError: boolean,
+  errorMessage: string,
+  basisViewersCount?: number,
+  nodeTree?: PageNode[],
+}
+
+export const Lsx = ({
+  prefix,
+  num, depth, sort, reverse, filter,
+  ...props
+}: Props): JSX.Element => {
+
+  const [isLoading, setLoading] = useState(false);
+  const [isError, setError] = useState(false);
+  const [isCacheExists, setCacheExists] = useState(false);
+  const [nodeTree, setNodeTree] = useState<PageNode[]|undefined>();
+  const [basisViewersCount, setBasisViewersCount] = useState<number|undefined>();
+  const [errorMessage, setErrorMessage] = useState('');
+
+  const { forceToFetchData } = props;
+
+  const lsxContext = useMemo(() => {
+    const options = {
+      num, depth, sort, reverse, filter,
+    };
+    return new LsxContext(prefix, options);
+  }, [depth, filter, num, prefix, reverse, sort]);
+
+  const retrieveDataFromCache = useCallback(() => {
+    // get state object cache
+    const stateCache = tagCacheManager.getStateCache(lsxContext) as StateCache | null;
+
+    // instanciate PageNode
+    if (stateCache != null && stateCache.nodeTree != null) {
+      stateCache.nodeTree = stateCache.nodeTree.map((obj) => {
+        return PageNode.instanciateFrom(obj);
+      });
+    }
+
+    return stateCache;
+  }, [lsxContext]);
+
+  const loadData = useCallback(async() => {
+    setLoading(true);
+
+    // add slash ensure not to forward match to another page
+    // ex: '/Java/' not to match to '/JavaScript'
+    const pagePath = pathUtils.addTrailingSlash(lsxContext.pagePath);
+
+    let newNodeTree: PageNode[] = [];
+    try {
+      const result = await axios.get('/_api/plugins/lsx', {
+        params: {
+          pagePath,
+          options: lsxContext.options,
+        },
+      });
+
+      newNodeTree = generatePageNodeTree(pagePath, result.data.pages);
+      setNodeTree(newNodeTree);
+      setBasisViewersCount(result.data.toppageViewersCount);
+      setError(false);
+
+      // store to sessionStorage
+      tagCacheManager.cacheState(lsxContext, {
+        isError: false,
+        errorMessage: '',
+        basisViewersCount,
+        nodeTree: newNodeTree,
+      });
+    }
+    catch (error) {
+      setError(true);
+      setErrorMessage(error.message);
+
+      // store to sessionStorage
+      tagCacheManager.cacheState(lsxContext, {
+        isError: true,
+        errorMessage: error.message,
+      });
+    }
+    finally {
+      setLoading(false);
+    }
+  }, [basisViewersCount, lsxContext]);
+
+  useEffect(() => {
+    // get state object cache
+    const stateCache = retrieveDataFromCache();
+
+    if (stateCache != null) {
+      setCacheExists(true);
+      setNodeTree(stateCache.nodeTree);
+      setError(stateCache.isError);
+      setErrorMessage(stateCache.errorMessage);
+
+      // switch behavior by forceToFetchData
+      if (!forceToFetchData) {
+        return; // go to render()
+      }
+    }
+
+    loadData();
+  }, [forceToFetchData, loadData, retrieveDataFromCache]);
+
+  const renderContents = () => {
+    if (isError) {
+      return (
+        <div className="text-warning">
+          <i className="fa fa-exclamation-triangle fa-fw"></i>
+          {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
+        </div>
+      );
+    }
+
+    const showListView = nodeTree != null && (!isLoading || nodeTree.length > 0);
+
+    return (
+      <>
+        { isLoading && (
+          <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
+            <small>
+              <i className="fa fa-spinner fa-pulse mr-1"></i>
+              {lsxContext.toString()}
+              { isCacheExists && <>&nbsp;(Showing cache..)</> }
+            </small>
+          </div>
+        ) }
+        { showListView && (
+          <LsxListView nodeTree={nodeTree} lsxContext={lsxContext} basisViewersCount={basisViewersCount} />
+        ) }
+      </>
+    );
+  };
+
+  return <div className={`lsx ${styles.lsx}`}>{renderContents()}</div>;
+};

+ 1 - 1
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxListView.jsx → packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx

@@ -2,8 +2,8 @@ import React from 'react';
 
 import PropTypes from 'prop-types';
 
-import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
 
 import { LsxPage } from './LsxPage';
 

+ 2 - 2
packages/plugin-lsx/src/client/js/components/LsxPageList/LsxPage.jsx → packages/plugin-lsx/src/components/LsxPageList/LsxPage.jsx

@@ -4,8 +4,8 @@ import { pathUtils } from '@growi/core';
 import { PageListMeta } from '@growi/ui';
 import PropTypes from 'prop-types';
 
-import { LsxContext } from '../../util/LsxContext';
 import { PageNode } from '../PageNode';
+import { LsxContext } from '../lsx-context';
 
 import { PagePathWrapper } from './PagePathWrapper';
 
@@ -33,7 +33,7 @@ export class LsxPage extends React.Component {
 
     // process depth option
     const optDepth = this.props.lsxContext.getOptDepth();
-    if (optDepth === undefined) {
+    if (optDepth == null) {
       this.setState({ isLinkable: true });
     }
     else {

+ 0 - 0
packages/plugin-lsx/src/client/js/components/LsxPageList/PagePathWrapper.jsx → packages/plugin-lsx/src/components/LsxPageList/PagePathWrapper.jsx


+ 2 - 0
packages/plugin-lsx/src/client/js/components/PageNode.js → packages/plugin-lsx/src/components/PageNode.js

@@ -3,6 +3,8 @@ export class PageNode {
   constructor(pagePath) {
     this.pagePath = pagePath;
     this.children = [];
+
+    this.page = undefined;
   }
 
   /**

+ 1 - 0
packages/plugin-lsx/src/components/index.ts

@@ -0,0 +1 @@
+export { Lsx } from './Lsx';

+ 48 - 0
packages/plugin-lsx/src/components/lsx-context.ts

@@ -0,0 +1,48 @@
+import { customTagUtils, ParseRangeResult } from '@growi/core';
+
+const { OptionParser } = customTagUtils;
+
+
+export class LsxContext {
+
+  pagePath: string;
+
+  options?: Record<string, string|undefined>;
+
+  constructor(pagePath: string, options: Record<string, string|undefined>) {
+    this.pagePath = pagePath;
+
+    // remove undefined keys
+    Object.keys(options).forEach(key => options[key] === undefined && delete options[key]);
+
+    this.options = options;
+  }
+
+  getOptDepth(): ParseRangeResult | null {
+    if (this.options?.depth == null) {
+      return null;
+    }
+    return OptionParser.parseRange(this.options.depth);
+  }
+
+  getStringifiedAttributes(separator = ', '): string {
+    const attributeStrs = [`prefix=${this.pagePath}`];
+    if (this.options != null) {
+      const optionEntries = Object.entries(this.options).sort();
+      attributeStrs.push(
+        ...optionEntries.map(([key, val]) => `${key}=${val || 'true'}`),
+      );
+    }
+
+    return attributeStrs.join(separator);
+  }
+
+  /**
+   * for printing errors
+   * @returns
+   */
+  toString(): string {
+    return `$lsx(${this.getStringifiedAttributes()})`;
+  }
+
+}

+ 21 - 0
packages/plugin-lsx/src/components/tag-cache-manager.ts

@@ -0,0 +1,21 @@
+import { TagCacheManager } from '@growi/core';
+
+import { LsxContext } from './lsx-context';
+
+const LSX_STATE_CACHE_NS = 'lsx-state-cache';
+
+
+let _instance;
+
+export function getInstance(): TagCacheManager {
+  if (_instance == null) {
+    // create generateCacheKey implementation
+    const generateCacheKey = (lsxContext: LsxContext) => {
+      return `${lsxContext.pagePath}__${lsxContext.getStringifiedAttributes('_')}`;
+    };
+
+    _instance = new TagCacheManager(LSX_STATE_CACHE_NS, generateCacheKey);
+  }
+
+  return _instance;
+}

+ 0 - 11
packages/plugin-lsx/src/index.js

@@ -1,11 +0,0 @@
-const isProd = process.env.NODE_ENV === 'production';
-
-module.exports = {
-  pluginSchemaVersion: 4,
-  serverEntries: [
-    isProd ? 'dist/cjs/server-entry.js' : 'src/server-entry.js',
-  ],
-  clientEntries: [
-    'src/client-entry.js',
-  ],
-};

+ 6 - 0
packages/plugin-lsx/src/index.ts

@@ -0,0 +1,6 @@
+import * as _serverRoutes from './server/routes';
+
+export const serverRoutes = _serverRoutes;
+
+export * from './components';
+export * from './services/renderer';

+ 0 - 4
packages/plugin-lsx/src/server-entry.js

@@ -1,4 +0,0 @@
-module.exports = (crowi, app) => {
-  // add routes
-  require('./server/routes')(crowi, app);
-};

+ 18 - 20
packages/plugin-lsx/src/server/routes/lsx.js

@@ -135,22 +135,13 @@ class Lsx {
    */
   static addSortCondition(query, pagePath, optionsSortArg, optionsReverse) {
     // init sort key
-    const optionsSort = optionsSortArg || 'path';
+    const optionsSort = optionsSortArg ?? 'path';
 
     // the default sort order
-    let isReversed = false;
+    const isReversed = optionsReverse === 'true';
 
-    if (optionsSort != null) {
-      if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
-        throw new Error(`The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
-      }
-    }
-
-    if (optionsReverse != null) {
-      if (optionsReverse !== 'true' && optionsReverse !== 'false') {
-        throw new Error(`The specified value '${optionsReverse}' for the reverse option is invalid. It must be 'true' or 'false'.`);
-      }
-      isReversed = (optionsReverse === 'true');
+    if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
+      throw new Error(`The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
     }
 
     const sortOption = {};
@@ -162,8 +153,6 @@ class Lsx {
 
 module.exports = (crowi, app) => {
   const Page = crowi.model('Page');
-  const User = crowi.model('User');
-  const ApiResponse = crowi.require('../util/apiResponse');
   const actions = {};
 
   /**
@@ -203,8 +192,17 @@ module.exports = (crowi, app) => {
 
   actions.listPages = async(req, res) => {
     const user = req.user;
-    const pagePath = req.query.pagePath;
-    const options = JSON.parse(req.query.options);
+
+    let pagePath;
+    let options;
+
+    try {
+      pagePath = req.query.pagePath;
+      options = JSON.parse(req.query.options);
+    }
+    catch (error) {
+      return res.status(400).send(error);
+    }
 
     const builder = await generateBaseQueryBuilder(pagePath, user);
 
@@ -221,7 +219,7 @@ module.exports = (crowi, app) => {
         : 1;
     }
     catch (error) {
-      return res.json(ApiResponse.error(error));
+      return res.status(500).send(error);
     }
 
     let query = builder.query;
@@ -244,10 +242,10 @@ module.exports = (crowi, app) => {
       query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
 
       const pages = await query.exec();
-      res.json(ApiResponse.success({ pages, toppageViewersCount }));
+      res.status(200).send({ pages, toppageViewersCount });
     }
     catch (error) {
-      return res.json(ApiResponse.error(error));
+      return res.status(500).send(error);
     }
   };
 

+ 1 - 0
packages/plugin-lsx/src/services/renderer/index.ts

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

+ 108 - 0
packages/plugin-lsx/src/services/renderer/lsx.ts

@@ -0,0 +1,108 @@
+import assert from 'assert';
+
+import { pathUtils } from '@growi/core';
+import { RemarkGrowiPluginType } from '@growi/remark-growi-plugin';
+import { Schema as SanitizeOption } from 'hast-util-sanitize';
+import { selectAll, HastNode } from 'hast-util-select';
+import { Plugin } from 'unified';
+import { visit } from 'unist-util-visit';
+
+const NODE_NAME_PATTERN = new RegExp(/ls|lsx/);
+const SUPPORTED_ATTRIBUTES = ['prefix', 'num', 'depth', 'sort', 'reverse', 'filter'];
+
+const { hasHeadingSlash } = pathUtils;
+
+type DirectiveAttributes = Record<string, string>
+
+
+export const remarkPlugin: Plugin = function() {
+  return (tree) => {
+    visit(tree, (node) => {
+      if (node.type === RemarkGrowiPluginType.Text || node.type === RemarkGrowiPluginType.Leaf) {
+        if (typeof node.name !== 'string') {
+          return;
+        }
+        if (!NODE_NAME_PATTERN.test(node.name)) {
+          return;
+        }
+
+        const data = node.data ?? (node.data = {});
+        const attributes = node.attributes as DirectiveAttributes || {};
+
+        // set 'prefix' attribute if the first attribute is only value
+        // e.g.
+        //   case 1: lsx(prefix=/path..., ...)    => prefix="/path"
+        //   case 2: lsx(/path, ...)              => prefix="/path"
+        //   case 3: lsx(/foo, prefix=/bar ...)   => prefix="/bar"
+        if (attributes.prefix == null) {
+          const attrEntries = Object.entries(attributes);
+
+          if (attrEntries.length > 0) {
+            const [firstAttrKey, firstAttrValue] = attrEntries[0];
+
+            if (firstAttrValue === '' && !SUPPORTED_ATTRIBUTES.includes(firstAttrValue)) {
+              attributes.prefix = firstAttrKey;
+            }
+          }
+        }
+
+        data.hName = 'lsx';
+        data.hProperties = attributes;
+      }
+    });
+  };
+};
+
+export type LsxRehypePluginParams = {
+  pagePath?: string,
+}
+
+const pathResolver = (relativeHref: string, basePath: string): string => {
+  // generate relative pathname
+  const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
+  const relativeUrl = new URL(relativeHref, baseUrl);
+
+  return relativeUrl.pathname;
+};
+
+export const rehypePlugin: Plugin<[LsxRehypePluginParams]> = (options = {}) => {
+  assert.notStrictEqual(options.pagePath, null, 'lsx rehype plugin requires \'pagePath\' option');
+
+  return (tree) => {
+    if (options.pagePath == null) {
+      return;
+    }
+
+    const basePagePath = options.pagePath;
+    const elements = selectAll('lsx', tree as HastNode);
+
+    elements.forEach((lsxElem) => {
+      if (lsxElem.properties == null) {
+        return;
+      }
+
+      const prefix = lsxElem.properties.prefix;
+
+      // set basePagePath when prefix is undefined or invalid
+      if (prefix == null || typeof prefix !== 'string') {
+        lsxElem.properties.prefix = basePagePath;
+        return;
+      }
+
+      // return when prefix is already determined and aboslute path
+      if (hasHeadingSlash(prefix)) {
+        return;
+      }
+
+      // resolve relative path
+      lsxElem.properties.prefix = pathResolver(prefix, basePagePath);
+    });
+  };
+};
+
+export const sanitizeOption: SanitizeOption = {
+  tagNames: ['lsx'],
+  attributes: {
+    lsx: SUPPORTED_ATTRIBUTES,
+  },
+};

+ 1 - 0
packages/plugin-lsx/tsconfig.base.json

@@ -1,6 +1,7 @@
 {
   "extends": "../../tsconfig.base.json",
   "compilerOptions": {
+    "jsx": "preserve",
   },
   "include": [
     "src"

+ 13 - 12
packages/remark-growi-plugin/src/micromark-extension-growi-plugin/lib/factory-attributes.js

@@ -10,13 +10,14 @@ import {
   asciiAlpha,
   asciiAlphanumeric,
   markdownLineEnding,
-  markdownLineEndingOrSpace,
   markdownSpace,
 } from 'micromark-util-character';
 import { codes } from 'micromark-util-symbol/codes.js';
 import { types } from 'micromark-util-symbol/types.js';
 import { ok as assert } from 'uvu/assert';
 
+import { markdownLineEndingOrSpaceOrComma, factoryAttributesDevider } from '../../micromark-factory-attributes-devider/index.js';
+
 /**
  * @param {Effects} effects
  * @param {State} ok
@@ -81,7 +82,7 @@ export function factoryAttributes(
       return shortcutStart(code);
     }
 
-    if (code === codes.colon || code === codes.underscore || asciiAlpha(code)) {
+    if (code === codes.colon || code === codes.underscore || code === codes.slash || asciiAlpha(code)) {
       effects.enter(attributeType);
       effects.enter(attributeNameType);
       effects.consume(code);
@@ -92,8 +93,8 @@ export function factoryAttributes(
       return factorySpace(effects, between, types.whitespace)(code);
     }
 
-    if (!disallowEol && markdownLineEndingOrSpace(code)) {
-      return factoryWhitespace(effects, between)(code);
+    if (!disallowEol && (markdownLineEndingOrSpaceOrComma(code))) {
+      return factoryAttributesDevider(effects, between)(code);
     }
 
     return end(code);
@@ -122,7 +123,7 @@ export function factoryAttributes(
       || code === codes.greaterThan
       || code === codes.graveAccent
       || code === codes.rightParenthesis
-      || markdownLineEndingOrSpace(code)
+      || markdownLineEndingOrSpaceOrComma(code)
     ) {
       return nok(code);
     }
@@ -150,7 +151,7 @@ export function factoryAttributes(
       code === codes.numberSign
       || code === codes.dot
       || code === codes.rightParenthesis
-      || markdownLineEndingOrSpace(code)
+      || markdownLineEndingOrSpaceOrComma(code)
     ) {
       effects.exit(`${type}Value`);
       effects.exit(type);
@@ -181,8 +182,8 @@ export function factoryAttributes(
       return factorySpace(effects, nameAfter, types.whitespace)(code);
     }
 
-    if (!disallowEol && markdownLineEndingOrSpace(code)) {
-      return factoryWhitespace(effects, nameAfter)(code);
+    if (!disallowEol && markdownLineEndingOrSpaceOrComma(code)) {
+      return factoryAttributesDevider(effects, nameAfter)(code);
     }
 
     return nameAfter(code);
@@ -229,8 +230,8 @@ export function factoryAttributes(
       return factorySpace(effects, valueBefore, types.whitespace)(code);
     }
 
-    if (!disallowEol && markdownLineEndingOrSpace(code)) {
-      return factoryWhitespace(effects, valueBefore)(code);
+    if (!disallowEol && markdownLineEndingOrSpaceOrComma(code)) {
+      return factoryAttributesDevider(effects, valueBefore)(code);
     }
 
     effects.enter(attributeValueType);
@@ -254,7 +255,7 @@ export function factoryAttributes(
       return nok(code);
     }
 
-    if (code === codes.rightParenthesis || markdownLineEndingOrSpace(code)) {
+    if (code === codes.rightParenthesis || markdownLineEndingOrSpaceOrComma(code)) {
       effects.exit(attributeValueData);
       effects.exit(attributeValueType);
       effects.exit(attributeType);
@@ -316,7 +317,7 @@ export function factoryAttributes(
 
   /** @type {State} */
   function valueQuotedAfter(code) {
-    return code === codes.rightParenthesis || markdownLineEndingOrSpace(code)
+    return code === codes.rightParenthesis || markdownLineEndingOrSpaceOrComma(code)
       ? between(code)
       : end(code);
   }

+ 12 - 0
packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.d.ts

@@ -0,0 +1,12 @@
+/**
+ * @param {Effects} effects
+ * @param {State} ok
+ */
+export function factoryAttributesDevider(
+  effects: Effects,
+  ok: State
+): (
+  code: import('micromark-util-types').Code
+) => void | import('micromark-util-types').State
+export type Effects = import('micromark-util-types').Effects
+export type State = import('micromark-util-types').State

+ 51 - 0
packages/remark-growi-plugin/src/micromark-factory-attributes-devider/index.js

@@ -0,0 +1,51 @@
+/**
+ * @typedef {import('micromark-util-types').Effects} Effects
+ * @typedef {import('micromark-util-types').State} State
+ */
+import { factorySpace } from 'micromark-factory-space';
+import { markdownLineEnding, markdownSpace } from 'micromark-util-character';
+import { codes } from 'micromark-util-symbol/codes.js';
+
+export function markdownLineEndingOrSpaceOrComma(code) {
+  return code !== null && (code < codes.nul || code === codes.space || code === codes.comma);
+}
+
+/**
+ * @param {Effects} effects
+ * @param {State} ok
+ */
+export function factoryAttributesDevider(effects, ok) {
+  /** @type {boolean} */
+  let seen;
+  return start;
+  /** @type {State} */
+
+  function start(code) {
+    if (markdownLineEnding(code)) {
+      effects.enter('lineEnding');
+      effects.consume(code);
+      effects.exit('lineEnding');
+      seen = true;
+      return start;
+    }
+
+    // consume comma
+    if (code === codes.comma) {
+      effects.enter('attributeDevider');
+      effects.consume(code);
+      effects.exit('attributeDevider');
+      seen = true;
+      return start;
+    }
+
+    if (markdownSpace(code)) {
+      return factorySpace(
+        effects,
+        start,
+        seen ? 'linePrefix' : 'lineSuffix',
+      )(code);
+    }
+
+    return ok(code);
+  }
+}

+ 142 - 0
packages/remark-growi-plugin/src/micromark-factory-attributes-devider/readme.md

@@ -0,0 +1,142 @@
+# micromark-factory-whitespace
+
+[![Build][build-badge]][build]
+[![Coverage][coverage-badge]][coverage]
+[![Downloads][downloads-badge]][downloads]
+[![Size][bundle-size-badge]][bundle-size]
+[![Sponsors][sponsors-badge]][opencollective]
+[![Backers][backers-badge]][opencollective]
+[![Chat][chat-badge]][chat]
+
+micromark factory to parse [markdown line endings or spaces][ws] (found in lots
+of places).
+
+## Contents
+
+*   [Install](#install)
+*   [Use](#use)
+*   [API](#api)
+    *   [`factoryWhitespace(…)`](#factorywhitespace)
+*   [Security](#security)
+*   [Contribute](#contribute)
+*   [License](#license)
+
+## Install
+
+[npm][]:
+
+```sh
+npm install micromark-factory-whitespace
+```
+
+## Use
+
+```js
+import {factoryWhitespace} from 'micromark-factory-whitespace'
+import {codes} from 'micromark-util-symbol/codes'
+import {types} from 'micromark-util-symbol/types'
+
+// A micromark tokenizer that uses the factory:
+/** @type {Tokenizer} */
+function tokenizeTitle(effects, ok, nok) {
+  return start
+
+  /** @type {State} */
+  function start(code) {
+    return markdownLineEndingOrSpace(code)
+      ? factoryWhitespace(effects, before)(code)
+      : nok(code)
+  }
+
+  // …
+}
+```
+
+## API
+
+This module exports the following identifiers: `factoryWhitespace`.
+There is no default export.
+
+### `factoryWhitespace(…)`
+
+Note that there is no `nok` parameter:
+
+*   line endings or spaces in markdown are often optional, in which case this
+    factory can be used and `ok` will be switched to whether spaces were found
+    or not,
+*   One line ending or space can be detected with
+    [markdownLineEndingOrSpace(code)][ws] right before using `factoryWhitespace`
+
+###### Parameters
+
+*   `effects` (`Effects`) — Context
+*   `ok` (`State`) — State switched to when successful
+
+###### Returns
+
+`State`.
+
+## Security
+
+See [`security.md`][securitymd] in [`micromark/.github`][health] for how to
+submit a security report.
+
+## Contribute
+
+See [`contributing.md`][contributing] in [`micromark/.github`][health] for ways
+to get started.
+See [`support.md`][support] for ways to get help.
+
+This project has a [code of conduct][coc].
+By interacting with this repository, organisation, or community you agree to
+abide by its terms.
+
+## License
+
+[MIT][license] © [Titus Wormer][author]
+
+<!-- Definitions -->
+
+[build-badge]: https://github.com/micromark/micromark/workflows/main/badge.svg
+
+[build]: https://github.com/micromark/micromark/actions
+
+[coverage-badge]: https://img.shields.io/codecov/c/github/micromark/micromark.svg
+
+[coverage]: https://codecov.io/github/micromark/micromark
+
+[downloads-badge]: https://img.shields.io/npm/dm/micromark-factory-whitespace.svg
+
+[downloads]: https://www.npmjs.com/package/micromark-factory-whitespace
+
+[bundle-size-badge]: https://img.shields.io/bundlephobia/minzip/micromark-factory-whitespace.svg
+
+[bundle-size]: https://bundlephobia.com/result?p=micromark-factory-whitespace
+
+[sponsors-badge]: https://opencollective.com/unified/sponsors/badge.svg
+
+[backers-badge]: https://opencollective.com/unified/backers/badge.svg
+
+[opencollective]: https://opencollective.com/unified
+
+[npm]: https://docs.npmjs.com/cli/install
+
+[chat-badge]: https://img.shields.io/badge/chat-discussions-success.svg
+
+[chat]: https://github.com/micromark/micromark/discussions
+
+[license]: https://github.com/micromark/micromark/blob/main/license
+
+[author]: https://wooorm.com
+
+[health]: https://github.com/micromark/.github
+
+[securitymd]: https://github.com/micromark/.github/blob/HEAD/security.md
+
+[contributing]: https://github.com/micromark/.github/blob/HEAD/contributing.md
+
+[support]: https://github.com/micromark/.github/blob/HEAD/support.md
+
+[coc]: https://github.com/micromark/.github/blob/HEAD/code-of-conduct.md
+
+[ws]: https://github.com/micromark/micromark/tree/main/packages/micromark-util-character#markdownlineendingorspacecode

+ 28 - 0
packages/remark-growi-plugin/test/micromark-extension-growi-plugin.test.js

@@ -969,6 +969,34 @@ test('content', (t) => {
     'should support `class` shortcuts after `class` attributes',
   );
 
+  t.test('spec for growi plugin', (t) => {
+    t.equal(
+      micromark('a $lsx(/Sandbox)', options()),
+      '<p>a </p>',
+      'should support name with slash',
+    );
+
+    t.equal(
+      micromark('a $lsx(key=value, reverse)', options()),
+      '<p>a </p>',
+      'should support name=value and an attribute w/o value',
+    );
+
+    t.equal(
+      micromark('a $lsx(key=value, reverse, reverse2)', options()),
+      '<p>a </p>',
+      'should support consecutive attributes w/o value',
+    );
+
+    t.equal(
+      micromark('a $lsx(/Sandbox, key=value, reverse)', options()),
+      '<p>a </p>',
+      'should support name=value after an empty value attribute',
+    );
+
+    t.end();
+  });
+
   t.end();
 });
 

+ 10 - 0
yarn.lock

@@ -3957,6 +3957,11 @@
   resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.12.tgz#6b2c510a7ad7039e98e7b8d3d6598f4359e5c080"
   integrity sha512-vt+kDhq/M2ayberEtJcIN/hxXy1Pk+59g2FV/ZQceeaTyCtCucjL2Q7FXlFjtWn4n15KCr1NE2lNNFhp0lEThw==
 
+"@types/css-modules@^1.0.2":
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/@types/css-modules/-/css-modules-1.0.2.tgz#8884135f9be3e204b42ef7ad7fce2474e8d74cb6"
+  integrity sha512-tyqlt2GtEBdsxJylh78zSxI/kOJK5Iz8Ta4Fxr8KLTP8mD/IgMa84D8EKPS/AWCp+MDoctgJyikrVWY28GKmcg==
+
 "@types/debug@^0.0.30":
   version "0.0.30"
   resolved "https://registry.yarnpkg.com/@types/debug/-/debug-0.0.30.tgz#dc1e40f7af3b9c815013a7860e6252f6352a84df"
@@ -22991,6 +22996,11 @@ try-resolve@^1.0.1:
   resolved "https://registry.yarnpkg.com/try-resolve/-/try-resolve-1.0.1.tgz#cfde6fabd72d63e5797cfaab873abbe8e700e912"
   integrity sha1-z95vq9ctY+V5fPqrhzq76OcA6RI=
 
+ts-deepmerge@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/ts-deepmerge/-/ts-deepmerge-3.0.0.tgz#231c48901606eb104ab51a74cb447af0e9e669e4"
+  integrity sha512-gpjFrde/nE3jp7l5cJTDpyhdbGdAIO/AkNjaz4V9odnopdLd9NVrQcBDEBiE/ucMV9dmNOcdgzOVwS7U6SsBhA==
+
 ts-essentials@9.0.0:
   version "9.0.0"
   resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-9.0.0.tgz#6196b7f390926429256c70951c8edd260e8e5097"