├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 | [](https://circleci.com/gh/taneliang/neh)
4 | [](https://codecov.io/gh/taneliang/neh)
5 | [](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 | 
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
--------------------------------------------------------------------------------