├── .dockerignore ├── .github ├── dependabot.yml └── workflows │ ├── docker.yml │ ├── go.yml │ └── js.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── Taskfile.yml ├── cmd └── update.go ├── go.mod ├── go.sum ├── lib ├── git │ ├── git.go │ └── keys.go ├── tldr │ └── files.go └── www │ └── json.go ├── out └── .gitkeep └── resources ├── .gitignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── README.md ├── eslint.config.mjs ├── files.go ├── grep-data.js ├── index.html ├── logo ├── README.md └── favicon.svg ├── package.json ├── public ├── favicon.ico ├── favicon192.png ├── favicon512.png └── manifest.json ├── src ├── App.css ├── App.tsx ├── AppFooter.tsx ├── AppLoader.tsx ├── Data.tsx ├── ErrorMessage.tsx ├── IconActionFilter.tsx ├── IconActionHelp.tsx ├── IconActionHighlight.tsx ├── IconActionJump.tsx ├── IconActionSearch.tsx ├── IconActionTheme.tsx ├── IconActions.css ├── IconActions.tsx ├── Table.css ├── Table.tsx ├── ThemeType.tsx ├── env.d.ts ├── index.tsx ├── tldrPageUrl.tsx ├── useEscClose.tsx └── useKeyPress.tsx ├── tsconfig.json ├── vite.config.js └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | ./.github/ 2 | ./.idea/ 3 | ./out/ 4 | ./systemd/ 5 | ./test/ 6 | **/*.task 7 | **/node_modules 8 | resources/output.css 9 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "monthly" 7 | - package-ecosystem: "npm" 8 | directory: "resources/" 9 | schedule: 10 | interval: "monthly" 11 | - package-ecosystem: "docker" 12 | directory: "/" 13 | schedule: 14 | interval: "monthly" 15 | - package-ecosystem: "gomod" 16 | directory: "/" 17 | schedule: 18 | interval: "monthly" 19 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | push: 10 | branches: [ main ] 11 | # Publish semver tags as releases. 12 | tags: [ 'v*.*.*' ] 13 | pull_request: 14 | branches: [ main ] 15 | 16 | env: 17 | # Use docker.io for Docker Hub if empty 18 | REGISTRY: ghcr.io 19 | # github.repository as / 20 | IMAGE_NAME: ${{ github.repository }} 21 | 22 | 23 | jobs: 24 | build: 25 | 26 | runs-on: ubuntu-latest 27 | permissions: 28 | contents: read 29 | packages: write 30 | 31 | steps: 32 | - name: Checkout repository 33 | uses: actions/checkout@v4 34 | 35 | # Login against a Docker registry except on PR 36 | # https://github.com/docker/login-action 37 | - name: Log into registry ${{ env.REGISTRY }} 38 | if: github.event_name != 'pull_request' 39 | uses: docker/login-action@v3 40 | with: 41 | registry: ${{ env.REGISTRY }} 42 | username: ${{ github.actor }} 43 | password: ${{ secrets.GITHUB_TOKEN }} 44 | 45 | # Extract metadata (tags, labels) for Docker 46 | # https://github.com/docker/metadata-action 47 | - name: Extract Docker metadata 48 | id: meta 49 | uses: docker/metadata-action@v5 50 | with: 51 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 52 | tags: | 53 | type=ref,event=branch 54 | type=ref,event=pr 55 | type=semver,pattern={{version}} 56 | type=semver,pattern={{major}} 57 | 58 | # Setup QEMU for multiplatform builds with cross-compilation 59 | # # https://docs.docker.com/build/ci/github-actions/multi-platform/ 60 | - name: Set up QEMU 61 | uses: docker/setup-qemu-action@v3 62 | 63 | # Setup Buildx for multiplatform builds with cross-compilation 64 | # https://docs.docker.com/build/ci/github-actions/multi-platform/ 65 | - name: Set up Docker Buildx 66 | uses: docker/setup-buildx-action@v3 67 | 68 | # Build and push Docker image with Buildx (don't push on PR) 69 | # https://github.com/docker/build-push-action 70 | - name: Build and push Docker image 71 | uses: docker/build-push-action@v6 72 | with: 73 | context: . 74 | push: ${{ github.event_name != 'pull_request' }} 75 | tags: ${{ steps.meta.outputs.tags }} 76 | labels: ${{ steps.meta.outputs.labels }} 77 | platforms: linux/amd64,linux/arm64 78 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | # Publish semver tags as releases. 7 | tags: [ 'v*.*.*' ] 8 | pull_request: 9 | branches: [ main ] 10 | 11 | jobs: 12 | 13 | build: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: ^1.24 19 | - uses: actions/setup-node@v4 20 | with: 21 | node-version: '20' 22 | - uses: actions/checkout@v4 23 | - name: Setup Task 24 | run: curl -sL https://taskfile.dev/install.sh | sh 25 | - name: Build using Taskfile.yml 26 | run: ./bin/task build 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | name: compiled-application-amd64-ubuntu 30 | path: out/* 31 | 32 | permissions: 33 | contents: read 34 | -------------------------------------------------------------------------------- /.github/workflows/js.yml: -------------------------------------------------------------------------------- 1 | name: JS 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | 11 | lint: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | - uses: actions/setup-node@v4 16 | with: 17 | node-version: '20' 18 | - run: yarn 19 | working-directory: resources 20 | - run: yarn lint 21 | working-directory: resources 22 | 23 | permissions: 24 | contents: read 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries 2 | /out/* 3 | 4 | # Testing 5 | /test/ 6 | 7 | # Task 8 | **/.task/ 9 | 10 | # IntelliJ 11 | /.idea/ 12 | *.iml 13 | 14 | # Keep .gitkeep files 15 | # Such directories must be ignored with a wildcard at the end: hello/* 16 | !/**/.gitkeep 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [1.3.10] - 30th June 2025 8 | - Adds cross-platform build for Docker image (linux/amd64,linux/arm64) 9 | - Uses relative paths for building the HTML with Vite (#102) 10 | - Cleans up the correct assets directory upon writing the resources 11 | - Removes unused systemd configuration 12 | - Removes release action for publishing binaries 13 | 14 | ## [1.3.9] - 19th June 2025 15 | - Upgrades Go and Node dependencies 16 | 17 | ## [1.3.8] - 6th October 2024 18 | - Upgrades Go to version 1.23 19 | - Upgrades Node dependencies 20 | 21 | ## [1.3.7] - 30th March 2024 22 | - Disables "Continue" button for initial language selection if only English is selected 23 | 24 | ## [1.3.6] - 26th November 2023 25 | - Fixes a wrong link for creating new pages 26 | 27 | ## [1.3.5] - 4th September 2023 28 | - Only shows a limited number of columns for selected languages 29 | - The number of languages in the tldr-pages repository has grown significantly over the last year, so that the columns for all languages do not fit on a normal screen 30 | - Upgrades Go to version 1.20 31 | - Upgrades Node.js to version 18 32 | 33 | ## [1.3.4] - 30th September 2022 34 | - Add package `tzdata` to Docker image to respect time zone setting 35 | 36 | ## [1.3.3] - 08th April 2022 37 | - Upgrade Geist UI to version 2.3.8 38 | - Upgrade Go to version 1.18 39 | - Utilize an external library for generating the SSH key 40 | - Change the name of the key file from `id_rsa` to `id_ed25519` as the Ed25519 algorithm is used for its creation 41 | 42 | ## [1.3.2] - 16th February 2022 43 | - Upgrade Geist UI to version 2.2.5 ([#19](https://github.com/LukWebsForge/TldrProgress/pull/19)) 44 | - Left-alignment of the content 45 | 46 | ## [1.3.1] - 08th June 2021 47 | - Publish container images using the 48 | [GitHub Registry](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry) 49 | ([#15](https://github.com/LukWebsForge/TldrProgress/pull/15)) 50 | - Link pull request in the README 51 | 52 | ## [1.3.0] - 07th June 2021 53 | - Dark mode ([#14](https://github.com/LukWebsForge/TldrProgress/pull/14)) 54 | 55 | ## [1.2.0] - 29th May 2021 56 | - Brand-new web interface built using Geist UI ([#11](https://github.com/LukWebsForge/TldrProgress/pull/11)) 57 | - Filter for not translated or outdated pages 58 | - Select columns to be highlighted 59 | - Help dialogue with more information 60 | - Thanks to @navarroaxel, @marchersimon, @bl-ue, @patricedenis and @waldyrious for testing and reviewing 61 | 62 | ## [1.1.3] - 18th March 2021 63 | - Stores the SSH keys using a Docker volume 64 | 65 | ## [1.1.2] - 14th March 2021 66 | - Highlights a row if the pointer hovers over it 67 | 68 | ## [1.1.1] - 14th March 2021 69 | - The build of the React website supports relative paths 70 | - Commits deleted files 71 | 72 | ## [1.1.0] - 13th March 2021 73 | - Uses React + TypeScript for the website 74 | - All progress information is stored in a `data.json` file 75 | - Every cell is clickable and links to ... 76 | * the translation *if it exists* 77 | * an editor to create the translation *otherwise* 78 | - Uses `go:embed` for embedding the static website assets in the binary application 79 | - The application now runs continuous and schedules updates by itself 80 | - Adds the `RUN_ONCE` environment variable to execute a single update 81 | - Packages the application as a Docker container 82 | - Improves the commit messages ([#4](https://github.com/LukWebsForge/TldrProgress/pull/4) - thanks @bl-ue) 83 | 84 | ## [1.0.1] - 14th November 2020 85 | - Minify the HTML output by activating the environment variable `MINIFY_HTML` 86 | - While sorting strings their capitalization will be ignored 87 | 88 | ## [1.0.0] - 04th November 2020 89 | This is the first release. If you spot any bugs, please let us know. 90 | 91 | ### Added 92 | - A Website generator which creates a website displaying the progress of translating all tldr pages 93 | - Automatic SSH key generator (those keys can be used as deployment keys) 94 | - [Taskfile.yml](https://taskfile.dev/#/) used to build the project 95 | - GitHub workflow files 96 | - Automatic release builds for linux_amd64, linux_arm and windows 97 | - Configuration files for systemd 98 | - This Changelog 99 | 100 | [Unreleased]: https://github.com/LukWebsForge/TldrProgress/compare/v1.3.10...HEAD 101 | [1.3.10]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.10 102 | [1.3.9]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.9 103 | [1.3.8]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.8 104 | [1.3.7]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.7 105 | [1.3.6]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.6 106 | [1.3.5]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.5 107 | [1.3.4]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.4 108 | [1.3.3]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.3 109 | [1.3.2]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.2 110 | [1.3.1]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.1 111 | [1.3.0]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.3.0 112 | [1.2.0]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.2.0 113 | [1.1.3]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.1.3 114 | [1.1.2]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.1.2 115 | [1.1.1]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.1.1 116 | [1.1.0]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.1.0 117 | [1.0.1]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.0.1 118 | [1.0.0]: https://github.com/LukWebsForge/TldrProgress/releases/tag/v1.0.0 119 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Building the application 2 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine 3 | 4 | # Installing dependencies 5 | RUN apk add curl git yarn && sh -c "$(curl --location https://taskfile.dev/install.sh)" -- -d -b /bin 6 | WORKDIR /go/src/tldrprogress 7 | 8 | # Caching go packages 9 | COPY go.mod go.sum ./ 10 | RUN go mod download 11 | 12 | # Caching yarn packages 13 | COPY resources/package.json resources/yarn.lock resources/ 14 | RUN cd resources && yarn 15 | 16 | # Copy all the files 17 | COPY . . 18 | 19 | # Building the application (cross-platform) 20 | ARG TARGETOS 21 | ARG TARGETARCH 22 | RUN GOOS=$TARGETOS GOARCH=$TARGETARCH /bin/task build 23 | 24 | # Packing the built application in a small container 25 | FROM alpine:latest 26 | 27 | WORKDIR /tldrprogress/ 28 | VOLUME /tldrprogress/keys/ 29 | 30 | RUN apk add tzdata 31 | 32 | COPY --from=0 /go/src/tldrprogress/out/update /bin/tldrprogress 33 | CMD ["/bin/tldrprogress"] 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 LukWeb 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tldr translation progress 2 | 3 | Generates and publishes a website, which shows the progress of translation of the 4 | [tldr](https://github.com/tldr-pages/tldr/) project. 5 | 6 | This project is inspired by 7 | * [Extension Registration Wall of Superpowers](https://extreg-wos.toolforge.org/) 8 | * [Python 3 Wall of Superpowers](http://python3wos.appspot.com/) 9 | 10 | ## Building 11 | 12 | ### Docker 13 | 14 | If you've got [Docker](https://www.docker.com/) on your system, you need nothing else to build and run this project. 15 | 16 | ```shell script 17 | docker build -t ghcr.io/lukwebsforge/tldrprogress:latest . 18 | ``` 19 | 20 | ### Local installation 21 | 22 | To build this project without Docker, you'll need the task runner Task and a Go SDK (>= 1.18). 23 | 24 | The build instructions are defined in the [`Taskfile.yml`](Taskfile.yml). 25 | Install [Task](https://taskfile.dev/#/installation) and run 26 | ```shell script 27 | task build 28 | ``` 29 | 30 | ## Configuration 31 | 32 | You can configure certain details using environment variables. 33 | 34 | | Environment variable | Description | Required | 35 | | --- | --- | --- | 36 | | GIT_NAME | The committers name | Yes | 37 | | GIT_EMAIL | The committers email | Yes | 38 | | SITE_REMOTE_URL | The Git SSH url of the deployment repository | Yes | 39 | | CHECK_KNOWN_HOSTS | Checks the known_hosts file when connecting | No | 40 | | SSH_KEY_PASSWORD | A password for the SSH key | No | 41 | | DONT_PUBLISH | No changes will be committed & pushed | No | 42 | | RUN_ONCE | Instantly updates & quits | No | 43 | 44 | You can either let the program generate a new SSH key pair, or you can use your own even if a password is required. 45 | This application doesn't support HTTP authentication. 46 | 47 | The SSH key pair `id_ed25519` and `id_ed25519.pub` in the folder `keys` will be used to access the tldr repository and 48 | publish changes to the deployment repository (`SITE_REMOTE_URL`). 49 | If the key pair is missing, it'll be generated during the startup of the application. 50 | The generated public key `id_ed25519.pub` will be printed to the console. 51 | I recommend adding the public key as a deployment key (with writing access) for the deployment repository. 52 | 53 | Each day at midnight (UTC) the program will execute the update. 54 | This includes the steps of cloning / updating the tldr repository in the folder `tldr` and 55 | generating the static asset files and copying them to the `upstream` folder. 56 | Finally, changes in this folder will be committed and pushed to the upstream repository. 57 | 58 | If you want to test your changes, I recommend you set the environment variables `DONT_PUBLISH` and `RUN_ONCE` 59 | and view the updated website locally before deploying it. 60 | The value of this environment variable doesn't matter, it just has be set. 61 | 62 | ## Run 63 | 64 | ### Docker 65 | 66 | You can simply start a container which runs this program. 67 | The program will start the repository update at midnight (UTC) each day. 68 | 69 | ```shell script 70 | # Create a docker container with name tldrprogress, which will always restart (system reboot, error) 71 | docker run -d --restart=always --name=tldrprogress \ 72 | -e GIT_NAME=LukWebBuilder -e GIT_EMAIL=gitbuilder@lukweb.de \ 73 | -e SITE_REMOTE_URL=git@github.com:LukWebsForge/tldri18n.git \ 74 | ghcr.io/lukwebsforge/tldrprogress:latest 75 | # Optional: Take a look at the logs to view the new public SSH key 76 | docker logs tldrprogress 77 | ``` 78 | 79 | ### systemd 80 | 81 | The program runs permanently, and start the repository update at midnight (UTC) each day. 82 | This repository contains a systemd configuration file, which can be used to keep it running. 83 | 84 | If you want to use the systemd unit file 85 | 1. Create a user named `tldr` 86 | 2. Put the executable at the path `/home/tldr/progress/update` 87 | 3. Copy the files from the [`systemd`](systemd) folder into `/etc/systemd/system/` 88 | 4. Finally, run 89 | ```shell script 90 | # Load the new systemd unit file 91 | systemctl daemon-reload 92 | # Start the program on every system startup 93 | systemctl enable tldrprogress.service 94 | # Start the program now 95 | sudo systemctl start tldrprogress.service 96 | # Optional: View generated the public SSH key 97 | cat /home/tldr/progress/keys/id_ed25519.pub 98 | ``` 99 | 100 | ## Contributing 101 | 102 | If you spot a bug or got an idea for an improvement, free welcome to open a new issue or create a pull request. 103 | 104 | ### Development tips 105 | 106 | #### Working on the Go application 107 | 108 | It's useful to set the environment variables `DONT_PUBLISH` and `RUN_ONCE` to any value (e.g. `true`). 109 | 110 | Even, if you don't want to push anything, don't forget to add the public SSH key to a GitHub account or as a deployment key. 111 | Otherwise, GitHub repositories can't be cloned or updated. 112 | 113 | #### Working on the React website 114 | 115 | Run the Go application once to generate a `data.json` file. 116 | Copy this file to the `resources/public` folder. 117 | Now you can use HMR by starting the development web server with `yarn run start`. 118 | 119 | #### Testing the application as a whole 120 | 121 | Run the compiled artifact and start a local webserver by using `npx serve upstream` to inspect the generated website. 122 | -------------------------------------------------------------------------------- /Taskfile.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | tasks: 4 | build: 5 | desc: Builds the executable application for the current platform 6 | deps: [ assets ] 7 | cmds: 8 | - go build -v -o out/ cmd/update.go 9 | 10 | assets: 11 | desc: Creates the assets using yarn 12 | dir: resources 13 | cmds: 14 | - yarn 15 | - yarn run build 16 | -------------------------------------------------------------------------------- /cmd/update.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/robfig/cron/v3" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "time" 10 | "tldr-translation-progress/lib/git" 11 | "tldr-translation-progress/lib/tldr" 12 | "tldr-translation-progress/lib/www" 13 | "tldr-translation-progress/resources" 14 | ) 15 | 16 | const TldrDir = "tldr" 17 | const UpstreamDir = "upstream" 18 | const DataJsonFile = "data.json" 19 | 20 | const KeyPath = "keys/id_ed25519" 21 | const TldrGitUrl = "git@github.com:tldr-pages/tldr.git" 22 | 23 | var quit = make(chan struct{}) 24 | 25 | // Entry point for the application 26 | func main() { 27 | createSSHKey() 28 | 29 | // If the environment variable RUN_ONCE is set, just the update function is being executed once 30 | if _, runNow := os.LookupEnv("RUN_ONCE"); runNow { 31 | log.Println("Because RUN_ONCE is set a update is executed now") 32 | update() 33 | log.Println("Because RUN_ONCE is set the application quits after the update has been finished") 34 | return 35 | } 36 | 37 | // Creating a new cron scheduler with panic recovery & UTC time 38 | c := cron.New(cron.WithChain(cron.Recover(cron.DefaultLogger)), cron.WithLocation(time.UTC)) 39 | 40 | // Executing the update function every day at midnight (UTC) 41 | _, err := c.AddFunc("0 0 * * *", update) 42 | if err != nil { 43 | log.Printf("Failed to add the cron task: %v", err) 44 | } else { 45 | log.Println("Added the cron task for each day at midnight (UTC)") 46 | } 47 | c.Start() 48 | 49 | // Blocking the main thread for an infinite amount of time 50 | // To stop the program you can close the channel using close(quit) 51 | <-quit 52 | } 53 | 54 | // Creates SSH keys if they don't exist 55 | func createSSHKey() { 56 | keyPassword := env("SSH_KEY_PASSWORD", true) 57 | 58 | if _, err := os.Stat(KeyPath); os.IsNotExist(err) { 59 | publicKey, err1 := git.CreateSSHKey(KeyPath, keyPassword) 60 | if err1 != nil { 61 | log.Fatalln(err1) 62 | } 63 | log.Printf("A new SSH key was generated, please add it to a GitHub account\n%v", publicKey) 64 | } else { 65 | log.Println("Using the existing SSH key") 66 | } 67 | } 68 | 69 | // The sequential update workflow 70 | func update() { 71 | keyPassword := env("SSH_KEY_PASSWORD", true) 72 | gitName := env("GIT_NAME", false) 73 | gitEmail := env("GIT_EMAIL", false) 74 | siteUrl := env("SITE_REMOTE_URL", false) 75 | _, dontPublish := os.LookupEnv("DONT_PUBLISH") 76 | 77 | tldrGit, err := git.NewTldrGit(gitName, gitEmail, KeyPath, keyPassword) 78 | if err != nil { 79 | log.Println(err) 80 | return 81 | } 82 | log.Println("Git configuration is correct") 83 | 84 | err = tldrGit.CloneOrUpdate(TldrGitUrl, TldrDir) 85 | if err != nil { 86 | log.Println(err) 87 | return 88 | } 89 | log.Println("tldr repository updated") 90 | 91 | index, err := tldr.MapTldr(TldrDir) 92 | if err != nil { 93 | log.Println(err) 94 | return 95 | } 96 | log.Println("Scanned the tldr files") 97 | 98 | err = tldrGit.CloneOrUpdate(siteUrl, UpstreamDir) 99 | if err != nil { 100 | log.Println(err) 101 | return 102 | } 103 | log.Println("Site repository updated") 104 | 105 | err = resources.WriteTo(UpstreamDir) 106 | if err != nil { 107 | log.Println(err) 108 | return 109 | } 110 | log.Println("Files for website copied") 111 | 112 | err = www.GenerateJson(index, filepath.Join(UpstreamDir, DataJsonFile)) 113 | if err != nil { 114 | log.Println(err) 115 | return 116 | } 117 | log.Printf("%v generated and written the folder", DataJsonFile) 118 | 119 | if dontPublish { 120 | log.Println("Won't publish the changes, because DONT_PUBLISH is set") 121 | return 122 | } 123 | 124 | // Note: All files in the directory will be added and committed (except those ignored by a .gitignore) 125 | date := time.Now().Format("2 January 2006") 126 | err = tldrGit.CommitAll(UpstreamDir, "Daily update - "+date) 127 | if err != nil { 128 | log.Println(err) 129 | return 130 | } 131 | log.Println("Changes committed") 132 | 133 | err = tldrGit.Push(UpstreamDir, siteUrl) 134 | if err != nil { 135 | log.Println(err) 136 | return 137 | } 138 | log.Println("Changes published") 139 | 140 | log.Println("Successful") 141 | } 142 | 143 | // Gets a value for an environment variable with the given key. 144 | // If defaultEmpty is true, then an empty string is returned when variable doesn't exists, 145 | // else the program terminates if the environment variable is not set. 146 | func env(key string, defaultEmpty bool) string { 147 | value, ok := os.LookupEnv(key) 148 | if !ok { 149 | if defaultEmpty { 150 | return "" 151 | } 152 | log.Fatalf("Please set the environment variable %v\n", key) 153 | } else if strings.TrimSpace(value) == "" { 154 | if defaultEmpty { 155 | return "" 156 | } 157 | log.Fatalf("The environment variable %v is set, but empty\n", key) 158 | } 159 | 160 | return value 161 | } 162 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module tldr-translation-progress 2 | 3 | go 1.24.0 4 | 5 | require ( 6 | github.com/charmbracelet/keygen v0.5.3 7 | github.com/go-git/go-git/v5 v5.16.2 8 | github.com/iancoleman/orderedmap v0.3.0 9 | github.com/robfig/cron/v3 v3.0.1 10 | golang.org/x/crypto v0.39.0 11 | ) 12 | 13 | require ( 14 | dario.cat/mergo v1.0.2 // indirect 15 | github.com/Microsoft/go-winio v0.6.2 // indirect 16 | github.com/ProtonMail/go-crypto v1.3.0 // indirect 17 | github.com/cloudflare/circl v1.6.1 // indirect 18 | github.com/cyphar/filepath-securejoin v0.4.1 // indirect 19 | github.com/emirpasic/gods v1.18.1 // indirect 20 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect 21 | github.com/go-git/go-billy/v5 v5.6.2 // indirect 22 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect 23 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect 24 | github.com/kevinburke/ssh_config v1.2.0 // indirect 25 | github.com/pjbgf/sha1cd v0.3.2 // indirect 26 | github.com/sergi/go-diff v1.4.0 // indirect 27 | github.com/skeema/knownhosts v1.3.1 // indirect 28 | github.com/xanzy/ssh-agent v0.3.3 // indirect 29 | golang.org/x/net v0.41.0 // indirect 30 | golang.org/x/sys v0.33.0 // indirect 31 | gopkg.in/warnings.v0 v0.1.2 // indirect 32 | ) 33 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= 2 | dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= 3 | github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= 4 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 5 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 6 | github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= 7 | github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= 8 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= 9 | github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= 10 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= 11 | github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= 12 | github.com/charmbracelet/keygen v0.5.3 h1:2MSDC62OUbDy6VmjIE2jM24LuXUvKywLCmaJDmr/Z/4= 13 | github.com/charmbracelet/keygen v0.5.3/go.mod h1:TcpNoMAO5GSmhx3SgcEMqCrtn8BahKhB8AlwnLjRUpk= 14 | github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= 15 | github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= 16 | github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= 17 | github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= 18 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 19 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 20 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 21 | github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= 22 | github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= 23 | github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= 24 | github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= 25 | github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= 26 | github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= 27 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= 28 | github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= 29 | github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= 30 | github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= 31 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= 32 | github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= 33 | github.com/go-git/go-git/v5 v5.16.2 h1:fT6ZIOjE5iEnkzKyxTHK1W4HGAsPhqEqiSAssSO77hM= 34 | github.com/go-git/go-git/v5 v5.16.2/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= 35 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= 36 | github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= 37 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 38 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 39 | github.com/iancoleman/orderedmap v0.3.0 h1:5cbR2grmZR/DiVt+VJopEhtVs9YGInGIxAoMJn+Ichc= 40 | github.com/iancoleman/orderedmap v0.3.0/go.mod h1:XuLcCUkdL5owUCQeF2Ue9uuw1EptkJDkXXS7VoV7XGE= 41 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= 42 | github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= 43 | github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= 44 | github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= 45 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 46 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 47 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 48 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 49 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 50 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 51 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 52 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 53 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 54 | github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= 55 | github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= 56 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 57 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 58 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 59 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 60 | github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= 61 | github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 62 | github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= 63 | github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= 64 | github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= 65 | github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= 66 | github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= 67 | github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= 68 | github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= 69 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 70 | github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 71 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 72 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 73 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 74 | github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= 75 | github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= 76 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 77 | golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= 78 | golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= 79 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= 80 | golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= 81 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 82 | golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= 83 | golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= 84 | golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 85 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 86 | golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 87 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 88 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 89 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 90 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 91 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 92 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 93 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= 94 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= 95 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 96 | golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= 97 | golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= 98 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 99 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 100 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 101 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 102 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 103 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 104 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 105 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 106 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 107 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 108 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 109 | -------------------------------------------------------------------------------- /lib/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/go-git/go-git/v5" 7 | "github.com/go-git/go-git/v5/config" 8 | "github.com/go-git/go-git/v5/plumbing/object" 9 | "github.com/go-git/go-git/v5/plumbing/transport/ssh" 10 | ssh2 "golang.org/x/crypto/ssh" 11 | "net" 12 | "os" 13 | "time" 14 | ) 15 | 16 | const DefaultFileMask = 0740 17 | const DefaultRemoteName = "origin" 18 | 19 | type TldrGit struct { 20 | name string 21 | email string 22 | publicKey *ssh.PublicKeys 23 | } 24 | 25 | // Prepares git operations by loading the ssh key, checking the known_hosts file and settings the details of the author 26 | func NewTldrGit(name string, email string, sshKeyPath string, sshKeyPassword string) (*TldrGit, error) { 27 | publicKey, err := ssh.NewPublicKeysFromFile("git", sshKeyPath, sshKeyPassword) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | // If CHECK_KNOWN_HOSTS is set, the ssh library of go-git tries to validate the remote ssh keys 33 | // by checking your local known_hosts file. 34 | if _, exists := os.LookupEnv("CHECK_KNOWN_HOSTS"); !exists { 35 | publicKey.HostKeyCallback = func(hostname string, remote net.Addr, key ssh2.PublicKey) error { 36 | // We're not checking the SSH key of remote, because it would be difficult to keep track of them. 37 | // Feel free to work on this problem, this might be useful: 38 | // https://pkg.go.dev/golang.org/x/crypto/ssh/knownhosts 39 | return nil 40 | } 41 | } 42 | 43 | return &TldrGit{ 44 | name: name, 45 | email: email, 46 | publicKey: publicKey, 47 | }, nil 48 | } 49 | 50 | // If no git repository exists at the given path, the remote repository at the given url is cloned to the path. 51 | // Else the existing repository will be updated by pulling the latest changes from the remote repository. 52 | func (g *TldrGit) CloneOrUpdate(url string, path string) error { 53 | _, err := os.Stat(path) 54 | if os.IsNotExist(err) { 55 | return g.Clone(url, path) 56 | } else { 57 | err = g.Pull(url, path) 58 | if errors.Is(err, git.ErrRepositoryNotExists) { 59 | err = os.RemoveAll(path) 60 | if err != nil { 61 | return err 62 | } 63 | return g.Clone(url, path) 64 | } else { 65 | return err 66 | } 67 | } 68 | } 69 | 70 | // Clones a remote git repository at the url into the given path, which will be created if needed 71 | func (g *TldrGit) Clone(url string, path string) error { 72 | // Creates the directory for the repository 73 | err := os.MkdirAll(path, DefaultFileMask) 74 | if err != nil { 75 | return err 76 | } 77 | 78 | // Clones the repository into the given dir, just as a normal git clone does 79 | _, err = git.PlainClone(path, false, &git.CloneOptions{ 80 | URL: url, 81 | Auth: g.publicKey, 82 | }) 83 | 84 | return err 85 | } 86 | 87 | // Pulls the latest changes of the remote repository into the git repository at the given path 88 | func (g *TldrGit) Pull(url string, path string) error { 89 | repository, err := git.PlainOpen(path) 90 | if err != nil { 91 | return err 92 | } 93 | 94 | _, err = g.setupDefaultRemote(repository, url) 95 | if err != nil { 96 | return err 97 | } 98 | 99 | worktree, err := repository.Worktree() 100 | if err != nil { 101 | return err 102 | } 103 | 104 | pullOptions := &git.PullOptions{ 105 | Auth: g.publicKey, 106 | } 107 | err = worktree.Pull(pullOptions) 108 | if errors.Is(err, git.NoErrAlreadyUpToDate) { 109 | return nil 110 | } else if errors.Is(err, git.ErrNonFastForwardUpdate) || errors.Is(err, git.ErrUnstagedChanges) { 111 | // Trying to hard reset the repository to the HEAD & attempt to pull again 112 | head, err := repository.Head() 113 | if err != nil { 114 | return err 115 | } 116 | 117 | err = worktree.Reset(&git.ResetOptions{ 118 | Commit: head.Hash(), 119 | Mode: git.HardReset, 120 | }) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | err = worktree.Pull(pullOptions) 126 | if !errors.Is(err, git.NoErrAlreadyUpToDate) && err != nil { 127 | return fmt.Errorf("can't update the repository %v automatically, please try to fix it by hand: %v", path, err) 128 | } 129 | } else if err != nil { 130 | return fmt.Errorf("can't update the repository %v automatically, please try to fix it by hand: %v", path, err) 131 | } 132 | 133 | return nil 134 | } 135 | 136 | // Commits all changed files in the git repository at the path with the given commit message. 137 | // Files ignored by a .gitignore won't be committed. 138 | func (g *TldrGit) CommitAll(path string, message string) error { 139 | repository, err := git.PlainOpen(path) 140 | if err != nil { 141 | return err 142 | } 143 | 144 | worktree, err := repository.Worktree() 145 | if err != nil { 146 | return err 147 | } 148 | 149 | // Doesn't detect deleted files, just new ones: https://github.com/go-git/go-git/issues/113 150 | err = worktree.AddGlob(".") 151 | if err != nil { 152 | return err 153 | } 154 | 155 | _, err = worktree.Commit(message, &git.CommitOptions{ 156 | // That's why we're requiring that the commit includes all (new, changed, deleted) files 157 | All: true, 158 | Author: &object.Signature{ 159 | Name: g.name, 160 | Email: g.email, 161 | When: time.Now(), 162 | }, 163 | }) 164 | 165 | return err 166 | } 167 | 168 | // Pushes all local changes of a git repository at the path to the remote repository at the given origin url. 169 | // If none remote exists, a remote with name DefaultRemoteName and the origin url will be created. 170 | // If a remote exists, but its first url isn't equal to the origin url, this url will be overwritten. 171 | func (g *TldrGit) Push(path string, origin string) error { 172 | repository, err := git.PlainOpen(path) 173 | if err != nil { 174 | return err 175 | } 176 | 177 | remote, err := g.setupDefaultRemote(repository, origin) 178 | if err != nil { 179 | return err 180 | } 181 | 182 | err = remote.Fetch(&git.FetchOptions{Auth: g.publicKey}) 183 | if !errors.Is(err, git.NoErrAlreadyUpToDate) && err != nil { 184 | return err 185 | } 186 | 187 | err = remote.Push(&git.PushOptions{Auth: g.publicKey}) 188 | if !errors.Is(err, git.NoErrAlreadyUpToDate) && err != nil { 189 | return err 190 | } 191 | 192 | return nil 193 | } 194 | 195 | // Checks if remote with the name DefaultRemoteName exists and has the correct url. 196 | // If not, this method tries to fix these problems. 197 | func (g *TldrGit) setupDefaultRemote(repository *git.Repository, url string) (*git.Remote, error) { 198 | resetRemote := false 199 | 200 | // Checking if the remote exists, if not creating it 201 | remote, err := repository.Remote(DefaultRemoteName) 202 | if errors.Is(err, git.ErrRemoteNotFound) { 203 | resetRemote = true 204 | } else if err != nil { 205 | return nil, err 206 | } else { 207 | // Checking if the first url of the remote is correct 208 | urls := remote.Config().URLs 209 | if urls[0] != url { 210 | resetRemote = true 211 | // Sadly it isn't possible to change properties of a remote, so we have to delete it and recreate it 212 | err := repository.DeleteRemote(DefaultRemoteName) 213 | if err != nil { 214 | return nil, err 215 | } 216 | } 217 | } 218 | 219 | // Creates a new default remote (if needed) 220 | if resetRemote { 221 | _, err = repository.CreateRemote(&config.RemoteConfig{ 222 | Name: DefaultRemoteName, 223 | URLs: []string{url}, 224 | }) 225 | if err != nil { 226 | return nil, err 227 | } 228 | } 229 | 230 | return remote, nil 231 | } 232 | -------------------------------------------------------------------------------- /lib/git/keys.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "github.com/charmbracelet/keygen" 5 | ) 6 | 7 | // CreateSSHKey generates an SSH key pair with the given password ("" = empty) and stores it as path and path.pub 8 | // If successful it returns the public key as a string, otherwise returns an error 9 | func CreateSSHKey(path string, password string) (string, error) { 10 | // Create a key pair with the keygen library: https://github.com/charmbracelet/keygen 11 | keyPair, err := keygen.New(path, keygen.WithPassphrase(password), keygen.WithKeyType(keygen.Ed25519), keygen.WithWrite()) 12 | if err != nil { 13 | return "", err 14 | } 15 | 16 | // Returns the public key as a string 17 | return keyPair.AuthorizedKey(), nil 18 | } 19 | -------------------------------------------------------------------------------- /lib/tldr/files.go: -------------------------------------------------------------------------------- 1 | package tldr 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path/filepath" 9 | "sort" 10 | "strings" 11 | "sync" 12 | ) 13 | 14 | const DefaultFileMask = 0740 15 | const DefaultLanguage = "en" 16 | 17 | const ( 18 | StatusTranslated StatusEnum = iota 19 | StatusOutdated StatusEnum = iota 20 | StatusNoTranslation StatusEnum = iota 21 | ) 22 | 23 | type Os string 24 | type Name string 25 | type Lang string 26 | type StatusEnum int 27 | 28 | type IndexMap map[Os]map[Name]map[Lang]Page 29 | type StatusMap map[Os]map[Name]map[Lang]StatusEnum 30 | type OrderedNameMap map[Os][]Name 31 | type ProgressMap map[Os]map[Lang]float64 32 | 33 | type Page struct { 34 | Name Name 35 | Os Os 36 | Language Lang 37 | Examples int 38 | } 39 | 40 | type Index struct { 41 | Pages []Page 42 | Index IndexMap 43 | Status StatusMap 44 | Names OrderedNameMap 45 | Progress ProgressMap 46 | Languages []Lang 47 | Os []Os 48 | } 49 | 50 | // Generates an index of the tldr git repository which contains information about the translations of every page 51 | func MapTldr(basePath string) (*Index, error) { 52 | sep := string(os.PathSeparator) 53 | matches, err := filepath.Glob(basePath + sep + "pages*" + sep + "*" + sep + "*.md") 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | pages := make([]Page, len(matches)) 59 | anError := false 60 | wg := sync.WaitGroup{} 61 | wg.Add(len(matches)) 62 | 63 | // Keeping the slice in sync: https://stackoverflow.com/a/18499708/4106848 64 | for i, match := range matches { 65 | go func(match string, basePath string, i int) { 66 | defer wg.Done() 67 | page, err := mapFile(match, basePath) 68 | if err != nil { 69 | log.Printf("can't index file %v: %v", match, err) 70 | anError = true 71 | } else { 72 | pages[i] = *page 73 | } 74 | }(match, basePath, i) 75 | } 76 | 77 | wg.Wait() 78 | 79 | if anError { 80 | newPages := make([]Page, 0, len(matches)) 81 | for _, page := range pages { 82 | if pages != nil { 83 | newPages = append(newPages, page) 84 | } 85 | } 86 | pages = newPages 87 | } 88 | 89 | return generateIndex(pages), nil 90 | } 91 | 92 | // Extracts information about a single tldr page like name, os, language and the number of examples 93 | func mapFile(path string, basePath string) (*Page, error) { 94 | rel, err := filepath.Rel(basePath, path) 95 | if err != nil { 96 | return nil, fmt.Errorf("unable to compute relative path: %v", err) 97 | } 98 | 99 | slash := filepath.ToSlash(rel) 100 | split := strings.Split(slash, "/") 101 | if len(split) < 3 { 102 | return nil, fmt.Errorf("relative path should yield at least three parts (only %v): %v", len(split), rel) 103 | } 104 | 105 | name := strings.TrimSuffix(split[2], ".md") 106 | lang := strings.TrimPrefix(split[0], "pages.") 107 | if split[0] == "pages" { 108 | lang = "en" 109 | } 110 | 111 | file, err := os.OpenFile(path, os.O_RDONLY, DefaultFileMask) 112 | if err != nil { 113 | return nil, fmt.Errorf("unable to open the file: %v", err) 114 | } 115 | defer file.Close() 116 | 117 | examples := 0 118 | scanner := bufio.NewScanner(file) 119 | for scanner.Scan() { 120 | if strings.HasPrefix(strings.TrimSpace(scanner.Text()), "-") { 121 | examples++ 122 | } 123 | } 124 | 125 | return &Page{ 126 | Name: Name(name), 127 | Os: Os(split[1]), 128 | Language: Lang(lang), 129 | Examples: examples, 130 | }, nil 131 | } 132 | 133 | // Generates the index of tldr pages which are just passed as a slice 134 | func generateIndex(pages []Page) *Index { 135 | languages := make([]Lang, 0, 10) 136 | oses := make([]Os, 0, 10) 137 | 138 | index := make(IndexMap) 139 | for _, page := range pages { 140 | // Adding to the slices 141 | languages = appendIfMissingLang(languages, page.Language) 142 | oses = appendIfMissingOs(oses, page.Os) 143 | 144 | // Generating the index 145 | _, ok := index[page.Os] 146 | if !ok { 147 | index[page.Os] = make(map[Name]map[Lang]Page) 148 | } 149 | _, ok = index[page.Os][page.Name] 150 | if !ok { 151 | index[page.Os][page.Name] = make(map[Lang]Page) 152 | } 153 | index[page.Os][page.Name][page.Language] = page 154 | } 155 | 156 | // Sorting the slices 157 | defaultIndex := 0 158 | for i, lang := range languages { 159 | if lang == DefaultLanguage { 160 | defaultIndex = i 161 | break 162 | } 163 | } 164 | languages[0], languages[defaultIndex] = languages[defaultIndex], languages[0] 165 | sortLang(languages[1:]) 166 | 167 | sort.Slice(languages, func(i, j int) bool { 168 | if languages[i] == DefaultLanguage { 169 | return true 170 | } else if languages[j] == DefaultLanguage { 171 | return false 172 | } 173 | return strings.Compare(string(languages[i]), string(languages[j])) < 0 174 | }) 175 | sortOs(oses) 176 | 177 | // Generating the status 178 | status := make(StatusMap) 179 | for _, oss := range oses { 180 | status[oss] = make(map[Name]map[Lang]StatusEnum) 181 | for name, langMap := range index[oss] { 182 | status[oss][name] = make(map[Lang]StatusEnum) 183 | 184 | defaultExamples := 0 185 | if pageEn, ok := langMap[DefaultLanguage]; ok { 186 | defaultExamples = pageEn.Examples 187 | } 188 | 189 | for _, lang := range languages { 190 | page, ok := langMap[lang] 191 | status[oss][name][lang] = statusForLanguage(ok, &page, defaultExamples) 192 | } 193 | } 194 | } 195 | 196 | // Sorting the names of pages 197 | names := make(OrderedNameMap) 198 | for oss, pageMap := range index { 199 | lst := make([]Name, len(pageMap)) 200 | 201 | i := 0 202 | for name := range pageMap { 203 | lst[i] = name 204 | i++ 205 | } 206 | 207 | sortNames(lst) 208 | 209 | names[oss] = lst 210 | } 211 | 212 | // Checking the progress 213 | progress := make(ProgressMap) 214 | for oss, pageMap := range status { 215 | counterAll := make(map[Lang]int) 216 | counterOk := make(map[Lang]int) 217 | 218 | for _, langMap := range pageMap { 219 | for lang, status := range langMap { 220 | if status != StatusNoTranslation { 221 | incrementOrOne(counterOk, lang) 222 | } 223 | incrementOrOne(counterAll, lang) 224 | } 225 | } 226 | 227 | progress[oss] = make(map[Lang]float64) 228 | for _, lang := range languages { 229 | ca, okA := counterAll[lang] 230 | co, okO := counterOk[lang] 231 | p := 0.0 232 | if okA && okO { 233 | p = float64(co) / float64(ca) 234 | } 235 | progress[oss][lang] = p 236 | } 237 | } 238 | 239 | return &Index{ 240 | Pages: pages, 241 | Index: index, 242 | Status: status, 243 | Names: names, 244 | Progress: progress, 245 | Languages: languages, 246 | Os: oses, 247 | } 248 | } 249 | 250 | // Returns the translation status for a given page and a given language. 251 | // For comparison the number of examples at the page in the default language (DefaultFileMask) is also provided. 252 | func statusForLanguage(pageExists bool, page *Page, defaultExamples int) StatusEnum { 253 | if !pageExists { 254 | return StatusNoTranslation 255 | } 256 | 257 | if defaultExamples == 0 { 258 | return StatusTranslated 259 | } 260 | 261 | if page.Examples == defaultExamples { 262 | return StatusTranslated 263 | } else { 264 | return StatusOutdated 265 | } 266 | } 267 | 268 | // Increases the count stored in the map m for a given language by one. 269 | // If no count is set (no map entry), the counter starts at one. 270 | func incrementOrOne(m map[Lang]int, l Lang) { 271 | count, ok := m[l] 272 | if !ok { 273 | m[l] = 1 274 | } else { 275 | m[l] = count + 1 276 | } 277 | } 278 | 279 | // Appends a Lang to the slice of Lang if it's missing 280 | func appendIfMissingLang(slice []Lang, elm Lang) []Lang { 281 | for _, s := range slice { 282 | if s == elm { 283 | return slice 284 | } 285 | } 286 | return append(slice, elm) 287 | } 288 | 289 | // Appends a Os to the slice of Os if it's missing 290 | func appendIfMissingOs(slice []Os, elm Os) []Os { 291 | for _, s := range slice { 292 | if s == elm { 293 | return slice 294 | } 295 | } 296 | return append(slice, elm) 297 | } 298 | 299 | // Sorts a slice of Name by casting them to strings 300 | func sortNames(slices []Name) { 301 | sort.Slice(slices, func(i, j int) bool { 302 | return strings.ToLower(string(slices[i])) < strings.ToLower(string(slices[j])) 303 | }) 304 | } 305 | 306 | // Sorts a slice of Os by casting them to strings 307 | func sortOs(slice []Os) { 308 | sort.Slice(slice, func(i, j int) bool { 309 | return strings.ToLower(string(slice[i])) < strings.ToLower(string(slice[j])) 310 | }) 311 | } 312 | 313 | // Sorts a slice of Lang by casting them to strings 314 | func sortLang(slice []Lang) { 315 | sort.Slice(slice, func(i, j int) bool { 316 | return strings.ToLower(string(slice[i])) < strings.ToLower(string(slice[j])) 317 | }) 318 | } 319 | -------------------------------------------------------------------------------- /lib/www/json.go: -------------------------------------------------------------------------------- 1 | package www 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/iancoleman/orderedmap" 6 | "os" 7 | "time" 8 | "tldr-translation-progress/lib/tldr" 9 | ) 10 | 11 | const defaultFileMask = 0740 12 | 13 | const ( 14 | jsonStatusOutdated = 1 15 | jsonStatusTranslated = 2 16 | ) 17 | 18 | // Root of the json file 19 | type ProgressJson struct { 20 | // human readable timestamp of the last update 21 | LastUpdate string `json:"last_update"` 22 | // sorted list of short language codes 23 | Languages []string `json:"languages"` 24 | // map[os]OperatingSystemJson 25 | Entries orderedmap.OrderedMap `json:"entries"` 26 | } 27 | 28 | // Section for every operating system 29 | type OperatingSystemJson struct { 30 | // map[language]percentage 31 | Progress map[string]float64 `json:"progress"` 32 | // map[page]PageProgressJson 33 | Pages orderedmap.OrderedMap `json:"pages"` 34 | } 35 | 36 | // The translation status in different languages of a given page 37 | type PageProgressJson struct { 38 | // map[language]{1 = outdated, 2 = translated} 39 | Status map[string]int `json:"status"` 40 | } 41 | 42 | // Writes a json file containing the progress information given by the index to the given path 43 | func GenerateJson(index *tldr.Index, path string) error { 44 | // Storing the languages in order 45 | languages := make([]string, len(index.Languages)) 46 | for i, language := range index.Languages { 47 | languages[i] = string(language) 48 | } 49 | 50 | entries := orderedmap.New() 51 | for _, oss := range index.Os { 52 | 53 | // Mapping the overall progress percentage for different languages into the map 54 | progress := make(map[string]float64) 55 | for _, language := range index.Languages { 56 | // Converting the percentage [0;1] to an integer [0;100] 57 | progress[string(language)] = (float64)((int)(index.Progress[oss][language] * 100)) 58 | } 59 | 60 | // Mapping the data for every page into PageProgressJson structs 61 | pages := orderedmap.New() 62 | for _, name := range index.Names[oss] { 63 | status := make(map[string]int) 64 | 65 | for _, language := range index.Languages { 66 | // We don't create an entry for the StatusNotTranslated 67 | switch index.Status[oss][name][language] { 68 | case tldr.StatusOutdated: 69 | status[string(language)] = jsonStatusOutdated 70 | case tldr.StatusTranslated: 71 | status[string(language)] = jsonStatusTranslated 72 | default: 73 | 74 | } 75 | } 76 | 77 | pages.Set(string(name), PageProgressJson{Status: status}) 78 | } 79 | 80 | entries.Set(string(oss), OperatingSystemJson{ 81 | Progress: progress, 82 | Pages: *pages, 83 | }) 84 | } 85 | 86 | progress := ProgressJson{ 87 | LastUpdate: time.Now().Format(time.RFC850), 88 | Languages: languages, 89 | Entries: *entries, 90 | } 91 | 92 | // We're indenting the json file to easily identify changes in Git commits 93 | bytes, err := json.MarshalIndent(progress, "", "\t") 94 | if err != nil { 95 | return err 96 | } 97 | 98 | _, err = os.Create(path) 99 | if err != nil { 100 | return err 101 | } 102 | 103 | return os.WriteFile(path, bytes, defaultFileMask) 104 | } 105 | -------------------------------------------------------------------------------- /out/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukWebsForge/TldrProgress/fff23f17d550d776e7e821a6a5def36cdb2b0d84/out/.gitkeep -------------------------------------------------------------------------------- /resources/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # Custom data.json file for testing 26 | public/data.json 27 | 28 | # Vercel 29 | .vercel 30 | 31 | .idea 32 | -------------------------------------------------------------------------------- /resources/.nvmrc: -------------------------------------------------------------------------------- 1 | lts/* 2 | -------------------------------------------------------------------------------- /resources/.prettierignore: -------------------------------------------------------------------------------- 1 | .vercel 2 | build 3 | public/data.json 4 | -------------------------------------------------------------------------------- /resources/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "semi": false, 4 | "singleQuote": true 5 | } 6 | -------------------------------------------------------------------------------- /resources/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | **Note**: Please run `yarn data` once to set up your development environment. 12 | 13 | Runs the app in the development mode.\ 14 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 15 | 16 | The page will reload if you make edits.\ 17 | You will also see any lint errors in the console. 18 | 19 | ### `yarn format` 20 | 21 | Formats the code using [Prettier](https://prettier.io/). 22 | Please run this command before committing. 23 | 24 | _Configuration files: 25 | [`.prettierignore`](.prettierignore), 26 | [`.prettierrc.json`](.prettierrc.json)_ 27 | 28 | ### `yarn lint` 29 | 30 | Lints the code using [Prettier](https://prettier.io/) and [ESLint](https://eslint.org/). 31 | This command is run as part of the CI process. 32 | 33 | _Configuration files: 34 | [`.eslintrc`](.eslintrc), 35 | [`.prettierignore`](.prettierignore), 36 | [`.prettierrc.json`](.prettierrc.json)_ 37 | 38 | ### `yarn data` 39 | 40 | Downloads the latest `data.json` file from the [`tldri18n`](https://github.com/LukWebsForge/tldri18n) repository. 41 | It contains the information which is being displayed by this front end. 42 | The file will be ignored by Git. 43 | 44 | ### `yarn test` 45 | 46 | Launches the test runner in the interactive watch mode.\ 47 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 48 | 49 | ### `yarn build` 50 | 51 | Builds the app for production to the `build` folder.\ 52 | It correctly bundles React in production mode and optimizes the build for the best performance. 53 | 54 | The build is minified and the filenames include the hashes.\ 55 | Your app is ready to be deployed! 56 | 57 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 58 | 59 | ### `yarn eject` 60 | 61 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 62 | 63 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 64 | 65 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 66 | 67 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 68 | 69 | ## Learn More 70 | 71 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 72 | 73 | To learn React, check out the [React documentation](https://reactjs.org/). 74 | 75 | ### Code Splitting 76 | 77 | This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting) 78 | 79 | ### Analyzing the Bundle Size 80 | 81 | This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size) 82 | 83 | ### Making a Progressive Web App 84 | 85 | This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app) 86 | 87 | ### Advanced Configuration 88 | 89 | This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration) 90 | 91 | ### Deployment 92 | 93 | This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment) 94 | 95 | ### `yarn build` fails to minify 96 | 97 | This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify) 98 | -------------------------------------------------------------------------------- /resources/eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import tseslint from 'typescript-eslint' 4 | import pluginReact from 'eslint-plugin-react' 5 | import { defineConfig } from 'eslint/config' 6 | 7 | export default defineConfig([ 8 | { 9 | settings: { 10 | react: { 11 | version: 'detect', 12 | }, 13 | }, 14 | }, 15 | { 16 | files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 17 | plugins: { js }, 18 | extends: ['js/recommended'], 19 | }, 20 | { 21 | files: ['**/*.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], 22 | languageOptions: { 23 | globals: globals.browser, 24 | }, 25 | }, 26 | tseslint.configs.recommended, 27 | pluginReact.configs.flat.recommended, 28 | pluginReact.configs.flat['jsx-runtime'], 29 | ]) 30 | -------------------------------------------------------------------------------- /resources/files.go: -------------------------------------------------------------------------------- 1 | package resources 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | ) 9 | 10 | const defaultFileMask = 0740 11 | const baseFSDir = "build" 12 | const assetsDir = "assets" 13 | 14 | //go:embed build 15 | var f embed.FS 16 | 17 | // Writes all static asset files from the 'resources/build' folder (now in the binary) to the given folder 18 | func WriteTo(ospath string) error { 19 | 20 | // Removes the 'assets' directory to prevent old hashed asset files from piling up 21 | staticDirPath := filepath.Join(ospath, assetsDir) 22 | if _, err := os.Stat(staticDirPath); !os.IsNotExist(err) { 23 | err := os.RemoveAll(staticDirPath) 24 | if err != nil { 25 | return err 26 | } 27 | } 28 | 29 | // Copies all files from this binary to the disk 30 | return fs.WalkDir(f, baseFSDir, func(fspath string, d fs.DirEntry, err error) error { 31 | relPath, err := filepath.Rel(baseFSDir, filepath.FromSlash(fspath)) 32 | fPath := filepath.Join(ospath, relPath) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | if d.IsDir() { 38 | return os.MkdirAll(fPath, defaultFileMask) 39 | } else { 40 | bytes, err := f.ReadFile(fspath) 41 | if err != nil { 42 | return err 43 | } 44 | return os.WriteFile(fPath, bytes, defaultFileMask) 45 | } 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /resources/grep-data.js: -------------------------------------------------------------------------------- 1 | const http = require('https') 2 | const fs = require('fs') 3 | 4 | // Inspiration: https://stackoverflow.com/a/17676794/4106848 5 | 6 | const destination = 'public/data.json' 7 | const url = 'https://raw.githubusercontent.com/LukWebsForge/tldri18n/main/data.json' 8 | 9 | const file = fs.createWriteStream(destination) 10 | http 11 | .get(url, (response) => { 12 | response.pipe(file) 13 | file.on('finish', () => { 14 | file.close() 15 | console.log(destination + ' was downloaded successfully') 16 | }) 17 | }) 18 | .on('error', (err) => { 19 | console.log('Error while downloading ' + destination + ': ' + err) 20 | }) 21 | -------------------------------------------------------------------------------- /resources/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 19 | 20 | tldr translation progress 21 | 22 | 23 | 24 |
25 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /resources/logo/README.md: -------------------------------------------------------------------------------- 1 | # Favicon 2 | 3 | - The SVG image uses the font family [Inter](https://rsms.me/inter/) 4 | - You can create a `favicon.ico` using [icoconverter.com](https://www.icoconverter.com/) 5 | -------------------------------------------------------------------------------- /resources/logo/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 20 | 22 | 28 | 29 | 50 | 52 | 53 | 55 | image/svg+xml 56 | 58 | 59 | 60 | 61 | 62 | 66 | 74 | 81 | tldr 90 | tldr 101 | 104 | 111 | 112 | 113 | 114 | -------------------------------------------------------------------------------- /resources/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tldrprogress", 3 | "version": "1.0.0", 4 | "private": true, 5 | "homepage": "./", 6 | "dependencies": { 7 | "@geist-ui/core": "~2.3.8", 8 | "@geist-ui/icons": "^1.0.1", 9 | "@types/node": "^24.0.10", 10 | "@types/react": "^19.1.8", 11 | "@types/react-dom": "^19.1.6", 12 | "@vitejs/plugin-react": "^4.6.0", 13 | "inter-ui": "^4.1.1", 14 | "react": "^19.1.0", 15 | "react-device-detect": "^2.1.2", 16 | "react-dom": "^19.1.0", 17 | "react-intersection-observer": "^9.16.0", 18 | "typescript": "^5.8.3", 19 | "vite": "^7.0.0" 20 | }, 21 | "devDependencies": { 22 | "@eslint/js": "^9.30.1", 23 | "eslint": "^9.30.1", 24 | "eslint-plugin-react": "^7.37.5", 25 | "globals": "^16.3.0", 26 | "prettier": "^3.6.2", 27 | "typescript-eslint": "^8.35.1" 28 | }, 29 | "scripts": { 30 | "start": "vite", 31 | "build": "vite build --base ''", 32 | "serve": "vite preview", 33 | "lint": "eslint src/*.tsx && prettier --check .", 34 | "format": "prettier --write .", 35 | "data": "node grep-data.js" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /resources/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukWebsForge/TldrProgress/fff23f17d550d776e7e821a6a5def36cdb2b0d84/resources/public/favicon.ico -------------------------------------------------------------------------------- /resources/public/favicon192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukWebsForge/TldrProgress/fff23f17d550d776e7e821a6a5def36cdb2b0d84/resources/public/favicon192.png -------------------------------------------------------------------------------- /resources/public/favicon512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LukWebsForge/TldrProgress/fff23f17d550d776e7e821a6a5def36cdb2b0d84/resources/public/favicon512.png -------------------------------------------------------------------------------- /resources/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "tldr i18n", 3 | "name": "tldr translation progress", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "favicon192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "favicon512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /resources/src/App.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Inter', sans-serif; 3 | } 4 | 5 | .cursor-pointer { 6 | cursor: pointer; 7 | } 8 | 9 | .small-font { 10 | font-size: 80%; 11 | } 12 | 13 | .vertical-align-icons { 14 | vertical-align: middle; 15 | } 16 | -------------------------------------------------------------------------------- /resources/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { CssBaseline, GeistProvider, Page, Text } from '@geist-ui/core' 2 | import { ThemeTypeProvider, useThemeType } from './ThemeType' 3 | import { DataFetcher } from './Data' 4 | import { DataTable } from './Table' 5 | import { AppLoader } from './AppLoader' 6 | import { ErrorMessage } from './ErrorMessage' 7 | import { AppFooter } from './AppFooter' 8 | import { IconActions } from './IconActions' 9 | import { SelectHighlights } from './IconActionHighlight' 10 | import 'inter-ui/inter.css' 11 | import './App.css' 12 | 13 | const App = () => ( 14 | 15 | 16 | 17 | ) 18 | 19 | const GeistApp = () => { 20 | const themeType = useThemeType() 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | tldr translation progress 28 | 29 | 30 | } 32 | error={} 33 | selection={} 34 | > 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | ) 45 | } 46 | 47 | export { App } 48 | -------------------------------------------------------------------------------- /resources/src/AppFooter.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Text } from '@geist-ui/core' 2 | 3 | console.log(__COMMIT__HASH__) 4 | 5 | const AppFooter = () => { 6 | const gitInfo = __COMMIT__HASH__ 7 | const version = gitInfo.tag.length > 0 ? gitInfo.tag : gitInfo.commit 8 | const commitUrl = 'https://github.com/LukWebsForge/TldrProgress/commit/' + gitInfo.commit 9 | 10 | return ( 11 | 12 | Generated by{' '} 13 | 14 | tldr-translation-progress 15 | {' '} 16 | • Version{' '} 17 | 18 | {version} 19 | {' '} 20 | • Thanks for using this site 21 | 22 | ) 23 | } 24 | 25 | export { AppFooter } 26 | -------------------------------------------------------------------------------- /resources/src/AppLoader.tsx: -------------------------------------------------------------------------------- 1 | import { Loading } from '@geist-ui/core' 2 | 3 | const AppLoader = () => Loading 4 | 5 | export { AppLoader } 6 | -------------------------------------------------------------------------------- /resources/src/Data.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | PropsWithChildren, 4 | ReactElement, 5 | useEffect, 6 | useState, 7 | useTransition, 8 | } from 'react' 9 | 10 | // https://reactjs.org/docs/faq-ajax.html 11 | // https://reactjs.org/docs/hooks-reference.html#usecontext 12 | 13 | enum TranslationStatus { 14 | Outdated = 1, 15 | Translated = 2, 16 | } 17 | 18 | type OperatingSystem = string 19 | type Language = string 20 | type PageName = string 21 | 22 | interface TranslationData { 23 | last_update: string 24 | languages: Language[] 25 | entries: Record 26 | } 27 | 28 | interface TranslationOS { 29 | progress: Record 30 | pages: Record 31 | } 32 | 33 | interface TranslationPage { 34 | status: Record 35 | } 36 | 37 | const DataContext = createContext<{ 38 | data: TranslationData | null 39 | error: string | null 40 | highlighted: Set 41 | setHighlighted: (languages: Language[]) => void 42 | }>({ 43 | data: null, 44 | error: null, 45 | highlighted: new Set(['en']), 46 | setHighlighted: () => {}, 47 | }) 48 | 49 | const DataFetcher = ( 50 | props: PropsWithChildren<{ error: ReactElement; loading: ReactElement; selection: ReactElement }>, 51 | ) => { 52 | const [error, setError] = useState(null) 53 | const [isLoaded, setIsLoaded] = useState(false) 54 | const [data, setData] = useState(null) 55 | const [highlighted, setHighlighted] = useState>(new Set(['en'])) 56 | const [isPending, startTransition] = useTransition() 57 | const webStorageHighlightedKey = 'language-highlighted' 58 | 59 | // Fetch the data.json file 60 | useEffect(() => { 61 | fetch('data.json') 62 | .then(async (r) => { 63 | if (!r.ok) { 64 | throw new Error(r.status + ': ' + r.statusText) 65 | } 66 | 67 | return r.json() 68 | }) 69 | .then( 70 | (result) => { 71 | startTransition(() => { 72 | setData(result as TranslationData) 73 | setIsLoaded(true) 74 | }) 75 | }, 76 | (error) => { 77 | startTransition(() => { 78 | setError(error.toString()) 79 | setIsLoaded(true) 80 | }) 81 | }, 82 | ) 83 | }, []) 84 | // [] = only run on component mount 85 | 86 | // Fetch the user preferences using the Web Storage API 87 | useEffect(() => { 88 | const stored = localStorage.getItem(webStorageHighlightedKey) 89 | if (stored) { 90 | const languages: Set = new Set(JSON.parse(stored)) 91 | languages.add('en') 92 | startTransition(() => setHighlighted(new Set(languages))) 93 | } 94 | }, []) 95 | 96 | const setHighlightedExternal = (languages: Language[]) => { 97 | // Convert the array to a set for better performance and broadcast the change 98 | // See: https://stackoverflow.com/a/57277566/4106848 99 | startTransition(() => setHighlighted(new Set(languages))) 100 | // Persist the user selection of highlighted columns 101 | localStorage.setItem(webStorageHighlightedKey, JSON.stringify(Array.from(languages))) 102 | } 103 | 104 | if (error) { 105 | const provided = { 106 | data: null, 107 | error: error, 108 | highlighted: new Set(), 109 | setHighlighted: () => {}, 110 | } 111 | return {props.error} 112 | } 113 | 114 | if (!isLoaded || isPending) { 115 | return props.loading 116 | } 117 | 118 | const provided = { 119 | data: data, 120 | error: null, 121 | highlighted: highlighted, 122 | setHighlighted: setHighlightedExternal, 123 | } 124 | return ( 125 | 126 | {highlighted.size < 2 ? props.selection : props.children} 127 | 128 | ) 129 | } 130 | 131 | export { DataFetcher, DataContext, TranslationStatus } 132 | export type { TranslationData, OperatingSystem, Language, PageName } 133 | -------------------------------------------------------------------------------- /resources/src/ErrorMessage.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Code, Modal, Note, useModal } from '@geist-ui/core' 3 | import { Info } from '@geist-ui/icons' 4 | import { DataContext } from './Data' 5 | import { useEscClose } from './useEscClose' 6 | 7 | const ErrorMessage = () => { 8 | const { error } = useContext(DataContext) 9 | const { visible, setVisible, bindings } = useModal() 10 | useEscClose(visible, setVisible) 11 | 12 | return ( 13 | <> 14 | 15 | Can't fetch the translation progress data from GitHub 16 | 17 | {' '} 18 | { 21 | setVisible(true) 22 | }} 23 | /> 24 | 25 | 26 | 27 | Error 28 | Can't fetch the data from GitHub 29 | 30 | 31 | {error} 32 | 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | export { ErrorMessage } 40 | -------------------------------------------------------------------------------- /resources/src/IconActionFilter.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useContext, useMemo, useState } from 'react' 2 | import { Link, Modal, Pagination, Select, Spacer, Text, Tooltip, useModal } from '@geist-ui/core' 3 | import { Filter } from '@geist-ui/icons' 4 | import { DataContext, TranslationData, TranslationStatus } from './Data' 5 | import { FileAction, tldrPageUrl } from './tldrPageUrl' 6 | import { useEscClose } from './useEscClose' 7 | 8 | enum FilterType { 9 | Outdated = 'outdated', 10 | NotTranslated = 'not-translated', 11 | } 12 | 13 | type FilterAttributes = { type: FilterType; language: string } 14 | 15 | type FilteredPage = { os: string; page: string } 16 | 17 | const FilterSelection = (props: { 18 | initial: FilterAttributes 19 | onChange: (attributes: FilterAttributes) => void 20 | }) => { 21 | const { data } = useContext(DataContext) 22 | const [type, setType] = useState(props.initial.type) 23 | const [language, setLanguage] = useState(props.initial.language) 24 | 25 | const languageOptions = 26 | data?.languages.map((lang) => ( 27 | 28 | {lang} 29 | 30 | )) ?? [] 31 | 32 | return ( 33 |
34 | Show 35 | 36 | 51 | 52 | pages in the language 53 | 54 | 64 |
65 | ) 66 | } 67 | 68 | function filterData(data: TranslationData, filter: FilterAttributes) { 69 | const filtered: FilteredPage[] = [] 70 | 71 | for (const [osName, osStatus] of Object.entries(data.entries ?? {})) { 72 | for (const [pageName, page] of Object.entries(osStatus.pages)) { 73 | const status = page.status[filter.language] 74 | 75 | let add = false 76 | switch (filter.type) { 77 | case FilterType.Outdated: 78 | add = status === TranslationStatus.Outdated 79 | break 80 | case FilterType.NotTranslated: 81 | add = !status 82 | break 83 | } 84 | 85 | if (add) { 86 | filtered.push({ os: osName, page: pageName }) 87 | } 88 | } 89 | } 90 | 91 | return filtered 92 | } 93 | 94 | const FilteredPageList = (props: { attributes: FilterAttributes; pages: FilteredPage[] }) => { 95 | let action: FileAction 96 | let empty: string 97 | switch (props.attributes.type) { 98 | case FilterType.NotTranslated: 99 | action = FileAction.Create 100 | empty = '✓ all pages are translated' 101 | break 102 | case FilterType.Outdated: 103 | action = FileAction.View 104 | empty = '✓ all translated pages are up-to-date' 105 | break 106 | } 107 | 108 | const elements = props.pages.map((page) => ( 109 | 110 | 115 | {page.os}/{page.page} 116 | 117 |
118 |
119 | )) 120 | 121 | return ( 122 | 123 | {props.pages.length > 0 ? elements : empty} 124 | 125 | ) 126 | } 127 | 128 | const FilteredData = (props: { attributes: FilterAttributes }) => { 129 | const initialPage = 1 130 | const elementsPerPage = 20 131 | 132 | const { data } = useContext(DataContext) 133 | const filtered = useMemo(() => filterData(data!, props.attributes), [data, props.attributes]) 134 | const [pageNumber, setPageNumber] = useState(initialPage) 135 | 136 | return ( 137 | <> 138 | 142 | { 146 | setPageNumber(page) 147 | }} 148 | /> 149 | 150 | ) 151 | } 152 | 153 | const FilterUI = () => { 154 | const initialAttributes: FilterAttributes = { 155 | type: FilterType.Outdated, 156 | language: 'en', 157 | } 158 | const [attributes, setAttributes] = useState(initialAttributes) 159 | 160 | return ( 161 | <> 162 | { 165 | setAttributes(attributes) 166 | }} 167 | /> 168 | {/* The key property is important to start the pagination for every selection at the first page */} 169 | 170 | 171 | ) 172 | } 173 | 174 | const IconActionFilter = (props: { side?: boolean }) => { 175 | const { visible, setVisible, bindings } = useModal() 176 | useEscClose(visible, setVisible) 177 | 178 | const placement = props.side ? 'left' : 'top' 179 | 180 | return ( 181 | <> 182 | 183 | { 187 | setVisible(true) 188 | }} 189 | /> 190 | 191 | 192 | Filter 193 | 194 | 195 | 196 | 197 | 198 | ) 199 | } 200 | 201 | export { IconActionFilter } 202 | -------------------------------------------------------------------------------- /resources/src/IconActionHelp.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { ArrowDownCircle, Bookmark, Filter, HelpCircle, Moon, Search, Sun } from '@geist-ui/icons' 3 | import { Link, Modal, Text, Tooltip, useModal } from '@geist-ui/core' 4 | import { DataContext } from './Data' 5 | import { useEscClose } from './useEscClose' 6 | 7 | const UpdateTime = () => { 8 | const { data } = useContext(DataContext) 9 | return {data?.last_update} 10 | } 11 | 12 | const HelpContent = () => ( 13 | <> 14 | the tldr-pages project 15 | 16 | The{' '} 17 | 18 | tldr-pages project 19 | {' '} 20 | provides a collection of community-maintained help pages for command-line tools. 21 | 22 | this website 23 | 24 | The main feature of this website is a table which lists the translation status for all tldr 25 | pages. You can click every status icon (legend) and you'll be taken to the respective page 26 | on GitHub or to a dialogue to create a new one. 27 | 28 | 29 | The pages are grouped by operating system and are sorted in alphabetical order. Each blue row 30 | marks the start of the next group and shows its percentage of pages translated (an outdated 31 | page counts as translated). 32 | 33 | 34 | The underlying dataset is based on the{' '} 35 | 36 | main 37 | 38 | branch of the tldr-pages/tldr repository and is scheduled to be updated every day at midnight 39 | (UTC). The last update was performed on . 40 | 41 | actions 42 | 43 | - get help (this page)
44 | - switch to the dark mode
45 | - switch to the light mode
46 | - jump to a section of an 47 | operating system
48 | - filter for not yet translated or 49 | outdated pages in a given language
50 | - select preferred columns to be 51 | highlighted
52 | - information on how to search the table{' '} 53 |
54 |
55 | legend 56 | 57 | ✓ - up-to-date translation (or source)
58 | - outdated translation
59 | ✗ - not yet translated
60 |
61 | ideas? 62 | 63 | Feel free to drop an issue or even a pull request at the{' '} 64 | 65 | tldr progress 66 | 67 | repository. 68 | 69 | 70 | ) 71 | 72 | const IconActionHelp = () => { 73 | const { visible, setVisible, bindings } = useModal() 74 | useEscClose(visible, setVisible) 75 | 76 | return ( 77 | <> 78 | 79 | { 83 | setVisible(true) 84 | }} 85 | /> 86 | 87 | 88 | Information 89 | about this project 90 | 91 | 92 | 93 | 94 | 95 | ) 96 | } 97 | 98 | export { IconActionHelp } 99 | -------------------------------------------------------------------------------- /resources/src/IconActionHighlight.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from 'react' 2 | import { Button, Checkbox, Grid, Modal, Text, Tooltip, useModal } from '@geist-ui/core' 3 | import { MessageCircle, Save } from '@geist-ui/icons' 4 | import { useEscClose } from './useEscClose' 5 | import { DataContext, Language } from './Data' 6 | 7 | const HighlightCheckboxes = (props: { 8 | highlighted: Language[] 9 | onChange: (selected: Language[]) => void 10 | }) => { 11 | const { data } = useContext(DataContext) 12 | 13 | const checkboxes = data!.languages.map((language) => ( 14 | 15 | 16 | {language} 17 | 18 | 19 | )) 20 | 21 | return ( 22 | 23 | 24 | {checkboxes} 25 | 26 | 27 | ) 28 | } 29 | 30 | const SelectHighlights = () => { 31 | // Columns which are being highlighted 32 | const { highlighted, setHighlighted } = useContext(DataContext) 33 | // We only want to apply the highlight selection once it has been confirmed, so we create a temporary store 34 | const [tmpHighlighted, setTmpHighlighted] = useState(Array.from(highlighted)) 35 | 36 | return ( 37 | <> 38 | Select languages to show 39 |
40 | setTmpHighlighted(selected)} 43 | /> 44 |
45 | 54 | 55 | ) 56 | } 57 | 58 | const IconActionHighlight = (props: { side?: boolean }) => { 59 | const { visible, setVisible, bindings } = useModal() 60 | useEscClose(visible, setVisible) 61 | 62 | // Columns which are being highlighted 63 | const { highlighted, setHighlighted } = useContext(DataContext) 64 | // We only want to apply the highlight selection once it has been confirmed, so we create a temporary store 65 | const [tmpHighlighted, setTmpHighlighted] = useState(Array.from(highlighted)) 66 | 67 | const placement = props.side ? 'left' : 'top' 68 | 69 | return ( 70 | <> 71 | 72 | { 76 | // Populate the temporary storage with the latest data 77 | setTmpHighlighted(Array.from(highlighted)) 78 | setVisible(true) 79 | }} 80 | /> 81 | 82 | 83 | Select Languages 84 | Choose languages to display 85 | 86 | setTmpHighlighted(selected)} 89 | /> 90 | 91 | { 94 | setVisible(false) 95 | }} 96 | > 97 | Cancel 98 | 99 | { 101 | setVisible(false) 102 | setHighlighted(tmpHighlighted) 103 | }} 104 | > 105 | Save 106 | 107 | 108 | 109 | ) 110 | } 111 | 112 | export { IconActionHighlight, SelectHighlights } 113 | -------------------------------------------------------------------------------- /resources/src/IconActionJump.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { ArrowDownCircle } from '@geist-ui/icons' 3 | import { Link, Popover } from '@geist-ui/core' 4 | import { DataContext } from './Data' 5 | 6 | const IconActionJump = (props: { side?: boolean }) => { 7 | const { data } = useContext(DataContext) 8 | 9 | const content = [] 10 | content.push( 11 | 12 | Jump to 13 | , 14 | ) 15 | 16 | if (data?.entries) { 17 | content.push( 18 | Object.keys(data?.entries).map((os) => ( 19 | 20 | {os} 21 | 22 | )), 23 | ) 24 | } 25 | 26 | const placement = props.side ? 'left' : 'bottom' 27 | 28 | return ( 29 | 30 | 31 | 32 | ) 33 | } 34 | 35 | export { IconActionJump } 36 | -------------------------------------------------------------------------------- /resources/src/IconActionSearch.tsx: -------------------------------------------------------------------------------- 1 | import { Search } from '@geist-ui/icons' 2 | import { Keyboard, Tooltip, useToasts } from '@geist-ui/core' 3 | import { isIOS, isMacOs } from 'react-device-detect' 4 | 5 | const IconActionSearch = (props: { side?: boolean }) => { 6 | const { setToast } = useToasts() 7 | 8 | const placement = props.side ? 'left' : 'top' 9 | const hotkey = isIOS || isMacOs ? f : <>(ctrl + f) 10 | const toastText =
Use your browser's search functionality {hotkey} to search
11 | 12 | return ( 13 | 14 | { 18 | setToast({ text: toastText }) 19 | }} 20 | /> 21 | 22 | ) 23 | } 24 | 25 | export { IconActionSearch } 26 | -------------------------------------------------------------------------------- /resources/src/IconActionTheme.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react' 2 | import { Tooltip } from '@geist-ui/core' 3 | import { Moon, Sun } from '@geist-ui/icons' 4 | import { ThemeType, ThemeTypeContext } from './ThemeType' 5 | 6 | const IconActionTheme = (props: { side?: boolean }) => { 7 | const { themeType, setThemeType } = useContext(ThemeTypeContext) 8 | 9 | const placement = props.side ? 'left' : 'top' 10 | const icon = 11 | themeType === ThemeType.Light ? ( 12 | setThemeType(ThemeType.Dark)} /> 13 | ) : ( 14 | setThemeType(ThemeType.Light)} /> 15 | ) 16 | 17 | return ( 18 | 19 | {icon} 20 | 21 | ) 22 | } 23 | 24 | export { IconActionTheme } 25 | -------------------------------------------------------------------------------- /resources/src/IconActions.css: -------------------------------------------------------------------------------- 1 | .floating-right { 2 | position: fixed; 3 | top: 15%; 4 | right: 0; 5 | 6 | padding: 5px; 7 | border-radius: 5px 0 0 5px; 8 | 9 | background: rgba(255, 255, 255, 0.9); 10 | } 11 | 12 | .dark .floating-right { 13 | background: rgba(0, 0, 0, 0.7); 14 | } 15 | 16 | .hidden { 17 | display: none; 18 | } 19 | -------------------------------------------------------------------------------- /resources/src/IconActions.tsx: -------------------------------------------------------------------------------- 1 | import { Grid } from '@geist-ui/core' 2 | import { useInView } from 'react-intersection-observer' 3 | import { IconActionHelp } from './IconActionHelp' 4 | import { IconActionTheme } from './IconActionTheme' 5 | import { IconActionJump } from './IconActionJump' 6 | import { IconActionFilter } from './IconActionFilter' 7 | import { IconActionHighlight } from './IconActionHighlight' 8 | import { IconActionSearch } from './IconActionSearch' 9 | import './IconActions.css' 10 | 11 | const IconActions = () => { 12 | const { ref, inView } = useInView() 13 | 14 | let floatingClasses = 'floating-right' 15 | if (inView) { 16 | floatingClasses += ' hidden' 17 | } 18 | 19 | return ( 20 | <> 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 |
43 | 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 |
63 | 64 | ) 65 | } 66 | 67 | export { IconActions } 68 | -------------------------------------------------------------------------------- /resources/src/Table.css: -------------------------------------------------------------------------------- 1 | .text-left { 2 | text-align: left; 3 | } 4 | 5 | .text-center { 6 | text-align: center; 7 | } 8 | 9 | .background-blue { 10 | background: rgba(128, 187, 255, 0.35); 11 | } 12 | 13 | .dark .background-blue { 14 | background: rgba(22, 27, 32, 0.8); 15 | } 16 | 17 | .background-green { 18 | background: rgba(113, 186, 93, 0.35); 19 | } 20 | 21 | .dark .background-green { 22 | background: rgba(28, 81, 44, 0.8); 23 | } 24 | 25 | .background-yellow { 26 | background: rgba(245, 198, 122, 0.35); 27 | } 28 | 29 | .dark .background-yellow { 30 | background: rgba(121, 103, 19, 0.8); 31 | } 32 | 33 | .background-red { 34 | background: rgba(255, 128, 128, 0.35); 35 | } 36 | 37 | .dark .background-red { 38 | background: rgba(89, 37, 38, 0.8); 39 | } 40 | 41 | /* An element has the class 'background-blue' and one of its child elements is 'highlighted'. */ 42 | .background-blue .highlighted { 43 | background: rgba(128, 187, 255, 0.55); 44 | } 45 | 46 | .dark .background-blue .highlighted { 47 | background: rgba(22, 27, 32, 1); 48 | } 49 | 50 | /* 51 | Notice the missing space between class names: https://stackoverflow.com/a/2554853/4106848 52 | A single element has the class 'background-green' and is 'highlighted'. 53 | */ 54 | .background-green.highlighted { 55 | background: rgba(113, 186, 93, 0.55); 56 | } 57 | 58 | .dark .background-green.highlighted { 59 | background: rgba(28, 81, 44, 1); 60 | } 61 | 62 | .background-yellow.highlighted { 63 | background: rgba(245, 198, 122, 0.55); 64 | } 65 | 66 | .dark .background-yellow.highlighted { 67 | background: rgba(121, 103, 19, 1); 68 | } 69 | 70 | .background-red.highlighted { 71 | background: rgba(255, 128, 128, 0.55); 72 | } 73 | 74 | .dark .background-red.highlighted { 75 | background: rgba(89, 37, 38, 1); 76 | } 77 | 78 | .bg-white-transparent { 79 | background: rgba(255, 255, 255, 0.9); 80 | -moz-background-clip: padding; 81 | -webkit-background-clip: padding; 82 | background-clip: padding-box; 83 | } 84 | 85 | .dark .bg-white-transparent { 86 | background: rgba(0, 0, 0, 0.9); 87 | -moz-background-clip: padding; 88 | -webkit-background-clip: padding; 89 | background-clip: padding-box; 90 | } 91 | 92 | .bg-white-transparent.highlighted { 93 | background: rgba(230, 241, 255, 0.9); 94 | -moz-background-clip: padding; 95 | -webkit-background-clip: padding; 96 | background-clip: padding-box; 97 | } 98 | 99 | .dark .bg-white-transparent.highlighted { 100 | background: rgba(22, 27, 32, 0.9); 101 | -moz-background-clip: padding; 102 | -webkit-background-clip: padding; 103 | background-clip: padding-box; 104 | } 105 | 106 | .bg-white-opaque { 107 | background: rgba(255, 255, 255, 1); 108 | -moz-background-clip: padding; 109 | -webkit-background-clip: padding; 110 | background-clip: padding-box; 111 | } 112 | 113 | .dark .bg-white-opaque { 114 | background: rgba(0, 0, 0, 1); 115 | -moz-background-clip: padding; 116 | -webkit-background-clip: padding; 117 | background-clip: padding-box; 118 | } 119 | 120 | .vertical-padding { 121 | padding: 5px 5px; 122 | } 123 | 124 | .zero-padding { 125 | padding: 0; 126 | } 127 | 128 | table { 129 | border-collapse: collapse; 130 | } 131 | 132 | td, 133 | th { 134 | border: 0.5px solid rgba(153, 153, 153, 0.5); 135 | padding: 2px; 136 | } 137 | 138 | .dark td, 139 | .dark th { 140 | border: 0.5px solid rgba(33, 38, 45, 1); 141 | } 142 | 143 | tr:hover { 144 | background: rgba(179, 179, 179, 0.5); 145 | } 146 | 147 | .dark tr:hover { 148 | background: rgba(102, 102, 102, 0.4); 149 | } 150 | 151 | .sticky { 152 | position: -webkit-sticky; 153 | position: sticky; 154 | top: 0; 155 | z-index: 2; 156 | } 157 | -------------------------------------------------------------------------------- /resources/src/Table.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment, useContext } from 'react' 2 | import { DataContext, OperatingSystem, TranslationStatus } from './Data' 3 | import { FileAction, tldrPageUrl } from './tldrPageUrl' 4 | import './Table.css' 5 | 6 | const DataTable = () => ( 7 | 8 | 9 | 10 |
11 | ) 12 | 13 | const DataTableHeader = () => { 14 | const { data, highlighted } = useContext(DataContext) 15 | 16 | // We're applying the sticky class to each , because Chrome does not support sticky on and 17 | // https://bugs.chromium.org/p/chromium/issues/detail?id=702927 18 | const languageRows = data?.languages 19 | .filter((lang) => highlighted.has(lang)) 20 | .map((lang) => { 21 | let classNames = 'vertical-padding sticky bg-white-transparent' 22 | if (lang.length > 3) { 23 | classNames += ' small-font' 24 | } 25 | 26 | return ( 27 | 28 | {lang} 29 | 30 | ) 31 | }) 32 | 33 | return ( 34 | 35 | 36 | page 37 | {languageRows} 38 | 39 | 40 | ) 41 | } 42 | 43 | const DataTableBody = () => { 44 | const { data } = useContext(DataContext) 45 | 46 | const osSections = Object.keys(data!.entries).map((os) => ( 47 | 48 | 49 | 50 | 51 | )) 52 | 53 | return {osSections} 54 | } 55 | 56 | const DataTableOSHeader = (props: { os: OperatingSystem }) => { 57 | const { data, highlighted } = useContext(DataContext) 58 | const osProgress = data!.entries[props.os].progress 59 | 60 | const percentages = data!.languages 61 | .filter((lang) => highlighted.has(lang)) 62 | .map((lang) => { 63 | const classNames = 'vertical-padding small-font' 64 | 65 | return ( 66 | 67 | {osProgress[lang]}% 68 | 69 | ) 70 | }) 71 | 72 | return ( 73 | 74 | 75 |
{props.os}
76 | 77 | {percentages} 78 | 79 | ) 80 | } 81 | 82 | const DataTableOSPages = (props: { os: OperatingSystem }) => { 83 | const { data } = useContext(DataContext) 84 | const osPages = data!.entries[props.os].pages 85 | 86 | const pages = Object.keys(osPages).map((page) => ( 87 | 88 | )) 89 | 90 | return <>{pages} 91 | } 92 | 93 | const DataTableOSPageRow = (props: { os: OperatingSystem; pageName: string }) => { 94 | const { data, highlighted } = useContext(DataContext) 95 | const pageData = data!.entries[props.os].pages[props.pageName] 96 | 97 | function handleClick(action: FileAction, language: string) { 98 | const win = window.open(tldrPageUrl(action, props.os, props.pageName, language)) 99 | if (win != null) { 100 | win.focus() 101 | } 102 | } 103 | 104 | // Pick symbols from: https://rsms.me/inter/#charset 105 | const cells = data!.languages 106 | .filter((lang) => highlighted.has(lang)) 107 | .map((lang) => { 108 | let classNames = 'cursor-pointer' 109 | let onClick = null 110 | let symbol = '?' 111 | 112 | if (lang in pageData.status) { 113 | const status = pageData.status[lang] 114 | switch (status) { 115 | case TranslationStatus.Translated: 116 | classNames += ' background-green' 117 | onClick = () => handleClick(FileAction.View, lang) 118 | symbol = '✓' 119 | break 120 | case TranslationStatus.Outdated: 121 | classNames += ' background-yellow' 122 | onClick = () => handleClick(FileAction.View, lang) 123 | symbol = '◇' 124 | break 125 | } 126 | } else { 127 | classNames += ' background-red' 128 | onClick = () => handleClick(FileAction.Create, lang) 129 | symbol = '✗' 130 | } 131 | 132 | return ( 133 | 134 | {symbol} 135 | 136 | ) 137 | }) 138 | 139 | return ( 140 | 141 | {props.pageName} 142 | {cells} 143 | 144 | ) 145 | } 146 | 147 | export { DataTable } 148 | -------------------------------------------------------------------------------- /resources/src/ThemeType.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, PropsWithChildren, useContext, useEffect, useState } from 'react' 2 | 3 | enum ThemeType { 4 | Light = 'light', 5 | Dark = 'dark', 6 | } 7 | 8 | const ThemeTypeContext = createContext<{ 9 | themeType: ThemeType 10 | setThemeType: (themeType: ThemeType) => void 11 | }>({ 12 | themeType: ThemeType.Light, 13 | setThemeType: () => {}, 14 | }) 15 | 16 | const ThemeTypeProvider = (props: PropsWithChildren) => { 17 | const [themeType, setThemeType] = useState(ThemeType.Light) 18 | const webStorageThemeTypeKey = 'theme-type' 19 | 20 | // Fetch the user preference using the Web Storage API or determine the system theme 21 | useEffect(() => { 22 | const stored = localStorage.getItem(webStorageThemeTypeKey) 23 | if (stored === ThemeType.Light || stored === ThemeType.Dark) { 24 | setThemeType(stored) 25 | } else if (window && typeof window.matchMedia === 'function') { 26 | // Inspired by: https://github.com/xcv58/use-system-theme/blob/master/src/index.tsx 27 | const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches 28 | setThemeType(isDark ? ThemeType.Dark : ThemeType.Light) 29 | } 30 | }, []) 31 | 32 | const setPersistentThemeType = (themeType: ThemeType) => { 33 | setThemeType(themeType) 34 | localStorage.setItem(webStorageThemeTypeKey, themeType) 35 | } 36 | 37 | return ( 38 | 39 | {props.children} 40 | 41 | ) 42 | } 43 | 44 | const useThemeType = () => { 45 | const { themeType } = useContext(ThemeTypeContext) 46 | return themeType 47 | } 48 | 49 | export { ThemeType, ThemeTypeContext, ThemeTypeProvider, useThemeType } 50 | -------------------------------------------------------------------------------- /resources/src/env.d.ts: -------------------------------------------------------------------------------- 1 | // vite-env.d.ts 2 | declare const __COMMIT__HASH__: { tag: string; commit: string } 3 | -------------------------------------------------------------------------------- /resources/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { App } from './App' 2 | import { createRoot } from 'react-dom/client' 3 | 4 | // Learn more about TypeScript + React: 5 | // https://2ality.com/2018/04/type-notation-typescript.html 6 | // https://github.com/typescript-cheatsheets/react#section-1-setup-typescript-with-react 7 | // https://create-react-app.dev/docs/adding-typescript/ 8 | 9 | const root = createRoot(document.getElementById('root')!) 10 | root.render() 11 | -------------------------------------------------------------------------------- /resources/src/tldrPageUrl.tsx: -------------------------------------------------------------------------------- 1 | enum FileAction { 2 | View, 3 | Create, 4 | } 5 | 6 | function tldrPageUrl(action: FileAction, os: string, page: string, language: string) { 7 | const languageSuffix = language === 'en' ? '' : '.' + language 8 | 9 | const baseUrl = 'https://github.com/tldr-pages/tldr' 10 | const folderPath = `/main/pages${languageSuffix}/${os}` 11 | 12 | if (action === FileAction.Create) { 13 | return `${baseUrl}/new${folderPath}?filename=${page}.md` 14 | } 15 | 16 | if (action === FileAction.View) { 17 | return `${baseUrl}/blob${folderPath}/${page}.md` 18 | } 19 | 20 | throw new Error('Unknown FileAction: ' + action) 21 | } 22 | 23 | export { FileAction, tldrPageUrl } 24 | -------------------------------------------------------------------------------- /resources/src/useEscClose.tsx: -------------------------------------------------------------------------------- 1 | import { useKeyPress } from './useKeyPress' 2 | 3 | function useEscClose(visible: boolean, setVisible: (value: boolean) => void) { 4 | useKeyPress( 5 | 'Escape', 6 | visible 7 | ? () => { 8 | setVisible(false) 9 | } 10 | : undefined, 11 | ) 12 | } 13 | 14 | export { useEscClose } 15 | -------------------------------------------------------------------------------- /resources/src/useKeyPress.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react' 2 | 3 | // Inspired by https://usehooks.com/useKeyPress/ 4 | 5 | function useKeyPress(targetKey: string, onPressed?: () => void) { 6 | // Only triggered if the callback is defined and the target key is pressed 7 | const onKeyDown = useCallback( 8 | (event: KeyboardEvent) => { 9 | if (onPressed && event.key === targetKey) { 10 | onPressed() 11 | } 12 | }, 13 | [targetKey, onPressed], 14 | ) 15 | 16 | useEffect(() => { 17 | if (onPressed) { 18 | // Register event listener 19 | window.addEventListener('keydown', onKeyDown) 20 | // Remove event listener on cleanup 21 | return () => { 22 | window.removeEventListener('keydown', onKeyDown) 23 | } 24 | } 25 | }, [onPressed, onKeyDown]) 26 | } 27 | 28 | export { useKeyPress } 29 | -------------------------------------------------------------------------------- /resources/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": false, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /resources/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import react from '@vitejs/plugin-react' 3 | 4 | // Get tag & hash of the latest commit (https://stackoverflow.com/a/70558835) 5 | const commitTag = require('child_process').execSync('git tag --points-at HEAD').toString().trim() 6 | 7 | const commitHash = require('child_process').execSync('git rev-parse --short HEAD').toString().trim() 8 | 9 | export default defineConfig(() => { 10 | return { 11 | define: { 12 | __COMMIT__HASH__: JSON.stringify({ tag: commitTag, commit: commitHash }), 13 | }, 14 | build: { 15 | outDir: 'build', 16 | }, 17 | plugins: [react()], 18 | } 19 | }) 20 | --------------------------------------------------------------------------------