├── .all-contributorsrc ├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ ├── dependencies.yml │ ├── spacebadgers.yml │ ├── utils.yml │ └── worker.yml ├── .gitignore ├── .gitmodules ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── badgers-cli ├── Cargo.toml └── src │ └── main.rs ├── badgers-utils ├── Cargo.toml ├── benches │ └── minify.rs └── src │ ├── lib.rs │ └── minify.rs ├── badgers-web ├── .eslintrc.json ├── .gitignore ├── .yarnrc.yml ├── README.md ├── biome.json ├── next.config.js ├── package.json ├── postcss.config.js ├── public │ ├── apple-touch-icon.png │ └── logo.png ├── src │ ├── app │ │ ├── badge │ │ │ └── [...params] │ │ │ │ └── route.ts │ │ ├── codeberg │ │ │ ├── closed-issues │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── issues │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── open-issues │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── release │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ └── stars │ │ │ │ └── [owner] │ │ │ │ └── [repo] │ │ │ │ └── route.ts │ │ ├── crates │ │ │ ├── downloads │ │ │ │ └── [crate] │ │ │ │ │ ├── latest │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ ├── info │ │ │ │ └── [crate] │ │ │ │ │ └── route.ts │ │ │ ├── name │ │ │ │ └── [crate] │ │ │ │ │ └── route.ts │ │ │ └── version │ │ │ │ └── [crate] │ │ │ │ └── route.ts │ │ ├── favicon.ico │ │ ├── github │ │ │ ├── checks │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ ├── [branch] │ │ │ │ │ ├── [check] │ │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ ├── closed-issues │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── contributors │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── issues │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── license │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ ├── open-issues │ │ │ │ └── [owner] │ │ │ │ │ └── [repo] │ │ │ │ │ └── route.ts │ │ │ └── release │ │ │ │ └── [owner] │ │ │ │ └── [repo] │ │ │ │ └── route.ts │ │ ├── globals.css │ │ ├── icons │ │ │ ├── IconShelf.tsx │ │ │ ├── IconUrlBanner.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── logo.png │ │ ├── npm │ │ │ ├── license │ │ │ │ └── [org_or_pkg] │ │ │ │ │ ├── [pkg] │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ ├── name │ │ │ │ └── [org_or_pkg] │ │ │ │ │ ├── [pkg] │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ ├── types │ │ │ │ └── [org_or_pkg] │ │ │ │ │ ├── [pkg] │ │ │ │ │ └── route.ts │ │ │ │ │ └── route.ts │ │ │ └── version │ │ │ │ └── [org_or_pkg] │ │ │ │ ├── [pkg] │ │ │ │ └── route.ts │ │ │ │ └── route.ts │ │ ├── opengraph-image.png │ │ ├── page.tsx │ │ └── pypi │ │ │ ├── info │ │ │ └── [pkg] │ │ │ │ └── route.ts │ │ │ ├── license │ │ │ └── [pkg] │ │ │ │ └── route.ts │ │ │ ├── name │ │ │ └── [pkg] │ │ │ │ └── route.ts │ │ │ └── version │ │ │ └── [pkg] │ │ │ └── route.ts │ ├── components │ │ ├── BadgeEndpoint.tsx │ │ ├── Path.tsx │ │ ├── Section.tsx │ │ ├── StaticBadge.tsx │ │ ├── ThemeStrip.tsx │ │ └── index.ts │ └── utils │ │ ├── Badge.ts │ │ ├── Codeberg.ts │ │ ├── Crates.ts │ │ ├── GitHub.ts │ │ ├── Npm.ts │ │ └── PyPI.ts ├── tailwind.config.js ├── tsconfig.json └── yarn.lock ├── badgers-worker ├── .gitignore ├── .yarnrc.yml ├── Cargo.toml ├── package.json ├── src │ ├── icon.rs │ ├── lib.rs │ └── utils.rs ├── wrangler.toml └── yarn.lock ├── badgers ├── Cargo.lock ├── Cargo.toml ├── README.md ├── benches │ └── badge.rs ├── build.rs ├── src │ ├── badge.rs │ ├── badge_builder.rs │ ├── color_palette.rs │ ├── icons.rs │ ├── icons │ │ ├── cssgg_icons.rs │ │ ├── eva_icons_fill.rs │ │ ├── eva_icons_outline.rs │ │ ├── feather_icons.rs │ │ └── icon_set.rs │ ├── lib.rs │ ├── snapshots │ │ ├── spacebadgers__badge__tests__colored_badge.snap │ │ ├── spacebadgers__badge__tests__colored_scaled_badge.snap │ │ ├── spacebadgers__badge__tests__default_badge.snap │ │ ├── spacebadgers__badge__tests__default_scaled_badge.snap │ │ ├── spacebadgers__badge__tests__icon_badge.snap │ │ └── spacebadgers__badge__tests__rounded_badge.snap │ ├── util.rs │ └── width.rs └── vendor │ └── .gitkeep ├── deny.toml └── tools ├── data └── verdana110.json └── verdana110.js /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "spacebadgers", 3 | "projectOwner": "SplittyDev", 4 | "linkToUsage": true, 5 | "skipCi": true, 6 | "files": [ 7 | "README.md" 8 | ], 9 | "commitType": "docs", 10 | "commitConvention": "angular", 11 | "contributorsPerLine": 7, 12 | "contributors": [ 13 | { 14 | "login": "rossmacarthur", 15 | "name": "Ross MacArthur", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/17109887?v=4", 17 | "profile": "https://github.com/rossmacarthur", 18 | "contributions": [ 19 | "code" 20 | ] 21 | }, 22 | { 23 | "login": "tranzystorekk", 24 | "name": "Marcin Puc", 25 | "avatar_url": "https://avatars.githubusercontent.com/u/5671049?v=4", 26 | "profile": "https://tranzystorekk.codeberg.page", 27 | "contributions": [ 28 | "code" 29 | ] 30 | }, 31 | { 32 | "login": "maltfield", 33 | "name": "Michael Altfield", 34 | "avatar_url": "https://avatars.githubusercontent.com/u/5026712?v=4", 35 | "profile": "https://www.michaelaltfield.net", 36 | "contributions": [ 37 | "bug", 38 | "ideas" 39 | ] 40 | } 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | tab_width = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.yml] 13 | tab_width = 2 14 | indent_style = space 15 | 16 | [*.md] 17 | trim_trailing_whitespace = false 18 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [splittydev] 4 | -------------------------------------------------------------------------------- /.github/workflows/dependencies.yml: -------------------------------------------------------------------------------- 1 | name: deps 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | cargo-deny: 11 | runs-on: [ubuntu-latest] 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: EmbarkStudios/cargo-deny-action@v2 15 | -------------------------------------------------------------------------------- /.github/workflows/spacebadgers.yml: -------------------------------------------------------------------------------- 1 | name: spacebadgers 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | CARGO_INCREMENTAL: 0 12 | CARGO_NET_RETRY: 10 13 | RUSTUP_MAX_RETRIES: 10 14 | RUST_BACKTRACE: short 15 | RUSTDOCFLAGS: -Dwarnings 16 | 17 | jobs: 18 | test: 19 | runs-on: [ubuntu-latest] 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | - run: rustup toolchain install stable --profile default 25 | - name: cargo test 26 | run: cargo test --release -p spacebadgers 27 | - name: cargo test (doctests) 28 | run: cargo test --release --doc -p spacebadgers 29 | - name: cargo clippy 30 | run: cargo clippy -p spacebadgers 31 | - name: rustfmt 32 | run: cargo fmt -p spacebadgers --check 33 | -------------------------------------------------------------------------------- /.github/workflows/utils.yml: -------------------------------------------------------------------------------- 1 | name: utils 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | CARGO_INCREMENTAL: 0 12 | CARGO_NET_RETRY: 10 13 | RUSTUP_MAX_RETRIES: 10 14 | RUST_BACKTRACE: short 15 | RUSTDOCFLAGS: -Dwarnings 16 | 17 | jobs: 18 | test: 19 | runs-on: [ubuntu-latest] 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | - run: rustup toolchain install stable --profile default 25 | - name: cargo test 26 | run: cargo test --release -p spacebadgers-utils 27 | - name: cargo test (doctests) 28 | run: cargo test --release --doc -p spacebadgers-utils 29 | - name: cargo clippy 30 | run: cargo clippy -p spacebadgers-utils 31 | - name: rustfmt 32 | run: cargo fmt -p spacebadgers-utils --check 33 | -------------------------------------------------------------------------------- /.github/workflows/worker.yml: -------------------------------------------------------------------------------- 1 | name: worker 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | CARGO_INCREMENTAL: 0 12 | CARGO_NET_RETRY: 10 13 | RUSTUP_MAX_RETRIES: 10 14 | RUST_BACKTRACE: short 15 | RUSTDOCFLAGS: -Dwarnings 16 | 17 | jobs: 18 | test: 19 | runs-on: [ubuntu-latest] 20 | steps: 21 | - uses: actions/checkout@v4 22 | with: 23 | submodules: true 24 | - run: rustup toolchain install stable --profile default 25 | - name: cargo test 26 | run: cargo test --release -p badgers-worker 27 | - name: cargo test (doctests) 28 | run: cargo test --release --doc -p badgers-worker 29 | - name: cargo clippy 30 | run: cargo clippy -p badgers-worker 31 | - name: rustfmt 32 | run: cargo fmt -p badgers-worker --check 33 | deploy: 34 | needs: [test] 35 | if: github.ref == 'refs/heads/main' 36 | runs-on: [ubuntu-latest] 37 | steps: 38 | - uses: actions/checkout@v4 39 | with: 40 | submodules: true 41 | - run: rustup toolchain install stable --profile minimal 42 | - name: Enable corepack 43 | run: corepack enable 44 | - name: Use Node 22 45 | uses: actions/setup-node@v4 46 | with: 47 | node-version: 22 48 | cache: yarn 49 | cache-dependency-path: badgers-worker/yarn.lock 50 | - run: yarn install --immutable 51 | working-directory: ./badgers-worker 52 | - name: Deploy to Cloudflare 53 | run: npx wrangler deploy 54 | working-directory: ./badgers-worker 55 | env: 56 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | .yarn/ 4 | .vscode/ 5 | 6 | **/*.rs.bk 7 | wasm-pack.log 8 | 9 | build/ 10 | target/ 11 | dist/ 12 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "badgers/vendor/feather"] 2 | path = badgers/vendor/feather 3 | url = https://github.com/feathericons/feather.git 4 | [submodule "badgers/vendor/cssgg"] 5 | path = badgers/vendor/cssgg 6 | url = https://github.com/astrit/css.gg.git 7 | [submodule "badgers/vendor/eva"] 8 | path = badgers/vendor/eva 9 | url = https://github.com/akveo/eva-icons.git 10 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "badgers", 4 | "badgers-cli", 5 | "badgers-utils", 6 | "badgers-worker" 7 | ] 8 | resolver = "2" 9 | 10 | [workspace.package] 11 | license = "MIT" 12 | repository = "https://github.com/splittydev/spacebadgers" 13 | homepage = "https://github.com/splittydev/spacebadgers" 14 | authors = ["Marco Quinten "] 15 | 16 | [workspace.dependencies] 17 | spacebadgers = { path = "badgers" } 18 | spacebadgers-utils = { version = "1", path = "badgers-utils" } 19 | 20 | [profile.release.package.badgers-worker] 21 | # Tell `rustc` to optimize for small code size. 22 | opt-level = "s" 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Marco Quinten 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | badgers.space Logo 3 |

4 | 5 | # SpaceBadgers - Fast SVG Badges 6 | 7 | [![](https://badgers.space/badge/live%20instance/badgers.space)](https://badgers.space) 8 | [![](https://badgers.space/github/license/splittydev/spacebadgers)](./LICENSE) 9 | [![](https://badgers.space/badge/crates.io/spacebadgers)](https://crates.io/crates/spacebadgers) 10 | [![](https://badgers.space/github/checks/splittydev/spacebadgers)](https://github.com/splittydev/spacebadgers/actions) 11 | 12 | > Yes, badgers is an ingenious name. It contains the word badge, is similar to [badgen](https://badgen.net) (a popular badge generation service), includes the `-rs` suffix 🦀 and it's an actual word! Badgers are awesome animals. And they're also the mascot of the [University of Wisconsin-Madison](https://en.wikipedia.org/wiki/Wisconsin_Badgers). I don't know why I'm telling you this, I don't even live in the US. But hey, the more you know. 13 | 14 | [Live instance at badgers.space](https://badgers.space) 15 | 16 | ## Project Structure 17 | 18 | - `badgers`: Core spacebadgers library 19 | - `badgers-utils`: Internal utilities for spacebadgers 20 | - `badgers-cli`: CLI for generating SVG badges 21 | - `badgers-worker`: Cloudflare worker 22 | - `badgers-web`: Web frontend for [badgers.space](https://badgers.space) 23 | 24 | ## Service Integrations 25 | 26 | - GitHub 27 | - Codeberg 28 | - crates.io 29 | - npm 30 | - PyPI 31 | 32 | More integrations coming soon! 33 | 34 | ## Why SpaceBadgers? 35 | 36 | The creation of SpaceBadgers was spurred by my experiences and challenges with existing badge generators such as shields.io and badgen.net. These platforms offer excellent services, but I found certain issues that could be improved upon. Here's why I decided to build SpaceBadgers: 37 | 38 | **1. Performance:** Acknowledging past speed concerns with platforms like Shields.io, SpaceBadgers emphasizes high performance and reliability. Our Rust-based core library and Cloudflare worker enable swift, edge-based badge delivery. Smooth integration with third-party services is achieved via NextJS API routes on Vercel, and we continually aim to enhance speed by utilizing the `edge` runtime. 39 | 40 | **2. Stability:** Badgen.net was created as a faster alternative to shields.io, but due to its lack of active maintenance, it often breaks, leading to broken images. SpaceBadgers is actively maintained and is committed to ensuring stability and uptime. 41 | 42 | **3. SVG Exclusivity:** In contrast to Shields.io, which produces images, SpaceBadgers dedicates itself solely to the generation of SVGs. Recognizing the scalability and high-quality visuals of SVGs, we deemed them the ideal choice. To optimize performance, we serve SVGs in a minified form, ensuring a swift and efficient delivery of badges. 43 | 44 | Building SpaceBadgers has been a labor of love, aiming to offer a superior, reliable, and open-source SVG badge generator for the developer community. I am excited to hear your feedback and to continue evolving this project with your help. Your suggestions, contributions, and active participation are always welcome. 45 | 46 | ## Development 47 | 48 | Clone the repository and make sure to initialize the submodules 49 | 50 | ```bash 51 | git submodule update --init --recursive 52 | ``` 53 | 54 | ### Environment Variables 55 | > Paste this template into `badgers-web/.env.local` 56 | 57 | ```ini 58 | # 59 | # Frontend Configuration 60 | # 61 | 62 | # Worker protocol 63 | NEXT_PUBLIC_API_PROTO=http 64 | 65 | # Worker host 66 | NEXT_PUBLIC_API_HOST=127.0.0.1:8787 67 | 68 | # Web frontend protocol 69 | NEXT_PUBLIC_WEB_PROTO=http 70 | 71 | # Web frontend host 72 | NEXT_PUBLIC_WEB_HOST=127.0.0.1:3000 73 | 74 | # 75 | # API Tokens 76 | # 77 | 78 | # Required for GitHub badges 79 | GITHUB_TOKEN=ghp_Foo1234567 80 | 81 | # Required for crates.io badges 82 | CRATESIO_TOKEN=cio51fdR1234567 83 | 84 | # Required for Codeberg badges 85 | CODEBERG_TOKEN=foobar123456789 86 | ``` 87 | 88 | ### spacebadgers 89 |
90 | Click to expand 91 | 92 | #### Prerequisites 93 | 94 | - cargo 95 | 96 | #### Running tests 97 | 98 | ```bash 99 | cargo test -p spacebadgers 100 | ``` 101 | 102 |
103 | 104 | ### badgers-worker 105 | 106 |
107 | Click to expand 108 | 109 | #### Prerequisites 110 | 111 | - cargo 112 | - yarn (preferred) or npm 113 | 114 | #### Installing dependencies 115 | 116 | ```bash 117 | cd badgers-worker 118 | npm install # If you're using npm 119 | yarn # If you're using yarn 120 | ``` 121 | 122 | #### Running locally 123 | 124 | ```bash 125 | cd badgers-worker 126 | npm run dev # If you're using npm 127 | yarn dev # If you're using yarn 128 | ``` 129 | 130 | #### Deploying to Cloudflare 131 | 132 | ```bash 133 | cd badgers-worker 134 | npm run deploy # If you're using npm 135 | yarn deploy # If you're using yarn 136 | ``` 137 | 138 |
139 | 140 | ### badgers-web 141 | 142 |
143 | Click to expand 144 | 145 | #### Prerequisites 146 | 147 | - yarn (preferred) or npm 148 | 149 | #### Installing dependencies 150 | 151 | ```bash 152 | cd badgers-web 153 | npm install # If you're using npm 154 | yarn # If you're using yarn 155 | ``` 156 | 157 | #### Running locally 158 | 159 | ```bash 160 | cd badgers-web 161 | npm run dev # If you're using npm 162 | yarn dev # If you're using yarn 163 | ``` 164 | 165 |
166 | 167 | ## Contributors 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 187 | 188 | 189 |
Ross MacArthur
Ross MacArthur

💻
Marcin Puc
Marcin Puc

💻
Michael Altfield
Michael Altfield

🐛 🤔
183 | 184 | Add your contributions 185 | 186 |
190 | 191 | 192 | 193 | 194 | 195 | -------------------------------------------------------------------------------- /badgers-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "badgers-cli" 3 | version = "0.2.2" 4 | edition = "2021" 5 | description = "Command-line SVG badge generation" 6 | 7 | license.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | authors.workspace = true 11 | 12 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 13 | 14 | [dependencies] 15 | clap = { version = "4.5", features = ["derive"] } 16 | 17 | [dependencies.spacebadgers] 18 | version = "1" 19 | -------------------------------------------------------------------------------- /badgers-cli/src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, fs::File, io::Write, path::PathBuf}; 2 | 3 | use clap::{CommandFactory, Parser, Subcommand}; 4 | use spacebadgers::{color_palettes, BadgeBuilder, ColorPalette}; 5 | 6 | #[derive(Debug, Subcommand)] 7 | enum Command { 8 | #[clap(name = "badge", about = "Generate a badge")] 9 | Generate { 10 | #[clap(long = "label")] 11 | label: Option, 12 | #[clap(long = "status")] 13 | status: Option, 14 | #[clap(long = "theme", default_value = "badgen")] 15 | theme: String, 16 | #[clap(long = "color")] 17 | color: Option, 18 | #[clap(long = "label-color")] 19 | label_color: Option, 20 | #[clap(long = "scale", default_value = "1.0")] 21 | scale: f32, 22 | #[clap(long = "out")] 23 | output_file: Option, 24 | #[clap(long = "icon")] 25 | icon: Option, 26 | #[clap(long = "icon-width")] 27 | icon_width: Option, 28 | #[clap(long = "corner-radius")] 29 | corner_radius: Option, 30 | }, 31 | } 32 | 33 | #[derive(Debug, Parser)] 34 | #[command(author, version, about, long_about = None)] 35 | struct App { 36 | #[clap(subcommand)] 37 | command: Command, 38 | } 39 | 40 | fn main() { 41 | let app = App::parse(); 42 | 43 | match app.command { 44 | Command::Generate { 45 | label, 46 | status, 47 | theme, 48 | color, 49 | label_color, 50 | scale, 51 | output_file, 52 | icon, 53 | icon_width, 54 | corner_radius, 55 | } => { 56 | // Validate that either label or icon is specified 57 | if label.is_none() && icon.is_none() { 58 | println!("{}", App::command().render_long_help()); 59 | eprintln!("Either --label or --icon must be specified."); 60 | std::process::exit(1); 61 | } 62 | 63 | // Get the color palette 64 | let Some(color_palette) = ColorPalette::from_name(&theme) else { 65 | let themes = color_palettes::ALL 66 | .iter() 67 | .map(|p| p.name()) 68 | .collect::>() 69 | .join(", "); 70 | println!("{}", App::command().render_long_help()); 71 | eprintln!("Invalid theme: {}", theme); 72 | eprintln!("Supported themes: {themes}"); 73 | std::process::exit(1); 74 | }; 75 | 76 | // Build the badge 77 | let svg = BadgeBuilder::new() 78 | .optional_label(label.map(Cow::Owned)) 79 | .optional_status(status.map(Cow::Owned)) 80 | .color_palette(Cow::Borrowed(color_palette)) 81 | .optional_color(color.map(Cow::Owned)) 82 | .optional_label_color(label_color.map(Cow::Owned)) 83 | .optional_icon(icon) 84 | .optional_icon_width(icon_width) 85 | .optional_corner_radius(corner_radius) 86 | .scale(scale) 87 | .build() 88 | .svg(); 89 | 90 | if let Some(path) = output_file { 91 | let mut file = File::options() 92 | .write(true) 93 | .create(true) 94 | .truncate(true) 95 | .open(path) 96 | .expect("Unable to open output file"); 97 | file.write_all(&svg.into_bytes()) 98 | .expect("Unable to write to output file"); 99 | } else { 100 | println!("{svg}"); 101 | } 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /badgers-utils/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spacebadgers-utils" 3 | version = "1.0.1" 4 | edition = "2021" 5 | description = "A collection of utilities for the SpaceBadgers project." 6 | 7 | license.workspace = true 8 | repository.workspace = true 9 | homepage.workspace = true 10 | authors.workspace = true 11 | 12 | [dependencies] 13 | once_cell = "1.21" 14 | 15 | [dependencies.regex] 16 | version = "1.11" 17 | default-features = false 18 | features = ["std", "perf", "unicode-perl"] 19 | 20 | [dev-dependencies] 21 | criterion = { version = "0.5", features = ["html_reports"] } 22 | 23 | [[bench]] 24 | name = "minify" 25 | harness = false 26 | -------------------------------------------------------------------------------- /badgers-utils/benches/minify.rs: -------------------------------------------------------------------------------- 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 2 | 3 | fn criterion_benchmark(c: &mut Criterion) { 4 | let svg = r#" 5 | 16 | 17 | 18 | 19 | 20 | "#; 21 | c.bench_function("minify_svg", |b| { 22 | b.iter(|| spacebadgers_utils::minify::minify_svg(black_box(svg))) 23 | }); 24 | } 25 | 26 | criterion_group!(benches, criterion_benchmark); 27 | criterion_main!(benches); 28 | -------------------------------------------------------------------------------- /badgers-utils/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # SpaceBadgers Utils 2 | //! 3 | //! This crate contains a collection of utilities used by the SpaceBadgers project. 4 | //! It is not intended to be used outside of the project. 5 | 6 | pub mod minify; 7 | -------------------------------------------------------------------------------- /badgers-utils/src/minify.rs: -------------------------------------------------------------------------------- 1 | use once_cell::sync::Lazy; 2 | use regex::Regex; 3 | 4 | static REGEX_MATCH_NEWLINE: Lazy = Lazy::new(|| Regex::new(r"\r?\n").unwrap()); 5 | static REGEX_MATCH_COMMENTS: Lazy = Lazy::new(|| Regex::new(r"(?s)").unwrap()); 6 | static REGEX_MATCH_BETWEEN_TAGS: Lazy = Lazy::new(|| Regex::new(r"(>)(\s+)(<)").unwrap()); 7 | static REGEX_MATCH_TAG_END: Lazy = Lazy::new(|| Regex::new(r"(\s+)(/?>)").unwrap()); 8 | static REGEX_MATCH_START_END_WHITESPACE: Lazy = 9 | Lazy::new(|| Regex::new(r"(?m)^\s+|\s+$").unwrap()); 10 | 11 | /// Minify an SVG string. 12 | pub fn minify_svg(str: impl AsRef) -> String { 13 | let str = str.as_ref(); 14 | let str = REGEX_MATCH_START_END_WHITESPACE.replace_all(str, ""); 15 | let str = REGEX_MATCH_NEWLINE.replace_all(&str, " "); 16 | let str = REGEX_MATCH_COMMENTS.replace_all(&str, ""); 17 | let str = REGEX_MATCH_BETWEEN_TAGS.replace_all(&str, "$1$3"); 18 | let str = REGEX_MATCH_TAG_END.replace_all(&str, "$2"); 19 | str.trim().to_string() 20 | } 21 | 22 | #[cfg(test)] 23 | mod tests { 24 | use super::minify_svg; 25 | 26 | /// Test minification of an SVG icon. 27 | #[test] 28 | fn test_minify_svg_icon() { 29 | let svg = r#" 30 | 41 | 42 | 43 | 44 | 45 | 46 | "#; 47 | let minified = minify_svg(svg); 48 | assert_eq!( 49 | minified, 50 | r#""# 51 | ); 52 | } 53 | 54 | /// Test minification of an SVG badge. 55 | #[test] 56 | fn test_minify_svg_badge() { 57 | let svg = r##" 58 | 59 | foo: bar 60 | 61 | 62 | 63 | 64 | 70 | 71 | "##; 72 | let minified = minify_svg(svg); 73 | assert_eq!( 74 | minified, 75 | r##"foo: bar"## 76 | ) 77 | } 78 | 79 | /// Test minification of an SVG badge with extra whitespace. 80 | /// The whitespace around the text should be preserved. 81 | #[test] 82 | fn test_minify_svg_badge_2() { 83 | let svg = r##" 84 | 85 | foo : bar 86 | 87 | 88 | 89 | 90 | 96 | 97 | "##; 98 | let minified = minify_svg(svg); 99 | assert_eq!( 100 | minified, 101 | r##" foo : bar "## 102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /badgers-web/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /badgers-web/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /badgers-web/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /badgers-web/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | # or 12 | pnpm dev 13 | ``` 14 | 15 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 16 | 17 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 18 | 19 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /badgers-web/biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json", 3 | "organizeImports": { 4 | "enabled": true 5 | }, 6 | "linter": { 7 | "enabled": true, 8 | "rules": { 9 | "recommended": true 10 | } 11 | }, 12 | "formatter": { 13 | "enabled": true, 14 | "indentStyle": "space", 15 | "indentWidth": 4, 16 | "lineEnding": "lf" 17 | }, 18 | "javascript": { 19 | "formatter": { 20 | "semicolons": "asNeeded", 21 | "quoteStyle": "single", 22 | "arrowParentheses": "asNeeded" 23 | } 24 | }, 25 | "vcs": { 26 | "enabled": true, 27 | "clientKind": "git", 28 | "useIgnoreFile": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /badgers-web/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {} 3 | 4 | module.exports = nextConfig 5 | -------------------------------------------------------------------------------- /badgers-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "badgers-web", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@octokit/rest": "^20.1.1", 13 | "@types/node": "20.13.0", 14 | "@types/react": "npm:types-react@19.0.0-rc.1", 15 | "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1", 16 | "@vercel/analytics": "^1.3.1", 17 | "autoprefixer": "10.4.19", 18 | "crates.io": "^2.2.7", 19 | "eslint": "8.57.0", 20 | "eslint-config-next": "15.0.3", 21 | "next": "15.0.3", 22 | "postcss": "8.4.38", 23 | "react": "19.0.0-rc-66855b96-20241106", 24 | "react-dom": "19.0.0-rc-66855b96-20241106", 25 | "tailwindcss": "3.4.3", 26 | "typescript": "5.4.5" 27 | }, 28 | "devDependencies": { 29 | "@octokit/types": "^13.5.0", 30 | "encoding": "^0.1.13", 31 | "sharp": "^0.33.4" 32 | }, 33 | "packageManager": "yarn@4.2.2", 34 | "resolutions": { 35 | "@types/react": "npm:types-react@19.0.0-rc.1", 36 | "@types/react-dom": "npm:types-react-dom@19.0.0-rc.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /badgers-web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /badgers-web/public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SplittyDev/spacebadgers/079cd7d750c2b45f476b55f2010e9f727c539091/badgers-web/public/apple-touch-icon.png -------------------------------------------------------------------------------- /badgers-web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SplittyDev/spacebadgers/079cd7d750c2b45f476b55f2010e9f727c539091/badgers-web/public/logo.png -------------------------------------------------------------------------------- /badgers-web/src/app/badge/[...params]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | 5 | export async function GET(request: NextRequest) { 6 | return await Badge.passThrough(request) 7 | } 8 | -------------------------------------------------------------------------------- /badgers-web/src/app/codeberg/closed-issues/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Codeberg from '@/utils/Codeberg' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const closedIssuesCount = await Codeberg.getClient().getIssuesCount( 22 | { owner, repo }, 23 | { type: 'issues', state: 'closed' }, 24 | ) 25 | 26 | return await Badge.generate( 27 | request, 28 | 'closed issues', 29 | closedIssuesCount?.toString() ?? 'None', 30 | ) 31 | } 32 | 33 | export const runtime = 'edge' 34 | -------------------------------------------------------------------------------- /badgers-web/src/app/codeberg/issues/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Codeberg from '@/utils/Codeberg' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const issuesCount = await Codeberg.getClient().getIssuesCount( 22 | { owner, repo }, 23 | { type: 'issues', state: 'all' }, 24 | ) 25 | 26 | return await Badge.generate( 27 | request, 28 | 'issues', 29 | issuesCount?.toString() ?? 'None', 30 | ) 31 | } 32 | 33 | export const runtime = 'edge' 34 | -------------------------------------------------------------------------------- /badgers-web/src/app/codeberg/open-issues/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Codeberg from '@/utils/Codeberg' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const openIssuesCount = await Codeberg.getClient().getIssuesCount( 22 | { owner, repo }, 23 | { type: 'issues', state: 'open' }, 24 | ) 25 | 26 | return await Badge.generate( 27 | request, 28 | 'open issues', 29 | openIssuesCount?.toString() ?? 'None', 30 | ) 31 | } 32 | 33 | export const runtime = 'edge' 34 | -------------------------------------------------------------------------------- /badgers-web/src/app/codeberg/release/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Codeberg from '@/utils/Codeberg' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const release = await Codeberg.getClient().getLatestRelease({ owner, repo }) 22 | 23 | const shortestName = (() => { 24 | if (release === null) { 25 | return null 26 | } 27 | return [release.tag_name, release.name].reduce((a, b) => 28 | a.length < b.length ? a : b, 29 | ) 30 | })() 31 | 32 | return await Badge.generate(request, 'release', shortestName ?? 'None', { 33 | color: shortestName ? 'blue' : 'yellow', 34 | }) 35 | } 36 | 37 | export const runtime = 'edge' 38 | -------------------------------------------------------------------------------- /badgers-web/src/app/codeberg/stars/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Codeberg from '@/utils/Codeberg' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const repository = await Codeberg.getClient().getRepository({ owner, repo }) 22 | const stargazers = repository?.stars_count 23 | 24 | return await Badge.generate( 25 | request, 26 | 'stars', 27 | stargazers?.toString() ?? 'None', 28 | { 29 | color: stargazers ? 'blue' : 'yellow', 30 | }, 31 | ) 32 | } 33 | 34 | export const runtime = 'edge' 35 | -------------------------------------------------------------------------------- /badgers-web/src/app/crates/downloads/[crate]/latest/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Crates from '@/utils/Crates' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | crate: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | crate 17 | } = params; 18 | 19 | const resp = await Crates.wrapRequest(crates => 20 | crates.api.crates.getVersions(crate), 21 | ) 22 | if (resp === null) return await Badge.error(request, 'crates.io') 23 | const latestVersion = resp.versions 24 | .filter(v => !v.yanked) 25 | .sort( 26 | (a, b) => 27 | new Date(b.updated_at).getTime() - 28 | new Date(a.updated_at).getTime(), 29 | ) 30 | .shift() 31 | if (latestVersion === undefined) 32 | return await Badge.error(request, 'crates.io') 33 | const downloadCount = Intl.NumberFormat('en-US', { 34 | notation: 'compact', 35 | maximumFractionDigits: 1, 36 | }).format(latestVersion.downloads) 37 | return await Badge.generate( 38 | request, 39 | 'downloads', 40 | `${downloadCount} latest version`, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /badgers-web/src/app/crates/downloads/[crate]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Crates from '@/utils/Crates' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | crate: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | crate 17 | } = params; 18 | 19 | const resp = await Crates.wrapRequest(crates => 20 | crates.api.crates.getCrate(crate), 21 | ) 22 | if (resp === null) return await Badge.error(request, 'crates.io') 23 | const downloadCount = Intl.NumberFormat('en-US', { 24 | notation: 'compact', 25 | maximumFractionDigits: 1, 26 | }).format(resp.crate.downloads) 27 | return await Badge.generate(request, 'downloads', downloadCount) 28 | } 29 | -------------------------------------------------------------------------------- /badgers-web/src/app/crates/info/[crate]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Crates from '@/utils/Crates' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | crate: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | crate 17 | } = params; 18 | 19 | const crateResp = await Crates.wrapRequest(crates => 20 | crates.api.crates.getCrate(crate), 21 | ) 22 | if (crateResp === null) return await Badge.error(request, 'crates.io') 23 | const versionsResp = await Crates.wrapRequest(crates => 24 | crates.api.crates.getVersions(crate), 25 | ) 26 | if (versionsResp === null) return await Badge.error(request, 'crates.io') 27 | const latestVersion = versionsResp.versions 28 | .filter(v => !v.yanked) 29 | .sort( 30 | (a, b) => 31 | new Date(b.updated_at).getTime() - 32 | new Date(a.updated_at).getTime(), 33 | ) 34 | .shift() 35 | if (latestVersion === undefined) 36 | return await Badge.error(request, 'crates.io') 37 | return await Badge.generate( 38 | request, 39 | 'crates.io', 40 | `${crateResp.crate.name} v${latestVersion.num}`, 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /badgers-web/src/app/crates/name/[crate]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Crates from '@/utils/Crates' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | crate: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | crate 17 | } = params; 18 | 19 | const crateResp = await Crates.wrapRequest(crates => 20 | crates.api.crates.getCrate(crate), 21 | ) 22 | if (crateResp === null) return await Badge.error(request, 'crates.io') 23 | return await Badge.generate(request, 'crates.io', `${crateResp.crate.name}`) 24 | } 25 | -------------------------------------------------------------------------------- /badgers-web/src/app/crates/version/[crate]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Crates from '@/utils/Crates' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | crate: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | crate 17 | } = params; 18 | 19 | const resp = await Crates.wrapRequest(crates => 20 | crates.api.crates.getVersions(crate), 21 | ) 22 | if (resp === null) return await Badge.error(request, 'crates.io') 23 | const latestVersion = resp.versions 24 | .filter(v => !v.yanked) 25 | .sort( 26 | (a, b) => 27 | new Date(b.updated_at).getTime() - 28 | new Date(a.updated_at).getTime(), 29 | ) 30 | .shift() 31 | if (latestVersion === undefined) 32 | return await Badge.error(request, 'crates.io') 33 | return await Badge.generate(request, 'crates.io', `v${latestVersion.num}`) 34 | } 35 | -------------------------------------------------------------------------------- /badgers-web/src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SplittyDev/spacebadgers/079cd7d750c2b45f476b55f2010e9f727c539091/badgers-web/src/app/favicon.ico -------------------------------------------------------------------------------- /badgers-web/src/app/github/checks/[owner]/[repo]/[branch]/[check]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | branch: string 11 | check: string 12 | }> 13 | } 14 | 15 | export async function GET(request: NextRequest, props: Params) { 16 | const params = await props.params; 17 | 18 | const { 19 | owner, 20 | repo, 21 | branch, 22 | check 23 | } = params; 24 | 25 | // Fetch all checks for latest commit 26 | const allChecksData = await GitHub.wrapRequest(octokit => 27 | octokit.checks.listForRef({ owner, repo, ref: branch }), 28 | ) 29 | 30 | // Get all check results 31 | const lowerCaseCheck = check.toLowerCase() 32 | const checkResults = allChecksData.data?.check_runs 33 | .filter(checkRun => checkRun.name.toLowerCase() === lowerCaseCheck) 34 | .map(checkRun => checkRun.conclusion) 35 | if (checkResults === undefined) return await Badge.error(request, 'github') 36 | if (checkResults.length === 0) return await Badge.error(request, 'github') 37 | 38 | // Combine check results 39 | const combinedConclusion = GitHub.getCombinedCheckConclusion( 40 | checkResults as string[], 41 | ) 42 | 43 | return await Badge.generate(request, check, combinedConclusion.status, { 44 | color: combinedConclusion.color, 45 | }) 46 | } 47 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/checks/[owner]/[repo]/[branch]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | branch: string 11 | }> 12 | } 13 | 14 | export async function GET(request: NextRequest, props: Params) { 15 | const params = await props.params; 16 | 17 | const { 18 | owner, 19 | repo, 20 | branch 21 | } = params; 22 | 23 | // Fetch all checks for latest commit 24 | const allChecksData = await GitHub.wrapRequest(octokit => 25 | octokit.checks.listForRef({ owner, repo, ref: branch }), 26 | ) 27 | 28 | // Get all check results 29 | const checkResults = allChecksData.data?.check_runs.map( 30 | check => check.conclusion, 31 | ) 32 | if (checkResults === undefined) return await Badge.error(request, 'github') 33 | 34 | // Combine check results 35 | const combinedConclusion = GitHub.getCombinedCheckConclusion( 36 | checkResults as string[], 37 | ) 38 | 39 | return await Badge.generate(request, 'checks', combinedConclusion.status, { 40 | color: combinedConclusion.color, 41 | }) 42 | } 43 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/checks/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | // Fetch repo 22 | const repoData = await GitHub.wrapRequest(octokit => 23 | octokit.repos.get({ owner, repo }), 24 | ) 25 | 26 | // Get default branch 27 | const defaultBranch = repoData.data?.default_branch 28 | if (defaultBranch === undefined) return await Badge.error(request, 'github') 29 | 30 | // Fetch all checks for latest commit 31 | const allChecksData = await GitHub.wrapRequest(octokit => 32 | octokit.checks.listForRef({ owner, repo, ref: defaultBranch }), 33 | ) 34 | 35 | // Get all check results 36 | const checkResults = allChecksData.data?.check_runs.map( 37 | check => check.conclusion, 38 | ) 39 | if (checkResults === undefined) return await Badge.error(request, 'github') 40 | 41 | // Combine check results 42 | const combinedConclusion = GitHub.getCombinedCheckConclusion( 43 | checkResults as string[], 44 | ) 45 | 46 | return await Badge.generate(request, 'checks', combinedConclusion.status, { 47 | color: combinedConclusion.color, 48 | }) 49 | } 50 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/closed-issues/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const resp = await GitHub.wrapRequest(octokit => 22 | octokit.issues.listForRepo({ owner, repo, state: 'closed' }), 23 | ) 24 | const issues = resp.data?.filter(issue => issue.pull_request === undefined) 25 | return await Badge.generate( 26 | request, 27 | 'closed issues', 28 | issues?.length?.toString() ?? 'None', 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/contributors/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const resp = await GitHub.wrapRequest(octokit => 22 | octokit.repos.listContributors({ owner, repo }), 23 | ) 24 | if (!resp.data) return await Badge.error(request, 'github') 25 | return await Badge.generate( 26 | request, 27 | 'contributors', 28 | resp.data.length.toString(), 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/issues/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const resp = await GitHub.wrapRequest(octokit => 22 | octokit.issues.listForRepo({ owner, repo, state: 'all' }), 23 | ) 24 | const issues = resp.data?.filter(issue => issue.pull_request === undefined) 25 | return await Badge.generate( 26 | request, 27 | 'issues', 28 | issues?.length?.toString() ?? 'None', 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/license/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const resp = await GitHub.wrapRequest(octokit => 22 | octokit.licenses.getForRepo({ owner, repo }), 23 | ) 24 | const licenseName = resp.data?.license?.spdx_id ?? resp.data?.license?.name 25 | return await Badge.generate(request, 'license', licenseName ?? 'unknown', { 26 | color: licenseName ? 'blue' : 'gray', 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/open-issues/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const resp = await GitHub.wrapRequest(octokit => 22 | octokit.issues.listForRepo({ owner, repo, state: 'open' }), 23 | ) 24 | const issues = resp.data?.filter(issue => issue.pull_request === undefined) 25 | return await Badge.generate( 26 | request, 27 | 'open issues', 28 | issues?.length?.toString() ?? 'None', 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /badgers-web/src/app/github/release/[owner]/[repo]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import GitHub from '@/utils/GitHub' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | owner: string 9 | repo: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | owner, 18 | repo 19 | } = params; 20 | 21 | const resp = await GitHub.wrapRequest(octokit => 22 | octokit.repos.getLatestRelease({ owner, repo }), 23 | ) 24 | return await Badge.generate( 25 | request, 26 | 'release', 27 | resp.data?.tag_name ?? 'None', 28 | { 29 | color: resp.data ? 'blue' : 'yellow', 30 | }, 31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /badgers-web/src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | -------------------------------------------------------------------------------- /badgers-web/src/app/icons/IconShelf.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { Fragment, useMemo, useState } from 'react' 4 | 5 | import type { IconSetList } from './page' 6 | 7 | type Props = { 8 | icons: IconSetList 9 | } 10 | 11 | export default function IconShelf({ icons }: Props) { 12 | const [searchTerm, setSearchTerm] = useState('') 13 | const filteredIcons = useMemo(() => { 14 | if (searchTerm === '') return icons 15 | return icons.map(({ name, icons }) => { 16 | return { 17 | name, 18 | icons: Object.fromEntries( 19 | Object.entries(icons).filter(([key]) => 20 | key.includes(searchTerm), 21 | ), 22 | ), 23 | } 24 | }) 25 | }, [icons, searchTerm]) 26 | 27 | return ( 28 |
29 | 35 | setSearchTerm((e.target as HTMLInputElement).value) 36 | } 37 | /> 38 | {filteredIcons.map(({ name, icons }) => ( 39 |
40 |

{name}

41 |
42 | {Object.keys(icons).length === 0 && ( 43 |
No matches.
44 | )} 45 | {Object.entries(icons).map(([name, data]) => { 46 | return ( 47 | 48 |
49 | {/* eslint-disable-next-line @next/next/no-img-element */} 50 | {name} 56 |
57 |
{name}
58 |
59 | ) 60 | })} 61 |
62 |
63 | ))} 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /badgers-web/src/app/icons/IconUrlBanner.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useEffect, useState } from 'react' 4 | 5 | import Path from '@/components/Path' 6 | 7 | const iconList = [ 8 | 'feather-github', 9 | 'cssgg-add', 10 | 'eva-book', 11 | 'feather-activity', 12 | 'cssgg-alarm', 13 | 'eva-cast', 14 | 'feather-arrow-left', 15 | 'cssgg-anchor', 16 | 'eva-google', 17 | 'feather-git-merge', 18 | 'cssgg-album', 19 | 'eva-activity', 20 | 'feather-plus', 21 | 'cssgg-arrow-up', 22 | 'eva-npm', 23 | 'feather-lock', 24 | 'cssgg-battery', 25 | 'eva-options', 26 | 'feather-mail', 27 | 'cssgg-box', 28 | 'eva-repeat', 29 | 'feather-code', 30 | 'cssgg-browser', 31 | 'eva-shield', 32 | 'feather-key', 33 | 'cssgg-bulb', 34 | 'eva-wifi', 35 | 'feather-globe', 36 | 'cssgg-calendar', 37 | 'eva-twitter', 38 | 'feather-hash', 39 | 'cssgg-carousel', 40 | 'eva-unlock', 41 | 'feather-map', 42 | 'cssgg-key', 43 | 'eva-sync', 44 | 'feather-chrome', 45 | 'cssgg-paypal', 46 | 'eva-trash', 47 | 'feather-box', 48 | 'cssgg-phone', 49 | 'eva-power', 50 | 'feather-bell', 51 | 'cssgg-quote', 52 | 'eva-star', 53 | 'feather-award', 54 | 'cssgg-size', 55 | 'eva-printer', 56 | 'feather-copy', 57 | 'cssgg-smartphone', 58 | 'eva-share', 59 | 'feather-heart', 60 | 'cssgg-tag', 61 | 'eva-pin', 62 | 'feather-command', 63 | 'cssgg-time', 64 | 'eva-person', 65 | 'feather-play', 66 | 'cssgg-usb', 67 | 'eva-percent', 68 | 'feather-mic', 69 | 'cssgg-windows', 70 | 'eva-move', 71 | 'feather-tag', 72 | 'cssgg-youtube', 73 | 'eva-minus', 74 | 'feather-power', 75 | 'cssgg-studio', 76 | 'eva-mic', 77 | 'feather-rewind', 78 | 'cssgg-slack', 79 | 'eva-linkedin', 80 | 'feather-coffee', 81 | 'cssgg-spinner', 82 | 'eva-menu', 83 | 'feather-slack', 84 | 'cssgg-sun', 85 | 'eva-list', 86 | 'feather-table', 87 | 'cssgg-vinyl', 88 | 'eva-at', 89 | ] 90 | 91 | const maxIconLength = iconList.reduce((a, b) => 92 | a.length > b.length ? a : b, 93 | ).length 94 | 95 | export default function IconUrlBanner() { 96 | const [iconIndex, setIconIndex] = useState(0) 97 | 98 | useEffect(() => { 99 | const intervalId = setInterval(() => { 100 | setIconIndex(i => (i + 1) % iconList.length) 101 | }, 1000) 102 | return () => clearInterval(intervalId) 103 | }) 104 | 105 | const icon = iconList[iconIndex].padEnd(maxIconLength, ' ') 106 | const path = `${process.env.NEXT_PUBLIC_WEB_PROTO}://${process.env.NEXT_PUBLIC_WEB_HOST}/badge/:label/:status__query__?icon=${icon}` 107 | 108 | return ( 109 |
110 | 111 |
112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /badgers-web/src/app/icons/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from 'react' 2 | import IconShelf from './IconShelf' 3 | import IconUrlBanner from './IconUrlBanner' 4 | 5 | export type IconSetList = { 6 | name: string 7 | icons: Record 8 | }[] 9 | 10 | async function getIcons() { 11 | const isDevelopment = process.env.NODE_ENV === 'development' 12 | const revalidate = isDevelopment ? 1 : 3600 13 | const endpoint = `${process.env.NEXT_PUBLIC_API_PROTO}://${process.env.NEXT_PUBLIC_API_HOST}/json/icons` 14 | const res = await fetch(endpoint, { next: { revalidate } }) 15 | const data = (await res.json()) as IconSetList 16 | for (const item in data) { 17 | data[item].icons = Object.fromEntries( 18 | Object.entries(data[item].icons).map(([key, value]) => { 19 | return [ 20 | key, 21 | `data:image/svg+xml;base64,${Buffer.from(value).toString( 22 | 'base64', 23 | )}`, 24 | ] 25 | }), 26 | ) 27 | } 28 | return data 29 | } 30 | 31 | export default async function IconsPage() { 32 | const icons = await getIcons() 33 | 34 | return ( 35 |
36 | 37 | 40 | Loading icons... 41 | 42 | } 43 | > 44 | 45 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /badgers-web/src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google' 2 | import Image from 'next/image' 3 | import { Analytics } from '@vercel/analytics/react' 4 | 5 | import Logo from './logo.png' 6 | 7 | import './globals.css' 8 | import Link from 'next/link' 9 | import type { Metadata, Viewport } from 'next' 10 | 11 | const inter = Inter({ subsets: ['latin'] }) 12 | 13 | const metadataBase = (() => { 14 | const proto = process.env.NEXT_PUBLIC_WEB_PROTO 15 | const host = process.env.NEXT_PUBLIC_WEB_HOST 16 | if (!proto || !host) { 17 | throw new Error( 18 | 'Missing environment variables: NEXT_PUBLIC_WEB_PROTO, NEXT_PUBLIC_WEB_HOST', 19 | ) 20 | } 21 | return new URL(`${proto}://${host}`) 22 | })() 23 | 24 | export const metadata: Metadata = { 25 | title: 'SpaceBadgers', 26 | applicationName: 'SpaceBadgers', 27 | description: 'Fast and clean SVG badges', 28 | keywords: [ 29 | 'badge', 30 | 'badges', 31 | 'badgers', 32 | 'spacebadgers', 33 | 'badge-generator', 34 | 'svg', 35 | ], 36 | authors: [{ name: 'Marco Quinten', url: 'https://github.com/splittydev' }], 37 | creator: 'Marco Quinten', 38 | metadataBase, 39 | } 40 | 41 | export const viewport: Viewport = { 42 | colorScheme: 'light', 43 | } 44 | 45 | export default function RootLayout({ 46 | children, 47 | }: { 48 | children: React.ReactNode 49 | }) { 50 | return ( 51 | 52 | 55 |
56 |
57 | 58 |
59 | badgers.space Logo 67 |
68 |

69 | Badge 70 | rs 71 |

72 | 73 |
74 | Fast and clean SVG badges for your projects 75 |
76 |
77 |
78 | {children} 79 |
80 | {/* Created by and link section */} 81 |
82 |
83 | 84 | Made with{' '} 85 | {' '} 86 | by{' '} 87 | 93 | Marco Quinten 94 | 95 | . 96 | 97 |
98 | 108 |
109 | 110 | {/* Informational Text */} 111 |
112 |

113 | About SpaceBadgers 114 |

115 |

116 | Hey there, welcome to SpaceBadgers! If 117 | you're wondering who we are and what we 118 | do, let us fill you in. We're an 119 | open-source project with a passion for 120 | delivering top-notch SVG badges that are as 121 | speedy as they are stylish. 122 |

123 |

124 | You see, we noticed that developers and 125 | project maintainers like you needed a way to 126 | display key information – think build 127 | status, version, download counts – in 128 | a way that's quick and easy to 129 | integrate. That's where we come in. 130 |

131 |

132 | Under the hood, we're powered by a 133 | Rust-based core library{' '} 134 | 140 | (crates.io) 141 | {' '} 142 | and a Cloudflare worker. This dynamic duo 143 | ensures that we're always delivering 144 | badges with excellent performance, straight 145 | from the edge. And speaking of integration, 146 | we've made it our mission to ensure 147 | smooth sailing when it comes to working with 148 | third-party services, courtesy of our 149 | NextJS-based API routes running on Vercel 150 | edge infrastructure. 151 |

152 |

153 | What makes us different? We're glad you 154 | asked! Unlike traditional image-based 155 | badges, we're all about SVGs. 156 | They're scalable, high-quality, and we 157 | serve them in a minified and compressed form 158 | for optimal delivery. 159 |

160 |

161 | So that's us, SpaceBadgers, in a 162 | nutshell. We're here to provide a 163 | seamless, efficient badge generation 164 | process. If that sounds like your cup of 165 | tea, why not join the open-source community 166 | using SpaceBadgers today? We'd be 167 | thrilled to have you onboard! 168 |

169 |

170 | For more information, take a look at our{' '} 171 | 177 | GitHub 178 | 179 | ! 180 |

181 |
182 |
183 |
184 |
185 | 186 | 187 | 188 | ) 189 | } 190 | -------------------------------------------------------------------------------- /badgers-web/src/app/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SplittyDev/spacebadgers/079cd7d750c2b45f476b55f2010e9f727c539091/badgers-web/src/app/logo.png -------------------------------------------------------------------------------- /badgers-web/src/app/npm/license/[org_or_pkg]/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | pkg: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | org_or_pkg: org, 18 | pkg 19 | } = params; 20 | 21 | const data = await Npm.getPackageVersion(`${org}/${pkg}`, 'latest') 22 | if (data === null) return await Badge.error(request, 'npm') 23 | return await Badge.generate(request, 'license', data.license) 24 | } 25 | 26 | export const runtime = 'edge' 27 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/license/[org_or_pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | org_or_pkg: pkg 17 | } = params; 18 | 19 | const data = await Npm.getPackageVersion(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'npm') 21 | return await Badge.generate(request, 'license', data.license) 22 | } 23 | 24 | export const runtime = 'edge' 25 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/name/[org_or_pkg]/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | pkg: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | org_or_pkg: org, 18 | pkg 19 | } = params; 20 | 21 | const data = await Npm.getPackageVersion(`${org}/${pkg}`, 'latest') 22 | if (data === null) return await Badge.error(request, 'npm') 23 | return await Badge.generate(request, 'npm', data.name) 24 | } 25 | 26 | export const runtime = 'edge' 27 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/name/[org_or_pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | org_or_pkg: pkg 17 | } = params; 18 | 19 | const data = await Npm.getPackageVersion(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'npm') 21 | return await Badge.generate(request, 'npm', data.name) 22 | } 23 | 24 | export const runtime = 'edge' 25 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/types/[org_or_pkg]/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | pkg: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | org_or_pkg: org, 18 | pkg 19 | } = params; 20 | 21 | const data = await Npm.getPackageVersion(`${org}/${pkg}`, 'latest') 22 | if (data === null) return await Badge.error(request, 'npm') 23 | const getTypesText = async (): Promise<'included' | 'missing' | string> => { 24 | if (data.types) return 'included' 25 | const typesPackage = await Npm.getTypesPackage(`${org}/${pkg}`) 26 | return typesPackage ?? 'missing' 27 | } 28 | const getTypesColor = (types: string): string => { 29 | if (types === 'included') return 'blue' 30 | if (types === 'missing') return 'orange' 31 | return 'cyan' 32 | } 33 | const typesText = await getTypesText() 34 | const typesColor = getTypesColor(typesText) 35 | return await Badge.generate(request, 'types', typesText, { 36 | color: typesColor, 37 | }) 38 | } 39 | 40 | export const runtime = 'edge' 41 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/types/[org_or_pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | org_or_pkg: pkg 17 | } = params; 18 | 19 | const data = await Npm.getPackageVersion(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'npm') 21 | const getTypesText = async (): Promise<'included' | 'missing' | string> => { 22 | if (data.types) return 'included' 23 | const typesPackage = await Npm.getTypesPackage(pkg) 24 | return typesPackage ?? 'missing' 25 | } 26 | const getTypesColor = (types: string): string => { 27 | if (types === 'included') return 'blue' 28 | if (types === 'missing') return 'orange' 29 | return 'cyan' 30 | } 31 | const typesText = await getTypesText() 32 | const typesColor = getTypesColor(typesText) 33 | return await Badge.generate(request, 'types', typesText, { 34 | color: typesColor, 35 | }) 36 | } 37 | 38 | export const runtime = 'edge' 39 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/version/[org_or_pkg]/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | pkg: string 10 | }> 11 | } 12 | 13 | export async function GET(request: NextRequest, props: Params) { 14 | const params = await props.params; 15 | 16 | const { 17 | org_or_pkg: org, 18 | pkg 19 | } = params; 20 | 21 | const data = await Npm.getPackageVersion(`${org}/${pkg}`, 'latest') 22 | if (data === null) return await Badge.error(request, 'npm') 23 | return await Badge.generate(request, 'npm', `v${data.version}`) 24 | } 25 | -------------------------------------------------------------------------------- /badgers-web/src/app/npm/version/[org_or_pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import Npm from '@/utils/Npm' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | org_or_pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | org_or_pkg: pkg 17 | } = params; 18 | 19 | const data = await Npm.getPackageVersion(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'npm') 21 | return await Badge.generate(request, 'npm', `v${data.version}`) 22 | } 23 | -------------------------------------------------------------------------------- /badgers-web/src/app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SplittyDev/spacebadgers/079cd7d750c2b45f476b55f2010e9f727c539091/badgers-web/src/app/opengraph-image.png -------------------------------------------------------------------------------- /badgers-web/src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { StaticBadge, Section, ThemeStrip, Path } from '@/components' 2 | import { BadgeEndpointRow as Row } from '@/components' 3 | import Link from 'next/link' 4 | 5 | const ApiParams = [ 6 | { 7 | name: 'color', 8 | description: 'Override status color', 9 | extra: ['named color', 'hex'], 10 | }, 11 | { 12 | name: 'label_color', 13 | description: 'Override label color', 14 | extra: ['named color', 'hex'], 15 | }, 16 | { 17 | name: 'label', 18 | description: 'Override label text', 19 | extra: ['string'], 20 | }, 21 | { 22 | name: 'scale', 23 | description: 'Set badge scale', 24 | extra: ['default: 1'], 25 | }, 26 | { 27 | name: 'theme', 28 | description: 'Set color theme', 29 | extra: ['default: honey'], 30 | }, 31 | { 32 | name: 'icon', 33 | description: 'Set label icon', 34 | extra: ['named icon', 'image url'], 35 | }, 36 | { 37 | name: 'icon_width', 38 | description: 'Set icon width', 39 | extra: ['number'], 40 | }, 41 | { 42 | name: 'corner_radius', 43 | description: 'Set corner radius', 44 | extra: ['s', 'm', 'l', 'number'], 45 | }, 46 | { 47 | name: 'cache', 48 | description: 'Set cache duration', 49 | extra: ['min: 300', 'default: 3600'], 50 | }, 51 | ] 52 | 53 | export default function Home() { 54 | const path = `${process.env.NEXT_PUBLIC_WEB_PROTO}://${process.env.NEXT_PUBLIC_WEB_HOST}/badge/:label/:status/:color` 55 | 56 | return ( 57 |
58 | {/* API URL */} 59 |
60 | 61 |
62 | 63 | {/* Named Colors */} 64 |
65 |

66 | Colors 67 |

68 |
69 |
70 | 71 | 72 | 77 | 82 | 87 |
88 |
89 | 90 | 91 | 96 | 97 | 102 |
103 |
104 |
105 | We also support{' '} 106 | 107 | hex annotation 108 | {' '} 109 | for custom colors. 110 |
111 |
112 | 113 | {/* Icons */} 114 |
115 |

116 | 900+ Icons 117 |

118 |
119 | 126 | 133 | 140 | 146 | 153 |
154 | 158 | Browse all supported icons here 159 | 160 |
161 | 162 |
163 | {/* Query Parameters */} 164 |
165 |

166 | Query Parameters 167 |

168 |
    169 | {ApiParams.map(({ name, description, extra }) => ( 170 |
  • 174 | 175 | {name} 176 | 177 | 178 | {description} 179 | 180 | {extra && ( 181 |
    182 | {extra.map(item => ( 183 |
    187 | {item} 188 |
    189 | ))} 190 |
    191 | )} 192 |
  • 193 | ))} 194 |
195 |
196 | 197 | {/* Themes */} 198 |
199 |

Themes

200 | {/* 201 | // name: 'monokai', 202 | // palette: [ 203 | // { name: 'black', color: '#272822' }, 204 | // { name: 'default_label', color: '#595b4d' }, 205 | // { name: 'gray', color: '#9ea191' }, 206 | // { name: 'red', color: '#ff616e' }, 207 | // { name: 'yellow', color: '#e5b567' }, 208 | // { name: 'orange', color: '#f73' }, 209 | // { name: 'green', color: '#b4d273' }, 210 | // { name: 'cyan', color: '#78dce8' }, 211 | // { name: 'blue', color: '#6c99e9' }, 212 | // { name: 'pink', color: '#f25fa6' }, 213 | // { name: 'purple', color: '#745af6' }, 214 | // ] 215 | */} 216 | 217 |
218 |
219 | 220 |
221 |

222 | Service Integrations 223 |

224 |
225 |
226 | 231 | 236 | 241 | 246 | 251 | 256 | 266 | 271 | 276 |
277 |
278 |
279 |
280 | 285 | 290 | 295 | 300 | 305 |
306 |
307 |
308 |
309 | 314 | 319 | 324 | 329 | 334 |
335 |
336 |
337 |
338 | 343 | 348 | 353 | 358 | 363 | 368 | 373 | 378 |
379 |
380 |
381 |
382 | 387 | 392 | 397 | 402 |
403 |
404 |
405 |
406 | ) 407 | } 408 | -------------------------------------------------------------------------------- /badgers-web/src/app/pypi/info/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import PyPI from '@/utils/PyPI' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | pkg 17 | } = params; 18 | 19 | const data = await PyPI.getPackage(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'pypi') 21 | return await Badge.generate( 22 | request, 23 | 'pypi', 24 | `${data.name} v${data.version}`, 25 | ) 26 | } 27 | 28 | export const runtime = 'edge' 29 | -------------------------------------------------------------------------------- /badgers-web/src/app/pypi/license/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import PyPI from '@/utils/PyPI' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | pkg 17 | } = params; 18 | 19 | const data = await PyPI.getPackage(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'pypi') 21 | const license = data.license || 'unknown' 22 | const color = data.license ? 'blue' : 'gray' 23 | return await Badge.generate(request, 'license', `${license}`, { color }) 24 | } 25 | 26 | export const runtime = 'edge' 27 | -------------------------------------------------------------------------------- /badgers-web/src/app/pypi/name/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import PyPI from '@/utils/PyPI' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | pkg 17 | } = params; 18 | 19 | const data = await PyPI.getPackage(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'pypi') 21 | return await Badge.generate(request, 'pypi', `${data.name}`) 22 | } 23 | 24 | export const runtime = 'edge' 25 | -------------------------------------------------------------------------------- /badgers-web/src/app/pypi/version/[pkg]/route.ts: -------------------------------------------------------------------------------- 1 | import type { NextRequest } from 'next/server' 2 | 3 | import Badge from '@/utils/Badge' 4 | import PyPI from '@/utils/PyPI' 5 | 6 | interface Params { 7 | params: Promise<{ 8 | pkg: string 9 | }> 10 | } 11 | 12 | export async function GET(request: NextRequest, props: Params) { 13 | const params = await props.params; 14 | 15 | const { 16 | pkg 17 | } = params; 18 | 19 | const data = await PyPI.getPackage(pkg, 'latest') 20 | if (data === null) return await Badge.error(request, 'pypi') 21 | return await Badge.generate(request, 'pypi', `v${data.version}`) 22 | } 23 | 24 | export const runtime = 'edge' 25 | -------------------------------------------------------------------------------- /badgers-web/src/components/BadgeEndpoint.tsx: -------------------------------------------------------------------------------- 1 | import Path from './Path' 2 | 3 | type Props = { 4 | name: string 5 | path: string 6 | inject: string[] 7 | } 8 | 9 | // function useOnScreen(ref: RefObject) { 10 | // const [isIntersecting, setIntersecting] = useState(false) 11 | 12 | // const observer = useMemo(() => new IntersectionObserver( 13 | // ([entry]) => setIntersecting(entry.isIntersecting) 14 | // ), [ref]) 15 | 16 | // useEffect(() => { 17 | // observer.observe(ref.current) 18 | // return () => observer.disconnect() 19 | // }, []) 20 | 21 | // return isIntersecting 22 | // } 23 | 24 | const isDevelopment = process.env.NODE_ENV === 'development' 25 | 26 | export default function BadgeEndpoint({ name, path, inject }: Props) { 27 | const buildUrl = () => { 28 | const proto = process.env.NEXT_PUBLIC_WEB_PROTO 29 | const host = process.env.NEXT_PUBLIC_WEB_HOST 30 | const baseUrl = `${proto}://${host}` 31 | const injectables = path.split('/').filter(part => part.startsWith(':')) 32 | const injectionTable = Object.fromEntries( 33 | injectables.map((part, i) => [part, inject[i]]), 34 | ) 35 | const examplePath = path.replace( 36 | /:[^/]+/g, 37 | match => injectionTable[match], 38 | ) 39 | if (isDevelopment) return `${baseUrl}/${examplePath}?bust=${Date.now()}` 40 | return `${baseUrl}/${examplePath}` 41 | } 42 | 43 | return ( 44 | <> 45 |
46 | {name} 47 |
48 |
49 | 50 |
51 | {/* eslint-disable-next-line @next/next/no-img-element */} 52 | {path} 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /badgers-web/src/components/Path.tsx: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'react' 2 | 3 | type Props = { 4 | value: string 5 | } 6 | 7 | export default function Path({ value }: Props) { 8 | const containsProtocol = /^https?:[/]{2}/m.test(value) 9 | 10 | const getPathColor = ( 11 | path: string, 12 | i: number, 13 | isProtocol: boolean, 14 | isQueryParam: boolean, 15 | ) => { 16 | const staticColors = [ 17 | 'text-slate-800', 18 | 'text-zinc-600', 19 | 'text-stone-600', 20 | ] 21 | const dynamicColors = [ 22 | 'text-emerald-700', 23 | 'text-cyan-700', 24 | 'text-sky-700', 25 | 'text-indigo-700', 26 | 'text-purple-700', 27 | ] 28 | const queryParamColors = ['text-pink-600', 'text-purple-600'] 29 | if (isProtocol) { 30 | return 'text-gray-400' 31 | } 32 | if (isQueryParam) { 33 | return queryParamColors[i % queryParamColors.length] 34 | } 35 | return path.startsWith(':') 36 | ? dynamicColors[i % dynamicColors.length] 37 | : staticColors[i % staticColors.length] 38 | } 39 | 40 | const explodePath = (path: string) => { 41 | const parts = path.replace(/^[/]+/gm, '').split(/\/|&|__query__/) 42 | let staticIndex = 0 43 | let dynamicIndex = 0 44 | let queryParamCount = 0 45 | let isQueryParam = false 46 | return parts.map(value => { 47 | const isProtocol = value.endsWith(':') 48 | const isDynamic = value.startsWith(':') 49 | isQueryParam ||= value.startsWith('?') || value.startsWith('&') 50 | if (isQueryParam) { 51 | queryParamCount += 1 52 | } 53 | return { 54 | value, 55 | className: getPathColor( 56 | value, 57 | isQueryParam 58 | ? queryParamCount - 1 59 | : isDynamic 60 | ? dynamicIndex++ 61 | : staticIndex++, 62 | isProtocol, 63 | isQueryParam, 64 | ), 65 | isQuery: isQueryParam, 66 | renderAmpersand: queryParamCount === 2, 67 | } 68 | }) 69 | } 70 | 71 | const parts = explodePath(value) 72 | 73 | return ( 74 |
75 | {parts.map(({ value, className, isQuery, renderAmpersand }, i) => ( 76 | 77 | {!isQuery && 78 | (!containsProtocol || 79 | (containsProtocol && i !== 0)) &&
/
} 80 | {renderAmpersand &&
&
} 81 |
{value}
82 |
83 | ))} 84 |
85 | ) 86 | } 87 | -------------------------------------------------------------------------------- /badgers-web/src/components/Section.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | name: string 3 | children: React.ReactNode 4 | } 5 | 6 | export default function Section({ name, children }: Props) { 7 | return ( 8 |
9 |

{name}

10 |
{children}
11 |
12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /badgers-web/src/components/StaticBadge.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | label: string 3 | status: string 4 | color?: string 5 | icon?: string 6 | labelOverride?: string 7 | } 8 | 9 | const isDevelopment = process.env.NODE_ENV === 'development' 10 | 11 | // Pre-calculated static badge widths to avoid layout shifts 12 | const widthLut = { 13 | 'color/blue/blue': 70, 14 | 'color/cyan/cyan': 73, 15 | 'color/green/green': 79, 16 | 'color/yellow/yellow': 82, 17 | 'color/orange/orange': 85, 18 | 'color/red/red': 65, 19 | 'color/pink/pink': 70, 20 | 'color/purple/purple': 82, 21 | 'color/gray/gray': 72, 22 | 'color/black/black': 76, 23 | } as Record 24 | 25 | export default function StaticBadge({ 26 | label, 27 | status, 28 | color, 29 | icon, 30 | labelOverride, 31 | }: Props) { 32 | const buildUrl = () => { 33 | const proto = process.env.NEXT_PUBLIC_API_PROTO 34 | const host = process.env.NEXT_PUBLIC_API_HOST 35 | const baseUrl = `${proto}://${host}/badge` 36 | const pathParams = [label, status, color] 37 | const queryParams = [ 38 | icon && `icon=${icon}`, 39 | labelOverride !== undefined && `label=${labelOverride}`, 40 | isDevelopment && `bust=${Date.now()}`, 41 | ] 42 | .filter(Boolean) 43 | .join('&') 44 | return `${baseUrl}/${pathParams 45 | .filter(Boolean) 46 | .join('/') 47 | .replace(/^[/]+/gm, '')}?cache=86400&${queryParams}` 48 | } 49 | 50 | const badgeHash = `${label}/${status}/${color}` 51 | const width = widthLut[badgeHash] || undefined 52 | 53 | return ( 54 |
55 | {/* eslint-disable-next-line @next/next/no-img-element */} 56 | {label} 64 |
65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /badgers-web/src/components/ThemeStrip.tsx: -------------------------------------------------------------------------------- 1 | type Props = { 2 | themes: string[] 3 | } 4 | 5 | const isDevelopment = process.env.NODE_ENV === 'development' 6 | 7 | export default function ThemeStrip({ themes }: Props) { 8 | return ( 9 |
10 | {themes.map(theme => { 11 | const name = theme === 'honey' ? 'honey (default)' : theme 12 | const bust = isDevelopment ? `?bust=${Date.now()}` : '' 13 | const url = `${process.env.NEXT_PUBLIC_API_PROTO}://${process.env.NEXT_PUBLIC_API_HOST}/theme/${theme}${bust}` 14 | return ( 15 |
19 |
{name}
20 | {/* eslint-disable-next-line @next/next/no-img-element */} 21 | {theme} 28 |
29 | ) 30 | })} 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /badgers-web/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as StaticBadge } from './StaticBadge' 2 | export { default as ThemeStrip } from './ThemeStrip' 3 | export { default as BadgeEndpointRow } from './BadgeEndpoint' 4 | export { default as Path } from './Path' 5 | export { default as Section } from './Section' 6 | -------------------------------------------------------------------------------- /badgers-web/src/utils/Badge.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from 'next/server' 2 | 3 | interface BadgeOverrides { 4 | labelColor?: string 5 | color?: string 6 | theme?: string 7 | } 8 | 9 | /** 10 | * Badge generation utility. 11 | * 12 | * Uses the Spacebadgers worker to generate badges. 13 | */ 14 | const Badge = { 15 | /** 16 | * Generate a badge. 17 | * 18 | * @param request The incoming request. 19 | * @param label The badge label. 20 | * @param status The badge status. 21 | * @param overrides Badge overrides. 22 | * @returns The badge response. 23 | */ 24 | async generate( 25 | request: NextRequest, 26 | label: string, 27 | status: string, 28 | overrides: BadgeOverrides = {}, 29 | ): Promise { 30 | // Get API configuration from env 31 | const api = { 32 | proto: process.env.NEXT_PUBLIC_API_PROTO, 33 | host: process.env.NEXT_PUBLIC_API_HOST, 34 | } 35 | 36 | // Build path params 37 | const pathParams = { 38 | label: encodeURIComponent(label), 39 | status: encodeURIComponent(status), 40 | } 41 | 42 | // Build query params 43 | const systemQueryOverrides = overrides 44 | const userQueryOverrides = Object.fromEntries(request.nextUrl.searchParams) 45 | const unifiedQueryOverrides = { 46 | ...systemQueryOverrides, 47 | ...userQueryOverrides, 48 | } 49 | const queryParams = Object.entries(unifiedQueryOverrides) 50 | .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) 51 | .join('&') 52 | 53 | // Fetch the badge from the worker 54 | const resp = await fetch( 55 | `${api.proto}://${api.host}/badge/${pathParams.label}/${pathParams.status}?${queryParams}`, 56 | { 57 | next: { 58 | revalidate: 300, // 5m 59 | }, 60 | }, 61 | ) 62 | const data = await resp.arrayBuffer() 63 | 64 | // Build response headers 65 | const headers = { 66 | 'content-type': 'image/svg+xml', 67 | } as Record 68 | const cacheControl = resp.headers.get('cache-control') 69 | if (cacheControl) { 70 | headers['cache-control'] = cacheControl 71 | } 72 | 73 | // Return the response 74 | return new NextResponse(data, { 75 | status: resp.status, 76 | statusText: resp.statusText, 77 | headers, 78 | }) 79 | }, 80 | 81 | async error( 82 | request: NextRequest, 83 | subsystem: string, 84 | ): Promise { 85 | return await Badge.generate(request, subsystem, 'error', { 86 | color: 'gray', 87 | }) 88 | }, 89 | 90 | async passThrough(request: NextRequest): Promise { 91 | const api = { 92 | proto: process.env.NEXT_PUBLIC_API_PROTO, 93 | host: process.env.NEXT_PUBLIC_API_HOST, 94 | } 95 | const urlPath = request.nextUrl.pathname.replace(/^[/]+/gm, '') 96 | const urlQuery = request.nextUrl.search.replace(/^\?+/gm, '') 97 | const url = `${api.proto}://${api.host}/${urlPath}?${urlQuery}` 98 | const resp = await fetch(url, { next: { revalidate: 300 } }) 99 | const data = await resp.arrayBuffer() 100 | const headers = { 101 | 'content-type': 'image/svg+xml', 102 | } as Record 103 | const cacheControl = resp.headers.get('cache-control') 104 | if (cacheControl) { 105 | headers['cache-control'] = cacheControl 106 | } 107 | return new NextResponse(data, { 108 | status: resp.status, 109 | statusText: resp.statusText, 110 | headers, 111 | }) 112 | }, 113 | } 114 | 115 | export default Badge 116 | -------------------------------------------------------------------------------- /badgers-web/src/utils/Codeberg.ts: -------------------------------------------------------------------------------- 1 | const API_BASE = 'https://codeberg.org/api/v1' 2 | 3 | type ProjectInfo = { 4 | owner: string 5 | repo: string 6 | } 7 | 8 | type Repository = { 9 | id: number 10 | default_branch: string 11 | name: string 12 | forks_count: number 13 | stars_count: number 14 | } 15 | 16 | type Release = { 17 | name: string 18 | tag_name: string 19 | } 20 | 21 | class CodebergClient { 22 | token: string 23 | 24 | constructor(token: string) { 25 | this.token = token 26 | } 27 | 28 | buildUrl( 29 | path: string, 30 | query: Record = {}, 31 | ): string { 32 | const queryArgs = { 33 | ...query, 34 | token: this.token, 35 | } 36 | const queryString = Object.entries(queryArgs) 37 | .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) 38 | .join('&') 39 | 40 | return `${API_BASE}/${path}?${queryString}` 41 | } 42 | 43 | async getRepository({ 44 | owner, 45 | repo, 46 | }: ProjectInfo): Promise { 47 | const repoId = `${owner}/${repo}` 48 | const url = this.buildUrl(`repos/${repoId}`) 49 | const resp = await fetch(url) 50 | 51 | if (resp.status !== 200) return null 52 | return (await resp.json()) as Repository 53 | } 54 | 55 | async getIssuesCount( 56 | { owner, repo }: ProjectInfo, 57 | query: Record = {}, 58 | ): Promise { 59 | const repoId = `${owner}/${repo}` 60 | const url = this.buildUrl(`repos/${repoId}/issues`, query) 61 | const resp = await fetch(url) 62 | 63 | if (resp.status !== 200) return null 64 | const count = resp.headers.get('x-total-count') 65 | return Number(count) 66 | } 67 | 68 | async getLatestRelease({ 69 | owner, 70 | repo, 71 | }: ProjectInfo): Promise { 72 | const repoId = `${owner}/${repo}` 73 | const url = this.buildUrl(`repos/${repoId}/releases/latest`) 74 | const resp = await fetch(url) 75 | 76 | if (resp.status !== 200) return null 77 | return (await resp.json()) as Release 78 | } 79 | } 80 | 81 | const Codeberg = { 82 | getClient(): CodebergClient { 83 | return new CodebergClient(process.env.CODEBERG_TOKEN as string) 84 | }, 85 | } 86 | 87 | export default Codeberg 88 | -------------------------------------------------------------------------------- /badgers-web/src/utils/Crates.ts: -------------------------------------------------------------------------------- 1 | import { CratesIO } from 'crates.io' 2 | 3 | type WrappedCratesRequest = (arg0: CratesIO) => Promise 4 | 5 | const Crates = { 6 | getCratesClient(): CratesIO { 7 | return new CratesIO() 8 | }, 9 | 10 | async wrapRequest(request: WrappedCratesRequest): Promise { 11 | try { 12 | return await request(Crates.getCratesClient()) 13 | } catch (error) { 14 | return null 15 | } 16 | }, 17 | } 18 | 19 | export default Crates 20 | 21 | // Disable Vercel data cache for all requests. 22 | // This is a temporary solution. Once we can serve all routes via fetch, 23 | // we can remove this and use the next revalidate feature. 24 | export const fetchCache = 'force-no-store' 25 | -------------------------------------------------------------------------------- /badgers-web/src/utils/GitHub.ts: -------------------------------------------------------------------------------- 1 | import { Octokit } from '@octokit/rest' 2 | import type { OctokitResponse } from '@octokit/types' 3 | 4 | type WrappedGitHubRequest = (arg0: Octokit) => Promise> 5 | 6 | type GitHubResponse = { 7 | data: T | null 8 | } 9 | 10 | type CombinedCheckResult = { 11 | status: string 12 | color: string 13 | } 14 | 15 | const GitHub = { 16 | /** 17 | * Get an Octokit instance. 18 | * 19 | * @returns An Octokit instance. 20 | */ 21 | getOctokit(): Octokit { 22 | return new Octokit({ auth: process.env.GITHUB_TOKEN }) 23 | }, 24 | 25 | /** 26 | * Wrap a GitHub request in a try-catch block. 27 | * 28 | * @param request The request to wrap. 29 | * @returns The response 30 | */ 31 | async wrapRequest( 32 | request: WrappedGitHubRequest, 33 | ): Promise> { 34 | try { 35 | const { data } = await request(GitHub.getOctokit()) 36 | return { 37 | data, 38 | } 39 | } catch (error) { 40 | return { 41 | data: null, 42 | } 43 | } 44 | }, 45 | 46 | /** 47 | * Reduce an array of check runs to a single check conclusion. 48 | * 49 | * @param checkRuns An array of check runs. 50 | * @returns The combined check conclusion. 51 | */ 52 | getCombinedCheckConclusion(conclusions: string[]): CombinedCheckResult { 53 | const ignoreList = ['neutral', 'cancelled', 'skipped'] 54 | 55 | const shortCircuitMatch = ( 56 | conclusion: string, 57 | ): CombinedCheckResult | undefined => { 58 | if (conclusions.some(c => c === conclusion)) { 59 | return { status: conclusion, color: 'red' } 60 | } 61 | } 62 | 63 | return ( 64 | shortCircuitMatch('failure') ?? 65 | shortCircuitMatch('timed_out') ?? 66 | shortCircuitMatch('action_required') ?? 67 | (conclusions 68 | .filter(conclusion => !ignoreList.includes(conclusion)) 69 | .every(conclusion => conclusion === 'success') 70 | ? { status: 'success', color: 'green' } 71 | : { status: 'unknown', color: 'gray' }) 72 | ) 73 | }, 74 | } 75 | 76 | export default GitHub 77 | 78 | // Disable Vercel data cache for all requests. 79 | // This is a temporary solution. Once we can serve all routes via fetch, 80 | // we can remove this and use the next revalidate feature. 81 | export const fetchCache = 'force-no-store' 82 | -------------------------------------------------------------------------------- /badgers-web/src/utils/Npm.ts: -------------------------------------------------------------------------------- 1 | const BASE_URL = 'https://registry.npmjs.com' 2 | 3 | type VersionIdentifier = string | 'latest' 4 | 5 | type PackageVersion = { 6 | _id: string 7 | name: string 8 | version: string 9 | types?: string 10 | license: string 11 | description?: string 12 | } 13 | 14 | type Package = { 15 | _id: string 16 | _rev: string 17 | name: string 18 | description: string 19 | 'dist-tags': { 20 | latest: string 21 | } & Record 22 | versions: Record 23 | types?: string 24 | time: { 25 | modified: string 26 | created: string 27 | } & Record 28 | license: string 29 | keywords: string[] 30 | } 31 | 32 | const fetchOptions = { 33 | next: { 34 | revalidate: 300, // 5m 35 | }, 36 | } 37 | 38 | const Npm = { 39 | /** 40 | * Use `getPackageVersion` whenever possible. 41 | * 42 | * @param packageName 43 | * @returns 44 | */ 45 | async getPackage(packageName: string): Promise { 46 | const url = `${BASE_URL}/${packageName}` 47 | const response = await fetch(url, fetchOptions) 48 | 49 | if (response.status === 404) { 50 | return null 51 | } 52 | 53 | return (await response.json()) as Package 54 | }, 55 | 56 | /** 57 | * Get the latest version of a package. 58 | * 59 | * @param packageName Package name 60 | * @param version Version or `'latest'` 61 | * @returns The latest version of the package, or `null` if the package does not exist. 62 | * 63 | * @example 64 | * ```ts 65 | * await Npm.getPackageVersion('react', 'latest') 66 | * await Npm.getPackageVersion('@octocat/rest', 'latest') 67 | * ``` 68 | */ 69 | async getPackageVersion( 70 | packageName: string, 71 | version: VersionIdentifier, 72 | ): Promise { 73 | const url = `${BASE_URL}/${packageName}/${version}` 74 | 75 | try { 76 | const response = await fetch(url, fetchOptions) 77 | 78 | if (response.status === 404) { 79 | return null 80 | } 81 | 82 | return (await response.json()) as PackageVersion 83 | } catch { 84 | return null 85 | } 86 | }, 87 | 88 | /** 89 | * Get the corresponding `@types` package for an npm package. 90 | * 91 | * @param packageName Package name 92 | * @returns The corresponding `@types` package, or `null` if the package does not exist. 93 | */ 94 | async getTypesPackage(packageName: string): Promise { 95 | const typesPackage = `@types/${packageName}` 96 | const data = await Npm.getPackageVersion(typesPackage, 'latest') 97 | if (data === null) return null 98 | return typesPackage 99 | }, 100 | } 101 | 102 | export default Npm 103 | -------------------------------------------------------------------------------- /badgers-web/src/utils/PyPI.ts: -------------------------------------------------------------------------------- 1 | // Docs: https://warehouse.pypa.io/api-reference/json.html 2 | 3 | const BASE_URL = 'https://pypi.org/pypi' 4 | 5 | type VersionIdentifier = string | 'latest' 6 | 7 | type Package = { 8 | name: string 9 | version: string 10 | license: string 11 | // ... 12 | } 13 | 14 | const fetchOptions = { 15 | next: { 16 | revalidate: 300, // 5m 17 | }, 18 | } 19 | 20 | const PyPI = { 21 | /** 22 | * Get the package information for the given version. 23 | * 24 | * @param packageName The package name 25 | * @param version The version or `'latest'` 26 | * @returns The package information for the given version, or `null` 27 | * 28 | * @example 29 | * ```ts 30 | * await PyPI.getPackageVersion('requests', 'latest') 31 | * await PyPI.getPackageVersion('numpy', '1.24.3') 32 | * ``` 33 | */ 34 | async getPackage( 35 | packageName: string, 36 | version: VersionIdentifier, 37 | ): Promise { 38 | const url = 39 | version === 'latest' 40 | ? `${BASE_URL}/${packageName}/json` 41 | : `${BASE_URL}/${packageName}/${version}/json` 42 | 43 | try { 44 | const response = await fetch(url, fetchOptions) 45 | 46 | if (response.status === 404) { 47 | return null 48 | } 49 | 50 | const resp = (await response.json()) as { info: Package } 51 | return resp.info 52 | } catch { 53 | return null 54 | } 55 | }, 56 | } 57 | 58 | export default PyPI 59 | -------------------------------------------------------------------------------- /badgers-web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}', 5 | './src/components/**/*.{js,ts,jsx,tsx,mdx}', 6 | './src/app/**/*.{js,ts,jsx,tsx,mdx}', 7 | ], 8 | theme: { 9 | extend: { 10 | backgroundImage: { 11 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 12 | 'gradient-conic': 13 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 14 | }, 15 | }, 16 | }, 17 | plugins: [], 18 | } 19 | -------------------------------------------------------------------------------- /badgers-web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ], 22 | "paths": { 23 | "@/*": ["./src/*"] 24 | } 25 | }, 26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 27 | "exclude": ["node_modules"] 28 | } 29 | -------------------------------------------------------------------------------- /badgers-worker/.gitignore: -------------------------------------------------------------------------------- 1 | .wrangler/ 2 | -------------------------------------------------------------------------------- /badgers-worker/.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /badgers-worker/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "badgers-worker" 3 | version = "1.4.2" 4 | edition = "2021" 5 | description = "Spacebadgers worker" 6 | 7 | authors.workspace = true 8 | license.workspace = true 9 | repository.workspace = true 10 | homepage.workspace = true 11 | 12 | [lib] 13 | crate-type = ["cdylib", "rlib"] 14 | 15 | [features] 16 | default = ["console_error_panic_hook"] 17 | 18 | [dependencies] 19 | urlencoding = "2.1" 20 | base64 = "0.22" 21 | worker = "0.5.0" 22 | cfg-if = "1" 23 | serde = "1" 24 | serde_json = "1" 25 | 26 | # The `console_error_panic_hook` crate provides better debugging of panics by 27 | # logging them with `console.error`. This is great for development, but requires 28 | # all the `std::fmt` and `std::panicking` infrastructure, so isn't great for 29 | # code size when deploying. 30 | console_error_panic_hook = { version = "0.1", optional = true } 31 | 32 | [dependencies.spacebadgers] 33 | workspace = true 34 | features = ["serde"] 35 | -------------------------------------------------------------------------------- /badgers-worker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "version": "1.4.0", 4 | "scripts": { 5 | "deploy": "wrangler deploy", 6 | "dev": "wrangler dev" 7 | }, 8 | "devDependencies": { 9 | "wrangler": "4" 10 | }, 11 | "packageManager": "yarn@4.2.2" 12 | } 13 | -------------------------------------------------------------------------------- /badgers-worker/src/icon.rs: -------------------------------------------------------------------------------- 1 | use base64::Engine; 2 | use worker::{Fetch, Request}; 3 | 4 | pub struct Icon<'a> { 5 | value: &'a str, 6 | } 7 | 8 | impl<'a> Icon<'a> { 9 | pub fn new(value: &'a impl AsRef) -> Self { 10 | Self { 11 | value: value.as_ref(), 12 | } 13 | } 14 | 15 | /// Turn the icon into a data URI. 16 | /// 17 | /// **Note on URL handling** 18 | /// 19 | /// In the case of external URLs, the icon will be downloaded first, and then 20 | /// converted to a data URI. This will introduce additional latency, 21 | /// so it's not generally recommended. 22 | pub async fn get_data(&self) -> Option { 23 | match self.value { 24 | // Handle URLs 25 | v if v.starts_with("http:") || v.starts_with("https:") => self.fetch_as_data().await, 26 | 27 | // Handle data URIs 28 | v if v.starts_with("data:") => Some(v.to_string()), 29 | 30 | // Handle everything else 31 | v => { 32 | // Try to find the icon in the built-in set of named icons 33 | let named_icon = spacebadgers::icons::get_icon_svg(v).map(|svg| { 34 | let engine = base64::engine::general_purpose::STANDARD; 35 | let data = engine.encode(svg); 36 | format!("data:image/svg+xml;base64,{data}") 37 | }); 38 | 39 | // Otherwise, try to download the icon. 40 | // The `value` might be a website without the protocol prefix. 41 | if named_icon.is_none() { 42 | return self.fetch_as_data().await; 43 | } 44 | 45 | named_icon 46 | } 47 | } 48 | } 49 | 50 | /// Download the icon using a `Fetch` request, and convert it to a data URI. 51 | async fn fetch_as_data(&self) -> Option { 52 | let req = Request::new(self.value, worker::Method::Get).ok()?; 53 | let mut res = Fetch::Request(req).send().await.ok()?; 54 | let mime_type = res 55 | .headers() 56 | .get("Content-Type") 57 | .ok()? 58 | .filter(|v| v.starts_with("image"))?; 59 | let raw_data = res.bytes().await.ok()?; 60 | let engine = base64::engine::general_purpose::STANDARD; 61 | let data = engine.encode(raw_data); 62 | Some(format!("data:{mime_type};base64,{data}")) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /badgers-worker/src/lib.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use spacebadgers::{color_palettes, BadgeBuilder, ColorPalette}; 4 | use worker::*; 5 | 6 | mod icon; 7 | mod utils; 8 | 9 | const DEFAULT_CACHE_DURATION: u32 = 300; // 5 minutes 10 | const ERROR_CACHE_DURATION: u32 = 300; // 5 minutes 11 | 12 | #[event(fetch)] 13 | pub async fn main(req: Request, env: Env, _ctx: worker::Context) -> Result { 14 | utils::set_panic_hook(); 15 | 16 | fn get_error_svg() -> String { 17 | BadgeBuilder::new() 18 | .label("badgers") 19 | .status("error") 20 | .color("gray") 21 | .build() 22 | .svg() 23 | } 24 | 25 | fn get_svg_headers(cache_duration: u32) -> Option { 26 | let mut headers = Headers::new(); 27 | headers.set("Encoding", "UTF-8").ok()?; 28 | headers.set("Content-Type", "image/svg+xml").ok()?; 29 | headers 30 | .set( 31 | "Cache-Control", 32 | &format!("public, max-age={cache_duration}, no-transform, must-revalidate"), 33 | ) 34 | .ok()?; 35 | Some(headers) 36 | } 37 | 38 | async fn handle_badge_route(req: Request, ctx: RouteContext<()>) -> worker::Result { 39 | // Get path params 40 | let mut label = ctx.param("label").cloned(); 41 | let mut color = ctx.param("color").cloned(); 42 | let status = ctx.param("status").cloned(); 43 | 44 | // Initialize query params 45 | let mut label_color: Option = None; 46 | let mut scale: Option = None; 47 | let mut theme: &ColorPalette = color_palettes::DEFAULT; 48 | let mut cache = DEFAULT_CACHE_DURATION; 49 | let mut icon: Option = None; 50 | let mut icon_width: Option = None; 51 | let mut corner_radius: Option = None; 52 | 53 | // Parse query params 54 | if let Ok(options) = req.url().as_ref().map(|url| url.query_pairs()) { 55 | for (key, value) in options { 56 | match key.as_ref() { 57 | "label" => label = Some(value.into_owned()), 58 | "label_color" | "labelColor" => label_color = Some(value.into_owned()), 59 | "color" => color = Some(value.into_owned()), 60 | "scale" => scale = value.parse().ok(), 61 | "cache" => cache = value.parse().unwrap_or(cache), 62 | "icon" => icon = Some(value.into_owned()), 63 | "icon_width" | "iconWidth" => icon_width = value.parse().ok(), 64 | "theme" => theme = ColorPalette::from_name_or_default(&value), 65 | "corner_radius" | "cornerRadius" => { 66 | corner_radius = match value.as_ref() { 67 | "s" => Some(2), 68 | "m" => Some(4), 69 | "l" => Some(6), 70 | value => value.parse().ok(), 71 | } 72 | } 73 | _ => (), 74 | } 75 | } 76 | } 77 | 78 | // Url-decode label 79 | let label = match label { 80 | Some(label) => urlencoding::decode(&label) 81 | .unwrap_or_else(|_| label.to_owned().into()) 82 | .into_owned(), 83 | None => return Response::error("Missing label in url path.", 400), 84 | }; 85 | 86 | // Url-decode status 87 | let status = match status { 88 | Some(status) => urlencoding::decode(&status) 89 | .unwrap_or(status.to_owned().into()) 90 | .into_owned(), 91 | None => return Response::error("Missing status in url path.", 400), 92 | }; 93 | 94 | // Fetch icon as base64 95 | let fetched_icon = if let Some(icon) = icon { 96 | icon::Icon::new(&icon).get_data().await 97 | } else { 98 | None 99 | }; 100 | 101 | // Build badge svg 102 | let badge = BadgeBuilder::new() 103 | .label(label) 104 | .status(status) 105 | .optional_color(color) 106 | .optional_label_color(label_color) 107 | .color_palette(Cow::Borrowed(theme)) 108 | .optional_icon(fetched_icon) 109 | .optional_icon_width(icon_width) 110 | .optional_corner_radius(corner_radius) 111 | .scale(scale.unwrap_or(1.0)) 112 | .build() 113 | .svg(); 114 | 115 | // Send response 116 | if let Ok(response) = Response::from_bytes(badge.into_bytes()) { 117 | Ok(response.with_headers(get_svg_headers(cache).unwrap())) 118 | } else if let Ok(response) = Response::from_bytes(get_error_svg().into_bytes()) { 119 | Ok(response.with_headers(get_svg_headers(ERROR_CACHE_DURATION).unwrap())) 120 | } else { 121 | Response::error("Failed to build badge.", 500) 122 | } 123 | } 124 | 125 | async fn handle_theme_route(req: Request, ctx: RouteContext<()>) -> worker::Result { 126 | // Get path params 127 | let name = { 128 | if let Some(name) = ctx.param("name").cloned() { 129 | name 130 | } else { 131 | return Response::error("Missing theme name in url path.", 400); 132 | } 133 | }; 134 | 135 | // Parse query params 136 | let mut rounded = false; 137 | let mut bordered = false; 138 | if let Ok(options) = req.url().as_ref().map(|url| url.query_pairs()) { 139 | for (key, _) in options { 140 | match key.as_ref() { 141 | "rounded" => rounded = true, 142 | "border" => bordered = true, 143 | _ => (), 144 | } 145 | } 146 | } 147 | 148 | let color_palette = ColorPalette::from_name_or_default(&name).svg(rounded, bordered); 149 | Response::from_bytes(color_palette.into_bytes()) 150 | .map(|res| res.with_headers(get_svg_headers(DEFAULT_CACHE_DURATION).unwrap())) 151 | } 152 | 153 | async fn handle_list_icons_route( 154 | _req: Request, 155 | _ctx: RouteContext<()>, 156 | ) -> worker::Result { 157 | let icons = spacebadgers::icons::ALL_ICON_SETS 158 | .iter() 159 | .flat_map(|set| serde_json::to_value(set)) 160 | .collect::>(); 161 | Response::from_json(&icons) 162 | } 163 | 164 | Router::new() 165 | .get_async("/badge/:label/:status/:color", handle_badge_route) 166 | .get_async("/badge/:label/:status", handle_badge_route) 167 | .get_async("/theme/:name", handle_theme_route) 168 | .get_async("/json/icons", handle_list_icons_route) 169 | .run(req, env) 170 | .await 171 | } 172 | -------------------------------------------------------------------------------- /badgers-worker/src/utils.rs: -------------------------------------------------------------------------------- 1 | use cfg_if::cfg_if; 2 | 3 | cfg_if! { 4 | // https://github.com/rustwasm/console_error_panic_hook#readme 5 | if #[cfg(feature = "console_error_panic_hook")] { 6 | extern crate console_error_panic_hook; 7 | pub use self::console_error_panic_hook::set_once as set_panic_hook; 8 | } else { 9 | #[inline] 10 | pub fn set_panic_hook() {} 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /badgers-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "badgers" 2 | main = "build/worker/shim.mjs" 3 | compatibility_date = "2023-05-15" 4 | account_id = "d9006e724848ae413c69d6f52a934063" 5 | 6 | [vars] 7 | WORKERS_RS_VERSION = "0.5.0" 8 | 9 | [build] 10 | command = "cargo install -q worker-build && worker-build" 11 | -------------------------------------------------------------------------------- /badgers/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "badgers" 7 | version = "0.1.0" 8 | -------------------------------------------------------------------------------- /badgers/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "spacebadgers" 3 | version = "1.3.4" 4 | edition = "2021" 5 | description = "Fast SVG badge generator" 6 | build = "build.rs" 7 | 8 | license.workspace = true 9 | repository.workspace = true 10 | homepage.workspace = true 11 | authors.workspace = true 12 | 13 | [features] 14 | serde = ["dep:serde", "phf/serde"] 15 | 16 | [dependencies] 17 | htmlize = "1.0" 18 | indoc = "2.0" 19 | phf = { version = "0.11", features = ["macros"] } 20 | serde = { version = "1", features = ["derive"], optional = true } 21 | spacebadgers-utils.workspace = true 22 | 23 | [dev-dependencies] 24 | criterion = { version = "0.5", features = ["html_reports"] } 25 | insta = "1.42" 26 | 27 | [build-dependencies] 28 | walkdir = "2.5" 29 | indoc = "2.0" 30 | spacebadgers-utils.workspace = true 31 | 32 | [[bench]] 33 | name = "badge" 34 | harness = false 35 | -------------------------------------------------------------------------------- /badgers/README.md: -------------------------------------------------------------------------------- 1 | # Spacebadgers 2 | > Library for generating SVG badges. It powers [badgers.space](https://badgers.space). 3 | 4 | ## Examples 5 | ```rust 6 | use spacebadgers::BadgeBuilder; 7 | 8 | // Generate a badge with the default color palette 9 | let badge_svg = BadgeBuilder::new() 10 | .label("release") 11 | .status("1.0") 12 | .build() 13 | .svg(); 14 | 15 | // Print the SVG code to stdout 16 | println!("{}", badge_svg); 17 | ``` 18 | -------------------------------------------------------------------------------- /badgers/benches/badge.rs: -------------------------------------------------------------------------------- 1 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 2 | 3 | fn criterion_benchmark(c: &mut Criterion) { 4 | c.bench_function("generate_badge", |b| { 5 | b.iter(|| { 6 | spacebadgers::BadgeBuilder::new() 7 | .label(black_box("build")) 8 | .status(black_box("passing")) 9 | .color(black_box("green")) 10 | .build() 11 | .svg() 12 | }) 13 | }); 14 | } 15 | 16 | criterion_group!(benches, criterion_benchmark); 17 | criterion_main!(benches); 18 | -------------------------------------------------------------------------------- /badgers/build.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{read_to_string, File}, 3 | io::Write, 4 | path::Path, 5 | }; 6 | 7 | use indoc::formatdoc; 8 | use spacebadgers_utils::minify::minify_svg; 9 | use walkdir::WalkDir; 10 | 11 | /// Main entry point for the build script. 12 | fn main() { 13 | println!("cargo:rerun-if-changed=vendor/*"); 14 | IconSetCompiler::new() 15 | .compile( 16 | "Feather Icons", 17 | "feather_icons", 18 | "feather", 19 | "vendor/feather/icons", 20 | "vendor/feather/LICENSE", 21 | // Feather icons use `currentColor` for strokes, which doesn't work in our case. 22 | // We embed the code as a base64 data URI, so we need to replace `currentColor`. 23 | Some(|svg: &str| svg.replace("currentColor", "#fff")), 24 | ) 25 | .compile( 26 | "css.gg Icons", 27 | "cssgg_icons", 28 | "cssgg", 29 | "vendor/cssgg/icons/svg", 30 | "vendor/cssgg/LICENSE", 31 | // css.gg icons use `currentColor` for strokes, which doesn't work in our case. 32 | // We embed the code as a base64 data URI, so we need to replace `currentColor`. 33 | Some(|svg: &str| svg.replace("currentColor", "#fff")), 34 | ) 35 | .compile( 36 | "Eva Icons / Filled", 37 | "eva_icons_fill", 38 | "eva", 39 | "vendor/eva/package/icons/fill/svg", 40 | "vendor/eva/LICENSE.txt", 41 | Some(|svg: &str| svg.replace("#231f20", "#fff")), 42 | ) 43 | .compile( 44 | "Eva Icons / Outlined", 45 | "eva_icons_outline", 46 | "eva", 47 | "vendor/eva/package/icons/outline/svg", 48 | "vendor/eva/LICENSE.txt", 49 | Some(|svg: &str| svg.replace("#231f20", "#fff")), 50 | ) 51 | .finalize(); 52 | } 53 | 54 | /// A single icon entry. 55 | /// Used for generating the icon hashmap. 56 | struct Icon { 57 | name: String, 58 | svg: String, 59 | } 60 | 61 | impl Icon { 62 | /// Generate a phf map entry for this icon. 63 | fn line(&self) -> String { 64 | let cleaned_svg = minify_svg(&self.svg); 65 | format!( 66 | r###""{name}" => r##"{svg}"##"###, 67 | name = self.name, 68 | svg = cleaned_svg.trim() 69 | ) 70 | } 71 | } 72 | 73 | /// Basic information about an icon set. 74 | /// Used for generating module declarations and exports. 75 | struct IconSet { 76 | module: String, 77 | export: String, 78 | } 79 | 80 | /// Icon set compiler. 81 | struct IconSetCompiler { 82 | icon_sets: Vec, 83 | } 84 | 85 | impl IconSetCompiler { 86 | /// Create a new icon set compiler. 87 | fn new() -> Self { 88 | Self { 89 | icon_sets: Vec::new(), 90 | } 91 | } 92 | 93 | /// Compile an icon set to a Rust module. 94 | fn compile( 95 | mut self, 96 | name: impl AsRef, 97 | module: impl AsRef, 98 | prefix: impl AsRef, 99 | icon_path: impl AsRef, 100 | license_path: impl AsRef, 101 | post_process: Option String>, 102 | ) -> Self { 103 | let prefix = prefix.as_ref(); 104 | let module = module.as_ref(); 105 | let export = module.to_uppercase().replace([' ', '.'], "_"); 106 | let mut icons = Vec::new(); 107 | 108 | // Read and format the license 109 | let license = read_to_string(&license_path) 110 | .expect(&format!( 111 | "Unable to read license file: {:?}", 112 | license_path.as_ref() 113 | )) 114 | .split("\n") 115 | .map(|line| format!("//! {line}")) 116 | .collect::>() 117 | .join("\n"); 118 | 119 | // Find all SVG files 120 | for entry in WalkDir::new(icon_path).into_iter().filter_map(Result::ok) { 121 | let path = entry.path(); 122 | if path.is_file() && path.extension().map(|e| e == "svg").unwrap_or(false) { 123 | let icon_name = path 124 | .file_stem() 125 | .expect(&format!("Unable to get file stem for file: {:?}", path)) 126 | .to_string_lossy(); 127 | let icon_name = format!("{prefix}-{icon_name}"); 128 | let icon_svg = 129 | read_to_string(path).expect(&format!("Unable to read file: {:?}", path)); 130 | let icon_svg = post_process 131 | .as_ref() 132 | .map(|f| f(&icon_svg)) 133 | .unwrap_or(icon_svg); 134 | icons.push(Icon { 135 | name: icon_name, 136 | svg: icon_svg, 137 | }); 138 | } 139 | } 140 | 141 | // Generate hashmap entries 142 | let hashmap_lines = icons 143 | .into_iter() 144 | .map(|icon| format!(" {line}", line = icon.line())) 145 | .collect::>() 146 | .join(",\n"); 147 | 148 | // Generate code 149 | let code = formatdoc! {r###" 150 | //! THIS FILE IS AUTO-GENERATED BY `build.rs`. 151 | //! DO NOT EDIT THIS FILE DIRECTLY. 152 | //! 153 | //! ## License 154 | //! ```plain,no_run 155 | {license} 156 | //! ``` 157 | 158 | use phf::phf_map; 159 | 160 | use super::IconSet; 161 | 162 | pub const {export}: IconSet = IconSet {{ 163 | name: "{name}", 164 | icons: phf_map! {{ 165 | {hashmap_lines} 166 | }}, 167 | }}; 168 | "###, 169 | name = name.as_ref(), 170 | }; 171 | 172 | // Write to file 173 | File::options() 174 | .write(true) 175 | .create(true) 176 | .truncate(true) 177 | .open(format!("src/icons/{module}.rs")) 178 | .expect(&format!( 179 | "Unable to open/create file: src/icons/{module}.rs" 180 | )) 181 | .write_all(code.trim().as_bytes()) 182 | .expect(&format!("Unable to write to file: src/icons/{module}.rs")); 183 | 184 | // Register for finalization 185 | self.icon_sets.push(IconSet { 186 | module: module.to_string(), 187 | export, 188 | }); 189 | 190 | self 191 | } 192 | 193 | fn finalize(self) { 194 | // Generate module declarations 195 | let modules = self 196 | .icon_sets 197 | .iter() 198 | .map(|set| format!("#[rustfmt::skip]\npub mod {};", set.module)) 199 | .collect::>() 200 | .join("\n"); 201 | 202 | // Generate reexports 203 | let reexports = self 204 | .icon_sets 205 | .iter() 206 | .map(|set| format!("#[rustfmt::skip]\npub use {}::{};", set.module, set.export)) 207 | .collect::>() 208 | .join("\n"); 209 | 210 | // Generate list of all icon sets 211 | let all_icon_sets = self 212 | .icon_sets 213 | .iter() 214 | .map(|set| format!("&{}", set.export)) 215 | .collect::>() 216 | .join(", "); 217 | 218 | // Generate icons.rs 219 | let code = formatdoc! {r###" 220 | //! THIS FILE IS AUTO-GENERATED BY `build.rs`. 221 | //! DO NOT EDIT THIS FILE DIRECTLY. 222 | 223 | pub mod icon_set; 224 | {modules} 225 | 226 | pub use icon_set::IconSet; 227 | {reexports} 228 | 229 | /// All available icon sets. 230 | #[rustfmt::skip] 231 | pub const ALL_ICON_SETS: &[&IconSet] = &[{all_icon_sets}]; 232 | 233 | /// Get the code for a named icon. 234 | pub fn get_icon_svg(name: impl AsRef) -> Option<&'static str> {{ 235 | let name = name.as_ref(); 236 | ALL_ICON_SETS.iter().find_map(|icon_set| icon_set.get(name)) 237 | }} 238 | "###}; 239 | 240 | // Write to file 241 | File::options() 242 | .write(true) 243 | .create(true) 244 | .truncate(true) 245 | .open(format!("src/icons.rs")) 246 | .expect("Unable to open/create file: src/icons.rs") 247 | .write_all(code.as_bytes()) 248 | .expect("Unable to write to file: src/icons.rs"); 249 | } 250 | } 251 | -------------------------------------------------------------------------------- /badgers/src/badge.rs: -------------------------------------------------------------------------------- 1 | use indoc::formatdoc; 2 | use spacebadgers_utils::minify::minify_svg; 3 | use std::borrow::Cow; 4 | 5 | use crate::{util::calculate_width, ColorPalette}; 6 | 7 | /// Badge generator. 8 | pub struct Badge { 9 | pub(crate) color_palette: Cow<'static, ColorPalette>, 10 | pub(crate) status: Cow<'static, str>, 11 | pub(crate) label: Option>, 12 | pub(crate) color: Option>, 13 | pub(crate) label_color: Option>, 14 | pub(crate) icon: Option>, 15 | pub(crate) icon_width: Option, 16 | pub(crate) corner_radius: Option, 17 | pub(crate) scale: f32, 18 | } 19 | 20 | impl Badge { 21 | fn accessible_text(&self) -> String { 22 | let prefix = self 23 | .label 24 | .as_ref() 25 | .map(|label| format!("{label}: ")) 26 | .unwrap_or_default(); 27 | format!("{prefix}{status}", status = self.status) 28 | } 29 | 30 | /// Generate an SVG badge. 31 | pub fn svg(&self) -> String { 32 | let label_padding: f32 = 50.0; 33 | let status_padding: f32 = 50.0; 34 | let text_shadow_offset: f32 = 10.0; 35 | let icon_gap: f32 = 50.0; 36 | 37 | // Calculate corner radius 38 | let corner_radius = self.corner_radius.map(|r| r as f32 * 10.0); 39 | 40 | // Space between icon and text 41 | let actual_icon_gap = { 42 | let label_is_empty = self 43 | .label 44 | .as_ref() 45 | .map(|s| s.chars().count()) 46 | .unwrap_or_default() 47 | .eq(&0); 48 | (self.icon.is_some() && !label_is_empty) 49 | .then_some(icon_gap) 50 | .unwrap_or_default() 51 | }; 52 | 53 | // Calculate icon and text widths 54 | let icon_width = self.icon_width.unwrap_or(13) * 10; 55 | let actual_icon_width = self 56 | .icon 57 | .is_some() 58 | .then_some(icon_width) 59 | .unwrap_or_default(); 60 | let label_text_width = self.label.as_ref().map(calculate_width).unwrap_or_default(); 61 | let status_text_width = calculate_width(self.status.as_ref()); 62 | let label_full_width = label_text_width + actual_icon_width as f32 + actual_icon_gap; 63 | 64 | // Calculate SVG background offsets and widths 65 | let label_rect_width = label_full_width + label_padding * 2.0; 66 | let status_rect_start = label_rect_width; 67 | let status_rect_width = status_text_width + status_padding * 2.0; 68 | 69 | // Calculate SVG text offsets and widths 70 | let icon_start = label_padding; 71 | let label_text_start = actual_icon_width as f32 + actual_icon_gap + label_padding; 72 | let label_text_shadow_start = label_text_start + text_shadow_offset; 73 | let status_text_start = label_rect_width + status_padding; 74 | let status_text_shadow_start = status_text_start + text_shadow_offset; 75 | 76 | // Calculate viewbox and scaled dimensions 77 | let badge_viewbox_width: f32 = label_rect_width + status_rect_width; 78 | let badge_viewbox_height: f32 = 200.0; 79 | let badge_scaled_width: f32 = self.scale * badge_viewbox_width / 10.0; 80 | let badge_scaled_height: f32 = self.scale * badge_viewbox_height / 10.0; 81 | 82 | // Evaluate badge parameters 83 | let color = self 84 | .color 85 | .as_ref() 86 | .and_then(|color| self.color_palette.resolve_color_string(color.as_ref())) 87 | .or_else(|| self.color.clone()) 88 | .unwrap_or_else(|| self.color_palette.default_color().into()); 89 | let label_color = self 90 | .label_color 91 | .as_ref() 92 | .and_then(|color| self.color_palette.resolve_color_string(color.as_ref())) 93 | .or_else(|| self.label_color.clone()) 94 | .unwrap_or_else(|| self.color_palette.default_label_color().into()); 95 | let label = self 96 | .label 97 | .as_ref() 98 | .map(|str| htmlize::escape_text(str.as_ref())) 99 | .unwrap_or_default(); 100 | let status = htmlize::escape_text(self.status.as_ref()); 101 | let accessible_text = self.accessible_text(); 102 | 103 | // Build rounded corner mask 104 | let mask_svg = corner_radius.map(|corner_radius| { 105 | format!(r##""##) 106 | }).unwrap_or_default(); 107 | 108 | // Build icon xlink 109 | let xlink = self 110 | .icon 111 | .is_some() 112 | .then_some(r#" xmlns:xlink="http://www.w3.org/1999/xlink""#) 113 | .unwrap_or_default(); 114 | 115 | // Build icon markup 116 | let icon_markup = self.icon.as_ref().map(|icon| { 117 | format!(r#""#) 118 | }).unwrap_or_default(); 119 | 120 | // Build final svg 121 | minify_svg(formatdoc!( 122 | r##" 123 | 124 | {accessible_text} 125 | {mask_svg} 126 | 127 | 128 | 129 | 130 | 136 | {icon_markup} 137 | 138 | "##, 139 | mask_addon = corner_radius 140 | .is_some() 141 | .then_some(r##" mask="url(#rounded)""##) 142 | .unwrap_or_default(), 143 | )) 144 | } 145 | } 146 | 147 | #[cfg(test)] 148 | mod tests { 149 | use std::borrow::Cow; 150 | 151 | use super::Badge; 152 | use crate::color_palettes; 153 | 154 | #[test] 155 | fn test_default_badge() { 156 | insta::assert_debug_snapshot!(Badge { 157 | color_palette: Cow::Borrowed(&color_palettes::DEFAULT), 158 | status: "foo".into(), 159 | label: Some("bar".into()), 160 | color: None, 161 | label_color: None, 162 | icon: None, 163 | icon_width: None, 164 | corner_radius: None, 165 | scale: 1.0, 166 | } 167 | .svg()); 168 | } 169 | 170 | #[test] 171 | fn test_colored_badge() { 172 | insta::assert_debug_snapshot!(Badge { 173 | color_palette: Cow::Borrowed(&color_palettes::DEFAULT), 174 | status: "passing".into(), 175 | label: Some("checks".into()), 176 | color: Some("green".into()), 177 | label_color: Some("gray".into()), 178 | icon: None, 179 | icon_width: None, 180 | corner_radius: None, 181 | scale: 1.0, 182 | } 183 | .svg()); 184 | } 185 | 186 | #[test] 187 | fn test_default_scaled_badge() { 188 | insta::assert_debug_snapshot!(Badge { 189 | color_palette: Cow::Borrowed(&color_palettes::DEFAULT), 190 | status: "foo".into(), 191 | label: Some("bar".into()), 192 | color: None, 193 | label_color: None, 194 | icon: None, 195 | icon_width: None, 196 | corner_radius: None, 197 | scale: 5.0, 198 | } 199 | .svg()); 200 | } 201 | 202 | #[test] 203 | fn test_colored_scaled_badge() { 204 | insta::assert_debug_snapshot!(Badge { 205 | color_palette: Cow::Borrowed(&color_palettes::DEFAULT), 206 | status: "passing".into(), 207 | label: Some("checks".into()), 208 | color: Some("green".into()), 209 | label_color: Some("gray".into()), 210 | icon: None, 211 | icon_width: None, 212 | corner_radius: None, 213 | scale: 5.0, 214 | } 215 | .svg()); 216 | } 217 | 218 | #[test] 219 | fn test_icon_badge() { 220 | insta::assert_debug_snapshot!(Badge { 221 | color_palette: Cow::Borrowed(&color_palettes::DEFAULT), 222 | status: "Quintschaf".into(), 223 | label: None, 224 | color: None, 225 | label_color: None, 226 | icon: Some("https://quintschaf.com/favicon.ico".into()), 227 | icon_width: None, 228 | corner_radius: None, 229 | scale: 1.0, 230 | } 231 | .svg()); 232 | } 233 | 234 | #[test] 235 | fn test_rounded_badge() { 236 | insta::assert_debug_snapshot!(Badge { 237 | color_palette: Cow::Borrowed(&color_palettes::DEFAULT), 238 | status: "foo".into(), 239 | label: Some("bar".into()), 240 | color: None, 241 | label_color: None, 242 | icon: None, 243 | icon_width: None, 244 | corner_radius: Some(30), 245 | scale: 1.0, 246 | } 247 | .svg()); 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /badgers/src/badge_builder.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | 3 | use crate::{Badge, ColorPalette}; 4 | 5 | /// [Badge] builder. 6 | pub struct BadgeBuilder { 7 | color_palette: Cow<'static, ColorPalette>, 8 | status: Option>, 9 | label: Option>, 10 | color: Option>, 11 | label_color: Option>, 12 | icon: Option>, 13 | icon_width: Option, 14 | corner_radius: Option, 15 | scale: f32, 16 | } 17 | 18 | impl BadgeBuilder { 19 | /// Construct a new [BadgeBuilder] with default values. 20 | pub fn new() -> Self { 21 | let color_palette = Cow::Borrowed(crate::color_palettes::DEFAULT); 22 | let scale = 1.0; 23 | Self { 24 | color_palette, 25 | status: None, 26 | label: None, 27 | color: None, 28 | label_color: None, 29 | icon: None, 30 | icon_width: None, 31 | corner_radius: None, 32 | scale, 33 | } 34 | } 35 | 36 | /// Set the [ColorPalette]. 37 | pub fn color_palette(mut self, color_palette: impl Into>) -> Self { 38 | self.color_palette = color_palette.into(); 39 | self 40 | } 41 | 42 | /// Set the label text. 43 | pub fn label(mut self, label: impl Into>) -> Self { 44 | self.label = Some(label.into()); 45 | self 46 | } 47 | 48 | /// Set an optional label text. 49 | pub fn optional_label(mut self, label: Option>>) -> Self { 50 | self.label = label.map(Into::into); 51 | self 52 | } 53 | 54 | /// Set the status text. 55 | pub fn status(mut self, status: impl Into>) -> Self { 56 | self.status = Some(status.into()); 57 | self 58 | } 59 | 60 | /// Set an optional status text. 61 | pub fn optional_status(mut self, status: Option>>) -> Self { 62 | self.status = status.map(Into::into); 63 | self 64 | } 65 | 66 | /// Set the color. 67 | pub fn color(mut self, color: impl Into>) -> Self { 68 | self.color = Some(color.into()); 69 | self 70 | } 71 | 72 | /// Set an optional color. 73 | pub fn optional_color(mut self, color: Option>>) -> Self { 74 | self.color = color.map(Into::into); 75 | self 76 | } 77 | 78 | /// Set the label color. 79 | pub fn label_color(mut self, label_color: impl Into>) -> Self { 80 | self.label_color = Some(label_color.into()); 81 | self 82 | } 83 | 84 | /// Set an optional label color. 85 | pub fn optional_label_color( 86 | mut self, 87 | label_color: Option>>, 88 | ) -> Self { 89 | self.label_color = label_color.map(Into::into); 90 | self 91 | } 92 | 93 | /// Set the badge scale. 94 | pub fn scale(mut self, scale: f32) -> Self { 95 | self.scale = scale; 96 | self 97 | } 98 | 99 | /// Set the icon. 100 | pub fn icon(mut self, icon: impl Into>) -> Self { 101 | self.icon = Some(icon.into()); 102 | self 103 | } 104 | 105 | /// Set an optional icon. 106 | pub fn optional_icon(mut self, icon: Option>>) -> Self { 107 | self.icon = icon.map(Into::into); 108 | self 109 | } 110 | 111 | /// Set the icon width. 112 | pub fn icon_width(mut self, icon_width: u32) -> Self { 113 | self.icon_width = Some(icon_width); 114 | self 115 | } 116 | 117 | /// Set an optional icon width. 118 | pub fn optional_icon_width(mut self, icon_width: Option) -> Self { 119 | self.icon_width = icon_width; 120 | self 121 | } 122 | 123 | /// Set the corner radius. 124 | pub fn corner_radius(mut self, corner_radius: u32) -> Self { 125 | self.corner_radius = Some(corner_radius); 126 | self 127 | } 128 | 129 | /// Set an optional corner radius. 130 | pub fn optional_corner_radius(mut self, corner_radius: Option) -> Self { 131 | self.corner_radius = corner_radius; 132 | self 133 | } 134 | 135 | /// Build the [Badge]. 136 | pub fn build(self) -> Badge { 137 | Badge { 138 | color_palette: self.color_palette, 139 | status: self.status.unwrap_or_default(), 140 | label: self.label, 141 | color: self.color, 142 | label_color: self.label_color, 143 | icon: self.icon, 144 | icon_width: self.icon_width, 145 | scale: self.scale, 146 | corner_radius: self.corner_radius, 147 | } 148 | } 149 | } 150 | 151 | impl Default for BadgeBuilder { 152 | fn default() -> Self { 153 | Self::new() 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /badgers/src/color_palette.rs: -------------------------------------------------------------------------------- 1 | use indoc::formatdoc; 2 | use spacebadgers_utils::minify::minify_svg; 3 | use std::borrow::Cow; 4 | 5 | /// Badge color palette. 6 | #[derive(Debug, Clone)] 7 | pub struct ColorPalette { 8 | name: &'static str, 9 | default_label: &'static str, 10 | default_status: &'static str, 11 | black: &'static str, 12 | white: &'static str, 13 | gray: &'static str, 14 | red: &'static str, 15 | yellow: &'static str, 16 | orange: &'static str, 17 | green: &'static str, 18 | cyan: &'static str, 19 | blue: &'static str, 20 | pink: &'static str, 21 | purple: &'static str, 22 | } 23 | 24 | impl ColorPalette { 25 | /// Get the name of the color palette. 26 | pub fn name(&self) -> &'static str { 27 | self.name 28 | } 29 | 30 | /// Get the default status background color. 31 | pub fn default_color(&self) -> &'static str { 32 | self.default_status 33 | } 34 | 35 | /// Get the default label background color. 36 | pub fn default_label_color(&self) -> &'static str { 37 | self.default_label 38 | } 39 | 40 | /// Resolve a color string to a color value. Supports named colors and hex colors. 41 | pub fn resolve_color_string(&self, color: &str) -> Option> { 42 | match color { 43 | // Handle supported color names 44 | "black" => Some(self.black.into()), 45 | "white" => Some(self.white.into()), 46 | "gray" | "grey" => Some(self.gray.into()), 47 | "red" => Some(self.red.into()), 48 | "yellow" => Some(self.yellow.into()), 49 | "orange" => Some(self.orange.into()), 50 | "green" => Some(self.green.into()), 51 | "cyan" => Some(self.cyan.into()), 52 | "blue" => Some(self.blue.into()), 53 | "pink" => Some(self.pink.into()), 54 | "purple" => Some(self.purple.into()), 55 | // Handle supported hex colors 56 | c if c.chars().all(|c| c.is_ascii_hexdigit()) => Some(format!("#{c}").into()), 57 | // Handle unsupported color names 58 | _ => None, 59 | } 60 | } 61 | 62 | /// Get a [ColorPalette] by name. 63 | pub fn from_name(name: &str) -> Option<&'static ColorPalette> { 64 | palettes::ALL.iter().find(|p| p.name == name).copied() 65 | } 66 | 67 | /// Get a [ColorPalette] by name. 68 | /// 69 | /// If the desired palette is not found, the default palette is returned. 70 | pub fn from_name_or_default(name: &str) -> &'static ColorPalette { 71 | palettes::ALL 72 | .iter() 73 | .find(|p| p.name == name) 74 | .unwrap_or_else(|| { 75 | eprintln!("Warning: color palette '{name}' not found, using default"); 76 | &palettes::DEFAULT 77 | }) 78 | } 79 | 80 | pub fn colors(&self) -> Vec<&'static str> { 81 | vec![ 82 | self.black, 83 | self.default_label, 84 | self.gray, 85 | self.red, 86 | self.orange, 87 | self.yellow, 88 | self.green, 89 | self.cyan, 90 | self.blue, 91 | self.pink, 92 | self.purple, 93 | ] 94 | } 95 | 96 | /// Generate an SVG color strip. 97 | pub fn svg(&self, rounded: bool, bordered: bool) -> String { 98 | let name = self.name; 99 | let colors = self.colors(); 100 | let rect_width = 200; 101 | let rect_height = 200; 102 | let corner_radius = if rounded { 50 } else { 0 }; 103 | let full_width = rect_width * colors.len(); 104 | let mut segments = Vec::new(); 105 | for (i, color) in colors.iter().enumerate() { 106 | let rect_offset = i * rect_width; 107 | let rect_svg = format!( 108 | r#""#, 109 | ); 110 | segments.push(rect_svg); 111 | } 112 | let segments_svg = segments.join(""); 113 | let viewbox = format!("0 0 {full_width} {rect_height}",); 114 | let output_width = full_width / 10; 115 | let output_height = rect_height / 10; 116 | let mask_svg = format!( 117 | r##""##, 118 | ); 119 | let border_svg = format!( 120 | r##""## 121 | ); 122 | minify_svg(formatdoc! {r##" 123 | 124 | {name}{mask} 125 | 126 | {segments_svg}{border} 127 | 128 | 129 | "##, 130 | mask = if rounded { mask_svg.as_ref() } else { "" }, 131 | mask_addon = if rounded { r##" mask="url(#rounded)""## } else { "" }, 132 | border = if bordered { border_svg.as_ref() } else { "" }, 133 | }) 134 | } 135 | } 136 | 137 | pub mod palettes { 138 | use super::ColorPalette; 139 | 140 | pub const DEFAULT: &ColorPalette = &HONEY; 141 | 142 | /// All available color palettes. 143 | pub const ALL: &[&ColorPalette] = &[&HONEY, &TAILWIND, &BADGEN]; 144 | 145 | /// The same color palette used by [badgen.net](https://badgen.net). 146 | pub const BADGEN: ColorPalette = ColorPalette { 147 | name: "badgen", 148 | default_label: "#555", // dark gray 149 | default_status: "#08c", // blue 150 | black: "#2a2a2a", 151 | white: "#fff", 152 | gray: "#999", 153 | red: "#e43", 154 | yellow: "#db1", 155 | orange: "#f73", 156 | green: "#3c1", 157 | cyan: "#1bc", 158 | blue: "#08c", 159 | pink: "#e5b", 160 | purple: "#94e", 161 | }; 162 | 163 | /// A color palette based on [Tailwind CSS](https://tailwindcss.com) colors. 164 | pub const TAILWIND: ColorPalette = ColorPalette { 165 | name: "tailwind", 166 | default_label: "#334155", 167 | default_status: "#f97316", 168 | black: "#030712", 169 | white: "#f9fafb", 170 | gray: "#9ca3af", 171 | red: "#ef4444", 172 | yellow: "#eab308", 173 | orange: "#f97316", 174 | green: "#22c55e", 175 | cyan: "#06b6d4", 176 | blue: "#3b82f6", 177 | pink: "#ec4899", 178 | purple: "#a855f7", 179 | }; 180 | 181 | /// Spacebadger's own hand-picked color palette. 182 | pub const HONEY: ColorPalette = ColorPalette { 183 | name: "honey", 184 | default_label: "#4a414e", 185 | default_status: "#3373cc", 186 | black: "#030712", 187 | white: "#f9f6f7", 188 | gray: "#8f8494", 189 | red: "#dd3c4f", 190 | yellow: "#dfb920", 191 | orange: "#ee6f2b", 192 | green: "#1bbb40", 193 | cyan: "#12b4bf", 194 | blue: "#3373cc", 195 | pink: "#c39", 196 | purple: "#943ae9", 197 | }; 198 | } 199 | -------------------------------------------------------------------------------- /badgers/src/icons.rs: -------------------------------------------------------------------------------- 1 | //! THIS FILE IS AUTO-GENERATED BY `build.rs`. 2 | //! DO NOT EDIT THIS FILE DIRECTLY. 3 | 4 | pub mod icon_set; 5 | #[rustfmt::skip] 6 | pub mod feather_icons; 7 | #[rustfmt::skip] 8 | pub mod cssgg_icons; 9 | #[rustfmt::skip] 10 | pub mod eva_icons_fill; 11 | #[rustfmt::skip] 12 | pub mod eva_icons_outline; 13 | 14 | pub use icon_set::IconSet; 15 | #[rustfmt::skip] 16 | pub use feather_icons::FEATHER_ICONS; 17 | #[rustfmt::skip] 18 | pub use cssgg_icons::CSSGG_ICONS; 19 | #[rustfmt::skip] 20 | pub use eva_icons_fill::EVA_ICONS_FILL; 21 | #[rustfmt::skip] 22 | pub use eva_icons_outline::EVA_ICONS_OUTLINE; 23 | 24 | /// All available icon sets. 25 | #[rustfmt::skip] 26 | pub const ALL_ICON_SETS: &[&IconSet] = &[&FEATHER_ICONS, &CSSGG_ICONS, &EVA_ICONS_FILL, &EVA_ICONS_OUTLINE]; 27 | 28 | /// Get the code for a named icon. 29 | pub fn get_icon_svg(name: impl AsRef) -> Option<&'static str> { 30 | let name = name.as_ref(); 31 | ALL_ICON_SETS.iter().find_map(|icon_set| icon_set.get(name)) 32 | } 33 | -------------------------------------------------------------------------------- /badgers/src/icons/icon_set.rs: -------------------------------------------------------------------------------- 1 | use phf::Map; 2 | 3 | #[derive(Debug)] 4 | #[cfg_attr(feature = "serde", derive(serde::Serialize))] 5 | pub struct IconSet { 6 | pub name: &'static str, 7 | pub icons: Map<&'static str, &'static str>, 8 | } 9 | 10 | impl IconSet { 11 | pub fn get(&self, name: &str) -> Option<&'static str> { 12 | self.icons.get(name).copied() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /badgers/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! SpaceBadgers is a library for generating SVG badges. It powers [badgers.space](https://badgers.space). 2 | //! 3 | //! # Examples 4 | //! ```rust 5 | //! use spacebadgers::BadgeBuilder; 6 | //! 7 | //! // Generate a badge with the default color palette 8 | //! let badge_svg = BadgeBuilder::new() 9 | //! .label("release") 10 | //! .status("1.0") 11 | //! .build() 12 | //! .svg(); 13 | //! 14 | //! // Print the SVG code to stdout 15 | //! println!("{}", badge_svg); 16 | //! ``` 17 | 18 | mod badge; 19 | mod badge_builder; 20 | mod color_palette; 21 | mod util; 22 | mod width; 23 | 24 | pub mod icons; 25 | 26 | pub use badge::Badge; 27 | pub use badge_builder::BadgeBuilder; 28 | pub use color_palette::ColorPalette; 29 | 30 | pub mod color_palettes { 31 | //! A collection of color palettes. 32 | 33 | // Reexport all color palettes 34 | pub use crate::color_palette::palettes::*; 35 | } 36 | -------------------------------------------------------------------------------- /badgers/src/snapshots/spacebadgers__badge__tests__colored_badge.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: badgers/src/badge.rs 3 | expression: "Badge {\n color_palette: Cow::Borrowed(&color_palettes::DEFAULT),\n status: \"passing\".into(),\n label: Some(\"checks\".into()),\n color: Some(\"green\".into()),\n label_color: Some(\"gray\".into()),\n icon: None,\n icon_width: None,\n corner_radius: None,\n scale: 1.0,\n }.svg()" 4 | --- 5 | "checks: passingcheckscheckspassingpassing" 6 | -------------------------------------------------------------------------------- /badgers/src/snapshots/spacebadgers__badge__tests__colored_scaled_badge.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: badgers/src/badge.rs 3 | expression: "Badge {\n color_palette: Cow::Borrowed(&color_palettes::DEFAULT),\n status: \"passing\".into(),\n label: Some(\"checks\".into()),\n color: Some(\"green\".into()),\n label_color: Some(\"gray\".into()),\n icon: None,\n icon_width: None,\n corner_radius: None,\n scale: 5.0,\n }.svg()" 4 | --- 5 | "checks: passingcheckscheckspassingpassing" 6 | -------------------------------------------------------------------------------- /badgers/src/snapshots/spacebadgers__badge__tests__default_badge.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: badgers/src/badge.rs 3 | expression: "Badge {\n color_palette: Cow::Borrowed(&color_palettes::DEFAULT),\n status: \"foo\".into(),\n label: Some(\"bar\".into()),\n color: None,\n label_color: None,\n icon: None,\n icon_width: None,\n corner_radius: None,\n scale: 1.0,\n }.svg()" 4 | --- 5 | "bar: foobarbarfoofoo" 6 | -------------------------------------------------------------------------------- /badgers/src/snapshots/spacebadgers__badge__tests__default_scaled_badge.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: badgers/src/badge.rs 3 | expression: "Badge {\n color_palette: Cow::Borrowed(&color_palettes::DEFAULT),\n status: \"foo\".into(),\n label: Some(\"bar\".into()),\n color: None,\n label_color: None,\n icon: None,\n icon_width: None,\n corner_radius: None,\n scale: 5.0,\n }.svg()" 4 | --- 5 | "bar: foobarbarfoofoo" 6 | -------------------------------------------------------------------------------- /badgers/src/snapshots/spacebadgers__badge__tests__icon_badge.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: badgers/src/badge.rs 3 | expression: "Badge {\n color_palette: Cow::Borrowed(&color_palettes::DEFAULT),\n status: \"Quintschaf\".into(),\n label: None,\n color: None,\n label_color: None,\n icon: Some(\"https://quintschaf.com/favicon.ico\".into()),\n icon_width: None,\n corner_radius: None,\n scale: 1.0,\n }.svg()" 4 | --- 5 | "QuintschafQuintschafQuintschaf" 6 | -------------------------------------------------------------------------------- /badgers/src/snapshots/spacebadgers__badge__tests__rounded_badge.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: badgers/src/badge.rs 3 | expression: "Badge {\n color_palette: Cow::Borrowed(&color_palettes::DEFAULT),\n status: \"foo\".into(),\n label: Some(\"bar\".into()),\n color: None,\n label_color: None,\n icon: None,\n icon_width: None,\n corner_radius: Some(30),\n scale: 1.0,\n }.svg()" 4 | --- 5 | "bar: foobarbarfoofoo" 6 | -------------------------------------------------------------------------------- /badgers/src/util.rs: -------------------------------------------------------------------------------- 1 | use crate::width::VERDANA_110; 2 | 3 | pub fn calculate_width(text: impl AsRef) -> f32 { 4 | let text = text.as_ref(); 5 | let fallback_width = VERDANA_110[64]; 6 | text.chars().fold(0f32, |acc, c| { 7 | acc + VERDANA_110.get(c as usize).unwrap_or(&fallback_width) 8 | }) 9 | } 10 | -------------------------------------------------------------------------------- /badgers/vendor/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SplittyDev/spacebadgers/079cd7d750c2b45f476b55f2010e9f727c539091/badgers/vendor/.gitkeep -------------------------------------------------------------------------------- /deny.toml: -------------------------------------------------------------------------------- 1 | [advisories] 2 | version = 2 3 | db-path = "~/.cargo/advisory-db" 4 | db-urls = ["https://github.com/rustsec/advisory-db"] 5 | ignore = [ 6 | #"RUSTSEC-0000-0000", 7 | ] 8 | unmaintained = 'workspace' 9 | 10 | [licenses] 11 | version = 2 12 | allow = [ 13 | "MIT", 14 | "BSD-3-Clause", 15 | "Apache-2.0", 16 | "Unicode-3.0", 17 | ] 18 | 19 | [bans] 20 | skip = [ 21 | #{ name = "ansi_term", version = "=0.11.0" }, 22 | ] 23 | 24 | [sources] 25 | unknown-registry = "deny" 26 | unknown-git = "deny" 27 | allow-registry = ["https://github.com/rust-lang/crates.io-index"] 28 | allow-git = [] 29 | 30 | [output] 31 | feature-depth = 1 32 | -------------------------------------------------------------------------------- /tools/verdana110.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const data = fs.readFileSync('./tools/data/verdana110.json', 'utf8') 4 | const json = JSON.parse(data) 5 | 6 | const items = json.map(item => item.toFixed(1)).join(',') 7 | console.log(`const Verdana110: [f32; ${json.length}] = [\n ${items}\n];`) 8 | --------------------------------------------------------------------------------