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

Merge pull request #7769 from weseek/fix/lsx-options

fix: Lsx options
Yuki Takei 2 лет назад
Родитель
Сommit
2cdbad68a6

+ 0 - 1
.devcontainer/devcontainer.json

@@ -19,7 +19,6 @@
     "eamodio.gitlens",
     "github.vscode-pull-request-github",
     "cschleiden.vscode-github-actions",
-    "firsttris.vscode-jest-runner",
     "msjsdiag.debugger-for-chrome",
     "firefox-devtools.vscode-firefox-debug",
     "editorconfig.editorconfig",

+ 8 - 0
apps/app/package.json

@@ -75,6 +75,7 @@
     "@promster/server": "^7.0.8",
     "@slack/web-api": "^6.2.4",
     "@slack/webhook": "^6.0.0",
+    "@types/jest": "^29.5.2",
     "JSONStream": "^1.3.5",
     "archiver": "^5.3.0",
     "array.prototype.flatmap": "^1.2.2",
@@ -212,7 +213,10 @@
     "@handsontable/react": "=2.1.0",
     "@icon/themify-icons": "1.0.1-alpha.3",
     "@next/bundle-analyzer": "^13.2.3",
+    "@swc-node/jest": "^1.6.2",
+    "@swc/jest": "^0.2.24",
     "@types/express": "^4.17.11",
+    "@types/jest": "^29.5.2",
     "@types/react-scroll": "^1.8.4",
     "autoprefixer": "^9.0.0",
     "babel-loader": "^8.2.5",
@@ -224,10 +228,14 @@
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",
+    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-regex": "^1.8.0",
     "font-awesome": "^4.7.0",
     "handsontable": "=6.2.2",
     "i18next-hmr": "^1.11.0",
+    "jest": "^29.5.0",
+    "jest-date-mock": "^1.0.8",
+    "jest-localstorage-mock": "^2.4.14",
     "jquery-slimscroll": "^1.3.8",
     "jquery.cookie": "~1.4.1",
     "load-css-file": "^1.0.0",

+ 1 - 8
package.json

@@ -53,16 +53,13 @@
     "yargs": "^17.7.1"
   },
   "devDependencies": {
-    "@swc-node/jest": "^1.6.2",
     "@swc-node/register": "^1.6.2",
     "@swc/core": "^1.3.36",
     "@swc/helpers": "^0.4.14",
-    "@swc/jest": "^0.2.24",
     "@testing-library/cypress": "^8.0.2",
     "@types/css-modules": "^1.0.2",
     "@types/eslint": "^8.37.0",
     "@types/estree": "^1.0.1",
-    "@types/jest": "^26.0.22",
     "@types/node": "^17.0.43",
     "@types/rewire": "^2.5.28",
     "@typescript-eslint/eslint-plugin": "^5.59.7",
@@ -77,15 +74,11 @@
     "eslint-config-weseek": "^2.1.1",
     "eslint-import-resolver-typescript": "^3.2.5",
     "eslint-plugin-import": "^2.26.0",
-    "eslint-plugin-jest": "^26.5.3",
     "eslint-plugin-react": "^7.30.1",
     "eslint-plugin-react-hooks": "^4.6.0",
     "eslint-plugin-rulesdir": "^0.2.2",
     "eslint-plugin-vitest": "^0.2.3",
     "glob": "^8.1.0",
-    "jest": "^28.1.3",
-    "jest-date-mock": "^1.0.8",
-    "jest-localstorage-mock": "^2.4.14",
     "mock-require": "^3.0.3",
     "postcss": "^8.4.5",
     "postcss-scss": "^4.0.3",
@@ -104,7 +97,7 @@
     "vite": "^4.3.8",
     "vite-plugin-dts": "^2.0.0-beta.0",
     "vite-tsconfig-paths": "^4.2.0",
-    "vitest": "^0.31.1",
+    "vitest": "^0.31.4",
     "vitest-mock-extended": "^1.1.3"
   },
   "engines": {

+ 4 - 1
packages/remark-lsx/package.json

@@ -19,7 +19,8 @@
     "lint:js": "yarn eslint **/*.{js,jsx,ts,tsx}",
     "lint:styles": "stylelint --allow-empty-input src/**/*.scss src/**/*.css",
     "lint:typecheck": "tsc",
-    "lint": "run-p lint:*"
+    "lint": "run-p lint:*",
+    "test": "vitest run --coverage"
   },
   "// comments for dependencies": {
     "escape-string-regexp": "5.0.0 or above exports only ESM"
@@ -29,6 +30,8 @@
     "@growi/remark-growi-directive": "^6.1.3-RC.0",
     "@growi/ui": "^6.1.3-RC.0",
     "escape-string-regexp": "^4.0.0",
+    "express": "^4.16.1",
+    "mongoose": "^6.5.0",
     "swr": "^2.0.3"
   },
   "devDependencies": {

+ 2 - 0
packages/remark-lsx/src/@types/declaration.d.ts

@@ -0,0 +1,2 @@
+// prevent TS2307: Cannot find module './xxx.module.scss' or its corresponding type declarations.
+declare module '*.scss';

+ 6 - 4
packages/remark-lsx/src/components/Lsx.tsx

@@ -49,10 +49,12 @@ const LsxSubstance = React.memo(({
     }
 
     return (
-      <div className="text-warning">
-        <i className="fa fa-exclamation-triangle fa-fw"></i>
-        {lsxContext.toString()} (-&gt; <small>{errorMessage}</small>)
-      </div>
+      <details>
+        <summary className="text-warning">
+          <i className="fa fa-exclamation-triangle fa-fw"></i> {lsxContext.toString()}
+        </summary>
+        <small className="ml-3 text-muted">{errorMessage}</small>
+      </details>
     );
   }, [errorMessage, hasError, lsxContext]);
 

+ 5 - 5
packages/remark-lsx/src/server/index.ts

@@ -1,17 +1,17 @@
-import { routesFactory } from './routes/lsx';
+import type { Request, Response } from 'express';
 
-const loginRequiredFallback = (req, res) => {
+import { listPages } from './routes/list-pages';
+
+const loginRequiredFallback = (req: Request, res: Response) => {
   return res.status(403).send('login required');
 };
 
 // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
 const middleware = (crowi: any, app: any): void => {
-  const lsx = routesFactory(crowi);
-
   const loginRequired = crowi.require('../middlewares/login-required')(crowi, true, loginRequiredFallback);
   const accessTokenParser = crowi.require('../middlewares/access-token-parser')(crowi);
 
-  app.get('/_api/lsx', accessTokenParser, loginRequired, lsx.listPages);
+  app.get('/_api/lsx', accessTokenParser, loginRequired, listPages);
 };
 
 export default middleware;

+ 41 - 0
packages/remark-lsx/src/server/routes/list-pages/add-depth-condition.ts

@@ -0,0 +1,41 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+import { pagePathUtils } from '@growi/core/dist/utils';
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+const { isTopPage } = pagePathUtils;
+
+export const addDepthCondition = (query: PageQuery, pagePath: string, optionsDepth: string): PageQuery => {
+
+  const range = OptionParser.parseRange(optionsDepth);
+
+  if (range == null) {
+    return query;
+  }
+
+  const start = range.start;
+  const end = range.end;
+
+  // check start
+  if (start < 1) {
+    throw createError(400, `specified depth is [${start}:${end}] : the start must be larger or equal than 1`);
+  }
+
+  // count slash
+  const slashNum = isTopPage(pagePath)
+    ? 1
+    : pagePath.split('/').length;
+  const depthStart = slashNum + start - 1;
+  const depthEnd = slashNum + end - 1;
+
+  if (end < 0) {
+    return query.and([
+      { path: new RegExp(`^(\\/[^\\/]*){${depthStart},}$`) },
+    ]);
+  }
+
+  return query.and([
+    { path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`) },
+  ]);
+};

+ 93 - 0
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.spec.ts

@@ -0,0 +1,93 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+import createError from 'http-errors';
+import { mock } from 'vitest-mock-extended';
+
+import { addNumCondition } from './add-num-condition';
+import type { PageQuery } from './generate-base-query';
+
+describe('addNumCondition()', () => {
+
+  const queryMock = mock<PageQuery>();
+
+  it('set limit with the specified number', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    const queryLimitResultMock = mock<PageQuery>();
+    queryMock.limit.calledWith(99).mockImplementation(() => queryLimitResultMock);
+
+    // when
+    const result = addNumCondition(queryMock, 99);
+
+    // then
+    expect(queryMock.limit).toHaveBeenCalledWith(99);
+    expect(result).toEqual(queryLimitResultMock);
+    expect(parseRangeSpy).not.toHaveBeenCalled();
+  });
+
+  it('returns the specified qeury as-is when the option value is invalid', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = addNumCondition(queryMock, 'invalid string');
+
+    // then
+    expect(queryMock.limit).not.toHaveBeenCalled();
+    expect(parseRangeSpy).toHaveBeenCalledWith('invalid string');
+    expect(result).toEqual(queryMock);
+  });
+
+  it('throws 400 http-errors instance when the start value is smaller than 1', () => {
+    // setup
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const caller = () => addNumCondition(queryMock, '-1:10');
+
+    // then
+    expect(caller).toThrowError(createError(400, 'specified num is [-1:10] : the start must be larger or equal than 1'));
+    expect(queryMock.limit).not.toHaveBeenCalledWith();
+    expect(parseRangeSpy).toHaveBeenCalledWith('-1:10');
+  });
+
+});
+
+
+describe('addNumCondition() set skip and limit with the range string', () => {
+
+  it.concurrent.each`
+    optionsNum    | expectedSkip    | expectedLimit   | isExpectedToSetLimit
+    ${'1:10'}     | ${0}            | ${10}           | ${true}
+    ${'3:'}       | ${2}            | ${-1}           | ${false}
+  `("'$optionsNum", ({
+    optionsNum, expectedSkip, expectedLimit, isExpectedToSetLimit,
+  }) => {
+    // setup
+    const queryMock = mock<PageQuery>();
+
+    const querySkipResultMock = mock<PageQuery>();
+    queryMock.skip.calledWith(expectedSkip).mockImplementation(() => querySkipResultMock);
+
+    const queryLimitResultMock = mock<PageQuery>();
+    querySkipResultMock.limit.calledWith(expectedLimit).mockImplementation(() => queryLimitResultMock);
+
+    const parseRangeSpy = vi.spyOn(OptionParser, 'parseRange');
+
+    // when
+    const result = addNumCondition(queryMock, optionsNum);
+
+    // then
+    expect(parseRangeSpy).toHaveBeenCalledWith(optionsNum);
+    expect(queryMock.skip).toHaveBeenCalledWith(expectedSkip);
+    if (isExpectedToSetLimit) {
+      expect(querySkipResultMock.limit).toHaveBeenCalledWith(expectedLimit);
+      expect(result).toEqual(queryLimitResultMock);
+    }
+    else {
+      expect(querySkipResultMock.limit).not.toHaveBeenCalled();
+      expect(result).toEqual(querySkipResultMock);
+    }
+  });
+
+});

+ 38 - 0
packages/remark-lsx/src/server/routes/list-pages/add-num-condition.ts

@@ -0,0 +1,38 @@
+import { OptionParser } from '@growi/core/dist/plugin';
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+
+/**
+ * add num condition that limit fetched pages
+ */
+export const addNumCondition = (query: PageQuery, optionsNum: string | number): PageQuery => {
+
+  if (typeof optionsNum === 'number') {
+    return query.limit(optionsNum);
+  }
+
+  const range = OptionParser.parseRange(optionsNum);
+
+  if (range == null) {
+    return query;
+  }
+
+  const start = range.start;
+  const end = range.end;
+
+  // check start
+  if (start < 1) {
+    throw createError(400, `specified num is [${start}:${end}] : the start must be larger or equal than 1`);
+  }
+
+  const skip = start - 1;
+  const limit = end - skip;
+
+  if (limit < 0) {
+    return query.skip(skip);
+  }
+
+  return query.skip(skip).limit(limit);
+};

+ 26 - 0
packages/remark-lsx/src/server/routes/list-pages/add-sort-condition.ts

@@ -0,0 +1,26 @@
+import createError from 'http-errors';
+
+import type { PageQuery } from './generate-base-query';
+
+/**
+ * add sort condition(sort key & sort order)
+ *
+ * If only the reverse option is specified, the sort key is 'path'.
+ * If only the sort key is specified, the sort order is the ascending order.
+ *
+ */
+export const addSortCondition = (query: PageQuery, optionsSortArg?: string, optionsReverse?: string): PageQuery => {
+  // init sort key
+  const optionsSort = optionsSortArg ?? 'path';
+
+  // the default sort order
+  const isReversed = optionsReverse === 'true';
+
+  if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
+    throw createError(400, `The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
+  }
+
+  const sortOption = {};
+  sortOption[optionsSort] = isReversed ? -1 : 1;
+  return query.sort(sortOption);
+};

+ 24 - 0
packages/remark-lsx/src/server/routes/list-pages/generate-base-query.ts

@@ -0,0 +1,24 @@
+import { IPage, IUser } from '@growi/core';
+import { model } from 'mongoose';
+import type { Document, Query } from 'mongoose';
+
+export type PageQuery = Query<IPage[], Document>;
+
+export type PageQueryBuilder = {
+  query: PageQuery,
+  addConditionToListOnlyDescendants: (pagePath: string) => PageQueryBuilder,
+  addConditionToFilteringByViewerForList: (builder: PageQueryBuilder, user: IUser) => PageQueryBuilder,
+};
+
+export const generateBaseQuery = async(pagePath: string, user: IUser): Promise<PageQueryBuilder> => {
+  const Page = model<IPage>('Page');
+  // eslint-disable-next-line @typescript-eslint/no-explicit-any
+  const PageAny = Page as any;
+
+  const baseQuery = Page.find();
+
+  const builder: PageQueryBuilder = new PageAny.PageQueryBuilder(baseQuery);
+  builder.addConditionToListOnlyDescendants(pagePath);
+
+  return PageAny.addConditionToFilteringByViewerForList(builder, user);
+};

+ 15 - 0
packages/remark-lsx/src/server/routes/list-pages/get-toppage-viewers-count.ts

@@ -0,0 +1,15 @@
+import { IPage } from '@growi/core';
+import { model } from 'mongoose';
+
+export const getToppageViewersCount = async(): Promise<number> => {
+  const Page = model<IPage>('Page');
+
+  const aggRes = await Page.aggregate<{ count: number }>([
+    { $match: { path: '/' } },
+    { $project: { count: { $size: '$seenUsers' } } },
+  ]);
+
+  return aggRes.length > 0
+    ? aggRes[0].count
+    : 1;
+};

+ 134 - 0
packages/remark-lsx/src/server/routes/list-pages/index.spec.ts

@@ -0,0 +1,134 @@
+import { IPage, IUser } from '@growi/core';
+import type { Request, Response } from 'express';
+import createError from 'http-errors';
+import { mock } from 'vitest-mock-extended';
+
+import type { PageQuery } from './generate-base-query';
+
+import { listPages } from '.';
+
+
+// mocking modules
+const mocks = vi.hoisted(() => {
+  return {
+    addNumConditionMock: vi.fn(),
+    addSortConditionMock: vi.fn(),
+    generateBaseQueryMock: vi.fn(),
+    getToppageViewersCountMock: vi.fn(),
+  };
+});
+
+vi.mock('./add-num-condition', () => ({ addNumCondition: mocks.addNumConditionMock }));
+vi.mock('./add-sort-condition', () => ({ addSortCondition: mocks.addSortConditionMock }));
+vi.mock('./generate-base-query', () => ({ generateBaseQuery: mocks.generateBaseQueryMock }));
+vi.mock('./get-toppage-viewers-count', () => ({ getToppageViewersCount: mocks.getToppageViewersCountMock }));
+
+
+describe('listPages', () => {
+
+  it("returns 400 HTTP response when the query 'pagePath' is undefined", async() => {
+    // setup
+    const reqMock = mock<Request & { user: IUser }>();
+    const resMock = mock<Response>();
+    const resStatusMock = mock<Response>();
+    resMock.status.calledWith(400).mockReturnValue(resStatusMock);
+
+    // when
+    await listPages(reqMock, resMock);
+
+    // then
+    expect(resMock.status).toHaveBeenCalledOnce();
+    expect(resStatusMock.send).toHaveBeenCalledOnce();
+    expect(mocks.generateBaseQueryMock).not.toHaveBeenCalled();
+  });
+
+  describe('with num option', () => {
+
+    mocks.generateBaseQueryMock.mockImplementation(() => vi.fn());
+    mocks.getToppageViewersCountMock.mockImplementation(() => 99);
+
+    it('returns 200 HTTP response', async() => {
+      // setup
+      const reqMock = mock<Request & { user: IUser }>();
+      reqMock.query = { pagePath: '/Sandbox' };
+
+      const pageMock = mock<IPage>();
+      const queryMock = mock<PageQuery>();
+      queryMock.exec.mockImplementation(async() => [pageMock]);
+      mocks.addSortConditionMock.mockImplementation(() => queryMock);
+
+      const resMock = mock<Response>();
+      const resStatusMock = mock<Response>();
+      resMock.status.calledWith(200).mockReturnValue(resStatusMock);
+
+      // when
+      await listPages(reqMock, resMock);
+
+      // then
+      expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
+      expect(mocks.getToppageViewersCountMock).toHaveBeenCalledOnce();
+      expect(mocks.addNumConditionMock).toHaveBeenCalledOnce();
+      expect(mocks.addSortConditionMock).toHaveBeenCalledOnce();
+      expect(resMock.status).toHaveBeenCalledOnce();
+      expect(resStatusMock.send).toHaveBeenCalledWith({
+        pages: [pageMock],
+        toppageViewersCount: 99,
+      });
+    });
+
+    it('returns 500 HTTP response when an unexpected error occured', async() => {
+      // setup
+      const reqMock = mock<Request & { user: IUser }>();
+      reqMock.query = { pagePath: '/Sandbox' };
+
+      // an Error instance will be thrown by addNumConditionMock
+      const expectedError = new Error('error for test');
+      mocks.addNumConditionMock.mockImplementation(() => {
+        throw expectedError;
+      });
+
+      const resMock = mock<Response>();
+      const resStatusMock = mock<Response>();
+      resMock.status.calledWith(500).mockReturnValue(resStatusMock);
+
+      // when
+      await listPages(reqMock, resMock);
+
+      // then
+      expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
+      expect(mocks.getToppageViewersCountMock).toHaveBeenCalledOnce();
+      expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
+      expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
+      expect(resMock.status).toHaveBeenCalledOnce();
+      expect(resStatusMock.send).toHaveBeenCalledWith(expectedError);
+    });
+
+    it('returns 400 HTTP response when the value is invalid', async() => {
+      // setup
+      const reqMock = mock<Request & { user: IUser }>();
+      reqMock.query = { pagePath: '/Sandbox' };
+
+      // an http-errors instance will be thrown by addNumConditionMock
+      const expectedError = createError(400, 'error for test');
+      mocks.addNumConditionMock.mockImplementation(() => {
+        throw expectedError;
+      });
+
+      const resMock = mock<Response>();
+      const resStatusMock = mock<Response>();
+      resMock.status.calledWith(400).mockReturnValue(resStatusMock);
+
+      // when
+      await listPages(reqMock, resMock);
+
+      // then
+      expect(mocks.generateBaseQueryMock).toHaveBeenCalledOnce();
+      expect(mocks.getToppageViewersCountMock).toHaveBeenCalledOnce();
+      expect(mocks.addNumConditionMock).toHaveBeenCalledOnce(); // throw an error
+      expect(mocks.addSortConditionMock).not.toHaveBeenCalledOnce(); // does not called
+      expect(resMock.status).toHaveBeenCalledOnce();
+      expect(resStatusMock.send).toHaveBeenCalledWith(expectedError);
+    });
+
+  });
+});

+ 130 - 0
packages/remark-lsx/src/server/routes/list-pages/index.ts

@@ -0,0 +1,130 @@
+
+import type { IUser } from '@growi/core';
+import { pathUtils } from '@growi/core/dist/utils';
+import escapeStringRegexp from 'escape-string-regexp';
+import type { Request, Response } from 'express';
+import createError, { isHttpError } from 'http-errors';
+
+import { addDepthCondition } from './add-depth-condition';
+import { addNumCondition } from './add-num-condition';
+import { addSortCondition } from './add-sort-condition';
+import { generateBaseQuery, type PageQuery } from './generate-base-query';
+import { getToppageViewersCount } from './get-toppage-viewers-count';
+
+
+const DEFAULT_PAGES_NUM = 50;
+
+
+const { addTrailingSlash } = pathUtils;
+
+/**
+ * add filter condition that filter fetched pages
+ */
+function addFilterCondition(query, pagePath, optionsFilter, isExceptFilter = false): PageQuery {
+  // when option strings is 'filter=', the option value is true
+  if (optionsFilter == null || optionsFilter === true) {
+    throw createError(400, 'filter option require value in regular expression.');
+  }
+
+  const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
+
+  let filterPath;
+  try {
+    if (optionsFilter.charAt(0) === '^') {
+      // move '^' to the first of path
+      filterPath = new RegExp(`^${pagePathForRegexp}${optionsFilter.slice(1, optionsFilter.length)}`);
+    }
+    else {
+      filterPath = new RegExp(`^${pagePathForRegexp}.*${optionsFilter}`);
+    }
+  }
+  catch (err) {
+    throw createError(400, err);
+  }
+
+  if (isExceptFilter) {
+    return query.and({
+      path: { $not: filterPath },
+    });
+  }
+  return query.and({
+    path: filterPath,
+  });
+}
+
+function addExceptCondition(query, pagePath, optionsFilter): PageQuery {
+  return this.addFilterCondition(query, pagePath, optionsFilter, true);
+}
+
+
+export type ListPagesOptions = {
+  depth?: string,
+  num?: string,
+  filter?: string,
+  except?: string,
+  sort?: string,
+  reverse?: string,
+}
+
+export const listPages = async(req: Request & { user: IUser }, res: Response): Promise<Response> => {
+  const user = req.user;
+
+  let pagePath: string;
+  let options: ListPagesOptions | undefined;
+
+  try {
+    // TODO: use express-validator
+    if (req.query.pagePath == null) {
+      throw new Error("The 'pagePath' query must not be null.");
+    }
+
+    pagePath = req.query.pagePath?.toString();
+    if (req.query.options != null) {
+      options = JSON.parse(req.query.options.toString());
+    }
+  }
+  catch (error) {
+    return res.status(400).send(error);
+  }
+
+  const builder = await generateBaseQuery(pagePath, user);
+
+  // count viewers of `/`
+  let toppageViewersCount;
+  try {
+    toppageViewersCount = await getToppageViewersCount();
+  }
+  catch (error) {
+    return res.status(500).send(error);
+  }
+
+  let query = builder.query;
+  try {
+    // depth
+    if (options?.depth != null) {
+      query = addDepthCondition(query, pagePath, options.depth);
+    }
+    // filter
+    if (options?.filter != null) {
+      query = addFilterCondition(query, pagePath, options.filter);
+    }
+    if (options?.except != null) {
+      query = addExceptCondition(query, pagePath, options.except);
+    }
+    // num
+    const optionsNum = options?.num || DEFAULT_PAGES_NUM;
+    query = addNumCondition(query, optionsNum);
+    // sort
+    query = addSortCondition(query, options?.sort, options?.reverse);
+
+    const pages = await query.exec();
+    return res.status(200).send({ pages, toppageViewersCount });
+  }
+  catch (error) {
+    if (isHttpError(error)) {
+      return res.status(error.status).send(error);
+    }
+    return res.status(500).send(error);
+  }
+
+};

+ 0 - 264
packages/remark-lsx/src/server/routes/lsx.ts

@@ -1,264 +0,0 @@
-
-import { OptionParser } from '@growi/core/dist/plugin';
-import { pathUtils, pagePathUtils } from '@growi/core/dist/utils';
-import escapeStringRegexp from 'escape-string-regexp';
-import createError, { isHttpError } from 'http-errors';
-
-
-const DEFAULT_PAGES_NUM = 50;
-
-
-const { addTrailingSlash } = pathUtils;
-const { isTopPage } = pagePathUtils;
-
-class Lsx {
-
-  /**
-   * add depth condition that limit fetched pages
-   *
-   * @static
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {any} optionsDepth
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addDepthCondition(query, pagePath, optionsDepth) {
-    // when option strings is 'depth=', the option value is true
-    if (optionsDepth == null || optionsDepth === true) {
-      throw createError(400, 'The value of depth option is invalid.');
-    }
-
-    const range = OptionParser.parseRange(optionsDepth);
-
-    if (range == null) {
-      return query;
-    }
-
-    const start = range.start;
-    const end = range.end;
-
-    if (start < 1 || end < 1) {
-      throw createError(400, `specified depth is [${start}:${end}] : start and end are must be larger than 1`);
-    }
-
-    // count slash
-    const slashNum = isTopPage(pagePath)
-      ? 1
-      : pagePath.split('/').length;
-    const depthStart = slashNum; // start is not affect to fetch page
-    const depthEnd = slashNum + end - 1;
-
-    return query.and({
-      path: new RegExp(`^(\\/[^\\/]*){${depthStart},${depthEnd}}$`),
-    });
-  }
-
-  /**
-   * add num condition that limit fetched pages
-   *
-   * @static
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {number|string} optionsNum
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addNumCondition(query, pagePath, optionsNum) {
-    // when option strings is 'num=', the option value is true
-    if (optionsNum == null || optionsNum === true) {
-      throw createError(400, 'The value of num option is invalid.');
-    }
-
-    if (typeof optionsNum === 'number') {
-      return query.limit(optionsNum);
-    }
-
-    const range = OptionParser.parseRange(optionsNum);
-
-    if (range == null) {
-      return query;
-    }
-
-    const start = range.start;
-    const end = range.end;
-
-    if (start < 1 || end < 1) {
-      throw createError(400, `specified num is [${start}:${end}] : start and end are must be larger than 1`);
-    }
-
-    const skip = start - 1;
-    const limit = end - skip;
-
-    return query.skip(skip).limit(limit);
-  }
-
-  /**
-   * add filter condition that filter fetched pages
-   *
-   * @static
-   * @param {any} query
-   * @param {any} pagePath
-   * @param {any} optionsFilter
-   * @param {boolean} isExceptFilter
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addFilterCondition(query, pagePath, optionsFilter, isExceptFilter = false) {
-    // when option strings is 'filter=', the option value is true
-    if (optionsFilter == null || optionsFilter === true) {
-      throw createError(400, 'filter option require value in regular expression.');
-    }
-
-    const pagePathForRegexp = escapeStringRegexp(addTrailingSlash(pagePath));
-
-    let filterPath;
-    try {
-      if (optionsFilter.charAt(0) === '^') {
-        // move '^' to the first of path
-        filterPath = new RegExp(`^${pagePathForRegexp}${optionsFilter.slice(1, optionsFilter.length)}`);
-      }
-      else {
-        filterPath = new RegExp(`^${pagePathForRegexp}.*${optionsFilter}`);
-      }
-    }
-    catch (err) {
-      throw createError(400, err);
-    }
-
-    if (isExceptFilter) {
-      return query.and({
-        path: { $not: filterPath },
-      });
-    }
-    return query.and({
-      path: filterPath,
-    });
-  }
-
-  static addExceptCondition(query, pagePath, optionsFilter) {
-    return this.addFilterCondition(query, pagePath, optionsFilter, true);
-  }
-
-  /**
-   * add sort condition(sort key & sort order)
-   *
-   * If only the reverse option is specified, the sort key is 'path'.
-   * If only the sort key is specified, the sort order is the ascending order.
-   *
-   * @static
-   * @param {any} query
-   * @param {string} pagePath
-   * @param {string} optionsSort
-   * @param {string} optionsReverse
-   * @returns
-   *
-   * @memberOf Lsx
-   */
-  static addSortCondition(query, pagePath, optionsSortArg, optionsReverse) {
-    // init sort key
-    const optionsSort = optionsSortArg ?? 'path';
-
-    // the default sort order
-    const isReversed = optionsReverse === 'true';
-
-    if (optionsSort !== 'path' && optionsSort !== 'createdAt' && optionsSort !== 'updatedAt') {
-      throw createError(400, `The specified value '${optionsSort}' for the sort option is invalid. It must be 'path', 'createdAt' or 'updatedAt'.`);
-    }
-
-    const sortOption = {};
-    sortOption[optionsSort] = isReversed ? -1 : 1;
-    return query.sort(sortOption);
-  }
-
-}
-
-// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/no-explicit-any
-export const routesFactory = (crowi): any => {
-  const Page = crowi.model('Page');
-  // eslint-disable-next-line @typescript-eslint/no-explicit-any
-  const actions: any = {};
-
-  /**
-   *
-   * @param {*} pagePath
-   * @param {*} user
-   *
-   * @return {Promise<Query>} query
-   */
-  async function generateBaseQueryBuilder(pagePath, user) {
-    const baseQuery = Page.find();
-
-    const builder = new Page.PageQueryBuilder(baseQuery);
-    builder.addConditionToListOnlyDescendants(pagePath);
-
-    return Page.addConditionToFilteringByViewerForList(builder, user);
-  }
-
-  actions.listPages = async(req, res) => {
-    const user = req.user;
-
-    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);
-
-    // count viewers of `/`
-    let toppageViewersCount;
-    try {
-      const aggRes = await Page.aggregate([
-        { $match: { path: '/' } },
-        { $project: { count: { $size: '$seenUsers' } } },
-      ]);
-
-      toppageViewersCount = aggRes.length > 0
-        ? aggRes[0].count
-        : 1;
-    }
-    catch (error) {
-      return res.status(500).send(error);
-    }
-
-    let query = builder.query;
-    try {
-      // depth
-      if (options.depth != null) {
-        query = Lsx.addDepthCondition(query, pagePath, options.depth);
-      }
-      // filter
-      if (options.filter != null) {
-        query = Lsx.addFilterCondition(query, pagePath, options.filter);
-      }
-      if (options.except != null) {
-        query = Lsx.addExceptCondition(query, pagePath, options.except);
-      }
-      // num
-      const optionsNum = options.num || DEFAULT_PAGES_NUM;
-      query = Lsx.addNumCondition(query, pagePath, optionsNum);
-      // sort
-      query = Lsx.addSortCondition(query, pagePath, options.sort, options.reverse);
-
-      const pages = await query.exec();
-      res.status(200).send({ pages, toppageViewersCount });
-    }
-    catch (error) {
-      if (isHttpError) {
-        return res.status(error.status).send(error);
-      }
-      return res.status(500).send(error);
-    }
-  };
-
-  return actions;
-};

+ 16 - 7
packages/remark-lsx/src/stores/lsx.tsx

@@ -100,13 +100,22 @@ const useSWRxLsxResponse = (
 ): SWRResponse<LsxResponse, Error> => {
   return useSWR(
     ['/_api/lsx', pagePath, options, isImmutable],
-    ([endpoint, pagePath, options]) => {
-      return axios.get(endpoint, {
-        params: {
-          pagePath,
-          options,
-        },
-      }).then(result => result.data as LsxResponse);
+    async([endpoint, pagePath, options]) => {
+      try {
+        const res = await axios.get<LsxResponse>(endpoint, {
+          params: {
+            pagePath,
+            options,
+          },
+        });
+        return res.data;
+      }
+      catch (err) {
+        if (axios.isAxiosError(err)) {
+          throw new Error(err.response?.data.message);
+        }
+        throw err;
+      }
     },
     {
       keepPreviousData: true,

+ 6 - 4
packages/remark-lsx/tsconfig.json

@@ -4,10 +4,12 @@
   "compilerOptions": {
     "jsx": "react-jsxdev",
 
-    "baseUrl": ".",
-    "paths": {
-      "~/*": ["./src/*"],
-    }
+    "plugins": [{ "name": "typescript-plugin-css-modules" }],
+
+    "typeRoots": ["./src/@types"],
+    "types": [
+      "vitest/globals"
+    ]
   },
   "include": [
     "src"

+ 3 - 1
packages/remark-lsx/vite.server.config.ts

@@ -22,11 +22,13 @@ export default defineConfig({
         preserveModulesRoot: 'src/server',
       },
       external: [
+        'react',
         'axios',
         'escape-string-regexp',
+        'express',
         'http-errors',
         'is-absolute-url',
-        'react',
+        'mongoose',
         'next/link',
         'unified',
         'swr',

+ 13 - 0
packages/remark-lsx/vitest.config.ts

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

Разница между файлами не показана из-за своего большого размера
+ 448 - 445
yarn.lock


Некоторые файлы не были показаны из-за большого количества измененных файлов