├── .nvmrc ├── .npmrc ├── codecov.yml ├── tests ├── .eslintrc └── index-test.js ├── renovate.json ├── docs ├── android.png ├── iphone.png ├── blackberry.png └── index.html ├── .gitignore ├── .github ├── QUESTION.md ├── FEATURE_REQUEST.md ├── FUNDING.yml ├── BUG_REPORT.md └── CONTRIBUTING.md ├── nwb.config.js ├── .editorconfig ├── .travis.yml ├── LICENSE ├── package.json ├── AUTHORS ├── Makefile ├── .all-contributorsrc ├── src ├── types │ └── index.d.ts └── index.js ├── .eslintrc.js ├── CODE_OF_CONDUCT.md └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 10 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | codecov: 2 | token: 8be73b98-657b-4d8c-9ad8-4e6080d1aeb4 -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | 'env': { 3 | 'mocha': true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /docs/android.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthewhudson/current-device/HEAD/docs/android.png -------------------------------------------------------------------------------- /docs/iphone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthewhudson/current-device/HEAD/docs/iphone.png -------------------------------------------------------------------------------- /docs/blackberry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/matthewhudson/current-device/HEAD/docs/blackberry.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /demo/dist 3 | /es 4 | /lib 5 | /node_modules 6 | /umd 7 | npm-debug.log* 8 | .DS_Store 9 | -------------------------------------------------------------------------------- /.github/QUESTION.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: ❓ Question 3 | about: If you have a question - just ask! 4 | labels: question 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /nwb.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'web-module', 3 | npm: { 4 | esModules: true, 5 | umd: { 6 | global: 'device', 7 | externals: {} 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | 4 | root = true 5 | 6 | [*] 7 | charset = utf-8 8 | end_of_line = lf 9 | indent_size = 2 10 | indent_style = space 11 | insert_final_newline = true 12 | trim_trailing_whitespace = true 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | 3 | language: node_js 4 | node_js: 5 | - 10 6 | 7 | before_install: 8 | - npm install codecov.io coveralls 9 | 10 | after_success: 11 | - cat ./coverage/lcov.info | ./node_modules/codecov.io/bin/codecov.io.js 12 | - cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js 13 | 14 | branches: 15 | only: 16 | - master 17 | - /^greenkeeper/.*$/ 18 | -------------------------------------------------------------------------------- /.github/FEATURE_REQUEST.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚀 Feature Suggestion 3 | about: Suggest an idea for this project 💡 4 | labels: "feature suggestion" 5 | --- 6 | 7 | **Provide a Use Case of the Feature** 8 | 9 | 10 | 11 | **Describe your Preferred Solution** 12 | 13 | 14 | 15 | **Additional Context** 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: hudsondev 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.paypal.me/thematthewhudson'] 13 | -------------------------------------------------------------------------------- /.github/BUG_REPORT.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🐛 Bug Report 3 | about: Create a report if you see a problem that should be fixed! 🤕 4 | labels: bug 5 | --- 6 | 7 | **Describe the Nug** 8 | 9 | 10 | 11 | **How To Reproduce** 12 | 13 | 14 | 15 | 16 | **Expected Behavior** 17 | 18 | 19 | 20 | **Error Messages and Tracebacks** 21 | 22 | 23 | 24 | **Additional Context** 25 | 26 | 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012-present Matthew Hudson 2 | 3 | Permission is hereby granted, free of charge, to any person 4 | obtaining a copy of this software and associated documentation 5 | files (the "Software"), to deal in the Software without 6 | restriction, including without limitation the rights to use, 7 | copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the 9 | Software is furnished to do so, subject to the following 10 | conditions: 11 | 12 | The above copyright notice and this permission notice shall be 13 | included in all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 16 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES 17 | OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 18 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT 19 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 20 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 21 | FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR 22 | OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "current-device", 3 | "version": "0.10.2", 4 | "homepage": "https://github.com/matthewhudson/current-device", 5 | "description": "The easiest way to write conditional CSS and/or JavaScript based on device operating system (iOS, Android, Blackberry, Windows, Firefox OS, MeeGo, AppleTV, etc), orientation (Portrait vs. Landscape), and type (Tablet vs. Mobile).", 6 | "main": "lib/index.js", 7 | "module": "es/index.js", 8 | "files": [ 9 | "es", 10 | "lib", 11 | "umd" 12 | ], 13 | "scripts": { 14 | "build": "nwb build-web-module && cp -R src/types ./lib", 15 | "test": "nwb test" 16 | }, 17 | "devDependencies": { 18 | "eslint": "8.32.0", 19 | "eslint-plugin-import": "2.23.4", 20 | "nwb": "0.23.0", 21 | "prettier": "2.7.1" 22 | }, 23 | "author": { 24 | "name": "Matthew Hudson", 25 | "email": "matthud@gmail.com", 26 | "url": "https://hudson.dev" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git://github.com/matthewhudson/current-device.git" 31 | }, 32 | "bugs": { 33 | "url": "https://github.com/matthewhudson/current-device/issues" 34 | }, 35 | "license": "MIT", 36 | "types": "lib/types/index.d.ts" 37 | } 38 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Ajith 2 | Andrey 3 | André Tarnowsky 4 | Athou 5 | Bohdan Zhuravel 6 | ckcecjtu <13730754@qq.com> 7 | Daniel Paul 8 | Evan Hahn 9 | Giorgio Cefaro 10 | greenkeeper[bot] 11 | Guilherme Simoes 12 | Jake 13 | Jonas Jonny 14 | JSteunou 15 | Kevin Kirsche 16 | Lauren Tan 17 | Markus Kaiserswerth 18 | Matthew Hudson 19 | Matthew Hudson 20 | matthewhudson 21 | Michael Chambaud 22 | Mike Taylor 23 | Mikhail 24 | Mustard Andrew 25 | NoobsArePeople2 26 | PatrickJS 27 | Philip Karpiak 28 | Rob Laucius 29 | sergeybochkarev 30 | The Gitter Badger 31 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PATH := node_modules/.bin:$(PATH) 2 | SHELL := /bin/bash 3 | 4 | lint: 5 | # Run ESLint guidelines against codebase 6 | eslint src/*.js 7 | 8 | clean: 9 | # Deletes node_modules, docs, and builds 10 | nwb clean-module 11 | rm -r docs 12 | 13 | build: 14 | # Generates builds for multiple target environments: 15 | # - A CommonJS build in lib/ 16 | # - An ES6 modules build in es/ 17 | # - UMD development and production builds in umd/ 18 | # ALso, copy index.d.ts to ./lib folder. 19 | nwb build-web-module && cp -R src/types ./lib 20 | 21 | authors: 22 | # Generate a file of all contributors based on git log 23 | git log --format='%aN <%aE>' | sort -f | uniq > AUTHORS 24 | 25 | publish: 26 | # Prepares and publishes the module to NPM 27 | # Bumps package.json version, git commits, and tags 28 | npm version $(filter-out $@,$(MAKECMDGOALS)) -m "Releasing v%s" 29 | git push origin master --follow-tags 30 | 31 | $(MAKE) build 32 | $(MAKE) test 33 | 34 | # Publishes to NPM 35 | npm publish 36 | 37 | test: 38 | # Runs test suite 39 | nwb test 40 | 41 | test-coverage: 42 | # Generates test coverage report 43 | nwb test --coverage 44 | open ./coverage/html/index.html 45 | 46 | test-watch: 47 | # Watches for file changes and re-runs test 48 | nwb test --server 49 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to `current-device` 2 | 3 | :+1::tada: First off, thanks for taking the time to contribute! :tada::+1: 4 | 5 | The following is a set of guidelines for contributing to `current-device`. These 6 | are mostly guidelines, not rules. Use your best judgment, and feel free to 7 | propose changes to this document in a Pull Request. 8 | 9 | ## Contributing Code 10 | 11 | 1. Pull the repo 12 | 2. Create a branch against `master` (`git checkout -b feature`) 13 | 3. Make change(s) 14 | 4. Commit your changes (`git commit -am 'Added a feature'`) 15 | 5. Push to the branch (`git push origin feature`) 16 | 6. Open a Pull Request 17 | 18 | ## Prerequisites 19 | 20 | [Node.js](http://nodejs.org/) >= v4 must be installed. 21 | 22 | ## Installation 23 | 24 | - Running `npm install` in the module's root directory will install everything 25 | you need for development. 26 | 27 | ## Running Tests 28 | 29 | - `npm test` will run the tests once. 30 | 31 | - `npm run test:coverage` will run the tests and produce a coverage report in 32 | `coverage/`. 33 | 34 | - `npm run test:watch` will run the tests on every change. 35 | 36 | ## Building 37 | 38 | - `npm run build` will build the module for publishing to npm. 39 | 40 | - `npm run clean` will delete built resources. 41 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "commit": false, 7 | "contributors": [ 8 | { 9 | "login": "matthewhudson", 10 | "name": "Matthew Hudson", 11 | "avatar_url": "https://avatars2.githubusercontent.com/u/320194?v=4", 12 | "profile": "http://hudson.dev", 13 | "contributions": [ 14 | "code", 15 | "maintenance" 16 | ] 17 | }, 18 | { 19 | "login": "RTeran", 20 | "name": "Rafael Terán", 21 | "avatar_url": "https://avatars3.githubusercontent.com/u/6477537?v=4", 22 | "profile": "http://rteran.com/", 23 | "contributions": [ 24 | "code" 25 | ] 26 | }, 27 | { 28 | "login": "winternet-studio", 29 | "name": "Allan", 30 | "avatar_url": "https://avatars1.githubusercontent.com/u/5200270?v=4", 31 | "profile": "https://github.com/winternet-studio", 32 | "contributions": [ 33 | "review" 34 | ] 35 | }, 36 | { 37 | "login": "martinwepner", 38 | "name": "martinwepner", 39 | "avatar_url": "https://avatars3.githubusercontent.com/u/12143284?v=4", 40 | "profile": "https://martin-wepner.de", 41 | "contributions": [ 42 | "code" 43 | ] 44 | } 45 | ], 46 | "contributorsPerLine": 7, 47 | "projectName": "current-device", 48 | "projectOwner": "matthewhudson", 49 | "repoType": "github", 50 | "repoHost": "https://github.com", 51 | "skipCi": true 52 | } 53 | -------------------------------------------------------------------------------- /src/types/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'current-device' { 2 | export interface CurrentDeviceInterface { 3 | mobile: () => boolean; 4 | tablet: () => boolean; 5 | desktop: () => boolean; 6 | ios: () => boolean; 7 | macos: () => boolean; 8 | ipad: () => boolean; 9 | iphone: () => boolean; 10 | ipod: () => boolean; 11 | android: () => boolean; 12 | androidPhone: () => boolean; 13 | androidTablet: () => boolean; 14 | blackberry: () => boolean; 15 | blackberryPhone: () => boolean; 16 | blackberryTablet: () => boolean; 17 | windows: () => boolean; 18 | windowsPhone: () => boolean; 19 | windowsTablet: () => boolean; 20 | fxos: () => boolean; 21 | fxosPhone: () => boolean; 22 | fxosTablet: () => boolean; 23 | meego: () => boolean; 24 | television: () => boolean; 25 | 26 | // Orientation 27 | landscape: () => boolean; 28 | portrait: () => boolean; 29 | onChangeOrientation: (cb: (newOrientation: DeviceOrientation) => void) => void; 30 | 31 | // Utility 32 | noConflict: (currentDevice: CurrentDeviceInterface) => void; 33 | 34 | // Properties 35 | type: DeviceType; 36 | orientation: DeviceOrientation; 37 | os: DeviceOs; 38 | } 39 | 40 | export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown'; 41 | export type DeviceOrientation = 'landscape' | 'portrait' | 'unknown'; 42 | export type DeviceOs = 43 | | 'ios' 44 | | 'macos' 45 | | 'iphone' 46 | | 'ipad' 47 | | 'ipod' 48 | | 'android' 49 | | 'blackberry' 50 | | 'windows' 51 | | 'fxos' 52 | | 'meego' 53 | | 'television' 54 | | 'unknown'; 55 | 56 | let instance: CurrentDeviceInterface; 57 | export default instance; 58 | } 59 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:import/errors'], 3 | plugins: ['import'], 4 | env: { 5 | es6: true, 6 | node: true, 7 | browser: true 8 | }, 9 | parserOptions: { 10 | ecmaVersion: 6, 11 | sourceType: 'module', 12 | ecmaFeatures: { 13 | impliedStrict: true, 14 | objectLiteralDuplicateProperties: false 15 | } 16 | }, 17 | rules: { 18 | 'array-bracket-spacing': ['error', 'never'], 19 | 20 | camelcase: [ 21 | 'error', 22 | { 23 | properties: 'never' 24 | } 25 | ], 26 | 27 | 'comma-dangle': ['error', 'never'], 28 | 29 | curly: ['error', 'all'], 30 | 31 | 'eol-last': ['error'], 32 | 33 | indent: [ 34 | 'error', 35 | 2, 36 | { 37 | SwitchCase: 1 38 | } 39 | ], 40 | 41 | 'keyword-spacing': ['error'], 42 | 43 | 'max-len': [ 44 | 'error', 45 | { 46 | code: 80 47 | } 48 | ], 49 | 50 | 'no-else-return': ['error'], 51 | 52 | 'no-mixed-spaces-and-tabs': ['error'], 53 | 54 | 'no-multiple-empty-lines': ['error'], 55 | 56 | 'no-spaced-func': ['error'], 57 | 58 | 'no-trailing-spaces': ['error'], 59 | 60 | 'no-undef': ['error'], 61 | 62 | 'no-unexpected-multiline': ['error'], 63 | 64 | 'no-unused-vars': [ 65 | 'error', 66 | { 67 | args: 'none', 68 | vars: 'all' 69 | } 70 | ], 71 | 72 | quotes: [ 73 | 'error', 74 | 'single', 75 | { 76 | allowTemplateLiterals: true, 77 | avoidEscape: true 78 | } 79 | ], 80 | 81 | semi: ['error', 'never'], 82 | 83 | 'space-before-blocks': ['error', 'always'], 84 | 85 | 'space-before-function-paren': ['error', 'never'], 86 | 87 | 'space-in-parens': ['error', 'never'], 88 | 89 | 'space-unary-ops': [ 90 | 'error', 91 | { 92 | nonwords: false, 93 | overrides: {} 94 | } 95 | ], 96 | 97 | // 'valid-jsdoc': ['error'] 98 | 99 | // ECMAScript 6 rules 100 | 101 | 'arrow-body-style': [ 102 | 'error', 103 | 'as-needed', 104 | { 105 | requireReturnForObjectLiteral: false 106 | } 107 | ], 108 | 109 | 'arrow-parens': ['error', 'always'], 110 | 111 | 'arrow-spacing': [ 112 | 'error', 113 | { 114 | after: true, 115 | before: true 116 | } 117 | ], 118 | 119 | 'no-class-assign': ['error'], 120 | 121 | 'no-const-assign': ['error'], 122 | 123 | 'no-dupe-class-members': ['error'], 124 | 125 | 'no-duplicate-imports': ['error'], 126 | 127 | 'no-new-symbol': ['error'], 128 | 129 | 'no-useless-rename': ['error'], 130 | 131 | 'no-var': ['error'], 132 | 133 | 'object-shorthand': [ 134 | 'error', 135 | 'always', 136 | { 137 | avoidQuotes: true, 138 | ignoreConstructors: false 139 | } 140 | ], 141 | 142 | 'prefer-arrow-callback': [ 143 | 'error', 144 | { 145 | allowNamedFunctions: false, 146 | allowUnboundThis: true 147 | } 148 | ], 149 | 150 | 'prefer-const': ['error'], 151 | 152 | 'prefer-rest-params': ['error'], 153 | 154 | 'prefer-template': ['error'], 155 | 156 | 'template-curly-spacing': ['error', 'never'] 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at matthud@gmail.com. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /tests/index-test.js: -------------------------------------------------------------------------------- 1 | import expect from 'expect' 2 | import device from 'src' 3 | 4 | describe('current-device', () => { 5 | it('Exports an `object`', () => { 6 | expect(device).toBeA('object') 7 | }) 8 | 9 | describe('Exposes attributes for os, type, and orientation', () => { 10 | it('Exposes `os` string', () => { 11 | expect(device.os).toBeA('string') 12 | }) 13 | it('Exposes `type` string', () => { 14 | expect(device.type).toBeA('string') 15 | }) 16 | it('Exposes `orientation` string', () => { 17 | expect(device.orientation).toBeA('string') 18 | }) 19 | }) 20 | 21 | describe('Exposes functions for detecting device `os`', () => { 22 | describe('Apple (iOS, macOS)', () => { 23 | it('Exposes a `macos` function', () => { 24 | expect(device.macos).toBeA('function') 25 | }) 26 | it('Exposes a `ios` function', () => { 27 | expect(device.ios).toBeA('function') 28 | }) 29 | it('Exposes a `iphone` function', () => { 30 | expect(device.iphone).toBeA('function') 31 | }) 32 | it('Exposes a `ipad` function', () => { 33 | expect(device.ipad).toBeA('function') 34 | }) 35 | it('Exposes a `ipod` function', () => { 36 | expect(device.ipod).toBeA('function') 37 | }) 38 | }) 39 | 40 | describe('Android', () => { 41 | it('Exposes a `android` function', () => { 42 | expect(device.android).toBeA('function') 43 | }) 44 | it('Exposes a `androidPhone` function', () => { 45 | expect(device.androidPhone).toBeA('function') 46 | }) 47 | it('Exposes a `androidTablet` function', () => { 48 | expect(device.androidTablet).toBeA('function') 49 | }) 50 | }) 51 | 52 | describe('Blackberry', () => { 53 | it('Exposes a `blackberry` function', () => { 54 | expect(device.blackberry).toBeA('function') 55 | }) 56 | it('Exposes a `blackberryPhone` function', () => { 57 | expect(device.blackberryPhone).toBeA('function') 58 | }) 59 | it('Exposes a `blackberryTablet` function', () => { 60 | expect(device.blackberryTablet).toBeA('function') 61 | }) 62 | }) 63 | 64 | describe('Windows', () => { 65 | it('Exposes a `windows` function', () => { 66 | expect(device.windows).toBeA('function') 67 | }) 68 | it('Exposes a `windowsPhone` function', () => { 69 | expect(device.windowsPhone).toBeA('function') 70 | }) 71 | it('Exposes a `windowsTablet` function', () => { 72 | expect(device.windowsTablet).toBeA('function') 73 | }) 74 | }) 75 | 76 | describe('Firefox OS', () => { 77 | it('Exposes a `fxos` function', () => { 78 | expect(device.fxos).toBeA('function') 79 | }) 80 | it('Exposes a `fxosPhone` function', () => { 81 | expect(device.fxosPhone).toBeA('function') 82 | }) 83 | it('Exposes a `fxosTablet` function', () => { 84 | expect(device.fxosTablet).toBeA('function') 85 | }) 86 | }) 87 | 88 | describe('Other', () => { 89 | it('Exposes a `meego` function', () => { 90 | expect(device.meego).toBeA('function') 91 | }) 92 | it('Exposes a `cordova` function', () => { 93 | expect(device.cordova).toBeA('function') 94 | }) 95 | it('Exposes a `nodeWebkit` function', () => { 96 | expect(device.nodeWebkit).toBeA('function') 97 | }) 98 | }) 99 | }) 100 | 101 | describe('Exposes functions for detecting device `type`', () => { 102 | it('Exposes a `desktop` function', () => { 103 | expect(device.desktop).toBeA('function') 104 | }) 105 | it('Exposes a `tablet` function', () => { 106 | expect(device.tablet).toBeA('function') 107 | }) 108 | it('Exposes a `mobile` function', () => { 109 | expect(device.mobile).toBeA('function') 110 | }) 111 | it('Exposes a `television` function', () => { 112 | expect(device.television).toBeA('function') 113 | }) 114 | }) 115 | 116 | describe('Exposes functions for detecting device `orientation`', () => { 117 | it('Exposes a `portrait` function', () => { 118 | expect(device.portrait).toBeA('function') 119 | }) 120 | it('Exposes a `landscape` function', () => { 121 | expect(device.landscape).toBeA('function') 122 | }) 123 | }) 124 | 125 | describe('Exposes helper functions', () => { 126 | it('Exposes a `noConflict` function', () => { 127 | expect(device.noConflict).toBeA('function') 128 | }) 129 | it('Restores the previous value of the `device` global object when `noConflict` is called', () => { 130 | const originalDevice = window.device; 131 | const deviceInstance = device.noConflict(); 132 | expect(window.device).toEqual(originalDevice); 133 | expect(deviceInstance).toEqual(device); 134 | }); 135 | it('Exposes a `onChangeOrientation` function', () => { 136 | expect(device.onChangeOrientation).toBeA('function') 137 | }) 138 | it('Calls the provided callback when orientation changes using `onChangeOrientation`', (done) => { 139 | const callback = (newOrientation) => { 140 | expect(newOrientation).toBeA('string'); 141 | done(); 142 | }; 143 | device.onChangeOrientation(callback); 144 | }); 145 | }); 146 | 147 | describe('HTML Element Handling', () => { 148 | it('Adds the correct CSS classes to the element based on the user agent', () => { 149 | const classNames = document.documentElement.className.split(' '); 150 | 151 | if (device.os !== 'unknown') { 152 | expect(classNames).toContain(device.os); 153 | } 154 | if (device.type !== 'unknown') { 155 | expect(classNames).toContain(device.type); 156 | } 157 | if (device.orientation !== 'unknown') { 158 | expect(classNames).toContain(device.orientation); 159 | } 160 | }); 161 | }); 162 | }) 163 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | current-device 6 | 138 | 139 | 140 | 141 |
142 |
143 |
144 |
145 |

Device Type

146 | .mobile 147 | .tablet 148 | .desktop 149 | 150 | 151 |

Orientation

152 | .portrait 153 | .landscape 154 | 155 |

Device OS

156 | .ios 157 | .iphone 158 | .ipad 159 | .ipod 160 | .android 161 | .blackberry 162 | .macos 163 | .windows 164 | .fxos 165 | .meego 166 | .television 167 |
168 |
174 |
175 |
176 | 177 | 178 | 179 | 200 | 201 | 202 | 210 | 211 | 212 | 213 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Save the previous value of the device variable. 2 | const previousDevice = window.device 3 | 4 | const device = {} 5 | 6 | const changeOrientationList = [] 7 | 8 | // Add device as a global object. 9 | window.device = device 10 | 11 | // The element. 12 | const documentElement = window.document.documentElement 13 | 14 | // The client user agent string. 15 | // Lowercase, so we can use the more efficient indexOf(), instead of Regex 16 | const userAgent = window.navigator.userAgent.toLowerCase() 17 | 18 | // Detectable television devices. 19 | const television = [ 20 | 'googletv', 21 | 'viera', 22 | 'smarttv', 23 | 'internet.tv', 24 | 'netcast', 25 | 'nettv', 26 | 'appletv', 27 | 'boxee', 28 | 'kylo', 29 | 'roku', 30 | 'dlnadoc', 31 | 'pov_tv', 32 | 'hbbtv', 33 | 'ce-html' 34 | ] 35 | 36 | // Main functions 37 | // -------------- 38 | 39 | device.macos = function() { 40 | return find('mac') 41 | } 42 | 43 | device.ios = function() { 44 | return device.iphone() || device.ipod() || device.ipad() 45 | } 46 | 47 | device.iphone = function() { 48 | return !device.windows() && find('iphone') 49 | } 50 | 51 | device.ipod = function() { 52 | return find('ipod') 53 | } 54 | 55 | device.ipad = function() { 56 | const iPadOS13Up = 57 | navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1 58 | return find('ipad') || iPadOS13Up 59 | }; 60 | 61 | device.android = function() { 62 | return !device.windows() && find('android') 63 | } 64 | 65 | device.androidPhone = function() { 66 | return device.android() && find('mobile') 67 | } 68 | 69 | device.androidTablet = function() { 70 | return device.android() && !find('mobile') 71 | } 72 | 73 | device.blackberry = function() { 74 | return find('blackberry') || find('bb10') 75 | } 76 | 77 | device.blackberryPhone = function() { 78 | return device.blackberry() && !find('tablet') 79 | } 80 | 81 | device.blackberryTablet = function() { 82 | return device.blackberry() && find('tablet') 83 | } 84 | 85 | device.windows = function() { 86 | return find('windows') 87 | } 88 | 89 | device.windowsPhone = function() { 90 | return device.windows() && find('phone') 91 | } 92 | 93 | device.windowsTablet = function() { 94 | return device.windows() && (find('touch') && !device.windowsPhone()) 95 | } 96 | 97 | device.fxos = function() { 98 | return (find('(mobile') || find('(tablet')) && find(' rv:') 99 | } 100 | 101 | device.fxosPhone = function() { 102 | return device.fxos() && find('mobile') 103 | } 104 | 105 | device.fxosTablet = function() { 106 | return device.fxos() && find('tablet') 107 | } 108 | 109 | device.meego = function() { 110 | return find('meego') 111 | } 112 | 113 | device.cordova = function() { 114 | return window.cordova && location.protocol === 'file:' 115 | } 116 | 117 | device.nodeWebkit = function() { 118 | return typeof window.process === 'object' 119 | } 120 | 121 | device.mobile = function() { 122 | return ( 123 | device.androidPhone() || 124 | device.iphone() || 125 | device.ipod() || 126 | device.windowsPhone() || 127 | device.blackberryPhone() || 128 | device.fxosPhone() || 129 | device.meego() 130 | ) 131 | } 132 | 133 | device.tablet = function() { 134 | return ( 135 | device.ipad() || 136 | device.androidTablet() || 137 | device.blackberryTablet() || 138 | device.windowsTablet() || 139 | device.fxosTablet() 140 | ) 141 | } 142 | 143 | device.desktop = function() { 144 | return !device.tablet() && !device.mobile() 145 | } 146 | 147 | device.television = function() { 148 | let i = 0 149 | while (i < television.length) { 150 | if (find(television[i])) { 151 | return true 152 | } 153 | i++ 154 | } 155 | return false 156 | } 157 | 158 | device.portrait = function() { 159 | if ( 160 | screen.orientation && 161 | Object.prototype.hasOwnProperty.call(window, 'onorientationchange') 162 | ) { 163 | return includes(screen.orientation.type, 'portrait') 164 | } 165 | if ( 166 | device.ios() && 167 | Object.prototype.hasOwnProperty.call(window, 'orientation') 168 | ) { 169 | return Math.abs(window.orientation) !== 90 170 | } 171 | return window.innerHeight / window.innerWidth > 1 172 | } 173 | 174 | device.landscape = function() { 175 | if ( 176 | screen.orientation && 177 | Object.prototype.hasOwnProperty.call(window, 'onorientationchange') 178 | ) { 179 | return includes(screen.orientation.type, 'landscape') 180 | } 181 | if ( 182 | device.ios() && 183 | Object.prototype.hasOwnProperty.call(window, 'orientation') 184 | ) { 185 | return Math.abs(window.orientation) === 90 186 | } 187 | return window.innerHeight / window.innerWidth < 1 188 | } 189 | 190 | // Public Utility Functions 191 | // ------------------------ 192 | 193 | // Run device.js in noConflict mode, 194 | // returning the device variable to its previous owner. 195 | device.noConflict = function() { 196 | window.device = previousDevice 197 | return this 198 | } 199 | 200 | // Private Utility Functions 201 | // ------------------------- 202 | 203 | // Check if element exists 204 | function includes(haystack, needle) { 205 | return haystack.indexOf(needle) !== -1 206 | } 207 | 208 | // Simple UA string search 209 | function find(needle) { 210 | return includes(userAgent, needle) 211 | } 212 | 213 | // Check if documentElement already has a given class. 214 | function hasClass(className) { 215 | return documentElement.className.match(new RegExp(className, 'i')) 216 | } 217 | 218 | // Add one or more CSS classes to the element. 219 | function addClass(className) { 220 | let currentClassNames = null 221 | if (!hasClass(className)) { 222 | currentClassNames = documentElement.className.replace(/^\s+|\s+$/g, '') 223 | documentElement.className = `${currentClassNames} ${className}` 224 | } 225 | } 226 | 227 | // Remove single CSS class from the element. 228 | function removeClass(className) { 229 | if (hasClass(className)) { 230 | documentElement.className = documentElement.className.replace( 231 | ` ${className}`, 232 | '' 233 | ) 234 | } 235 | } 236 | 237 | // HTML Element Handling 238 | // --------------------- 239 | 240 | // Insert the appropriate CSS class based on the _user_agent. 241 | 242 | if (device.ios()) { 243 | if (device.ipad()) { 244 | addClass('ios ipad tablet') 245 | } else if (device.iphone()) { 246 | addClass('ios iphone mobile') 247 | } else if (device.ipod()) { 248 | addClass('ios ipod mobile') 249 | } 250 | } else if (device.macos()) { 251 | addClass('macos desktop') 252 | } else if (device.android()) { 253 | if (device.androidTablet()) { 254 | addClass('android tablet') 255 | } else { 256 | addClass('android mobile') 257 | } 258 | } else if (device.blackberry()) { 259 | if (device.blackberryTablet()) { 260 | addClass('blackberry tablet') 261 | } else { 262 | addClass('blackberry mobile') 263 | } 264 | } else if (device.windows()) { 265 | if (device.windowsTablet()) { 266 | addClass('windows tablet') 267 | } else if (device.windowsPhone()) { 268 | addClass('windows mobile') 269 | } else { 270 | addClass('windows desktop') 271 | } 272 | } else if (device.fxos()) { 273 | if (device.fxosTablet()) { 274 | addClass('fxos tablet') 275 | } else { 276 | addClass('fxos mobile') 277 | } 278 | } else if (device.meego()) { 279 | addClass('meego mobile') 280 | } else if (device.nodeWebkit()) { 281 | addClass('node-webkit') 282 | } else if (device.television()) { 283 | addClass('television') 284 | } else if (device.desktop()) { 285 | addClass('desktop') 286 | } 287 | 288 | if (device.cordova()) { 289 | addClass('cordova') 290 | } 291 | 292 | // Orientation Handling 293 | // -------------------- 294 | 295 | // Handle device orientation changes. 296 | function handleOrientation() { 297 | if (device.landscape()) { 298 | removeClass('portrait') 299 | addClass('landscape') 300 | walkOnChangeOrientationList('landscape') 301 | } else { 302 | removeClass('landscape') 303 | addClass('portrait') 304 | walkOnChangeOrientationList('portrait') 305 | } 306 | setOrientationCache() 307 | } 308 | 309 | function walkOnChangeOrientationList(newOrientation) { 310 | for (let index = 0; index < changeOrientationList.length; index++) { 311 | changeOrientationList[index](newOrientation) 312 | } 313 | } 314 | 315 | device.onChangeOrientation = function(cb) { 316 | if (typeof cb == 'function') { 317 | changeOrientationList.push(cb) 318 | } 319 | } 320 | 321 | // Detect whether device supports orientationchange event, 322 | // otherwise fall back to the resize event. 323 | let orientationEvent = 'resize' 324 | if (Object.prototype.hasOwnProperty.call(window, 'onorientationchange')) { 325 | orientationEvent = 'orientationchange' 326 | } 327 | 328 | // Listen for changes in orientation. 329 | if (window.addEventListener) { 330 | window.addEventListener(orientationEvent, handleOrientation, false) 331 | } else if (window.attachEvent) { 332 | window.attachEvent(orientationEvent, handleOrientation) 333 | } else { 334 | window[orientationEvent] = handleOrientation 335 | } 336 | 337 | handleOrientation() 338 | 339 | // Public functions to get the current value of type, os, or orientation 340 | // --------------------------------------------------------------------- 341 | 342 | function findMatch(arr) { 343 | for (let i = 0; i < arr.length; i++) { 344 | if (device[arr[i]]()) { 345 | return arr[i] 346 | } 347 | } 348 | return 'unknown' 349 | } 350 | 351 | device.type = findMatch(['mobile', 'tablet', 'desktop']) 352 | device.os = findMatch([ 353 | 'ios', 354 | 'iphone', 355 | 'ipad', 356 | 'ipod', 357 | 'android', 358 | 'blackberry', 359 | 'macos', 360 | 'windows', 361 | 'fxos', 362 | 'meego', 363 | 'television' 364 | ]) 365 | 366 | function setOrientationCache() { 367 | device.orientation = findMatch(['portrait', 'landscape']) 368 | } 369 | 370 | setOrientationCache() 371 | 372 | export default device 373 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [CURRENT-DEVICE](https://matthewhudson.github.io/current-device/) 2 | 3 | [![All Contributors](https://img.shields.io/badge/all_contributors-2-orange.svg?style=flat-square)](#contributors) 4 | [![Build Status](https://travis-ci.com/matthewhudson/current-device.svg?branch=master)](https://www.travis-ci.com/matthewhudson/current-device) 5 | [![Bundle size](https://badgen.net/bundlephobia/minzip/current-device)](https://bundlephobia.com/result?p=current-device@0.8.2) 6 | [![Coverage Status](https://codecov.io/gh/matthewhudson/current-device/branch/master/graph/badge.svg?token=88TRoAbpd7)](https://codecov.io/gh/matthewhudson/current-device) 7 | [![NPM version](https://badge.fury.io/js/current-device.svg)](http://badge.fury.io/js/current-device) 8 | [![NPM downloads](https://img.shields.io/npm/dm/current-device.svg)](https://www.npmjs.com/package/current-device) 9 | 10 | This module makes it easy to write conditional CSS _and/or_ JavaScript based on 11 | device operating system (iOS, Android, Blackberry, Windows, macOS, Firefox OS, MeeGo, 12 | AppleTV, etc), orientation (Portrait vs. Landscape), and type (Tablet vs. 13 | Mobile). 14 | 15 | [View the Demo →](https://matthewhudson.github.io/current-device/) 16 | 17 | ### EXAMPLES 18 | 19 | This module inserts CSS classes into the `` element. 20 | 21 | #### iPhone 22 | 23 | 24 | 25 | #### Android Tablet 26 | 27 | 28 | 29 | #### Blackberry Tablet 30 | 31 | 32 | 33 | ### DEVICE SUPPORT 34 | 35 | - iOS: iPhone, iPod, iPad 36 | - macOS 37 | - Android: Phones & Tablets 38 | - Blackberry: Phones & Tablets 39 | - Windows: Phones, Tablets, Desktops 40 | - Firefox OS: Phones & Tablets 41 | 42 | ### USAGE 43 | 44 | Just include the script. The script then updates the `` section with the 45 | [appropriate classes](https://github.com/matthewhudson/current-device#conditional-css) 46 | based on the device's characteristics. 47 | 48 | ## Installation 49 | 50 | ```sh 51 | npm install current-device 52 | ``` 53 | 54 | And then import it: 55 | 56 | ```js 57 | // using es modules 58 | import device from "current-device"; 59 | 60 | // common.js 61 | const device = require("current-device").default; 62 | ``` 63 | 64 | Or use script tags and globals. 65 | 66 | ```html 67 | 68 | ``` 69 | 70 | And then access it off the global like so: 71 | 72 | ```js 73 | console.log("device.mobile() === %s", device.mobile()); 74 | ``` 75 | 76 | ### CONDITIONAL CSS 77 | 78 | The following tables map which CSS classes are added based on device and 79 | orientation. 80 | 81 | #### Device CSS Class Names 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 |
DeviceCSS Classes
iPadios ipad tablet
iPhoneios iphone mobile
iPodios ipod mobile
Macmacos desktop
Android Phoneandroid mobile
Android Tabletandroid tablet
BlackBerry Phoneblackberry mobile
BlackBerry Tabletblackberry tablet
Windows Phonewindows mobile
Windows Tabletwindows tablet
Windows Desktopwindows desktop
Firefox OS Phonefxos mobile
Firefox OS Tabletfxos tablet
MeeGomeego
Desktopdesktop
Televisiontelevision
153 | 154 | #### Orientation CSS Class Names 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 |
OrientationCSS Classes
Landscapelandscape
Portraitportrait
170 | 171 | ### CONDITIONAL JAVASCRIPT 172 | 173 | This module _also_ includes support for conditional JavaScript, allowing you to 174 | write checks on the following device characteristics: 175 | 176 | #### Device JavaScript Methods 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 |
DeviceJavaScript Method
Mobiledevice.mobile()
Tabletdevice.tablet()
Desktopdevice.desktop()
iOSdevice.ios()
iPaddevice.ipad()
iPhonedevice.iphone()
iPoddevice.ipod()
Macdevice.macos()
Androiddevice.android()
Android Phonedevice.androidPhone()
Android Tabletdevice.androidTablet()
BlackBerrydevice.blackberry()
BlackBerry Phonedevice.blackberryPhone()
BlackBerry Tabletdevice.blackberryTablet()
Windowsdevice.windows()
Windows Phonedevice.windowsPhone()
Windows Tabletdevice.windowsTablet()
Firefox OSdevice.fxos()
Firefox OS Phonedevice.fxosPhone()
Firefox OS Tabletdevice.fxosTablet()
MeeGodevice.meego()
Televisiondevice.television()
272 | 273 | #### Orientation JavaScript Methods 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 |
OrientationJavaScript Method
Landscapedevice.landscape()
Portraitdevice.portrait()
289 | 290 | #### Orientation JavaScript Callback 291 | 292 | ```js 293 | device.onChangeOrientation(newOrientation => { 294 | console.log(`New orientation is ${newOrientation}`); 295 | }); 296 | ``` 297 | 298 | ### Utility Methods 299 | 300 | #### device.noConflict() 301 | 302 | Run `current-device` in noConflict mode, returning the device variable to its 303 | previous owner. Returns a reference to the `device` object. 304 | 305 | ```js 306 | const currentDevice = device.noConflict(); 307 | ``` 308 | 309 | ### Useful Properties 310 | 311 | Access these properties on the `device` object to get the first match on that 312 | attribute without looping through all of its getter methods. 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 |
JS PropertyReturns
device.type'mobile', 'tablet', 'desktop', or 'unknown'
device.orientation'landscape', 'portrait', or 'unknown'
device.os'ios', 'iphone', 'ipad', 'ipod', 'android', 'blackberry', 'windows', 'macos', 'fxos', 'meego', 'television', or 'unknown'
332 | 333 | ### BEST PRACTICES 334 | 335 | Environment detection has a high rate of misuse. Often times, folks will attempt 336 | to work around browser feature support problems by checking for the affected 337 | browser and doing something different in response. The preferred solution for 338 | those kinds of problems, of course, is to check for the feature, not the browser 339 | (ala [Modernizr](http://modernizr.com/)). 340 | 341 | However, that common misuse of device detection doesn't mean it should never be 342 | done. For example, `current-device` could be employed to change the interface of 343 | your web app such that it uses interaction patterns and UI elements common to 344 | the device it's being presented on. Android devices might get a slightly 345 | different treatment than Windows or iOS, for instance. Another valid use-case is 346 | guiding users to different app stores depending on the device they're using. 347 | 348 | In short, check for features when you need features, and check for the browser 349 | when you need the browser. 350 | 351 | ## Contributors ✨ 352 | 353 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 |

Matthew Hudson

💻 🚧

Rafael Terán

💻

Allan

👀

martinwepner

💻
366 | 367 | 368 | 369 | 370 | 371 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 372 | --------------------------------------------------------------------------------