├── .editorconfig ├── .gitattributes ├── .github ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml └── workflows │ ├── publish.yml │ └── test.yml ├── .gitignore ├── .husky ├── pre-commit └── pre-push ├── .mocharc.js ├── LICENSE.md ├── README.md ├── bin ├── completion │ ├── bash │ │ └── tldr │ └── zsh │ │ └── _tldr └── tldr ├── config.json ├── eslint.config.mjs ├── lib ├── cache.js ├── completion.js ├── config.js ├── errors.js ├── index.js ├── parser.js ├── platforms.js ├── remote.js ├── render.js ├── search.js ├── theme.js ├── tldr.js └── utils.js ├── package-lock.json ├── package.json ├── screenshot.png └── test ├── cache.spec.js ├── completion.spec.js ├── config.spec.js ├── functional-test.sh ├── index.spec.js ├── mocha.js ├── parser.spec.js ├── platform.spec.js ├── remote.spec.js ├── render.spec.js ├── search.spec.js ├── theme.spec.js ├── tldr.spec.js └── utils.spec.js /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contribution are most welcome! 4 | We've already accepted a lot of new features, command-line flags, and general bug fixes. 5 | 6 | That being said, if it's something sizeable or a brand new feature, 7 | it's a good idea to open an issue beforehand to discuss it openly and gather some feedback. 8 | 9 | ## Quick design notes 10 | 11 | ``` 12 | bin/tldr 13 | | 14 | lib/tldr 15 | | 16 | | --> platform --> cache --> remote 17 | | 18 | | --> parser --> render 19 | ``` 20 | 21 | ## Dev notes 22 | 23 | The best way to submit a change is a pull-request from a feature branch. 24 | Once you've cloned the project: 25 | 26 | ```bash 27 | $ git checkout -b fix-for-blah 28 | $ npm install 29 | $ npm test 30 | ``` 31 | 32 | Everything should be passing! 33 | Don't forget to keep the tests green on your branch - or to add some where necessary. 34 | 35 | ## License 36 | 37 | `tldr-node-client` is under MIT license, which means you're free to modify or redistribute the source. 38 | That being said, but why not contribute over here? :) 39 | 40 | Also, if you create a new client, don't forget to ping us at 41 | [tldr-pages](https://github.com/tldr-pages) so we can add it to the organisation & in the README. 42 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Expected behavior 2 | Tell us what should happen. 3 | 4 | ## Actual behavior 5 | Tell us what happens instead. 6 | 7 | ## Log, debug output 8 | Put any log or error output here. 9 | 10 | ## Environment 11 | - Operating system - macOS/Linux/Windows (add version if needed) 12 | - Node.js version (`node --version`) 13 | - tldr-node-client version (`tldr --version`) 14 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | Please explain the changes you made here. 3 | 4 | ## Checklist 5 | 6 | Please review this checklist before submitting a pull request. 7 | 8 | - [ ] Code compiles correctly 9 | - [ ] Created tests, if possible 10 | - [ ] All tests passing (`npm run test:all`) 11 | - [ ] Extended the README / documentation, if necessary 12 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | 4 | # Enable version updates for npm 5 | - package-ecosystem: "npm" 6 | directory: "/" 7 | schedule: 8 | interval: "weekly" 9 | 10 | # Enable version updates for GitHub actions 11 | - package-ecosystem: "github-actions" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | # This workflow automatically publishes the package to NPM when a new release is created. 2 | # Before, creating a new release, make sure to update the package version in package.json 3 | # and add a Granular Access Token (with read and write packages scope) 4 | # to the repository secrets with the name NPM_TOKEN. 5 | # Once, the release has been published remove it from the repository secrets. 6 | 7 | name: Publish Package to NPM 8 | on: 9 | release: 10 | types: [published] 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | id-token: write 18 | 19 | steps: 20 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | 22 | # Setup .npmrc file to publish to npm 23 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 24 | with: 25 | node-version: '22.x' 26 | registry-url: 'https://registry.npmjs.org' 27 | 28 | - run: npm ci 29 | - run: npm publish --provenance 30 | env: 31 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: ['push', 'pull_request'] 4 | 5 | jobs: 6 | build: 7 | runs-on: ${{ matrix.os }} 8 | 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest, macos-latest, windows-latest] 12 | node-version: [22.x, 23.x, 24.x] 13 | 14 | name: Node ${{ matrix.node-version }} - ${{ matrix.os }} 15 | 16 | steps: 17 | - name: Cancel Previous Runs 18 | uses: styfle/cancel-workflow-action@85880fa0301c86cca9da44039ee3bb12d3bedbfa # 0.12.1 19 | if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.id == github.event.pull_request.base.repo.id }} 20 | with: 21 | access_token: ${{ github.token }} 22 | 23 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 24 | 25 | - name: Cache node modules 26 | uses: actions/cache@v4 27 | with: 28 | path: ~/.npm 29 | key: ${{ matrix.os }}-${{ matrix.node-version }}-npm-${{ hashFiles('**/package-lock.json') }} 30 | restore-keys: | 31 | ${{ matrix.os }}-${{ matrix.node-version }}-npm- 32 | ${{ matrix.os }}-npm- 33 | 34 | - name: Use Node.js ${{ matrix.node-version }} 35 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 36 | with: 37 | node-version: ${{ matrix.node-version }} 38 | 39 | - run: npm ci 40 | - run: npm run test:all 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | npm-debug.log 4 | .idea 5 | coverage/ 6 | .nyc_output 7 | .vscode/ 8 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | npm run test:quiet 2 | -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | recursive: true, 3 | file:['./test/mocha.js'], 4 | }; 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Romain Prieto 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tldr-node-client 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![GitHub Action Build Status][gh-actions-image]][gh-actions-url] 5 | [![Matrix chat][matrix-image]][matrix-url] 6 | 7 | A `Node.js` based command-line client for [tldr](https://github.com/tldr-pages/tldr). 8 | 9 | ![tldr screenshot](screenshot.png) 10 | 11 | _tldr-node-client's output for the `tar` page, using a custom color theme_ 12 | 13 | ## Installing 14 | 15 | ```bash 16 | npm install -g tldr 17 | ``` 18 | 19 | ## Usage 20 | 21 | To see tldr pages: 22 | 23 | - `tldr ` show examples for this command 24 | - `tldr --platform=` show command page for the given platform 25 | - `tldr --android ` show command page for Android 26 | - `tldr --darwin ` show command page for darwin (macOS) 27 | - `tldr --freebsd ` show command page for FreeBSD 28 | - `tldr --linux ` show command page for Linux 29 | - `tldr --macos ` show command page for macOS 30 | - `tldr --netbsd ` show command page for NetBSD 31 | - `tldr --openbsd ` show command page for OpenBSD 32 | - `tldr --osx ` show command page for osx (macOS) 33 | - `tldr --sunos ` show command page for SunOS 34 | - `tldr --win32 ` show command page for win32 (Windows) 35 | - `tldr --windows ` show command page for Windows 36 | - `tldr --search ""` search all pages for the query 37 | - `tldr --list` show all pages for current platform 38 | - `tldr --list-all` show all available pages 39 | - `tldr --random` show a page at random 40 | - `tldr --random-example` show a single random example 41 | - `tldr --markdown` show the original markdown format page 42 | 43 | The client caches a copy of all pages locally, in `~/.tldr`. 44 | There are more commands to control the local cache: 45 | 46 | - `tldr --update` download the latest pages and generate search index 47 | - `tldr --clear-cache` delete the entire local cache 48 | 49 | As a contributor, you might also need the following commands: 50 | 51 | - `tldr --render ` render a local page for testing purposes 52 | 53 | Tldr pages defaults to showing pages in the current language of the operating system, or English if that's not available. To view tldr pages for a different language, set an environment variable `LANG` containing a valid [POSIX locale](https://www.gnu.org/software/gettext/manual/html_node/Locale-Names.html#Locale-Names) (such as `zh`, `pt_BR`, or `fr`) and then run the above commands as usual. In most `*nix` systems, this variable will already be set. 54 | 55 | It is suggested that the `LANG` environment variable be set system-wide if this isn't already the case. Users without `sudo` access can set it locally in their `~/.profile`. 56 | 57 | - `LANG=zh tldr ` 58 | 59 | For the list of available translations, please refer to the main [tldr](https://github.com/tldr-pages/tldr) repo. 60 | 61 | ## Configuration 62 | 63 | You can configure the `tldr` client by adding a `.tldrrc` file in your HOME directory. You can copy the contents of the `config.json` file from the repo to get the basic structure to start with, and modify it to suit your needs. 64 | 65 | The default color theme is the one named `"simple"`. You can change the theme by assigning a different value to the `"theme"` variable -- either to one of the pre-configured themes, or to a new theme that you have previously created in the `"themes"` section. Note that the colors and text effects you can choose are limited. Refer to the [chalk documentation](https://github.com/chalk/chalk#styles) for all options. 66 | 67 | ```json 68 | { 69 | "themes": { 70 | "ocean": { 71 | "commandName": "bold, cyan", 72 | "mainDescription": "", 73 | "exampleDescription": "green", 74 | "exampleCode": "cyan", 75 | "exampleToken": "dim" 76 | }, 77 | "myOwnCoolTheme": { 78 | "commandName": "bold, red", 79 | "mainDescription": "underline", 80 | "exampleDescription": "yellow", 81 | "exampleCode": "underline, green", 82 | "exampleToken": "" 83 | } 84 | }, 85 | "theme": "ocean" 86 | } 87 | ``` 88 | 89 | If you regularly need pages for a different platform (e.g. Linux), 90 | you can put it in the config file: 91 | 92 | ```json 93 | { 94 | "platform": "linux" 95 | } 96 | ``` 97 | 98 | The default platform value can be overwritten with command-line option: 99 | 100 | ```shell 101 | tldr du --platform= 102 | ``` 103 | 104 | As a contributor, you can also point to your own fork containing the `tldr.zip` file. The file is just a zipped version of the entire tldr repo: 105 | 106 | ```js 107 | { 108 | "repository": "http://myrepo/assets/tldr.zip" 109 | } 110 | ``` 111 | 112 | By default, a cache update is performed anytime a page is not found for a command. To prevent this behavior, 113 | you can set the configuration variable `skipUpdateWhenPageNotFound` to `true` (defaults to `false`): 114 | 115 | ```js 116 | { 117 | "skipUpdateWhenPageNotFound": true 118 | } 119 | ``` 120 | 121 | ## Command-line Autocompletion 122 | 123 | We currently support command-line autocompletion for zsh and bash. 124 | Pull requests for other shells are most welcome! 125 | 126 | To enable autocompletion for the tldr command, run: 127 | 128 | ### zsh 129 | 130 | It's easiest for 131 | [oh-my-zsh](https://github.com/robbyrussell/oh-my-zsh) 132 | users, so let's start with that. 133 | 134 | ```zsh 135 | mkdir -p $ZSH_CUSTOM/plugins/tldr 136 | ln -s bin/completion/zsh/_tldr $ZSH_CUSTOM/plugins/tldr/_tldr 137 | ``` 138 | 139 | Then add tldr to your oh-my-zsh plugins, 140 | usually defined in `~/.zshrc`, 141 | resulting in something looking like this: 142 | 143 | ```zsh 144 | plugins=(git tmux tldr) 145 | ``` 146 | 147 | Fret not regular zsh user! 148 | You can also do this: 149 | 150 | ```zsh 151 | tldr completion zsh 152 | source ~/.zshrc 153 | ``` 154 | 155 | ### bash 156 | 157 | ```bash 158 | tldr completion bash 159 | source ~/.bashrc 160 | ``` 161 | 162 | This command will generate the appropriate completion script and append it to your shell's configuration file (`.zshrc` or `.bashrc`). 163 | 164 | If you encounter any issues or need more information about the autocompletion setup, please refer to the [completion.js](https://github.com/tldr-pages/tldr-node-client/blob/main/lib/completion.js) file in the repository. 165 | 166 | ## FAQ 167 | 168 | ### Installation Issues 169 | 170 | - If you are trying to install as non-root user (`npm install -g tldr`) and get something like: 171 | 172 | ```text 173 | Error: EACCES: permission denied, access '/usr/local/lib/node_modules/tldr' 174 | ``` 175 | 176 | Then most probably your npm's default installation directory has improper permissions. You can resolve it by following [this guide](https://docs.npmjs.com/getting-started/fixing-npm-permissions). 177 | 178 | - If you are trying to install as a root user (`sudo npm install -g tldr`) and get something like: 179 | 180 | ```shell 181 | as root -> 182 | gyp WARN EACCES attempting to reinstall using temporary dev dir "/usr/local/lib/node_modules/tldr/node_modules/webworker-threads/.node-gyp" 183 | gyp WARN EACCES user "root" does not have permission to access the dev dir "/usr/local/lib/node_modules/tldr/node_modules/webworker-threads/.node-gyp/8.9.1" 184 | ``` 185 | 186 | You need to add the option `--unsafe-perm` to your command. This is because when npm goes to the postinstall step, it downgrades the permission levels to "nobody". Probably you should fix your installation directory permissions and install as a non-root user in the first place. 187 | 188 | - If you see an error related to `webworker-threads` like: 189 | 190 | ```text 191 | /usr/local/lib/node_modules/tldr/node_modules/natural/lib/natural/classifiers/classifier.js:32 192 | if (e.code !== 'MODULE_NOT_FOUND') throw e; 193 | ``` 194 | 195 | Most probably you need to reinstall `node-gyp` and `webworker-threads`. Try this - 196 | 197 | ```shell 198 | sudo -H npm uninstall -g tldr 199 | sudo -H npm uninstall -g webworker-threads 200 | npm install -g node-gyp 201 | npm install -g webworker-threads 202 | npm install -g tldr 203 | ``` 204 | 205 | For further context, take a look at this [issue](https://github.com/tldr-pages/tldr-node-client/issues/179) 206 | 207 | #### Colors under Cygwin 208 | 209 | Colors can't be shown under Mintty or PuTTY, because the dependency `colors.js` has a bug. 210 | Please show support to [this pull request](https://github.com/Marak/colors.js/pull/154), so it can be merged. 211 | 212 | Meanwhile, you can do one of the following to fix this issue: 213 | 214 | - Add the following script to your shell's rc file (`.zshrc`, `.bashrc`, etc.): (RECOMMENDED) 215 | 216 | ```bash 217 | tldr_path="$(which tldr)" 218 | function tldr() { 219 | eval "$tldr_path" $@ "--color" 220 | } 221 | ``` 222 | 223 | - Add `alias tldr="tldr --color=true"` to your shell's rc file. 224 | - Prepend `process.stdout.isTTY = true;` to `tldr.js` (NOT RECOMMENDED) 225 | - Fix `colors.js`'s logic (NOT RECOMMENDED) 226 | - Go to `%appdata%\npm\node_modules\tldr\node_modules\colors\lib\system\` 227 | - Overwrite `supports-colors.js` with [supports-colors.js](https://raw.githubusercontent.com/RShadowhand/colors.js/master/lib/system/supports-colors.js) from my repo. 228 | - Use `CMD.exe`. 229 | 230 | ## Contributing 231 | 232 | Contribution are most welcome! 233 | Have a look [over here](https://github.com/tldr-pages/tldr-node-client/blob/main/.github/CONTRIBUTING.md) 234 | for a few rough guidelines. 235 | 236 | [npm-url]: https://www.npmjs.com/package/tldr 237 | [npm-image]: https://img.shields.io/npm/v/tldr.svg 238 | [gh-actions-url]: https://github.com/tldr-pages/tldr-node-client/actions/workflows/test.yml?query=workflow%3ATest+branch%3Amain 239 | [gh-actions-image]: https://img.shields.io/github/actions/workflow/status/tldr-pages/tldr-node-client/test.yml?branch=main 240 | [matrix-url]: https://matrix.to/#/#tldr-pages:matrix.org 241 | [matrix-image]: https://img.shields.io/matrix/tldr-pages:matrix.org?label=chat+on+matrix 242 | -------------------------------------------------------------------------------- /bin/completion/bash/tldr: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # tldr bash completion 4 | 5 | # Check if bash-completion is already sourced 6 | if ! type _completion_loader &>/dev/null; then 7 | # If not, try to load it 8 | if [ -f /usr/share/bash-completion/bash_completion ]; then 9 | . /usr/share/bash-completion/bash_completion 10 | elif [ -f /etc/bash_completion ]; then 11 | . /etc/bash_completion 12 | fi 13 | fi 14 | 15 | BUILTIN_THEMES="single base16 ocean" 16 | 17 | PLATFORM_TYPES="android freebsd linux netbsd openbsd osx sunos windows" 18 | 19 | OPTIONS='-v 20 | --version 21 | -l 22 | --list 23 | -a 24 | --list-all 25 | -1 26 | --single-column 27 | -r 28 | --random 29 | -e 30 | --random-example 31 | -f 32 | --render 33 | -m 34 | --markdown 35 | -p 36 | --android 37 | --darwin 38 | --freebsd 39 | --linux 40 | --macos 41 | --netbsd 42 | --openbsd 43 | --osx 44 | --sunos 45 | --win32 46 | --windows 47 | -t 48 | --theme 49 | -s 50 | --search 51 | -u 52 | --update 53 | -c 54 | --clear-cache 55 | -h 56 | --help' 57 | 58 | function _tldr_autocomplete { 59 | OPTS_NOT_USED=$( comm -23 <( echo "$OPTIONS" | sort ) <( printf '%s\n' "${COMP_WORDS[@]}" | sort ) ) 60 | 61 | cur="${COMP_WORDS[$COMP_CWORD]}" 62 | COMPREPLY=() 63 | if [[ "$cur" =~ ^-.* ]] 64 | then 65 | COMPREPLY=(`compgen -W "$OPTS_NOT_USED" -- $cur`) 66 | else 67 | if [[ $COMP_CWORD -eq 0 ]] 68 | then 69 | prev="" 70 | else 71 | prev=${COMP_WORDS[$COMP_CWORD-1]} 72 | fi 73 | case "$prev" in 74 | -f|--render) 75 | COMPREPLY=(`compgen -f $cur`) 76 | ;; 77 | 78 | -p|--platform) 79 | COMPREPLY=(`compgen -W "$PLATFORM_TYPES" $cur`) 80 | ;; 81 | 82 | -t|--theme) 83 | # No suggestions for these, they take arbitrary values 84 | SUGGESTED_BUILTINS=(`compgen -W "$BUILTIN_THEMES" $cur`) 85 | if [[ ${#SUGGESTED_BUILTINS[@]} -eq 0 ]] 86 | then 87 | COMPREPLY=() 88 | else 89 | COMPREPLY=("" "${SUGGESTED_BUILTINS[@]}") 90 | fi 91 | ;; 92 | 93 | -s|--search) 94 | # No suggestions for these, they take arbitrary values 95 | COMPREPLY=("") 96 | ;; 97 | 98 | *) 99 | sheets=$(tldr -l -1) 100 | COMPREPLY=(`compgen -W "$sheets $OPTS_NOT_USED" -- $cur`) 101 | ;; 102 | esac 103 | fi 104 | } 105 | 106 | complete -F _tldr_autocomplete tldr 107 | -------------------------------------------------------------------------------- /bin/completion/zsh/_tldr: -------------------------------------------------------------------------------- 1 | #compdef tldr 2 | 3 | local -a pages platforms 4 | pages=$(tldr -a1) 5 | platforms='( android freebsd linux netbsd openbsd osx sunos windows )' 6 | 7 | _arguments \ 8 | '(- *)'{-h,--help}'[show help]' \ 9 | '(- *)'{-v,--version}'[display version]' \ 10 | '(- *)'{-l,--list}'[list all commands for chosen platform]' \ 11 | '(- *)'{-a,--list-all}'[list all commands]' \ 12 | '(- *)'{-1,--single-column}'[list one command per line (used with -l or -a)]' \ 13 | '(- *)'{-r,--random}'[show a random command]' \ 14 | '(- *)'{-s,--search}'[search all pages for query]' \ 15 | '(- *)'{-e,--random-example}'[show a random example]' \ 16 | '(- *)'{-m,--markdown}'[show the original markdown format page]' \ 17 | '(-f --render)'{-f,--render}'[render a specific markdown file]:markdown file:_files -/' \ 18 | '(-p --platform)'{-p,--platform}"[override platform]:platform:(${(j:|:)platforms})" \ 19 | '(- *)'{-u,--update}'[update local cache]' \ 20 | '--android[override operating system with Android]' \ 21 | '--darwin[override operating system with macOS]' \ 22 | '--freebsd[override operating system with FreeBSD]' \ 23 | '--linux[override operating system with Linux]' \ 24 | '--macos[override operating system with macOS]' \ 25 | '--netbsd[override operating system with NetBSD]' \ 26 | '--openbsd[override operating system with OpenBSD]' \ 27 | '--osx[override operating system with macOS]' \ 28 | '--sunos[override operating system with SunOS]' \ 29 | '--win32[override operating system with Windows]' \ 30 | '--windows[override operating system with Windows]' \ 31 | '(- *)'{-c,--clear-cache}'[clear local cache]' \ 32 | "*:page:(${(b)pages})" && return 0 33 | -------------------------------------------------------------------------------- /bin/tldr: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | const program = require('commander'); 4 | const pkg = require('../package'); 5 | const Tldr = require('../lib/tldr'); 6 | const config = require('../lib/config'); 7 | const platforms = require('../lib/platforms'); 8 | const Completion = require('../lib/completion'); 9 | const { TldrError } = require('../lib/errors'); 10 | 11 | pkg.version = `v${pkg.version}\nClient Specification: 2.0`; 12 | 13 | program 14 | .version(pkg.version, '-v, --version', 'Display version') 15 | .helpOption('-h, --help', 'Show this help message') 16 | .description(pkg.description) 17 | .usage('command [options]') 18 | // 19 | // BASIC OPTIONS 20 | // 21 | .option('-l, --list', 'List all commands for the chosen platform in the cache') 22 | .option('-a, --list-all', 'List all commands in the cache') 23 | .option('-1, --single-column', 'List single command per line (use with options -l or -a)') 24 | .option('-r, --random', 'Show a random command') 25 | .option('-e, --random-example', 'Show a random example') 26 | .option('-f, --render [file]', 'Render a specific markdown [file]') 27 | .option('-m, --markdown', 'Output in markdown format') 28 | .option('-p, --platform [type]', `Override the current platform [${platforms.supportedPlatforms.join(', ')}]`) 29 | .option('completion [shell]', 'Generate and add shell completion script to your shell configuration'); 30 | 31 | for (const platform of platforms.supportedPlatforms) { 32 | program.option(`--${platform}`, `Override the platform with ${platform}`); 33 | } 34 | 35 | program 36 | .option('-t, --theme [theme]', 'Color theme (simple, base16, ocean)') 37 | .option('-s, --search [keywords]', 'Search pages using keywords') 38 | // 39 | // CACHE MANAGEMENT 40 | // 41 | .option('-u, --update', 'Update the local cache') 42 | .option('-c, --clear-cache', 'Clear the local cache'); 43 | 44 | const help = ` 45 | Examples: 46 | 47 | $ tldr tar 48 | $ tldr du --platform=linux 49 | $ tldr --search "create symbolic link to file" 50 | $ tldr --list 51 | $ tldr --list-all 52 | $ tldr --random 53 | $ tldr --random-example 54 | 55 | To control the cache: 56 | 57 | $ tldr --update 58 | $ tldr --clear-cache 59 | 60 | To render a local file (for testing): 61 | 62 | $ tldr --render /path/to/file.md 63 | 64 | To add shell completion: 65 | 66 | $ tldr completion bash 67 | $ tldr completion zsh 68 | `; 69 | 70 | program.on('--help', () => { 71 | console.log(help); 72 | }); 73 | 74 | program.parse(process.argv); 75 | 76 | for (const platform of platforms.supportedPlatforms) { 77 | if (program[platform]) { 78 | program.platform = platform; 79 | } 80 | } 81 | 82 | let cfg = config.get(); 83 | if (program.platform && platforms.isSupported(program.platform)) { 84 | cfg.platform = program.platform; 85 | } 86 | 87 | if (program.theme) { 88 | cfg.theme = program.theme; 89 | } 90 | 91 | const tldr = new Tldr(cfg); 92 | 93 | let p = null; 94 | if (program.list) { 95 | p = tldr.list(program.singleColumn); 96 | } else if (program.listAll) { 97 | p = tldr.listAll(program.singleColumn); 98 | } else if (program.random) { 99 | p = tldr.random(program); 100 | } else if (program.randomExample) { 101 | p = tldr.randomExample(); 102 | } else if (program.clearCache) { 103 | p = tldr.clearCache(); 104 | } else if (program.update) { 105 | p = tldr.updateCache() 106 | .then(() => { 107 | return tldr.updateIndex(); 108 | }); 109 | } else if (program.render) { 110 | p = tldr.render(program.render); 111 | } else if (program.search) { 112 | program.args.unshift(program.search); 113 | p = tldr.search(program.args); 114 | } else if (program.args.length >= 1) { 115 | if (program.args[0] === 'completion') { 116 | const shell = program.args[1]; 117 | const completion = new Completion(shell); 118 | p = completion.getScript() 119 | .then((script) => {return completion.appendScript(script);}) 120 | .then(() => { 121 | if (shell === 'zsh') { 122 | console.log('If completions don\'t work, you may need to rebuild your zcompdump:'); 123 | console.log(' rm -f ~/.zcompdump; compinit'); 124 | } 125 | }); 126 | } else { 127 | p = tldr.get(program.args, program); 128 | } 129 | } 130 | 131 | if (p === null) { 132 | program.outputHelp(); 133 | process.exitCode = 1; 134 | } else { 135 | p.catch((err) => { 136 | let output = TldrError.isTldrError(err) 137 | ? err.message 138 | : err.stack; 139 | console.error(output); 140 | process.exitCode = err.code || 1; 141 | }); 142 | } 143 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "pagesRepository": "https://github.com/tldr-pages/tldr", 3 | "repositoryBase": "https://tldr.sh/assets/tldr-pages", 4 | "skipUpdateWhenPageNotFound": false, 5 | "themes": { 6 | "simple": { 7 | "commandName": "bold, underline", 8 | "mainDescription": "bold", 9 | "exampleDescription": "", 10 | "exampleCode": "", 11 | "exampleToken": "underline", 12 | "exampleNumber": "yellow, bold", 13 | "exampleString": "green, italic", 14 | "exampleBool": "magenta, bold" 15 | }, 16 | "base16": { 17 | "commandName": "bold", 18 | "mainDescription": "", 19 | "exampleDescription": "green", 20 | "exampleCode": "red", 21 | "exampleToken": "cyan", 22 | "exampleNumber": "yellow, bold", 23 | "exampleString": "green, italic", 24 | "exampleBool": "magenta, bold" 25 | }, 26 | "ocean": { 27 | "commandName": "bold, cyan", 28 | "mainDescription": "", 29 | "exampleDescription": "green", 30 | "exampleCode": "cyan", 31 | "exampleToken": "dim", 32 | "exampleNumber": "yellow, bold", 33 | "exampleString": "green, italic", 34 | "exampleBool": "magenta, bold" 35 | }, 36 | "inverse": { 37 | "commandName": "bold, inverse", 38 | "mainDescription": "inverse", 39 | "exampleDescription": "black", 40 | "exampleCode": "inverse", 41 | "exampleToken": "green, bgBlack, inverse", 42 | "exampleNumber": "yellow, bold", 43 | "exampleString": "green, italic", 44 | "exampleBool": "magenta, bold" 45 | }, 46 | "matrix": { 47 | "commandName": "bold", 48 | "mainDescription": "underline", 49 | "exampleDescription": "green, bgBlack", 50 | "exampleCode": "green, bgBlack", 51 | "exampleToken": "green, bold, bgBlack", 52 | "exampleNumber": "yellow, bold", 53 | "exampleString": "green, italic", 54 | "exampleBool": "magenta, bold" 55 | } 56 | }, 57 | "theme": "simple" 58 | } 59 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | "files": ["bin/tldr"], 4 | "rules": { 5 | "indent": ["error", 2], 6 | "quotes": ["error", "single"], 7 | "linebreak-style": ["error", "unix"], 8 | "semi": ["error", "always"], 9 | "no-console": "off", 10 | "arrow-parens": ["error", "always"], 11 | "arrow-body-style": ["error", "always"], 12 | "array-callback-return": "error", 13 | "no-magic-numbers": ["error", { 14 | "ignore": [-1, 0, 1, 2], 15 | "ignoreArrayIndexes": true, 16 | "detectObjects": true 17 | }], 18 | "no-var": "error", 19 | "no-warning-comments": "warn", 20 | "handle-callback-err": "error" 21 | }, 22 | "languageOptions": { 23 | "ecmaVersion": "latest", 24 | "sourceType": "module", 25 | "globals": { 26 | "node": true, 27 | "mocha": true, 28 | "es2019": true 29 | } 30 | } 31 | } 32 | ]; 33 | -------------------------------------------------------------------------------- /lib/cache.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | const remote = require('./remote'); 7 | const platforms = require('./platforms'); 8 | const index = require('./index'); 9 | const utils = require('./utils'); 10 | 11 | class Cache { 12 | constructor(config) { 13 | this.config = config; 14 | this.cacheFolder = path.join(config.cache, 'cache'); 15 | } 16 | 17 | lastUpdated() { 18 | return fs.stat(this.cacheFolder); 19 | } 20 | 21 | getPage(page) { 22 | let preferredPlatform = platforms.getPreferredPlatformFolder(this.config); 23 | const preferredLanguage = process.env.LANG || 'en'; 24 | return index.findPage(page, preferredPlatform, preferredLanguage) 25 | .then((folder) => { 26 | if (!folder) { 27 | return; 28 | } 29 | let filePath = path.join(this.cacheFolder, folder, page + '.md'); 30 | return fs.readFile(filePath, 'utf8'); 31 | }) 32 | .catch((err) => { 33 | console.error(err); 34 | }); 35 | } 36 | 37 | clear() { 38 | return fs.remove(this.cacheFolder); 39 | } 40 | 41 | update() { 42 | // Temporary folder path: /tmp/tldr/{randomName} 43 | const tempFolder = path.join(os.tmpdir(), 'tldr', utils.uniqueId()); 44 | 45 | // Downloading fresh copy 46 | return Promise.all([ 47 | // Create new temporary folder 48 | fs.ensureDir(tempFolder), 49 | fs.ensureDir(this.cacheFolder), 50 | ]) 51 | .then(() => { 52 | // Download and extract cache data to temporary folder 53 | return Promise.allSettled(this.config.languages.map((lang) => { 54 | return remote.download(tempFolder, lang); 55 | })); 56 | }) 57 | .then(() => { 58 | // Copy data to cache folder 59 | return fs.copy(tempFolder, this.cacheFolder); 60 | }) 61 | .then(() => { 62 | return Promise.all([ 63 | // Remove temporary folder 64 | fs.remove(tempFolder), 65 | index.rebuildPagesIndex(), 66 | ]); 67 | }) 68 | 69 | .then(([_, shortIndex]) => { 70 | return shortIndex; 71 | }); 72 | } 73 | } 74 | 75 | module.exports = Cache; 76 | -------------------------------------------------------------------------------- /lib/completion.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const os = require('os'); 6 | const { UnsupportedShellError, CompletionScriptError } = require('./errors'); 7 | 8 | class Completion { 9 | constructor(shell) { 10 | this.supportedShells = ['bash', 'zsh']; 11 | if (!this.supportedShells.includes(shell)) { 12 | throw new UnsupportedShellError(shell, this.supportedShells); 13 | } 14 | this.shell = shell; 15 | this.rcFilename = shell === 'zsh' ? '.zshrc' : '.bashrc'; 16 | } 17 | 18 | getFilePath() { 19 | const homeDir = os.homedir(); 20 | return path.join(homeDir, this.rcFilename); 21 | } 22 | 23 | appendScript(script) { 24 | const rcFilePath = this.getFilePath(); 25 | return new Promise((resolve, reject) => { 26 | fs.appendFile(rcFilePath, `\n${script}\n`, (err) => { 27 | if (err) { 28 | reject((new CompletionScriptError(`Error appending to ${rcFilePath}: ${err.message}`))); 29 | } else { 30 | console.log(`Completion script added to ${rcFilePath}`); 31 | console.log(`Please restart your shell or run 'source ~/${this.rcFilename}' to enable completions`); 32 | resolve(); 33 | } 34 | }); 35 | }); 36 | } 37 | 38 | getScript() { 39 | return new Promise((resolve) => { 40 | if (this.shell === 'zsh') { 41 | resolve(this.getZshScript()); 42 | } else if (this.shell === 'bash') { 43 | resolve(this.getBashScript()); 44 | } 45 | }); 46 | } 47 | 48 | getZshScript() { 49 | const completionDir = path.join(__dirname, '..', 'bin', 'completion', 'zsh'); 50 | return ` 51 | # tldr zsh completion 52 | fpath=(${completionDir} $fpath) 53 | 54 | # You might need to force rebuild zcompdump: 55 | # rm -f ~/.zcompdump; compinit 56 | 57 | # If you're using oh-my-zsh, you can force reload of completions: 58 | # autoload -U compinit && compinit 59 | 60 | # Check if compinit is already loaded, if not, load it 61 | if (( ! $+functions[compinit] )); then 62 | autoload -Uz compinit 63 | compinit -C 64 | fi 65 | `.trim(); 66 | } 67 | 68 | getBashScript() { 69 | return new Promise((resolve, reject) => { 70 | const scriptPath = path.join(__dirname, '..', 'bin', 'completion', 'bash', 'tldr'); 71 | fs.readFile(scriptPath, 'utf8', (err, data) => { 72 | if (err) { 73 | reject(new CompletionScriptError(`Error reading bash completion script: ${err.message}`)); 74 | } else { 75 | resolve(data); 76 | } 77 | }); 78 | }); 79 | } 80 | } 81 | 82 | module.exports = Completion; -------------------------------------------------------------------------------- /lib/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const defaults = require('lodash/defaults'); 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | const utils = require('./utils'); 7 | const osHomedir = require('os').homedir; 8 | const platforms = require('./platforms'); 9 | 10 | exports.get = () => { 11 | const DEFAULT = path.join(__dirname, '..', 'config.json'); 12 | const CUSTOM = path.join(osHomedir(), '.tldrrc'); 13 | 14 | let defaultConfig = JSON.parse(fs.readFileSync(DEFAULT)); 15 | defaultConfig.cache = path.join(osHomedir(), '.tldr'); 16 | 17 | let customConfig = {}; 18 | try { 19 | customConfig = JSON.parse(fs.readFileSync(CUSTOM)); 20 | } catch (ex) { 21 | if (ex instanceof SyntaxError) { 22 | throw new Error('The content of .tldrrc is not a valid JSON object:\n' + ex); 23 | } 24 | } 25 | 26 | let merged = defaults(customConfig, defaultConfig); 27 | // Validating the theme settings 28 | let errors = Object.keys(!merged.themes ? {} : merged.themes).map( 29 | (key) => { 30 | return validateThemeItem(merged.themes[key], key); 31 | } 32 | ); 33 | errors.push(validatePlatform(merged.platform)); 34 | // Filtering out all the null entries 35 | errors = errors.filter((item) => { return item !== null; }); 36 | 37 | if (errors.length > 0) { 38 | throw new Error('Error in .tldrrc configuration:\n' + errors.join('\n')); 39 | } 40 | 41 | // Setting correct languages 42 | merged.languages = ['en']; 43 | // Get the primary & secondary language. 44 | let langs = utils.localeToLang(process.env.LANG); 45 | merged.languages = merged.languages.concat(langs); 46 | 47 | if(process.env.LANGUAGE !== undefined) { 48 | let langs = process.env.LANGUAGE.split(':'); 49 | 50 | merged.languages.push(...langs.map((lang) => { 51 | return utils.localeToLang(lang); 52 | })); 53 | } 54 | merged.languages = [...new Set(merged.languages)]; 55 | 56 | return merged; 57 | }; 58 | 59 | function validatePlatform(platform) { 60 | if (platform && !platforms.isSupported(platform)) { 61 | return 'Unsupported platform : ' + platform; 62 | } 63 | return null; 64 | } 65 | 66 | function validateThemeItem(field, key) { 67 | const validValues = ['', 68 | 'reset', 69 | 'bold', 70 | 'dim', 71 | 'italic', 72 | 'underline', 73 | 'inverse', 74 | 'hidden', 75 | 'black', 76 | 'red', 77 | 'redBright', 78 | 'green', 79 | 'greenBright', 80 | 'yellow', 81 | 'yellowBright', 82 | 'blue', 83 | 'blueBright', 84 | 'magenta', 85 | 'magentaBright', 86 | 'cyan', 87 | 'cyanBright', 88 | 'white', 89 | 'whiteBright', 90 | 'gray', 91 | 'bgBlack', 92 | 'bgRed', 93 | 'bgGreen', 94 | 'bgYellow', 95 | 'bgBlue', 96 | 'bgMagenta', 97 | 'bgCyan', 98 | 'bgWhite', 99 | 'bgBlackBright', 100 | 'bgRedBright', 101 | 'bgGreenBright', 102 | 'bgYellowBright', 103 | 'bgBlueBright', 104 | 'bgMagentaBright', 105 | 'bgCyanBright', 106 | 'bgWhiteBright' 107 | ]; 108 | let errMsg = []; 109 | for (let fieldKey of Object.keys(field)) { 110 | let tokens = field[fieldKey].replace(/\s+/g, '').split(','); 111 | tokens.forEach((token) => { 112 | if (validValues.indexOf(token) < 0) { 113 | errMsg.push('Invalid theme value : ' + token + ' in ' + key + ' theme'); 114 | } 115 | }); 116 | } 117 | if (errMsg.length === 0) { 118 | return null; 119 | } 120 | return errMsg.join('\n'); 121 | } 122 | -------------------------------------------------------------------------------- /lib/errors.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | class TldrError extends Error { 4 | constructor(message) { 5 | super(message); 6 | this.name = 'TldrError'; 7 | } 8 | 9 | static isTldrError(err) { 10 | return err instanceof this; 11 | } 12 | } 13 | 14 | class EmptyCacheError extends TldrError { 15 | constructor() { 16 | super(trim` 17 | Local cache is empty 18 | Please run tldr --update 19 | `); 20 | this.name = 'EmptyCacheError'; 21 | 22 | this.code = 2; 23 | } 24 | } 25 | 26 | class MissingPageError extends TldrError { 27 | constructor(repo) { 28 | super(trim` 29 | Page not found. 30 | If you want to contribute it, feel free to send a pull request to: ${repo} 31 | `); 32 | this.name = 'MissingPageError'; 33 | 34 | this.code = 3; 35 | } 36 | } 37 | 38 | class MissingRenderPathError extends TldrError { 39 | constructor() { 40 | super('Option --render needs an argument.'); 41 | this.name = 'MissingRenderPathError'; 42 | 43 | this.code = 4; 44 | } 45 | } 46 | 47 | class UnsupportedShellError extends TldrError { 48 | constructor(shell, supportedShells) { 49 | super(`Unsupported shell: ${shell}. Supported shells are: ${supportedShells.join(', ')}`); 50 | this.name = 'UnsupportedShellError'; 51 | 52 | this.code = 5; 53 | } 54 | } 55 | 56 | class CompletionScriptError extends TldrError { 57 | constructor(message) { 58 | super(message); 59 | this.name = 'CompletionScriptError'; 60 | 61 | this.code = 6; 62 | } 63 | } 64 | 65 | module.exports = { 66 | TldrError, 67 | EmptyCacheError, 68 | MissingPageError, 69 | MissingRenderPathError, 70 | UnsupportedShellError, 71 | CompletionScriptError 72 | }; 73 | 74 | function trim(strings, ...values) { 75 | let output = values.reduce((acc, value, i) => { 76 | return acc + strings[i] + value; 77 | }, '') + strings[values.length]; 78 | return output.trim(); 79 | } 80 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const path = require('path'); 5 | const config = require('./config'); 6 | const utils = require('./utils'); 7 | 8 | let shortIndex = null; 9 | 10 | const pagesPath = path.join(config.get().cache, 'cache'); 11 | const shortIndexFile = path.join(pagesPath, 'shortIndex.json'); 12 | 13 | function findPage(page, preferredPlatform, preferredLanguage) { 14 | // Load the index 15 | return getShortIndex() 16 | .then((idx) => { 17 | // First, check whether page is in the index 18 | if (! (page in idx)) { 19 | return null; 20 | } 21 | const targets = idx[page].targets; 22 | 23 | // Remove unwanted stuff from lang code. 24 | if (preferredLanguage.includes('.')) { 25 | preferredLanguage = preferredLanguage.substring(0, preferredLanguage.indexOf('.')); 26 | } 27 | if (preferredLanguage.includes('@')) { 28 | preferredLanguage = preferredLanguage.substring(0, preferredLanguage.indexOf('@')); 29 | } 30 | 31 | let ll; 32 | if (preferredLanguage.includes('_')) { 33 | ll = preferredLanguage.substring(0, preferredLanguage.indexOf('_')); 34 | } 35 | if (!hasLang(targets, preferredLanguage)) { 36 | preferredLanguage = ll; 37 | } 38 | 39 | // Page resolution logic: 40 | // 1. Look into the target platform, target lang 41 | // 2. If not found, look into target platform, en lang. 42 | // 3. If not found, look into common, target lang. 43 | // 4. If not found, look into common, en lang. 44 | // 5. If not found, look into any platform, target lang. 45 | // 6. If not found, look into any platform, en lang. 46 | let targetPlatform; 47 | let targetLanguage; 48 | if (hasPlatformLang(targets, preferredPlatform, preferredLanguage)) { 49 | targetLanguage = preferredLanguage; 50 | targetPlatform = preferredPlatform; 51 | } else if (hasPlatformLang(targets, preferredPlatform, 'en')) { 52 | targetLanguage = 'en'; 53 | targetPlatform = preferredPlatform; 54 | } else if (hasPlatformLang(targets, 'common', preferredLanguage)) { 55 | targetLanguage = preferredLanguage; 56 | targetPlatform = 'common'; 57 | } else if (hasPlatformLang(targets, 'common', 'en')) { 58 | targetLanguage = 'en'; 59 | targetPlatform = 'common'; 60 | } else if (targets.length > 0 && hasLang(targets, preferredLanguage)) { 61 | targetLanguage = preferredLanguage; 62 | targetPlatform = targets[0].platform; 63 | console.log(`Command ${page} does not exist for the host platform. Displaying the page from ${targetPlatform} platform`); 64 | } else if (targets.length > 0 && hasLang(targets, 'en')) { 65 | targetLanguage = 'en'; 66 | targetPlatform = targets[0].platform; 67 | console.log(`Command ${page} does not exist for the host platform. Displaying the page from ${targetPlatform} platform`); 68 | } 69 | 70 | if (!targetLanguage && !targetPlatform) { 71 | return null; 72 | } 73 | 74 | let targetPath = 'pages'; 75 | if (targetLanguage !== 'en') { 76 | targetPath += '.' + targetLanguage; 77 | } 78 | return path.join(targetPath, targetPlatform); 79 | }); 80 | } 81 | 82 | function hasPlatformLang(targets, preferredPlatform, preferredLanguage) { 83 | return targets.some((t) => { 84 | return t.platform === preferredPlatform && t.language === preferredLanguage; 85 | }); 86 | } 87 | 88 | function hasLang(targets, preferredLanguage) { 89 | return targets.some((t) => { 90 | return t.language === preferredLanguage; 91 | }); 92 | } 93 | 94 | // hasPage is always called after the index is created, 95 | // hence just return the variable in memory. 96 | // There is no need to re-read the index file again. 97 | function hasPage(page) { 98 | if (!shortIndex) { 99 | return false; 100 | } 101 | return page in shortIndex; 102 | } 103 | 104 | // Return all commands available in the local cache. 105 | function commands() { 106 | return getShortIndex().then((idx) => { 107 | return Object.keys(idx).sort(); 108 | }); 109 | } 110 | 111 | // Return all commands for a given platform. 112 | // P.S. - The platform 'common' is always included. 113 | function commandsFor(platform) { 114 | return getShortIndex() 115 | .then((idx) => { 116 | let commands = Object.keys(idx) 117 | .filter((cmd) => { 118 | let targets = idx[cmd].targets; 119 | let platforms = targets.map((t) => {return t.platform;}); 120 | return platforms.indexOf(platform) !== -1 || platforms.indexOf('common') !== -1; 121 | }) 122 | .sort(); 123 | return commands; 124 | }); 125 | } 126 | 127 | // Delete the index file. 128 | function clearPagesIndex() { 129 | return fs.unlink(shortIndexFile) 130 | .then(() => { 131 | return clearRuntimeIndex(); 132 | }) 133 | .catch((err) => { 134 | // If the file is not present, then it is already unlinked and our job is done. 135 | // So raise an error only if it is some other scenario. 136 | if (err.code !== 'ENOENT') { 137 | console.error(err); 138 | } 139 | }); 140 | } 141 | 142 | // Set the shortIndex variable to null. 143 | function clearRuntimeIndex() { 144 | shortIndex = null; 145 | } 146 | 147 | function rebuildPagesIndex() { 148 | return clearPagesIndex().then(() => { 149 | return getShortIndex(); 150 | }); 151 | } 152 | 153 | // If the variable is not set, read the file and set it. 154 | // Else, just return the variable. 155 | function getShortIndex() { 156 | if (shortIndex) { 157 | return Promise.resolve(shortIndex); 158 | } 159 | return readShortPagesIndex(); 160 | } 161 | 162 | // Read the index file, and load it into memory. 163 | // If the file does not exist, create the data structure, write the file, 164 | // and load it into memory. 165 | function readShortPagesIndex() { 166 | return fs.readJson(shortIndexFile) 167 | .then((idx) => { 168 | shortIndex = idx; 169 | return shortIndex; 170 | }) 171 | .catch(() => { 172 | // File is not present; we need to create the index. 173 | return buildShortPagesIndex().then((idx) => { 174 | if (Object.keys(idx).length <= 0) { 175 | return idx; 176 | } 177 | shortIndex = idx; 178 | return fs.writeJson(shortIndexFile, shortIndex).then(() => { 179 | return shortIndex; 180 | }); 181 | }); 182 | }); 183 | } 184 | 185 | function buildShortPagesIndex() { 186 | return utils.walk(pagesPath) 187 | .then((files) => { 188 | files = files.filter(utils.isPage); 189 | let reducer = (index, file) => { 190 | let platform = utils.parsePlatform(file); 191 | let page = utils.parsePagename(file); 192 | let language = utils.parseLanguage(file); 193 | if (index[page]) { 194 | let targets = index[page].targets; 195 | let needsPush = true; 196 | for (const target of targets) { 197 | if (target.platform === platform && target.language === language) { 198 | needsPush = false; 199 | continue; 200 | } 201 | } 202 | if (needsPush) { 203 | targets.push({ platform, language }); 204 | index[page].targets = targets; 205 | } 206 | } else { 207 | index[page] = {targets: [{ platform, language }]}; 208 | } 209 | 210 | return index; 211 | }; 212 | return files.reduce(reducer, {}); 213 | }) 214 | .catch(() => { 215 | return {}; 216 | }); 217 | } 218 | 219 | module.exports = { 220 | getShortIndex, 221 | hasPage, 222 | findPage, 223 | commands, 224 | commandsFor, 225 | clearPagesIndex, 226 | clearRuntimeIndex, 227 | rebuildPagesIndex 228 | }; 229 | -------------------------------------------------------------------------------- /lib/parser.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const unescape = require('lodash/unescape'); 4 | const marked = require('marked'); 5 | const chalk = require('chalk'); 6 | const index = require('./index'); 7 | 8 | const allElements = [ 9 | 'blockquote', 'html', 'strong', 'em', 'br', 'del', 10 | 'heading', 'hr', 'image', 'link', 'list', 'listitem', 11 | 'paragraph', 'strikethrough', 'table', 'tablecell', 'tablerow' 12 | ]; 13 | 14 | function unhtml(text){ 15 | return unescape(text); 16 | } 17 | 18 | exports.parse = (markdown) => { 19 | // Creating the page structure 20 | let page = { 21 | name: '', 22 | description: '', 23 | examples: [], 24 | seeAlso: [] 25 | }; 26 | // Initializing the markdown renderer 27 | let r = new marked.Renderer(); 28 | 29 | // ignore all syntax by default 30 | allElements.forEach((e) => { 31 | r[e] = () => { return ''; }; 32 | }); 33 | 34 | // Overriding the different elements to incorporate the custom tldr format 35 | 36 | r.codespan = (text) => { 37 | if (index.hasPage(text) && text !== page.name) { 38 | if (page.seeAlso.indexOf(text) < 0) { 39 | page.seeAlso.push(text); 40 | } 41 | } 42 | let example = page.examples[page.examples.length-1]; 43 | // If example exists and a code is already not added 44 | if (example && !example.code) { 45 | example.code = unhtml(text); 46 | } 47 | return text; 48 | }; 49 | 50 | // underline links 51 | r.link = (uri) => { 52 | return uri; 53 | }; 54 | 55 | // paragraphs just pass through (automatically created by new lines) 56 | r.paragraph = (text) => { 57 | return text; 58 | }; 59 | 60 | r.heading = (text, level) => { 61 | if (level === 1) { 62 | page.name = text.trim(); 63 | } 64 | return text; 65 | }; 66 | 67 | r.blockquote = (text) => { 68 | page.description += unhtml(text); 69 | return text; 70 | }; 71 | 72 | r.strong = (text) => { 73 | return chalk.bold(text); 74 | }; 75 | 76 | r.em = (text) => { 77 | return chalk.italic(text); 78 | }; 79 | 80 | r.listitem = (text) => { 81 | page.examples.push({ 82 | description: unhtml(text) 83 | }); 84 | return text; 85 | }; 86 | 87 | marked.parse(markdown, { 88 | renderer: r 89 | }); 90 | 91 | page.examples = page.examples.filter((example) => { 92 | return example.description && example.code; 93 | }); 94 | 95 | return page; 96 | }; 97 | -------------------------------------------------------------------------------- /lib/platforms.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | 5 | const folders = { 6 | 'android': 'android', 7 | 'darwin': 'osx', 8 | 'freebsd': 'freebsd', 9 | 'linux': 'linux', 10 | 'macos': 'osx', 11 | 'netbsd': 'netbsd', 12 | 'openbsd': 'openbsd', 13 | 'osx': 'osx', 14 | 'sunos': 'sunos', 15 | 'win32': 'windows', 16 | 'windows': 'windows' 17 | }; 18 | 19 | const supportedPlatforms = Object.keys(folders); 20 | 21 | // Check if the platform is there in the list of platforms or not 22 | function isSupported(platform) { 23 | return supportedPlatforms.includes(platform); 24 | } 25 | 26 | // If the platform given in config is present, return that. 27 | // Else, return the system platform 28 | function getPreferredPlatform(config) { 29 | let platform = config.platform; 30 | if (isSupported(platform)) { 31 | return platform; 32 | } 33 | return os.platform(); 34 | } 35 | 36 | // Get the folder name for a platform 37 | function getPreferredPlatformFolder(config) { 38 | let platform = getPreferredPlatform(config); 39 | return folders[platform]; 40 | } 41 | 42 | module.exports = { 43 | isSupported, 44 | getPreferredPlatform, 45 | getPreferredPlatformFolder, 46 | supportedPlatforms 47 | }; 48 | -------------------------------------------------------------------------------- /lib/remote.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const path = require('path'); 4 | const fs = require('fs-extra'); 5 | const unzip = require('adm-zip'); 6 | const config = require('./config'); 7 | const axios = require('axios'); 8 | 9 | // Downloads the zip file from github and extracts it to folder 10 | exports.download = (loc, lang) => { 11 | // If the lang is english then keep the url simple, otherwise add language. 12 | const suffix = (lang === 'en' ? '' : '.' + lang); 13 | const url = config.get().repositoryBase + suffix + '.zip'; 14 | const folderName = path.join(loc, 'pages' + suffix); 15 | const REQUEST_TIMEOUT = 10000; 16 | 17 | return axios({ 18 | method: 'get', 19 | url: url, 20 | responseType: 'stream', 21 | headers: { 'User-Agent' : 'tldr-node-client' }, 22 | timeout: REQUEST_TIMEOUT, 23 | }).then((response) => { 24 | return new Promise((resolve, reject) => { 25 | let fileName = path.join(loc, 'download_' + lang + '.zip'); 26 | 27 | const writer = fs.createWriteStream(fileName); 28 | response.data.pipe(writer); 29 | 30 | writer.on('finish', () => { 31 | writer.end(); 32 | const zip = new unzip(fileName); 33 | 34 | zip.extractAllTo(folderName, true); 35 | fs.unlinkSync(fileName); 36 | resolve(); 37 | }).on('error', (err) => { 38 | reject(err); 39 | }); 40 | }); 41 | }).catch((err) => { 42 | return Promise.reject(err); 43 | }); 44 | }; -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Theme = require('./theme'); 4 | const he = require('he'); // Import the 'he' library 5 | 6 | // The page structure is passed to this function, and then the theme is applied 7 | // to different parts of the page and rendered to the console 8 | exports.toANSI = (page, config) => { 9 | // Creating the theme object 10 | let themeOptions = config.themes[config.theme]; 11 | if (!themeOptions) { 12 | console.error(`invalid theme: ${config.theme}`); 13 | return; 14 | } 15 | let theme = new Theme(themeOptions); 16 | 17 | function highlight(code) { 18 | let parts = code.split(/\{\{(.*?)\}\}/); 19 | // every second part is a token 20 | return ' ' + parts.reduce(function(memo, item, i) { 21 | if (i % 2) { 22 | return memo + theme.renderExampleToken(item); 23 | } 24 | return memo + theme.renderExampleCode(item); 25 | }, ''); 26 | } 27 | 28 | function decodeEntities(text) { 29 | return he.decode(text); // Decode HTML entities 30 | } 31 | 32 | // Creating an array where each line is an element in it 33 | let output = []; 34 | 35 | // Pushing each line by extracting the page parts and applying the theme to it 36 | output.push(' ' + theme.renderCommandName(page.name)); 37 | output.push(''); 38 | output.push(' ' + theme.renderMainDescription(decodeEntities(page.description.replace(/mailto:/g, '')).replace(/\n/g, '\n '))); // Decode entities and remove "mailto:" prefix in the description 39 | output.push(''); 40 | 41 | page.examples.forEach((example) => { 42 | // Decode entities and remove "mailto:" prefix in the description and code 43 | output.push(theme.renderExampleDescription(' - ' + decodeEntities(example.description.replace(/mailto:/g, '')))); 44 | output.push(highlight(decodeEntities(example.code.replace(/mailto:/g, '')))); 45 | output.push(''); 46 | }); 47 | 48 | if (page.seeAlso && page.seeAlso.length > 0) { 49 | output.push(''); 50 | output.push('See also: ' + page.seeAlso.join(', ')); 51 | output.push(''); 52 | } 53 | 54 | return '\n' + output.join('\n') + '\n'; 55 | }; 56 | -------------------------------------------------------------------------------- /lib/search.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const path = require('path'); 5 | const natural = require('natural'); 6 | 7 | const config = require('./config'); 8 | const utils = require('./utils'); 9 | const index = require('./index'); 10 | const platforms = require('./platforms'); 11 | 12 | const CACHE_FOLDER = path.join(config.get().cache, 'cache'); 13 | 14 | const filepath = CACHE_FOLDER + '/search-corpus.json'; 15 | 16 | let corpus = {}; 17 | 18 | corpus.fileWords = {}; 19 | corpus.fileLengths = {}; 20 | corpus.invertedIndex = {}; 21 | corpus.allTokens = new Set(); 22 | corpus.tfidf = {}; 23 | 24 | let query = {}; 25 | 26 | query.raw = null; 27 | query.tokens = null; 28 | query.frequency = {}; 29 | query.score = {}; 30 | query.ranks = []; 31 | 32 | let getTokens = (data) => { 33 | let tokenizer = new natural.WordTokenizer(); 34 | let tokens = tokenizer.tokenize(data); 35 | tokens.forEach((word, index) => { 36 | word = word.toLowerCase(); 37 | word = natural.PorterStemmer.stem(word); 38 | tokens[index] = word; 39 | }); 40 | 41 | return tokens; 42 | }; 43 | 44 | let createFileIndex = (tokens, name) => { 45 | // Creates word frequency index for each file. 46 | corpus.fileWords[name] = {}; 47 | tokens.forEach((word) => { 48 | corpus.allTokens.add(word); 49 | if (corpus.fileWords[name][word]) { // Word already exists. Increment count. 50 | corpus.fileWords[name][word]++; 51 | } else { 52 | corpus.fileWords[name][word] = 1; 53 | } 54 | }); 55 | }; 56 | 57 | let createInvertedIndex = (tokens) => { 58 | tokens.forEach((word) => { 59 | Object.keys(corpus.fileWords).forEach((name) => { 60 | if (corpus.fileWords[name][word]) { 61 | if (corpus.invertedIndex[word]) { 62 | corpus.invertedIndex[word].push(name); 63 | } else { 64 | corpus.invertedIndex[word] = [name]; 65 | } 66 | } 67 | }); 68 | }); 69 | }; 70 | 71 | let getTf = (word, filename) => { 72 | let worddata = corpus.fileWords[filename]; 73 | let frequency = worddata[word] || 0; 74 | let length = 0; 75 | Object.keys(worddata).forEach((key) => { 76 | length += worddata[key]; 77 | }); 78 | let tf = frequency / length; 79 | return tf; 80 | }; 81 | 82 | let getIdf = (word) => { 83 | let allFiles = Object.keys(corpus.fileWords).length; 84 | let wordFiles = corpus.invertedIndex[word].length; 85 | let idf = Math.log(allFiles / wordFiles); 86 | return idf; 87 | }; 88 | 89 | let createTfIdf = () => { 90 | corpus.tfidf = {}; 91 | let idfCache = {}; 92 | 93 | Object.keys(corpus.fileWords).forEach((file) => { 94 | corpus.tfidf[file] = {}; 95 | Object.keys(corpus.fileWords[file]).forEach((word) => { 96 | if(!(word in idfCache)) { 97 | idfCache[word] = getIdf(word); 98 | } 99 | corpus.tfidf[file][word] = getTf(word, file) * idfCache[word]; 100 | }); 101 | }); 102 | }; 103 | 104 | let processRawDocument = (data, name) => { 105 | let tokens = getTokens(data); 106 | createFileIndex(tokens, name); 107 | }; 108 | 109 | let createFileLengths = () => { 110 | Object.keys(corpus.tfidf).forEach((name) => { 111 | let len = 0; 112 | Object.keys(corpus.tfidf[name]).forEach((word) => { 113 | len += corpus.tfidf[name][word] * corpus.tfidf[name][word]; 114 | }); 115 | corpus.fileLengths[name] = Math.sqrt(len); 116 | }); 117 | }; 118 | 119 | let writeCorpus = () => { 120 | corpus.allTokens = Array.from(corpus.allTokens); 121 | let json = JSON.stringify(corpus); 122 | return fs.writeFile(filepath, json, 'utf8') 123 | .then(() => { 124 | return Promise.resolve('JSON written to disk at: ' + filepath); 125 | }) 126 | .catch((err) => { 127 | return Promise.reject(err); 128 | }); 129 | }; 130 | 131 | let readCorpus = () => { 132 | return fs.readFile(filepath, 'utf8') 133 | .then((data) => { 134 | corpus = JSON.parse(data.toString()); 135 | return Promise.resolve(); 136 | }) 137 | .catch((err) => { 138 | return Promise.reject(err); 139 | }); 140 | }; 141 | 142 | let processQuery = (rawquery) => { 143 | query.raw = rawquery; 144 | query.tokens = getTokens(rawquery); 145 | //calculate word frequency in the query 146 | query.frequency = {}; 147 | query.tokens.forEach((word) => { 148 | if (query.frequency[word]) { // Word already exists. Increment count. 149 | query.frequency[word]++; 150 | } else { 151 | query.frequency[word] = 1; 152 | } 153 | }); 154 | let numberOfFiles = Object.keys(corpus.fileWords).length; 155 | // calculate score of each file for the query. 156 | query.score = {}; 157 | query.tokens.forEach((word) => { 158 | if (corpus.invertedIndex[word]) { 159 | let logbase = 10; 160 | let df = corpus.invertedIndex[word].length; 161 | let idf = Math.log(numberOfFiles / df, logbase); 162 | let wordWeight = idf * (1 + Math.log(query.frequency[word], logbase)); 163 | corpus.invertedIndex[word].forEach((file) => { 164 | let fileWeight = corpus.tfidf[file][word]; 165 | if (query.score[file]) { 166 | query.score[file] += fileWeight * wordWeight; 167 | } else { 168 | query.score[file] = fileWeight * wordWeight; 169 | } 170 | }); 171 | } 172 | }); 173 | // rank the files by the score and sort 174 | Object.keys(query.score).forEach((file) => { 175 | query.score[file] = query.score[file] / corpus.fileLengths[file]; 176 | let rankobj = {}; 177 | rankobj.file = file; 178 | rankobj.score = query.score[file]; 179 | query.ranks.push(rankobj); 180 | }); 181 | query.ranks.sort((a, b) => { 182 | if (a.score > b.score) { 183 | return -1; 184 | } 185 | if (a.score < b.score) { 186 | return 1; 187 | } 188 | return 0; 189 | }); 190 | }; 191 | 192 | exports.printResults = (results, config) => { 193 | // Prints the passed results to the console. 194 | // If the command is not available for the current platform, 195 | // it lists the available platforms instead. 196 | // Example: tldr --search print directory tree --platform sunos prints: 197 | // $ tree (Available on: linux, osx) 198 | index.getShortIndex().then((shortIndex) => { 199 | let outputs = new Set(); 200 | let preferredPlatform = platforms.getPreferredPlatform(config); 201 | results.forEach((elem) => { 202 | let cmdname = utils.parsePagename(elem.file); 203 | let output = ' $ ' + cmdname; 204 | let targets = shortIndex[cmdname]['targets']; 205 | let platforms = targets.map((t) => {return t.platform;}); 206 | if (platforms.indexOf('common') === -1 && platforms.indexOf(preferredPlatform) === -1) { 207 | output += ' (Available on: ' + platforms.join(', ') + ')'; 208 | } 209 | outputs.add(output); 210 | }); 211 | 212 | console.log('Searching for:', query.raw.trim()); 213 | console.log(); 214 | Array.from(outputs).forEach((elem) => { 215 | console.log(elem); 216 | }); 217 | console.log(); 218 | console.log('Run tldr to see specific pages.'); 219 | }); 220 | }; 221 | 222 | exports.createIndex = () => { 223 | return utils.glob(CACHE_FOLDER + '/pages/**/*.md', {}) 224 | .then((files) => { 225 | let promises = []; 226 | files.forEach((file) => { 227 | let promise = fs.readFile(file).then((data) => { 228 | processRawDocument(data.toString(), file); 229 | }); 230 | promises.push(promise); 231 | }); 232 | return Promise.all(promises) 233 | .then(() => { 234 | createInvertedIndex(corpus.allTokens); 235 | createTfIdf(); 236 | createFileLengths(); 237 | return writeCorpus(); 238 | }) 239 | .then(() => { 240 | return Promise.resolve(corpus); 241 | }) 242 | .catch((error) => { 243 | console.error('Error in creating corpus. Exiting.'); 244 | return Promise.reject(error); 245 | }); 246 | }); 247 | }; 248 | 249 | exports.getResults = (rawquery) => { 250 | query.ranks = []; 251 | return readCorpus() 252 | .then(() => { 253 | processQuery(rawquery); 254 | let resultcount = 10; 255 | let results = query.ranks.slice(0, resultcount); 256 | return Promise.resolve(results); 257 | }) 258 | .catch((error) => { 259 | return Promise.reject(error); 260 | }); 261 | }; 262 | -------------------------------------------------------------------------------- /lib/theme.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const get = require('lodash/get'); 4 | const isEmpty = require('lodash/isEmpty'); 5 | const identity = require('lodash/identity'); 6 | const chalk = require('chalk'); 7 | 8 | // Translates strings like 'red, underline, bold' 9 | // into function chalk.red.underline.bold(text) 10 | function buildStylingFunction(styles) { 11 | if (isEmpty(styles)) { 12 | return identity; 13 | } 14 | let stylingFunction = chalk; 15 | let stylesPath = styles.replace(/,\s*/g, '.'); 16 | return get(stylingFunction, stylesPath); 17 | } 18 | 19 | class Theme { 20 | constructor(options) { 21 | this.theme = options; 22 | } 23 | 24 | hasDistinctStylesForTypes() { 25 | return (this.theme['exampleBool'] 26 | && this.theme['exampleNumber'] 27 | && this.theme['exampleString']); 28 | } 29 | 30 | getStylingFunction(partName) { 31 | let styles = this.theme[partName]; 32 | return buildStylingFunction(styles); 33 | } 34 | 35 | renderCommandName(text) { 36 | return this.getStylingFunction('commandName')(text); 37 | } 38 | 39 | renderMainDescription(text) { 40 | return this.getStylingFunction('mainDescription')(text); 41 | } 42 | 43 | renderExampleDescription(text) { 44 | return this.getStylingFunction('exampleDescription')(text); 45 | } 46 | 47 | renderExampleCode(text) { 48 | return this.getStylingFunction('exampleCode')(text); 49 | } 50 | 51 | renderExampleToken(text) { 52 | let tokenName = 'exampleToken'; 53 | if (!this.hasDistinctStylesForTypes()) 54 | return this.getStylingFunction(tokenName)(text); 55 | 56 | let baseStyle = this.theme[tokenName] || ''; 57 | let typeStyle = ''; 58 | 59 | if (!Number.isNaN(Number(text))) 60 | typeStyle = this.theme['exampleNumber']; 61 | else if (/true|false/.test(text)) 62 | typeStyle = this.theme['exampleBool']; 63 | else 64 | typeStyle = this.theme['exampleString']; 65 | 66 | let combinedStyle = baseStyle ? 67 | baseStyle + (typeStyle ? ', ' + typeStyle : '') : 68 | typeStyle; 69 | 70 | return buildStylingFunction(combinedStyle)(text); 71 | } 72 | } 73 | 74 | module.exports = Theme; 75 | -------------------------------------------------------------------------------- /lib/tldr.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const sample = require('lodash/sample'); 4 | const fs = require('fs-extra'); 5 | const ms = require('ms'); 6 | const ora = require('ora'); 7 | const { EmptyCacheError, MissingPageError, MissingRenderPathError } = require('./errors'); 8 | const Cache = require('./cache'); 9 | const search = require('./search'); 10 | const platforms = require('./platforms'); 11 | const parser = require('./parser'); 12 | const render = require('./render'); 13 | const index = require('./index'); 14 | 15 | 16 | class Tldr { 17 | constructor(config) { 18 | this.config = config; 19 | this.cache = new Cache(this.config); 20 | } 21 | 22 | list(singleColumn) { 23 | let platform = platforms.getPreferredPlatformFolder(this.config); 24 | return index.commandsFor(platform) 25 | .then((commands) => { 26 | return this.printPages(commands, singleColumn); 27 | }); 28 | } 29 | 30 | listAll(singleColumn) { 31 | return index.commands() 32 | .then((commands) => { 33 | return this.printPages(commands, singleColumn); 34 | }); 35 | } 36 | 37 | get(commands, options) { 38 | return this.printBestPage(commands.join('-'), options); 39 | } 40 | 41 | random(options) { 42 | let platform = platforms.getPreferredPlatformFolder(this.config); 43 | return index.commandsFor(platform) 44 | .then((pages) => { 45 | if (pages.length === 0) { 46 | throw new EmptyCacheError(); 47 | } 48 | let page = sample(pages); 49 | console.log('PAGE', page); 50 | return this.printBestPage(page, options); 51 | }) 52 | .catch((err) => { 53 | console.error(err); 54 | }); 55 | } 56 | 57 | randomExample() { 58 | let platform = platforms.getPreferredPlatformFolder(this.config); 59 | return index.commandsFor(platform) 60 | .then((pages) => { 61 | if (pages.length === 0) { 62 | throw new EmptyCacheError(); 63 | } 64 | let page = sample(pages); 65 | console.log('PAGE', page); 66 | return this.printBestPage(page, {randomExample: true}); 67 | }) 68 | .catch((err) => { 69 | console.error(err); 70 | }); 71 | } 72 | 73 | render(file) { 74 | if (typeof file !== 'string') { 75 | throw new MissingRenderPathError(); 76 | } 77 | return fs.readFile(file, 'utf8') 78 | .then((content) => { 79 | // Getting the shortindex first to populate the shortindex var 80 | return index.getShortIndex().then(() => { 81 | this.renderContent(content); 82 | }); 83 | }); 84 | } 85 | 86 | clearCache() { 87 | return this.cache.clear().then(() => { 88 | console.log('Done'); 89 | }); 90 | } 91 | 92 | updateCache() { 93 | return spinningPromise('Updating...', () => { 94 | return this.cache.update(); 95 | }); 96 | } 97 | 98 | updateIndex() { 99 | return spinningPromise('Creating index...', () => { 100 | return search.createIndex(); 101 | }); 102 | } 103 | 104 | search(keywords) { 105 | return search.getResults(keywords.join(' ')) 106 | .then((results) => { 107 | // TODO: make search into a class also. 108 | search.printResults(results, this.config); 109 | }); 110 | } 111 | 112 | printPages(pages, singleColumn) { 113 | if (pages.length === 0) { 114 | throw new EmptyCacheError(); 115 | } 116 | return this.checkStale() 117 | .then(() => { 118 | let endOfLine = require('os').EOL; 119 | let delimiter = singleColumn ? endOfLine : ', '; 120 | console.log('\n' + pages.join(delimiter)); 121 | }); 122 | } 123 | 124 | printBestPage(command, options={}) { 125 | // Trying to get the page from cache first 126 | return this.cache.getPage(command) 127 | .then((content) => { 128 | // If found in first try, render it 129 | if (!content) { 130 | // If not found, try to update cache unless user explicitly wants to skip 131 | if (this.config.skipUpdateWhenPageNotFound === true) { 132 | return ''; 133 | } 134 | return spinningPromise('Page not found. Updating cache...', () => { 135 | return this.cache.update(); 136 | }) 137 | .then(() => { 138 | return spinningPromise('Creating index...', () => { 139 | return search.createIndex(); 140 | }); 141 | }) 142 | .then(() => { 143 | // And then, try to check in cache again 144 | return this.cache.getPage(command); 145 | }); 146 | } 147 | return content; 148 | }) 149 | .then((content) => { 150 | if (!content) { 151 | throw new MissingPageError(this.config.pagesRepository); 152 | } 153 | return this.checkStale() 154 | .then(() => { 155 | this.renderContent(content, options); 156 | }); 157 | }); 158 | } 159 | 160 | checkStale() { 161 | return this.cache.lastUpdated() 162 | .then((stats) => { 163 | if (stats.mtime < Date.now() - ms('30d')) { 164 | console.warn('Cache is out of date. You should run "tldr --update"'); 165 | } 166 | }); 167 | } 168 | 169 | renderContent(content, options={}) { 170 | if (options.markdown) { 171 | return console.log(content); 172 | } 173 | let page = parser.parse(content); 174 | if (options && options.randomExample === true) { 175 | page.examples = [sample(page.examples)]; 176 | } 177 | let output = render.toANSI(page, this.config); 178 | if (output) { 179 | console.log(output); 180 | } 181 | } 182 | } 183 | 184 | function spinningPromise(text, factory) { 185 | const spinner = ora(); 186 | spinner.start(text); 187 | return factory() 188 | .then((val) => { 189 | spinner.succeed(); 190 | return val; 191 | }) 192 | .catch((err) => { 193 | spinner.fail(); 194 | throw err; 195 | }); 196 | } 197 | 198 | module.exports = Tldr; 199 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs-extra'); 2 | const path = require('path'); 3 | const { glob } = require('glob'); 4 | const crypto = require('crypto'); 5 | const flatten = require('lodash/flatten'); 6 | 7 | module.exports = { 8 | parsePlatform(pagefile) { 9 | const components = pagefile.split(path.sep); 10 | return components[components.length-2]; 11 | }, 12 | 13 | parsePagename(pagefile) { 14 | return path.basename(pagefile, '.md'); 15 | }, 16 | 17 | parseLanguage(pagefile) { 18 | const components = pagefile.split(path.sep); 19 | const langPathIndex = 3; 20 | const langParts = components[components.length-langPathIndex].split('.'); 21 | if (langParts.length === 1) { 22 | return 'en'; 23 | } 24 | return langParts[1]; 25 | }, 26 | 27 | localeToLang(locale) { 28 | if(locale === undefined || locale.startsWith('en')) return []; 29 | 30 | const withDialect = ['pt', 'zh']; 31 | 32 | let lang = locale; 33 | if(lang.includes('.')) { 34 | lang = lang.substring(0, lang.indexOf('.')); 35 | } 36 | 37 | // Check for language code & country code. 38 | let ll = lang, cc = ''; 39 | if(lang.includes('_')) { 40 | cc = lang.substring(lang.indexOf('_') + 1); 41 | ll = lang.substring(0, lang.indexOf('_')); 42 | } 43 | 44 | // If we have dialect for this language take dialect as well. 45 | if(withDialect.indexOf(ll) !== -1 && cc !== '') { 46 | return [ll, ll + '_' + cc]; 47 | } 48 | 49 | return [ll]; 50 | }, 51 | 52 | isPage(file) { 53 | return path.extname(file) === '.md'; 54 | }, 55 | 56 | // TODO: remove this 57 | commandSupportedOn(platform) { 58 | return (command) => { 59 | return command.platform.indexOf(platform) >= 0 60 | || command.platform.indexOf('common') >= 0; 61 | }; 62 | }, 63 | 64 | walk: function walk(dir) { 65 | return fs.readdir(dir) 66 | .then((items) => { 67 | return Promise.all(items.map((item) => { 68 | const itemPath = path.join(dir, item); 69 | return fs.stat(itemPath).then((stat) => { 70 | if (stat.isDirectory()) { 71 | return walk(itemPath); 72 | } 73 | return path.join(dir, item); 74 | }); 75 | })); 76 | }) 77 | .then((paths) => { 78 | return flatten(paths); 79 | }); 80 | }, 81 | 82 | glob(string,options) { 83 | return new Promise((resolve, reject) => { 84 | glob(string, options, (err, data) => { 85 | if (err) { 86 | reject(err); 87 | } else { 88 | resolve(data); 89 | } 90 | }); 91 | }); 92 | }, 93 | 94 | 95 | uniqueId(length = 32) { 96 | const size = Math.ceil(length / 2); 97 | return crypto.randomBytes(size).toString('hex').slice(0, length); 98 | } 99 | }; 100 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tldr", 3 | "version": "3.4.0", 4 | "description": "Simplified and community-driven man pages", 5 | "author": "Romain Prieto", 6 | "license": "MIT", 7 | "repository": { 8 | "type": "git", 9 | "url": "git+https://github.com/tldr-pages/tldr-node-client.git" 10 | }, 11 | "keywords": [ 12 | "man", 13 | "pages", 14 | "cheatsheets", 15 | "examples", 16 | "help", 17 | "unix", 18 | "linux", 19 | "osx", 20 | "openbsd", 21 | "freebsd", 22 | "netbsd", 23 | "commands", 24 | "command-line", 25 | "shell", 26 | "bash", 27 | "zsh" 28 | ], 29 | "homepage": "https://tldr.sh", 30 | "engines": { 31 | "node": ">=22" 32 | }, 33 | "main": "bin/tldr", 34 | "files": [ 35 | "bin", 36 | "config.json", 37 | "lib", 38 | "LICENSE.md" 39 | ], 40 | "bin": { 41 | "tldr": "bin/tldr" 42 | }, 43 | "preferGlobal": true, 44 | "directories": { 45 | "test": "test" 46 | }, 47 | "scripts": { 48 | "start": "node ./bin/tldr", 49 | "example": "node ./bin/tldr tar", 50 | "test": "mocha", 51 | "test:quiet": "mocha --reporter=dot", 52 | "lint": "eslint lib test bin/tldr", 53 | "watch": "mocha --reporter=min --watch --growl", 54 | "test:functional": "bash test/functional-test.sh", 55 | "test:coverage": "nyc mocha", 56 | "test:all": "npm run lint && npm test && npm run test:functional", 57 | "prepare": "husky" 58 | }, 59 | "dependencies": { 60 | "adm-zip": "^0.5.10", 61 | "axios": "^1.6.0", 62 | "chalk": "^4.1.0", 63 | "commander": "^6.1.0", 64 | "fs-extra": "^11.2.0", 65 | "glob": "^11.0.0", 66 | "he": "^1.2.0", 67 | "lodash": "^4.17.20", 68 | "marked": "^4.0.10", 69 | "ms": "^2.1.2", 70 | "natural": "^8.0.1", 71 | "ora": "^5.1.0" 72 | }, 73 | "devDependencies": { 74 | "eslint": "^9.14.0", 75 | "eslint-config-eslint": "^11.0.0", 76 | "husky": "^9.1.6", 77 | "mocha": "^11.0.1", 78 | "nyc": "^17.1.0", 79 | "should": "^13.2.3", 80 | "sinon": "^19.0.2" 81 | }, 82 | "funding": { 83 | "type": "liberapay", 84 | "url": "https://liberapay.com/tldr-pages" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tldr-pages/tldr-node-client/e3e2a323ec98d7701c5a024b1c689569e669600e/screenshot.png -------------------------------------------------------------------------------- /test/cache.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Cache = require('../lib/cache'); 4 | const config = require('../lib/config'); 5 | const should = require('should'); 6 | const sinon = require('sinon'); 7 | const path = require('path'); 8 | const fs = require('fs-extra'); 9 | const index = require('../lib/index'); 10 | const remote = require('../lib/remote'); 11 | const platforms = require('../lib/platforms'); 12 | 13 | 14 | describe('Cache', () => { 15 | describe('update()', () => { 16 | beforeEach(() => { 17 | sinon.spy(fs, 'ensureDir'); 18 | sinon.spy(fs, 'remove'); 19 | sinon.stub(fs, 'copy').resolves(); 20 | sinon.stub(remote, 'download').resolves(); 21 | sinon.stub(index, 'rebuildPagesIndex').resolves(); 22 | this.cacheFolder = path.join(config.get().cache, 'cache'); 23 | }); 24 | 25 | it('should use randomly created temp folder', () => { 26 | const count = 16; 27 | const cache = new Cache(config.get()); 28 | return Promise.all(Array.from({ length: count }).map(() => { 29 | return cache.update(); 30 | })).then(() => { 31 | let calls = fs.ensureDir.getCalls().filter((call) => { 32 | return !call.calledWith(this.cacheFolder); 33 | }); 34 | calls.should.have.length(count); 35 | let tempFolders = calls.map((call) => { 36 | return call.args[0]; 37 | }); 38 | tempFolders.should.have.length(new Set(tempFolders).size); 39 | }); 40 | }); 41 | 42 | it('should remove temp folder after cache gets updated', () => { 43 | const cache = new Cache(config.get()); 44 | return cache.update().then(() => { 45 | let createFolder = fs.ensureDir.getCalls().find((call) => { 46 | return !call.calledWith(this.cacheFolder); 47 | }); 48 | let removeFolder = fs.remove.getCall(0); 49 | removeFolder.args[0].should.be.equal(createFolder.args[0]); 50 | }); 51 | }); 52 | 53 | afterEach(() => { 54 | fs.ensureDir.restore(); 55 | fs.remove.restore(); 56 | fs.copy.restore(); 57 | remote.download.restore(); 58 | index.rebuildPagesIndex.restore(); 59 | }); 60 | }); 61 | 62 | describe('getPage()', () => { 63 | beforeEach(() => { 64 | sinon.stub(index, 'getShortIndex').returns({ 65 | cp: ['common'], 66 | git: ['common'], 67 | ln: ['common'], 68 | ls: ['common'], 69 | dd: ['linux', 'osx', 'sunos'], 70 | du: ['linux', 'osx', 'sunos'], 71 | top: ['linux', 'osx'], 72 | svcs: ['sunos'], 73 | pkg: ['android', 'freebsd', 'openbsd'], 74 | pkgin: ['netbsd'] 75 | }); 76 | }); 77 | 78 | afterEach(() => { 79 | index.getShortIndex.restore(); 80 | }); 81 | 82 | it('should return page contents for ls', () => { 83 | sinon.stub(fs, 'readFile').resolves('# ls\n> ls page'); 84 | sinon.stub(platforms, 'getPreferredPlatformFolder').returns('osx'); 85 | sinon.stub(index, 'findPage').resolves('osx'); 86 | const cache = new Cache(config.get()); 87 | return cache.getPage('ls') 88 | .then((content) => { 89 | should.exist(content); 90 | content.should.startWith('# ls'); 91 | fs.readFile.restore(); 92 | platforms.getPreferredPlatformFolder.restore(); 93 | index.findPage.restore(); 94 | }); 95 | }); 96 | 97 | it('should return empty contents for svcs on OSX', () =>{ 98 | sinon.stub(fs, 'readFile').resolves('# svcs\n> svcs'); 99 | sinon.stub(platforms, 'getPreferredPlatformFolder').returns('osx'); 100 | sinon.stub(index, 'findPage').resolves(null); 101 | const cache = new Cache(config.get()); 102 | return cache.getPage('svc') 103 | .then((content) => { 104 | should.not.exist(content); 105 | fs.readFile.restore(); 106 | platforms.getPreferredPlatformFolder.restore(); 107 | index.findPage.restore(); 108 | }); 109 | }); 110 | 111 | it('should return page contents for svcs on SunOS', () => { 112 | sinon.stub(fs, 'readFile').resolves('# svcs\n> svcs'); 113 | sinon.stub(platforms, 'getPreferredPlatformFolder').returns('sunos'); 114 | sinon.stub(index, 'findPage').resolves('svcs'); 115 | const cache = new Cache(config.get()); 116 | return cache.getPage('svcs') 117 | .then((content) => { 118 | should.exist(content); 119 | content.should.startWith('# svcs'); 120 | fs.readFile.restore(); 121 | platforms.getPreferredPlatformFolder.restore(); 122 | index.findPage.restore(); 123 | }); 124 | }); 125 | 126 | it('should return page contents for pkg on Android', () => { 127 | sinon.stub(fs, 'readFile').resolves('# pkg\n> pkg'); 128 | sinon.stub(platforms, 'getPreferredPlatformFolder').returns('android'); 129 | sinon.stub(index, 'findPage').resolves('pkg'); 130 | const cache = new Cache(config.get()); 131 | return cache.getPage('pkg') 132 | .then((content) => { 133 | should.exist(content); 134 | content.should.startWith('# pkg'); 135 | fs.readFile.restore(); 136 | platforms.getPreferredPlatformFolder.restore(); 137 | index.findPage.restore(); 138 | }); 139 | }); 140 | 141 | it('should return empty contents for non-existing page', () => { 142 | const cache = new Cache(config.get()); 143 | return cache.getPage('qwerty') 144 | .then((content) => { 145 | return should.not.exist(content); 146 | }); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /test/completion.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Completion = require('../lib/completion'); 4 | const { UnsupportedShellError, CompletionScriptError } = require('../lib/errors'); 5 | const sinon = require('sinon'); 6 | const fs = require('fs'); 7 | const os = require('os'); 8 | const should = require('should'); 9 | const path = require('path'); 10 | 11 | describe('Completion', () => { 12 | const zshrcPath = path.join(os.homedir(), '.zshrc'); 13 | const bashrcPath = path.join(os.homedir(), '.bashrc'); 14 | 15 | describe('constructor()', () => { 16 | it('should construct with supported shell', () => { 17 | const completion = new Completion('zsh'); 18 | should.exist(completion); 19 | completion.shell.should.equal('zsh'); 20 | completion.rcFilename.should.equal('.zshrc'); 21 | }); 22 | 23 | it('should throw UnsupportedShellError for unsupported shell', () => { 24 | (() => {return new Completion('fish');}).should.throw(UnsupportedShellError); 25 | }); 26 | }); 27 | 28 | describe('getFilePath()', () => { 29 | it('should return .zshrc path for zsh', () => { 30 | const completion = new Completion('zsh'); 31 | completion.getFilePath().should.equal(zshrcPath); 32 | }); 33 | 34 | it('should return .bashrc path for bash', () => { 35 | const completion = new Completion('bash'); 36 | completion.getFilePath().should.equal(bashrcPath); 37 | }); 38 | }); 39 | 40 | describe('appendScript()', () => { 41 | let appendFileStub; 42 | 43 | beforeEach(() => { 44 | appendFileStub = sinon.stub(fs, 'appendFile').yields(null); 45 | }); 46 | 47 | afterEach(() => { 48 | appendFileStub.restore(); 49 | }); 50 | 51 | it('should append script to file', () => { 52 | const completion = new Completion('zsh'); 53 | return completion.appendScript('test script') 54 | .then(() => { 55 | appendFileStub.calledOnce.should.be.true(); 56 | appendFileStub.firstCall.args[0].should.equal(zshrcPath); 57 | appendFileStub.firstCall.args[1].should.equal('\ntest script\n'); 58 | }); 59 | }); 60 | 61 | it('should reject with CompletionScriptError on fs error', () => { 62 | const completion = new Completion('zsh'); 63 | appendFileStub.yields(new Error('File write error')); 64 | return completion.appendScript('test script') 65 | .should.be.rejectedWith(CompletionScriptError); 66 | }); 67 | }); 68 | 69 | describe('getScript()', () => { 70 | it('should return zsh script for zsh shell', () => { 71 | const completion = new Completion('zsh'); 72 | return completion.getScript() 73 | .then((script) => { 74 | script.should.containEql('# tldr zsh completion'); 75 | script.should.containEql('fpath=('); 76 | }); 77 | }); 78 | 79 | it('should return bash script for bash shell', () => { 80 | const completion = new Completion('bash'); 81 | const readFileStub = sinon.stub(fs, 'readFile').yields(null, '# bash completion script'); 82 | 83 | return completion.getScript() 84 | .then((script) => { 85 | script.should.equal('# bash completion script'); 86 | readFileStub.restore(); 87 | }); 88 | }); 89 | }); 90 | 91 | describe('getZshScript()', () => { 92 | it('should return zsh completion script', () => { 93 | const completion = new Completion('zsh'); 94 | const script = completion.getZshScript(); 95 | script.should.containEql('# tldr zsh completion'); 96 | script.should.containEql('fpath=('); 97 | script.should.containEql('compinit'); 98 | }); 99 | }); 100 | 101 | describe('getBashScript()', () => { 102 | let readFileStub; 103 | 104 | beforeEach(() => { 105 | readFileStub = sinon.stub(fs, 'readFile'); 106 | }); 107 | 108 | afterEach(() => { 109 | readFileStub.restore(); 110 | }); 111 | 112 | it('should return bash completion script', () => { 113 | const completion = new Completion('bash'); 114 | readFileStub.yields(null, '# bash completion script'); 115 | 116 | return completion.getBashScript() 117 | .then((script) => { 118 | script.should.equal('# bash completion script'); 119 | }); 120 | }); 121 | 122 | it('should reject with CompletionScriptError on fs error', () => { 123 | const completion = new Completion('bash'); 124 | readFileStub.yields(new Error('File read error')); 125 | 126 | return completion.getBashScript() 127 | .should.be.rejectedWith(CompletionScriptError); 128 | }); 129 | }); 130 | }); 131 | -------------------------------------------------------------------------------- /test/config.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs'); 4 | const sinon = require('sinon'); 5 | const config = require('../lib/config'); 6 | 7 | describe('Config', () => { 8 | 9 | const DEFAULT = 10 | ` 11 | { 12 | "repository": "http://tldr-pages.github.io/assets/tldr.zip" 13 | }`; 14 | 15 | const CUSTOM = 16 | ` 17 | { 18 | "repository": "http://myrepo/assets/tldr.zip" 19 | }`; 20 | 21 | const CUSTOM_INVALID_JSON = 22 | `# comments are not allowed in json 23 | {}`; 24 | 25 | const CUSTOM_INVALID_SCHEMA = 26 | ` 27 | { 28 | "themes": { 29 | "simple": { 30 | "commandName": "bold,underline", 31 | "mainDescription": "#876992", 32 | "exampleDescription": "", 33 | "exampleCode": "", 34 | "exampleToken": "underline" 35 | } 36 | } 37 | }`; 38 | 39 | 40 | beforeEach(() => { 41 | sinon.stub(fs, 'readFileSync'); 42 | }); 43 | 44 | afterEach(() => { 45 | fs.readFileSync.restore(); 46 | }); 47 | 48 | it('should load the default config', () => { 49 | fs.readFileSync.onCall(0).returns(DEFAULT); 50 | fs.readFileSync.onCall(1).throws('Not found'); 51 | config.get().repository.should.eql('http://tldr-pages.github.io/assets/tldr.zip'); 52 | }); 53 | 54 | it('should override the defaults with content from .tldrrc', () => { 55 | fs.readFileSync.onCall(0).returns(DEFAULT); 56 | fs.readFileSync.onCall(1).returns(CUSTOM); 57 | config.get().repository.should.eql('http://myrepo/assets/tldr.zip'); 58 | }); 59 | 60 | it('should validate the custom config JSON', () => { 61 | fs.readFileSync.onCall(0).returns(DEFAULT); 62 | fs.readFileSync.onCall(1).returns(CUSTOM_INVALID_JSON); 63 | config.get.should.throw(/not a valid JSON object/); 64 | }); 65 | 66 | it('should validate the custom config schema', () => { 67 | fs.readFileSync.onCall(0).returns(DEFAULT); 68 | fs.readFileSync.onCall(1).returns(CUSTOM_INVALID_SCHEMA); 69 | config.get.should.throw(/Invalid theme value/); 70 | }); 71 | 72 | }); 73 | -------------------------------------------------------------------------------- /test/functional-test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | shopt -s expand_aliases 4 | 5 | alias tldr="node bin/tldr" 6 | 7 | function tldr-render-pages { 8 | tldr zip && \ 9 | tldr du --platform=linux && \ 10 | tldr du --platform=osx && \ 11 | tldr du --platform=linux --markdown && \ 12 | tldr du --platform=osx --markdown && \ 13 | tldr du --platform=windows --markdown && \ 14 | LANG= tldr --random && \ 15 | LANG= tldr --random-example && \ 16 | tldr --list && \ 17 | tldr --list-all 18 | } 19 | 20 | tldr --update && \ 21 | tldr --render $HOME/.tldr/cache/pages/common/ssh.md && \ 22 | tldr --update && tldr-render-pages && \ 23 | tldr --clear-cache && \ 24 | tldr --update && tldr-render-pages && \ 25 | LANG=pt_BR tldr-render-pages && \ 26 | unset LANG && tldr-render-pages \ 27 | tldr --search "disk space" 28 | -------------------------------------------------------------------------------- /test/index.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const fs = require('fs-extra'); 4 | const path = require('path'); 5 | const index = require('../lib/index'); 6 | const utils = require('../lib/utils'); 7 | const sinon = require('sinon'); 8 | const should = require('should'); 9 | 10 | const pages = [ 11 | ['/index.json'], 12 | ['/pages', 'linux', 'apk.md'], 13 | ['/pages.zh', 'linux', 'apk.md'], 14 | ['/pages', 'common', 'cp.md'], 15 | ['/pages.it', 'common', 'cp.md'], 16 | ['/pages.ta', 'common', 'cp.md'], 17 | ['/pages', 'common', 'git.md'], 18 | ['/pages', 'common', 'ln.md'], 19 | ['/pages', 'common', 'ls.md'], 20 | ['/pages', 'freebsd', 'pkg.md'], 21 | ['/pages', 'linux', 'dd.md'], 22 | ['/pages', 'linux', 'du.md'], 23 | ['/pages', 'linux', 'top.md'], 24 | ['/pages', 'netbsd', 'pkgin.md'], 25 | ['/pages', 'openbsd', 'pkg.md'], 26 | ['/pages', 'osx', 'dd.md'], 27 | ['/pages', 'osx', 'du.md'], 28 | ['/pages', 'osx', 'top.md'], 29 | ['/pages', 'sunos', 'dd.md'], 30 | ['/pages', 'sunos', 'du.md'], 31 | ['/pages', 'sunos', 'svcs.md'], 32 | ['/pages', 'android', 'pkg.md'], 33 | ].map((x) => { 34 | return path.join(...x); 35 | }); 36 | 37 | describe('Index building', () => { 38 | beforeEach(() => { 39 | sinon.stub(fs, 'readJson').rejects('dummy error'); 40 | sinon.stub(fs, 'writeJson').resolves(''); 41 | return index.rebuildPagesIndex(); 42 | }); 43 | 44 | describe('failure', () => { 45 | before(() => { 46 | sinon.stub(utils, 'walk').rejects('dummy error'); 47 | }); 48 | 49 | it('shortIndex should not be created', () => { 50 | return index.hasPage('cp').should.be.false() && 51 | index.hasPage('dummy').should.be.false(); 52 | }); 53 | }); 54 | 55 | describe('success', () => { 56 | before(() => { 57 | sinon.stub(utils, 'walk').resolves(pages); 58 | }); 59 | 60 | it('correct shortIndex should be created', () => { 61 | return index.hasPage('cp').should.be.true() && 62 | index.hasPage('dummy').should.be.false(); 63 | }); 64 | }); 65 | 66 | afterEach(() => { 67 | utils.walk.restore(); 68 | fs.readJson.restore(); 69 | fs.writeJson.restore(); 70 | }); 71 | }); 72 | 73 | describe('Index', () => { 74 | beforeEach(() => { 75 | index.clearRuntimeIndex(); 76 | sinon.stub(utils, 'walk').resolves(pages); 77 | sinon.stub(fs, 'readJson').rejects('dummy error'); 78 | sinon.stub(fs, 'writeJson').resolves(''); 79 | }); 80 | 81 | afterEach(() => { 82 | utils.walk.restore(); 83 | fs.readJson.restore(); 84 | fs.writeJson.restore(); 85 | }); 86 | 87 | describe('findPage()', () => { 88 | it('should find Linux platform for apk command for Chinese', () => { 89 | return index.findPage('apk', 'linux', 'zh') 90 | .then((folder) => { 91 | should.equal(folder, path.join('pages.zh', 'linux')); 92 | }); 93 | }); 94 | 95 | it('should find platform android for pkg command for English', () => { 96 | return index.findPage('pkg', 'android', 'en') 97 | .then((folder) => { 98 | should.equal(folder, path.join('pages', 'android')); 99 | }); 100 | }); 101 | 102 | it('should find platform freebsd for pkg command for English', () => { 103 | return index.findPage('pkg', 'freebsd', 'en') 104 | .then((folder) => { 105 | should.equal(folder, path.join('pages', 'freebsd')); 106 | }); 107 | }); 108 | 109 | it('should find platform openbsd for pkg command for English', () => { 110 | return index.findPage('pkg', 'openbsd', 'en') 111 | .then((folder) => { 112 | should.equal(folder, path.join('pages', 'openbsd')); 113 | }); 114 | }); 115 | 116 | it('should find platform netbsd for pkgin command for English', () => { 117 | return index.findPage('pkgin', 'netbsd', 'en') 118 | .then((folder) => { 119 | should.equal(folder, path.join('pages', 'netbsd')); 120 | }); 121 | }); 122 | 123 | it('should find Linux platform for apk command for Chinese given Windows', () => { 124 | return index.findPage('apk', 'windows', 'zh') 125 | .then((folder) => { 126 | should.equal(folder, path.join('pages.zh', 'linux')); 127 | }); 128 | }); 129 | 130 | it('should find Linux platform for dd command', () => { 131 | return index.findPage('dd', 'linux', 'en') 132 | .then((folder) => { 133 | should.equal(folder, path.join('pages', 'linux')); 134 | }); 135 | }); 136 | 137 | it('should find platform common for cp command for English', () => { 138 | return index.findPage('cp', 'linux', 'en') 139 | .then((folder) => { 140 | should.equal(folder, path.join('pages', 'common')); 141 | }); 142 | }); 143 | 144 | it('should find platform common for cp command for Tamil', () => { 145 | return index.findPage('cp', 'linux', 'ta') 146 | .then((folder) => { 147 | should.equal(folder, path.join('pages.ta', 'common')); 148 | }); 149 | }); 150 | 151 | it('should find platform common for cp command for Italian', () => { 152 | return index.findPage('cp', 'linux', 'it') 153 | .then((folder) => { 154 | should.equal(folder, path.join('pages.it', 'common')); 155 | }); 156 | }); 157 | 158 | it('should find platform common for cp command for Italian given Windows', () => { 159 | return index.findPage('cp', 'windows', 'it') 160 | .then((folder) => { 161 | should.equal(folder, path.join('pages.it', 'common')); 162 | }); 163 | }); 164 | 165 | it('should find platform common for ls command for Italian', () => { 166 | return index.findPage('ls', 'linux', 'it') 167 | .then((folder) => { 168 | should.equal(folder, path.join('pages', 'common')); 169 | }); 170 | }); 171 | 172 | 173 | it('should find platform common for cp command for Italian given common platform', () => { 174 | return index.findPage('cp', 'common', 'it') 175 | .then((folder) => { 176 | should.equal(folder, path.join('pages.it', 'common')); 177 | }); 178 | }); 179 | 180 | it('should find platform common for cp command for English given a bad language', () => { 181 | return index.findPage('cp', 'linux', 'notexist') 182 | .then((folder) => { 183 | should.equal(folder, path.join('pages', 'common')); 184 | }); 185 | }); 186 | 187 | it('should find platform for svcs command on Linux', () => { 188 | return index.findPage('svcs', 'linux', 'en') 189 | .then((folder) => { 190 | should.equal(folder, path.join('pages', 'sunos')); 191 | }); 192 | }); 193 | 194 | it('should not find platform for non-existing command', () => { 195 | return index.findPage('qwerty', 'linux', 'en') 196 | .then((folder) => { 197 | should.not.exist(folder); 198 | }); 199 | }); 200 | }); 201 | 202 | it('should return the correct list of all pages', () => { 203 | return index.commands() 204 | .then((commands) => { 205 | should.deepEqual(commands, [ 206 | 'apk', 'cp', 'dd', 'du', 'git', 'ln', 'ls', 'pkg', 'pkgin', 'svcs', 'top' 207 | ]); 208 | }); 209 | }); 210 | 211 | describe('commandsFor()', () => { 212 | it('should return the correct list of pages for Linux', () => { 213 | return index.commandsFor('linux') 214 | .then((commands) => { 215 | should.deepEqual(commands, [ 216 | 'apk', 'cp', 'dd', 'du', 'git', 'ln', 'ls', 'top' 217 | ]); 218 | }); 219 | }); 220 | 221 | it('should return the correct list of pages for OSX', () => { 222 | return index.commandsFor('osx') 223 | .then((commands) => { 224 | should.deepEqual(commands, [ 225 | 'cp', 'dd', 'du', 'git', 'ln', 'ls', 'top' 226 | ]); 227 | }); 228 | }); 229 | 230 | it('should return the correct list of pages for SunOS', () => { 231 | return index.commandsFor('sunos') 232 | .then((commands) => { 233 | should.deepEqual(commands, [ 234 | 'cp', 'dd', 'du', 'git', 'ln', 'ls', 'svcs' 235 | ]); 236 | }); 237 | }); 238 | }); 239 | 240 | it('should return the correct short index on getShortIndex()', () => { 241 | return index.getShortIndex() 242 | .then((idx) => { 243 | should.deepEqual(idx, { 244 | apk: { targets: [{ language: 'en', platform: 'linux' }, { language: 'zh', platform: 'linux' }] }, 245 | cp: { targets: [{ language: 'en', platform: 'common' }, { language: 'it', platform: 'common' }, { language: 'ta', platform: 'common' }] }, 246 | dd: { targets: [{ language: 'en', platform: 'linux' }, { language: 'en', platform: 'osx' }, { language: 'en', platform: 'sunos' }] }, 247 | du: { targets: [{ language: 'en', platform: 'linux' }, { language: 'en', platform: 'osx' }, { language: 'en', platform: 'sunos' }] }, 248 | git: { targets: [{ language: 'en', platform: 'common' }] }, 249 | ln: { targets: [{ language: 'en', platform: 'common' }] }, 250 | ls: { targets: [{ language: 'en', platform: 'common' }] }, 251 | pkg: { targets: [{ language: 'en', platform: 'freebsd' }, { language: 'en', platform: 'openbsd' }, { language: 'en', platform: 'android' }] }, 252 | pkgin: { targets: [{ language: 'en', platform: 'netbsd' }] }, 253 | svcs: { targets: [{ language: 'en', platform: 'sunos' }] }, 254 | top: { targets: [{ language: 'en', platform: 'linux' }, { language: 'en', platform: 'osx' }] }, 255 | }); 256 | }); 257 | }); 258 | }); 259 | -------------------------------------------------------------------------------- /test/mocha.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | process.env.NODE_ENV = 'test'; 4 | 5 | const sinon = require('sinon'); 6 | 7 | beforeEach(() => { 8 | sinon.stub(console); 9 | }); 10 | 11 | afterEach(() => { 12 | sinon.restore(); 13 | }); 14 | -------------------------------------------------------------------------------- /test/parser.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const parser = require('../lib/parser'); 4 | const sinon = require('sinon'); 5 | const index = require('../lib/index'); 6 | 7 | describe('Parser', () => { 8 | it('parses the command name', () => { 9 | let page = parser.parse( 10 | '\n# tar' 11 | ); 12 | page.name.should.eql('tar'); 13 | }); 14 | 15 | it('parses the description', () => { 16 | let page = parser.parse(` 17 | # tar 18 | > archiving utility` 19 | ); 20 | page.description.should.eql('archiving utility'); 21 | }); 22 | 23 | it('can parse the description on multiple lines', () => { 24 | let page = parser.parse(` 25 | # tar 26 | > archiving utility 27 | > with support for compression` 28 | ); 29 | page.description.should.eql('archiving utility\nwith support for compression'); 30 | }); 31 | 32 | it('can parse the homepage', () => { 33 | let page = parser.parse(` 34 | # tar 35 | > archiving utility 36 | > Homepage: .` 37 | ); 38 | page.description.should.eql('archiving utility\nHomepage: https://www.gnu.org/software/tar/manual/tar.html.'); 39 | }); 40 | 41 | it('does not escape HTML entities', () => { 42 | let page = parser.parse(` 43 | # tar 44 | > compress & decompress` 45 | ); 46 | page.description.should.eql('compress & decompress'); 47 | }); 48 | 49 | it('parses example descriptions and codes', () => { 50 | let page = parser.parse(` 51 | # tar 52 | > archiving utility 53 | 54 | - create an archive 55 | 56 | \`tar cf {{file.tar}}\`` 57 | ); 58 | page.examples.should.have.length(1); 59 | page.examples[0].description.should.eql('create an archive'); 60 | page.examples[0].code.should.eql('tar cf {{file.tar}}'); 61 | }); 62 | 63 | it('does not escape HTML in the examples either', () => { 64 | let page = parser.parse(` 65 | - this & that 66 | 67 | \`cmd & data\`` 68 | ); 69 | page.examples.should.have.length(1); 70 | page.examples[0].description.should.eql('this & that'); 71 | page.examples[0].code.should.eql('cmd & data'); 72 | }); 73 | 74 | it('parses all the examples', () => { 75 | let page = parser.parse(` 76 | # tar 77 | > archiving utility 78 | 79 | - create an archive 80 | 81 | \`tar cf {{file.tar}}\` 82 | 83 | - extract an archive 84 | 85 | \`tar xf {{file}}\`` 86 | ); 87 | page.examples.should.have.length(2); 88 | }); 89 | 90 | it('leaves out malformed examples', () => { 91 | let page = parser.parse(` 92 | - example 1 93 | 94 | \`cmd --foo\` 95 | 96 | - example 2` 97 | ); 98 | page.examples.should.have.length(1); 99 | }); 100 | 101 | it('should parse description with inline code', () => { 102 | let page = parser.parse(` 103 | # uname 104 | > See also \`lsb_release\`` 105 | ); 106 | page.description.should.eql('See also lsb_release'); 107 | }); 108 | 109 | it('should parse examples with inline code', () => { 110 | let page = parser.parse(` 111 | # uname 112 | > See also 113 | 114 | - example 1, see \`inline_cmd1\` for details 115 | 116 | \`cmd1 --foo\` 117 | 118 | - example 2, see \`inline_cmd2\` for details 119 | 120 | \`cmd2 --foo\`` 121 | ); 122 | page.examples[0].code.should.eql('cmd1 --foo'); 123 | page.examples[1].code.should.eql('cmd2 --foo'); 124 | page.examples[0].description.should.eql('example 1, see inline_cmd1 for details'); 125 | page.examples[1].description.should.eql('example 2, see inline_cmd2 for details'); 126 | }); 127 | 128 | it('should parse code examples with unix redirects ">", "<", ">>" and "<<<"', () => { 129 | let page = parser.parse(` 130 | - Concatenate several files into the target file. 131 | 132 | \`cat {{file1}} {{file2}} > {{target-file}}\` 133 | 134 | - Concatenate several files into the target file. 135 | 136 | \`wc -l < {{users-file}}\` 137 | 138 | - Output one file into the target file. 139 | 140 | \`cat {{file}} >> {{target-file}}\` 141 | 142 | - Calculate the result of expression 143 | 144 | \`bc <<< "1 + 1"\`` 145 | ); 146 | page.examples[0].code.should.eql('cat {{file1}} {{file2}} > {{target-file}}'); 147 | page.examples[1].code.should.eql('wc -l < {{users-file}}'); 148 | page.examples[2].code.should.eql('cat {{file}} >> {{target-file}}'); 149 | page.examples[3].code.should.eql('bc <<< "1 + 1"'); 150 | }); 151 | 152 | describe('See also section', () => { 153 | 154 | beforeEach(() => { 155 | let hasPage = sinon.stub(index, 'hasPage'); 156 | hasPage.withArgs('lsb_release').returns(true); 157 | hasPage.withArgs('ln').returns(true); 158 | hasPage.withArgs('cp').returns(false); 159 | hasPage.withArgs('mv').returns(false); 160 | }); 161 | 162 | afterEach(() => { 163 | index.hasPage.restore(); 164 | }); 165 | 166 | it('should parse seeAlso commands when mentioned in description', () => { 167 | let page = parser.parse(` 168 | # uname 169 | > See also \`lsb_release\`, \`mv\`` 170 | ); 171 | page.seeAlso.should.eql(['lsb_release']); 172 | }); 173 | 174 | it('should parse seeAlso commands when mentioned in examples', () => { 175 | let page = parser.parse(` 176 | # uname 177 | > Description for uname 178 | 179 | - example 1, see \`ln\` for details 180 | 181 | \`cmd1 --foo\`` 182 | ); 183 | page.seeAlso.should.eql(['ln']); 184 | }); 185 | 186 | it('should have only unique seeAlso commands when mentioned a few times', () => { 187 | let page = parser.parse(` 188 | # uname 189 | > Description for uname, see \`lsb_release\`, \`ln\` 190 | 191 | - example 1, see \`ln\`, \`lsb_release\` for details 192 | 193 | \`cmd1 --foo\` 194 | 195 | - example 2, see \`ln\` for details 196 | 197 | \`cmd1 --foo\`` 198 | ); 199 | page.seeAlso.should.eql(['lsb_release', 'ln']); 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/platform.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const os = require('os'); 4 | const config = require('../lib/config'); 5 | const sinon = require('sinon'); 6 | const platforms = require('../lib/platforms'); 7 | 8 | describe('Platform', () => { 9 | 10 | describe('getPreferredPlatform', () => { 11 | beforeEach(() => { 12 | sinon.stub(os, 'platform'); 13 | this.config = config.get(); 14 | }); 15 | 16 | afterEach(() => { 17 | os.platform.restore(); 18 | }); 19 | 20 | it('should return the running platform with no configuration', () => { 21 | os.platform.onCall(0).returns('darwin'); 22 | this.config = {}; 23 | platforms.getPreferredPlatform(this.config).should.eql('darwin'); 24 | }); 25 | 26 | it('should overwrite the running platform if configured', () => { 27 | os.platform.onCall(0).returns('darwin'); 28 | this.config = { 29 | platform: 'linux' 30 | }; 31 | platforms.getPreferredPlatform(this.config).should.eql('linux'); 32 | }); 33 | 34 | it('should return current system platform if configuration is wrong', () => { 35 | os.platform.onCall(0).returns('darwin'); 36 | this.config = { 37 | platform: 'there_is_no_such_platform' 38 | }; 39 | platforms.getPreferredPlatform(this.config).should.eql('darwin'); 40 | }); 41 | }); 42 | 43 | describe('isSupported', () => { 44 | it('should tell that Android, FreeBSD, Linux, NetBSD, OpenBSD, OSX, SunOS and Win32 are supported', () => { 45 | platforms.isSupported('android').should.eql(true); 46 | platforms.isSupported('osx').should.eql(true); 47 | platforms.isSupported('freebsd').should.eql(true); 48 | platforms.isSupported('linux').should.eql(true); 49 | platforms.isSupported('netbsd').should.eql(true); 50 | platforms.isSupported('openbsd').should.eql(true); 51 | platforms.isSupported('sunos').should.eql(true); 52 | platforms.isSupported('windows').should.eql(true); 53 | platforms.isSupported('ios').should.eql(false); 54 | }); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/remote.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Cache = require('../lib/cache'); 4 | const config = require('../lib/config'); 5 | const sinon = require('sinon'); 6 | const path = require('path'); 7 | const fs = require('fs-extra'); 8 | const index = require('../lib/index'); 9 | const utils = require('../lib/utils'); 10 | var assert = require('assert'); 11 | 12 | describe('Remote', () => { 13 | describe('update()', () => { 14 | const TIMEOUT_INTERVAL = 120000; // 2 min timeout for each test case. 15 | 16 | const testCases = [ 17 | { 18 | description: 'No language specified', 19 | LANG: ['en'], 20 | expectedFolders: ['pages'], 21 | }, 22 | { 23 | description: '1 Language Specified that doesn\'t exist', 24 | LANG: ['pt_BB'], 25 | expectedFolders: ['pages'], 26 | }, 27 | { 28 | description: '1 Language Specified that does exist', 29 | LANG: ['ca'], 30 | expectedFolders: ['pages', 'pages.ca'], 31 | }, 32 | { 33 | description: 'Languages Specified that exist', 34 | LANG: ['pt', 'pt_BR'], 35 | expectedFolders: ['pages', 'pages.pt_BR'], 36 | }, 37 | { 38 | description: 'Multiple Languages Specified that exist', 39 | LANG: ['pt_BR', 'pt', 'en', 'hi', 'mo'], 40 | expectedFolders: ['pages', 'pages.hi', 'pages.pt_BR'], 41 | }, 42 | ]; 43 | 44 | testCases.forEach((testCase) => { 45 | describe(`${testCase.description}`, () => { 46 | let tempFolder; 47 | 48 | beforeEach(() => { 49 | sinon.spy(fs, 'ensureDir'); 50 | sinon.stub(fs, 'remove').resolves(); 51 | sinon.stub(fs, 'copy').resolves(); 52 | sinon.stub(utils, 'localeToLang').returns(testCase.LANG); 53 | sinon.stub(index, 'rebuildPagesIndex').resolves(); 54 | }); 55 | 56 | it('passes', () => { 57 | const cache = new Cache(config.get()); 58 | return cache.update().then(() => { 59 | let call = fs.ensureDir.getCall(0); 60 | tempFolder = call.args[0]; 61 | 62 | // Get the actual cache folders created 63 | const items = fs.readdirSync(tempFolder); 64 | 65 | // Filter the items to get only the directories 66 | const presentFolders = items.filter((item) => { 67 | try { 68 | return fs.statSync(path.join(tempFolder, item)).isDirectory(); 69 | } catch (err) { 70 | return false; 71 | } 72 | }); 73 | assert.deepEqual(presentFolders, testCase.expectedFolders); 74 | }).catch((err) => { 75 | throw err; 76 | }); 77 | }).timeout(TIMEOUT_INTERVAL); 78 | 79 | afterEach(async () => { 80 | // Clearing Spies & Stubs 81 | fs.copy.restore(); 82 | fs.remove.restore(); 83 | fs.ensureDir.restore(); 84 | utils.localeToLang.restore(); 85 | index.rebuildPagesIndex.restore(); 86 | 87 | await fs.remove(tempFolder); 88 | }); 89 | 90 | }); 91 | }); 92 | }); 93 | }); 94 | 95 | -------------------------------------------------------------------------------- /test/render.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const render = require('../lib/render'); 4 | const sinon = require('sinon'); 5 | const should = require('should'); 6 | 7 | describe('Render', () => { 8 | 9 | beforeEach(() => { 10 | this.config = { 11 | 'themes': { 12 | 'base16': { 13 | 'commandName': 'bold', 14 | 'mainDescription': '', 15 | 'exampleDescription': 'green', 16 | 'exampleCode': 'red', 17 | 'exampleToken': 'cyan' 18 | } 19 | }, 20 | 'theme': 'base16' 21 | }; 22 | }); 23 | 24 | afterEach(() => { 25 | sinon.restore(); 26 | }); 27 | 28 | it('surrounds the output with blank lines', () => { 29 | let text = render.toANSI({ 30 | name: 'tar', 31 | description: 'archive utility', 32 | examples: [] 33 | }, this.config); 34 | text.should.startWith('\n'); 35 | text.should.endWith('\n'); 36 | }); 37 | 38 | it('contains the command name', () => { 39 | let text = render.toANSI({ 40 | name: 'tar', 41 | description: 'archive utility', 42 | examples: [] 43 | }, this.config); 44 | text.should.containEql('tar'); 45 | text.should.containEql('archive utility'); 46 | }); 47 | 48 | it('contains the description name', () => { 49 | let text = render.toANSI({ 50 | name: 'tar', 51 | description: 'archive utility\nwith support for compression', 52 | examples: [] 53 | }, this.config); 54 | text.should.containEql('archive utility'); 55 | text.should.containEql('with support for compression'); 56 | }); 57 | 58 | it('highlights replaceable {{tokens}}', () => { 59 | let text = render.toANSI({ 60 | name: 'tar', 61 | description: 'archive utility', 62 | examples: [{ 63 | description: 'create', 64 | code: 'hello {{token}} bye' 65 | }] 66 | }, this.config); 67 | text.should.containEql('hello '); 68 | text.should.containEql('token'); 69 | text.should.containEql(' bye'); 70 | }); 71 | 72 | it('should correctly render see also section', () => { 73 | let text = render.toANSI({ 74 | name: 'uname', 75 | description: 'Description for `uname`.\n' + 76 | 'See also `lsb_release`.', 77 | examples: [{ 78 | description: '1st example. You need `sudo` to run this', 79 | code: 'uname {{token}}' 80 | }], 81 | seeAlso: [ 82 | 'lsb_release', 83 | 'sudo' 84 | ] 85 | }, this.config); 86 | text.should.containEql('See also: lsb_release, sudo'); 87 | }); 88 | 89 | it('should return an error for invalid theme', () => { 90 | const config = { 91 | 'themes': { 92 | 'base16': { 93 | 'commandName': 'bold', 94 | 'mainDescription': '', 95 | 'exampleDescription': 'green', 96 | 'exampleCode': 'red', 97 | 'exampleToken': 'cyan' 98 | } 99 | }, 100 | 'theme': 'bad' 101 | }; 102 | 103 | let text = render.toANSI({ 104 | name: 'tar', 105 | description: 'archive utility', 106 | }, config); 107 | should.not.exist(text); 108 | console.error.getCall(0).args[0].should.equal('invalid theme: bad'); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/search.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const search = require('../lib/search'); 4 | const should = require('should'); 5 | const sinon = require('sinon'); 6 | const path = require('path'); 7 | const fs = require('fs-extra'); 8 | 9 | const config = require('../lib/config'); 10 | const utils = require('../lib/utils'); 11 | const index = require('../lib/index'); 12 | 13 | const CACHE_FOLDER = path.join(config.get().cache, 'cache'); 14 | const filepath = CACHE_FOLDER + '/search-corpus.json'; 15 | 16 | const testData = { 17 | files: [{ 18 | path: '/path/to/file-00.md', 19 | platforms: ['common'], 20 | data: 'Random Text Generated Using An Online Sentence Generator', 21 | }, { 22 | path: '/path/to/file-01.md', 23 | platforms: ['common'], 24 | data: 'Debbie isn\'t a librarian.', 25 | }, { 26 | path: '/path/to/file-02.md', 27 | platforms: ['common'], 28 | data: 'Joe\'s girlfriend became a photographer.', 29 | }, { 30 | path: '/path/to/file-03.md', 31 | platforms: ['common'], 32 | data: 'Debbie looks rude.', 33 | }, { 34 | path: '/path/to/file-04.md', 35 | platforms: ['common'], 36 | data: 'Miss Debbie is careless.', 37 | }, { 38 | path: '/path/to/file-05.md', 39 | platforms: ['common'], 40 | data: 'Joe is popular.', 41 | }, { 42 | path: '/path/to/file-06.md', 43 | platforms: ['common'], 44 | data: 'Anthony won\'t become a barber.', 45 | }, { 46 | path: '/path/to/file-07.md', 47 | platforms: ['common'], 48 | data: 'Stephen will become a store clerk.', 49 | }, { 50 | path: '/path/to/file-08.md', 51 | platforms: ['common'], 52 | data: 'Stephen is very popular.', 53 | }, { 54 | path: '/path/to/file-09.md', 55 | platforms: ['common'], 56 | data: 'Anthony is not kind, Anthony is totally evil.', 57 | }, { 58 | path: '/path/to/file-10.md', 59 | platforms: ['common'], 60 | data: 'Joe has become a science teacher.', 61 | }, { 62 | path: '/path/to/file-11.md', 63 | platforms: ['common'], 64 | data: 'Roxie and Joe have become spies.', 65 | }, { 66 | path: '/path/to/file-12.md', 67 | platforms: ['common'], 68 | data: 'Roxie is not a carpenter.', 69 | }, { 70 | path: '/path/to/file-13.md', 71 | platforms: ['common'], 72 | data: 'Debbie is helpful', 73 | }, { 74 | path: '/path/to/file-14.md', 75 | platforms: ['common'], 76 | data: 'Roxie isn\'t a scientist.', 77 | }, { 78 | path: '/path/to/file-15.md', 79 | platforms: ['common'], 80 | data: 'Stephen is amazing at football.', 81 | }, { 82 | path: '/path/to/file-16.md', 83 | platforms: ['common'], 84 | data: 'Roxie, Debbie, Stephen, Anthony and Joe are friends.', 85 | }, { 86 | path: '/path/to/file-17.md', 87 | platforms: ['common'], 88 | data: 'Roxie is Joe\'s girlfriend', 89 | }, { 90 | path: '/path/to/file-18.md', 91 | platforms: ['common'], 92 | data: 'Stephen punched Anthony.', 93 | }, { 94 | path: '/path/to/file-19.md', 95 | platforms: ['common'], 96 | data: 'End of testing data.', 97 | }, ], 98 | corpus: {}, 99 | }; 100 | 101 | let fakes = { 102 | utils: { 103 | glob: () => { 104 | let filenames = []; 105 | testData.files.forEach((elem) => { 106 | filenames.push(elem.path); 107 | }); 108 | return Promise.resolve(filenames); 109 | }, 110 | }, 111 | index: { 112 | getShortIndex: (() => { 113 | let idx = {}; 114 | testData.files.forEach((elem) => { 115 | idx[utils.parsePagename(elem.path)] = elem.platforms; 116 | }); 117 | return Promise.resolve(idx); 118 | }), 119 | }, 120 | fs: { 121 | writeFile: (writepath, content) => { 122 | return new Promise((resolve, reject) => { 123 | if (writepath !== filepath) { 124 | return reject('Incorrect File Path'); 125 | } 126 | if (content) { 127 | resolve(); 128 | } 129 | }); 130 | }, 131 | readFile: (readpath) => { 132 | return new Promise((resolve, reject) => { 133 | let file = testData.files.find((elem) => { 134 | return elem.path === readpath; 135 | }); 136 | if (file) { 137 | return resolve(file.data); 138 | } 139 | if (readpath === filepath) { 140 | return resolve(JSON.stringify(testData.corpus)); 141 | } 142 | return reject('Trying to read incorrect file path: ' + readpath); 143 | }); 144 | }, 145 | }, 146 | }; 147 | 148 | let restoreStubs = (stubs) => { 149 | stubs.forEach((stub) => { 150 | stub.restore(); 151 | }); 152 | }; 153 | 154 | //TODO write tests for private functions in the search module. 155 | 156 | describe('Search', () => { 157 | it('should create index', function(done) { 158 | let stubs = []; 159 | stubs.push(sinon.stub(utils, 'glob').callsFake(fakes.utils.glob)); 160 | stubs.push(sinon.stub(fs, 'readFile').callsFake(fakes.fs.readFile)); 161 | stubs.push(sinon.stub(fs, 'writeFile').callsFake(fakes.fs.writeFile)); 162 | search.createIndex().then((data) => { 163 | Object.keys(data.tfidf).length.should.equal(20); 164 | Object.keys(data.invertedIndex).length.should.equal(56); 165 | data.invertedIndex['roxi'][0].should.equal('/path/to/file-11.md'); 166 | testData.corpus = data; 167 | done(); 168 | }).catch((error) => { 169 | should.not.exist(error); 170 | done(error); 171 | }).then(() => { 172 | restoreStubs(stubs); 173 | }); 174 | }); 175 | it('should perform searches', function(done) { 176 | let stubs = []; 177 | stubs.push(sinon.stub(fs, 'readFile').callsFake(fakes.fs.readFile)); 178 | stubs.push(sinon.stub(fs, 'writeFile').callsFake(fakes.fs.writeFile)); 179 | stubs.push(sinon.stub(index, 'getShortIndex').callsFake(fakes.index.getShortIndex)); 180 | search.getResults('Anthony').then((data) => { 181 | data.length.should.equal(4); 182 | data[0].file.should.equal('/path/to/file-09.md'); 183 | return Promise.resolve(); 184 | }).then(() => { 185 | return search.getResults('textnotfound').then((data) => { 186 | data.length.should.equal(0); 187 | return Promise.resolve(); 188 | }); 189 | }).then(() => { 190 | return search.getResults('Joe and Roxie').then((data) => { 191 | data.length.should.equal(8); 192 | data[1].file.should.equal('/path/to/file-16.md'); 193 | done(); 194 | return Promise.resolve(); 195 | }); 196 | }).catch((error) => { 197 | should.not.exist(error); 198 | done(error); 199 | }).then(() => { 200 | restoreStubs(stubs); 201 | }); 202 | }); 203 | }); 204 | -------------------------------------------------------------------------------- /test/theme.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const Theme = require('../lib/theme'); 4 | const chalk = require('chalk'); 5 | 6 | describe('Theme', () => { 7 | 8 | describe('Rendering', () => { 9 | 10 | let theme = new Theme({ 11 | commandName: 'green, bold', 12 | mainDescription: 'red, underline', 13 | exampleDescription: 'blue', 14 | exampleCode: 'bold', 15 | exampleToken: 'yellow,dim,underline' 16 | }); 17 | 18 | it('should render name with green and bold', () => { 19 | theme.renderCommandName('text') 20 | .should.equal( 21 | chalk.green.bold('text')); 22 | }); 23 | 24 | it('should render description with red and underline', () => { 25 | theme.renderMainDescription('text') 26 | .should.equal( 27 | chalk.red.underline('text')); 28 | }); 29 | 30 | it('should render example description with blue', () => { 31 | theme.renderExampleDescription('text') 32 | .should.equal( 33 | chalk.blue('text')); 34 | }); 35 | 36 | it('should render example code with blue', () => { 37 | theme.renderExampleCode('text') 38 | .should.equal( 39 | chalk.bold('text')); 40 | }); 41 | 42 | it('should render example argument with yellow, dim, underline', () => { 43 | theme.renderExampleToken('text') 44 | .should.equal( 45 | chalk.yellow.dim.underline('text')); 46 | }); 47 | }); 48 | 49 | describe('Rendering with new theme colors', () => { 50 | 51 | let theme = new Theme({ 52 | commandName: 'greenBright, bold', 53 | mainDescription: 'greenBright, bold', 54 | exampleDescription: 'greenBright', 55 | exampleCode: 'redBright', 56 | exampleToken: 'white' 57 | }); 58 | 59 | it('should render name with greenBright and bold', () => { 60 | theme.renderCommandName('text') 61 | .should.equal( 62 | chalk.greenBright.bold('text')); 63 | }); 64 | 65 | it('should render description with greenBright and bold', () => { 66 | theme.renderMainDescription('text') 67 | .should.equal( 68 | chalk.greenBright.bold('text')); 69 | }); 70 | 71 | it('should render example description with greenBright', () => { 72 | theme.renderExampleDescription('text') 73 | .should.equal( 74 | chalk.greenBright('text')); 75 | }); 76 | 77 | it('should render example code with redBright', () => { 78 | theme.renderExampleCode('text') 79 | .should.equal( 80 | chalk.redBright('text')); 81 | }); 82 | 83 | it('should render example argument with white', () => { 84 | theme.renderExampleToken('text') 85 | .should.equal( 86 | chalk.white('text')); 87 | }); 88 | }); 89 | 90 | describe('Rendering with distinct colors for each token type', () => { 91 | 92 | let theme = new Theme({ 93 | commandName: 'greenBright, bold', 94 | mainDescription: 'greenBright, bold', 95 | exampleDescription: 'greenBright', 96 | exampleCode: 'redBright', 97 | exampleBool: 'magenta', 98 | exampleNumber: 'white', 99 | exampleString: 'blue' 100 | }); 101 | 102 | it('should render name with greenBright and bold', () => { 103 | theme.renderCommandName('text') 104 | .should.equal( 105 | chalk.greenBright.bold('text')); 106 | }); 107 | 108 | it('should render description with greenBright and bold', () => { 109 | theme.renderMainDescription('text') 110 | .should.equal( 111 | chalk.greenBright.bold('text')); 112 | }); 113 | 114 | it('should render example description with greenBright', () => { 115 | theme.renderExampleDescription('text') 116 | .should.equal( 117 | chalk.greenBright('text')); 118 | }); 119 | 120 | it('should render example code with redBright', () => { 121 | theme.renderExampleCode('text') 122 | .should.equal( 123 | chalk.redBright('text')); 124 | }); 125 | 126 | it('should render example arguments with magenta, white, and blue, for boolean, number, and string respectively', () => { 127 | theme.renderExampleToken('true') 128 | .should.equal( 129 | chalk.magenta('true')); 130 | theme.renderExampleToken('9') 131 | .should.equal( 132 | chalk.white('9')); 133 | theme.renderExampleToken('text') 134 | .should.equal( 135 | chalk.blue('text')); 136 | }); 137 | }); 138 | }); 139 | -------------------------------------------------------------------------------- /test/tldr.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const TLDR = require('../lib/tldr'); 4 | 5 | describe('TLDR class', () => { 6 | it('should construct', () => { 7 | const tldr = new TLDR({ cache: 'some-random-string' }); 8 | tldr.should.exist; 9 | }); 10 | }); 11 | -------------------------------------------------------------------------------- /test/utils.spec.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const utils = require('../lib/utils'); 4 | var assert = require('assert'); 5 | 6 | describe('Utils', () => { 7 | describe('localeToLang()', () => { 8 | it('should return with cc', () => { 9 | let result = utils.localeToLang('pt_BR'); 10 | assert.deepEqual(result, ['pt', 'pt_BR']); 11 | }); 12 | 13 | it('should return without cc', () => { 14 | let result = utils.localeToLang('pp_BR'); 15 | assert.deepEqual(result, ['pp']); 16 | }); 17 | }); 18 | }); 19 | --------------------------------------------------------------------------------