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

configure biome for app services dir

Futa Arai 6 месяцев назад
Родитель
Сommit
33e9a5b908
25 измененных файлов с 319 добавлено и 249 удалено
  1. 1 0
      apps/app/.eslintrc.js
  2. 3 3
      apps/app/src/services/general-xss-filter/general-xss-filter.spec.ts
  3. 4 5
      apps/app/src/services/general-xss-filter/general-xss-filter.ts
  4. 6 2
      apps/app/src/services/layout/use-should-expand-content.ts
  5. 6 5
      apps/app/src/services/renderer/markdown-it/PreProcessor/EasyGrid.js
  6. 13 6
      apps/app/src/services/renderer/recommended-whitelist.spec.ts
  7. 31 20
      apps/app/src/services/renderer/recommended-whitelist.ts
  8. 6 3
      apps/app/src/services/renderer/rehype-plugins/add-class.ts
  9. 4 1
      apps/app/src/services/renderer/rehype-plugins/add-inline-code-property.ts
  10. 4 2
      apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts
  11. 21 11
      apps/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts
  12. 45 39
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts
  13. 6 2
      apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts
  14. 31 33
      apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts
  15. 14 8
      apps/app/src/services/renderer/rehype-plugins/relative-links.ts
  16. 8 7
      apps/app/src/services/renderer/rehype-plugins/relocate-toc.ts
  17. 5 4
      apps/app/src/services/renderer/remark-plugins/attachment.ts
  18. 0 1
      apps/app/src/services/renderer/remark-plugins/codeblock.ts
  19. 3 4
      apps/app/src/services/renderer/remark-plugins/echo-directive.ts
  20. 2 3
      apps/app/src/services/renderer/remark-plugins/emoji.ts
  21. 13 16
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts
  22. 34 26
      apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts
  23. 6 8
      apps/app/src/services/renderer/remark-plugins/xsv-to-table.ts
  24. 53 39
      apps/app/src/services/renderer/renderer.tsx
  25. 0 1
      biome.json

+ 1 - 0
apps/app/.eslintrc.js

@@ -45,6 +45,7 @@ module.exports = {
     'src/stores-universal/**',
     'src/interfaces/**',
     'src/utils/**',
+    'src/services/**'
   ],
   settings: {
     // resolve path aliases by eslint-import-resolver-typescript

+ 3 - 3
apps/app/src/services/general-xss-filter/general-xss-filter.spec.ts

@@ -1,7 +1,6 @@
 import { generalXssFilter } from './general-xss-filter';
 
 describe('generalXssFilter', () => {
-
   test('should be sanitize script tag', () => {
     // Act
     const result = generalXssFilter.process('<script>alert("XSS")</script>');
@@ -12,7 +11,9 @@ describe('generalXssFilter', () => {
 
   test('should be sanitize nested script tag recursively', () => {
     // Act
-    const result = generalXssFilter.process('<scr<script>ipt>alert("XSS")</scr<script>ipt>');
+    const result = generalXssFilter.process(
+      '<scr<script>ipt>alert("XSS")</scr<script>ipt>',
+    );
 
     // Assert
     expect(result).toBe('alert("XSS")');
@@ -35,5 +36,4 @@ describe('generalXssFilter', () => {
     // Assert
     expect(result).toBe('<span>text</span>');
   });
-
 });

+ 4 - 5
apps/app/src/services/general-xss-filter/general-xss-filter.ts

@@ -7,11 +7,12 @@ const option: IFilterXSSOptions = {
   stripIgnoreTag: true,
   stripIgnoreTagBody: false, // see https://github.com/growilabs/growi/pull/505
   css: false,
-  escapeHtml: (html) => { return html }, // resolve https://github.com/growilabs/growi/issues/221
+  escapeHtml: (html) => {
+    return html;
+  }, // resolve https://github.com/growilabs/growi/issues/221
 };
 
 class GeneralXssFilter extends FilterXSS {
-
   override process(document: string | undefined): string {
     let count = 0;
     let currDoc = document;
@@ -26,12 +27,10 @@ class GeneralXssFilter extends FilterXSS {
 
       prevDoc = currDoc;
       currDoc = super.process(currDoc ?? '');
-    }
-    while (currDoc !== prevDoc);
+    } while (currDoc !== prevDoc);
 
     return currDoc;
   }
-
 }
 
 export const generalXssFilter = new GeneralXssFilter(option);

+ 6 - 2
apps/app/src/services/layout/use-should-expand-content.ts

@@ -2,14 +2,18 @@ import type { IPage, IPagePopulatedToShowRevision } from '@growi/core';
 
 import { useIsContainerFluid } from '~/stores-universal/context';
 
-const useDetermineExpandContent = (expandContentWidth?: boolean | null): boolean => {
+const useDetermineExpandContent = (
+  expandContentWidth?: boolean | null,
+): boolean => {
   const { data: dataIsContainerFluid } = useIsContainerFluid();
 
   const isContainerFluidDefault = dataIsContainerFluid;
   return expandContentWidth ?? isContainerFluidDefault ?? false;
 };
 
-export const useShouldExpandContent = (data?: IPage | IPagePopulatedToShowRevision | boolean | null): boolean => {
+export const useShouldExpandContent = (
+  data?: IPage | IPagePopulatedToShowRevision | boolean | null,
+): boolean => {
   const expandContentWidth = (() => {
     // when data is null
     if (data == null) {

+ 6 - 5
apps/app/src/services/renderer/markdown-it/PreProcessor/EasyGrid.js

@@ -1,10 +1,11 @@
 export default class EasyGrid {
-
   process(markdown) {
     // see: https://regex101.com/r/7NWvUU/2
-    return markdown.replace(/:::\s*editable-row[\r\n]((.|[\r\n])*?)[\r\n]:::/gm, (all, group) => {
-      return group;
-    });
+    return markdown.replace(
+      /:::\s*editable-row[\r\n]((.|[\r\n])*?)[\r\n]:::/gm,
+      (all, group) => {
+        return group;
+      },
+    );
   }
-
 }

+ 13 - 6
apps/app/src/services/renderer/recommended-whitelist.spec.ts

@@ -1,9 +1,8 @@
 import { notDeepEqual } from 'assert';
 
-import { tagNames, attributes } from './recommended-whitelist';
+import { attributes, tagNames } from './recommended-whitelist';
 
 describe('recommended-whitelist', () => {
-
   test('.tagNames should return iframe tag', () => {
     expect(tagNames).not.toBeNull();
     expect(tagNames).includes('iframe');
@@ -52,7 +51,10 @@ describe('recommended-whitelist', () => {
     assert(attributes != null);
 
     expect(Object.keys(attributes)).includes('a');
-    expect(attributes.a).not.toContainEqual(['className', 'data-footnote-backref']);
+    expect(attributes.a).not.toContainEqual([
+      'className',
+      'data-footnote-backref',
+    ]);
   });
 
   test('.attributes.ul should allow class and className by excluding partial className specification', () => {
@@ -61,7 +63,10 @@ describe('recommended-whitelist', () => {
     assert(attributes != null);
 
     expect(Object.keys(attributes)).includes('a');
-    expect(attributes.a).not.toContainEqual(['className', 'data-footnote-backref']);
+    expect(attributes.a).not.toContainEqual([
+      'className',
+      'data-footnote-backref',
+    ]);
   });
 
   test('.attributes.li should allow class and className by excluding partial className specification', () => {
@@ -70,7 +75,9 @@ describe('recommended-whitelist', () => {
     assert(attributes != null);
 
     expect(Object.keys(attributes)).includes('a');
-    expect(attributes.a).not.toContainEqual(['className', 'data-footnote-backref']);
+    expect(attributes.a).not.toContainEqual([
+      'className',
+      'data-footnote-backref',
+    ]);
   });
-
 });

+ 31 - 20
apps/app/src/services/renderer/recommended-whitelist.ts

@@ -9,7 +9,9 @@ type ExtractPropertyDefinition<T> = T extends Record<string, (infer U)[]>
 
 type PropertyDefinition = ExtractPropertyDefinition<NonNullable<Attributes>>;
 
-const excludeRestrictedClassAttributes = (propertyDefinitions: PropertyDefinition[]): PropertyDefinition[] => {
+const excludeRestrictedClassAttributes = (
+  propertyDefinitions: PropertyDefinition[],
+): PropertyDefinition[] => {
   if (propertyDefinitions == null) {
     return propertyDefinitions;
   }
@@ -18,15 +20,24 @@ const excludeRestrictedClassAttributes = (propertyDefinitions: PropertyDefinitio
     if (!Array.isArray(propertyDefinition)) {
       return true;
     }
-    return propertyDefinition[0] !== 'class' && propertyDefinition[0] !== 'className';
+    return (
+      propertyDefinition[0] !== 'class' && propertyDefinition[0] !== 'className'
+    );
   });
 };
 
 // generate relaxed schema
-const relaxedSchemaAttributes: Record<string, PropertyDefinition[]> = structuredClone(defaultSchema.attributes) ?? {};
-relaxedSchemaAttributes.a = excludeRestrictedClassAttributes(relaxedSchemaAttributes.a);
-relaxedSchemaAttributes.ul = excludeRestrictedClassAttributes(relaxedSchemaAttributes.ul);
-relaxedSchemaAttributes.li = excludeRestrictedClassAttributes(relaxedSchemaAttributes.li);
+const relaxedSchemaAttributes: Record<string, PropertyDefinition[]> =
+  structuredClone(defaultSchema.attributes) ?? {};
+relaxedSchemaAttributes.a = excludeRestrictedClassAttributes(
+  relaxedSchemaAttributes.a,
+);
+relaxedSchemaAttributes.ul = excludeRestrictedClassAttributes(
+  relaxedSchemaAttributes.ul,
+);
+relaxedSchemaAttributes.li = excludeRestrictedClassAttributes(
+  relaxedSchemaAttributes.li,
+);
 
 /**
  * reference: https://meta.stackexchange.com/questions/1777/what-html-tags-are-allowed-on-stack-exchange-sites,
@@ -34,23 +45,23 @@ relaxedSchemaAttributes.li = excludeRestrictedClassAttributes(relaxedSchemaAttri
  */
 
 export const tagNames: Array<string> = [
-  ...defaultSchema.tagNames ?? [],
-  '-', 'bdi',
+  ...(defaultSchema.tagNames ?? []),
+  '-',
+  'bdi',
   'button',
-  'col', 'colgroup',
+  'col',
+  'colgroup',
   'data',
   'iframe',
   'video',
-  'rb', 'u',
+  'rb',
+  'u',
 ];
 
-export const attributes: Attributes = deepmerge(
-  relaxedSchemaAttributes,
-  {
-    iframe: ['allow', 'referrerpolicy', 'sandbox', 'src'],
-    video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
-    // The special value 'data*' as a property name can be used to allow all data properties.
-    // see: https://github.com/syntax-tree/hast-util-sanitize/
-    '*': ['key', 'class', 'className', 'style', 'role', 'data*'],
-  },
-);
+export const attributes: Attributes = deepmerge(relaxedSchemaAttributes, {
+  iframe: ['allow', 'referrerpolicy', 'sandbox', 'src'],
+  video: ['controls', 'src', 'muted', 'preload', 'width', 'height', 'autoplay'],
+  // The special value 'data*' as a property name can be used to allow all data properties.
+  // see: https://github.com/syntax-tree/hast-util-sanitize/
+  '*': ['key', 'class', 'className', 'style', 'role', 'data*'],
+});

+ 6 - 3
apps/app/src/services/renderer/rehype-plugins/add-class.ts

@@ -1,6 +1,6 @@
 // See: https://github.com/martypdx/rehype-add-classes for the original implementation.
 // Re-implemeted in TypeScript.
-import type { Nodes as HastNode, Element, Properties } from 'hast';
+import type { Element, Nodes as HastNode, Properties } from 'hast';
 import { selectAll } from 'hast-util-select';
 import type { Plugin } from 'unified';
 
@@ -9,7 +9,10 @@ export type ClassName = string; // e.g. 'header'
 export type Additions = Record<SelectorName, ClassName>;
 export type AdditionsEntry = [SelectorName, ClassName];
 
-export const addClassToProperties = (properties: Properties | undefined, className: string): void => {
+export const addClassToProperties = (
+  properties: Properties | undefined,
+  className: string,
+): void => {
   if (properties == null) {
     return;
   }
@@ -42,5 +45,5 @@ const adder = (entry: AdditionsEntry) => {
 export const rehypePlugin: Plugin<[Additions]> = (additions) => {
   const adders = Object.entries(additions).map(adder);
 
-  return node => adders.forEach(a => a(node as HastNode));
+  return (node) => adders.forEach((a) => a(node as HastNode));
 };

+ 4 - 1
apps/app/src/services/renderer/rehype-plugins/add-inline-code-property.ts

@@ -3,7 +3,10 @@ import type { Plugin } from 'unified';
 import { is } from 'unist-util-is';
 import { visitParents } from 'unist-util-visit-parents';
 
-const isInlineCodeTag = (node: Element, parent: Element | Root | null): boolean => {
+const isInlineCodeTag = (
+  node: Element,
+  parent: Element | Root | null,
+): boolean => {
   if (node.tagName !== 'code') {
     return false;
   }

+ 4 - 2
apps/app/src/services/renderer/rehype-plugins/add-line-number-attribute.ts

@@ -1,11 +1,13 @@
 import type { Element } from 'hast';
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Plugin } from 'unified';
-import { visit, EXIT, CONTINUE } from 'unist-util-visit';
+import { CONTINUE, EXIT, visit } from 'unist-util-visit';
 
 import { addClassToProperties } from './add-class';
 
-const REGEXP_TARGET_TAGNAMES = new RegExp(/^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul|table)$/);
+const REGEXP_TARGET_TAGNAMES = new RegExp(
+  /^(h1|h2|h3|h4|h5|h6|p|img|pre|blockquote|hr|ol|ul|table)$/,
+);
 
 export const rehypePlugin: Plugin = () => {
   return (tree) => {

+ 21 - 11
apps/app/src/services/renderer/rehype-plugins/keyword-highlighter.ts

@@ -1,8 +1,7 @@
-import type { Root, Element, Text } from 'hast';
+import type { Element, Root, Text } from 'hast';
 import rehypeRewrite from 'rehype-rewrite';
 import type { Plugin } from 'unified';
 
-
 /**
  * This method returns ['foo', 'bar', 'foo']
  *  when the arguments are { keyword: 'foo', value: 'foobarfoo' }
@@ -50,7 +49,12 @@ function wrapWithEm(textElement: Text): Element {
   };
 }
 
-function highlight(lowercasedKeyword: string, node: Text, index: number, parent: Root | Element): void {
+function highlight(
+  lowercasedKeyword: string,
+  node: Text,
+  index: number,
+  parent: Root | Element,
+): void {
   if (node.value.toLowerCase().includes(lowercasedKeyword)) {
     const splitted = splitWithKeyword(lowercasedKeyword, node.value);
 
@@ -67,25 +71,31 @@ function highlight(lowercasedKeyword: string, node: Text, index: number, parent:
   }
 }
 
-
 export type KeywordHighlighterPluginParams = {
-  keywords?: string | string[],
-}
+  keywords?: string | string[];
+};
 
-export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (options) => {
+export const rehypePlugin: Plugin<[KeywordHighlighterPluginParams]> = (
+  options,
+) => {
   if (options?.keywords == null) {
-    return node => node;
+    return (node) => node;
   }
 
-  const keywords = (typeof options.keywords === 'string') ? [options.keywords] : options.keywords;
+  const keywords =
+    typeof options.keywords === 'string'
+      ? [options.keywords]
+      : options.keywords;
 
-  const lowercasedKeywords = keywords.map(keyword => keyword.toLowerCase());
+  const lowercasedKeywords = keywords.map((keyword) => keyword.toLowerCase());
 
   // return rehype-rewrite with hithlighter
   return rehypeRewrite.bind(this)({
     rewrite: (node, index, parent) => {
       if (parent != null && index != null && node.type === 'text') {
-        lowercasedKeywords.forEach(keyword => highlight(keyword, node, index, parent));
+        lowercasedKeywords.forEach((keyword) =>
+          highlight(keyword, node, index, parent),
+        );
       }
     },
   });

+ 45 - 39
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.spec.ts

@@ -9,45 +9,51 @@ import { pukiwikiLikeLinker } from '../remark-plugins/pukiwiki-like-linker';
 import { relativeLinksByPukiwikiLikeLinker } from './relative-links-by-pukiwiki-like-linker';
 
 describe('relativeLinksByPukiwikiLikeLinker', () => {
-
   /* eslint-disable indent */
   describe.each`
-    input                                   | expectedHref                        | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                          | ${'/page'}
-    ${'[[./page]]'}                         | ${'/user/admin/page'}               | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'/user/admin/page'}               | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
-    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}             | ${'/page?q=foo#header'}
-    ${'[[./page?q=foo#header]]'}            | ${'/user/admin/page?q=foo#header'}  | ${'./page?q=foo#header'}
-    ${'[[Title>./page?q=foo#header]]'}      | ${'/user/admin/page?q=foo#header'}  | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}            | ${'Title'}
-  `('should convert relative links correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker)
-        .use(rehype)
-        .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
-
-      // when:
-      const mdast = processor.parse(input);
-      const hast = processor.runSync(mdast) as HastNode;
-      const anchorElement = select('a', hast);
-
-      // then
-      expect(anchorElement).not.toBeNull();
-      expect(anchorElement?.properties).not.toBeNull();
-      expect((anchorElement?.properties?.className as string).startsWith('pukiwiki-like-linker')).toBeTruthy();
-      expect(anchorElement?.properties?.href).toEqual(expectedHref);
-
-      expect(anchorElement?.children[0]).not.toBeNull();
-      expect(anchorElement?.children[0].type).toEqual('text');
-      expect((anchorElement?.children[0] as HastNode as Text).value).toEqual(expectedValue);
-
-    });
-  });
-
+    input                              | expectedHref                       | expectedValue
+    ${'[[/page]]'}                     | ${'/page'}                         | ${'/page'}
+    ${'[[./page]]'}                    | ${'/user/admin/page'}              | ${'./page'}
+    ${'[[Title>./page]]'}              | ${'/user/admin/page'}              | ${'Title'}
+    ${'[[Title>https://example.com]]'} | ${'https://example.com'}           | ${'Title'}
+    ${'[[/page?q=foo#header]]'}        | ${'/page?q=foo#header'}            | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}       | ${'/user/admin/page?q=foo#header'} | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'} | ${'/user/admin/page?q=foo#header'} | ${'Title'}
+    ${'[[Title>https://example.com]]'} | ${'https://example.com'}           | ${'Title'}
+  `(
+    'should convert relative links correctly',
+    ({ input, expectedHref, expectedValue }) => {
+      /* eslint-enable indent */
+
+      test(`when the input is '${input}'`, () => {
+        // setup:
+        const processor = unified()
+          .use(parse)
+          .use(pukiwikiLikeLinker)
+          .use(rehype)
+          .use(relativeLinksByPukiwikiLikeLinker, { pagePath: '/user/admin' });
+
+        // when:
+        const mdast = processor.parse(input);
+        const hast = processor.runSync(mdast) as HastNode;
+        const anchorElement = select('a', hast);
+
+        // then
+        expect(anchorElement).not.toBeNull();
+        expect(anchorElement?.properties).not.toBeNull();
+        expect(
+          (anchorElement?.properties?.className as string).startsWith(
+            'pukiwiki-like-linker',
+          ),
+        ).toBeTruthy();
+        expect(anchorElement?.properties?.href).toEqual(expectedHref);
+
+        expect(anchorElement?.children[0]).not.toBeNull();
+        expect(anchorElement?.children[0].type).toEqual('text');
+        expect((anchorElement?.children[0] as HastNode as Text).value).toEqual(
+          expectedValue,
+        );
+      });
+    },
+  );
 });

+ 6 - 2
apps/app/src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker.ts

@@ -3,8 +3,10 @@ import { selectAll } from 'hast-util-select';
 import type { Plugin } from 'unified';
 
 import {
+  type IAnchorsSelector,
+  type IUrlResolver,
+  type RelativeLinksPluginParams,
   relativeLinks,
-  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
@@ -17,7 +19,9 @@ const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   return new URL(relativeHref, baseUrl);
 };
 
-export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+export const relativeLinksByPukiwikiLikeLinker: Plugin<
+  [RelativeLinksPluginParams]
+> = (options = {}) => {
   return relativeLinks.bind(this)({
     ...options,
     anchorsSelector: customAnchorsSelector,

+ 31 - 33
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -1,4 +1,3 @@
-
 import type { Nodes as HastNode } from 'hast';
 import { select } from 'hast-util-select';
 import parse from 'remark-parse';
@@ -8,7 +7,6 @@ import { unified } from 'unified';
 import { relativeLinks } from './relative-links';
 
 describe('relativeLinks', () => {
-
   test('do nothing when the options does not have pagePath', () => {
     // setup
     const processor = unified()
@@ -27,10 +25,9 @@ describe('relativeLinks', () => {
 
   test.concurrent.each`
     originalHref
-      ${'http://example.com/Sandbox'}
-      ${'#header'}
-    `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
-
+    ${'http://example.com/Sandbox'}
+    ${'#header'}
+  `("leaves the original href '$originalHref' as-is", ({ originalHref }) => {
     // setup
     const pagePath = '/foo/bar/baz';
     const processor = unified()
@@ -48,33 +45,34 @@ describe('relativeLinks', () => {
   });
 
   test.concurrent.each`
-    originalHref                        | expectedHref
-      ${'/Sandbox'}                     | ${'/Sandbox'}
-      ${'/Sandbox?q=foo'}               | ${'/Sandbox?q=foo'}
-      ${'/Sandbox#header'}              | ${'/Sandbox#header'}
-      ${'/Sandbox?q=foo#header'}        | ${'/Sandbox?q=foo#header'}
-      ${'./Sandbox'}                    | ${'/foo/bar/Sandbox'}
-      ${'./Sandbox?q=foo'}              | ${'/foo/bar/Sandbox?q=foo'}
-      ${'./Sandbox#header'}             | ${'/foo/bar/Sandbox#header'}
-      ${'./Sandbox?q=foo#header'}       | ${'/foo/bar/Sandbox?q=foo#header'}
-    `('rewrites the original href \'$originalHref\' to \'$expectedHref\'', ({ originalHref, expectedHref }) => {
+    originalHref                | expectedHref
+    ${'/Sandbox'}               | ${'/Sandbox'}
+    ${'/Sandbox?q=foo'}         | ${'/Sandbox?q=foo'}
+    ${'/Sandbox#header'}        | ${'/Sandbox#header'}
+    ${'/Sandbox?q=foo#header'}  | ${'/Sandbox?q=foo#header'}
+    ${'./Sandbox'}              | ${'/foo/bar/Sandbox'}
+    ${'./Sandbox?q=foo'}        | ${'/foo/bar/Sandbox?q=foo'}
+    ${'./Sandbox#header'}       | ${'/foo/bar/Sandbox#header'}
+    ${'./Sandbox?q=foo#header'} | ${'/foo/bar/Sandbox?q=foo#header'}
+  `(
+    "rewrites the original href '$originalHref' to '$expectedHref'",
+    ({ originalHref, expectedHref }) => {
+      // setup
+      const pagePath = '/foo/bar/baz';
+      const processor = unified()
+        .use(parse)
+        .use(remarkRehype)
+        .use(relativeLinks, { pagePath });
 
-    // setup
-    const pagePath = '/foo/bar/baz';
-    const processor = unified()
-      .use(parse)
-      .use(remarkRehype)
-      .use(relativeLinks, { pagePath });
-
-    // when
-    const mdastTree = processor.parse(`[link](${originalHref})`);
-    const hastTree = processor.runSync(mdastTree) as HastNode;
-
-    // then
-    const anchorElement = select('a', hastTree);
-    expect(anchorElement).not.toBeNull();
-    expect(anchorElement?.properties).not.toBeNull();
-    expect(anchorElement?.properties?.href).toBe(expectedHref);
-  });
+      // when
+      const mdastTree = processor.parse(`[link](${originalHref})`);
+      const hastTree = processor.runSync(mdastTree) as HastNode;
 
+      // then
+      const anchorElement = select('a', hastTree);
+      expect(anchorElement).not.toBeNull();
+      expect(anchorElement?.properties).not.toBeNull();
+      expect(anchorElement?.properties?.href).toBe(expectedHref);
+    },
+  );
 });

+ 14 - 8
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,11 +1,10 @@
 import assert from 'assert';
 
-import type { Nodes as HastNode, Element } from 'hast';
+import type { Element, Nodes as HastNode } from 'hast';
 import { selectAll } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
 import type { Plugin } from 'unified';
 
-
 export type IAnchorsSelector = (node: HastNode) => Element[];
 export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
@@ -29,12 +28,14 @@ const isAnchorLink = (href: string): boolean => {
 };
 
 export type RelativeLinksPluginParams = {
-  pagePath?: string,
-  anchorsSelector?: IAnchorsSelector,
-  urlResolver?: IUrlResolver,
-}
+  pagePath?: string;
+  anchorsSelector?: IAnchorsSelector;
+  urlResolver?: IUrlResolver;
+};
 
-export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
+export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (
+  options = {},
+) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
   const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
@@ -50,7 +51,12 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
       assert(anchor.properties != null);
 
       const href = anchor.properties.href;
-      if (href == null || typeof href !== 'string' || isAbsolute(href) || isAnchorLink(href)) {
+      if (
+        href == null ||
+        typeof href !== 'string' ||
+        isAbsolute(href) ||
+        isAnchorLink(href)
+      ) {
         return;
       }
 

+ 8 - 7
apps/app/src/services/renderer/rehype-plugins/relocate-toc.ts

@@ -1,11 +1,11 @@
-import rehypeToc from 'rehype-toc';
 import type { HtmlElementNode } from 'rehype-toc';
+import rehypeToc from 'rehype-toc';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
 type StoreTocPluginParams = {
-  storeTocNode: (toc: HtmlElementNode) => void,
-}
+  storeTocNode: (toc: HtmlElementNode) => void;
+};
 
 export const rehypePluginStore: Plugin<[StoreTocPluginParams]> = (options) => {
   return rehypeToc.bind(this)({
@@ -21,7 +21,6 @@ export const rehypePluginStore: Plugin<[StoreTocPluginParams]> = (options) => {
   });
 };
 
-
 // method for replace <ol> to <ul>
 const replaceOlToUl = (tree: HtmlElementNode) => {
   visit(tree, 'element', (node: HtmlElementNode) => {
@@ -32,10 +31,12 @@ const replaceOlToUl = (tree: HtmlElementNode) => {
 };
 
 type RestoreTocPluginParams = {
-  tocNode?: HtmlElementNode,
-}
+  tocNode?: HtmlElementNode;
+};
 
-export const rehypePluginRestore: Plugin<[RestoreTocPluginParams]> = (options) => {
+export const rehypePluginRestore: Plugin<[RestoreTocPluginParams]> = (
+  options,
+) => {
   const { tocNode } = options;
 
   return rehypeToc.bind(this)({

+ 5 - 4
apps/app/src/services/renderer/remark-plugins/attachment.ts

@@ -1,7 +1,6 @@
-import path from 'path';
-
 import type { Schema as SanitizeOption } from 'hast-util-sanitize';
 import type { Link } from 'mdast';
+import path from 'path';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
@@ -15,7 +14,10 @@ const isAttachmentLink = (url: string): boolean => {
 
 const rewriteNode = (node: Link) => {
   const attachmentId = path.basename(node.url);
-  const attachmentName = node.children[0] != null && node.children[0].type === 'text' ? node.children[0].value : '';
+  const attachmentName =
+    node.children[0] != null && node.children[0].type === 'text'
+      ? node.children[0].value
+      : '';
 
   const data = node.data ?? (node.data = {});
   data.hName = 'attachment';
@@ -26,7 +28,6 @@ const rewriteNode = (node: Link) => {
   };
 };
 
-
 export const remarkPlugin: Plugin = () => {
   return (tree) => {
     visit(tree, 'link', (node: Link) => {

+ 0 - 1
apps/app/src/services/renderer/remark-plugins/codeblock.ts

@@ -3,7 +3,6 @@ import type { InlineCode } from 'mdast';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
-
 const SUPPORTED_CODE = ['inline'];
 
 export const remarkPlugin: Plugin = () => {

+ 3 - 4
apps/app/src/services/renderer/remark-plugins/echo-directive.ts

@@ -5,19 +5,19 @@ import type { LeafDirective, TextDirective } from 'mdast-util-directive';
 import type { Plugin } from 'unified';
 import { visit } from 'unist-util-visit';
 
-
 function echoDirective(node: TextDirective | LeafDirective): ElementContent[] {
   const mark = node.type === 'textDirective' ? ':' : '::';
 
   return [
     h('span', `${mark}${node.name}`),
-    ...(node.children ?? []).map((child: Text) => h('span', `[${child.value}]`)),
+    ...(node.children ?? []).map((child: Text) =>
+      h('span', `[${child.value}]`),
+    ),
   ];
 }
 
 export const remarkPlugin: Plugin = () => {
   return (tree) => {
-
     visit(tree, 'textDirective', (node: TextDirective) => {
       const tagName = 'span';
 
@@ -35,6 +35,5 @@ export const remarkPlugin: Plugin = () => {
       data.hProperties = h(tagName, node.attributes ?? {}).properties;
       data.hChildren = echoDirective(node);
     });
-
   };
 };

+ 2 - 3
apps/app/src/services/renderer/remark-plugins/emoji.ts

@@ -4,13 +4,12 @@ import { findAndReplace } from 'mdast-util-find-and-replace';
 import type { Plugin } from 'unified';
 
 export const remarkPlugin: Plugin = () => {
-  return function(tree: Root) {
+  return (tree: Root) => {
     findAndReplace(tree, [
-
       // Ref: https://github.com/remarkjs/remark-gemoji/blob/fb4d8a5021f02384e180c17f72f40d8dc698bd46/lib/index.js
       /:(\+1|[-\w]+):/g,
 
-      function(_, $1: string) {
+      (_, $1: string) => {
         const emoji = emojiData.emojis[$1]?.skins[0].native;
         return emoji ?? false;
       },

+ 13 - 16
apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.spec.ts

@@ -5,24 +5,20 @@ import { visit } from 'unist-util-visit';
 import { pukiwikiLikeLinker } from './pukiwiki-like-linker';
 
 describe('pukiwikiLikeLinker', () => {
-
   describe.each`
-    input                                   | expectedHref                    | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                      | ${'/page'}
-    ${'[[./page]]'}                         | ${'./page'}                     | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'./page'}                     | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
-    ${'[[/page?q=foo#header]]'}             | ${'/page?q=foo#header'}         | ${'/page?q=foo#header'}
-    ${'[[./page?q=foo#header]]'}            | ${'./page?q=foo#header'}        | ${'./page?q=foo#header'}
-    ${'[[Title>./page?q=foo#header]]'}      | ${'./page?q=foo#header'}        | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}        | ${'Title'}
+    input                              | expectedHref             | expectedValue
+    ${'[[/page]]'}                     | ${'/page'}               | ${'/page'}
+    ${'[[./page]]'}                    | ${'./page'}              | ${'./page'}
+    ${'[[Title>./page]]'}              | ${'./page'}              | ${'Title'}
+    ${'[[Title>https://example.com]]'} | ${'https://example.com'} | ${'Title'}
+    ${'[[/page?q=foo#header]]'}        | ${'/page?q=foo#header'}  | ${'/page?q=foo#header'}
+    ${'[[./page?q=foo#header]]'}       | ${'./page?q=foo#header'} | ${'./page?q=foo#header'}
+    ${'[[Title>./page?q=foo#header]]'} | ${'./page?q=foo#header'} | ${'Title'}
+    ${'[[Title>https://example.com]]'} | ${'https://example.com'} | ${'Title'}
   `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
-
     test(`when the input is '${input}'`, () => {
       // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker);
+      const processor = unified().use(parse).use(pukiwikiLikeLinker);
 
       // when:
       const ast = processor.parse(input);
@@ -33,11 +29,12 @@ describe('pukiwikiLikeLinker', () => {
         expect(node.data.alias).toEqual(expectedValue);
         expect(node.data.permalink).toEqual(expectedHref);
         expect(node.data.hName).toEqual('a');
-        expect(node.data.hProperties.className.startsWith('pukiwiki-like-linker')).toBeTruthy();
+        expect(
+          node.data.hProperties.className.startsWith('pukiwiki-like-linker'),
+        ).toBeTruthy();
         expect(node.data.hProperties.href).toEqual(expectedHref);
         expect(node.data.hChildren[0].value).toEqual(expectedValue);
       });
     });
   });
-
 });

+ 34 - 26
apps/app/src/services/renderer/remark-plugins/pukiwiki-like-linker.ts

@@ -2,27 +2,28 @@ import { fromMarkdown, toMarkdown } from 'mdast-util-wiki-link';
 import { syntax } from 'micromark-extension-wiki-link';
 import type { Plugin } from 'unified';
 
-
 type FromMarkdownExtension = {
   enter: {
-    wikiLink: (token: string) => void,
-  },
+    wikiLink: (token: string) => void;
+  };
   exit: {
-    wikiLinkTarget: (token: string) => void,
-    wikiLinkAlias: (token: string) => void,
-    wikiLink: (token: string) => void,
-  }
-}
+    wikiLinkTarget: (token: string) => void;
+    wikiLinkAlias: (token: string) => void;
+    wikiLink: (token: string) => void;
+  };
+};
 
 type FromMarkdownData = {
-  value: string | null,
+  value: string | null;
   data: {
-    alias: string | null,
-    hProperties: Record<string, unknown>,
-  }
-}
+    alias: string | null;
+    hProperties: Record<string, unknown>;
+  };
+};
 
-function swapTargetAndAlias(fromMarkdownExtension: FromMarkdownExtension): FromMarkdownExtension {
+function swapTargetAndAlias(
+  fromMarkdownExtension: FromMarkdownExtension,
+): FromMarkdownExtension {
   return {
     enter: fromMarkdownExtension.enter,
     exit: {
@@ -52,7 +53,7 @@ function swapTargetAndAlias(fromMarkdownExtension: FromMarkdownExtension): FromM
 /**
  * Implemented with reference to https://github.com/landakram/remark-wiki-link/blob/master/src/index.js
  */
-export const pukiwikiLikeLinker: Plugin = function() {
+export const pukiwikiLikeLinker: Plugin = function () {
   const data = this.data();
 
   function add(field: string, value) {
@@ -61,20 +62,27 @@ export const pukiwikiLikeLinker: Plugin = function() {
       if (Array.isArray(array)) {
         array.push(value);
       }
-    }
-    else {
+    } else {
       data[field] = [value];
     }
   }
 
-  add('micromarkExtensions', syntax({
-    aliasDivider: '>',
-  }));
-  add('fromMarkdownExtensions', swapTargetAndAlias(fromMarkdown({
-    wikiLinkClassName: 'pukiwiki-like-linker',
-    newClassName: ' ',
-    pageResolver: value => [value],
-    hrefTemplate: permalink => permalink,
-  })));
+  add(
+    'micromarkExtensions',
+    syntax({
+      aliasDivider: '>',
+    }),
+  );
+  add(
+    'fromMarkdownExtensions',
+    swapTargetAndAlias(
+      fromMarkdown({
+        wikiLinkClassName: 'pukiwiki-like-linker',
+        newClassName: ' ',
+        pageResolver: (value) => [value],
+        hrefTemplate: (permalink) => permalink,
+      }),
+    ),
+  );
   add('toMarkdownExtensions', toMarkdown({}));
 };

+ 6 - 8
apps/app/src/services/renderer/remark-plugins/xsv-to-table.ts

@@ -33,12 +33,10 @@ function rewriteNode(node: Node, lang: Lang) {
   }
 }
 
-export const remarkPlugin: Plugin = function() {
-  return (tree) => {
-    visit(tree, 'code', (node: Code) => {
-      if (isXsv(node.lang)) {
-        rewriteNode(node, node.lang);
-      }
-    });
-  };
+export const remarkPlugin: Plugin = () => (tree) => {
+  visit(tree, 'code', (node: Code) => {
+    if (isXsv(node.lang)) {
+      rewriteNode(node, node.lang);
+    }
+  });
 };

+ 53 - 39
apps/app/src/services/renderer/renderer.tsx

@@ -19,7 +19,10 @@ import { RehypeSanitizeType } from '~/interfaces/services/rehype-sanitize';
 import type { RendererConfig } from '~/interfaces/services/renderer';
 import loggerFactory from '~/utils/logger';
 
-import { tagNames as recommendedTagNames, attributes as recommendedAttributes } from './recommended-whitelist';
+import {
+  attributes as recommendedAttributes,
+  tagNames as recommendedTagNames,
+} from './recommended-whitelist';
 import * as addClass from './rehype-plugins/add-class';
 import * as addInlineProperty from './rehype-plugins/add-inline-code-property';
 import { relativeLinks } from './rehype-plugins/relative-links';
@@ -30,29 +33,34 @@ import * as emoji from './remark-plugins/emoji';
 import { pukiwikiLikeLinker } from './remark-plugins/pukiwiki-like-linker';
 import * as xsvToTable from './remark-plugins/xsv-to-table';
 
-
 // import EasyGrid from './PreProcessor/EasyGrid';
 
-
 // eslint-disable-next-line @typescript-eslint/no-unused-vars
 const logger = loggerFactory('growi:services:renderer');
 
-
 type SanitizePlugin = PluginTuple<[SanitizeOption]>;
 
-let currentInitializedSanitizeType: RehypeSanitizeType = RehypeSanitizeType.RECOMMENDED;
+let currentInitializedSanitizeType: RehypeSanitizeType =
+  RehypeSanitizeType.RECOMMENDED;
 let commonSanitizeOption: SanitizeOption;
-export const getCommonSanitizeOption = (config:RendererConfig): SanitizeOption => {
-  if (commonSanitizeOption == null || config.sanitizeType !== currentInitializedSanitizeType) {
+export const getCommonSanitizeOption = (
+  config: RendererConfig,
+): SanitizeOption => {
+  if (
+    commonSanitizeOption == null ||
+    config.sanitizeType !== currentInitializedSanitizeType
+  ) {
     // initialize
     commonSanitizeOption = deepmerge(
       {
-        tagNames: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
-          ? recommendedTagNames
-          : config.customTagWhitelist ?? recommendedTagNames,
-        attributes: config.sanitizeType === RehypeSanitizeType.RECOMMENDED
-          ? recommendedAttributes
-          : config.customAttrWhitelist ?? recommendedAttributes,
+        tagNames:
+          config.sanitizeType === RehypeSanitizeType.RECOMMENDED
+            ? recommendedTagNames
+            : (config.customTagWhitelist ?? recommendedTagNames),
+        attributes:
+          config.sanitizeType === RehypeSanitizeType.RECOMMENDED
+            ? recommendedAttributes
+            : (config.customAttrWhitelist ?? recommendedAttributes),
         clobberPrefix: '', // remove clobber prefix
       },
       codeBlock.sanitizeOption,
@@ -64,8 +72,9 @@ export const getCommonSanitizeOption = (config:RendererConfig): SanitizeOption =
   return commonSanitizeOption;
 };
 
-
-const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin => {
+const isSanitizePlugin = (
+  pluggable: Pluggable,
+): pluggable is SanitizePlugin => {
   if (!Array.isArray(pluggable) || pluggable.length < 2) {
     return false;
   }
@@ -73,7 +82,10 @@ const isSanitizePlugin = (pluggable: Pluggable): pluggable is SanitizePlugin =>
   return 'tagNames' in sanitizeOption && 'attributes' in sanitizeOption;
 };
 
-const hasSanitizePlugin = (options: RendererOptions, shouldBeTheLastItem: boolean): boolean => {
+const hasSanitizePlugin = (
+  options: RendererOptions,
+  shouldBeTheLastItem: boolean,
+): boolean => {
   const { rehypePlugins } = options;
   if (rehypePlugins == null || rehypePlugins.length === 0) {
     return false;
@@ -81,18 +93,25 @@ const hasSanitizePlugin = (options: RendererOptions, shouldBeTheLastItem: boolea
 
   return shouldBeTheLastItem
     ? isSanitizePlugin(rehypePlugins.slice(-1)[0]) // evaluate the last one
-    : rehypePlugins.some(rehypePlugin => isSanitizePlugin(rehypePlugin));
+    : rehypePlugins.some((rehypePlugin) => isSanitizePlugin(rehypePlugin));
 };
 
-export const verifySanitizePlugin = (options: RendererOptions, shouldBeTheLastItem = true): void => {
+export const verifySanitizePlugin = (
+  options: RendererOptions,
+  shouldBeTheLastItem = true,
+): void => {
   if (hasSanitizePlugin(options, shouldBeTheLastItem)) {
     return;
   }
 
-  throw new Error('The specified options does not have sanitize plugin in \'rehypePlugins\'');
+  throw new Error(
+    "The specified options does not have sanitize plugin in 'rehypePlugins'",
+  );
 };
 
-export const generateCommonOptions = (pagePath: string|undefined): RendererOptions => {
+export const generateCommonOptions = (
+  pagePath: string | undefined,
+): RendererOptions => {
   return {
     remarkPlugins: [
       gfm,
@@ -112,9 +131,12 @@ export const generateCommonOptions = (pagePath: string|undefined): RendererOptio
       [relativeLinksByPukiwikiLikeLinker, { pagePath }],
       [relativeLinks, { pagePath }],
       raw,
-      [addClass.rehypePlugin, {
-        table: 'table table-bordered',
-      }],
+      [
+        addClass.rehypePlugin,
+        {
+          table: 'table table-bordered',
+        },
+      ],
       addInlineProperty.rehypePlugin,
     ],
     components: {
@@ -124,21 +146,16 @@ export const generateCommonOptions = (pagePath: string|undefined): RendererOptio
   };
 };
 
-
 export const generateSSRViewOptions = (
-    config: RendererConfig,
-    pagePath: string,
+  config: RendererConfig,
+  pagePath: string,
 ): RendererOptions => {
-
   const options = generateCommonOptions(pagePath);
 
   const { remarkPlugins, rehypePlugins } = options;
 
   // add remark plugins
-  remarkPlugins.push(
-    math,
-    xsvToTable.remarkPlugin,
-  );
+  remarkPlugins.push(math, xsvToTable.remarkPlugin);
 
   const isEnabledLinebreaks = config.isEnabledLinebreaks;
 
@@ -146,16 +163,13 @@ export const generateSSRViewOptions = (
     remarkPlugins.push(breaks);
   }
 
-  const rehypeSanitizePlugin: Pluggable | (() => void) = config.isEnabledXssPrevention
-    ? [sanitize, getCommonSanitizeOption(config)]
-    : () => {};
+  const rehypeSanitizePlugin: Pluggable | (() => void) =
+    config.isEnabledXssPrevention
+      ? [sanitize, getCommonSanitizeOption(config)]
+      : () => {};
 
   // add rehype plugins
-  rehypePlugins.push(
-    slug,
-    rehypeSanitizePlugin,
-    katex,
-  );
+  rehypePlugins.push(slug, rehypeSanitizePlugin, katex);
 
   // add components
   // if (components != null) {

+ 0 - 1
biome.json

@@ -34,7 +34,6 @@
       "!apps/app/src/models/**",
       "!apps/app/src/pages/**",
       "!apps/app/src/server/**",
-      "!apps/app/src/services/**",
       "!apps/app/src/stores/**",
       "!apps/app/src/styles/**",
       "!apps/app/test-with-vite/**",