Parcourir la source

Merge pull request #7018 from weseek/imprv/optimize-lsx

imprv: Optimize lsx
Yuki Takei il y a 3 ans
Parent
commit
573736911f
42 fichiers modifiés avec 364 ajouts et 626 suppressions
  1. 2 6
      .github/workflows/ci-app-prod.yml
  2. 3 5
      .github/workflows/ci-app.yml
  3. 2 6
      .vscode/launch.json
  4. 1 2
      packages/app/docker/Dockerfile
  5. 3 2
      packages/app/package.json
  6. 1 1
      packages/app/src/server/crowi/index.js
  7. 5 5
      packages/app/src/services/renderer/renderer.tsx
  8. 1 1
      packages/app/tsconfig.build.client.json
  9. 1 1
      packages/app/tsconfig.build.server.json
  10. 1 1
      packages/app/tsconfig.json
  11. 0 1
      packages/core/src/index.ts
  12. 0 69
      packages/core/src/plugin/service/tag-cache-manager.js
  13. 0 125
      packages/core/src/test/plugin/service/tag-cache-manager.test.js
  14. 0 265
      packages/plugin-lsx/src/components/Lsx.tsx
  15. 0 54
      packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx
  16. 0 48
      packages/plugin-lsx/src/components/PageNode.js
  17. 0 1
      packages/plugin-lsx/src/components/index.ts
  18. 0 21
      packages/plugin-lsx/src/components/tag-cache-manager.ts
  19. 1 1
      packages/remark-drawio-plugin/package.json
  20. 0 0
      packages/remark-lsx/.eslintignore
  21. 0 0
      packages/remark-lsx/.eslintrc.js
  22. 0 0
      packages/remark-lsx/.gitignore
  23. 1 1
      packages/remark-lsx/README.md
  24. 3 2
      packages/remark-lsx/package.json
  25. 0 0
      packages/remark-lsx/src/components/Lsx.module.scss
  26. 98 0
      packages/remark-lsx/src/components/Lsx.tsx
  27. 0 0
      packages/remark-lsx/src/components/LsxPageList/LsxListView.module.scss
  28. 58 0
      packages/remark-lsx/src/components/LsxPageList/LsxListView.tsx
  29. 22 8
      packages/remark-lsx/src/components/LsxPageList/LsxPage.tsx
  30. 1 0
      packages/remark-lsx/src/components/index.ts
  31. 0 0
      packages/remark-lsx/src/components/lsx-context.ts
  32. 0 0
      packages/remark-lsx/src/index.ts
  33. 7 0
      packages/remark-lsx/src/interfaces/page-node.ts
  34. 0 0
      packages/remark-lsx/src/server/routes/index.js
  35. 0 0
      packages/remark-lsx/src/server/routes/lsx.js
  36. 0 0
      packages/remark-lsx/src/services/renderer/index.ts
  37. 9 0
      packages/remark-lsx/src/services/renderer/lsx.ts
  38. 144 0
      packages/remark-lsx/src/stores/lsx.tsx
  39. 0 0
      packages/remark-lsx/tsconfig.base.json
  40. 0 0
      packages/remark-lsx/tsconfig.build.cjs.json
  41. 0 0
      packages/remark-lsx/tsconfig.build.esm.json
  42. 0 0
      packages/remark-lsx/tsconfig.json

+ 2 - 6
.github/workflows/ci-app-prod.yml

@@ -14,11 +14,9 @@ on:
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/core/**
-      - packages/remark-drawio-plugin/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/ui/**
-      - packages/plugin-**
   pull_request:
     branches:
       - master
@@ -33,11 +31,9 @@ on:
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/core/**
-      - packages/remark-drawio-plugin/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/ui/**
-      - packages/plugin-**
   workflow_call:
     inputs:
       cypress-config-video:

+ 3 - 5
.github/workflows/ci-app.yml

@@ -16,11 +16,9 @@ on:
       - '!packages/app/docker/**'
       - packages/codemirror-textlint/**
       - packages/core/**
-      - packages/remark-drawio-plugin/**
-      - packages/remark-growi-plugin/**
+      - packages/remark-*/**
       - packages/slack/**
       - packages/ui/**
-      - packages/plugin-*/**
 
 jobs:
   lint:
@@ -56,7 +54,7 @@ jobs:
 
       - name: lerna run lint for plugins
         run: |
-          yarn lerna run lint --scope @growi/remark-* --scope @growi/plugin-*
+          yarn lerna run lint --scope @growi/remark-*
       - name: lerna run lint for app
         run: |
           yarn lerna run lint --scope @growi/app --scope @growi/codemirror-textlint --scope @growi/core --scope @growi/slack --scope @growi/ui
@@ -110,7 +108,7 @@ jobs:
 
       - name: lerna run test for plugins
         run: |
-          yarn lerna run test --scope @growi/remark-* --scope @growi/plugin-*
+          yarn lerna run test --scope @growi/remark-*
 
       - name: Test app
         working-directory: ./packages/app

+ 2 - 6
.vscode/launch.json

@@ -73,12 +73,8 @@
             "path": "${workspaceFolder}/packages/core"
           },
           {
-            "url": "webpack://_n_e/plugin-attachment-refs",
-            "path": "${workspaceFolder}/packages/plugin-attachment-refs"
-          },
-          {
-            "url": "webpack://_n_e/plugin-lsx",
-            "path": "${workspaceFolder}/packages/plugin-lsx"
+            "url": "webpack://_n_e/remark-lsx",
+            "path": "${workspaceFolder}/packages/remark-lsx"
           },
           {
             "url": "webpack://_n_e/slack",

+ 1 - 2
packages/app/docker/Dockerfile

@@ -105,12 +105,11 @@ COPY ["package.json", "lerna.json", "tsconfig.base.json", "./"]
 COPY packages/app packages/app
 COPY packages/core packages/core
 COPY packages/codemirror-textlint packages/codemirror-textlint
-COPY packages/plugin-attachment-refs packages/plugin-attachment-refs
-COPY packages/plugin-lsx packages/plugin-lsx
 COPY packages/slack packages/slack
 COPY packages/ui packages/ui
 COPY packages/remark-drawio-plugin packages/remark-drawio-plugin
 COPY packages/remark-growi-plugin packages/remark-growi-plugin
+COPY packages/remark-lsx packages/remark-lsx
 COPY packages/hackmd packages/hackmd
 
 # build

+ 3 - 2
packages/app/package.json

@@ -68,8 +68,9 @@
     "@growi/codemirror-textlint": "^6.0.0-RC.9",
     "@growi/core": "^6.0.0-RC.9",
     "@growi/hackmd": "^6.0.0-RC.9",
-    "@growi/plugin-attachment-refs": "^6.0.0-RC.9",
-    "@growi/plugin-lsx": "^6.0.0-RC.9",
+    "@growi/remark-drawio-plugin": "^6.0.0-RC.9",
+    "@growi/remark-growi-plugin": "^6.0.0-RC.9",
+    "@growi/remark-lsx": "^6.0.0-RC.9",
     "@growi/slack": "^6.0.0-RC.9",
     "@promster/express": "^7.0.2",
     "@promster/server": "^7.0.4",

+ 1 - 1
packages/app/src/server/crowi/index.js

@@ -3,7 +3,7 @@ import http from 'http';
 import path from 'path';
 
 import { createTerminus } from '@godaddy/terminus';
-import lsxRoutes from '@growi/plugin-lsx/server/routes';
+import lsxRoutes from '@growi/remark-lsx/server/routes';
 import mongoose from 'mongoose';
 import next from 'next';
 

+ 5 - 5
packages/app/src/services/renderer/renderer.tsx

@@ -1,8 +1,8 @@
 // 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 { Lsx, LsxImmutable } from '@growi/remark-lsx/components';
+import * as lsxGrowiPlugin from '@growi/remark-lsx/services/renderer';
 import * as drawioPlugin from '@growi/remark-drawio-plugin';
 import growiPlugin from '@growi/remark-growi-plugin';
 import { Schema as SanitizeOption } from 'hast-util-sanitize';
@@ -352,7 +352,7 @@ export const generateViewOptions = (
     components.h1 = Header;
     components.h2 = Header;
     components.h3 = Header;
-    components.lsx = props => <Lsx {...props} forceToFetchData />;
+    components.lsx = Lsx;
     components.drawio = DrawioViewerWithEditButton;
     components.table = TableWithEditButton;
   }
@@ -426,7 +426,7 @@ export const generateSimpleViewOptions = (config: RendererConfig, pagePath: stri
 
   // add components
   if (components != null) {
-    components.lsx = props => <Lsx {...props} />;
+    components.lsx = LsxImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
   }
 
@@ -466,7 +466,7 @@ export const generatePreviewOptions = (config: RendererConfig, pagePath: string)
 
   // add components
   if (components != null) {
-    components.lsx = props => <Lsx {...props} />;
+    components.lsx = LsxImmutable;
     components.drawio = drawioPlugin.DrawioViewer;
   }
 

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

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

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

@@ -11,7 +11,7 @@
     "paths": {
       "~/*": ["./src/*"],
       "^/*": ["./*"],
-      "@growi/plugin-lsx/*": ["../plugin-lsx/dist/cjs/*"],
+      "@growi/remark-lsx/*": ["../remark-lsx/dist/cjs/*"],
       "debug": ["./src/utils/logger/alias-for-debug"]
     }
   },

+ 1 - 1
packages/app/tsconfig.json

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

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

@@ -22,7 +22,6 @@ export * from './interfaces/revision';
 export * from './interfaces/subscription';
 export * from './interfaces/tag';
 export * from './interfaces/user';
-export * from './plugin/service/tag-cache-manager';
 export * from './models/devided-page-path';
 export * from './models/vo/error-apiv3';
 export * from './service/localstorage-manager';

+ 0 - 69
packages/core/src/plugin/service/tag-cache-manager.js

@@ -1,69 +0,0 @@
-import { LocalStorageManager } from '../../service/localstorage-manager';
-
-/**
- * Service Class for caching React state and TagContext
- */
-export class TagCacheManager {
-
-  /**
-   * @callback generateCacheKey
-   * @param {TagContext} tagContext - TagContext instance
-   * @returns {string} Cache key from TagContext
-   *
-   */
-
-  /**
-   * Constructor
-   * @param {string} cacheNs Used as LocalStorageManager namespace
-   * @param {generateCacheKey} generateCacheKey
-   */
-  constructor(cacheNs, generateCacheKey) {
-    if (cacheNs == null) {
-      throw new Error('args \'cacheNs\' is required.');
-    }
-    if (generateCacheKey == null) {
-      throw new Error('args \'generateCacheKey\' is required.');
-    }
-    if (typeof generateCacheKey !== 'function') {
-      throw new Error('args \'generateCacheKey\' should be function.');
-    }
-
-    this.cacheNs = cacheNs;
-    this.generateCacheKey = generateCacheKey;
-  }
-
-  /**
-   * Retrieve state cache object from local storage
-   * @param {TagContext} tagContext
-   * @returns {object} a cache object that correspont to the specified `tagContext`
-   */
-  getStateCache(tagContext) {
-    const localStorageManager = LocalStorageManager.getInstance();
-
-    const key = this.generateCacheKey(tagContext);
-    const stateCache = localStorageManager.retrieveFromSessionStorage(this.cacheNs, key);
-
-    return stateCache;
-  }
-
-  /**
-   * store state object of React Component with specified key
-   *
-   * @param {TagContext} tagContext
-   * @param {object} state state object of React Component
-   */
-  cacheState(tagContext, state) {
-    const localStorageManager = LocalStorageManager.getInstance();
-    const key = this.generateCacheKey(tagContext);
-    localStorageManager.saveToSessionStorage(this.cacheNs, key, state);
-  }
-
-  /**
-   * clear all state caches
-   */
-  clearAllStateCaches() {
-    const localStorageManager = LocalStorageManager.getInstance();
-    localStorageManager.clearAllStateCaches(this.cacheNs);
-  }
-
-}

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

@@ -1,125 +0,0 @@
-/* eslint-disable import/first */
-
-// import each from 'jest-each';
-jest.mock('~/service/localstorage-manager');
-
-import { TagCacheManager } from '~/plugin/service/tag-cache-manager';
-import { LocalStorageManager } from '~/service/localstorage-manager';
-/* eslint-enable import/first */
-
-describe('TagCacheManager.constructor', () => {
-
-  test('throws Exception when \'cacheNs\' is null', () => {
-    const generateCacheKeyMock = jest.fn();
-
-    expect(() => {
-      // eslint-disable-next-line no-new
-      new TagCacheManager(null, generateCacheKeyMock);
-    }).toThrowError(/cacheNs/);
-  });
-
-  test('throws Exception when \'generateCacheKey\' is null', () => {
-    expect(() => {
-      // eslint-disable-next-line no-new
-      new TagCacheManager('dummy ns', null);
-    }).toThrowError(/generateCacheKey/);
-  });
-
-  test('throws Exception when \'generateCacheKey\' is not function', () => {
-    expect(() => {
-      // eslint-disable-next-line no-new
-      new TagCacheManager('dummy ns', {});
-    }).toThrowError(/generateCacheKey/);
-  });
-
-  test('set params', () => {
-    const generateCacheKeyMock = jest.fn();
-
-    const instance = new TagCacheManager('dummy ns', generateCacheKeyMock);
-    expect(instance).not.toBeNull();
-    expect(instance.cacheNs).toBe('dummy ns');
-    expect(instance.generateCacheKey).toBe(generateCacheKeyMock);
-  });
-
-});
-
-describe('TagCacheManager', () => {
-
-  let generateCacheKeyMock = null;
-  let localStorageManagerMock = null;
-
-  let tagCacheManager = null;
-
-
-  beforeEach(() => {
-    generateCacheKeyMock = jest.fn();
-    localStorageManagerMock = jest.fn();
-
-    // mock for LocalStorageManager.getInstance
-    LocalStorageManager.getInstance = jest.fn();
-    LocalStorageManager.getInstance.mockReturnValue(localStorageManagerMock);
-
-    tagCacheManager = new TagCacheManager('dummy ns', generateCacheKeyMock);
-  });
-
-  test('.getStateCache', () => {
-    // partial mock
-    tagCacheManager.generateCacheKey = jest.fn().mockReturnValue('dummy key');
-
-    // mock for LocalStorageManager
-    const stateCacheMock = jest.fn();
-    localStorageManagerMock.retrieveFromSessionStorage = jest.fn();
-    localStorageManagerMock.retrieveFromSessionStorage
-      .mockReturnValue(stateCacheMock);
-
-    const tagContextMock = jest.fn();
-
-    // when
-    const result = tagCacheManager.getStateCache(tagContextMock);
-    // then
-    expect(result).not.toBeNull();
-    expect(result).toBe(stateCacheMock);
-    const generateCacheKeyMockCalls = tagCacheManager.generateCacheKey.mock.calls;
-    expect(generateCacheKeyMockCalls.length).toBe(1);
-    expect(generateCacheKeyMockCalls[0][0]).toBe(tagContextMock);
-    const retrieveFromSessionStorageMockCalls = localStorageManagerMock.retrieveFromSessionStorage.mock.calls;
-    expect(retrieveFromSessionStorageMockCalls.length).toBe(1);
-    expect(retrieveFromSessionStorageMockCalls[0][0]).toBe('dummy ns');
-    expect(retrieveFromSessionStorageMockCalls[0][1]).toBe('dummy key');
-  });
-
-  test('.getStateCache with state object', () => {
-    // partial mock
-    tagCacheManager.generateCacheKey = jest.fn().mockReturnValue('dummy key');
-
-    // mock for LocalStorageManager
-    localStorageManagerMock.saveToSessionStorage = jest.fn();
-
-    const tagContextMock = jest.fn();
-    const stateMock = jest.fn();
-
-    // when
-    tagCacheManager.cacheState(tagContextMock, stateMock);
-    // then
-    const generateCacheKeyMockCalls = tagCacheManager.generateCacheKey.mock.calls;
-    expect(generateCacheKeyMockCalls.length).toBe(1);
-    expect(generateCacheKeyMockCalls[0][0]).toBe(tagContextMock);
-    const saveToSessionStorageMockCalls = localStorageManagerMock.saveToSessionStorage.mock.calls;
-    expect(saveToSessionStorageMockCalls.length).toBe(1);
-    expect(saveToSessionStorageMockCalls[0][0]).toBe('dummy ns');
-    expect(saveToSessionStorageMockCalls[0][1]).toBe('dummy key');
-    expect(saveToSessionStorageMockCalls[0][2]).toBe(stateMock);
-  });
-
-  test('.clearAllStateCaches', () => {
-    // mock for LocalStorageManager
-    localStorageManagerMock.clearAllStateCaches = jest.fn();
-
-    // when
-    tagCacheManager.clearAllStateCaches();
-    // then
-    const clearAllStateCachesMockCalls = localStorageManagerMock.clearAllStateCaches.mock.calls;
-    expect(clearAllStateCachesMockCalls.length).toBe(1);
-    expect(clearAllStateCachesMockCalls[0][0]).toBe('dummy ns');
-  });
-});

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

@@ -1,265 +0,0 @@
-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>;
-};

+ 0 - 54
packages/plugin-lsx/src/components/LsxPageList/LsxListView.jsx

@@ -1,54 +0,0 @@
-import React from 'react';
-
-import PropTypes from 'prop-types';
-
-import { PageNode } from '../PageNode';
-import { LsxContext } from '../lsx-context';
-
-import { LsxPage } from './LsxPage';
-
-import styles from './LsxListView.module.scss';
-
-export class LsxListView extends React.Component {
-
-  render() {
-    const listView = this.props.nodeTree.map((pageNode) => {
-      return (
-        <LsxPage
-          key={pageNode.pagePath}
-          depth={1}
-          pageNode={pageNode}
-          lsxContext={this.props.lsxContext}
-          basisViewersCount={this.props.basisViewersCount}
-        />
-      );
-    });
-
-    // no contents
-    if (this.props.nodeTree.length === 0) {
-      return (
-        <div className="text-muted">
-          <small>
-            <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
-            $lsx(<a href={this.props.lsxContext.pagePath}>{this.props.lsxContext.pagePath}</a>) has no contents
-          </small>
-        </div>
-      );
-    }
-
-    return (
-      <div className={`page-list ${styles['page-list']} lsx`}>
-        <ul className="page-list-ul">
-          {listView}
-        </ul>
-      </div>
-    );
-  }
-
-}
-
-LsxListView.propTypes = {
-  nodeTree: PropTypes.arrayOf(PropTypes.instanceOf(PageNode)).isRequired,
-  lsxContext: PropTypes.instanceOf(LsxContext).isRequired,
-  basisViewersCount: PropTypes.number,
-};

+ 0 - 48
packages/plugin-lsx/src/components/PageNode.js

@@ -1,48 +0,0 @@
-export class PageNode {
-
-  constructor(pagePath) {
-    this.pagePath = pagePath;
-    this.children = [];
-
-    this.page = undefined;
-  }
-
-  /**
-   * calculate generations number of decendants
-   *
-   * ex:
-   *  /foo          -2
-   *  /foo/bar      -1
-   *  /foo/bar/buz   0
-   *
-   * @returns generations num of decendants
-   *
-   * @memberOf PageNode
-   */
-  /*
-   * commented out because it became unnecessary -- 2017.05.18 Yuki Takei
-   *
-  getDecendantsGenerationsNum() {
-    if (this.children.length == 0) {
-      return -1;
-    }
-
-    return -1 + Math.min.apply(null, this.children.map((child) => {
-      return child.getDecendantsGenerationsNum();
-    }))
-  }
-  */
-
-  static instanciateFrom(obj) {
-    const pageNode = new PageNode(obj.pagePath);
-    pageNode.page = obj.page;
-
-    // instanciate recursively
-    pageNode.children = obj.children.map((childObj) => {
-      return PageNode.instanciateFrom(childObj);
-    });
-
-    return pageNode;
-  }
-
-}

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

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

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

@@ -1,21 +0,0 @@
-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;
-}

+ 1 - 1
packages/remark-drawio-plugin/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@growi/remark-drawio-plugin",
-  "version": "6.0.0-RC.8",
+  "version": "6.0.0-RC.9",
   "description": "remark plugin to draw diagrams with draw.io (diagrams.net)",
   "license": "MIT",
   "keywords": [

+ 0 - 0
packages/plugin-lsx/.eslintignore → packages/remark-lsx/.eslintignore


+ 0 - 0
packages/plugin-lsx/.eslintrc.js → packages/remark-lsx/.eslintrc.js


+ 0 - 0
packages/plugin-lsx/.gitignore → packages/remark-lsx/.gitignore


+ 1 - 1
packages/plugin-lsx/README.md → packages/remark-lsx/README.md

@@ -1,4 +1,4 @@
-# growi-plugin-lsx
+# remark-lsx
 
 [GROWI][growi] Plugin to add lsx tag like [Pukiwiki lsx plugin](http://ukiya.sakura.ne.jp/index.php?PukiWiki%2F1.4%2F%E3%83%9E%E3%83%8B%E3%83%A5%E3%82%A2%E3%83%AB%2F%E3%83%97%E3%83%A9%E3%82%B0%E3%82%A4%E3%83%B3%2F%E7%8B%AC%E8%87%AA%E3%81%AB%E8%BF%BD%E5%8A%A0%E3%81%97%E3%81%9F%E3%82%82%E3%81%AE%2Flsx)
 

+ 3 - 2
packages/plugin-lsx/package.json → packages/remark-lsx/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "@growi/plugin-lsx",
+  "name": "@growi/remark-lsx",
   "version": "6.0.0-RC.9",
   "description": "GROWI plugin to list pages",
   "license": "MIT",
@@ -25,7 +25,8 @@
   "dependencies": {
     "@growi/core": "^6.0.0-RC.9",
     "@growi/remark-growi-plugin": "^6.0.0-RC.9",
-    "@growi/ui": "^6.0.0-RC.9"
+    "@growi/ui": "^6.0.0-RC.9",
+    "swr": "^1.3.0"
   },
   "devDependencies": {
     "eslint-plugin-regex": "^1.8.0",

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


+ 98 - 0
packages/remark-lsx/src/components/Lsx.tsx

@@ -0,0 +1,98 @@
+import React, { useCallback, useMemo } from 'react';
+
+
+import { useSWRxNodeTree } from '../stores/lsx';
+
+import { LsxListView } from './LsxPageList/LsxListView';
+import { LsxContext } from './lsx-context';
+
+import styles from './Lsx.module.scss';
+
+
+type Props = {
+  children: React.ReactNode,
+  className?: string,
+
+  prefix: string,
+  num?: string,
+  depth?: string,
+  sort?: string,
+  reverse?: string,
+  filter?: string,
+
+  isImmutable?: boolean,
+};
+
+export const Lsx = React.memo(({
+  prefix,
+  num, depth, sort, reverse, filter,
+  isImmutable,
+  ...props
+}: Props): JSX.Element => {
+
+  const lsxContext = useMemo(() => {
+    const options = {
+      num, depth, sort, reverse, filter,
+    };
+    return new LsxContext(prefix, options);
+  }, [depth, filter, num, prefix, reverse, sort]);
+
+  const { data, error } = useSWRxNodeTree(lsxContext, isImmutable);
+
+  const isLoading = data === undefined;
+  const hasError = error != null;
+  const errorMessage = error?.message;
+
+  const Error = useCallback((): JSX.Element => {
+    if (!hasError) {
+      return <></>;
+    }
+
+    return (
+      <div className="text-warning">
+        <i className="fa fa-exclamation-triangle fa-fw"></i>
+        {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
+      </div>
+    );
+  }, [errorMessage, hasError, lsxContext]);
+
+  const Loading = useCallback((): JSX.Element => {
+    if (hasError) {
+      return <></>;
+    }
+    if (!isLoading) {
+      return <></>;
+    }
+
+    return (
+      <div className={`text-muted ${isLoading ? 'lsx-blink' : ''}`}>
+        <small>
+          <i className="fa fa-spinner fa-pulse mr-1"></i>
+          {lsxContext.toString()}
+        </small>
+      </div>
+    );
+  }, [hasError, isLoading, lsxContext]);
+
+  const contents = useMemo(() => {
+    if (isLoading) {
+      return <></>;
+    }
+
+    return <LsxListView nodeTree={data.nodeTree} lsxContext={lsxContext} basisViewersCount={data.toppageViewersCount} />;
+  }, [data?.nodeTree, data?.toppageViewersCount, isLoading, lsxContext]);
+
+  return (
+    <div className={`lsx ${styles.lsx}`}>
+      <Error />
+      <Loading />
+      {contents}
+    </div>
+  );
+});
+Lsx.displayName = 'Lsx';
+
+export const LsxImmutable = React.memo((props: Omit<Props, 'isImmutable'>): JSX.Element => {
+  return <Lsx {...props} isImmutable />;
+});
+LsxImmutable.displayName = 'LsxImmutable';

+ 0 - 0
packages/plugin-lsx/src/components/LsxPageList/LsxListView.module.scss → packages/remark-lsx/src/components/LsxPageList/LsxListView.module.scss


+ 58 - 0
packages/remark-lsx/src/components/LsxPageList/LsxListView.tsx

@@ -0,0 +1,58 @@
+import React, { useMemo } from 'react';
+
+import type { PageNode } from '../../interfaces/page-node';
+import { LsxContext } from '../lsx-context';
+
+import { LsxPage } from './LsxPage';
+
+import styles from './LsxListView.module.scss';
+
+
+type Props = {
+  nodeTree?: PageNode[],
+  lsxContext: LsxContext,
+  basisViewersCount?: number,
+};
+
+
+export const LsxListView = React.memo((props: Props): JSX.Element => {
+
+  const { nodeTree, lsxContext, basisViewersCount } = props;
+
+  const isEmpty = nodeTree == null || nodeTree.length === 0;
+
+  const contents = useMemo(() => {
+    if (isEmpty) {
+      return (
+        <div className="text-muted">
+          <small>
+            <i className="fa fa-fw fa-info-circle" aria-hidden="true"></i>
+            $lsx(<a href={lsxContext.pagePath}>{lsxContext.pagePath}</a>) has no contents
+          </small>
+        </div>
+      );
+    }
+
+    return nodeTree.map((pageNode) => {
+      return (
+        <LsxPage
+          key={pageNode.pagePath}
+          depth={1}
+          pageNode={pageNode}
+          lsxContext={lsxContext}
+          basisViewersCount={basisViewersCount}
+        />
+      );
+    });
+  }, [basisViewersCount, isEmpty, lsxContext, nodeTree]);
+
+  return (
+    <div className={`page-list ${styles['page-list']} lsx`}>
+      <ul className="page-list-ul">
+        {contents}
+      </ul>
+    </div>
+  );
+
+});
+LsxListView.displayName = 'LsxListView';

+ 22 - 8
packages/plugin-lsx/src/components/LsxPageList/LsxPage.tsx → packages/remark-lsx/src/components/LsxPageList/LsxPage.tsx

@@ -2,8 +2,9 @@ import React, { useMemo } from 'react';
 
 import { pathUtils } from '@growi/core';
 import { PagePathLabel, PageListMeta } from '@growi/ui';
+import Link from 'next/link';
 
-import { PageNode } from '../PageNode';
+import type { PageNode } from '../../interfaces/page-node';
 import { LsxContext } from '../lsx-context';
 
 
@@ -19,7 +20,8 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
     pageNode, lsxContext, depth, basisViewersCount,
   } = props;
 
-  const isExists = pageNode.page !== undefined;
+  const pageId = pageNode.page?._id;
+  const pagePath = pageNode.pagePath;
   const isLinkable = (() => {
     // process depth option
     const optDepth = lsxContext.getOptDepth();
@@ -58,31 +60,42 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
   }, [basisViewersCount, depth, hasChildren, lsxContext, pageNode.children]);
 
   const iconElement: JSX.Element = useMemo(() => {
+    const isExists = pageId != null;
     return (isExists)
       ? <i className="ti ti-agenda" aria-hidden="true"></i>
       : <i className="ti ti-file lsx-page-not-exist" aria-hidden="true"></i>;
-  }, [isExists]);
+  }, [pageId]);
 
   const pagePathElement: JSX.Element = useMemo(() => {
+    const isExists = pageId != null;
+
     const classNames: string[] = [];
     if (!isExists) {
       classNames.push('lsx-page-not-exist');
     }
 
     // create PagePath element
-    let pagePathNode = <PagePathLabel path={pageNode.pagePath} isLatterOnly additionalClassNames={classNames} />;
+    let pagePathNode = <PagePathLabel path={pagePath} isLatterOnly additionalClassNames={classNames} />;
     if (isLinkable) {
-      pagePathNode = <a className="page-list-link" href={encodeURI(pathUtils.removeTrailingSlash(pageNode.pagePath))}>{pagePathNode}</a>;
+      const href = isExists
+        ? `/${pageId}`
+        : encodeURI(pathUtils.removeTrailingSlash(pagePath));
+
+      pagePathNode = (
+        <Link href={href} prefetch={false}>
+          <a className="page-list-link" href={href}>{pagePathNode}</a>
+        </Link>
+      );
     }
     return pagePathNode;
-  }, [isExists, isLinkable, pageNode.pagePath]);
+  }, [isLinkable, pageId, pagePath]);
 
   const pageListMetaElement: JSX.Element = useMemo(() => {
-    if (!isExists) {
+    if (pageNode.page == null) {
       return <></>;
     }
     return <PageListMeta page={pageNode.page} basisViewersCount={basisViewersCount} />;
-  }, [basisViewersCount, isExists, pageNode.page]);
+  }, [basisViewersCount, pageNode.page]);
 
   return (
     <li className="page-list-li">
@@ -93,3 +106,4 @@ export const LsxPage = React.memo((props: Props): JSX.Element => {
   );
 
 });
+LsxPage.displayName = 'LsxPage';

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

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

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


+ 0 - 0
packages/plugin-lsx/src/index.ts → packages/remark-lsx/src/index.ts


+ 7 - 0
packages/remark-lsx/src/interfaces/page-node.ts

@@ -0,0 +1,7 @@
+import { IPageHasId } from '@growi/core';
+
+export type PageNode = {
+  pagePath: string,
+  children: PageNode[],
+  page?: IPageHasId,
+}

+ 0 - 0
packages/plugin-lsx/src/server/routes/index.js → packages/remark-lsx/src/server/routes/index.js


+ 0 - 0
packages/plugin-lsx/src/server/routes/lsx.js → packages/remark-lsx/src/server/routes/lsx.js


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


+ 9 - 0
packages/plugin-lsx/src/services/renderer/lsx.ts → packages/remark-lsx/src/services/renderer/lsx.ts

@@ -48,6 +48,15 @@ export const remarkPlugin: Plugin = function() {
 
         data.hName = 'lsx';
         data.hProperties = attributes;
+
+        // omit position to fix the key regardless of its position
+        // see:
+        //   https://github.com/remarkjs/react-markdown/issues/703
+        //   https://github.com/remarkjs/react-markdown/issues/466
+        //
+        //   https://github.com/remarkjs/react-markdown/blob/a80dfdee2703d84ac2120d28b0e4998a5b417c85/lib/ast-to-react.js#L201-L204
+        //   https://github.com/remarkjs/react-markdown/blob/a80dfdee2703d84ac2120d28b0e4998a5b417c85/lib/ast-to-react.js#L217-L222
+        delete node.position;
       }
     });
   };

+ 144 - 0
packages/remark-lsx/src/stores/lsx.tsx

@@ -0,0 +1,144 @@
+import * as url from 'url';
+
+import { IPageHasId, pathUtils } from '@growi/core';
+import axios from 'axios';
+import useSWR, { SWRResponse } from 'swr';
+
+import { LsxContext } from '../components/lsx-context';
+import type { PageNode } from '../interfaces/page-node';
+
+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 = { pagePath, children: [] };
+  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: IPageHasId[]) {
+  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 LsxResponse = {
+  pages: IPageHasId[],
+  toppageViewersCount: number,
+}
+
+const useSWRxLsxResponse = (
+    pagePath: string, options?: Record<string, string | undefined>, isImmutable?: boolean,
+): SWRResponse<LsxResponse, Error> => {
+  return useSWR(
+    ['/_api/plugins/lsx', pagePath, options, isImmutable],
+    (endpoint, pagePath, options) => {
+      return axios.get(endpoint, {
+        params: {
+          pagePath,
+          options,
+        },
+      }).then(result => result.data as LsxResponse);
+    },
+    {
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+    },
+  );
+};
+
+type LsxNodeTree = {
+  nodeTree: PageNode[],
+  toppageViewersCount: number,
+}
+
+export const useSWRxNodeTree = (lsxContext: LsxContext, isImmutable?: boolean): SWRResponse<LsxNodeTree, Error> => {
+  const { data, error } = useSWRxLsxResponse(lsxContext.pagePath, lsxContext.options, isImmutable);
+
+  return useSWR(
+    data === undefined ? null : ['lsxNodeTree', lsxContext.pagePath, lsxContext.options, isImmutable, data],
+    (key, pagePath, options, isImmutable, data) => {
+      if (error != null) {
+        throw error;
+      }
+      return {
+        nodeTree: generatePageNodeTree(pagePath, data.pages),
+        toppageViewersCount: data.toppageViewersCount,
+      };
+    },
+    {
+      revalidateIfStale: !isImmutable,
+      revalidateOnFocus: !isImmutable,
+      revalidateOnReconnect: !isImmutable,
+    },
+  );
+};

+ 0 - 0
packages/plugin-lsx/tsconfig.base.json → packages/remark-lsx/tsconfig.base.json


+ 0 - 0
packages/plugin-lsx/tsconfig.build.cjs.json → packages/remark-lsx/tsconfig.build.cjs.json


+ 0 - 0
packages/plugin-lsx/tsconfig.build.esm.json → packages/remark-lsx/tsconfig.build.esm.json


+ 0 - 0
packages/plugin-lsx/tsconfig.json → packages/remark-lsx/tsconfig.json