Repository: vuepress/vuepress-next (ts)

Issue:
[Bug report] Customized URL Protocol is not Recongzied
### Description

Refer to https://github.com/vuepress-theme-hope/vuepress-theme-hope/issues/3395

In the markdown file, the link field with customized URL protocol will be converted based on the relative path rule instead of considering it as an absolute link.

In the reproduction below, I use the link `ms-windows-store://pdp/?ProductId=9PH4NXJ2JN52` in the primary button. When click on it, it redirects to a link that does not exist, instead of pulling up the Windows Store.

### Reproduction

https://stackblitz.com/edit/vuepress-5qhpva?file=docs%2FREADME.md

### Used Package Manager

npm

### System Info

```shell
System:
    OS: Linux 5.0 undefined
    CPU: (8) x64 Intel(R) Core(TM) i9-9880H CPU @ 2.30GHz
    Memory: 0 Bytes / 0 Bytes
    Shell: 1.0 - /bin/jsh
  Binaries:
    Node: 16.20.0 - /usr/local/bin/node
    Yarn: 1.22.19 - /usr/local/bin/yarn
    npm: 9.4.2 - /usr/local/bin/npm
  Utilities:
    Git: Not Found
  Browsers:
    Chrome: Not Found
  npmPackages:
    @vuepress/bundler-vite:  2.0.0-beta.66 
    @vuepress/bundler-webpack: Not Found
    @vuepress/cli:  2.0.0-beta.66 
    @vuepress/client:  2.0.0-beta.66 
    @vuepress/core:  2.0.0-beta.66 
    @vuepress/markdown:  2.0.0-beta.66 
    @vuepress/plugin-active-header-links:  2.0.0-beta.66 
    @vuepress/plugin-back-to-top:  2.0.0-beta.66 
    @vuepress/plugin-container:  2.0.0-beta.66 
    @vuepress/plugin-docsearch: Not Found
    @vuepress/plugin-external-link-icon:  2.0.0-beta.66 
    @vuepress/plugin-git:  2.0.0-beta.66 
    @vuepress/plugin-google-analytics: Not Found
    @vuepress/plugin-medium-zoom:  2.0.0-beta.66 
    @vuepress/plugin-nprogress:  2.0.0-beta.66 
    @vuepress/plugin-palette:  2.0.0-beta.66 
    @vuepress/plugin-prismjs:  2.0.0-beta.66 
    @vuepress/plugin-pwa: Not Found
    @vuepress/plugin-pwa-popup: Not Found
    @vuepress/plugin-register-components: Not Found
    @vuepress/plugin-search: Not Found
    @vuepress/plugin-shiki: Not Found
    @vuepress/plugin-theme-data:  2.0.0-beta.66 
    @vuepress/plugin-toc: Not Found
    @vuepress/shared:  2.0.0-beta.66 
    @vuepress/theme-default:  2.0.0-beta.66 
    @vuepress/utils:  2.0.0-beta.66 
    vue:  3.3.4 
    vue-loader: Not Found
    vue-router:  4.2.4 
    vuepress: next => 2.0.0-beta.66 
    vuepress-vite:  2.0.0-beta.66 
    vuepress-webpack: Not Found
```


Pull request description:
fix: add support for custom protocol, close #1404
Note: `mailto:` and `tel:` works with `target: _blank`, so the special treatment is removed.

Also chrome no longer support `ftp://` links now, so users should use a third-party ftp client.

Patch:
diff --git a/ecosystem/theme-default/src/client/components/AutoLink.vue b/ecosystem/theme-default/src/client/components/AutoLink.vue
index e15784a3..b6d8e404 100644
--- a/ecosystem/theme-default/src/client/components/AutoLink.vue
+++ b/ecosystem/theme-default/src/client/components/AutoLink.vue
@@ -10,7 +10,7 @@ export default defineComponent({
 
 <script setup lang="ts">
 import { useSiteData } from '@vuepress/client'
-import { isLinkHttp, isLinkMailto, isLinkTel } from '@vuepress/shared'
+import { isLinkHttp, isLinkWithProtocol } from '@vuepress/shared'
 import { computed, toRefs } from 'vue'
 import type { PropType } from 'vue'
 import { useRoute } from 'vue-router'
@@ -36,7 +36,7 @@ const { item } = toRefs(props)
 const hasHttpProtocol = computed(() => isLinkHttp(item.value.link))
 // if the link has non-http protocol
 const hasNonHttpProtocol = computed(
-  () => isLinkMailto(item.value.link) || isLinkTel(item.value.link),
+  () => !hasHttpProtocol.value && isLinkWithProtocol(item.value.link),
 )
 // resolve the `target` attr
 const linkTarget = computed(() => {
diff --git a/packages/shared/src/utils/index.ts b/packages/shared/src/utils/index.ts
index a3967da2..5cbea86d 100644
--- a/packages/shared/src/utils/index.ts
+++ b/packages/shared/src/utils/index.ts
@@ -6,10 +6,8 @@ export * from './ensureLeadingSlash.js'
 export * from './ensureEndingSlash.js'
 export * from './formatDateString.js'
 export * from './isLinkExternal.js'
-export * from './isLinkFtp.js'
 export * from './isLinkHttp.js'
-export * from './isLinkMailto.js'
-export * from './isLinkTel.js'
+export * from './isLinkWithProtocol.js'
 export * from './isPlainObject.js'
 export * from './omit.js'
 export * from './removeEndingSlash.js'
diff --git a/packages/shared/src/utils/isLinkExternal.ts b/packages/shared/src/utils/isLinkExternal.ts
index 0c3f9a88..4c75a564 100644
--- a/packages/shared/src/utils/isLinkExternal.ts
+++ b/packages/shared/src/utils/isLinkExternal.ts
@@ -1,4 +1,3 @@
-import { isLinkFtp } from './isLinkFtp.js'
 import { isLinkHttp } from './isLinkHttp.js'
 
 const markdownLinkRegexp = /.md((\?|#).*)?$/
@@ -7,8 +6,7 @@ const markdownLinkRegexp = /.md((\?|#).*)?$/
  * Determine a link is external or not
  */
 export const isLinkExternal = (link: string, base = '/'): boolean => {
-  // http link or ftp link
-  if (isLinkHttp(link) || isLinkFtp(link)) {
+  if (isLinkHttp(link)) {
     return true
   }
 
diff --git a/packages/shared/src/utils/isLinkFtp.ts b/packages/shared/src/utils/isLinkFtp.ts
deleted file mode 100644
index 182043c5..00000000
--- a/packages/shared/src/utils/isLinkFtp.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Determine a link is ftp link or not
- */
-export const isLinkFtp = (link: string): boolean => link.startsWith('ftp://')
diff --git a/packages/shared/src/utils/isLinkMailto.ts b/packages/shared/src/utils/isLinkMailto.ts
deleted file mode 100644
index 3e0ade35..00000000
--- a/packages/shared/src/utils/isLinkMailto.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Determine a link is a mailto: address or not
- */
-export const isLinkMailto = (link: string): boolean => /^mailto:/.test(link)
diff --git a/packages/shared/src/utils/isLinkTel.ts b/packages/shared/src/utils/isLinkTel.ts
deleted file mode 100644
index 1290cba0..00000000
--- a/packages/shared/src/utils/isLinkTel.ts
+++ /dev/null
@@ -1,4 +0,0 @@
-/**
- * Determine a link is a tel: address or not
- */
-export const isLinkTel = (link: string): boolean => /^tel:/.test(link)
diff --git a/packages/shared/src/utils/isLinkWithProtocol.ts b/packages/shared/src/utils/isLinkWithProtocol.ts
new file mode 100644
index 00000000..e6f66e97
--- /dev/null
+++ b/packages/shared/src/utils/isLinkWithProtocol.ts
@@ -0,0 +1,5 @@
+/**
+ * Determine a link has protocol or not
+ */
+export const isLinkWithProtocol = (link: string): boolean =>
+  /^[a-z][a-z0-9+.-]*:/.test(link)


Test patch:
diff --git a/packages/shared/tests/isLinkExternal.spec.ts b/packages/shared/tests/isLinkExternal.spec.ts
index b7a2515e..96291a4b 100644
--- a/packages/shared/tests/isLinkExternal.spec.ts
+++ b/packages/shared/tests/isLinkExternal.spec.ts
@@ -5,27 +5,28 @@ const testCases: [
   Parameters<typeof isLinkExternal>,
   ReturnType<typeof isLinkExternal>,
 ][] = [
-  // http & ftp links
+  // http links
   [['https://foobar.com'], true],
   [['https://foobar.com', '/base/'], true],
   [['http://foobar.com'], true],
   [['http://foobar.com', '/base/'], true],
   [['//foobar.com'], true],
   [['//foobar.com', '/base/'], true],
-  [['ftp://foobar.com'], true],
-  [['ftp://foobar.com', '/base/'], true],
   [['https://foobar.com/base/README.md'], true],
   [['https://foobar.com/base/README.md', '/base/'], true],
   [['http://foobar.com/base/README.md'], true],
   [['http://foobar.com/base/README.md', '/base/'], true],
   [['//foobar.com/base/README.md'], true],
   [['//foobar.com/base/README.md', '/base/'], true],
-  [['ftp://foobar.com/base/README.md'], true],
-  [['ftp://foobar.com/base/README.md', '/base/'], true],
 
   // links with other protocols
   [['mailto:foobar', '/base/'], false],
   [['tel:foobar', '/base/'], false],
+  [['ftp://foobar.com'], false],
+  [['ftp://foobar.com', '/base/'], false],
+  [['ftp://foobar.com/base/README.md'], false],
+  [['ftp://foobar.com/base/README.md', '/base/'], false],
+  [['ms-windows-store://home', '/base/'], false],
 
   // absolute links
   [['/foo/bar'], false],
diff --git a/packages/shared/tests/isLinkFtp.spec.ts b/packages/shared/tests/isLinkFtp.spec.ts
deleted file mode 100644
index 446d084c..00000000
--- a/packages/shared/tests/isLinkFtp.spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { isLinkFtp } from '../src/index.js'
-
-const testCases: [string, ReturnType<typeof isLinkFtp>][] = [
-  ['ftp://foobar.com', true],
-  ['https://foobar.com', false],
-  ['http://foobar.com', false],
-  ['//foobar.com', false],
-  ['foobar.com', false],
-  ['/foo/bar', false],
-]
-
-describe('shared > isLinkFtp', () => {
-  describe('should determine ftp link correctly', () => {
-    testCases.forEach(([source, expected]) => {
-      it(`link: ${source}`, () => {
-        expect(isLinkFtp(source)).toBe(expected)
-      })
-    })
-  })
-})
diff --git a/packages/shared/tests/isLinkMailto.spec.ts b/packages/shared/tests/isLinkMailto.spec.ts
deleted file mode 100644
index 900d4b45..00000000
--- a/packages/shared/tests/isLinkMailto.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { isLinkMailto } from '../src/index.js'
-
-const testCases: [string, boolean][] = [
-  ['https://foobar.com', false],
-  ['http://foobar.com', false],
-  ['//foobar.com', false],
-  ['foobar.com', false],
-  ['/foo/bar', false],
-  ['mailto:foobar', true],
-  ['tel:foobar', false],
-  ['../foo/bar', false],
-]
-
-describe('shared > isLinkMailto', () => {
-  describe('should determine mailto link correctly', () => {
-    testCases.forEach(([source, expected]) => {
-      it(`link: ${source}`, () => {
-        expect(isLinkMailto(source)).toBe(expected)
-      })
-    })
-  })
-})
diff --git a/packages/shared/tests/isLinkTel.spec.ts b/packages/shared/tests/isLinkTel.spec.ts
deleted file mode 100644
index 60c8361b..00000000
--- a/packages/shared/tests/isLinkTel.spec.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { isLinkTel } from '../src/index.js'
-
-const testCases: [string, boolean][] = [
-  ['https://foobar.com', false],
-  ['http://foobar.com', false],
-  ['//foobar.com', false],
-  ['foobar.com', false],
-  ['/foo/bar', false],
-  ['mailto:foobar', false],
-  ['tel:foobar', true],
-  ['../foo/bar', false],
-]
-
-describe('shared > isLinkMailto', () => {
-  describe('should determine mailto link correctly', () => {
-    testCases.forEach(([source, expected]) => {
-      it(`link: ${source}`, () => {
-        expect(isLinkTel(source)).toBe(expected)
-      })
-    })
-  })
-})
diff --git a/packages/shared/tests/isLinkWithProtocol.spec.ts b/packages/shared/tests/isLinkWithProtocol.spec.ts
new file mode 100644
index 00000000..cacc8b31
--- /dev/null
+++ b/packages/shared/tests/isLinkWithProtocol.spec.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest'
+import { isLinkWithProtocol } from '../src/index.js'
+
+const testCases: [string, ReturnType<typeof isLinkWithProtocol>][] = [
+  ['ftp://foobar.com', true],
+  ['ms-windows-store://home', true],
+  ['mailto:foobar', true],
+  ['tel:foobar', true],
+  ['https://foobar.com', true],
+  ['http://foobar.com', true],
+  ['foobar.com', false],
+  ['/foo/bar', false],
+  ['../foo/bar', false],
+  ['//foobar.com', false],
+]
+
+describe('shared > isLinkWithProtocol', () => {
+  describe('should determine link with protocol correctly', () => {
+    testCases.forEach(([source, expected]) => {
+      it(`link: ${source}`, () => {
+        expect(isLinkWithProtocol(source)).toBe(expected)
+      })
+    })
+  })
+})