Skip to content

Commit 60de511

Browse files
authored
Add testing lib (potty#1)
1 parent af7ddf1 commit 60de511

17 files changed

+3703
-61
lines changed

.eslintrc.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
module.exports = {
2+
env: {
3+
browser: true,
4+
es6: true,
5+
},
6+
extends: [
7+
'plugin:@typescript-eslint/recommended',
8+
'plugin:react/recommended',
9+
'plugin:react-hooks/recommended',
10+
'prettier',
11+
'prettier/@typescript-eslint',
12+
'prettier/react',
13+
],
14+
plugins: ['react', 'prettier', '@typescript-eslint'],
15+
globals: {
16+
Atomics: 'readonly',
17+
SharedArrayBuffer: 'readonly',
18+
},
19+
parser: '@typescript-eslint/parser',
20+
parserOptions: {
21+
ecmaFeatures: {
22+
jsx: true,
23+
},
24+
ecmaVersion: 2018,
25+
sourceType: 'module',
26+
},
27+
rules: {
28+
'@typescript-eslint/explicit-function-return-type': 'off',
29+
},
30+
settings: {
31+
react: {
32+
version: 'detect',
33+
},
34+
},
35+
}

.github/workflows/tests.yml

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
1-
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
2-
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions
3-
4-
name: Node.js CI
1+
name: Tests
52

63
on:
74
push:
@@ -11,19 +8,14 @@ on:
118

129
jobs:
1310
build:
14-
1511
runs-on: ubuntu-latest
16-
17-
strategy:
18-
matrix:
19-
node-version: [12.x]
20-
2112
steps:
2213
- uses: actions/checkout@v2
23-
- name: Use Node.js ${{ matrix.node-version }}
14+
- name: Use Node.js
2415
uses: actions/setup-node@v1
2516
with:
26-
node-version: ${{ matrix.node-version }}
27-
- run: npm ci
28-
- run: npm run build --if-present
29-
- run: npm run test
17+
node-version: '12.x'
18+
- name: Install dependencies
19+
run: yarn --frozen-lockfile
20+
- name: Run tests
21+
run: yarn test

.gitignore

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,16 @@
1+
# Logs
2+
logs
3+
*.log
4+
npm-debug.log*
5+
yarn-debug.log*
6+
yarn-error.log*
7+
8+
# Coverage directory used by tools like istanbul
9+
coverage
10+
11+
# Dependency directories
12+
node_modules/
13+
114
.idea
2-
node_modules
15+
/dist
16+
package-lock.json

.prettierrc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"semi": false,
3+
"useTabs": true,
4+
"trailingComma": "all",
5+
"singleQuote": true
6+
}

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
# react-anchorme
1+
# react-anchorme
2+
3+
![](https://github.com/actions/potty/react-anchorme/Tests/badge.svg)

jest.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module.exports = {
2+
preset: 'ts-jest',
3+
}

package.json

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,52 @@
11
{
22
"name": "react-anchorme",
3+
"description": "React component to detect links in text and make them clickable",
34
"version": "1.0.0",
4-
"main": "index.js",
5-
"author": "Pavel Potacek <[email protected]>",
5+
"main": "dist/react-anchorme.js",
6+
"module": "dist/react-anchorme.es.js",
7+
"types": "dist/types.d.ts",
8+
"author": "Pavel Potáček <[email protected]>",
69
"license": "MIT",
10+
"sideEffects": false,
11+
"scripts": {
12+
"build": "rollup -c",
13+
"lint": "yarn lint:check --fix && yarn prettier",
14+
"lint:check": "eslint ./src --ext .tsx,.ts --report-unused-disable-directives",
15+
"prettier": "prettier --write './src/**/*.ts' './src/**/*.tsx'",
16+
"test": "jest --runInBand",
17+
"test:coverage": "jest --runInBand --coverage",
18+
"test:watch": "yarn test:coverage -- --watchAll"
19+
},
720
"dependencies": {
821
"anchorme": "^2.0.0"
922
},
1023
"peerDependencies": {
1124
"react": "^16.8.0"
1225
},
1326
"devDependencies": {
27+
"@testing-library/jest-dom": "^5.5.0",
28+
"@testing-library/react": "^10.0.3",
29+
"@types/jest": "^25.2.1",
30+
"@types/react": "^16.9.34",
31+
"@types/react-dom": "^16.9.7",
32+
"@typescript-eslint/eslint-plugin": "^2.30.0",
33+
"@typescript-eslint/parser": "^2.30.0",
1434
"eslint": "^6.8.0",
35+
"eslint-config-prettier": "^6.11.0",
36+
"eslint-plugin-import": "^2.20.2",
37+
"eslint-plugin-prettier": "^3.1.3",
1538
"eslint-plugin-react": "^7.19.0",
1639
"eslint-plugin-react-hooks": "^3.0.0",
1740
"husky": "^4.2.5",
41+
"jest": "^25.5.1",
1842
"lint-staged": "^10.2.0",
1943
"prettier": "^2.0.5",
44+
"react": "^16.13.1",
45+
"react-dom": "^16.13.1",
46+
"rollup": "^2.7.5",
47+
"rollup-plugin-terser": "^5.3.0",
48+
"rollup-plugin-typescript2": "^0.27.0",
49+
"ts-jest": "^25.4.0",
2050
"typescript": "^3.8.3"
2151
},
2252
"husky": {

rollup.config.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import typescript from 'rollup-plugin-typescript2'
2+
import { terser } from 'rollup-plugin-terser'
3+
import pkg from './package.json'
4+
5+
export default {
6+
input: 'src/index.ts',
7+
output: [
8+
{
9+
file: pkg.module,
10+
format: 'es',
11+
},
12+
],
13+
external: ['react', 'anchorme'],
14+
plugins: [
15+
typescript({
16+
typescript: require('typescript'),
17+
}),
18+
terser(),
19+
],
20+
}

src/Anchorme.test.tsx

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import React from 'react'
2+
import { render } from '@testing-library/react'
3+
import '@testing-library/jest-dom/extend-expect'
4+
5+
import Anchorme from './Anchorme'
6+
7+
describe('Anchorme', () => {
8+
const URL = 'http://www.example.loc'
9+
const EMAIL = '[email protected]'
10+
const IP = '127.0.0.1'
11+
12+
const expectLink = (el: HTMLElement, url: string) => {
13+
const linkEl = el.querySelector('a')
14+
expect(linkEl).not.toBeNull()
15+
expect(linkEl?.href).toBe(url)
16+
}
17+
18+
it('should render link', () => {
19+
const { container, getByText } = render(<Anchorme>{URL}</Anchorme>)
20+
expectLink(container, `${URL}/`)
21+
expect(getByText(URL)).toBeInTheDocument()
22+
})
23+
24+
it('should render link and text', () => {
25+
const { container, getByText } = render(
26+
<Anchorme>{`foo ${URL} bar`}</Anchorme>,
27+
)
28+
expectLink(container, `${URL}/`)
29+
expect(getByText(/foo/i)).toBeInTheDocument()
30+
expect(getByText(URL)).toBeInTheDocument()
31+
expect(getByText(/bar/i)).toBeInTheDocument()
32+
})
33+
34+
it('should render only text', () => {
35+
const { container, getByText } = render(<Anchorme>foo bar</Anchorme>)
36+
const linkEl = container.querySelector('a')
37+
expect(linkEl).toBeNull()
38+
expect(getByText(/foo bar/i)).toBeInTheDocument()
39+
})
40+
41+
it('should render email as a link', () => {
42+
const { container, getByText } = render(<Anchorme>{EMAIL}</Anchorme>)
43+
expectLink(container, `mailto:${EMAIL}`)
44+
expect(getByText(EMAIL)).toBeInTheDocument()
45+
})
46+
47+
it('should render IP as a link', () => {
48+
const { container } = render(<Anchorme>{IP}</Anchorme>)
49+
expectLink(container, `http://${IP}/`)
50+
})
51+
52+
it('should render link with custom props', () => {
53+
const { container, getByText } = render(
54+
<Anchorme target="_blank" rel="noreferrer noopener">
55+
{URL}
56+
</Anchorme>,
57+
)
58+
const linkEl = container.querySelector('a')
59+
expect(linkEl).not.toBeNull()
60+
expect(linkEl?.href).toBe(`${URL}/`)
61+
expect(linkEl?.target).toBe('_blank')
62+
expect(linkEl?.rel).toBe('noreferrer noopener')
63+
expect(getByText(URL)).toBeInTheDocument()
64+
})
65+
})

src/Anchorme.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { useCallback, useMemo } from 'react'
2+
import anchorme from 'anchorme'
3+
4+
import { AnchorProps } from './types'
5+
import { Link } from './Link'
6+
7+
type Props = {
8+
children: string
9+
} & AnchorProps
10+
11+
const Anchorme = ({ children, ...rest }: Props) => {
12+
const text = children
13+
14+
const parse = useCallback(() => {
15+
const matches = anchorme.list(text)
16+
if (matches.length === 0) return text
17+
18+
const elements = []
19+
let lastIndex = 0
20+
matches.forEach((match, index) => {
21+
// Push text located before matched string
22+
if (match.start > lastIndex) {
23+
elements.push(text.substring(lastIndex, match.start))
24+
}
25+
26+
// Push Link component
27+
elements.push(<Link {...rest} key={index} href={match.string} />)
28+
29+
lastIndex = match.end
30+
})
31+
32+
// Push remaining text
33+
if (text.length > lastIndex) {
34+
elements.push(text.substring(lastIndex))
35+
}
36+
37+
return elements.length === 1 ? elements[0] : elements
38+
}, [text, rest])
39+
40+
const parsedText = useMemo(() => parse(), [parse])
41+
42+
return <>{parsedText}</>
43+
}
44+
45+
export default React.memo(Anchorme)

src/Link.tsx

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react'
2+
3+
import { AnchorProps } from './types'
4+
import { getProtocol } from './utils'
5+
6+
type Props = {
7+
href: string
8+
} & AnchorProps
9+
10+
export const Link = ({ href, ...rest }: Props) => {
11+
const protocol = getProtocol(href)
12+
return (
13+
<a {...rest} href={`${protocol}${href}`}>
14+
{href}
15+
</a>
16+
)
17+
}

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as Anchorme } from './Anchorme'

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import React from 'react'
2+
3+
export type AnchorProps = Omit<React.HTMLProps<HTMLAnchorElement>, 'href'>

src/utils.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { getProtocol } from './utils'
2+
3+
describe('getProtocol', () => {
4+
it('should return protocol for url', () => {
5+
const protocol = getProtocol('example.loc')
6+
expect(protocol).toBe('http://')
7+
})
8+
9+
it('should return protocol for email', () => {
10+
const protocol = getProtocol('[email protected]')
11+
expect(protocol).toBe('mailto:')
12+
})
13+
14+
it('should return protocol for IP', () => {
15+
const protocol = getProtocol('127.0.0.1')
16+
expect(protocol).toBe('http://')
17+
})
18+
})

src/utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import anchorme from 'anchorme'
2+
3+
const protocolRegex = /^((file:\/\/\/)|(https?:|ftps?:)\/\/|(mailto:))/i
4+
5+
const hasProtocol = (input: string) => protocolRegex.test(input)
6+
7+
export const getProtocol = (input: string) => {
8+
if (hasProtocol(input)) return ''
9+
10+
return anchorme.validate.email(input) ? 'mailto:' : 'http://'
11+
}

tsconfig.json

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"compilerOptions": {
33
"sourceMap": true,
4-
"module": "es2015",
4+
"module": "esnext",
55
"target": "es2017",
66
"moduleResolution": "node",
77
"outDir": "./dist",
@@ -10,12 +10,13 @@
1010
"declaration": true,
1111
"noEmit": true,
1212
"allowSyntheticDefaultImports": true,
13-
"lib": ["dom", "dom.iterable", "esnext"],
13+
"lib": ["dom", "esnext"],
1414
"strict": true,
1515
"noUnusedLocals": true,
1616
"noUnusedParameters": true,
1717
"noImplicitReturns": true,
18-
"noFallthroughCasesInSwitch": true
18+
"noFallthroughCasesInSwitch": true,
19+
"esModuleInterop": true
1920
},
2021
"include": ["src"],
2122
"exclude": ["node_modules"]

0 commit comments

Comments
 (0)