├── .circleci └── config.yml ├── .editorconfig ├── .env.example ├── .eslintrc.js ├── .gitignore ├── .idea ├── .gitignore ├── codeStyles │ ├── Project.xml │ └── codeStyleConfig.xml ├── inspectionProfiles │ └── profiles_settings.xml ├── misc.xml ├── modules.xml ├── neh.iml └── vcs.xml ├── .ignore ├── .prettierrc.js ├── CODE_OF_CONDUCT.md ├── LICENSE_MIT ├── README.md ├── jest.config.js ├── package.json ├── postcss.config.js ├── screenshots └── screencast.gif ├── setupJest.ts ├── src ├── Handler.test.ts ├── Handler.ts ├── SearchEngineHandler.test.ts ├── SearchEngineHandler.ts ├── __snapshots__ │ └── index.test.ts.snap ├── handlers │ ├── __snapshots__ │ │ └── gh.test.ts.snap │ ├── dict.ts │ ├── docs.ts │ ├── gh.test.ts │ ├── gh.ts │ ├── gl.ts │ ├── ibank.ts │ ├── index.test.ts │ ├── index.ts │ ├── iron │ │ ├── gh.test.ts │ │ ├── gh.ts │ │ ├── index.ts │ │ ├── jira.test.ts │ │ └── jira.ts │ ├── list │ │ ├── index.ts │ │ └── template.pug │ ├── npm.ts │ ├── nus │ │ ├── __mocks__ │ │ │ └── modules.json │ │ ├── __snapshots__ │ │ │ └── index.test.ts.snap │ │ ├── index.test.ts │ │ ├── index.ts │ │ ├── modules.json │ │ ├── nus.test.ts │ │ └── nus.ts │ ├── rd.ts │ └── tw.ts ├── index.test.ts ├── index.ts ├── resources │ ├── _opensearch.xml │ └── tailwind.css ├── typings.d.ts ├── util.test.ts └── util.ts ├── tailwind.config.js ├── tsconfig.json ├── webpack.config.js ├── wrangler.toml └── yarn.lock /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | aliases: 4 | - &docker 5 | - image: circleci/node:12 6 | 7 | - &restore_yarn_cache 8 | restore_cache: 9 | name: Restore yarn cache 10 | key: v2-node-{{ arch }}-{{ checksum "yarn.lock" }}-yarn 11 | 12 | - &restore_node_modules 13 | restore_cache: 14 | name: Restore node_modules cache 15 | key: v2-node-{{ arch }}-{{ .Branch }}-{{ checksum "yarn.lock" }}-node-modules 16 | 17 | jobs: 18 | build: 19 | working_directory: ~/neh 20 | docker: *docker 21 | steps: 22 | - checkout 23 | - *restore_yarn_cache 24 | - *restore_node_modules 25 | - run: 26 | name: Install dependencies 27 | command: yarn --frozen-lockfile --cache-folder ~/.cache/yarn --non-interactive 28 | - save_cache: 29 | # Store the yarn cache globally for all lock files with this same 30 | # checksum. This will speed up the setup job for all PRs where the 31 | # lockfile is the same. 32 | name: Save yarn cache for future installs 33 | key: v2-node-{{ arch }}-{{ checksum "yarn.lock" }}-yarn 34 | paths: 35 | - ~/.cache/yarn 36 | - save_cache: 37 | # Store node_modules for all jobs in this workflow so that they don't 38 | # need to each run a yarn install for each job. This will speed up 39 | # all jobs run on this branch with the same lockfile. 40 | name: Save node_modules cache 41 | key: v2-node-{{ arch }}-{{ .Branch }}-{{ checksum "yarn.lock" }}-node-modules 42 | paths: 43 | - node_modules 44 | - run: 45 | name: Typecheck Code 46 | command: yarn typecheck 47 | - run: 48 | name: Run Linters 49 | command: yarn lint 50 | - run: 51 | name: Test code 52 | environment: 53 | NODE_ENV: test 54 | command: | 55 | set -e 56 | yarn test 57 | yarn codecov --disable=gcov -f ./coverage/coverage-final.json 58 | - store_test_results: 59 | path: test-reports 60 | 61 | deploy: 62 | working_directory: ~/neh 63 | docker: *docker 64 | steps: 65 | - checkout 66 | - *restore_yarn_cache 67 | # `yarn install` run necessary so that wrangler is installed correctly 68 | - run: 69 | name: Install dependencies 70 | command: yarn --frozen-lockfile --cache-folder ~/.cache/yarn --non-interactive 71 | - run: yarn run publish 72 | 73 | workflows: 74 | version: 2 75 | build-and-deploy: 76 | jobs: 77 | - build 78 | - deploy: 79 | requires: 80 | - build 81 | filters: 82 | branches: 83 | only: main 84 | context: neh-cf-deploy 85 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 2 8 | 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | CF_ZONE_ID= 2 | CF_ACCOUNT_ID= 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint', 'prettier'], 4 | extends: ['plugin:@typescript-eslint/recommended', 'prettier'], 5 | rules: { 6 | 'prettier/prettier': 'error', 7 | }, 8 | ignorePatterns: ['node_modules/', 'dist/', 'worker/'], 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | worker 2 | .cargo-ok 3 | test-reports 4 | 5 | # Created by https://www.gitignore.io/api/vim,node,linux,macos,pycharm,windows 6 | # Edit at https://www.gitignore.io/?templates=vim,node,linux,macos,pycharm,windows 7 | 8 | ### Linux ### 9 | *~ 10 | 11 | # temporary files which can be created if a process still has a handle open of a deleted file 12 | .fuse_hidden* 13 | 14 | # KDE directory preferences 15 | .directory 16 | 17 | # Linux trash folder which might appear on any partition or disk 18 | .Trash-* 19 | 20 | # .nfs files are created when an open file is removed but is still being accessed 21 | .nfs* 22 | 23 | ### macOS ### 24 | # General 25 | .DS_Store 26 | .AppleDouble 27 | .LSOverride 28 | 29 | # Icon must end with two \r 30 | Icon 31 | 32 | # Thumbnails 33 | ._* 34 | 35 | # Files that might appear in the root of a volume 36 | .DocumentRevisions-V100 37 | .fseventsd 38 | .Spotlight-V100 39 | .TemporaryItems 40 | .Trashes 41 | .VolumeIcon.icns 42 | .com.apple.timemachine.donotpresent 43 | 44 | # Directories potentially created on remote AFP share 45 | .AppleDB 46 | .AppleDesktop 47 | Network Trash Folder 48 | Temporary Items 49 | .apdisk 50 | 51 | ### Node ### 52 | # Logs 53 | logs 54 | *.log 55 | npm-debug.log* 56 | yarn-debug.log* 57 | yarn-error.log* 58 | lerna-debug.log* 59 | 60 | # Diagnostic reports (https://nodejs.org/api/report.html) 61 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 62 | 63 | # Runtime data 64 | pids 65 | *.pid 66 | *.seed 67 | *.pid.lock 68 | 69 | # Directory for instrumented libs generated by jscoverage/JSCover 70 | lib-cov 71 | 72 | # Coverage directory used by tools like istanbul 73 | coverage 74 | *.lcov 75 | 76 | # nyc test coverage 77 | .nyc_output 78 | 79 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 80 | .grunt 81 | 82 | # Bower dependency directory (https://bower.io/) 83 | bower_components 84 | 85 | # node-waf configuration 86 | .lock-wscript 87 | 88 | # Compiled binary addons (https://nodejs.org/api/addons.html) 89 | build/Release 90 | 91 | # Dependency directories 92 | node_modules/ 93 | jspm_packages/ 94 | 95 | # TypeScript v1 declaration files 96 | typings/ 97 | 98 | # TypeScript cache 99 | *.tsbuildinfo 100 | 101 | # Optional npm cache directory 102 | .npm 103 | 104 | # Optional eslint cache 105 | .eslintcache 106 | 107 | # Optional REPL history 108 | .node_repl_history 109 | 110 | # Output of 'npm pack' 111 | *.tgz 112 | 113 | # Yarn Integrity file 114 | .yarn-integrity 115 | 116 | # dotenv environment variables file 117 | .env 118 | .env.test 119 | 120 | # parcel-bundler cache (https://parceljs.org/) 121 | .cache 122 | 123 | # next.js build output 124 | .next 125 | 126 | # nuxt.js build output 127 | .nuxt 128 | 129 | # rollup.js default build output 130 | dist/ 131 | 132 | # Uncomment the public line if your project uses Gatsby 133 | # https://nextjs.org/blog/next-9-1#public-directory-support 134 | # https://create-react-app.dev/docs/using-the-public-folder/#docsNav 135 | # public 136 | 137 | # Storybook build outputs 138 | .out 139 | .storybook-out 140 | 141 | # vuepress build output 142 | .vuepress/dist 143 | 144 | # Serverless directories 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | .fusebox/ 149 | 150 | # DynamoDB Local files 151 | .dynamodb/ 152 | 153 | # Temporary folders 154 | tmp/ 155 | temp/ 156 | 157 | ### PyCharm ### 158 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 159 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 160 | 161 | # User-specific stuff 162 | .idea/**/workspace.xml 163 | .idea/**/tasks.xml 164 | .idea/**/usage.statistics.xml 165 | .idea/**/dictionaries 166 | .idea/**/shelf 167 | 168 | # Generated files 169 | .idea/**/contentModel.xml 170 | 171 | # Sensitive or high-churn files 172 | .idea/**/dataSources/ 173 | .idea/**/dataSources.ids 174 | .idea/**/dataSources.local.xml 175 | .idea/**/sqlDataSources.xml 176 | .idea/**/dynamic.xml 177 | .idea/**/uiDesigner.xml 178 | .idea/**/dbnavigator.xml 179 | 180 | # Gradle 181 | .idea/**/gradle.xml 182 | .idea/**/libraries 183 | 184 | # Gradle and Maven with auto-import 185 | # When using Gradle or Maven with auto-import, you should exclude module files, 186 | # since they will be recreated, and may cause churn. Uncomment if using 187 | # auto-import. 188 | # .idea/modules.xml 189 | # .idea/*.iml 190 | # .idea/modules 191 | # *.iml 192 | # *.ipr 193 | 194 | # CMake 195 | cmake-build-*/ 196 | 197 | # Mongo Explorer plugin 198 | .idea/**/mongoSettings.xml 199 | 200 | # File-based project format 201 | *.iws 202 | 203 | # IntelliJ 204 | out/ 205 | 206 | # mpeltonen/sbt-idea plugin 207 | .idea_modules/ 208 | 209 | # JIRA plugin 210 | atlassian-ide-plugin.xml 211 | 212 | # Cursive Clojure plugin 213 | .idea/replstate.xml 214 | 215 | # Crashlytics plugin (for Android Studio and IntelliJ) 216 | com_crashlytics_export_strings.xml 217 | crashlytics.properties 218 | crashlytics-build.properties 219 | fabric.properties 220 | 221 | # Editor-based Rest Client 222 | .idea/httpRequests 223 | 224 | # Android studio 3.1+ serialized cache file 225 | .idea/caches/build_file_checksums.ser 226 | 227 | ### PyCharm Patch ### 228 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 229 | 230 | # *.iml 231 | # modules.xml 232 | # .idea/misc.xml 233 | # *.ipr 234 | 235 | # Sonarlint plugin 236 | .idea/**/sonarlint/ 237 | 238 | # SonarQube Plugin 239 | .idea/**/sonarIssues.xml 240 | 241 | # Markdown Navigator plugin 242 | .idea/**/markdown-navigator.xml 243 | .idea/**/markdown-navigator/ 244 | 245 | ### Vim ### 246 | # Swap 247 | [._]*.s[a-v][a-z] 248 | [._]*.sw[a-p] 249 | [._]s[a-rt-v][a-z] 250 | [._]ss[a-gi-z] 251 | [._]sw[a-p] 252 | 253 | # Session 254 | Session.vim 255 | Sessionx.vim 256 | 257 | # Temporary 258 | .netrwhist 259 | 260 | # Auto-generated tag files 261 | tags 262 | 263 | # Persistent undo 264 | [._]*.un~ 265 | 266 | # Coc configuration directory 267 | .vim 268 | 269 | ### Windows ### 270 | # Windows thumbnail cache files 271 | Thumbs.db 272 | Thumbs.db:encryptable 273 | ehthumbs.db 274 | ehthumbs_vista.db 275 | 276 | # Dump file 277 | *.stackdump 278 | 279 | # Folder config file 280 | [Dd]esktop.ini 281 | 282 | # Recycle Bin used on file shares 283 | $RECYCLE.BIN/ 284 | 285 | # Windows Installer files 286 | *.cab 287 | *.msi 288 | *.msix 289 | *.msm 290 | *.msp 291 | 292 | # Windows shortcuts 293 | *.lnk 294 | 295 | # End of https://www.gitignore.io/api/vim,node,linux,macos,pycharm,windows 296 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /workspace.xml 3 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | 21 | 22 | 29 | 30 | 37 | 38 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/neh.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.ignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | worker 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 100, 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | }; 6 | -------------------------------------------------------------------------------- /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 ag_dubs@cloudflare.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 | -------------------------------------------------------------------------------- /LICENSE_MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019 E-Liang Tan 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neh 2 | 3 | [![CircleCI](https://circleci.com/gh/taneliang/neh.svg?style=svg)](https://circleci.com/gh/taneliang/neh) 4 | [![codecov](https://codecov.io/gh/taneliang/neh/branch/main/graph/badge.svg)](https://codecov.io/gh/taneliang/neh) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/a17d9aa41c5fe1ee3dfb/maintainability)](https://codeclimate.com/github/taneliang/neh/maintainability) 6 | 7 | 8 | 9 | A tool that redirects you to some commonly used sites; intended to be an 10 | Alfred for your browser. 11 | 12 | ![screencast](screenshots/screencast.gif) 13 | 14 | Inspired by Facebook's open source [bunny](http://www.bunny1.org) tool, but 15 | rewritten for the cloud serverless edge computing age – neh runs in Cloudflare 16 | Workers on Cloudflare's edge servers located close to you. TL;DR it's fast. 17 | 18 | ## Usage 19 | 20 | The easiest way to use neh is to set as your 21 | browser's default search engine. Then, run commands by typing their command 22 | names followed by your query. If no command is detected, neh will just redirect 23 | you to DuckDuckGo. 24 | 25 | Here are some examples: 26 | 27 | - Search YouTube: [yt rickroll](https://neh.eltan.net/yt%20rickroll) 28 | - Go to a GitHub repository: [gh r nusmodifications/nusmods](https://neh.eltan.net/gh%20r%20nusmodifications/nusmods) 29 | - Perform a Google search: [g neh](https://neh.eltan.net/g%20neh) 30 | - Fallback to a DuckDuckGo search: [not a command](https://neh.eltan.net/not%20a%20command) 31 | 32 | Neh also does some basic query detection and is able to convert between 33 | different search engines. It's not completely reliable but works in most cases. 34 | Examples: 35 | 36 | - Convert a YouTube search to a Wikipedia search: [wk https://www.youtube.com/results?search_query=rickroll](https://neh.eltan.net/wk%20https://www.youtube.com/results?search_query=rickroll) 37 | - Convert a Google search to an NPM package: [npm p https://www.google.com/search?q=react](https://neh.eltan.net/npm%20p%20https://www.google.com/search?q=react) 38 | - Look up an NPM package on BundlePhobia: [bp https://www.npmjs.com/package/react](https://neh.eltan.net/bp%20https://www.npmjs.com/package/react) 39 | 40 | Neh! Liddat only. Easy. 41 | 42 | ## Development 43 | 44 | ### Setup 45 | 46 | We use Cloudflare's [Wrangler](https://github.com/cloudflare/wrangler) tool to 47 | run and publish neh. 48 | 49 | 1. Run `yarn` to install dependencies. 50 | 1. `cp .env.example .env` and fill it in. 51 | 1. Modify the wrangler.toml file as appropriate. 52 | 53 | ### Local development server 54 | 55 | To start a local development server, run: 56 | 57 | ```sh 58 | yarn start 59 | ``` 60 | 61 | You'll then be able to open neh at http://localhost:8787. 62 | 63 | ### Cloudflare Workers preview 64 | 65 | To 66 | [preview](https://developers.cloudflare.com/workers/learning/getting-started#5-preview-your-project) 67 | neh, run: 68 | 69 | ```sh 70 | yarn preview 71 | ``` 72 | 73 | Wrangler will open the preview in your web browser. If this does not happen, 74 | ensure that the `BROWSER` environmental variable has been set. 75 | 76 | ## Publishing 77 | 78 | Our CircleCI workflow automatically deploys neh's `main` branch to 79 | Cloudflare Workers. 80 | 81 | Having said that, here's how you can publish neh manually: 82 | 83 | 1. Add `CF_API_TOKEN` to your `.env` file. 84 | 1. Run: 85 | ```sh 86 | yarn env-cmd yarn run publish 87 | ``` 88 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | setupFiles: ['./setupJest.ts'], 5 | transform: { 6 | '\\.pug$': 'awesome-pug-jest', 7 | '\\.xml$': 'jest-raw-loader', 8 | }, 9 | moduleNameMapper: { 10 | '\\.(?:css|scss)$': 'jest-transform-stub', 11 | }, 12 | 13 | collectCoverageFrom: ['src/**/!(*.d).{js,jsx,ts,tsx}'], 14 | 15 | // Only write lcov and test report files in CI 16 | coverageReporters: ['text'].concat(process.env.CI ? 'json' : []), 17 | reporters: ['default'].concat( 18 | process.env.CI ? [['jest-junit', { outputDirectory: './test-reports/junit' }]] : [], 19 | ), 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "neh", 4 | "version": "1.0.0", 5 | "description": "A tool that redirects you; intended to be an Alfred for your browser.", 6 | "scripts": { 7 | "start": "env-cmd wrangler dev", 8 | "preview": "env-cmd wrangler preview --watch", 9 | "publish": "wrangler publish", 10 | "typecheck": "tsc --noEmit", 11 | "test": "jest --coverage", 12 | "test:watch": "jest --watch", 13 | "lint": "yarn lint:code && yarn lint:misc", 14 | "lint:fix": "yarn lint:code:fix && yarn lint:misc:fix", 15 | "lint:code": "eslint --ext .ts,.js .", 16 | "lint:code:fix": "yarn lint:code --fix", 17 | "lint:misc": "prettier --check **/*.{json,md,pug} --ignore-path '{node_modules,worker,dist}'", 18 | "lint:misc:fix": "yarn lint:misc --write", 19 | "ci": "yarn typecheck && yarn lint && yarn test" 20 | }, 21 | "author": "E-Liang Tan ", 22 | "license": "MIT", 23 | "devDependencies": { 24 | "@cloudflare/wrangler": "^1.13.0", 25 | "@fullhuman/postcss-purgecss": "^3.1.3", 26 | "@prettier/plugin-pug": "^1.13.3", 27 | "@types/fuzzyset": "^1.0.2", 28 | "@types/jest": "^26.0.20", 29 | "@types/node": "^14.14.27", 30 | "@types/pug": "^2.0.4", 31 | "@types/url-parse": "^1.4.3", 32 | "@typescript-eslint/eslint-plugin": "^4.15.0", 33 | "@typescript-eslint/parser": "^4.15.0", 34 | "@udacity/types-service-worker-mock": "^1.2.0", 35 | "awesome-pug-jest": "^1.1.0", 36 | "cloudflare-worker-mock": "^1.2.0", 37 | "codecov": "^3.8.1", 38 | "css-loader": "^5.0.2", 39 | "cssnano": "^4.1.10", 40 | "env-cmd": "^10.1.0", 41 | "eslint": "^7.19.0", 42 | "eslint-config-prettier": "^7.2.0", 43 | "eslint-plugin-prettier": "^3.3.1", 44 | "fuzzyset": "^1.0.6", 45 | "jest": "^26.6.3", 46 | "jest-fetch-mock": "^3.0.3", 47 | "jest-junit": "^12.0.0", 48 | "jest-raw-loader": "^1.0.1", 49 | "jest-transform-stub": "^2.0.0", 50 | "postcss-import": "^12.0.1", 51 | "postcss-loader": "^3.0.0", 52 | "prettier": "^2.2.1", 53 | "pug": "^3.0.0", 54 | "pug-loader": "^2.4.0", 55 | "raw-loader": "^4.0.2", 56 | "source-map-loader": "^1.1.3", 57 | "tailwindcss": "^1.9.6", 58 | "ts-jest": "^26.5.1", 59 | "ts-loader": "^8.0.17", 60 | "types-cloudflare-worker": "^1.2.0", 61 | "typescript": "^4.1.5", 62 | "url-parse": "^1.4.7", 63 | "webpack": "^4.46.0", 64 | "webpack-cli": "^4.5.0" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ident: 'postcss', 3 | plugins: [ 4 | require('postcss-import'), 5 | require('tailwindcss'), 6 | // eslint-disable-next-line @typescript-eslint/no-var-requires 7 | require('@fullhuman/postcss-purgecss')({ 8 | content: ['./src/**/*.pug'], 9 | }), 10 | require('autoprefixer'), 11 | require('cssnano'), 12 | ], 13 | }; 14 | -------------------------------------------------------------------------------- /screenshots/screencast.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/taneliang/neh/c57a14392e6c9f4a143722b7effa48aa9cc38220/screenshots/screencast.gif -------------------------------------------------------------------------------- /setupJest.ts: -------------------------------------------------------------------------------- 1 | import makeCloudflareWorkerEnv from 'cloudflare-worker-mock'; 2 | import jestFetchMock, { GlobalWithFetchMock } from 'jest-fetch-mock'; 3 | 4 | Object.assign(global, makeCloudflareWorkerEnv()); 5 | 6 | const customGlobal = global as GlobalWithFetchMock & typeof globalThis; 7 | customGlobal.fetch = jestFetchMock; 8 | customGlobal.fetchMock = customGlobal.fetch; 9 | -------------------------------------------------------------------------------- /src/Handler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Handler, 3 | FunctionHandler, 4 | RedirectHandler, 5 | CommandHandler, 6 | Token, 7 | DEFAULT_HANDLER_KEY, 8 | NOTHING_HANDLER_KEY, 9 | } from './Handler'; 10 | 11 | const docstring = 12 | 'Are you in the right headspace to receive information that could possibly hurt you?'; 13 | const tokens = ['token1', 'token2']; 14 | 15 | describe(FunctionHandler, () => { 16 | test('should passthrough docstring', () => { 17 | const mockHandlerFn = jest.fn(); 18 | const handler = new FunctionHandler(docstring, mockHandlerFn); 19 | expect(handler.doc).toEqual(docstring); 20 | }); 21 | 22 | test('should execute function with tokens when handle is called', async () => { 23 | const mockHandlerFn = jest.fn(); 24 | const handler = new FunctionHandler(docstring, mockHandlerFn); 25 | await handler.handle(tokens); 26 | expect(mockHandlerFn).toBeCalledWith(tokens); 27 | }); 28 | }); 29 | 30 | describe(RedirectHandler, () => { 31 | const targetUrl = 'https://eliangtan.com'; 32 | 33 | test('should passthrough docstring', () => { 34 | const handler = new RedirectHandler(docstring, targetUrl); 35 | expect(handler.doc).toEqual(docstring); 36 | }); 37 | 38 | test('should return a redirection when handle is called', async () => { 39 | const handler = new RedirectHandler(docstring, targetUrl); 40 | const response = await handler.handle(tokens); 41 | expect(response.status).toBe(302); 42 | expect(response.headers.get('location')).toBe(targetUrl); 43 | }); 44 | }); 45 | 46 | describe(CommandHandler, () => { 47 | type MockHandlerWrapper = { 48 | command: string; 49 | mockHandlerFn: jest.Mock; 50 | handler: Handler; 51 | }; 52 | 53 | function makeMockHandler(command: string): MockHandlerWrapper { 54 | const mockHandlerFn = jest.fn(); 55 | return { 56 | command, 57 | mockHandlerFn, 58 | handler: new FunctionHandler(docstring, mockHandlerFn), 59 | }; 60 | } 61 | 62 | let handler1: MockHandlerWrapper; 63 | let handler2: MockHandlerWrapper; 64 | let nothingHandler: MockHandlerWrapper; 65 | let defaultHandler: MockHandlerWrapper; 66 | let handlers: MockHandlerWrapper[] = []; 67 | 68 | beforeEach(() => { 69 | handlers = [ 70 | makeMockHandler('h1'), 71 | makeMockHandler('h2'), 72 | makeMockHandler('nothing'), 73 | makeMockHandler('default'), 74 | ]; 75 | [handler1, handler2, nothingHandler, defaultHandler] = handlers; 76 | }); 77 | 78 | test('should generate doc object', () => { 79 | const handler = new CommandHandler(); 80 | 81 | handler.addHandler(handler1.command, handler1.handler); 82 | expect(handler.doc).toEqual({ 83 | [handler1.command]: docstring, 84 | }); 85 | 86 | handler.addHandler(handler2.command, handler2.handler); 87 | expect(handler.doc).toEqual({ 88 | [handler1.command]: docstring, 89 | [handler2.command]: docstring, 90 | }); 91 | 92 | handler.setNothingHandler(nothingHandler.handler); 93 | expect(handler.doc).toEqual({ 94 | [handler1.command]: docstring, 95 | [handler2.command]: docstring, 96 | [NOTHING_HANDLER_KEY]: docstring, 97 | }); 98 | 99 | handler.setDefaultHandler(defaultHandler.handler); 100 | expect(handler.doc).toEqual({ 101 | [handler1.command]: docstring, 102 | [handler2.command]: docstring, 103 | [NOTHING_HANDLER_KEY]: docstring, 104 | [DEFAULT_HANDLER_KEY]: docstring, 105 | }); 106 | }); 107 | 108 | describe(CommandHandler.prototype.handle, () => { 109 | function expectHandlingByOnly(handler: MockHandlerWrapper, tokens: Token[]): void { 110 | handlers.forEach((h) => { 111 | if (h === handler) { 112 | expect(h.mockHandlerFn).toBeCalledWith(tokens); 113 | } else { 114 | expect(h.mockHandlerFn).not.toBeCalled(); 115 | } 116 | h.mockHandlerFn.mockClear(); 117 | }); 118 | } 119 | 120 | async function expectGenericResponse( 121 | commandHandler: CommandHandler, 122 | tokens: Token[], 123 | ): Promise { 124 | const response = await commandHandler.handle(tokens); 125 | expect(response.status).toBe(200); 126 | expect(response.body).toBeDefined(); 127 | expect(response.headers.get('content-type')?.startsWith('text/html')).toBeTruthy(); 128 | } 129 | 130 | test('should execute function with tokens when handle is called', async () => { 131 | const handler = new CommandHandler(); 132 | handler.addHandler(handler1.command, handler1.handler); 133 | handler.addHandler(handler2.command, handler2.handler); 134 | 135 | await handler.handle([handler1.command, ...tokens]); 136 | expectHandlingByOnly(handler1, tokens); 137 | await handler.handle([handler2.command, ...tokens]); 138 | expectHandlingByOnly(handler2, tokens); 139 | }); 140 | 141 | test('should call nothing handler if no tokens', async () => { 142 | const handler = new CommandHandler(); 143 | handler.addHandler(handler1.command, handler1.handler); 144 | handler.setNothingHandler(nothingHandler.handler); 145 | handler.setDefaultHandler(defaultHandler.handler); 146 | 147 | await handler.handle([]); 148 | expectHandlingByOnly(nothingHandler, []); 149 | }); 150 | 151 | test('should return generic response if no tokens and no nothing handler', async () => { 152 | const handler = new CommandHandler(); 153 | handler.addHandler(handler1.command, handler1.handler); 154 | handler.setDefaultHandler(defaultHandler.handler); 155 | await expectGenericResponse(handler, []); 156 | }); 157 | 158 | test('should call default handler if no handler assigned to command', async () => { 159 | const handler = new CommandHandler(); 160 | handler.addHandler(handler1.command, handler1.handler); 161 | handler.setNothingHandler(nothingHandler.handler); 162 | handler.setDefaultHandler(defaultHandler.handler); 163 | 164 | await handler.handle(tokens); 165 | expectHandlingByOnly(defaultHandler, tokens); 166 | }); 167 | 168 | test('should return generic response if tokens present but no applicable handler', async () => { 169 | const handler = new CommandHandler(); 170 | handler.setNothingHandler(nothingHandler.handler); 171 | await expectGenericResponse(handler, tokens); 172 | }); 173 | }); 174 | }); 175 | -------------------------------------------------------------------------------- /src/Handler.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from './util'; 2 | 3 | export type DocString = string; 4 | export type DocObject = { [command: string]: DocType }; 5 | export type DocType = DocString | DocObject; 6 | export const DEFAULT_HANDLER_KEY = '$$$default_handler$$$'; 7 | export const NOTHING_HANDLER_KEY = '$$$nothing_handler$$$'; 8 | 9 | export type Token = string; 10 | 11 | export type HandlerFn = (tokens: Token[]) => Response | Promise; 12 | 13 | export abstract class Handler { 14 | abstract readonly doc: DocType; 15 | abstract handle(tokens: Token[]): Promise; 16 | } 17 | 18 | export class FunctionHandler extends Handler { 19 | doc: DocString; 20 | 21 | private handlerFn: HandlerFn; 22 | 23 | constructor(docstring: DocString, handlerFn: HandlerFn) { 24 | super(); 25 | this.doc = docstring; 26 | this.handlerFn = handlerFn; 27 | } 28 | 29 | async handle(tokens: Token[]): Promise { 30 | return await this.handlerFn(tokens); 31 | } 32 | } 33 | 34 | export class RedirectHandler extends FunctionHandler { 35 | constructor(docstring: DocString, targetUrl: string) { 36 | super(docstring, () => redirect(targetUrl)); 37 | } 38 | } 39 | 40 | export class CommandHandler extends Handler { 41 | private handlers: { [command: string]: Handler } = {}; 42 | private defaultHandler?: Handler; 43 | private nothingHandler?: Handler; 44 | 45 | get doc(): DocObject { 46 | const docObject: DocObject = Object.fromEntries( 47 | Object.entries(this.handlers) 48 | .sort(([a], [b]) => a.localeCompare(b)) 49 | .map(([command, handler]) => [command, handler.doc]), 50 | ); 51 | if (this.defaultHandler) { 52 | docObject[DEFAULT_HANDLER_KEY] = this.defaultHandler.doc; 53 | } 54 | if (this.nothingHandler) { 55 | docObject[NOTHING_HANDLER_KEY] = this.nothingHandler.doc; 56 | } 57 | return docObject; 58 | } 59 | 60 | addHandler(command: string, handler: Handler): void { 61 | this.handlers[command] = handler; 62 | } 63 | 64 | setDefaultHandler(handler: Handler): void { 65 | this.defaultHandler = handler; 66 | } 67 | 68 | setNothingHandler(handler: Handler): void { 69 | this.nothingHandler = handler; 70 | } 71 | 72 | async handle(tokens: Token[]): Promise { 73 | if (tokens.length === 0) { 74 | if (this.nothingHandler) { 75 | return await this.nothingHandler.handle(tokens); 76 | } 77 | } else { 78 | const [command, ...otherTokens] = tokens; 79 | const lowerCommand = command.toLowerCase(); 80 | if (lowerCommand in this.handlers) { 81 | return await this.handlers[lowerCommand].handle(otherTokens); 82 | } 83 | if (this.defaultHandler) { 84 | return await this.defaultHandler.handle(tokens); 85 | } 86 | } 87 | 88 | return new Response( 89 | 'Idk what to do with your command. Try checking list?', 90 | { 91 | headers: { 92 | 'content-type': 'text/html;charset=UTF-8', 93 | }, 94 | }, 95 | ); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/SearchEngineHandler.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SearchEngine, 3 | SearchEngineHandler, 4 | makeAppendBasedSearchEngine, 5 | makeHashBasedSearchEngine, 6 | makeParamBasedSearchEngine, 7 | makePathBasedSearchEngine, 8 | searchEngines, 9 | } from './SearchEngineHandler'; 10 | import { emptyArray } from './util'; 11 | 12 | const defaultUrl = 'https://fancy.search/'; 13 | const baseUrl = 'https://fancy.search/search/'; 14 | const queryTokens = ['query', 'string']; 15 | 16 | describe(makeAppendBasedSearchEngine, () => { 17 | const complexBaseUrl = 'https://fancy.search/s?q=a#b=c&d='; 18 | 19 | describe('defaultUrl', () => { 20 | test('should passthrough defaultUrl', () => { 21 | const engine = makeAppendBasedSearchEngine(defaultUrl, null); 22 | expect(engine.defaultUrl).toEqual(defaultUrl); 23 | }); 24 | }); 25 | 26 | describe('generateSearchUrl', () => { 27 | test('should use baseURL', () => { 28 | const engine = makeAppendBasedSearchEngine(defaultUrl, baseUrl); 29 | expect(engine.generateSearchUrl(queryTokens)).toEqual( 30 | 'https://fancy.search/search/query%20string', 31 | ); 32 | }); 33 | 34 | test('should use defaultUrl if baseURL is null', () => { 35 | const engine = makeAppendBasedSearchEngine(complexBaseUrl, null); 36 | expect(engine.generateSearchUrl(queryTokens)).toEqual( 37 | 'https://fancy.search/s?q=a#b=c&d=query%20string', 38 | ); 39 | }); 40 | }); 41 | 42 | describe('parseSearchUrl', () => { 43 | test('should return null if search URL is not prefixed with base URL', () => { 44 | const engine = makeAppendBasedSearchEngine(complexBaseUrl, null); 45 | expect(engine.parseSearchUrl?.('https://fancy.search/s?q=a#d=query%20string')).toBeNull(); 46 | }); 47 | 48 | test('should return null if search URL has no query', () => { 49 | const engine = makeAppendBasedSearchEngine(complexBaseUrl, null); 50 | expect(engine.parseSearchUrl?.(complexBaseUrl)).toBeNull(); 51 | }); 52 | 53 | test('should extract query if present', () => { 54 | const engine = makeAppendBasedSearchEngine(complexBaseUrl, null); 55 | expect(engine.parseSearchUrl?.('https://fancy.search/s?q=a#b=c&d=query%20string')).toEqual( 56 | 'query string', 57 | ); 58 | }); 59 | }); 60 | }); 61 | 62 | describe(makeHashBasedSearchEngine, () => { 63 | describe('defaultUrl', () => { 64 | test('should passthrough defaultUrl', () => { 65 | const engine = makeHashBasedSearchEngine(defaultUrl, null); 66 | expect(engine.defaultUrl).toEqual(defaultUrl); 67 | }); 68 | }); 69 | 70 | describe('generateSearchUrl', () => { 71 | test('should use baseURL', () => { 72 | const engine = makeHashBasedSearchEngine(defaultUrl, baseUrl); 73 | expect(engine.generateSearchUrl(queryTokens)).toEqual( 74 | 'https://fancy.search/search/#query%20string', 75 | ); 76 | }); 77 | 78 | test('should use defaultUrl if baseURL is null', () => { 79 | const engine = makeHashBasedSearchEngine(defaultUrl, null); 80 | expect(engine.generateSearchUrl(queryTokens)).toEqual('https://fancy.search/#query%20string'); 81 | }); 82 | }); 83 | 84 | describe('parseSearchUrl', () => { 85 | test('should return null if search URL does not belong to this engine', () => { 86 | const engine = makeHashBasedSearchEngine(defaultUrl, null); 87 | expect(engine.parseSearchUrl?.('https://despicable.search/#query%20string')).toBeNull(); 88 | }); 89 | 90 | test('should return null if no hash is present', () => { 91 | const engine = makeHashBasedSearchEngine(defaultUrl, baseUrl); 92 | expect(engine.parseSearchUrl?.(baseUrl)).toBeNull(); 93 | }); 94 | 95 | test('should return null if hash is empty', () => { 96 | const engine = makeHashBasedSearchEngine(defaultUrl, baseUrl); 97 | expect(engine.parseSearchUrl?.('https://fancy.search/search#')).toBeNull(); 98 | }); 99 | 100 | test('should extract query if present', () => { 101 | const engine = makeHashBasedSearchEngine(defaultUrl, null); 102 | expect(engine.parseSearchUrl?.('https://fancy.search/#query%20string')).toEqual( 103 | 'query string', 104 | ); 105 | }); 106 | }); 107 | }); 108 | 109 | describe(makeParamBasedSearchEngine, () => { 110 | describe('defaultUrl', () => { 111 | test('should passthrough defaultUrl', () => { 112 | const engine = makeParamBasedSearchEngine(defaultUrl, null, 'q'); 113 | expect(engine.defaultUrl).toEqual(defaultUrl); 114 | }); 115 | }); 116 | 117 | describe('generateSearchUrl', () => { 118 | test('should use baseURL', () => { 119 | const engine = makeParamBasedSearchEngine(defaultUrl, baseUrl, 'q'); 120 | expect(engine.generateSearchUrl(queryTokens)).toEqual( 121 | 'https://fancy.search/search/?q=query+string', 122 | ); 123 | }); 124 | 125 | test('should use defaultUrl if baseURL is null', () => { 126 | const engine = makeParamBasedSearchEngine(defaultUrl, null, 'q'); 127 | expect(engine.generateSearchUrl(queryTokens)).toEqual('https://fancy.search/?q=query+string'); 128 | }); 129 | }); 130 | 131 | describe('parseSearchUrl', () => { 132 | test('should return null if search URL does not belong to this engine', () => { 133 | const engine = makeParamBasedSearchEngine(defaultUrl, null, 'q'); 134 | expect(engine.parseSearchUrl?.('https://despicable.search/?q=query+string')).toBeNull(); 135 | }); 136 | 137 | test('should return null if query param is not present', () => { 138 | const engine = makeParamBasedSearchEngine(defaultUrl, baseUrl, 'q'); 139 | expect(engine.parseSearchUrl?.(baseUrl)).toBeNull(); 140 | }); 141 | 142 | test('should return null if query param is empty', () => { 143 | const engine = makeParamBasedSearchEngine(defaultUrl, baseUrl, 'q'); 144 | expect(engine.parseSearchUrl?.('https://fancy.search/search?q=')).toBeNull(); 145 | }); 146 | 147 | test('should extract query if present', () => { 148 | const engine = makeParamBasedSearchEngine(defaultUrl, null, 'q'); 149 | expect(engine.parseSearchUrl?.('https://fancy.search/?q=query+string')).toEqual( 150 | 'query string', 151 | ); 152 | }); 153 | 154 | test('should only extract query even if other queries are present', () => { 155 | const engine = makeParamBasedSearchEngine(defaultUrl, null, 'q'); 156 | expect(engine.parseSearchUrl?.('https://fancy.search/?fee=fi&q=query+string&fo=fum')).toEqual( 157 | 'query string', 158 | ); 159 | }); 160 | }); 161 | }); 162 | 163 | describe(makePathBasedSearchEngine, () => { 164 | describe('defaultUrl', () => { 165 | test('should passthrough defaultUrl', () => { 166 | const engine = makePathBasedSearchEngine(defaultUrl, null, []); 167 | expect(engine.defaultUrl).toEqual(defaultUrl); 168 | }); 169 | }); 170 | 171 | describe('generateSearchUrl', () => { 172 | test('should use baseURL', () => { 173 | const engine = makePathBasedSearchEngine(defaultUrl, baseUrl, []); 174 | expect(engine.generateSearchUrl(queryTokens)).toEqual( 175 | 'https://fancy.search/search/query/string', 176 | ); 177 | }); 178 | 179 | test('should use defaultUrl if baseURL is null', () => { 180 | const engine = makePathBasedSearchEngine(defaultUrl, null, []); 181 | expect(engine.generateSearchUrl(queryTokens)).toEqual('https://fancy.search/query/string'); 182 | }); 183 | }); 184 | 185 | describe('parseSearchUrl', () => { 186 | test('should return null if search URL does not belong to this engine', () => { 187 | const engine = makePathBasedSearchEngine(defaultUrl, null, [0]); 188 | expect(engine.parseSearchUrl?.('https://despicable.search/query/string')).toBeNull(); 189 | }); 190 | 191 | test('should return null if no path is present', () => { 192 | const engine = makePathBasedSearchEngine('https://fancy.search/', null, [0]); 193 | expect(engine.parseSearchUrl?.('https://fancy.search/')).toBeNull(); 194 | }); 195 | 196 | test('should return null if no interesting path segment is present', () => { 197 | const engine = makePathBasedSearchEngine(defaultUrl, baseUrl, [1]); 198 | expect(engine.parseSearchUrl?.('https://fancy.search/search/')).toBeNull(); 199 | }); 200 | 201 | test('should extract null if no interested path segments', () => { 202 | const engine = makePathBasedSearchEngine(defaultUrl, null, []); 203 | expect(engine.parseSearchUrl?.('https://fancy.search/search/query/string')).toBeNull(); 204 | }); 205 | 206 | test('should extract only specified path segments', () => { 207 | const engine = makePathBasedSearchEngine(defaultUrl, null, [1]); 208 | expect(engine.parseSearchUrl?.('https://fancy.search/search/query/string')).toEqual('query'); 209 | }); 210 | 211 | test('should extract >1 path segments if present', () => { 212 | const engine = makePathBasedSearchEngine(defaultUrl, null, [1, 2]); 213 | expect(engine.parseSearchUrl?.('https://fancy.search/search/query/string')).toEqual( 214 | 'query/string', 215 | ); 216 | }); 217 | 218 | test('should extract >1 path segments in order of indices', () => { 219 | const engine = makePathBasedSearchEngine(defaultUrl, null, [2, 1]); 220 | expect(engine.parseSearchUrl?.('https://fancy.search/search/query/string')).toEqual( 221 | 'string/query', 222 | ); 223 | }); 224 | 225 | test('should extract path segments even if some are not present', () => { 226 | const engine = makePathBasedSearchEngine(defaultUrl, null, [1, 9000]); 227 | expect(engine.parseSearchUrl?.('https://fancy.search/search/query/string')).toEqual('query'); 228 | }); 229 | }); 230 | }); 231 | 232 | describe(SearchEngineHandler, () => { 233 | beforeEach(() => { 234 | emptyArray(searchEngines); 235 | }); 236 | 237 | describe('constructor', () => { 238 | const simpleEngine: SearchEngine = { 239 | defaultUrl, 240 | generateSearchUrl: () => '', 241 | }; 242 | 243 | test('should passthrough docstring', () => { 244 | const docstring = "Don't be evil"; 245 | const handler = new SearchEngineHandler(docstring, simpleEngine); 246 | expect(handler.doc).toEqual(docstring); 247 | }); 248 | 249 | test('should register search engine', () => { 250 | expect(searchEngines).toEqual([]); 251 | new SearchEngineHandler('', simpleEngine); 252 | expect(searchEngines).toEqual([simpleEngine]); 253 | }); 254 | }); 255 | 256 | describe(SearchEngineHandler.prototype.handle, () => { 257 | type MockSearchEngine = SearchEngine & { 258 | generateSearchUrl: jest.Mock; 259 | parseSearchUrl?: jest.Mock; 260 | }; 261 | 262 | let noParseEngine: MockSearchEngine; 263 | let parsableEngine1: MockSearchEngine; 264 | let parsableEngine2: MockSearchEngine; 265 | 266 | beforeEach(() => { 267 | noParseEngine = { 268 | defaultUrl, 269 | generateSearchUrl: jest.fn(), 270 | }; 271 | parsableEngine1 = { 272 | defaultUrl, 273 | generateSearchUrl: jest.fn(), 274 | parseSearchUrl: jest.fn(), 275 | }; 276 | parsableEngine2 = { 277 | defaultUrl, 278 | generateSearchUrl: jest.fn(), 279 | parseSearchUrl: jest.fn(), 280 | }; 281 | }); 282 | 283 | test('should redirect to defaultUrl if no tokens', async () => { 284 | const handler = new SearchEngineHandler('', noParseEngine); 285 | const response = await handler.handle([]); 286 | expect(response.status).toEqual(302); 287 | expect(response.headers.get('location')).toBe(defaultUrl); 288 | }); 289 | 290 | test('should call generateSearchUrl with all search tokens if not transformable', async () => { 291 | // Register other engines 292 | new SearchEngineHandler('', noParseEngine); 293 | new SearchEngineHandler('', parsableEngine1); 294 | 295 | const handler = new SearchEngineHandler('', parsableEngine2); 296 | const response = await handler.handle(queryTokens); 297 | expect(response.status).toEqual(302); 298 | expect(parsableEngine1.parseSearchUrl).toBeCalledWith('query string'); 299 | expect(parsableEngine2.parseSearchUrl).toBeCalledWith('query string'); 300 | expect(parsableEngine2.generateSearchUrl).toBeCalledWith(queryTokens); 301 | }); 302 | 303 | test('should call generateSearchUrl with all transformed query if transformable', async () => { 304 | // Register other engines 305 | new SearchEngineHandler('', noParseEngine); 306 | new SearchEngineHandler('', parsableEngine1); 307 | 308 | const transformedQuery = 'transformed query'; 309 | parsableEngine1.parseSearchUrl?.mockReturnValue(transformedQuery); 310 | 311 | const handler = new SearchEngineHandler('', parsableEngine2); 312 | const response = await handler.handle(queryTokens); 313 | expect(response.status).toEqual(302); 314 | expect(parsableEngine2.generateSearchUrl).toBeCalledWith([transformedQuery]); 315 | }); 316 | }); 317 | }); 318 | -------------------------------------------------------------------------------- /src/SearchEngineHandler.ts: -------------------------------------------------------------------------------- 1 | import parse from 'url-parse'; 2 | import { FunctionHandler } from './Handler'; 3 | import { redirect } from './util'; 4 | 5 | type SearchUrlGenerator = (tokens: string[]) => string; 6 | type SearchUrlParser = (url: string) => string | null; 7 | 8 | export type SearchEngine = { 9 | defaultUrl: string; 10 | generateSearchUrl: SearchUrlGenerator; 11 | parseSearchUrl?: SearchUrlParser; 12 | }; 13 | 14 | export function makeAppendBasedSearchEngine( 15 | defaultUrl: string, 16 | baseUrl: string | null, 17 | ): SearchEngine { 18 | const nonNullBaseUrl = baseUrl ?? defaultUrl; 19 | return { 20 | defaultUrl, 21 | 22 | generateSearchUrl(tokens): string { 23 | return nonNullBaseUrl + tokens.join('%20'); 24 | }, 25 | 26 | parseSearchUrl(url): string | null { 27 | if (!url.startsWith(nonNullBaseUrl)) { 28 | return null; 29 | } 30 | const query = url.substring(nonNullBaseUrl.length); 31 | if (query.length === 0) { 32 | return null; 33 | } 34 | return decodeURIComponent(query); 35 | }, 36 | }; 37 | } 38 | 39 | export function makeHashBasedSearchEngine( 40 | defaultUrl: string, 41 | baseUrl: string | null, 42 | ): SearchEngine { 43 | const nonNullBaseUrl = baseUrl ?? defaultUrl; 44 | return { 45 | defaultUrl, 46 | 47 | generateSearchUrl(tokens): string { 48 | const url = new URL(nonNullBaseUrl); 49 | url.hash = tokens.join(' '); 50 | return url.toString(); 51 | }, 52 | 53 | parseSearchUrl(url): string | null { 54 | if (!url.startsWith(nonNullBaseUrl)) { 55 | return null; 56 | } 57 | try { 58 | const searchUrl = new URL(url); 59 | const query = searchUrl.hash; 60 | if (searchUrl.hash.length > 0) { 61 | return decodeURIComponent(query.substring(1, query.length)); 62 | } 63 | } catch {} 64 | return null; 65 | }, 66 | }; 67 | } 68 | 69 | export function makeParamBasedSearchEngine( 70 | defaultUrl: string, 71 | baseUrl: string | null, 72 | queryParamName: string, 73 | ): SearchEngine { 74 | const nonNullBaseUrl = baseUrl ?? defaultUrl; 75 | return { 76 | defaultUrl, 77 | 78 | generateSearchUrl(tokens): string { 79 | const url = new URL(nonNullBaseUrl); 80 | url.searchParams.set(queryParamName, tokens.join(' ')); 81 | return url.toString(); 82 | }, 83 | 84 | parseSearchUrl(url): string | null { 85 | if (!url.startsWith(nonNullBaseUrl)) { 86 | return null; 87 | } 88 | try { 89 | const searchUrl = new URL(url); 90 | const query = searchUrl.searchParams.get(queryParamName); 91 | if (query && query.length > 0) { 92 | return query; 93 | } 94 | } catch {} 95 | return null; 96 | }, 97 | }; 98 | } 99 | 100 | export function makePathBasedSearchEngine( 101 | defaultUrl: string, 102 | baseUrl: string | null, 103 | pathIndicesToParse: number[], 104 | ): SearchEngine { 105 | const nonNullBaseUrl = baseUrl ?? defaultUrl; 106 | return { 107 | defaultUrl, 108 | 109 | generateSearchUrl(tokens): string { 110 | return nonNullBaseUrl + tokens.join('/'); 111 | }, 112 | 113 | parseSearchUrl(url): string | null { 114 | if (!url.startsWith(nonNullBaseUrl)) { 115 | return null; 116 | } 117 | 118 | const searchUrl = parse(url, true); 119 | const query = searchUrl.pathname.substring(1); 120 | if (query.length === 0) { 121 | return null; 122 | } 123 | 124 | const querySegments = query.split('/').map((s) => s.trim()); 125 | const interestingSegments = pathIndicesToParse 126 | .map((i) => querySegments[i]) 127 | .filter((s) => typeof s !== 'undefined' && s.length > 0); 128 | 129 | if (interestingSegments.length === 0) { 130 | return null; 131 | } 132 | return interestingSegments.join('/'); 133 | }, 134 | }; 135 | } 136 | 137 | // Exported for tests 138 | export const searchEngines: SearchEngine[] = []; 139 | 140 | function parseSearchQuery(searchUrl: string): string | null { 141 | for (const engine of searchEngines) { 142 | const query = engine.parseSearchUrl?.(searchUrl); 143 | if (query) { 144 | return query; 145 | } 146 | } 147 | return null; 148 | } 149 | 150 | export class SearchEngineHandler extends FunctionHandler { 151 | constructor(docstring: string, searchEngine: SearchEngine) { 152 | searchEngines.push(searchEngine); 153 | super(docstring, (tokens) => { 154 | if (tokens.length === 0) { 155 | return redirect(searchEngine.defaultUrl); 156 | } 157 | 158 | // Transform existing search if possible 159 | const query = parseSearchQuery(tokens.join(' ')); 160 | let queryTokens = tokens; 161 | if (query !== null) { 162 | queryTokens = [query]; 163 | } 164 | return redirect(searchEngine.generateSearchUrl(queryTokens)); 165 | }); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`neh should handle "1%20+%201" "%20"-spaced query correctly 1`] = ` 4 | Response { 5 | "body": null, 6 | "bodyUsed": false, 7 | "headers": Headers { 8 | "_map": Map { 9 | "location" => "https://duckduckgo.com/?q=1+%2B+1", 10 | }, 11 | }, 12 | "method": "GET", 13 | "ok": false, 14 | "redirected": false, 15 | "status": 302, 16 | "statusText": "OK", 17 | "type": "basic", 18 | "url": "http://example.com/asset", 19 | } 20 | `; 21 | 22 | exports[`neh should handle "1+%2B+1" "+"-spaced query correctly 1`] = ` 23 | Response { 24 | "body": null, 25 | "bodyUsed": false, 26 | "headers": Headers { 27 | "_map": Map { 28 | "location" => "https://duckduckgo.com/?q=1+%2B+1", 29 | }, 30 | }, 31 | "method": "GET", 32 | "ok": false, 33 | "redirected": false, 34 | "status": 302, 35 | "statusText": "OK", 36 | "type": "basic", 37 | "url": "http://example.com/asset", 38 | } 39 | `; 40 | 41 | exports[`neh should handle "drive" "%20"-spaced query correctly 1`] = ` 42 | Response { 43 | "body": null, 44 | "bodyUsed": false, 45 | "headers": Headers { 46 | "_map": Map { 47 | "location" => "https://drive.google.com", 48 | }, 49 | }, 50 | "method": "GET", 51 | "ok": false, 52 | "redirected": false, 53 | "status": 302, 54 | "statusText": "OK", 55 | "type": "basic", 56 | "url": "http://example.com/asset", 57 | } 58 | `; 59 | 60 | exports[`neh should handle "drive" "+"-spaced query correctly 1`] = ` 61 | Response { 62 | "body": null, 63 | "bodyUsed": false, 64 | "headers": Headers { 65 | "_map": Map { 66 | "location" => "https://drive.google.com", 67 | }, 68 | }, 69 | "method": "GET", 70 | "ok": false, 71 | "redirected": false, 72 | "status": 302, 73 | "statusText": "OK", 74 | "type": "basic", 75 | "url": "http://example.com/asset", 76 | } 77 | `; 78 | 79 | exports[`neh should handle "drive%202" "%20"-spaced query correctly 1`] = ` 80 | Response { 81 | "body": null, 82 | "bodyUsed": false, 83 | "headers": Headers { 84 | "_map": Map { 85 | "location" => "https://drive.google.com/drive/u/2/", 86 | }, 87 | }, 88 | "method": "GET", 89 | "ok": false, 90 | "redirected": false, 91 | "status": 302, 92 | "statusText": "OK", 93 | "type": "basic", 94 | "url": "http://example.com/asset", 95 | } 96 | `; 97 | 98 | exports[`neh should handle "drive+2" "+"-spaced query correctly 1`] = ` 99 | Response { 100 | "body": null, 101 | "bodyUsed": false, 102 | "headers": Headers { 103 | "_map": Map { 104 | "location" => "https://drive.google.com/drive/u/2/", 105 | }, 106 | }, 107 | "method": "GET", 108 | "ok": false, 109 | "redirected": false, 110 | "status": 302, 111 | "statusText": "OK", 112 | "type": "basic", 113 | "url": "http://example.com/asset", 114 | } 115 | `; 116 | 117 | exports[`neh should handle "ip" "%20"-spaced query correctly 1`] = ` 118 | Response { 119 | "body": null, 120 | "bodyUsed": false, 121 | "headers": Headers { 122 | "_map": Map { 123 | "location" => "https://icanhazip.com", 124 | }, 125 | }, 126 | "method": "GET", 127 | "ok": false, 128 | "redirected": false, 129 | "status": 302, 130 | "statusText": "OK", 131 | "type": "basic", 132 | "url": "http://example.com/asset", 133 | } 134 | `; 135 | 136 | exports[`neh should handle "ip" "+"-spaced query correctly 1`] = ` 137 | Response { 138 | "body": null, 139 | "bodyUsed": false, 140 | "headers": Headers { 141 | "_map": Map { 142 | "location" => "https://icanhazip.com", 143 | }, 144 | }, 145 | "method": "GET", 146 | "ok": false, 147 | "redirected": false, 148 | "status": 302, 149 | "statusText": "OK", 150 | "type": "basic", 151 | "url": "http://example.com/asset", 152 | } 153 | `; 154 | 155 | exports[`neh should handle "lyrics%20here's%20to%20never%20growing%20up" "%20"-spaced query correctly 1`] = ` 156 | Response { 157 | "body": null, 158 | "bodyUsed": false, 159 | "headers": Headers { 160 | "_map": Map { 161 | "location" => "https://duckduckgo.com/?q=%5Csite:genius.com%20here's%20to%20never%20growing%20up", 162 | }, 163 | }, 164 | "method": "GET", 165 | "ok": false, 166 | "redirected": false, 167 | "status": 302, 168 | "statusText": "OK", 169 | "type": "basic", 170 | "url": "http://example.com/asset", 171 | } 172 | `; 173 | 174 | exports[`neh should handle "lyrics+here's+to+never+growing+up" "+"-spaced query correctly 1`] = ` 175 | Response { 176 | "body": null, 177 | "bodyUsed": false, 178 | "headers": Headers { 179 | "_map": Map { 180 | "location" => "https://duckduckgo.com/?q=%5Csite:genius.com%20here's%20to%20never%20growing%20up", 181 | }, 182 | }, 183 | "method": "GET", 184 | "ok": false, 185 | "redirected": false, 186 | "status": 302, 187 | "statusText": "OK", 188 | "type": "basic", 189 | "url": "http://example.com/asset", 190 | } 191 | `; 192 | 193 | exports[`neh should handle "npm%20p%20@babel/core" "%20"-spaced query correctly 1`] = ` 194 | Response { 195 | "body": null, 196 | "bodyUsed": false, 197 | "headers": Headers { 198 | "_map": Map { 199 | "location" => "https://www.npmjs.com/package/@babel/core", 200 | }, 201 | }, 202 | "method": "GET", 203 | "ok": false, 204 | "redirected": false, 205 | "status": 302, 206 | "statusText": "OK", 207 | "type": "basic", 208 | "url": "http://example.com/asset", 209 | } 210 | `; 211 | 212 | exports[`neh should handle "npm+p+@babel/core" "+"-spaced query correctly 1`] = ` 213 | Response { 214 | "body": null, 215 | "bodyUsed": false, 216 | "headers": Headers { 217 | "_map": Map { 218 | "location" => "https://www.npmjs.com/package/@babel/core", 219 | }, 220 | }, 221 | "method": "GET", 222 | "ok": false, 223 | "redirected": false, 224 | "status": 302, 225 | "statusText": "OK", 226 | "type": "basic", 227 | "url": "http://example.com/asset", 228 | } 229 | `; 230 | 231 | exports[`neh should handle "search%20query" "%20"-spaced query correctly 1`] = ` 232 | Response { 233 | "body": null, 234 | "bodyUsed": false, 235 | "headers": Headers { 236 | "_map": Map { 237 | "location" => "https://duckduckgo.com/?q=search+query", 238 | }, 239 | }, 240 | "method": "GET", 241 | "ok": false, 242 | "redirected": false, 243 | "status": 302, 244 | "statusText": "OK", 245 | "type": "basic", 246 | "url": "http://example.com/asset", 247 | } 248 | `; 249 | 250 | exports[`neh should handle "search+query" "+"-spaced query correctly 1`] = ` 251 | Response { 252 | "body": null, 253 | "bodyUsed": false, 254 | "headers": Headers { 255 | "_map": Map { 256 | "location" => "https://duckduckgo.com/?q=search+query", 257 | }, 258 | }, 259 | "method": "GET", 260 | "ok": false, 261 | "redirected": false, 262 | "status": 302, 263 | "statusText": "OK", 264 | "type": "basic", 265 | "url": "http://example.com/asset", 266 | } 267 | `; 268 | 269 | exports[`neh should handle "trzh%20hong%20kong" "%20"-spaced query correctly 1`] = ` 270 | Response { 271 | "body": null, 272 | "bodyUsed": false, 273 | "headers": Headers { 274 | "_map": Map { 275 | "location" => "https://fanyi.baidu.com/#en/zh/hong%20kong", 276 | }, 277 | }, 278 | "method": "GET", 279 | "ok": false, 280 | "redirected": false, 281 | "status": 302, 282 | "statusText": "OK", 283 | "type": "basic", 284 | "url": "http://example.com/asset", 285 | } 286 | `; 287 | 288 | exports[`neh should handle "trzh+hong+kong" "+"-spaced query correctly 1`] = ` 289 | Response { 290 | "body": null, 291 | "bodyUsed": false, 292 | "headers": Headers { 293 | "_map": Map { 294 | "location" => "https://fanyi.baidu.com/#en/zh/hong%20kong", 295 | }, 296 | }, 297 | "method": "GET", 298 | "ok": false, 299 | "redirected": false, 300 | "status": 302, 301 | "statusText": "OK", 302 | "type": "basic", 303 | "url": "http://example.com/asset", 304 | } 305 | `; 306 | -------------------------------------------------------------------------------- /src/handlers/__snapshots__/gh.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`gh handler p handler should return error message if GitHub API returns error 1`] = ` 4 | Response { 5 | "body": Blob { 6 | "parts": Array [ 7 | "GitHub API returned 403 Forbidden. Some message", 8 | ], 9 | "type": "", 10 | }, 11 | "bodyUsed": false, 12 | "headers": Headers { 13 | "_map": Map {}, 14 | }, 15 | "method": "GET", 16 | "ok": false, 17 | "redirected": false, 18 | "status": 500, 19 | "statusText": "OK", 20 | "type": "basic", 21 | "url": "http://example.com/asset", 22 | } 23 | `; 24 | 25 | exports[`gh handler p handler should return error message if GitHub returns empty items array 1`] = ` 26 | Response { 27 | "body": Blob { 28 | "parts": Array [ 29 | "No users found for query some query.", 30 | ], 31 | "type": "", 32 | }, 33 | "bodyUsed": false, 34 | "headers": Headers { 35 | "_map": Map {}, 36 | }, 37 | "method": "GET", 38 | "ok": false, 39 | "redirected": false, 40 | "status": 500, 41 | "statusText": "OK", 42 | "type": "basic", 43 | "url": "http://example.com/asset", 44 | } 45 | `; 46 | 47 | exports[`gh handler r handler should return error message if GitHub API returns error 1`] = ` 48 | Response { 49 | "body": Blob { 50 | "parts": Array [ 51 | "GitHub API returned 403 Forbidden. Some message", 52 | ], 53 | "type": "", 54 | }, 55 | "bodyUsed": false, 56 | "headers": Headers { 57 | "_map": Map {}, 58 | }, 59 | "method": "GET", 60 | "ok": false, 61 | "redirected": false, 62 | "status": 500, 63 | "statusText": "OK", 64 | "type": "basic", 65 | "url": "http://example.com/asset", 66 | } 67 | `; 68 | 69 | exports[`gh handler r handler should return error message if GitHub returns empty items array 1`] = ` 70 | Response { 71 | "body": Blob { 72 | "parts": Array [ 73 | "No repositories found for query some query.", 74 | ], 75 | "type": "", 76 | }, 77 | "bodyUsed": false, 78 | "headers": Headers { 79 | "_map": Map {}, 80 | }, 81 | "method": "GET", 82 | "ok": false, 83 | "redirected": false, 84 | "status": 500, 85 | "statusText": "OK", 86 | "type": "basic", 87 | "url": "http://example.com/asset", 88 | } 89 | `; 90 | -------------------------------------------------------------------------------- /src/handlers/dict.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '../Handler'; 2 | import { SearchEngineHandler, makePathBasedSearchEngine } from '../SearchEngineHandler'; 3 | 4 | const dict = new CommandHandler(); 5 | 6 | const websterHandler = new SearchEngineHandler( 7 | 'navigates to the Merriam-Webster entry for a provided term', 8 | makePathBasedSearchEngine( 9 | 'https://www.merriam-webster.com/', 10 | 'https://www.merriam-webster.com/dictionary/', 11 | [1], 12 | ), 13 | ); 14 | 15 | dict.setDefaultHandler(websterHandler); 16 | 17 | dict.addHandler('webster', websterHandler); 18 | 19 | dict.addHandler( 20 | 'cambridge', 21 | new SearchEngineHandler( 22 | 'navigates to the Cambridge Dictionary entry for a provided term', 23 | makePathBasedSearchEngine( 24 | 'https://dictionary.cambridge.org/', 25 | 'https://dictionary.cambridge.org/dictionary/english/', 26 | [2], 27 | ), 28 | ), 29 | ); 30 | 31 | export default dict; 32 | -------------------------------------------------------------------------------- /src/handlers/docs.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler } from '../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeAppendBasedSearchEngine, 5 | makeHashBasedSearchEngine, 6 | makeParamBasedSearchEngine, 7 | } from '../SearchEngineHandler'; 8 | 9 | const docs = new CommandHandler(); 10 | 11 | docs.setDefaultHandler( 12 | new SearchEngineHandler( 13 | 'does a DuckDuckGo search', 14 | makeParamBasedSearchEngine('https://duckduckgo.com/', null, 'q'), 15 | ), 16 | ); 17 | 18 | docs.addHandler( 19 | 'apple', 20 | new SearchEngineHandler( 21 | 'does a search of Apple Developer documentation', 22 | makeParamBasedSearchEngine( 23 | 'https://developer.apple.com/documentation', 24 | 'https://developer.apple.com/search/', 25 | 'q', 26 | ), 27 | ), 28 | ); 29 | 30 | docs.addHandler( 31 | 'caniuse', 32 | new SearchEngineHandler( 33 | 'does a "Can I Use" search', 34 | makeAppendBasedSearchEngine('https://caniuse.com/', 'https://caniuse.com/#search='), 35 | ), 36 | ); 37 | 38 | docs.addHandler( 39 | 'lodash', 40 | new SearchEngineHandler( 41 | 'navigates to a Lodash method', 42 | makeHashBasedSearchEngine('https://lodash.com/docs', null), 43 | ), 44 | ); 45 | 46 | docs.addHandler( 47 | 'mdn', 48 | new SearchEngineHandler( 49 | 'does an MDN web docs search', 50 | makeParamBasedSearchEngine( 51 | 'https://developer.mozilla.org/en-US/docs/Web/API', 52 | 'https://developer.mozilla.org/en-US/search', 53 | 'q', 54 | ), 55 | ), 56 | ); 57 | 58 | docs.addHandler( 59 | 'np', 60 | new SearchEngineHandler( 61 | 'does a NumPy docs search', 62 | makeParamBasedSearchEngine( 63 | 'https://docs.scipy.org/doc/numpy', 64 | 'https://docs.scipy.org/doc/numpy/search.html', 65 | 'q', 66 | ), 67 | ), 68 | ); 69 | 70 | docs.addHandler( 71 | 'pytorch', 72 | new SearchEngineHandler( 73 | 'does a PyTorch docs search', 74 | makeParamBasedSearchEngine( 75 | 'https://pytorch.org/docs', 76 | 'https://pytorch.org/docs/master/search.html', 77 | 'q', 78 | ), 79 | ), 80 | ); 81 | 82 | docs.addHandler( 83 | 'scipy', 84 | new SearchEngineHandler( 85 | 'does a SciPy docs search', 86 | makeParamBasedSearchEngine( 87 | 'https://docs.scipy.org/doc/scipy/reference/', 88 | 'https://docs.scipy.org/doc/scipy/reference/search.html', 89 | 'q', 90 | ), 91 | ), 92 | ); 93 | 94 | docs.addHandler( 95 | 'tf', 96 | new SearchEngineHandler( 97 | 'does a Tensorflow docs search', 98 | makeParamBasedSearchEngine( 99 | 'https://www.tensorflow.org/api_docs/python/tf', 100 | 'https://www.tensorflow.org/s/results', 101 | 'q', 102 | ), 103 | ), 104 | ); 105 | 106 | export default docs; 107 | -------------------------------------------------------------------------------- /src/handlers/gh.test.ts: -------------------------------------------------------------------------------- 1 | import fetchMock from 'jest-fetch-mock'; 2 | import handler from './gh'; 3 | 4 | describe('gh handler', () => { 5 | describe('p handler', () => { 6 | test('should redirect if at least one user is returned', async () => { 7 | fetchMock.enableMocks(); 8 | 9 | const htmlUrl = 'https://github.com/taneliang'; 10 | const expectedJsonResponse = { items: [{ html_url: htmlUrl }] }; 11 | fetchMock.mockOnce(async () => JSON.stringify(expectedJsonResponse)); 12 | 13 | const response = await handler.handle(['p', 'some query']); 14 | expect(response.status).toBe(302); 15 | expect(response.headers.get('location')).toEqual(htmlUrl); 16 | 17 | fetchMock.disableMocks(); 18 | }); 19 | 20 | test('should return error message if GitHub returns empty items array', async () => { 21 | fetchMock.enableMocks(); 22 | 23 | const expectedJsonResponse = { items: [] }; 24 | fetchMock.mockOnce(async () => JSON.stringify(expectedJsonResponse)); 25 | 26 | const response = await handler.handle(['p', 'some query']); 27 | expect(response).toMatchSnapshot(); 28 | 29 | fetchMock.disableMocks(); 30 | }); 31 | 32 | test('should return error message if GitHub API returns error', async () => { 33 | fetchMock.enableMocks(); 34 | 35 | fetchMock.mockOnce(async () => ({ 36 | init: { 37 | status: 403, 38 | statusText: 'Forbidden', 39 | }, 40 | body: 'Some message', 41 | })); 42 | 43 | const response = await handler.handle(['p', 'some query']); 44 | expect(response).toMatchSnapshot(); 45 | 46 | fetchMock.disableMocks(); 47 | }); 48 | }); 49 | 50 | describe('r handler', () => { 51 | test('should redirect if at least one repository is returned', async () => { 52 | fetchMock.enableMocks(); 53 | 54 | const htmlUrl = 'https://github.com/taneliang/neh'; 55 | const expectedJsonResponse = { items: [{ html_url: htmlUrl }] }; 56 | fetchMock.mockOnce(async () => JSON.stringify(expectedJsonResponse)); 57 | 58 | const response = await handler.handle(['r', 'some query']); 59 | expect(response.status).toBe(302); 60 | expect(response.headers.get('location')).toEqual(htmlUrl); 61 | 62 | fetchMock.disableMocks(); 63 | }); 64 | 65 | test('should return error message if GitHub returns empty items array', async () => { 66 | fetchMock.enableMocks(); 67 | 68 | const expectedJsonResponse = { items: [] }; 69 | fetchMock.mockOnce(async () => JSON.stringify(expectedJsonResponse)); 70 | 71 | const response = await handler.handle(['r', 'some query']); 72 | expect(response).toMatchSnapshot(); 73 | 74 | fetchMock.disableMocks(); 75 | }); 76 | 77 | test('should return error message if GitHub API returns error', async () => { 78 | fetchMock.enableMocks(); 79 | 80 | fetchMock.mockOnce(async () => ({ 81 | init: { 82 | status: 403, 83 | statusText: 'Forbidden', 84 | }, 85 | body: 'Some message', 86 | })); 87 | 88 | const response = await handler.handle(['r', 'some query']); 89 | expect(response).toMatchSnapshot(); 90 | 91 | fetchMock.disableMocks(); 92 | }); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/handlers/gh.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, FunctionHandler, RedirectHandler, HandlerFn } from '../Handler'; 2 | import { SearchEngineHandler, makeParamBasedSearchEngine } from '../SearchEngineHandler'; 3 | import { redirect } from '../util'; 4 | 5 | const gh = new CommandHandler(); 6 | const ghHomeUrl = 'https://github.com/'; 7 | 8 | gh.setNothingHandler(new RedirectHandler('navigates to GitHub', ghHomeUrl)); 9 | 10 | gh.setDefaultHandler( 11 | new SearchEngineHandler( 12 | 'does a GitHub search', 13 | makeParamBasedSearchEngine(ghHomeUrl, 'https://github.com/search', 'q'), 14 | ), 15 | ); 16 | 17 | const makeGitHubSearchHandlerFn = (resource: string): HandlerFn => async ( 18 | tokens, 19 | ): Promise => { 20 | const query = tokens.join('+'); 21 | const url = new URL(`https://api.github.com/search/${resource}`); 22 | url.searchParams.set('q', query); 23 | url.searchParams.set('per_page', '1'); 24 | const searchUrl = url.toString(); 25 | 26 | const response = await fetch(searchUrl, { 27 | headers: { 28 | 'User-Agent': 'neh.eltan.net', 29 | 'Content-Type': 'application/json', 30 | }, 31 | }); 32 | 33 | if (!response.ok) { 34 | const body = await response.text(); 35 | return new Response(`GitHub API returned ${response.status} ${response.statusText}. ${body}`, { 36 | status: 500, 37 | }); 38 | } 39 | 40 | const { items } = await response.json(); 41 | if (items.length === 0) { 42 | return new Response(`No ${resource} found for query ${query}.`, { status: 500 }); 43 | } 44 | 45 | return redirect(items[0].html_url); 46 | }; 47 | 48 | gh.addHandler( 49 | 'p', 50 | new FunctionHandler( 51 | 'navigates to a GitHub user/organization profile', 52 | makeGitHubSearchHandlerFn('users'), 53 | ), 54 | ); 55 | 56 | gh.addHandler( 57 | 'r', 58 | new FunctionHandler( 59 | 'navigates to a GitHub repository', 60 | makeGitHubSearchHandlerFn('repositories'), 61 | ), 62 | ); 63 | 64 | export default gh; 65 | -------------------------------------------------------------------------------- /src/handlers/gl.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeParamBasedSearchEngine, 5 | makePathBasedSearchEngine, 6 | } from '../SearchEngineHandler'; 7 | 8 | const gl = new CommandHandler(); 9 | const glHomeUrl = 'https://gitlab.com/'; 10 | 11 | gl.setNothingHandler(new RedirectHandler('navigates to GitLab', glHomeUrl)); 12 | 13 | gl.setDefaultHandler( 14 | new SearchEngineHandler( 15 | 'does a GitLab search', 16 | makeParamBasedSearchEngine(glHomeUrl, 'https://gitlab.com/search', 'search'), 17 | ), 18 | ); 19 | 20 | const glPathEngine = makePathBasedSearchEngine( 21 | glHomeUrl, 22 | null, 23 | [0, 1, 2, 3, 4, 5, 6, 7], // GitLab supports nesting, so we'll just have to whack a bunch of indices 24 | ); 25 | 26 | gl.addHandler('p', new SearchEngineHandler('navigates to a GitLab user profile', glPathEngine)); 27 | 28 | gl.addHandler('r', new SearchEngineHandler('navigates to a GitLab repo', glPathEngine)); 29 | 30 | export default gl; 31 | -------------------------------------------------------------------------------- /src/handlers/ibank.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../Handler'; 2 | 3 | const ibank = new CommandHandler(); 4 | 5 | const banks = { 6 | cimb: ['CIMB Clicks Singapore', 'https://www.cimbclicks.com.sg/clicks/'], 7 | citi: [ 8 | 'Citibank Online Singapore', 9 | 'https://www.citibank.com.sg/SGGCB/JSO/signon/DisplayUsernameSignon.do', 10 | ], 11 | dbs: ['DBS Singapore digibank', 'https://internet-banking.dbs.com.sg/IB/Welcome'], 12 | dbsideal: ['DBS IDEAL', 'https://ideal.dbs.com/'], 13 | hsbc: ['HSBC Singapore Online Banking', 'https://www.hsbc.com.sg/security/'], 14 | maybank: [ 15 | 'Maybank Singapore Online Banking', 16 | 'https://sslsecure.maybank.com.sg/cgi-bin/mbs/scripts/mbb_login.jsp', 17 | ], 18 | ocbc: ['OCBC Online Banking', 'https://internet.ocbc.com/internet-banking/'], 19 | rhb: ['RHB Singapore Internet Banking', 'https://logon.rhbbank.com.sg/'], 20 | sc: ['Standard Chartered Singapore online banking', 'https://ibank.standardchartered.com.sg/'], 21 | uob: ['UOB Personal Internet Banking', 'https://pib.uob.com.sg/'], 22 | }; 23 | 24 | Object.entries(banks).forEach(([command, [product, targetUrl]]) => 25 | ibank.addHandler(command, new RedirectHandler(`navigates to ${product}`, targetUrl)), 26 | ); 27 | 28 | export default ibank; 29 | -------------------------------------------------------------------------------- /src/handlers/index.test.ts: -------------------------------------------------------------------------------- 1 | import handler from '.'; 2 | 3 | describe('neh global handler', () => { 4 | test('should respond to root with list', async () => { 5 | const homeResponse = await handler.handle([]); 6 | const listResponse = await handler.handle(['list']); 7 | expect(homeResponse).toEqual(listResponse); 8 | }); 9 | 10 | test('should default to DuckDuckGo', async () => { 11 | const tokens = ['search', 'query']; 12 | const homeResponse = await handler.handle(tokens); 13 | const dResponse = await handler.handle(['d', ...tokens]); 14 | expect(homeResponse).toEqual(dResponse); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/handlers/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, FunctionHandler, RedirectHandler } from '../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeAppendBasedSearchEngine, 5 | makeParamBasedSearchEngine, 6 | makePathBasedSearchEngine, 7 | } from '../SearchEngineHandler'; 8 | import { redirect } from '../util'; 9 | 10 | import dictHandler from './dict'; 11 | import docsHandler from './docs'; 12 | import ghHandler from './gh'; 13 | import glHandler from './gl'; 14 | import ibankHandler from './ibank'; 15 | import ironHandler from './iron'; 16 | import npmHandler from './npm'; 17 | import nusHandler from './nus'; 18 | import rdHandler from './rd'; 19 | import twHandler from './tw'; 20 | 21 | import makeListHandler from './list'; 22 | 23 | const neh = new CommandHandler(); 24 | 25 | // Handlers with their own files 26 | neh.addHandler('dict', dictHandler); 27 | neh.addHandler('docs', docsHandler); 28 | neh.addHandler('gh', ghHandler); 29 | neh.addHandler('gl', glHandler); 30 | neh.addHandler('ibank', ibankHandler); 31 | neh.addHandler('iron', ironHandler); 32 | neh.addHandler('npm', npmHandler); 33 | neh.addHandler('nus', nusHandler); 34 | neh.addHandler('rd', rdHandler); 35 | neh.addHandler('tw', twHandler); 36 | 37 | const listHandler = makeListHandler(neh); 38 | neh.addHandler('list', listHandler); 39 | neh.setNothingHandler(listHandler); 40 | 41 | // Remaining handlers 42 | 43 | neh.addHandler( 44 | 'bp', 45 | new SearchEngineHandler( 46 | 'does a Bundlephobia bundle size search', 47 | makeParamBasedSearchEngine('https://bundlephobia.com/', 'https://bundlephobia.com/result', 'p'), 48 | ), 49 | ); 50 | 51 | neh.addHandler('cf', new RedirectHandler('navigates to Cloudflare', 'https://dash.cloudflare.com')); 52 | 53 | const dHandler = new SearchEngineHandler( 54 | 'does a DuckDuckGo search', 55 | makeParamBasedSearchEngine('https://duckduckgo.com/', null, 'q'), 56 | ); 57 | neh.addHandler('d', dHandler); 58 | neh.setDefaultHandler(dHandler); 59 | 60 | neh.addHandler( 61 | 'do', 62 | new RedirectHandler('navigates to DigitalOcean', 'https://cloud.digitalocean.com'), 63 | ); 64 | 65 | neh.addHandler( 66 | 'drive', 67 | new FunctionHandler('navigates to Google Drive', (tokens) => { 68 | if (tokens && tokens.length > 0) { 69 | const [accountIndex] = tokens; 70 | return redirect(`https://drive.google.com/drive/u/${accountIndex}/`); 71 | } 72 | return redirect('https://drive.google.com'); 73 | }), 74 | ); 75 | 76 | neh.addHandler( 77 | 'fb', 78 | new SearchEngineHandler( 79 | 'does a Facebook search', 80 | makeParamBasedSearchEngine( 81 | 'https://www.facebook.com/', 82 | 'https://www.facebook.com/search/top/', 83 | 'q', 84 | ), 85 | ), 86 | ); 87 | 88 | neh.addHandler( 89 | 'g', 90 | new SearchEngineHandler( 91 | 'does a Google search', 92 | makeParamBasedSearchEngine('https://www.google.com/', 'https://www.google.com/search', 'q'), 93 | ), 94 | ); 95 | 96 | neh.addHandler( 97 | 'ip', 98 | new RedirectHandler('shows your current public IP address', 'https://icanhazip.com'), 99 | ); 100 | 101 | neh.addHandler( 102 | 'lyrics', 103 | new SearchEngineHandler( 104 | "navigates to a song's lyrics on Genius", 105 | makeAppendBasedSearchEngine( 106 | 'https://genius.com', 107 | 'https://duckduckgo.com/?q=%5Csite:genius.com%20', 108 | ), 109 | ), 110 | ); 111 | 112 | neh.addHandler( 113 | 'nixo', 114 | new SearchEngineHandler( 115 | 'does a NixOS option search', 116 | makeParamBasedSearchEngine('https://search.nixos.org/options?channel=unstable', null, 'query'), 117 | ), 118 | ); 119 | 120 | neh.addHandler( 121 | 'nixp', 122 | new SearchEngineHandler( 123 | 'does a NixOS package search', 124 | makeParamBasedSearchEngine('https://search.nixos.org/packages?channel=unstable', null, 'query'), 125 | ), 126 | ); 127 | 128 | neh.addHandler( 129 | 'nm', 130 | new SearchEngineHandler( 131 | 'does an NUSMods search', 132 | makeParamBasedSearchEngine( 133 | 'https://nusmods.com/', 134 | 'https://nusmods.com/modules?sem[0]=1&sem[1]=2&sem[2]=3&sem[3]=4', 135 | 'q', 136 | ), 137 | ), 138 | ); 139 | 140 | neh.addHandler( 141 | 'pb', 142 | new RedirectHandler('navigates to Productboard', 'https://app.productboard.com'), 143 | ); 144 | 145 | neh.addHandler( 146 | 'rtm', 147 | new RedirectHandler('navigates to Remember the Milk', 'https://www.rememberthemilk.com'), 148 | ); 149 | 150 | neh.addHandler( 151 | 'so', 152 | new SearchEngineHandler( 153 | 'does a StackOverflow search', 154 | makeParamBasedSearchEngine( 155 | 'https://stackoverflow.com', 156 | 'https://stackoverflow.com/search', 157 | 'q', 158 | ), 159 | ), 160 | ); 161 | 162 | neh.addHandler( 163 | 'speedtest', 164 | new RedirectHandler( 165 | "navigates to fast.com; Netflix's Internet speedtest service", 166 | 'https://fast.com/', 167 | ), 168 | ); 169 | 170 | neh.addHandler( 171 | 'tld', 172 | new SearchEngineHandler( 173 | 'navigates to a top-level domain price list on TLD List', 174 | makePathBasedSearchEngine('https://tld-list.com/', 'https://tld-list.com/tld/', [1]), 175 | ), 176 | ); 177 | 178 | neh.addHandler( 179 | 'tren', 180 | new SearchEngineHandler( 181 | 'translate text to English using Google Translate', 182 | makeAppendBasedSearchEngine( 183 | 'https://translate.google.com', 184 | 'https://translate.google.com/#view=home&op=translate&sl=auto&tl=en&text=', 185 | ), 186 | ), 187 | ); 188 | 189 | neh.addHandler( 190 | 'trzh', 191 | new SearchEngineHandler( 192 | 'translate text to/from Chinese using 百度翻译', 193 | makeAppendBasedSearchEngine('https://fanyi.baidu.com', 'https://fanyi.baidu.com/#en/zh/'), 194 | ), 195 | ); 196 | 197 | neh.addHandler( 198 | 'wk', 199 | new SearchEngineHandler( 200 | 'English Wikipedia search', 201 | makeParamBasedSearchEngine( 202 | 'https://en.wikipedia.org', 203 | 'https://en.wikipedia.org/w/index.php', 204 | 'search', 205 | ), 206 | ), 207 | ); 208 | 209 | neh.addHandler( 210 | 'yt', 211 | new SearchEngineHandler( 212 | 'does a YouTube search', 213 | makeParamBasedSearchEngine( 214 | 'https://www.youtube.com', 215 | 'https://www.youtube.com/results', 216 | 'search_query', 217 | ), 218 | ), 219 | ); 220 | 221 | neh.addHandler( 222 | 'yub', 223 | new SearchEngineHandler( 224 | 'run a YubNub command', 225 | makeParamBasedSearchEngine('https://yubnub.org', 'https://yubnub.org/parser/parse', 'command'), 226 | ), 227 | ); 228 | 229 | export default neh; 230 | -------------------------------------------------------------------------------- /src/handlers/iron/gh.test.ts: -------------------------------------------------------------------------------- 1 | import ghHandler from './gh'; 2 | 3 | describe('iron gh', () => { 4 | describe('default handler', () => { 5 | test('should redirect to GitHub if no tokens provided', async () => { 6 | const response = await ghHandler.handle([]); 7 | expect(response.status).toBe(302); 8 | expect(response.headers.get('location')).toMatchInlineSnapshot( 9 | `"https://github.com/Ironclad/ironclad"`, 10 | ); 11 | }); 12 | 13 | test('should redirect to GitHub search if provided token is not a GitHub PR number', async () => { 14 | const response = await ghHandler.handle(['gravy', '1611']); 15 | expect(response.status).toBe(302); 16 | expect(response.headers.get('location')).toMatchInlineSnapshot( 17 | `"https://github.com/Ironclad/ironclad/pulls?q=is%3Apr+sort%3Aupdated-desc+gravy%201611"`, 18 | ); 19 | }); 20 | 21 | test('should redirect to PR if provided token is a GitHub PR number', async () => { 22 | const response = await ghHandler.handle(['74656']); 23 | expect(response.status).toBe(302); 24 | expect(response.headers.get('location')).toMatchInlineSnapshot( 25 | `"https://github.com/Ironclad/ironclad/pull/74656"`, 26 | ); 27 | }); 28 | 29 | test('should redirect to PR if provided token is a GitHub token', async () => { 30 | const response = await ghHandler.handle(['iron-1701']); 31 | expect(response.status).toBe(302); 32 | expect(response.headers.get('location')).toMatchInlineSnapshot( 33 | `"https://github.com/Ironclad/ironclad/pulls?q=is%3Apr+sort%3Aupdated-desc+iron-1701"`, 34 | ); 35 | }); 36 | }); 37 | 38 | describe('pr handler', () => { 39 | test('should redirect to GitHub PRs if no tokens provided', async () => { 40 | const response = await ghHandler.handle(['pr']); 41 | expect(response.status).toBe(302); 42 | expect(response.headers.get('location')).toMatchInlineSnapshot( 43 | `"https://github.com/Ironclad/ironclad/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc"`, 44 | ); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/handlers/iron/gh.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeParamBasedSearchEngine, 5 | makeAppendBasedSearchEngine, 6 | } from '../../SearchEngineHandler'; 7 | 8 | const gh = new CommandHandler(); 9 | const repoUrl = 'https://github.com/Ironclad/ironclad'; 10 | 11 | gh.setNothingHandler(new RedirectHandler('navigates to GitHub', repoUrl)); 12 | 13 | gh.addHandler( 14 | 'f', 15 | new SearchEngineHandler( 16 | 'does a filename search of the GitHub repo', 17 | makeAppendBasedSearchEngine( 18 | repoUrl, 19 | 'https://github.com/search?type=code&q=repo%3aIronclad/ironclad%20filename%3a', 20 | ), 21 | ), 22 | ); 23 | 24 | const baseSearchEngine = makeAppendBasedSearchEngine( 25 | `${repoUrl}/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc`, 26 | `${repoUrl}/pulls?q=is%3Apr+sort%3Aupdated-desc+`, 27 | ); 28 | const prHandler = new SearchEngineHandler('navigates to a specific PR or does a PR search', { 29 | ...baseSearchEngine, 30 | generateSearchUrl(tokens): string { 31 | const prNumberString = tokens[0]; 32 | const prNumber = parseInt(prNumberString, 10); 33 | if (!isNaN(prNumber)) { 34 | return `${repoUrl}/pull/${prNumber}`; 35 | } 36 | return baseSearchEngine.generateSearchUrl(tokens); 37 | }, 38 | }); 39 | gh.setDefaultHandler(prHandler); 40 | gh.addHandler('pr', prHandler); 41 | 42 | gh.addHandler( 43 | 's', 44 | new SearchEngineHandler( 45 | 'does a string search of the GitHub repo', 46 | makeParamBasedSearchEngine(repoUrl, `${repoUrl}/search`, 'q'), 47 | ), 48 | ); 49 | 50 | export default gh; 51 | -------------------------------------------------------------------------------- /src/handlers/iron/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../../Handler'; 2 | import { makeParamBasedSearchEngine, SearchEngineHandler } from '../../SearchEngineHandler'; 3 | 4 | import ghHandler from './gh'; 5 | import jiraHandler, { jiraSearchEngineHandler } from './jira'; 6 | 7 | const iron = new CommandHandler(); 8 | 9 | iron.addHandler('gh', ghHandler); 10 | iron.addHandler('jira', jiraHandler); 11 | 12 | const ironcladHomeUrl = 'https://ironcladapp.com'; 13 | iron.setNothingHandler(new RedirectHandler('navigates to Ironclad', ironcladHomeUrl)); 14 | 15 | iron.setDefaultHandler(jiraSearchEngineHandler); 16 | 17 | iron.addHandler( 18 | 'conf', 19 | new SearchEngineHandler( 20 | 'does a Confluence search', 21 | makeParamBasedSearchEngine( 22 | 'https://ironcladapp.atlassian.net/wiki/home', 23 | 'https://ironcladapp.atlassian.net/wiki/search', 24 | 'text', 25 | ), 26 | ), 27 | ); 28 | 29 | export default iron; 30 | -------------------------------------------------------------------------------- /src/handlers/iron/jira.test.ts: -------------------------------------------------------------------------------- 1 | import { jiraSearchEngineHandler } from './jira'; 2 | 3 | describe('jiraSearchEngineHandler', () => { 4 | test('should redirect to Jira if no tokens provided', async () => { 5 | const response = await jiraSearchEngineHandler.handle([]); 6 | expect(response.status).toBe(302); 7 | expect(response.headers.get('location')).toMatchInlineSnapshot( 8 | `"https://ironcladapp.atlassian.net/jira/your-work"`, 9 | ); 10 | }); 11 | 12 | test('should redirect to Jira search if provided token is not a Jira token', async () => { 13 | const response = await jiraSearchEngineHandler.handle(['gravy', '1611']); 14 | expect(response.status).toBe(302); 15 | expect(response.headers.get('location')).toMatchInlineSnapshot( 16 | `"https://ironcladapp.atlassian.net/secure/QuickSearch.jspa?searchString=gravy+1611"`, 17 | ); 18 | }); 19 | 20 | test('should redirect to ticket if provided token is a Jira ticket number', async () => { 21 | const response = await jiraSearchEngineHandler.handle(['74656']); 22 | expect(response.status).toBe(302); 23 | expect(response.headers.get('location')).toMatchInlineSnapshot( 24 | `"https://ironcladapp.atlassian.net/browse/IRON-74656"`, 25 | ); 26 | }); 27 | 28 | test('should redirect to ticket if provided token is a Jira token', async () => { 29 | const response = await jiraSearchEngineHandler.handle(['iron-1701']); 30 | expect(response.status).toBe(302); 31 | expect(response.headers.get('location')).toMatchInlineSnapshot( 32 | `"https://ironcladapp.atlassian.net/browse/IRON-1701"`, 33 | ); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/handlers/iron/jira.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../../Handler'; 2 | import { makeParamBasedSearchEngine, SearchEngineHandler } from '../../SearchEngineHandler'; 3 | 4 | const jira = new CommandHandler(); 5 | 6 | const defaultUrl = 'https://ironcladapp.atlassian.net/jira/your-work'; 7 | 8 | jira.addHandler( 9 | 'backlog', 10 | new RedirectHandler( 11 | 'navigates to Jira backlog', 12 | 'https://ironcladapp.atlassian.net/secure/RapidBoard.jspa?rapidView=8&view=planning.nodetail&issueLimit=100', 13 | ), 14 | ); 15 | 16 | const baseSearchEngine = makeParamBasedSearchEngine( 17 | defaultUrl, 18 | 'https://ironcladapp.atlassian.net/secure/QuickSearch.jspa', 19 | 'searchString', 20 | ); 21 | export const jiraSearchEngineHandler = new SearchEngineHandler( 22 | 'navigates to a Jira ticket or does a Jira search', 23 | { 24 | ...baseSearchEngine, 25 | generateSearchUrl(tokens): string { 26 | const ticket = tokens[0]; 27 | const ticketNumber = parseInt(ticket, 10); 28 | if (!isNaN(ticketNumber)) { 29 | return `https://ironcladapp.atlassian.net/browse/IRON-${ticketNumber}`; 30 | } 31 | if (ticket.toUpperCase().startsWith('IRON-')) { 32 | return `https://ironcladapp.atlassian.net/browse/${ticket.toUpperCase()}`; 33 | } 34 | return baseSearchEngine.generateSearchUrl(tokens); 35 | }, 36 | }, 37 | ); 38 | 39 | jira.setNothingHandler(new RedirectHandler('navigates to Jira', defaultUrl)); 40 | jira.setDefaultHandler(jiraSearchEngineHandler); 41 | 42 | export default jira; 43 | -------------------------------------------------------------------------------- /src/handlers/list/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Handler, 3 | CommandHandler, 4 | FunctionHandler, 5 | DocObject, 6 | DEFAULT_HANDLER_KEY, 7 | NOTHING_HANDLER_KEY, 8 | } from '../../Handler'; 9 | import css from '../../resources/tailwind.css'; 10 | import listTemplate from './template.pug'; 11 | 12 | export default (neh: CommandHandler): Handler => { 13 | return new FunctionHandler('show the list of methods you can use or search that list', () => { 14 | type PugFriendlyObj = { 15 | command: string; 16 | doc: string | PugFriendlyObj[]; 17 | }; 18 | const mapToPugFriendly = (doc: DocObject): PugFriendlyObj[] => { 19 | return Object.keys(doc).map((command) => ({ 20 | command, 21 | doc: 22 | typeof doc[command] === 'string' 23 | ? (doc[command] as string) 24 | : mapToPugFriendly(doc[command] as DocObject), 25 | })); 26 | }; 27 | 28 | const displayableDoc = mapToPugFriendly(neh.doc); 29 | const html = listTemplate({ 30 | doc: displayableDoc, 31 | DEFAULT_HANDLER_KEY, 32 | NOTHING_HANDLER_KEY, 33 | css, 34 | }); 35 | return new Response(html, { 36 | headers: { 37 | 'content-type': 'text/html;charset=UTF-8', 38 | }, 39 | }); 40 | }); 41 | }; 42 | -------------------------------------------------------------------------------- /src/handlers/list/template.pug: -------------------------------------------------------------------------------- 1 | mixin docTable(doc, nested) 2 | table.text-left.w-full.sm_px-4.nested(class=nested ? "" : " h-full") 3 | thead.uppercase.text-xs.text-gray-700.tracking-wide 4 | tr 5 | th.p-3 Command 6 | th.p-3 Description 7 | tbody 8 | each c in doc 9 | - var hasNestedTable = typeof c.doc === "object"; 10 | tr(class=nested ? "hover_bg-gray-200" : "odd_bg-gray-100") 11 | td.px-3.h-full.align-text-top 12 | if c.command === DEFAULT_HANDLER_KEY 13 | em.py-3 No Matching Command 14 | else if c.command === NOTHING_HANDLER_KEY 15 | em.py-3 No Input 16 | else 17 | .block.h-full 18 | strong.py-3.my-3(class=hasNestedTable ? "sticky top-0" : "")= c.command 19 | if hasNestedTable 20 | td.align-text-top 21 | +docTable(c.doc, true) 22 | else 23 | td.p-3.align-text-top= c.doc 24 | 25 | doctype html 26 | html(lang='en') 27 | head 28 | meta(charset='UTF-8') 29 | meta(name='viewport', content='width=device-width,initial-scale=1') 30 | title= "neh commands" 31 | link( 32 | rel='search', 33 | type='application/opensearchdescription+xml', 34 | title='Neh', 35 | href='/_opensearch' 36 | ) 37 | style 38 | #{css} 39 | body 40 | .py-10.max-w-3xl.mx-auto 41 | h1.px-3.font-black.leading-none.text-6xl.mt-10.mb-8 👇 NEH! 42 | +docTable(doc, false) 43 | p.px-3.mt-10.text-gray-500.text-sm 44 | | Source code available on 45 | | 46 | a(href='/gh%20r%20taneliang/neh') GitHub 47 | | . 48 | -------------------------------------------------------------------------------- /src/handlers/npm.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeParamBasedSearchEngine, 5 | makePathBasedSearchEngine, 6 | } from '../SearchEngineHandler'; 7 | 8 | const npm = new CommandHandler(); 9 | const npmHomeUrl = 'https://www.npmjs.com'; 10 | 11 | npm.setNothingHandler(new RedirectHandler('navigates to NPM', npmHomeUrl)); 12 | 13 | npm.setDefaultHandler( 14 | new SearchEngineHandler( 15 | 'does an NPM search', 16 | makeParamBasedSearchEngine(npmHomeUrl, 'https://www.npmjs.com/search', 'q'), 17 | ), 18 | ); 19 | 20 | npm.addHandler( 21 | 'p', 22 | new SearchEngineHandler( 23 | 'navigates to an NPM package', 24 | makePathBasedSearchEngine( 25 | npmHomeUrl, 26 | 'https://www.npmjs.com/package/', 27 | [1, 2], // Account for @org/pkg-format package names 28 | ), 29 | ), 30 | ); 31 | 32 | export default npm; 33 | -------------------------------------------------------------------------------- /src/handlers/nus/__mocks__/modules.json: -------------------------------------------------------------------------------- 1 | { 2 | "CS3219": { 3 | "luminus": "42f051f9-44d2-4393-8ed5-e79d4a97b8de", 4 | "panopto": "a37a4fec-408e-4e1e-9a89-aabc000a5ce8" 5 | }, 6 | "CS3244": { 7 | "coursemology": "1677", 8 | "luminus": "783480f2-d3be-4587-843d-d73140337bec" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/handlers/nus/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`nus handler coursem should redirect to appropriate path for mod if can be found 1`] = ` 4 | Response { 5 | "body": null, 6 | "bodyUsed": false, 7 | "headers": Headers { 8 | "_map": Map { 9 | "location" => "https://coursemology.org", 10 | }, 11 | }, 12 | "method": "GET", 13 | "ok": false, 14 | "redirected": false, 15 | "status": 302, 16 | "statusText": "OK", 17 | "type": "basic", 18 | "url": "http://example.com/asset", 19 | } 20 | `; 21 | 22 | exports[`nus handler coursem should redirect to appropriate path for mod if can be found 2`] = ` 23 | Response { 24 | "body": null, 25 | "bodyUsed": false, 26 | "headers": Headers { 27 | "_map": Map { 28 | "location" => "https://coursemology.org/courses/1677/", 29 | }, 30 | }, 31 | "method": "GET", 32 | "ok": false, 33 | "redirected": false, 34 | "status": 302, 35 | "statusText": "OK", 36 | "type": "basic", 37 | "url": "http://example.com/asset", 38 | } 39 | `; 40 | 41 | exports[`nus handler coursem should redirect to homepage if no mod provided 1`] = ` 42 | Response { 43 | "body": null, 44 | "bodyUsed": false, 45 | "headers": Headers { 46 | "_map": Map { 47 | "location" => "https://coursemology.org", 48 | }, 49 | }, 50 | "method": "GET", 51 | "ok": false, 52 | "redirected": false, 53 | "status": 302, 54 | "statusText": "OK", 55 | "type": "basic", 56 | "url": "http://example.com/asset", 57 | } 58 | `; 59 | 60 | exports[`nus handler lum should redirect to appropriate path for mod if can be found 1`] = ` 61 | Response { 62 | "body": null, 63 | "bodyUsed": false, 64 | "headers": Headers { 65 | "_map": Map { 66 | "location" => "https://luminus.nus.edu.sg/modules/42f051f9-44d2-4393-8ed5-e79d4a97b8de/", 67 | }, 68 | }, 69 | "method": "GET", 70 | "ok": false, 71 | "redirected": false, 72 | "status": 302, 73 | "statusText": "OK", 74 | "type": "basic", 75 | "url": "http://example.com/asset", 76 | } 77 | `; 78 | 79 | exports[`nus handler lum should redirect to appropriate path for mod if can be found 2`] = ` 80 | Response { 81 | "body": null, 82 | "bodyUsed": false, 83 | "headers": Headers { 84 | "_map": Map { 85 | "location" => "https://luminus.nus.edu.sg/modules/783480f2-d3be-4587-843d-d73140337bec/", 86 | }, 87 | }, 88 | "method": "GET", 89 | "ok": false, 90 | "redirected": false, 91 | "status": 302, 92 | "statusText": "OK", 93 | "type": "basic", 94 | "url": "http://example.com/asset", 95 | } 96 | `; 97 | 98 | exports[`nus handler lum should redirect to homepage if no mod provided 1`] = ` 99 | Response { 100 | "body": null, 101 | "bodyUsed": false, 102 | "headers": Headers { 103 | "_map": Map { 104 | "location" => "https://luminus.nus.edu.sg/dashboard", 105 | }, 106 | }, 107 | "method": "GET", 108 | "ok": false, 109 | "redirected": false, 110 | "status": 302, 111 | "statusText": "OK", 112 | "type": "basic", 113 | "url": "http://example.com/asset", 114 | } 115 | `; 116 | 117 | exports[`nus handler webcast should redirect to appropriate path for mod if can be found 1`] = ` 118 | Response { 119 | "body": null, 120 | "bodyUsed": false, 121 | "headers": Headers { 122 | "_map": Map { 123 | "location" => "https://mediaweb.ap.panopto.com/Panopto/Pages/Sessions/List.aspx#folderID=\\"a37a4fec-408e-4e1e-9a89-aabc000a5ce8\\"", 124 | }, 125 | }, 126 | "method": "GET", 127 | "ok": false, 128 | "redirected": false, 129 | "status": 302, 130 | "statusText": "OK", 131 | "type": "basic", 132 | "url": "http://example.com/asset", 133 | } 134 | `; 135 | 136 | exports[`nus handler webcast should redirect to appropriate path for mod if can be found 2`] = ` 137 | Response { 138 | "body": null, 139 | "bodyUsed": false, 140 | "headers": Headers { 141 | "_map": Map { 142 | "location" => "https://mediaweb.ap.panopto.com/Panopto/Pages/Sessions/List.aspx", 143 | }, 144 | }, 145 | "method": "GET", 146 | "ok": false, 147 | "redirected": false, 148 | "status": 302, 149 | "statusText": "OK", 150 | "type": "basic", 151 | "url": "http://example.com/asset", 152 | } 153 | `; 154 | 155 | exports[`nus handler webcast should redirect to homepage if no mod provided 1`] = ` 156 | Response { 157 | "body": null, 158 | "bodyUsed": false, 159 | "headers": Headers { 160 | "_map": Map { 161 | "location" => "https://mediaweb.ap.panopto.com/Panopto/Pages/Sessions/List.aspx", 162 | }, 163 | }, 164 | "method": "GET", 165 | "ok": false, 166 | "redirected": false, 167 | "status": 302, 168 | "statusText": "OK", 169 | "type": "basic", 170 | "url": "http://example.com/asset", 171 | } 172 | `; 173 | -------------------------------------------------------------------------------- /src/handlers/nus/index.test.ts: -------------------------------------------------------------------------------- 1 | import handler from '.'; 2 | 3 | jest.mock('./modules.json'); 4 | 5 | describe('nus handler', () => { 6 | const subcommands = ['coursem', 'lum', 'webcast']; 7 | subcommands.forEach((subcommand) => { 8 | describe(subcommand, () => { 9 | test('should redirect to homepage if no mod provided', async () => { 10 | const response = await handler.handle([subcommand]); 11 | expect(response).toMatchSnapshot(); 12 | }); 13 | 14 | test('should redirect to appropriate path for mod if can be found', async () => { 15 | let response; 16 | response = await handler.handle([subcommand, 'cs3219']); 17 | expect(response).toMatchSnapshot(); 18 | response = await handler.handle([subcommand, 'cs3244']); 19 | expect(response).toMatchSnapshot(); 20 | }); 21 | 22 | test('should redirect to homepage if mod is provided but cannot be found', async () => { 23 | const homeResponse = await handler.handle([subcommand]); 24 | const modResponse = await handler.handle([subcommand, 'nonsensemod']); 25 | expect(homeResponse).toEqual(modResponse); 26 | }); 27 | }); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/handlers/nus/index.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, FunctionHandler, HandlerFn, RedirectHandler, Token } from '../../Handler'; 2 | import { redirect } from '../../util'; 3 | import { getClosestModule, modules, NUSModBookmarks, NUSModOnlyStringValues } from './nus'; 4 | import { makeParamBasedSearchEngine, SearchEngineHandler } from '../../SearchEngineHandler'; 5 | 6 | const nus = new CommandHandler(); 7 | 8 | nus.setDefaultHandler( 9 | new SearchEngineHandler( 10 | 'does a DuckDuckGo search', 11 | makeParamBasedSearchEngine('https://duckduckgo.com/', null, 'q'), 12 | ), 13 | ); 14 | 15 | nus.addHandler( 16 | 'dochub', 17 | new RedirectHandler( 18 | "navigates to NUS SoC's Documentation Hub", 19 | 'https://dochub.comp.nus.edu.sg/', 20 | ), 21 | ); 22 | 23 | nus.addHandler( 24 | 'emergency', 25 | new RedirectHandler( 26 | "navigates to NUS's emergency circulars", 27 | 'https://emergency.nus.edu.sg/circulars/', 28 | ), 29 | ); 30 | 31 | nus.addHandler( 32 | 'talentconnect', 33 | new RedirectHandler('navigates to NUS TalentConnect', 'https://nus-csm.symplicity.com/'), 34 | ); 35 | 36 | nus.addHandler( 37 | 'temp', 38 | new RedirectHandler( 39 | 'navigates to temperature declaration portal', 40 | 'https://myaces.nus.edu.sg/htd/htd', 41 | ), 42 | ); 43 | 44 | // Module handlers 45 | 46 | const makeModRedirector = ( 47 | defaultUrl: string, 48 | modFieldName: keyof NUSModOnlyStringValues, 49 | modUrlTransformer: (fieldValue: string, otherTokens: Token[]) => string, 50 | ): HandlerFn => (tokens): Response => { 51 | if (tokens.length > 0) { 52 | const [fuzzyModcode, ...otherTokens] = tokens; 53 | const fieldValue = getClosestModule(fuzzyModcode)?.[modFieldName]; 54 | if (fieldValue) { 55 | return redirect(modUrlTransformer(fieldValue, otherTokens)); 56 | } 57 | } 58 | return redirect(defaultUrl); 59 | }; 60 | 61 | nus.addHandler( 62 | 'coursem', 63 | new FunctionHandler( 64 | 'navigates to Coursemology', 65 | makeModRedirector( 66 | 'https://coursemology.org', 67 | 'coursemology', 68 | (fieldValue, otherTokens) => 69 | `https://coursemology.org/courses/${fieldValue}/${otherTokens.join('/')}`, 70 | ), 71 | ), 72 | ); 73 | 74 | nus.addHandler( 75 | 'lum', 76 | new FunctionHandler( 77 | 'navigates to LumiNUS', 78 | makeModRedirector( 79 | 'https://luminus.nus.edu.sg/dashboard', 80 | 'luminus', 81 | (fieldValue, otherTokens) => 82 | `https://luminus.nus.edu.sg/modules/${fieldValue}/${otherTokens.join('/')}`, 83 | ), 84 | ), 85 | ); 86 | 87 | nus.addHandler( 88 | 'webcast', 89 | new FunctionHandler( 90 | "navigates to an NUS module's Panopto webcasts", 91 | makeModRedirector( 92 | 'https://mediaweb.ap.panopto.com/Panopto/Pages/Sessions/List.aspx', 93 | 'panopto', 94 | (fieldValue) => 95 | `https://mediaweb.ap.panopto.com/Panopto/Pages/Sessions/List.aspx#folderID="${fieldValue}"`, 96 | ), 97 | ), 98 | ); 99 | 100 | // Bookmarks 101 | 102 | function makeModBookmarkHandler(modcode: string, bookmarks: NUSModBookmarks): CommandHandler { 103 | const bookmarksHandler = new CommandHandler(); 104 | Object.entries(bookmarks).forEach(([name, url]) => { 105 | bookmarksHandler.addHandler( 106 | name, 107 | new RedirectHandler(`navigates to ${modcode}'s ${name}`, url), 108 | ); 109 | }); 110 | return bookmarksHandler; 111 | } 112 | 113 | Object.entries(modules).forEach( 114 | ([modcode, module]) => 115 | module.bookmarks && 116 | nus.addHandler(modcode.toLowerCase(), makeModBookmarkHandler(modcode, module.bookmarks)), 117 | ); 118 | 119 | export default nus; 120 | -------------------------------------------------------------------------------- /src/handlers/nus/modules.json: -------------------------------------------------------------------------------- 1 | { 2 | "CS2030S": { 3 | "luminus": "c8378b00-daa4-430e-b49f-22f6509a1287" 4 | }, 5 | "CS2040S": { 6 | "coursemology": "2013" 7 | }, 8 | "CS2100": { 9 | "luminus": "6186cea5-8af5-4cc0-987b-1422e4145bc2" 10 | }, 11 | "CS3219": { 12 | "luminus": "42f051f9-44d2-4393-8ed5-e79d4a97b8de" 13 | }, 14 | "CS3230": { 15 | "luminus": "b85e28eb-bab3-4375-b03e-16f0ce45a591" 16 | }, 17 | "CS3244": { 18 | "coursemology": "1677", 19 | "luminus": "783480f2-d3be-4587-843d-d73140337bec" 20 | }, 21 | "CS4211": { 22 | "luminus": "e24ac9c6-1c2b-4c0d-a4d7-91bc3121e5d6" 23 | }, 24 | "CS4246": { 25 | "luminus": "b864e4ca-c625-475b-931e-e48dcd746058" 26 | }, 27 | "CS4231": { 28 | "luminus": "ed35bc67-1722-47f3-ac5a-e9f8bee89515" 29 | }, 30 | "ES2660": { 31 | "luminus": "ea39a802-3aa8-412b-a5b1-5d606612db9e" 32 | }, 33 | "GER1000": { 34 | "luminus": "05f3169d-1473-4435-8a9d-088945a40e8f" 35 | }, 36 | "GET1036": { 37 | "luminus": "cca6d37b-76e4-4658-a62b-5bbd90f919d3" 38 | }, 39 | "GET1042": { 40 | "luminus": "6f630b59-1d59-44f8-a70c-1f1023857453", 41 | "bookmarks": { 42 | "files": "https://nusu.sharepoint.com/sites/GET1042SkyandTelescopes1920/Class%20Materials/Forms/AllItems.aspx", 43 | "teams": "https://teams.microsoft.com/_#/tab::1c91b052-23cf-439e-8522-e4b5fadac169/General?threadId=19:93415ed9fcf34d11823e1c55b710cd32@thread.skype&ctx=channel" 44 | } 45 | }, 46 | "IS1103": { 47 | "luminus": "bec9a2ca-49a8-4774-bb0a-c243b85165b5", 48 | "bookmarks": { 49 | "missions": "http://is1103db-i.comp.nus.edu.sg/wordpress/" 50 | } 51 | }, 52 | "LSM1301": { 53 | "luminus": "cd4b5dae-e8b6-4bda-b8ea-ac739559e606" 54 | }, 55 | "AH2101": { 56 | "luminus": "f558bac6-e2ed-4558-b829-b967795403f6" 57 | }, 58 | "ALS1020": { 59 | "luminus": "5be8677f-5081-458a-bc55-fa01902834f1" 60 | }, 61 | "CS2102": { 62 | "coursemology": "1904", 63 | "luminus": "f570a755-a92f-4701-82c4-fb889645a7d0" 64 | }, 65 | "CS2104": { 66 | "luminus": "d2e3f374-3709-40a3-a2bf-722591ea98d4" 67 | }, 68 | "IS4152": { 69 | "luminus": "59fa601c-f93c-4374-bc53-03192760502e", 70 | "panopto": "e6ffcd4a-b0c3-4432-9045-ac16009dfc67" 71 | }, 72 | "PH2241": { 73 | "luminus": "9ac212d2-fc44-4f63-9a65-263a0fe4771f" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/handlers/nus/nus.test.ts: -------------------------------------------------------------------------------- 1 | import { getClosestModcode, getClosestModule, modules } from './nus'; 2 | 3 | jest.mock('./modules.json'); 4 | 5 | describe(getClosestModcode, () => { 6 | it('should return a closest modcode if present', () => { 7 | expect(getClosestModcode('abcdef')).toBeFalsy(); 8 | expect(getClosestModcode('0000')).toBeFalsy(); 9 | expect(getClosestModcode('cs3219')).toEqual('CS3219'); 10 | expect(getClosestModcode('cs32')).toEqual('CS3219'); 11 | expect(getClosestModcode('cs324')).toEqual('CS3244'); 12 | expect(getClosestModcode('324')).toEqual('CS3244'); 13 | }); 14 | }); 15 | 16 | describe(getClosestModule, () => { 17 | it('should return a closest module if present', () => { 18 | expect(getClosestModule('abcdef')).toBeFalsy(); 19 | expect(getClosestModule('424')).toEqual(modules.CS3244); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/handlers/nus/nus.ts: -------------------------------------------------------------------------------- 1 | // National University of Singapore-related data and utils 2 | 3 | import FuzzySet from 'fuzzyset'; 4 | import mods from './modules.json'; 5 | 6 | export type NUSModBookmarks = { [bookmark: string]: string }; 7 | 8 | export type NUSMod = { 9 | coursemology?: string; 10 | luminus?: string; 11 | panopto?: string; 12 | bookmarks?: NUSModBookmarks; 13 | }; 14 | 15 | export type NUSModOnlyStringValues = Omit; 16 | 17 | export const modules: { [modcode: string]: NUSMod } = mods; 18 | 19 | const modcodes = FuzzySet(Object.keys(modules)); 20 | 21 | export function getClosestModcode(fuzzyModcode: string): string | undefined { 22 | const hypotheses = modcodes.get(fuzzyModcode); 23 | if (!hypotheses) return; 24 | return hypotheses[0][1]; 25 | } 26 | 27 | export function getClosestModule(fuzzyModcode: string): NUSMod | undefined { 28 | const modcode = getClosestModcode(fuzzyModcode); 29 | if (!modcode) return; 30 | return modules[modcode]; 31 | } 32 | -------------------------------------------------------------------------------- /src/handlers/rd.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeParamBasedSearchEngine, 5 | makePathBasedSearchEngine, 6 | } from '../SearchEngineHandler'; 7 | 8 | const rd = new CommandHandler(); 9 | const redditHomeUrl = 'https://www.reddit.com'; 10 | 11 | rd.setNothingHandler(new RedirectHandler('navigates to Reddit', redditHomeUrl)); 12 | 13 | rd.setDefaultHandler( 14 | new SearchEngineHandler( 15 | 'does a Reddit search', 16 | makeParamBasedSearchEngine(redditHomeUrl, 'https://www.reddit.com/search', 'q'), 17 | ), 18 | ); 19 | 20 | rd.addHandler( 21 | 'r', 22 | new SearchEngineHandler( 23 | 'navigates to a subreddit', 24 | makePathBasedSearchEngine(redditHomeUrl, 'https://www.reddit.com/r/', [1]), 25 | ), 26 | ); 27 | 28 | export default rd; 29 | -------------------------------------------------------------------------------- /src/handlers/tw.ts: -------------------------------------------------------------------------------- 1 | import { CommandHandler, RedirectHandler } from '../Handler'; 2 | import { 3 | SearchEngineHandler, 4 | makeParamBasedSearchEngine, 5 | makePathBasedSearchEngine, 6 | } from '../SearchEngineHandler'; 7 | 8 | const tw = new CommandHandler(); 9 | const twHomeUrl = 'https://twitter.com/'; 10 | 11 | tw.setNothingHandler(new RedirectHandler('navigates to Twitter', twHomeUrl)); 12 | 13 | tw.setDefaultHandler( 14 | new SearchEngineHandler( 15 | 'does a Twitter search', 16 | makeParamBasedSearchEngine(twHomeUrl, 'https://twitter.com/search', 'q'), 17 | ), 18 | ); 19 | 20 | tw.addHandler( 21 | 'p', 22 | new SearchEngineHandler( 23 | 'navigates to a Twitter user profile', 24 | makePathBasedSearchEngine('https://twitter.com/taneliang', twHomeUrl, [0]), 25 | ), 26 | ); 27 | 28 | export default tw; 29 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { CloudflareWorkerGlobalScope } from 'types-cloudflare-worker'; 2 | declare const self: CloudflareWorkerGlobalScope; 3 | 4 | import { makeCloudflareWorkerRequest } from 'cloudflare-worker-mock'; 5 | 6 | async function getResponse(requestInfo: RequestInfo): Promise { 7 | const request = makeCloudflareWorkerRequest(requestInfo); 8 | return await self.trigger('fetch', request); 9 | } 10 | 11 | describe('neh', () => { 12 | beforeEach(() => { 13 | // Import and init the Worker. 14 | jest.requireActual('.'); 15 | }); 16 | 17 | // Ensure that test env has been set up correctly. 18 | test('should add listeners', async () => { 19 | expect(self.listeners.get('fetch')).toBeDefined(); 20 | }); 21 | 22 | test('should respond to OpenSearch endpoint', async () => { 23 | const response = await getResponse('/_opensearch'); 24 | expect(response.status).toBe(200); 25 | const responseBody = await response.text(); 26 | expect(responseBody).toContain(' inputStr.replace(/ /g, '%20')) 44 | .forEach((inputStr) => { 45 | test(`should handle "${inputStr}" "%20"-spaced query correctly`, async () => { 46 | const response = await getResponse(inputStr); 47 | expect(response).toMatchSnapshot(); 48 | }); 49 | }); 50 | 51 | testCases 52 | .map((inputStr) => inputStr.replace(/\+/g, '%2B').replace(/ /g, '+')) 53 | .forEach((inputStr) => { 54 | test(`should handle "${inputStr}" "+"-spaced query correctly`, async () => { 55 | const response = await getResponse( 56 | new Request(inputStr, { 57 | headers: { 58 | 'User-Agent': 59 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:85.0) Gecko/20100101 Firefox/85.0', 60 | }, 61 | }), 62 | ); 63 | expect(response).toMatchSnapshot(); 64 | }); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import CloudflareWorkerGlobalScope from 'types-cloudflare-worker'; 2 | declare const self: CloudflareWorkerGlobalScope; 3 | 4 | import handler from './handlers'; 5 | import { extractQueryFromUrl, tokenizeQuery } from './util'; 6 | import openSearchDescription from './resources/_opensearch.xml'; 7 | 8 | /** 9 | * Fetch and log a request 10 | */ 11 | async function handleRequest(request: Request): Promise { 12 | const requestURL = new URL(request.url); 13 | if (requestURL.pathname === '/_opensearch') { 14 | return new Response(openSearchDescription, { 15 | headers: { 16 | 'content-type': 'application/opensearchdescription+xml', 17 | }, 18 | }); 19 | } 20 | 21 | // extractQueryFromUrl explains why areSpacesEncodedAsPlus is necessary 22 | const areSpacesEncodedAsPlus = /\bFirefox\b/i.test(request.headers.get('user-agent') || ''); 23 | const query = extractQueryFromUrl(request.url, areSpacesEncodedAsPlus); 24 | 25 | const tokens = tokenizeQuery(query); 26 | return await handler.handle(tokens); 27 | } 28 | 29 | self.addEventListener('fetch', (event) => { 30 | event.respondWith(handleRequest(event.request)); 31 | }); 32 | -------------------------------------------------------------------------------- /src/resources/_opensearch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | Neh 4 | A tool that smartly redirects you around the Interwebs 5 | UTF-8 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/resources/tailwind.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | body { 6 | @apply font-sans; 7 | } 8 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.css'; 2 | 3 | declare module '*.pug' { 4 | const contents: import('pug').compileTemplate; 5 | export = contents; 6 | } 7 | 8 | declare module '*.txt' { 9 | const contents: string; 10 | export = contents; 11 | } 12 | 13 | declare module '*.xml' { 14 | const contents: string; 15 | export = contents; 16 | } 17 | -------------------------------------------------------------------------------- /src/util.test.ts: -------------------------------------------------------------------------------- 1 | import { emptyArray, extractQueryFromUrl, tokenizeQuery } from './util'; 2 | 3 | describe(emptyArray, () => { 4 | test('should empty array in place', () => { 5 | const a = [1, 2, 3]; 6 | emptyArray(a); 7 | expect(a).toEqual([]); 8 | }); 9 | 10 | test('should do nothing if array is empty', () => { 11 | const a: string[] = []; 12 | emptyArray(a); 13 | expect(a).toEqual([]); 14 | }); 15 | }); 16 | 17 | describe(extractQueryFromUrl, () => { 18 | test('should return empty string if no query', () => { 19 | expect(extractQueryFromUrl('https://example.com', false)).toEqual(''); 20 | expect(extractQueryFromUrl('https://example.com/', false)).toEqual(''); 21 | }); 22 | 23 | test('should return query-encoded query from path if present', () => { 24 | expect(extractQueryFromUrl('https://example.com/cmd+token', true)).toEqual('cmd token'); 25 | 26 | expect(extractQueryFromUrl('https://example.com/d+1%2B2', true)).toEqual('d 1+2'); 27 | 28 | expect(extractQueryFromUrl('https://example.com/cmd+https://derp.com', true)).toEqual( 29 | 'cmd https://derp.com', 30 | ); 31 | 32 | expect( 33 | extractQueryFromUrl('https://example.com/cmd+https://derp.com/search?q=query', true), 34 | ).toEqual('cmd https://derp.com/search?q=query'); 35 | 36 | expect( 37 | extractQueryFromUrl('https://example.com/cmd+https://derp.com/search#query', true), 38 | ).toEqual('cmd https://derp.com/search#query'); 39 | }); 40 | 41 | test('should return path-encoded query from path if present', () => { 42 | expect(extractQueryFromUrl('https://example.com/cmd%20token', false)).toEqual('cmd token'); 43 | 44 | expect(extractQueryFromUrl('https://example.com/d%201+2', false)).toEqual('d 1+2'); 45 | 46 | expect(extractQueryFromUrl('https://example.com/cmd%20https://derp.com', false)).toEqual( 47 | 'cmd https://derp.com', 48 | ); 49 | 50 | expect( 51 | extractQueryFromUrl('https://example.com/cmd%20https://derp.com/search?q=query', false), 52 | ).toEqual('cmd https://derp.com/search?q=query'); 53 | 54 | expect( 55 | extractQueryFromUrl('https://example.com/cmd%20https://derp.com/search#query', false), 56 | ).toEqual('cmd https://derp.com/search#query'); 57 | }); 58 | }); 59 | 60 | describe(tokenizeQuery, () => { 61 | test('should return query tokens', () => { 62 | expect(tokenizeQuery('t1 / https://url/?q=a+b')).toEqual(['t1', '/', 'https://url/?q=a+b']); 63 | }); 64 | 65 | test('should return empty array for empty query', () => { 66 | expect(tokenizeQuery('')).toEqual([]); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import parse from 'url-parse'; 2 | import { Token } from './Handler'; 3 | 4 | export function emptyArray(arr: T[]): void { 5 | // Source: https://stackoverflow.com/a/1232046 6 | arr.splice(0, arr.length); 7 | } 8 | 9 | export function extractQueryFromUrl(urlStr: string, areSpacesEncodedAsPlus: boolean): string { 10 | // Use url-parse instead of URL for pathname as double slashes will be 11 | // removed on Cloudflare by URL. 12 | const parsedUrl = parse(urlStr, true); 13 | 14 | // Browsers encode the query in 2 ways: 15 | // 1. Query, e.g. "token1+%2B+token2". Firefox does this. 16 | // 2. Path, e.g. "token1%20+%20token2". Chrome does this. 17 | const pathname = areSpacesEncodedAsPlus 18 | ? parsedUrl.pathname.replace(/\+/g, ' ') 19 | : parsedUrl.pathname; 20 | 21 | const url = new URL(urlStr); 22 | let query = decodeURIComponent(pathname + url.search + url.hash); 23 | if (query.charAt(0) === '/') { 24 | query = query.substring(1); 25 | } 26 | return query; 27 | } 28 | 29 | export function tokenizeQuery(query: string): Token[] { 30 | return query.split(' ').filter((c) => c); 31 | } 32 | 33 | export function redirect(location: string): Response { 34 | // Don't use Response.redirect due to poor mock support 35 | return new Response(null, { status: 302, headers: { location } }); 36 | } 37 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | separator: '_', 3 | purge: [], 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | backgroundColor: ['responsive', 'hover', 'focus', 'odd'], 9 | }, 10 | plugins: [], 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "alwaysStrict": true, 4 | "esModuleInterop": true, 5 | "lib": ["esnext", "webworker", "es2015.promise"], 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "outDir": "./dist", 9 | "preserveConstEnums": true, 10 | "resolveJsonModule": true, 11 | "sourceMap": true, 12 | "strict": true, 13 | "target": "es2019" 14 | }, 15 | "include": ["./src/typings.d.ts", "./src/*.ts", "./src/**/*.ts"], 16 | "exclude": ["node_modules/", "dist/"] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | 3 | const path = require('path'); 4 | 5 | const mode = process.env.NODE_ENV || 'production'; 6 | 7 | module.exports = { 8 | output: { 9 | filename: `worker.${mode}.js`, 10 | path: path.join(__dirname, 'dist'), 11 | }, 12 | devtool: 'source-map', 13 | mode, 14 | resolve: { 15 | extensions: ['.ts'], 16 | plugins: [], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | loader: 'ts-loader', 23 | }, 24 | { 25 | enforce: 'pre', 26 | test: /\.js$/, 27 | loader: 'source-map-loader', 28 | }, 29 | { 30 | test: /\.(txt|xml)$/i, 31 | use: 'raw-loader', 32 | }, 33 | { 34 | test: /\.pug$/i, 35 | use: 'pug-loader', 36 | }, 37 | { 38 | test: /\.css$/, 39 | use: ['css-loader', 'postcss-loader'], 40 | }, 41 | ], 42 | }, 43 | }; 44 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "neh" 2 | type = "webpack" 3 | webpack_config = "./webpack.config.js" 4 | private = false 5 | route = "neh.eltan.net/*" 6 | workers_dev = false --------------------------------------------------------------------------------