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

Merge pull request #7697 from weseek/fix/7696-relative-links

fix: Hash and search query in the relative link are omitted wrongly
Yuki Takei 2 лет назад
Родитель
Сommit
d46045eaac

+ 5 - 0
.eslintrc.js

@@ -20,6 +20,11 @@ module.exports = {
       'warn',
       {
         pathGroups: [
+          {
+            pattern: 'vitest',
+            group: 'builtin',
+            position: 'before',
+          },
           {
             pattern: 'react',
             group: 'builtin',

+ 2 - 1
apps/app/src/server/models/eslint-rules-dir/test/no-populate.spec.ts

@@ -1,6 +1,7 @@
-import { RuleTester } from 'eslint';
 import { test } from 'vitest';
 
+import { RuleTester } from 'eslint';
+
 import noPopulate from '../no-populate';
 
 const ruleTester = new RuleTester({

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

@@ -0,0 +1,54 @@
+import { describe, test, expect } from 'vitest';
+
+import { type HastNode, select } from 'hast-util-select';
+import parse from 'remark-parse';
+import rehype from 'remark-rehype';
+import { unified } from 'unified';
+
+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].value).toEqual(expectedValue);
+
+    });
+  });
+
+});

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

@@ -1,27 +1,26 @@
 import { pathUtils } from '@growi/core';
 import { selectAll } from 'hast-util-select';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 import {
-  IAnchorsSelector, IHrefResolver, relativeLinks, RelativeLinksPluginParams,
+  relativeLinks,
+  type IAnchorsSelector, type IUrlResolver, type RelativeLinksPluginParams,
 } from './relative-links';
 
 const customAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href].pukiwiki-like-linker', node);
 };
 
-const customHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const customUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(pathUtils.addTrailingSlash(basePath), 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
-
-  return relativeUrl.pathname;
+  return new URL(relativeHref, baseUrl);
 };
 
 export const relativeLinksByPukiwikiLikeLinker: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   return relativeLinks.bind(this)({
     ...options,
     anchorsSelector: customAnchorsSelector,
-    hrefResolver: customHrefResolver,
+    urlResolver: customUrlResolver,
   });
 };

+ 64 - 0
apps/app/src/services/renderer/rehype-plugins/relative-links.spec.ts

@@ -0,0 +1,64 @@
+
+import { describe, test, expect } from 'vitest';
+
+import { select, type HastNode } from 'hast-util-select';
+import parse from 'remark-parse';
+import remarkRehype from 'remark-rehype';
+import { unified } from 'unified';
+
+import { relativeLinks } from './relative-links';
+
+describe('relativeLinks', () => {
+
+  test.concurrent.each`
+    originalHref
+      ${'http://example.com/Sandbox'}
+      ${'#header'}
+    `('leaves the original href \'$originalHref\' as-is', ({ originalHref }) => {
+
+    // setup
+    const processor = unified()
+      .use(parse)
+      .use(remarkRehype)
+      .use(relativeLinks, {});
+
+    // when
+    const mdastTree = processor.parse(`[link](${originalHref})`);
+    const hastTree = processor.runSync(mdastTree) as HastNode;
+
+    // then
+    const anchorElement = select('a', hastTree);
+    expect(anchorElement?.properties?.href).toBe(originalHref);
+  });
+
+  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 }) => {
+
+    // 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);
+  });
+
+});

+ 12 - 9
apps/app/src/services/renderer/rehype-plugins/relative-links.ts

@@ -1,20 +1,23 @@
-import { selectAll, HastNode, Element } from 'hast-util-select';
+import { selectAll, type HastNode, type Element } from 'hast-util-select';
 import isAbsolute from 'is-absolute-url';
-import { Plugin } from 'unified';
+import type { Plugin } from 'unified';
 
 export type IAnchorsSelector = (node: HastNode) => Element[];
-export type IHrefResolver = (relativeHref: string, basePath: string) => string;
+export type IUrlResolver = (relativeHref: string, basePath: string) => URL;
 
 const defaultAnchorsSelector: IAnchorsSelector = (node) => {
   return selectAll('a[href]', node);
 };
 
-const defaultHrefResolver: IHrefResolver = (relativeHref, basePath) => {
+const defaultUrlResolver: IUrlResolver = (relativeHref, basePath) => {
   // generate relative pathname
   const baseUrl = new URL(basePath, 'https://example.com');
-  const relativeUrl = new URL(relativeHref, baseUrl);
+  return new URL(relativeHref, baseUrl);
+};
 
-  return relativeUrl.pathname;
+const urlToHref = (url: URL): string => {
+  const { pathname, search, hash } = url;
+  return `${pathname}${search}${hash}`;
 };
 
 const isAnchorLink = (href: string): boolean => {
@@ -24,12 +27,12 @@ const isAnchorLink = (href: string): boolean => {
 export type RelativeLinksPluginParams = {
   pagePath?: string,
   anchorsSelector?: IAnchorsSelector,
-  hrefResolver?: IHrefResolver,
+  urlResolver?: IUrlResolver,
 }
 
 export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {}) => {
   const anchorsSelector = options.anchorsSelector ?? defaultAnchorsSelector;
-  const hrefResolver = options.hrefResolver ?? defaultHrefResolver;
+  const urlResolver = options.urlResolver ?? defaultUrlResolver;
 
   return (tree) => {
     if (options.pagePath == null) {
@@ -49,7 +52,7 @@ export const relativeLinks: Plugin<[RelativeLinksPluginParams]> = (options = {})
         return;
       }
 
-      anchor.properties.href = hrefResolver(href, pagePath);
+      anchor.properties.href = urlToHref(urlResolver(href, pagePath));
     });
   };
 };

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

@@ -0,0 +1,45 @@
+import { describe, test, expect } from 'vitest';
+
+import parse from 'remark-parse';
+import { unified } from 'unified';
+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'}
+  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
+
+    test(`when the input is '${input}'`, () => {
+      // setup:
+      const processor = unified()
+        .use(parse)
+        .use(pukiwikiLikeLinker);
+
+      // when:
+      const ast = processor.parse(input);
+
+      expect(ast).not.toBeNull();
+
+      visit(ast, 'wikiLink', (node: any) => {
+        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.href).toEqual(expectedHref);
+        expect(node.data.hChildren[0].value).toEqual(expectedValue);
+      });
+    });
+  });
+
+});

+ 0 - 92
apps/app/test/unit/services/renderer/pukiwiki-like-linker.test.ts

@@ -1,92 +0,0 @@
-import { HastNode, selectAll } from 'hast-util-select';
-import parse from 'remark-parse';
-import rehype from 'remark-rehype';
-import { unified } from 'unified';
-import { visit } from 'unist-util-visit';
-
-import { relativeLinksByPukiwikiLikeLinker } from '../../../../src/services/renderer/rehype-plugins/relative-links-by-pukiwiki-like-linker';
-import { pukiwikiLikeLinker } from '../../../../src/services/renderer/remark-plugins/pukiwiki-like-linker';
-
-describe('pukiwikiLikeLinker', () => {
-
-  /* eslint-disable indent */
-  describe.each`
-    input                                   | expectedHref                | expectedValue
-    ${'[[/page]]'}                          | ${'/page'}                  | ${'/page'}
-    ${'[[./page]]'}                         | ${'./page'}                 | ${'./page'}
-    ${'[[Title>./page]]'}                   | ${'./page'}                 | ${'Title'}
-    ${'[[Title>https://example.com]]'}      | ${'https://example.com'}    | ${'Title'}
-  `('should parse correctly', ({ input, expectedHref, expectedValue }) => {
-  /* eslint-enable indent */
-
-    test(`when the input is '${input}'`, () => {
-      // setup:
-      const processor = unified()
-        .use(parse)
-        .use(pukiwikiLikeLinker);
-
-      // when:
-      const ast = processor.parse(input);
-
-      expect(ast).not.toBeNull();
-
-      visit(ast, 'wikiLink', (node: any) => {
-        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.href).toEqual(expectedHref);
-        expect(node.data.hChildren[0].value).toEqual(expectedValue);
-      });
-
-    });
-  });
-
-});
-
-
-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'}
-  `('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);
-
-      expect(hast).not.toBeNull();
-      expect((hast as any).children[0].type).toEqual('element');
-
-      const anchors = selectAll('a', hast as HastNode);
-
-      expect(anchors.length).toEqual(1);
-
-      const anchor = anchors[0];
-
-      expect(anchor.tagName).toEqual('a');
-      expect((anchor.properties as any).className.startsWith('pukiwiki-like-linker')).toBeTruthy();
-      expect(anchor.properties?.href).toEqual(expectedHref);
-
-      expect(anchor.children[0]).not.toBeNull();
-      expect(anchor.children[0].type).toEqual('text');
-      expect(anchor.children[0].value).toEqual(expectedValue);
-
-    });
-  });
-
-});