Skip to content

Commit 3260636

Browse files
authored
fix: Broken mailto: links in Markdoc (#506)
1 parent 72b56fd commit 3260636

File tree

8 files changed

+1366
-160
lines changed

8 files changed

+1366
-160
lines changed

.github/workflows/ci.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,25 @@ jobs:
4242
node-version: ${{ steps.engines.outputs.nodeVersion }}
4343
- run: yarn install --immutable
4444
- run: yarn build:storybook
45+
test:
46+
name: Unit test
47+
runs-on: ubuntu-latest
48+
defaults:
49+
run:
50+
shell: bash
51+
steps:
52+
- name: 'Checkout'
53+
uses: actions/checkout@v3
54+
- name: Read Node.js version from package.json
55+
run: echo "nodeVersion=$(node -p "require('./package.json').engines.node")" >> $GITHUB_OUTPUT
56+
id: engines
57+
- name: 'Setup Node'
58+
uses: actions/setup-node@v3
59+
with:
60+
node-version: ${{ steps.engines.outputs.nodeVersion }}
61+
- run: |
62+
yarn install --immutable
63+
- run: yarn test
4564
lint:
4665
name: Lint
4766
runs-on: ubuntu-latest

package.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@
1414
"storybook:serve-static": "yarn build:storybook && http-server storybook-static",
1515
"build": "npx tsc --declaration",
1616
"clean": "rimraf storybook-static dist",
17+
"test": "vitest --run",
18+
"test:watch": "vitest",
19+
"test:coverage": "vitest run --coverage",
20+
"test:ui": "vitest --ui",
1721
"lint": "prettier --check ./src && eslint . --ext ts,tsx,js,jsx",
1822
"fix": "run-s fix:format fix:js",
1923
"fix:format": "prettier --write --no-error-on-unmatched-pattern ./src",
@@ -80,11 +84,14 @@
8084
"@storybook/react": "7.0.22",
8185
"@storybook/react-vite": "7.0.22",
8286
"@storybook/testing-library": "0.1.0",
87+
"@testing-library/jest-dom": "5.17.0",
8388
"@types/react-dom": "18.2.4",
8489
"@types/react-transition-group": "4.4.6",
8590
"@types/styled-components": "5.1.26",
8691
"@typescript-eslint/eslint-plugin": "5.59.9",
8792
"@typescript-eslint/parser": "5.59.9",
93+
"@vitest/coverage-v8": "0.34.1",
94+
"@vitest/ui": "0.34.1",
8895
"babel-loader": "9.1.2",
8996
"conventional-changelog-conventionalcommits": "6.1.0",
9097
"eslint": "8.42.0",
@@ -102,6 +109,7 @@
102109
"http-server": "14.1.1",
103110
"husky": "8.0.3",
104111
"jest-mock": "29.5.0",
112+
"jsdom": "22.1.0",
105113
"lint-staged": "13.2.2",
106114
"npm-run-all": "4.1.5",
107115
"prettier": "2.8.8",
@@ -112,7 +120,8 @@
112120
"storybook": "7.0.22",
113121
"styled-components": "5.3.11",
114122
"typescript": "4.9.5",
115-
"vite": "4.3.9"
123+
"vite": "4.4.8",
124+
"vitest": "0.34.1"
116125
},
117126
"peerDependencies": {
118127
"@emotion/react": ">=11.11.0",

src/markdoc/utils/text.ts

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,6 @@
1-
export function removeTrailingSlashes(str: unknown) {
2-
if (typeof str !== 'string') {
3-
return str
4-
}
5-
6-
return str.replace(/\/+$/, '')
7-
}
1+
import { isExternalUrl } from '../../utils/urls'
82

9-
export function isRelativeUrl(str: string) {
10-
return !str.match(/^\/.*$|^[^:/]*?:\/\/.*?$/giu)
11-
}
12-
13-
export function isExternalUrl(url?: string | null) {
14-
if (!url) return false
15-
16-
return url.substr(0, 4) === 'http' || url.substr(0, 2) === '//'
17-
}
3+
export * from '../../utils/urls'
184

195
export const stripMdExtension = (url?: string) => {
206
url = url ?? ''
@@ -25,14 +11,6 @@ export const stripMdExtension = (url?: string) => {
2511
return url
2612
}
2713

28-
export function getBarePathFromPath(url: string) {
29-
return url.split(/[?#]/)[0]
30-
}
31-
32-
export function isSubrouteOf(route: string, compareRoute: string) {
33-
return route.startsWith(compareRoute)
34-
}
35-
3614
export const providerToProviderName: Record<string, string> = {
3715
GCP: 'GCP',
3816
AWS: 'AWS',
@@ -43,10 +21,3 @@ export const providerToProviderName: Record<string, string> = {
4321
KUBERNETES: 'Kubernetes',
4422
GENERIC: 'Generic',
4523
}
46-
47-
export function toHtmlId(str: string) {
48-
const id = str.replace(/\W+/g, ' ').trim().replace(/\s/g, '-').toLowerCase()
49-
50-
// make sure the id starts with a letter or underscore
51-
return id.match(/^[A-Za-z]/) ? id : `_${id}`
52-
}

src/utils/urls.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import {
2+
getBarePathFromPath,
3+
isExternalUrl,
4+
isRelativeUrl,
5+
isSubrouteOf,
6+
removeTrailingSlashes,
7+
toHtmlId,
8+
} from './urls'
9+
10+
const relativeUrls = [
11+
'',
12+
'a',
13+
'a/',
14+
'abcd',
15+
'abcd/',
16+
'abcd#something-something',
17+
'abcd/#hash.hash',
18+
'deep/path/to/page.html',
19+
'deep/path/to/page.html#hash',
20+
':something',
21+
'#hash-link',
22+
]
23+
24+
const absoluteUrls = [
25+
'/a',
26+
'/a/',
27+
'/abcd',
28+
'/abcd/',
29+
'/abcd#something-something',
30+
'/abcd/#hash.hash',
31+
'/deep/path/to/page.html',
32+
'/deep/path/to/page.html#hash',
33+
]
34+
35+
const externalUrls = [
36+
// Links with protocols
37+
'//google.com',
38+
'http://google.com',
39+
'https://google.com',
40+
'ftp://google.com',
41+
'gopher://google.com',
42+
'HTTP://google.com',
43+
'HTTPS://google.com',
44+
'FTP://google.com',
45+
'GOPHER://google.com',
46+
'234h+-.:something', // Weird, but valid protocol
47+
48+
// Alternative links
49+
'mailto:',
50+
51+
'tel:',
52+
'sms:',
53+
'callto:',
54+
'tel:+1.123.345.6342',
55+
'sms:+1.123.345.6342',
56+
'callto:+1.123.345.6342',
57+
]
58+
59+
describe('URL utils', () => {
60+
it('should detect relative urls', () => {
61+
relativeUrls.forEach((url) => {
62+
expect(isRelativeUrl(url)).toBeTruthy()
63+
})
64+
absoluteUrls.forEach((url) => {
65+
expect(isRelativeUrl(url)).toBeFalsy()
66+
})
67+
externalUrls.forEach((url) => {
68+
expect(isRelativeUrl(url)).toBeFalsy()
69+
})
70+
})
71+
72+
it('should detect external urls', () => {
73+
relativeUrls.forEach((url) => {
74+
expect(isExternalUrl(url)).toBeFalsy()
75+
})
76+
absoluteUrls.forEach((url) => {
77+
expect(isExternalUrl(url)).toBeFalsy()
78+
})
79+
externalUrls.forEach((url) => {
80+
expect(isExternalUrl(url)).toBeTruthy()
81+
})
82+
})
83+
84+
it('should remove trailing slashes', () => {
85+
expect(removeTrailingSlashes(null)).toBe(null)
86+
expect(removeTrailingSlashes(undefined)).toBe(undefined)
87+
expect(removeTrailingSlashes('/')).toBe('')
88+
expect(removeTrailingSlashes('//')).toBe('')
89+
expect(removeTrailingSlashes('///////')).toBe('')
90+
expect(removeTrailingSlashes('/abc/a/')).toBe('/abc/a')
91+
expect(removeTrailingSlashes('/abc/a////')).toBe('/abc/a')
92+
expect(removeTrailingSlashes('/abc////a')).toBe('/abc////a')
93+
expect(removeTrailingSlashes('http://a.b.c/#d')).toBe('http://a.b.c/#d')
94+
})
95+
96+
it('should detect subroutes', () => {
97+
expect(isSubrouteOf('/', '')).toBeTruthy()
98+
expect(isSubrouteOf('/something', '/')).toBeTruthy()
99+
expect(isSubrouteOf('/a/b/cdefg/h/', '/a/b/cdefg/h/')).toBeTruthy()
100+
expect(isSubrouteOf('/a/b/cdefg/h/ijk', '/a/b/cdefg/h/')).toBeTruthy()
101+
expect(
102+
isSubrouteOf('http://google.com/?x=something', 'http://google.com')
103+
).toBeTruthy()
104+
105+
expect(isSubrouteOf('', '/')).toBeFalsy()
106+
expect(isSubrouteOf('https://google.com', 'http://google.com')).toBeFalsy()
107+
})
108+
109+
it('should create valid id attributes', () => {
110+
expect(toHtmlId('some%#$long9 string-with_chars')).toBe(
111+
'some-long9-string-with_chars'
112+
)
113+
expect(toHtmlId('123 numbers')).toBe('_123-numbers')
114+
expect(toHtmlId('')).toBe('')
115+
expect(toHtmlId(' ')).toBe('')
116+
expect(toHtmlId('a')).toBe('a')
117+
expect(toHtmlId(' abc')).toBe('abc')
118+
expect(toHtmlId('-things')).toBe('things')
119+
expect(toHtmlId('_things')).toBe('_things')
120+
expect(toHtmlId('_thiñgs')).toBe('_thi-gs')
121+
expect(toHtmlId('CAPITALS')).toBe('capitals')
122+
})
123+
124+
it('should get paths without url params or hashes', () => {
125+
expect(getBarePathFromPath('')).toBe('')
126+
expect(getBarePathFromPath('abc')).toBe('abc')
127+
expect(getBarePathFromPath('abc//')).toBe('abc//')
128+
expect(getBarePathFromPath('#hash?var=val')).toBe('')
129+
expect(getBarePathFromPath('path#hash?var=val')).toBe('path')
130+
expect(getBarePathFromPath('//path#hash?var=val')).toBe('//path')
131+
expect(getBarePathFromPath('path/#/morepath')).toBe('path/')
132+
expect(getBarePathFromPath('//path/#hash?var=val')).toBe('//path/')
133+
expect(getBarePathFromPath('http://path.com?var=val')).toBe(
134+
'http://path.com'
135+
)
136+
})
137+
})

src/utils/urls.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ export function removeTrailingSlashes(str: string | null | undefined) {
77
}
88

99
export function isRelativeUrl(str: string) {
10-
return !str.match(/^\/.*$|^[^:/]*?:\/\/.*?$/giu)
11-
}
10+
if (str.startsWith('/')) {
11+
return false
12+
}
1213

13-
export function isExternalUrl(url?: string | null) {
14-
if (!url) return false
14+
return !isExternalUrl(str)
15+
}
1516

16-
return url.substr(0, 4) === 'http' || url.substr(0, 2) === '//'
17+
export function isExternalUrl(str?: string | null) {
18+
return !!str.match(/^(\/\/|[a-z\d+-.]+?:)/i)
1719
}
1820

1921
export function getBarePathFromPath(url: string) {
@@ -25,8 +27,12 @@ export function isSubrouteOf(route: string, compareRoute: string) {
2527
}
2628

2729
export function toHtmlId(str: string) {
28-
const id = str.replace(/\W+/g, ' ').trim().replace(/\s/g, '-').toLowerCase()
30+
const id = str
31+
.replace(/\W+/g, ' ') //
32+
.trim()
33+
.replace(/\s/g, '-')
34+
.toLowerCase()
2935

3036
// make sure the id starts with a letter or underscore
31-
return id.match(/^[A-Za-z]/) ? id : `_${id}`
37+
return id.match(/^($|[A-Za-z_])/) ? id : `_${id}`
3238
}

tsconfig.eslint.json

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@
22
"extends": "./tsconfig.json",
33
"compilerOptions": {
44
"rootDir": ".",
5-
"types": ["@types/node"],
5+
"types": ["@types/node", "vitest/globals"],
66
"noEmit": true,
77
"allowJs": true
88
},
9-
"include": ["src/**/*", ".storybook/**/*", "vite.config.ts", ".eslintrc.cjs"]
9+
"include": [
10+
"src/**/*",
11+
".storybook/**/*",
12+
"vite.config.ts",
13+
"vitest.config.ts",
14+
".eslintrc.cjs"
15+
]
1016
}

vitest.config.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { defineConfig, mergeConfig } from 'vitest/config'
2+
3+
import viteConfig from './vite.config'
4+
5+
// https://vitest.dev/config/
6+
export default mergeConfig(
7+
viteConfig,
8+
defineConfig({
9+
test: {
10+
globals: true,
11+
environment: 'jsdom',
12+
},
13+
})
14+
)

0 commit comments

Comments
 (0)