Compare commits
1 Commits
missing-sp
...
test202304
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1f3c3710 |
@@ -3,7 +3,6 @@ shields.env
|
||||
.git/
|
||||
.gitignore
|
||||
.vscode/
|
||||
fly.toml
|
||||
|
||||
# Improve layer cacheability.
|
||||
Dockerfile
|
||||
|
||||
@@ -2,6 +2,7 @@ extends:
|
||||
- standard
|
||||
- standard-jsx
|
||||
- standard-react
|
||||
- plugin:@typescript-eslint/recommended
|
||||
- prettier
|
||||
- eslint:recommended
|
||||
|
||||
@@ -17,7 +18,7 @@ settings:
|
||||
react:
|
||||
version: '16.8'
|
||||
jsdoc:
|
||||
mode: typescript
|
||||
mode: jsdoc
|
||||
|
||||
plugins:
|
||||
- chai-friendly
|
||||
@@ -35,34 +36,40 @@ overrides:
|
||||
# list of rules, even if they only apply to certain files. That way the
|
||||
# rules listed here are only ones which conflict.
|
||||
|
||||
- files:
|
||||
- 'badge-maker/**/*.js'
|
||||
- '**/*.cjs'
|
||||
env:
|
||||
node: true
|
||||
es6: true
|
||||
|
||||
- files:
|
||||
- '**/*.js'
|
||||
- '!frontend/**/*.js'
|
||||
- '!badge-maker/**/*.js'
|
||||
env:
|
||||
node: true
|
||||
es6: true
|
||||
rules:
|
||||
no-console: 'off'
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off'
|
||||
|
||||
- files:
|
||||
- '**/*.@(ts|tsx)'
|
||||
parserOptions:
|
||||
sourceType: 'module'
|
||||
parser: '@typescript-eslint/parser'
|
||||
rules:
|
||||
no-console: 'off'
|
||||
# Argh.
|
||||
'@typescript-eslint/explicit-function-return-type':
|
||||
['error', { 'allowExpressions': true }]
|
||||
'@typescript-eslint/no-empty-function': 'error'
|
||||
'@typescript-eslint/no-var-requires': 'error'
|
||||
'@typescript-eslint/no-object-literal-type-assertion': 'off'
|
||||
'@typescript-eslint/no-explicit-any': 'error'
|
||||
'@typescript-eslint/ban-ts-ignore': 'off'
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off'
|
||||
|
||||
- files:
|
||||
- '**/*.ts'
|
||||
- core/**/*.ts
|
||||
parserOptions:
|
||||
sourceType: 'module'
|
||||
parser: '@typescript-eslint/parser'
|
||||
|
||||
- files:
|
||||
- 'frontend/**/*.js'
|
||||
- gatsby-browser.js
|
||||
- 'frontend/**/*.@(js|ts|tsx)'
|
||||
parserOptions:
|
||||
sourceType: 'module'
|
||||
env:
|
||||
@@ -121,6 +128,14 @@ rules:
|
||||
# Disable some rules from eslint:recommended.
|
||||
no-empty: ['error', { 'allowEmptyCatch': true }]
|
||||
|
||||
# Allow unused parameters. In callbacks, removing them seems to obscure
|
||||
# what the functions are doing.
|
||||
'@typescript-eslint/no-unused-vars': ['error', { 'args': 'none' }]
|
||||
no-unused-vars: 'off'
|
||||
|
||||
'@typescript-eslint/no-var-requires': 'off'
|
||||
|
||||
'@typescript-eslint/no-use-before-define': 'error'
|
||||
no-use-before-define: 'off'
|
||||
|
||||
# These should be disabled by eslint-config-prettier, but are not.
|
||||
@@ -171,7 +186,7 @@ rules:
|
||||
jsdoc/check-tag-names: 'error'
|
||||
jsdoc/check-types: 'error'
|
||||
jsdoc/implements-on-classes: 'error'
|
||||
jsdoc/tag-lines: ['error', 'any', { 'startLines': 1 }]
|
||||
jsdoc/newline-after-description: 'error'
|
||||
jsdoc/require-param: 'error'
|
||||
jsdoc/require-param-description: 'error'
|
||||
jsdoc/require-param-name: 'error'
|
||||
@@ -182,7 +197,11 @@ rules:
|
||||
jsdoc/require-returns-type: 'error'
|
||||
jsdoc/valid-types: 'error'
|
||||
|
||||
react/prop-types: 'off'
|
||||
# Disable some from TypeScript.
|
||||
'@typescript-eslint/camelcase': off
|
||||
'@typescript-eslint/explicit-function-return-type': 'off'
|
||||
'@typescript-eslint/no-empty-function': 'off'
|
||||
|
||||
react/jsx-sort-props: 'error'
|
||||
react-hooks/rules-of-hooks: 'error'
|
||||
react-hooks/exhaustive-deps: 'error'
|
||||
|
||||
1
.github/actions/close-bot/helpers.js
vendored
1
.github/actions/close-bot/helpers.js
vendored
@@ -35,6 +35,7 @@ function allChangelogLinesAreVersionBump(changelogLines) {
|
||||
|
||||
function isPointlessVersionBump(body) {
|
||||
const pointlessBumpLinks = [
|
||||
'https://github.com/gatsbyjs/gatsby',
|
||||
'https://github.com/typescript-eslint/typescript-eslint',
|
||||
]
|
||||
|
||||
|
||||
31
.github/actions/frontend-tests/action.yml
vendored
Normal file
31
.github/actions/frontend-tests/action.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: 'Frontend tests'
|
||||
description: 'Run frontend tests and check types'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Prepare frontend tests
|
||||
if: always()
|
||||
run: npm run defs && npm run features
|
||||
shell: bash
|
||||
|
||||
- name: Tests
|
||||
if: always()
|
||||
run: npm run test:frontend -- --reporter json --reporter-option 'output=reports/frontend-tests.json'
|
||||
shell: bash
|
||||
|
||||
- name: Type Checks
|
||||
if: always()
|
||||
run: |
|
||||
set -o pipefail
|
||||
npm run check-types:frontend 2>&1 | tee reports/frontend-types.txt
|
||||
shell: bash
|
||||
|
||||
- name: Write Markdown Summary
|
||||
if: always()
|
||||
run: |
|
||||
node scripts/mocha2md.js 'Frontend Tests' reports/frontend-tests.json >> $GITHUB_STEP_SUMMARY
|
||||
echo '# Frontend Types' >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
cat reports/frontend-types.txt >> $GITHUB_STEP_SUMMARY
|
||||
echo '```' >> $GITHUB_STEP_SUMMARY
|
||||
shell: bash
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -8,7 +8,6 @@ updates:
|
||||
day: friday
|
||||
time: '12:00'
|
||||
open-pull-requests-limit: 99
|
||||
rebase-strategy: disabled
|
||||
ignore:
|
||||
# https://github.com/badges/shields/issues/7324
|
||||
# https://github.com/badges/shields/issues/7447
|
||||
@@ -28,7 +27,6 @@ updates:
|
||||
day: friday
|
||||
time: '12:00'
|
||||
open-pull-requests-limit: 99
|
||||
rebase-strategy: disabled
|
||||
|
||||
# close-bot package dependencies
|
||||
- package-ecosystem: npm
|
||||
@@ -38,12 +36,8 @@ updates:
|
||||
day: friday
|
||||
time: '12:00'
|
||||
open-pull-requests-limit: 99
|
||||
rebase-strategy: disabled
|
||||
|
||||
# GH actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 99
|
||||
rebase-strategy: disabled
|
||||
|
||||
10
.github/scripts/cleanup-review-apps.sh
vendored
10
.github/scripts/cleanup-review-apps.sh
vendored
@@ -1,10 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
apps=$(flyctl apps list --json | jq -r .[].ID | grep -E "pr-[0-9]+-badges-shields") || exit 0
|
||||
|
||||
for app in $apps
|
||||
do
|
||||
flyctl apps destroy "$app" -y
|
||||
done
|
||||
35
.github/scripts/deploy-review-app.sh
vendored
35
.github/scripts/deploy-review-app.sh
vendored
@@ -1,35 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
app="pr-$PR_NUMBER-badges-shields"
|
||||
region="ewr"
|
||||
org="shields-io"
|
||||
|
||||
# Get PR JSON from the API
|
||||
# This will fail if $PR_NUMBER is not a valid PR
|
||||
pr_json=$(curl --fail "https://api.github.com/repos/badges/shields/pulls/$PR_NUMBER")
|
||||
|
||||
# Attempt to apply the PR diff to the target branch
|
||||
# This will fail if it does not merge cleanly
|
||||
git config user.name "actions[bot]"
|
||||
git config user.email "actions@users.noreply.github.com"
|
||||
git fetch origin "pull/$PR_NUMBER/head:pr-$PR_NUMBER"
|
||||
git merge "pr-$PR_NUMBER"
|
||||
|
||||
# If the app does not already exist, create it
|
||||
if ! flyctl status --app "$app"; then
|
||||
flyctl launch --no-deploy --copy-config --name "$app" --region "$region" --org "$org"
|
||||
echo $SECRETS | tr " " "\n" | flyctl secrets import --app "$app"
|
||||
fi
|
||||
|
||||
# Deploy
|
||||
flyctl deploy --app "$app" --region "$region"
|
||||
|
||||
# Post a comment on the PR
|
||||
app_url=$(flyctl status --app "$app" --json | jq -r .Hostname)
|
||||
comment_url=$(echo "$pr_json" | jq .comments_url -r)
|
||||
curl "$comment_url" \
|
||||
-X POST \
|
||||
-H "Authorization: token $GITHUB_TOKEN" \
|
||||
--data "{\"body\":\"🚀 Updated review app: https://$app_url\"}"
|
||||
24
.github/workflows/cleanup-review-apps.yml
vendored
24
.github/workflows/cleanup-review-apps.yml
vendored
@@ -1,24 +0,0 @@
|
||||
name: Cleanup Review Apps
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 7 * * *'
|
||||
# At 07:00, daily
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup-review-apps:
|
||||
runs-on: ubuntu-latest
|
||||
environment: 'Review Apps'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
|
||||
- name: install jq
|
||||
run: |
|
||||
sudo apt-get -qq update
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- run: .github/scripts/cleanup-review-apps.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
2
.github/workflows/danger.yml
vendored
2
.github/workflows/danger.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Danger
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
permissions:
|
||||
checks: write
|
||||
|
||||
43
.github/workflows/deploy-review-app.yml
vendored
43
.github/workflows/deploy-review-app.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Create/Update Review App
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR Number to deploy e.g: 1234'
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
deploy-review-app:
|
||||
runs-on: ubuntu-latest
|
||||
environment: 'Review Apps'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: superfly/flyctl-actions/setup-flyctl@master
|
||||
|
||||
- name: install jq
|
||||
run: |
|
||||
sudo apt-get -qq update
|
||||
sudo apt-get install -y jq
|
||||
|
||||
- run: .github/scripts/deploy-review-app.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.inputs.pr_number }}
|
||||
# credentials to set when we create the review app
|
||||
SECRETS: |
|
||||
GH_TOKEN=${{ secrets.GH_PAT }}
|
||||
LIBRARIESIO_TOKENS=${{ secrets.SERVICETESTS_LIBRARIESIO_TOKENS }}
|
||||
OBS_USER=${{ secrets.SERVICETESTS_OBS_USER }}
|
||||
OBS_PASS=${{ secrets.SERVICETESTS_OBS_PASS }}
|
||||
SL_INSIGHT_API_TOKEN=${{ secrets.SERVICETESTS_SL_INSIGHT_USER_UUID }}
|
||||
SL_INSIGHT_USER_UUID=${{ secrets.SERVICETESTS_SL_INSIGHT_API_TOKEN }}
|
||||
TWITCH_CLIENT_ID=${{ secrets.SERVICETESTS_TWITCH_CLIENT_ID }}
|
||||
TWITCH_CLIENT_SECRET=${{ secrets.SERVICETESTS_TWITCH_CLIENT_SECRET }}
|
||||
WHEELMAP_TOKEN=${{ secrets.SERVICETESTS_WHEELMAP_TOKEN }}
|
||||
YOUTUBE_API_KEY=${{ secrets.SERVICETESTS_YOUTUBE_API_KEY }}
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'Dependency Review'
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
enforce-dependency-review:
|
||||
|
||||
7
.github/workflows/test-e2e.yml
vendored
7
.github/workflows/test-e2e.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: E2E
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
@@ -29,10 +29,13 @@ jobs:
|
||||
node-version: 16
|
||||
cypress: true
|
||||
|
||||
- name: Frontend build
|
||||
run: GATSBY_BASE_URL=http://localhost:8080 npm run build
|
||||
|
||||
- name: Run tests
|
||||
env:
|
||||
GH_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
run: npm run e2e
|
||||
run: npm run e2e-on-build
|
||||
|
||||
- name: Archive videos
|
||||
if: always()
|
||||
|
||||
26
.github/workflows/test-frontend.yml
vendored
Normal file
26
.github/workflows/test-frontend.yml
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
name: Frontend
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
- 'dependabot/**'
|
||||
|
||||
jobs:
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Setup
|
||||
uses: ./.github/actions/setup
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: Frontend tests
|
||||
uses: ./.github/actions/frontend-tests
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
11
.github/workflows/test-integration-17.yml
vendored
11
.github/workflows/test-integration-17.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Integration@node 17
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
@@ -14,6 +14,15 @@ jobs:
|
||||
PAT_EXISTS: ${{ secrets.GH_PAT != '' }}
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
|
||||
11
.github/workflows/test-integration.yml
vendored
11
.github/workflows/test-integration.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Integration
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
@@ -14,6 +14,15 @@ jobs:
|
||||
PAT_EXISTS: ${{ secrets.GH_PAT != '' }}
|
||||
|
||||
services:
|
||||
redis:
|
||||
image: redis
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 6379:6379
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
|
||||
2
.github/workflows/test-lint.yml
vendored
2
.github/workflows/test-lint.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Lint
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
|
||||
2
.github/workflows/test-main-17.yml
vendored
2
.github/workflows/test-main-17.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Main@node 17
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
|
||||
2
.github/workflows/test-main.yml
vendored
2
.github/workflows/test-main.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Main
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
|
||||
2
.github/workflows/test-package-cli.yml
vendored
2
.github/workflows/test-package-cli.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Package CLI
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
|
||||
2
.github/workflows/test-package-lib.yml
vendored
2
.github/workflows/test-package-lib.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: Package Library
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
types: [opened, edited, reopened, synchronize]
|
||||
push:
|
||||
branches-ignore:
|
||||
- 'gh-pages'
|
||||
|
||||
2
.github/workflows/update-github-api.yml
vendored
2
.github/workflows/update-github-api.yml
vendored
@@ -25,7 +25,7 @@ jobs:
|
||||
run: node scripts/update-github-api.js
|
||||
|
||||
- name: Create Pull Request if config has changed
|
||||
uses: peter-evans/create-pull-request@v5
|
||||
uses: peter-evans/create-pull-request@v4
|
||||
with:
|
||||
token: '${{ secrets.GITHUB_TOKEN }}'
|
||||
commit-message: Update GitHub API Version
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -92,6 +92,10 @@ typings/
|
||||
|
||||
# Temporary build artifacts.
|
||||
/build
|
||||
.next
|
||||
badge-examples.json
|
||||
supported-features.json
|
||||
service-definitions.yml
|
||||
frontend/categories/*.yaml
|
||||
|
||||
# Local runtime configuration.
|
||||
@@ -100,6 +104,11 @@ frontend/categories/*.yaml
|
||||
# Template for the local runtime configuration.
|
||||
!/config/local*.template.yml
|
||||
|
||||
# Gatsby
|
||||
/frontend/.cache
|
||||
/frontend/public
|
||||
/public
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
@@ -112,8 +121,3 @@ flamegraph.html
|
||||
|
||||
# config file for node-pg-migrate
|
||||
migrations-config.json
|
||||
|
||||
# Frontend/Docusaurus
|
||||
frontend/.docusaurus
|
||||
frontend/.cache-loader
|
||||
/public
|
||||
|
||||
5
.mocharc-frontend.yml
Normal file
5
.mocharc-frontend.yml
Normal file
@@ -0,0 +1,5 @@
|
||||
reporter: mocha-env-reporter
|
||||
require:
|
||||
- '@babel/polyfill'
|
||||
- '@babel/register'
|
||||
- mocha-yaml-loader
|
||||
10
.nycrc-frontend.json
Normal file
10
.nycrc-frontend.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"reporter": ["lcov"],
|
||||
"all": false,
|
||||
"silent": true,
|
||||
"clean": false,
|
||||
"sourceMap": false,
|
||||
"instrument": false,
|
||||
"include": ["frontend/**/*.js"],
|
||||
"exclude": ["**/*.spec.js", "**/mocha-*.js"]
|
||||
}
|
||||
@@ -10,5 +10,5 @@ public
|
||||
private/*.json
|
||||
/.nyc_output
|
||||
analytics.json
|
||||
frontend/.docusaurus
|
||||
frontend/categories
|
||||
supported-features.json
|
||||
service-definitions.yml
|
||||
|
||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -4,23 +4,6 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m
|
||||
|
||||
---
|
||||
|
||||
## server-2023-06-01
|
||||
|
||||
- feat: Add total commits to [GitHubCommitActivity] [#9196](https://github.com/badges/shields/issues/9196)
|
||||
- set a custom error on 429 [#9159](https://github.com/badges/shields/issues/9159)
|
||||
- deprecate [travis].org badges [#9171](https://github.com/badges/shields/issues/9171)
|
||||
- count private sponsors on [GithubSponsors] badge [#9170](https://github.com/badges/shields/issues/9170)
|
||||
- Dependency updates
|
||||
|
||||
## server-2023-05-01
|
||||
|
||||
** Removal:** For users who need to maintain a Github Token pool, storage has been provided via the `RedisTokenPersistence` and `REDIS_URL` settings. This feature was deprecated in `server-2023-03-01`. As of this release, the `RedisTokenPersistence` backend is now removed. If you are using this feature, you will need to migrate to using the `SQLTokenPersistence` backend for storage and provide a postgres connection string via the `POSTGRES_URL` setting. [#8922](https://github.com/badges/shields/issues/8922)
|
||||
|
||||
- fail to start server if there are duplicate service names [#9099](https://github.com/badges/shields/issues/9099)
|
||||
- [SourceForge] Added badges for SourceForge [#9078](https://github.com/badges/shields/issues/9078) [#9102](https://github.com/badges/shields/issues/9102)
|
||||
- crates: Use `?include=` to reduce crates.io backend load [#9081](https://github.com/badges/shields/issues/9081)
|
||||
- Dependency updates
|
||||
|
||||
## server-2023-04-02
|
||||
|
||||
- [JenkinsCoverage] Update Jenkins Code Coverage API for new plugin version [#9010](https://github.com/badges/shields/issues/9010)
|
||||
|
||||
@@ -134,11 +134,12 @@ Prettier before a commit by default.
|
||||
When adding or changing a service [please write tests][service-tests], and ensure the [title of your Pull Requests follows the required conventions](#running-service-tests-in-pull-requests) to ensure your tests are executed.
|
||||
When changing other code, please add unit tests.
|
||||
|
||||
The integration tests are not run by default. For most contributions it is OK to skip these unless you're working directly on the code for storing the GitHub token pool in postgres.
|
||||
The integration tests are not run by default. For most contributions it is OK to skip these unless you're working directly on the code for storing the GitHub token pool in postgres/redis.
|
||||
|
||||
To run the integration tests:
|
||||
|
||||
- You must have PostgreSQL installed. Use `brew install postgresql`, `apt-get install postgresql`, etc.
|
||||
- You must have Redis installed and in your PATH. Use `brew install redis`, `apt-get install redis`, etc. The test runner will start the server automatically.
|
||||
- You must also have PostgreSQL installed. Use `brew install postgresql`, `apt-get install postgresql`, etc.
|
||||
- Set a connection string either with an env var `POSTGRES_URL=postgresql://user:pass@127.0.0.1:5432/db_name` or by using
|
||||
```yaml
|
||||
private:
|
||||
|
||||
@@ -107,7 +107,7 @@ You can read a [tutorial on how to add a badge][tutorial].
|
||||
|
||||
When server source files change, the badge server should automatically restart
|
||||
itself (using [nodemon][]). When the frontend files change, the frontend dev
|
||||
server (`docusaurus start`) should also automatically reload. However the badge
|
||||
server (`gatsby dev`) should also automatically reload. However the badge
|
||||
definitions are built only before the server first starts. To regenerate those,
|
||||
either run `npm run defs` or manually restart the server.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict'
|
||||
|
||||
const path = require('path')
|
||||
const isSvg = require('is-svg')
|
||||
const { spawn } = require('child-process-promise')
|
||||
const { expect, use } = require('chai')
|
||||
use(require('chai-string'))
|
||||
@@ -19,7 +20,6 @@ describe('The CLI', function () {
|
||||
})
|
||||
|
||||
it('should produce default badges', async function () {
|
||||
const { default: isSvg } = await import('is-svg')
|
||||
const { stdout } = await runCli(['cactus', 'grown'])
|
||||
expect(stdout)
|
||||
.to.satisfy(isSvg)
|
||||
@@ -28,13 +28,11 @@ describe('The CLI', function () {
|
||||
})
|
||||
|
||||
it('should produce colorschemed badges', async function () {
|
||||
const { default: isSvg } = await import('is-svg')
|
||||
const { stdout } = await runCli(['cactus', 'grown', ':green'])
|
||||
expect(stdout).to.satisfy(isSvg)
|
||||
})
|
||||
|
||||
it('should produce right-color badges', async function () {
|
||||
const { default: isSvg } = await import('is-svg')
|
||||
const { stdout } = await runCli(['cactus', 'grown', '#abcdef'])
|
||||
expect(stdout).to.satisfy(isSvg).and.to.include('#abcdef')
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
'use strict'
|
||||
|
||||
const { expect } = require('chai')
|
||||
const isSvg = require('is-svg')
|
||||
const { makeBadge, ValidationError } = require('.')
|
||||
|
||||
describe('makeBadge function', function () {
|
||||
it('should produce badge with valid input', async function () {
|
||||
const { default: isSvg } = await import('is-svg')
|
||||
it('should produce badge with valid input', function () {
|
||||
expect(
|
||||
makeBadge({
|
||||
label: 'build',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const { test, given, forCases } = require('sazerac')
|
||||
const { expect } = require('chai')
|
||||
const snapshot = require('snap-shot-it')
|
||||
const isSvg = require('is-svg')
|
||||
const prettier = require('prettier')
|
||||
const makeBadge = require('./make-badge')
|
||||
|
||||
@@ -79,8 +80,7 @@ describe('The badge generator', function () {
|
||||
})
|
||||
|
||||
describe('SVG', function () {
|
||||
it('should produce SVG', async function () {
|
||||
const { default: isSvg } = await import('is-svg')
|
||||
it('should produce SVG', function () {
|
||||
expect(makeBadge({ label: 'cactus', message: 'grown', format: 'svg' }))
|
||||
.to.satisfy(isSvg)
|
||||
.and.to.include('cactus')
|
||||
@@ -113,8 +113,7 @@ describe('The badge generator', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('should replace undefined svg badge style with "flat"', async function () {
|
||||
const { default: isSvg } = await import('is-svg')
|
||||
it('should replace undefined svg badge style with "flat"', function () {
|
||||
const jsonBadgeWithUnknownStyle = makeBadge({
|
||||
label: 'name',
|
||||
message: 'Bob',
|
||||
|
||||
@@ -4,6 +4,7 @@ private:
|
||||
gh_client_id: ...
|
||||
gh_client_secret: ...
|
||||
gitlab_token: ...
|
||||
redis_url: ...
|
||||
sentry_dsn: ...
|
||||
shields_secret: ...
|
||||
sl_insight_userUuid: ...
|
||||
|
||||
94
core/badge-urls/make-badge-url.d.ts
vendored
Normal file
94
core/badge-urls/make-badge-url.d.ts
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
export function badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path,
|
||||
queryParams,
|
||||
style,
|
||||
format,
|
||||
longCache,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
path: string
|
||||
queryParams: { [k: string]: string | number | boolean }
|
||||
style?: string
|
||||
format?: string
|
||||
longCache?: boolean
|
||||
}): string
|
||||
|
||||
export function encodeField(s: string): string
|
||||
|
||||
export function staticBadgeUrl({
|
||||
baseUrl,
|
||||
label,
|
||||
message,
|
||||
labelColor,
|
||||
color,
|
||||
style,
|
||||
namedLogo,
|
||||
format,
|
||||
links,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
label: string
|
||||
message: string
|
||||
labelColor?: string
|
||||
color?: string
|
||||
style?: string
|
||||
namedLogo?: string
|
||||
format?: string
|
||||
links?: string[]
|
||||
}): string
|
||||
|
||||
export function queryStringStaticBadgeUrl({
|
||||
baseUrl,
|
||||
label,
|
||||
message,
|
||||
color,
|
||||
labelColor,
|
||||
style,
|
||||
namedLogo,
|
||||
logoColor,
|
||||
logoWidth,
|
||||
logoPosition,
|
||||
format,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
label: string
|
||||
message: string
|
||||
color?: string
|
||||
labelColor?: string
|
||||
style?: string
|
||||
namedLogo?: string
|
||||
logoColor?: string
|
||||
logoWidth?: number
|
||||
logoPosition?: number
|
||||
format?: string
|
||||
}): string
|
||||
|
||||
export function dynamicBadgeUrl({
|
||||
baseUrl,
|
||||
datatype,
|
||||
label,
|
||||
dataUrl,
|
||||
query,
|
||||
prefix,
|
||||
suffix,
|
||||
color,
|
||||
style,
|
||||
format,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
datatype: string
|
||||
label: string
|
||||
dataUrl: string
|
||||
query: string
|
||||
prefix: string
|
||||
suffix: string
|
||||
color?: string
|
||||
style?: string
|
||||
format?: string
|
||||
}): string
|
||||
|
||||
export function rasterRedirectUrl(
|
||||
{ rasterUrl }: { rasterUrl: string },
|
||||
badgeUrl: string
|
||||
): string
|
||||
@@ -1,5 +1,119 @@
|
||||
// Avoid "Attempted import error: 'URL' is not exported from 'url'" in frontend.
|
||||
import url from 'url'
|
||||
import queryString from 'query-string'
|
||||
|
||||
function badgeUrlFromPath({
|
||||
baseUrl = '',
|
||||
path,
|
||||
queryParams,
|
||||
style,
|
||||
format = '',
|
||||
longCache = false,
|
||||
}) {
|
||||
const outExt = format.length ? `.${format}` : ''
|
||||
|
||||
const outQueryString = queryString.stringify({
|
||||
cacheSeconds: longCache ? '2592000' : undefined,
|
||||
style,
|
||||
...queryParams,
|
||||
})
|
||||
const suffix = outQueryString ? `?${outQueryString}` : ''
|
||||
|
||||
return `${baseUrl}${path}${outExt}${suffix}`
|
||||
}
|
||||
|
||||
function encodeField(s) {
|
||||
return encodeURIComponent(s.replace(/-/g, '--').replace(/_/g, '__'))
|
||||
}
|
||||
|
||||
function staticBadgeUrl({
|
||||
baseUrl = '',
|
||||
label,
|
||||
message,
|
||||
labelColor,
|
||||
color = 'lightgray',
|
||||
style,
|
||||
namedLogo,
|
||||
format = '',
|
||||
links = [],
|
||||
}) {
|
||||
const path = [label, message, color].map(encodeField).join('-')
|
||||
const outQueryString = queryString.stringify({
|
||||
labelColor,
|
||||
style,
|
||||
logo: namedLogo,
|
||||
link: links,
|
||||
})
|
||||
const outExt = format.length ? `.${format}` : ''
|
||||
const suffix = outQueryString ? `?${outQueryString}` : ''
|
||||
return `${baseUrl}/badge/${path}${outExt}${suffix}`
|
||||
}
|
||||
|
||||
function queryStringStaticBadgeUrl({
|
||||
baseUrl = '',
|
||||
label,
|
||||
message,
|
||||
color,
|
||||
labelColor,
|
||||
style,
|
||||
namedLogo,
|
||||
logoColor,
|
||||
logoWidth,
|
||||
logoPosition,
|
||||
format = '',
|
||||
}) {
|
||||
// schemaVersion could be a parameter if we iterate on it,
|
||||
// for now it's hardcoded to the only supported version.
|
||||
const schemaVersion = '1'
|
||||
const suffix = `?${queryString.stringify({
|
||||
label,
|
||||
message,
|
||||
color,
|
||||
labelColor,
|
||||
style,
|
||||
logo: namedLogo,
|
||||
logoColor,
|
||||
logoWidth,
|
||||
logoPosition,
|
||||
})}`
|
||||
const outExt = format.length ? `.${format}` : ''
|
||||
return `${baseUrl}/static/v${schemaVersion}${outExt}${suffix}`
|
||||
}
|
||||
|
||||
function dynamicBadgeUrl({
|
||||
baseUrl,
|
||||
datatype,
|
||||
label,
|
||||
dataUrl,
|
||||
query,
|
||||
prefix,
|
||||
suffix,
|
||||
color,
|
||||
style,
|
||||
format = '',
|
||||
}) {
|
||||
const outExt = format.length ? `.${format}` : ''
|
||||
|
||||
const queryParams = {
|
||||
label,
|
||||
url: dataUrl,
|
||||
query,
|
||||
style,
|
||||
}
|
||||
|
||||
if (color) {
|
||||
queryParams.color = color
|
||||
}
|
||||
if (prefix) {
|
||||
queryParams.prefix = prefix
|
||||
}
|
||||
if (suffix) {
|
||||
queryParams.suffix = suffix
|
||||
}
|
||||
|
||||
const outQueryString = queryString.stringify(queryParams)
|
||||
return `${baseUrl}/badge/dynamic/${datatype}${outExt}?${outQueryString}`
|
||||
}
|
||||
|
||||
function rasterRedirectUrl({ rasterUrl }, badgeUrl) {
|
||||
// Ensure we're always using the `rasterUrl` by using just the path from
|
||||
@@ -10,4 +124,11 @@ function rasterRedirectUrl({ rasterUrl }, badgeUrl) {
|
||||
return result
|
||||
}
|
||||
|
||||
export { rasterRedirectUrl }
|
||||
export {
|
||||
badgeUrlFromPath,
|
||||
encodeField,
|
||||
staticBadgeUrl,
|
||||
queryStringStaticBadgeUrl,
|
||||
dynamicBadgeUrl,
|
||||
rasterRedirectUrl,
|
||||
}
|
||||
|
||||
142
core/badge-urls/make-badge-url.spec.js
Normal file
142
core/badge-urls/make-badge-url.spec.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import { test, given } from 'sazerac'
|
||||
import {
|
||||
badgeUrlFromPath,
|
||||
encodeField,
|
||||
staticBadgeUrl,
|
||||
queryStringStaticBadgeUrl,
|
||||
dynamicBadgeUrl,
|
||||
} from './make-badge-url.js'
|
||||
|
||||
describe('Badge URL generation functions', function () {
|
||||
test(badgeUrlFromPath, () => {
|
||||
given({
|
||||
baseUrl: 'http://example.com',
|
||||
path: '/npm/v/gh-badges',
|
||||
style: 'flat-square',
|
||||
longCache: true,
|
||||
}).expect(
|
||||
'http://example.com/npm/v/gh-badges?cacheSeconds=2592000&style=flat-square'
|
||||
)
|
||||
})
|
||||
|
||||
test(encodeField, () => {
|
||||
given('foo').expect('foo')
|
||||
given('').expect('')
|
||||
given('happy go lucky').expect('happy%20go%20lucky')
|
||||
given('do-right').expect('do--right')
|
||||
given('it_is_a_snake').expect('it__is__a__snake')
|
||||
})
|
||||
|
||||
test(staticBadgeUrl, () => {
|
||||
given({
|
||||
label: 'foo',
|
||||
message: 'bar',
|
||||
color: 'blue',
|
||||
style: 'flat-square',
|
||||
}).expect('/badge/foo-bar-blue?style=flat-square')
|
||||
given({
|
||||
label: 'foo',
|
||||
message: 'bar',
|
||||
color: 'blue',
|
||||
style: 'flat-square',
|
||||
format: 'png',
|
||||
namedLogo: 'github',
|
||||
}).expect('/badge/foo-bar-blue.png?logo=github&style=flat-square')
|
||||
given({
|
||||
label: 'Hello World',
|
||||
message: 'Привет Мир',
|
||||
color: '#aabbcc',
|
||||
}).expect(
|
||||
'/badge/Hello%20World-%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%D0%9C%D0%B8%D1%80-%23aabbcc'
|
||||
)
|
||||
given({
|
||||
label: '123-123',
|
||||
message: 'abc-abc',
|
||||
color: 'blue',
|
||||
}).expect('/badge/123--123-abc--abc-blue')
|
||||
given({
|
||||
label: '123-123',
|
||||
message: '',
|
||||
color: 'blue',
|
||||
style: 'social',
|
||||
}).expect('/badge/123--123--blue?style=social')
|
||||
given({
|
||||
label: '',
|
||||
message: 'blue',
|
||||
color: 'blue',
|
||||
}).expect('/badge/-blue-blue')
|
||||
})
|
||||
|
||||
test(queryStringStaticBadgeUrl, () => {
|
||||
// the query-string library sorts parameters by name
|
||||
given({
|
||||
label: 'foo',
|
||||
message: 'bar',
|
||||
color: 'blue',
|
||||
style: 'flat-square',
|
||||
}).expect('/static/v1?color=blue&label=foo&message=bar&style=flat-square')
|
||||
given({
|
||||
label: 'foo Bar',
|
||||
message: 'bar Baz',
|
||||
color: 'blue',
|
||||
style: 'flat-square',
|
||||
format: 'png',
|
||||
namedLogo: 'github',
|
||||
}).expect(
|
||||
'/static/v1.png?color=blue&label=foo%20Bar&logo=github&message=bar%20Baz&style=flat-square'
|
||||
)
|
||||
given({
|
||||
label: 'Hello World',
|
||||
message: 'Привет Мир',
|
||||
color: '#aabbcc',
|
||||
}).expect(
|
||||
'/static/v1?color=%23aabbcc&label=Hello%20World&message=%D0%9F%D1%80%D0%B8%D0%B2%D0%B5%D1%82%20%D0%9C%D0%B8%D1%80'
|
||||
)
|
||||
})
|
||||
|
||||
test(dynamicBadgeUrl, () => {
|
||||
const dataUrl = 'http://example.com/foo.json'
|
||||
const query = '$.bar'
|
||||
const prefix = 'value: '
|
||||
given({
|
||||
baseUrl: 'http://img.example.com',
|
||||
datatype: 'json',
|
||||
label: 'foo',
|
||||
dataUrl,
|
||||
query,
|
||||
prefix,
|
||||
style: 'plastic',
|
||||
}).expect(
|
||||
[
|
||||
'http://img.example.com/badge/dynamic/json',
|
||||
'?label=foo',
|
||||
`&prefix=${encodeURIComponent(prefix)}`,
|
||||
`&query=${encodeURIComponent(query)}`,
|
||||
'&style=plastic',
|
||||
`&url=${encodeURIComponent(dataUrl)}`,
|
||||
].join('')
|
||||
)
|
||||
const suffix = '<- value'
|
||||
const color = 'blue'
|
||||
given({
|
||||
baseUrl: 'http://img.example.com',
|
||||
datatype: 'json',
|
||||
label: 'foo',
|
||||
dataUrl,
|
||||
query,
|
||||
suffix,
|
||||
color,
|
||||
style: 'plastic',
|
||||
}).expect(
|
||||
[
|
||||
'http://img.example.com/badge/dynamic/json',
|
||||
'?color=blue',
|
||||
'&label=foo',
|
||||
`&query=${encodeURIComponent(query)}`,
|
||||
'&style=plastic',
|
||||
`&suffix=${encodeURIComponent(suffix)}`,
|
||||
`&url=${encodeURIComponent(dataUrl)}`,
|
||||
].join('')
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -44,12 +44,6 @@ class BaseGraphqlService extends BaseService {
|
||||
* and custom error messages e.g: `{ 404: 'package not found' }`.
|
||||
* This can be used to extend or override the
|
||||
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
|
||||
* @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes
|
||||
* and an object of params to pass when we construct an Inaccessible exception object
|
||||
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
|
||||
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
|
||||
* for allowed keys
|
||||
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
|
||||
* @param {Function} [attrs.transformJson=data => data] Function which takes the raw json and transforms it before
|
||||
* further procesing. In case of multiple query in a single graphql call and few of them
|
||||
* throw error, partial data might be used ignoring the error.
|
||||
@@ -68,7 +62,6 @@ class BaseGraphqlService extends BaseService {
|
||||
variables = {},
|
||||
options = {},
|
||||
httpErrorMessages = {},
|
||||
systemErrors = {},
|
||||
transformJson = data => data,
|
||||
transformErrors = defaultTransformErrors,
|
||||
}) {
|
||||
@@ -81,8 +74,7 @@ class BaseGraphqlService extends BaseService {
|
||||
const { buffer } = await this._request({
|
||||
url,
|
||||
options: mergedOptions,
|
||||
httpErrors: httpErrorMessages,
|
||||
systemErrors,
|
||||
errorMessages: httpErrorMessages,
|
||||
})
|
||||
const json = transformJson(this._parseJson(buffer))
|
||||
if (json.errors) {
|
||||
|
||||
@@ -30,26 +30,14 @@ class BaseJsonService extends BaseService {
|
||||
* @param {string} attrs.url URL to request
|
||||
* @param {object} [attrs.options={}] Options to pass to got. See
|
||||
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
|
||||
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
|
||||
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
|
||||
* and custom error messages e.g: `{ 404: 'package not found' }`.
|
||||
* This can be used to extend or override the
|
||||
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
|
||||
* @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes
|
||||
* and an object of params to pass when we construct an Inaccessible exception object
|
||||
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
|
||||
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
|
||||
* for allowed keys
|
||||
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
|
||||
* @returns {object} Parsed response
|
||||
* @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md
|
||||
*/
|
||||
async _requestJson({
|
||||
schema,
|
||||
url,
|
||||
options = {},
|
||||
httpErrors = {},
|
||||
systemErrors = {},
|
||||
}) {
|
||||
async _requestJson({ schema, url, options = {}, errorMessages = {} }) {
|
||||
const mergedOptions = {
|
||||
...{ headers: { Accept: 'application/json' } },
|
||||
...options,
|
||||
@@ -57,8 +45,7 @@ class BaseJsonService extends BaseService {
|
||||
const { buffer } = await this._request({
|
||||
url,
|
||||
options: mergedOptions,
|
||||
httpErrors,
|
||||
systemErrors,
|
||||
errorMessages,
|
||||
})
|
||||
const json = this._parseJson(buffer)
|
||||
return this.constructor._validate(json, schema)
|
||||
|
||||
@@ -53,16 +53,10 @@ class BaseSvgScrapingService extends BaseService {
|
||||
* @param {string} attrs.url URL to request
|
||||
* @param {object} [attrs.options={}] Options to pass to got. See
|
||||
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
|
||||
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
|
||||
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
|
||||
* and custom error messages e.g: `{ 404: 'package not found' }`.
|
||||
* This can be used to extend or override the
|
||||
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
|
||||
* @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes
|
||||
* and an object of params to pass when we construct an Inaccessible exception object
|
||||
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
|
||||
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
|
||||
* for allowed keys
|
||||
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
|
||||
* @returns {object} Parsed response
|
||||
* @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md
|
||||
*/
|
||||
@@ -71,8 +65,7 @@ class BaseSvgScrapingService extends BaseService {
|
||||
valueMatcher,
|
||||
url,
|
||||
options = {},
|
||||
httpErrors = {},
|
||||
systemErrors = {},
|
||||
errorMessages = {},
|
||||
}) {
|
||||
const logTrace = (...args) => trace.logTrace('fetch', ...args)
|
||||
const mergedOptions = {
|
||||
@@ -82,8 +75,7 @@ class BaseSvgScrapingService extends BaseService {
|
||||
const { buffer } = await this._request({
|
||||
url,
|
||||
options: mergedOptions,
|
||||
httpErrors,
|
||||
systemErrors,
|
||||
errorMessages,
|
||||
})
|
||||
logTrace(emojic.dart, 'Response SVG', buffer)
|
||||
const data = {
|
||||
|
||||
@@ -24,16 +24,10 @@ class BaseXmlService extends BaseService {
|
||||
* @param {string} attrs.url URL to request
|
||||
* @param {object} [attrs.options={}] Options to pass to got. See
|
||||
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
|
||||
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
|
||||
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
|
||||
* and custom error messages e.g: `{ 404: 'package not found' }`.
|
||||
* This can be used to extend or override the
|
||||
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
|
||||
* @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes
|
||||
* and an object of params to pass when we construct an Inaccessible exception object
|
||||
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
|
||||
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
|
||||
* for allowed keys
|
||||
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
|
||||
* @param {object} [attrs.parserOptions={}] Options to pass to fast-xml-parser. See
|
||||
* [documentation](https://github.com/NaturalIntelligence/fast-xml-parser#xml-to-json)
|
||||
* @returns {object} Parsed response
|
||||
@@ -44,8 +38,7 @@ class BaseXmlService extends BaseService {
|
||||
schema,
|
||||
url,
|
||||
options = {},
|
||||
httpErrors = {},
|
||||
systemErrors = {},
|
||||
errorMessages = {},
|
||||
parserOptions = {},
|
||||
}) {
|
||||
const logTrace = (...args) => trace.logTrace('fetch', ...args)
|
||||
@@ -56,8 +49,7 @@ class BaseXmlService extends BaseService {
|
||||
const { buffer } = await this._request({
|
||||
url,
|
||||
options: mergedOptions,
|
||||
httpErrors,
|
||||
systemErrors,
|
||||
errorMessages,
|
||||
})
|
||||
const validateResult = XMLValidator.validate(buffer)
|
||||
if (validateResult !== true) {
|
||||
|
||||
@@ -23,16 +23,10 @@ class BaseYamlService extends BaseService {
|
||||
* @param {string} attrs.url URL to request
|
||||
* @param {object} [attrs.options={}] Options to pass to got. See
|
||||
* [documentation](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md)
|
||||
* @param {object} [attrs.httpErrors={}] Key-value map of status codes
|
||||
* @param {object} [attrs.errorMessages={}] Key-value map of status codes
|
||||
* and custom error messages e.g: `{ 404: 'package not found' }`.
|
||||
* This can be used to extend or override the
|
||||
* [default](https://github.com/badges/shields/blob/master/core/base-service/check-error-response.js#L5)
|
||||
* @param {object} [attrs.systemErrors={}] Key-value map of got network exception codes
|
||||
* and an object of params to pass when we construct an Inaccessible exception object
|
||||
* e.g: `{ ECONNRESET: { prettyMessage: 'connection reset' } }`.
|
||||
* See {@link https://github.com/sindresorhus/got/blob/main/documentation/7-retry.md#errorcodes got error codes}
|
||||
* for allowed keys
|
||||
* and {@link module:core/base-service/errors~RuntimeErrorProps} for allowed values
|
||||
* @param {object} [attrs.encoding='utf8'] Character encoding
|
||||
* @returns {object} Parsed response
|
||||
* @see https://github.com/sindresorhus/got/blob/main/documentation/2-options.md
|
||||
@@ -41,8 +35,7 @@ class BaseYamlService extends BaseService {
|
||||
schema,
|
||||
url,
|
||||
options = {},
|
||||
httpErrors = {},
|
||||
systemErrors = {},
|
||||
errorMessages = {},
|
||||
encoding = 'utf8',
|
||||
}) {
|
||||
const logTrace = (...args) => trace.logTrace('fetch', ...args)
|
||||
@@ -58,8 +51,7 @@ class BaseYamlService extends BaseService {
|
||||
const { buffer } = await this._request({
|
||||
url,
|
||||
options: mergedOptions,
|
||||
httpErrors,
|
||||
systemErrors,
|
||||
errorMessages,
|
||||
})
|
||||
let parsed
|
||||
try {
|
||||
|
||||
@@ -226,7 +226,7 @@ class BaseService {
|
||||
this._metricHelper = metricHelper
|
||||
}
|
||||
|
||||
async _request({ url, options = {}, httpErrors = {}, systemErrors = {} }) {
|
||||
async _request({ url, options = {}, errorMessages = {} }) {
|
||||
const logTrace = (...args) => trace.logTrace('fetch', ...args)
|
||||
let logUrl = url
|
||||
const logOptions = Object.assign({}, options)
|
||||
@@ -246,14 +246,10 @@ class BaseService {
|
||||
'Request',
|
||||
`${logUrl}\n${JSON.stringify(logOptions, null, 2)}`
|
||||
)
|
||||
const { res, buffer } = await this._requestFetcher(
|
||||
url,
|
||||
options,
|
||||
systemErrors
|
||||
)
|
||||
const { res, buffer } = await this._requestFetcher(url, options)
|
||||
await this._meterResponse(res, buffer)
|
||||
logTrace(emojic.dart, 'Response status code', res.statusCode)
|
||||
return checkErrorResponse(httpErrors)({ buffer, res })
|
||||
return checkErrorResponse(errorMessages)({ buffer, res })
|
||||
}
|
||||
|
||||
static enabledMetrics = []
|
||||
@@ -332,15 +328,11 @@ class BaseService {
|
||||
error instanceof Deprecated
|
||||
) {
|
||||
trace.logTrace('outbound', emojic.noGoodWoman, 'Handled error', error)
|
||||
const serviceData = {
|
||||
return {
|
||||
isError: true,
|
||||
message: error.prettyMessage,
|
||||
color: 'lightgray',
|
||||
}
|
||||
if (error.cacheSeconds !== undefined) {
|
||||
serviceData.cacheSeconds = error.cacheSeconds
|
||||
}
|
||||
return serviceData
|
||||
} else if (this._handleInternalErrors) {
|
||||
if (
|
||||
!trace.logTrace(
|
||||
|
||||
@@ -39,7 +39,6 @@ function coalesceCacheLength({
|
||||
assert(defaultCacheLengthSeconds !== undefined)
|
||||
|
||||
const cacheLength = coalesce(
|
||||
serviceOverrideCacheLengthSeconds,
|
||||
serviceDefaultCacheLengthSeconds,
|
||||
defaultCacheLengthSeconds
|
||||
)
|
||||
@@ -47,6 +46,7 @@ function coalesceCacheLength({
|
||||
// Overrides can apply _more_ caching, but not less. Query param overriding
|
||||
// can request more overriding than service override, but not less.
|
||||
const candidateOverrides = [
|
||||
serviceOverrideCacheLengthSeconds,
|
||||
overrideCacheLengthFromQueryParams(queryParams),
|
||||
].filter(x => x !== undefined)
|
||||
|
||||
|
||||
@@ -74,12 +74,12 @@ describe('Cache header functions', function () {
|
||||
serviceDefaultCacheLengthSeconds: 900,
|
||||
serviceOverrideCacheLengthSeconds: 400,
|
||||
queryParams: {},
|
||||
}).expect(400)
|
||||
}).expect(900)
|
||||
given({
|
||||
cacheHeaderConfig,
|
||||
serviceOverrideCacheLengthSeconds: 400,
|
||||
queryParams: {},
|
||||
}).expect(400)
|
||||
}).expect(777)
|
||||
given({
|
||||
cacheHeaderConfig,
|
||||
serviceOverrideCacheLengthSeconds: 900,
|
||||
|
||||
@@ -2,22 +2,21 @@ import { NotFound, InvalidResponse, Inaccessible } from './errors.js'
|
||||
|
||||
const defaultErrorMessages = {
|
||||
404: 'not found',
|
||||
429: 'rate limited by upstream service',
|
||||
}
|
||||
|
||||
export default function checkErrorResponse(httpErrors = {}) {
|
||||
export default function checkErrorResponse(errorMessages = {}) {
|
||||
return async function ({ buffer, res }) {
|
||||
let error
|
||||
httpErrors = { ...defaultErrorMessages, ...httpErrors }
|
||||
errorMessages = { ...defaultErrorMessages, ...errorMessages }
|
||||
if (res.statusCode === 404) {
|
||||
error = new NotFound({ prettyMessage: httpErrors[404] })
|
||||
error = new NotFound({ prettyMessage: errorMessages[404] })
|
||||
} else if (res.statusCode !== 200) {
|
||||
const underlying = Error(
|
||||
`Got status code ${res.statusCode} (expected 200)`
|
||||
)
|
||||
const props = { underlyingError: underlying }
|
||||
if (httpErrors[res.statusCode] !== undefined) {
|
||||
props.prettyMessage = httpErrors[res.statusCode]
|
||||
if (errorMessages[res.statusCode] !== undefined) {
|
||||
props.prettyMessage = errorMessages[res.statusCode]
|
||||
}
|
||||
if (res.statusCode >= 500) {
|
||||
error = new Inaccessible(props)
|
||||
|
||||
@@ -45,42 +45,6 @@ describe('async error handler', function () {
|
||||
})
|
||||
})
|
||||
|
||||
context('when status is 429', function () {
|
||||
const buffer = Buffer.from('some stuff')
|
||||
const res = { statusCode: 429 }
|
||||
|
||||
it('throws InvalidResponse', async function () {
|
||||
try {
|
||||
await checkErrorResponse()({ res, buffer })
|
||||
expect.fail('Expected to throw')
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceof(InvalidResponse)
|
||||
expect(e.message).to.equal(
|
||||
'Invalid Response: Got status code 429 (expected 200)'
|
||||
)
|
||||
expect(e.prettyMessage).to.equal('rate limited by upstream service')
|
||||
expect(e.response).to.equal(res)
|
||||
expect(e.buffer).to.equal(buffer)
|
||||
}
|
||||
})
|
||||
|
||||
it('displays the custom too many requests', async function () {
|
||||
const notFoundMessage = "terribly sorry but that's one too many requests"
|
||||
try {
|
||||
await checkErrorResponse({ 429: notFoundMessage })({ res, buffer })
|
||||
expect.fail('Expected to throw')
|
||||
} catch (e) {
|
||||
expect(e).to.be.an.instanceof(InvalidResponse)
|
||||
expect(e.message).to.equal(
|
||||
'Invalid Response: Got status code 429 (expected 200)'
|
||||
)
|
||||
expect(e.prettyMessage).to.equal(
|
||||
"terribly sorry but that's one too many requests"
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
context('when status is 4xx', function () {
|
||||
it('throws InvalidResponse', async function () {
|
||||
const res = { statusCode: 499 }
|
||||
|
||||
@@ -42,7 +42,6 @@ class ShieldsRuntimeError extends Error {
|
||||
if (props.underlyingError) {
|
||||
this.stack = props.underlyingError.stack
|
||||
}
|
||||
this.cacheSeconds = props.cacheSeconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,9 +206,6 @@ class Deprecated extends ShieldsRuntimeError {
|
||||
* @property {string} prettyMessage User-facing error message to override the
|
||||
* value of `defaultPrettyMessage()`. This is the text that will appear on the
|
||||
* badge when we catch and render the exception (Optional)
|
||||
* @property {number} cacheSeconds Length of time to cache this error response
|
||||
* for. Defaults to the cacheLength of the service class throwing the error
|
||||
* (Optional)
|
||||
*/
|
||||
|
||||
export {
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
const userAgent = getUserAgent()
|
||||
|
||||
async function sendRequest(gotWrapper, url, options = {}, systemErrors = {}) {
|
||||
async function sendRequest(gotWrapper, url, options) {
|
||||
const gotOptions = Object.assign({}, options)
|
||||
gotOptions.throwHttpErrors = false
|
||||
gotOptions.retry = { limit: 0 }
|
||||
@@ -22,12 +22,6 @@ async function sendRequest(gotWrapper, url, options = {}, systemErrors = {}) {
|
||||
underlyingError: new Error('Maximum response size exceeded'),
|
||||
})
|
||||
}
|
||||
if (err.code in systemErrors) {
|
||||
throw new Inaccessible({
|
||||
...systemErrors[err.code],
|
||||
underlyingError: err,
|
||||
})
|
||||
}
|
||||
throw new Inaccessible({ underlyingError: err })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,36 +45,6 @@ describe('got wrapper', function () {
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw a custom error if provided', async function () {
|
||||
const sendRequest = _fetchFactory(1024)
|
||||
return (
|
||||
expect(
|
||||
sendRequest(
|
||||
'https://www.google.com/foo/bar',
|
||||
{ timeout: { request: 1 } },
|
||||
{
|
||||
ETIMEDOUT: {
|
||||
prettyMessage: 'Oh no! A terrible thing has happened',
|
||||
cacheSeconds: 10,
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
.to.be.rejectedWith(
|
||||
Inaccessible,
|
||||
"Inaccessible: Timeout awaiting 'request' for 1ms"
|
||||
)
|
||||
// eslint-disable-next-line promise/prefer-await-to-then
|
||||
.then(error => {
|
||||
expect(error).to.have.property(
|
||||
'prettyMessage',
|
||||
'Oh no! A terrible thing has happened'
|
||||
)
|
||||
expect(error).to.have.property('cacheSeconds', 10)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('should pass a custom user agent header', async function () {
|
||||
nock('https://www.google.com', {
|
||||
reqheaders: {
|
||||
|
||||
@@ -65,7 +65,7 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
|
||||
*/
|
||||
if (match[0] === '/endpoint' && Object.keys(queryParams).length === 0) {
|
||||
ask.res.statusCode = 301
|
||||
ask.res.setHeader('Location', '/badges/endpoint-badge')
|
||||
ask.res.setHeader('Location', '/endpoint/')
|
||||
ask.res.end()
|
||||
return
|
||||
}
|
||||
@@ -73,9 +73,11 @@ function handleRequest(cacheHeaderConfig, handlerOptions) {
|
||||
// `defaultCacheLengthSeconds` can be overridden by
|
||||
// `serviceDefaultCacheLengthSeconds` (either by category or on a badge-
|
||||
// by-badge basis). Then in turn that can be overridden by
|
||||
// `serviceOverrideCacheLengthSeconds`.
|
||||
// Then the `cacheSeconds` query param can also override both of those
|
||||
// but only if `cacheSeconds` is longer.
|
||||
// `serviceOverrideCacheLengthSeconds` (which we expect to be used only in
|
||||
// the dynamic badge) but only if `serviceOverrideCacheLengthSeconds` is
|
||||
// longer than `serviceDefaultCacheLengthSeconds` and then the `cacheSeconds`
|
||||
// query param can also override both of those but again only if `cacheSeconds`
|
||||
// is longer.
|
||||
//
|
||||
// When the legacy services have been rewritten, all the code in here
|
||||
// will go away, which should achieve this goal in a simpler way.
|
||||
|
||||
@@ -148,7 +148,7 @@ describe('The request handler', function () {
|
||||
expect(headers['cache-control']).to.equal('max-age=900, s-maxage=900')
|
||||
})
|
||||
|
||||
it('should allow serviceData to override the default cache headers with longer value', async function () {
|
||||
it('should let live service data override the default cache headers with longer value', async function () {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(
|
||||
@@ -168,7 +168,7 @@ describe('The request handler', function () {
|
||||
expect(headers['cache-control']).to.equal('max-age=400, s-maxage=400')
|
||||
})
|
||||
|
||||
it('should allow serviceData to override the default cache headers with shorter value', async function () {
|
||||
it('should not let live service data override the default cache headers with shorter value', async function () {
|
||||
camp.route(
|
||||
/^\/testing\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
handleRequest(
|
||||
@@ -185,7 +185,7 @@ describe('The request handler', function () {
|
||||
)
|
||||
|
||||
const { headers } = await got(`${baseUrl}/testing/123.json`)
|
||||
expect(headers['cache-control']).to.equal('max-age=200, s-maxage=200')
|
||||
expect(headers['cache-control']).to.equal('max-age=300, s-maxage=300')
|
||||
})
|
||||
|
||||
it('should set the expires header to current time + cacheSeconds', async function () {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import BaseJsonService from '../base-json.js'
|
||||
|
||||
class BadBaseService {}
|
||||
class GoodMixedService extends BaseJsonService {
|
||||
class GoodService extends BaseJsonService {
|
||||
static category = 'build'
|
||||
static route = { base: 'it/is', pattern: 'good' }
|
||||
}
|
||||
class BadMixedService extends BadBaseService {}
|
||||
class BadService extends BadBaseService {}
|
||||
|
||||
export default [GoodMixedService, BadMixedService]
|
||||
export default [GoodService, BadService]
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
class BadNoBaseService {}
|
||||
class BadService {}
|
||||
|
||||
export default BadNoBaseService
|
||||
export default BadService
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
class BadBaseService {}
|
||||
class BadChildService extends BadBaseService {}
|
||||
class BadService extends BadBaseService {}
|
||||
|
||||
export default BadChildService
|
||||
export default BadService
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import BaseJsonService from '../base-json.js'
|
||||
|
||||
class GoodServiceArrayOne extends BaseJsonService {
|
||||
class GoodServiceOne extends BaseJsonService {
|
||||
static category = 'build'
|
||||
static route = { base: 'good', pattern: 'one' }
|
||||
}
|
||||
class GoodServiceArrayTwo extends BaseJsonService {
|
||||
class GoodServiceTwo extends BaseJsonService {
|
||||
static category = 'build'
|
||||
static route = { base: 'good', pattern: 'two' }
|
||||
}
|
||||
|
||||
export default [GoodServiceArrayOne, GoodServiceArrayTwo]
|
||||
export default [GoodServiceOne, GoodServiceTwo]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import BaseJsonService from '../base-json.js'
|
||||
|
||||
class GoodServiceObjectOne extends BaseJsonService {
|
||||
class GoodServiceOne extends BaseJsonService {
|
||||
static category = 'build'
|
||||
static route = { base: 'good', pattern: 'one' }
|
||||
}
|
||||
class GoodServiceObjectTwo extends BaseJsonService {
|
||||
class GoodServiceTwo extends BaseJsonService {
|
||||
static category = 'build'
|
||||
static route = { base: 'good', pattern: 'two' }
|
||||
}
|
||||
|
||||
export { GoodServiceObjectOne, GoodServiceObjectTwo }
|
||||
export { GoodServiceOne, GoodServiceTwo }
|
||||
|
||||
@@ -31,18 +31,6 @@ function getServicePaths(pattern) {
|
||||
return globSync(toUnixPath(path.join(serviceDir, '**', pattern))).sort()
|
||||
}
|
||||
|
||||
function assertNamesUnique(names, { message }) {
|
||||
const duplicates = {}
|
||||
Object.entries(countBy(names))
|
||||
.filter(([name, count]) => count > 1)
|
||||
.forEach(([name, count]) => {
|
||||
duplicates[name] = count
|
||||
})
|
||||
if (Object.keys(duplicates).length) {
|
||||
throw new Error(`${message}: ${JSON.stringify(duplicates, undefined, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServiceClasses(servicePaths) {
|
||||
if (!servicePaths) {
|
||||
servicePaths = getServicePaths('*.service.js')
|
||||
@@ -76,14 +64,29 @@ async function loadServiceClasses(servicePaths) {
|
||||
})
|
||||
}
|
||||
|
||||
return serviceClasses
|
||||
}
|
||||
|
||||
function assertNamesUnique(names, { message }) {
|
||||
const duplicates = {}
|
||||
Object.entries(countBy(names))
|
||||
.filter(([name, count]) => count > 1)
|
||||
.forEach(([name, count]) => {
|
||||
duplicates[name] = count
|
||||
})
|
||||
if (Object.keys(duplicates).length) {
|
||||
throw new Error(`${message}: ${JSON.stringify(duplicates, undefined, 2)}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNames() {
|
||||
const services = await loadServiceClasses()
|
||||
assertNamesUnique(
|
||||
serviceClasses.map(({ name }) => name),
|
||||
services.map(({ name }) => name),
|
||||
{
|
||||
message: 'Duplicate service names found',
|
||||
}
|
||||
)
|
||||
|
||||
return serviceClasses
|
||||
}
|
||||
|
||||
async function collectDefinitions() {
|
||||
@@ -111,6 +114,7 @@ export {
|
||||
InvalidService,
|
||||
loadServiceClasses,
|
||||
getServicePaths,
|
||||
checkNames,
|
||||
collectDefinitions,
|
||||
loadTesters,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const baseUrl = process.env.BASE_URL
|
||||
const baseUrl = process.env.BASE_URL || 'https://img.shields.io'
|
||||
const globalParamRefs = [
|
||||
{ $ref: '#/components/parameters/style' },
|
||||
{ $ref: '#/components/parameters/logo' },
|
||||
@@ -228,7 +228,7 @@ function category2openapi(category, services) {
|
||||
name: 'CC0',
|
||||
},
|
||||
},
|
||||
servers: baseUrl ? [{ url: baseUrl }] : undefined,
|
||||
servers: [{ url: baseUrl }],
|
||||
components: {
|
||||
parameters: {
|
||||
style: {
|
||||
|
||||
@@ -76,6 +76,7 @@ class LegacyService extends BaseJsonService {
|
||||
const expected = {
|
||||
openapi: '3.0.0',
|
||||
info: { version: '1.0.0', title: 'build', license: { name: 'CC0' } },
|
||||
servers: [{ url: 'https://img.shields.io' }],
|
||||
components: {
|
||||
parameters: {
|
||||
style: {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import Joi from 'joi'
|
||||
|
||||
// This should be kept in sync with the schema in
|
||||
// `frontend/lib/service-definitions/index.ts`.
|
||||
|
||||
const arrayOfStrings = Joi.array().items(Joi.string()).min(0).required()
|
||||
|
||||
const objectOfKeyValues = Joi.object()
|
||||
@@ -89,4 +92,9 @@ function assertValidServiceDefinitionExport(examples, message = undefined) {
|
||||
Joi.assert(examples, serviceDefinitionExport, message)
|
||||
}
|
||||
|
||||
export { assertValidServiceDefinition, assertValidServiceDefinitionExport }
|
||||
export {
|
||||
serviceDefinition,
|
||||
assertValidServiceDefinition,
|
||||
serviceDefinitionExport,
|
||||
assertValidServiceDefinitionExport,
|
||||
}
|
||||
|
||||
@@ -362,7 +362,7 @@ class Server {
|
||||
})
|
||||
|
||||
if (!rasterUrl) {
|
||||
camp.route(/^\/((?!img\/)).*\.png$/, (query, match, end, request) => {
|
||||
camp.route(/\.png$/, (query, match, end, request) => {
|
||||
makeSend(
|
||||
'svg',
|
||||
request.res,
|
||||
@@ -412,7 +412,7 @@ class Server {
|
||||
|
||||
if (rasterUrl) {
|
||||
// Redirect to the raster server for raster versions of modern badges.
|
||||
camp.route(/^\/((?!img\/)).*\.png$/, (queryParams, match, end, ask) => {
|
||||
camp.route(/\.png$/, (queryParams, match, end, ask) => {
|
||||
ask.res.statusCode = 301
|
||||
ask.res.setHeader(
|
||||
'Location',
|
||||
|
||||
@@ -98,11 +98,6 @@ describe('The server', function () {
|
||||
)
|
||||
})
|
||||
|
||||
it('should not redirect for PNG requests in /img', async function () {
|
||||
const { statusCode } = await got(`${baseUrl}img/frontend-image.png`)
|
||||
expect(statusCode).to.equal(200)
|
||||
})
|
||||
|
||||
it('should produce SVG badges with expected headers', async function () {
|
||||
const { statusCode, headers } = await got(
|
||||
`${baseUrl}:fruit-apple-green.svg`
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
94
core/token-pooling/redis-token-persistence.integration.js
Normal file
94
core/token-pooling/redis-token-persistence.integration.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import RedisServer from 'redis-server'
|
||||
import Redis from 'ioredis'
|
||||
import { expect } from 'chai'
|
||||
import RedisTokenPersistence from './redis-token-persistence.js'
|
||||
|
||||
describe('Redis token persistence', function () {
|
||||
let server
|
||||
// In CI, expect redis already to be running.
|
||||
if (!process.env.CI) {
|
||||
beforeEach(async function () {
|
||||
server = new RedisServer({ config: { host: 'localhost' } })
|
||||
await server.open()
|
||||
})
|
||||
}
|
||||
|
||||
const key = 'tokenPersistenceIntegrationTest'
|
||||
|
||||
let redis
|
||||
beforeEach(async function () {
|
||||
redis = new Redis()
|
||||
await redis.del(key)
|
||||
})
|
||||
afterEach(async function () {
|
||||
if (redis) {
|
||||
await redis.quit()
|
||||
redis = undefined
|
||||
}
|
||||
})
|
||||
|
||||
if (!process.env.CI) {
|
||||
afterEach(async function () {
|
||||
await server.close()
|
||||
server = undefined
|
||||
})
|
||||
}
|
||||
|
||||
let persistence
|
||||
beforeEach(function () {
|
||||
persistence = new RedisTokenPersistence({ key })
|
||||
})
|
||||
afterEach(async function () {
|
||||
if (persistence) {
|
||||
await persistence.stop()
|
||||
persistence = undefined
|
||||
}
|
||||
})
|
||||
|
||||
context('when the key does not exist', function () {
|
||||
it('does nothing', async function () {
|
||||
const tokens = await persistence.initialize()
|
||||
expect(tokens).to.deep.equal([])
|
||||
})
|
||||
})
|
||||
|
||||
context('when the key exists', function () {
|
||||
const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40))
|
||||
|
||||
beforeEach(async function () {
|
||||
await redis.sadd(key, initialTokens)
|
||||
})
|
||||
|
||||
it('loads the contents', async function () {
|
||||
const tokens = await persistence.initialize()
|
||||
expect(tokens.sort()).to.deep.equal(initialTokens)
|
||||
})
|
||||
|
||||
context('when tokens are added', function () {
|
||||
it('saves the change', async function () {
|
||||
const newToken = 'e'.repeat(40)
|
||||
const expected = initialTokens.slice()
|
||||
expected.push(newToken)
|
||||
|
||||
await persistence.initialize()
|
||||
await persistence.noteTokenAdded(newToken)
|
||||
|
||||
const savedTokens = await redis.smembers(key)
|
||||
expect(savedTokens.sort()).to.deep.equal(expected)
|
||||
})
|
||||
})
|
||||
|
||||
context('when tokens are removed', function () {
|
||||
it('saves the change', async function () {
|
||||
const expected = Array.from(initialTokens)
|
||||
const toRemove = expected.pop()
|
||||
|
||||
await persistence.initialize()
|
||||
await persistence.noteTokenRemoved(toRemove)
|
||||
|
||||
const savedTokens = await redis.smembers(key)
|
||||
expect(savedTokens.sort()).to.deep.equal(expected)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
57
core/token-pooling/redis-token-persistence.js
Normal file
57
core/token-pooling/redis-token-persistence.js
Normal file
@@ -0,0 +1,57 @@
|
||||
import { URL } from 'url'
|
||||
import Redis from 'ioredis'
|
||||
import log from '../server/log.js'
|
||||
|
||||
export default class RedisTokenPersistence {
|
||||
constructor({ url, key }) {
|
||||
this.url = url
|
||||
this.key = key
|
||||
this.noteTokenAdded = this.noteTokenAdded.bind(this)
|
||||
this.noteTokenRemoved = this.noteTokenRemoved.bind(this)
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
const options =
|
||||
this.url && this.url.startsWith('rediss:')
|
||||
? {
|
||||
// https://www.compose.com/articles/ssl-connections-arrive-for-redis-on-compose/
|
||||
tls: { servername: new URL(this.url).hostname },
|
||||
}
|
||||
: undefined
|
||||
this.redis = new Redis(this.url, options)
|
||||
this.redis.on('error', e => {
|
||||
log.error(e)
|
||||
})
|
||||
|
||||
const tokens = await this.redis.smembers(this.key)
|
||||
return tokens
|
||||
}
|
||||
|
||||
async stop() {
|
||||
await this.redis.quit()
|
||||
}
|
||||
|
||||
async onTokenAdded(token) {
|
||||
await this.redis.sadd(this.key, token)
|
||||
}
|
||||
|
||||
async onTokenRemoved(token) {
|
||||
await this.redis.srem(this.key, token)
|
||||
}
|
||||
|
||||
async noteTokenAdded(token) {
|
||||
try {
|
||||
await this.onTokenAdded(token)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async noteTokenRemoved(token) {
|
||||
try {
|
||||
await this.onTokenRemoved(token)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,16 @@ import { registerCommand } from 'cypress-wait-for-stable-dom'
|
||||
|
||||
registerCommand()
|
||||
|
||||
describe('Frontend', function () {
|
||||
describe('Main page', function () {
|
||||
const backendUrl = Cypress.env('backend_url')
|
||||
const SEARCH_INPUT = 'input[placeholder="Search"]'
|
||||
const SEARCH_INPUT = 'input[placeholder="search"]'
|
||||
|
||||
function expectBadgeExample(title, previewUrl, pattern) {
|
||||
cy.contains('tr', `${title}:`).find('code').should('have.text', pattern)
|
||||
cy.contains('tr', `${title}:`)
|
||||
.find('img')
|
||||
.should('have.attr', 'src', previewUrl)
|
||||
}
|
||||
|
||||
function visitAndWait(page) {
|
||||
cy.visit(page)
|
||||
@@ -19,37 +26,36 @@ describe('Frontend', function () {
|
||||
cy.contains('PyPI - License')
|
||||
})
|
||||
|
||||
it('Shows badges from category', function () {
|
||||
visitAndWait('/badges')
|
||||
it('Shows badge from category', function () {
|
||||
visitAndWait('/category/chat')
|
||||
|
||||
cy.contains('Build')
|
||||
cy.contains('Chat').click()
|
||||
|
||||
cy.contains('Discourse status')
|
||||
cy.contains('Stack Exchange questions')
|
||||
expectBadgeExample(
|
||||
'Discourse status',
|
||||
'http://localhost:8080/badge/discourse-online-brightgreen',
|
||||
'/discourse/status?server=https%3A%2F%2Fmeta.discourse.org'
|
||||
)
|
||||
})
|
||||
|
||||
it('Shows expected code examples', function () {
|
||||
visitAndWait('/badges/static-badge')
|
||||
it('Customizate badges', function () {
|
||||
visitAndWait('/')
|
||||
|
||||
cy.contains('button', 'URL').should('have.class', 'api-code-tab')
|
||||
cy.contains('button', 'Markdown').should('have.class', 'api-code-tab')
|
||||
cy.contains('button', 'rSt').should('have.class', 'api-code-tab')
|
||||
cy.contains('button', 'AsciiDoc').should('have.class', 'api-code-tab')
|
||||
cy.contains('button', 'HTML').should('have.class', 'api-code-tab')
|
||||
cy.get(SEARCH_INPUT).type('issues')
|
||||
|
||||
cy.contains('/github/issues/:user/:repo').click()
|
||||
|
||||
cy.get('input[name="user"]').type('badges')
|
||||
cy.get('input[name="repo"]').type('shields')
|
||||
cy.get('table input[name="color"]').type('orange')
|
||||
|
||||
cy.get(`img[src='${backendUrl}/github/issues/badges/shields?color=orange']`)
|
||||
})
|
||||
|
||||
it('Build a badge', function () {
|
||||
visitAndWait('/badges/git-hub-issues')
|
||||
it('Do not duplicate example parameters', function () {
|
||||
visitAndWait('/category/funding')
|
||||
|
||||
cy.contains('/github/issues/:user/:repo')
|
||||
|
||||
cy.get('input[placeholder="user"]').type('badges')
|
||||
cy.get('input[placeholder="repo"]').type('shields')
|
||||
|
||||
cy.intercept('GET', `${backendUrl}/github/issues/badges/shields`).as('get')
|
||||
cy.contains('Execute').click()
|
||||
cy.wait('@get').its('response.statusCode').should('eq', 200)
|
||||
cy.get('img[id="badge-preview"]')
|
||||
cy.contains('GitHub Sponsors').click()
|
||||
cy.get('[name="style"]').should($style => {
|
||||
expect($style).to.have.length(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,8 +15,8 @@ const { fileMatch } = danger.git
|
||||
|
||||
const documentation = fileMatch(
|
||||
'**/*.md',
|
||||
'frontend/docs/**',
|
||||
'frontend/src/**'
|
||||
'frontend/components/usage.tsx',
|
||||
'frontend/pages/endpoint.tsx'
|
||||
)
|
||||
const server = fileMatch('core/server/**.js', '!*.spec.js')
|
||||
const serverTests = fileMatch('core/server/**.spec.js')
|
||||
|
||||
@@ -44,7 +44,7 @@ In case you get the _"getaddrinfo ENOTFOUND localhost"_ error, visit [http://127
|
||||
|
||||
## (3) Open an Issue
|
||||
|
||||
Before you want to implement your service, you may want to [open an issue](https://github.com/badges/shields/issues/new?template=3_Badge_request.yml) and describe what you have in mind:
|
||||
Before you want to implement your service, you may want to [open an issue](https://github.com/badges/shields/issues/new?template=3_Badge_request.md) and describe what you have in mind:
|
||||
|
||||
- What is the badge for?
|
||||
- Which API do you want to use?
|
||||
@@ -229,14 +229,14 @@ Description of the code:
|
||||
|
||||
- `_requestJson()` automatically adds an Accept header, checks the status code, parses the response as JSON, and returns the parsed response.
|
||||
- `_requestJson()` uses [got](https://github.com/sindresorhus/got) to perform the HTTP request. Options can be passed to got, including method, query string, and headers. If headers are provided they will override the ones automatically set by `_requestJson()`. There is no need to specify json, as the JSON parsing is handled by `_requestJson()`. See the `got` docs for [supported options](https://github.com/sindresorhus/got/blob/main/documentation/2-options.md).
|
||||
- Error messages corresponding to each status code can be returned by passing a dictionary of status codes -> messages in `httpErrors`.
|
||||
- Error messages corresponding to each status code can be returned by passing a dictionary of status codes -> messages in `errorMessages`.
|
||||
- A more complex call to `_requestJson()` might look like this:
|
||||
```js
|
||||
return this._requestJson({
|
||||
schema: mySchema,
|
||||
url,
|
||||
options: { searchParams: { branch: 'master' } },
|
||||
httpErrors: {
|
||||
errorMessages: {
|
||||
401: 'private application not supported',
|
||||
404: 'application not found',
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
The Shields codebase is divided into several parts:
|
||||
|
||||
1. The frontend
|
||||
1. The frontend (about 7% of the code)
|
||||
1. [`frontend`][frontend]
|
||||
2. The badge renderer (which is available as an npm package)
|
||||
1. [`badge-maker`][badge-maker]
|
||||
@@ -30,16 +30,16 @@ The Shields codebase is divided into several parts:
|
||||
|
||||
The tests are also divided into several parts:
|
||||
|
||||
1. Unit and functional tests of the badge renderer
|
||||
1. Unit and functional tests of the frontend
|
||||
1. `frontend/**/*.spec.js`
|
||||
2. Unit and functional tests of the badge renderer
|
||||
1. `badge-maker/**/*.spec.js`
|
||||
2. Unit and functional tests of the core code
|
||||
3. Unit and functional tests of the core code
|
||||
1. `core/**/*.spec.js`
|
||||
3. Unit and functional tests of the service helper functions
|
||||
4. Unit and functional tests of the service helper functions
|
||||
1. `services/*.spec.js`
|
||||
4. Unit and functional tests of the service code (we have only a few of these)
|
||||
5. Unit and functional tests of the service code (we have only a few of these)
|
||||
1. `services/*/**/*.spec.js`
|
||||
5. End-to-end tests for the frontend
|
||||
1. `cypress/e2e/*.cy.js`
|
||||
6. The service tester and service test runner
|
||||
1. [`core/service-test-runner`][service-test-runner]
|
||||
7. [The service tests themselves][service tests] live integration tests of the
|
||||
|
||||
@@ -90,8 +90,6 @@ Past that point, all related code will be deleted, and a not found error will be
|
||||
|
||||
Here is a listing of all deleted badges that were once part of the Shields.io service:
|
||||
|
||||
- Beerpay
|
||||
- Bintray
|
||||
- bitHound
|
||||
- Cauditor
|
||||
- CocoaPods Apps
|
||||
@@ -99,8 +97,6 @@ Here is a listing of all deleted badges that were once part of the Shields.io se
|
||||
- Codetally
|
||||
- continuousphp
|
||||
- Coverity
|
||||
- David
|
||||
- dependabot
|
||||
- Dockbit
|
||||
- Dotnet Status
|
||||
- Gemnasium
|
||||
@@ -111,11 +107,8 @@ Here is a listing of all deleted badges that were once part of the Shields.io se
|
||||
- Leanpub
|
||||
- Libscore
|
||||
- Magnum CI
|
||||
- MicroBadger
|
||||
- NSP
|
||||
- PHP Eye
|
||||
- requires.io
|
||||
- Shippable
|
||||
- Snap CI
|
||||
- VersionEye
|
||||
- Waffle
|
||||
|
||||
@@ -155,13 +155,13 @@ These are documented in [server-secrets.md](./server-secrets.md)
|
||||
If you want to host the frontend on a separate server, such as cloud storage
|
||||
or a CDN, you can do that.
|
||||
|
||||
First, build the frontend, pointing `BASE_URL` to your server.
|
||||
First, build the frontend, pointing `GATSBY_BASE_URL` to your server.
|
||||
|
||||
```sh
|
||||
BASE_URL=https://your-server.example.com npm run build
|
||||
GATSBY_BASE_URL=https://your-server.example.com npm run build
|
||||
```
|
||||
|
||||
Then copy the contents of the `public/` folder to your static hosting / CDN.
|
||||
Then copy the contents of the `build/` folder to your static hosting / CDN.
|
||||
|
||||
There are also a couple settings you should configure on the server.
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ npm run test:services -- --only="wercker" --fgrep="Build status (with branch)"
|
||||
Having covered the typical and custom cases, we'll move on to errors. We should include a test for the 'not found' response and also tests for any other custom error handling. The Wercker integration defines a custom error condition for 401 as well as a custom 404 message:
|
||||
|
||||
```js
|
||||
httpErrors: {
|
||||
errorMessages: {
|
||||
401: 'private application not supported',
|
||||
404: 'application not found',
|
||||
}
|
||||
|
||||
47
fly.toml
47
fly.toml
@@ -1,47 +0,0 @@
|
||||
app = "shields-io-review-apps"
|
||||
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
[env]
|
||||
HTTPS="false"
|
||||
GITLAB_ORIGINS = "https://gitlab.com"
|
||||
METRICS_PROMETHEUS_ENABLED = "false"
|
||||
REQUEST_TIMEOUT_SECONDS = "20"
|
||||
REQUIRE_CLOUDFLARE = "false"
|
||||
USER_AGENT_BASE = "Shields-Review-App"
|
||||
|
||||
[deploy]
|
||||
strategy = "immediate"
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 80
|
||||
processes = ["app"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
type = "connections"
|
||||
|
||||
[[services.ports]]
|
||||
force_https = true
|
||||
handlers = ["http"]
|
||||
port = 80
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = 443
|
||||
|
||||
[[services.tcp_checks]]
|
||||
grace_period = "1s"
|
||||
interval = "15s"
|
||||
restart_limit = 0
|
||||
timeout = "2s"
|
||||
@@ -1,3 +0,0 @@
|
||||
module.exports = {
|
||||
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
|
||||
}
|
||||
108
frontend/components/badge-examples.tsx
Normal file
108
frontend/components/badge-examples.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import {
|
||||
badgeUrlFromPath,
|
||||
staticBadgeUrl,
|
||||
} from '../../core/badge-urls/make-badge-url'
|
||||
import { removeRegexpFromPattern } from '../lib/pattern-helpers'
|
||||
import {
|
||||
Example as ExampleData,
|
||||
RenderableExample,
|
||||
} from '../lib/service-definitions'
|
||||
import { Badge } from './common'
|
||||
import { StyledCode } from './snippet'
|
||||
|
||||
const ExampleTable = styled.table`
|
||||
min-width: 50%;
|
||||
margin: auto;
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
}
|
||||
`
|
||||
|
||||
const ClickableTh = styled.th`
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
const ClickableCode = styled(StyledCode)`
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
function Example({
|
||||
baseUrl,
|
||||
onClick,
|
||||
exampleData,
|
||||
}: {
|
||||
baseUrl?: string
|
||||
onClick: (example: RenderableExample) => void
|
||||
exampleData: RenderableExample
|
||||
}): JSX.Element {
|
||||
const handleClick = React.useCallback(
|
||||
function (): void {
|
||||
onClick(exampleData)
|
||||
},
|
||||
[exampleData, onClick]
|
||||
)
|
||||
|
||||
const {
|
||||
example: { pattern, queryParams },
|
||||
preview: { label, message, color, style, namedLogo },
|
||||
} = exampleData as ExampleData
|
||||
const previewUrl = staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: label || '',
|
||||
message,
|
||||
color,
|
||||
style,
|
||||
namedLogo,
|
||||
})
|
||||
const exampleUrl = badgeUrlFromPath({
|
||||
path: removeRegexpFromPattern(pattern),
|
||||
queryParams,
|
||||
})
|
||||
|
||||
const { title } = exampleData
|
||||
return (
|
||||
<tr>
|
||||
<ClickableTh onClick={handleClick}>{title}:</ClickableTh>
|
||||
<td>
|
||||
<Badge
|
||||
alt={`${title} badge`}
|
||||
clickable
|
||||
onClick={handleClick}
|
||||
src={previewUrl}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<ClickableCode onClick={handleClick}>{exampleUrl}</ClickableCode>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
export function BadgeExamples({
|
||||
examples,
|
||||
baseUrl,
|
||||
onClick,
|
||||
}: {
|
||||
examples: RenderableExample[]
|
||||
baseUrl?: string
|
||||
onClick: (exampleData: RenderableExample) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<ExampleTable>
|
||||
<tbody>
|
||||
{examples.map(exampleData => (
|
||||
<Example
|
||||
baseUrl={baseUrl}
|
||||
exampleData={exampleData}
|
||||
key={`${exampleData.title} ${exampleData.example.pattern}`}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</ExampleTable>
|
||||
)
|
||||
}
|
||||
84
frontend/components/category-headings.tsx
Normal file
84
frontend/components/category-headings.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Link } from 'gatsby'
|
||||
import { H3 } from './common'
|
||||
|
||||
export interface Category {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export function CategoryHeading({
|
||||
category: { id, name },
|
||||
}: {
|
||||
category: Category
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Link to={`/category/${id}`}>
|
||||
<H3 id={id}>{name}</H3>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function CategoryHeadings({
|
||||
categories,
|
||||
}: {
|
||||
categories: Category[]
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
{categories.map(category => (
|
||||
<CategoryHeading category={category} key={category.id} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StyledNav = styled.nav`
|
||||
ul {
|
||||
display: flex;
|
||||
|
||||
min-width: 50%;
|
||||
max-width: 500px;
|
||||
|
||||
margin: 0 auto 20px;
|
||||
padding-inline-start: 0;
|
||||
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
ul {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 4px 10px;
|
||||
}
|
||||
|
||||
.active {
|
||||
font-weight: 900;
|
||||
}
|
||||
`
|
||||
|
||||
export function CategoryNav({
|
||||
categories,
|
||||
}: {
|
||||
categories: Category[]
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<StyledNav>
|
||||
<ul>
|
||||
{categories.map(({ id, name }) => (
|
||||
<li key={id}>
|
||||
<Link to={`/category/${id}`}>{name}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</StyledNav>
|
||||
)
|
||||
}
|
||||
125
frontend/components/common.tsx
Normal file
125
frontend/components/common.tsx
Normal file
@@ -0,0 +1,125 @@
|
||||
import React from 'react'
|
||||
import styled, { css, createGlobalStyle } from 'styled-components'
|
||||
|
||||
export const noAutocorrect = Object.freeze({
|
||||
autoComplete: 'off',
|
||||
autoCorrect: 'off',
|
||||
autoCapitalize: 'off',
|
||||
spellcheck: 'false',
|
||||
})
|
||||
|
||||
export const nonBreakingSpace = '\u00a0'
|
||||
|
||||
export const GlobalStyle = createGlobalStyle`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
`
|
||||
|
||||
export const BaseFont = styled.div`
|
||||
font-family: Lekton, sans-serif;
|
||||
color: #534;
|
||||
`
|
||||
|
||||
export const H2 = styled.h2`
|
||||
font-style: italic;
|
||||
|
||||
margin-top: 12mm;
|
||||
font-variant: small-caps;
|
||||
|
||||
::before {
|
||||
content: '☙ ';
|
||||
}
|
||||
|
||||
::after {
|
||||
content: ' ❧';
|
||||
}
|
||||
`
|
||||
|
||||
export const H3 = styled.h3`
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
interface BadgeWrapperProps {
|
||||
height: string
|
||||
display: string
|
||||
clickable: boolean
|
||||
}
|
||||
|
||||
const BadgeWrapper = styled.span<BadgeWrapperProps>`
|
||||
padding: 2px;
|
||||
height: ${({ height }) => height};
|
||||
vertical-align: middle;
|
||||
display: ${({ display }) => display};
|
||||
|
||||
${({ clickable }) =>
|
||||
clickable &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
`};
|
||||
`
|
||||
|
||||
interface BadgeProps extends React.HTMLAttributes<HTMLImageElement> {
|
||||
src: string
|
||||
alt?: string
|
||||
display?: 'inline' | 'block' | 'inline-block'
|
||||
height?: string
|
||||
clickable?: boolean
|
||||
object?: boolean
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
src,
|
||||
alt = '',
|
||||
display = 'inline',
|
||||
height = '20px',
|
||||
clickable = false,
|
||||
object = false,
|
||||
...rest
|
||||
}: BadgeProps): JSX.Element {
|
||||
return (
|
||||
<BadgeWrapper clickable={clickable} display={display} height={height}>
|
||||
{src ? (
|
||||
object ? (
|
||||
<object data={src}>alt</object>
|
||||
) : (
|
||||
<img alt={alt} src={src} {...rest} />
|
||||
)
|
||||
) : (
|
||||
nonBreakingSpace
|
||||
)}
|
||||
</BadgeWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export const StyledInput = styled.input`
|
||||
height: 15px;
|
||||
border: solid #b9a;
|
||||
border-width: 0 0 1px 0;
|
||||
padding: 0;
|
||||
|
||||
text-align: center;
|
||||
|
||||
color: #534;
|
||||
|
||||
:focus {
|
||||
outline: 0;
|
||||
}
|
||||
`
|
||||
|
||||
export const InlineInput = styled(StyledInput)`
|
||||
width: 70px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
`
|
||||
|
||||
export const BlockInput = styled(StyledInput)`
|
||||
width: 40%;
|
||||
background-color: transparent;
|
||||
`
|
||||
|
||||
export const VerticalSpace = styled.hr`
|
||||
border: 0;
|
||||
display: block;
|
||||
height: 3mm;
|
||||
`
|
||||
46
frontend/components/customizer/builder-common.tsx
Normal file
46
frontend/components/customizer/builder-common.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const BuilderOuterContainer = styled.div`
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
`
|
||||
|
||||
// The inner container is inline-block so that its width matches its columns.
|
||||
const BuilderInnerContainer = styled.div`
|
||||
display: inline-block;
|
||||
|
||||
padding: 1px 14px 10px;
|
||||
|
||||
border-radius: 4px;
|
||||
background: #eef;
|
||||
`
|
||||
|
||||
export function BuilderContainer({
|
||||
children,
|
||||
}: {
|
||||
children: JSX.Element[] | JSX.Element
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<BuilderOuterContainer>
|
||||
<BuilderInnerContainer>{children}</BuilderInnerContainer>
|
||||
</BuilderOuterContainer>
|
||||
)
|
||||
}
|
||||
|
||||
const labelFont = `
|
||||
font-family: system-ui;
|
||||
font-size: 11px;
|
||||
`
|
||||
|
||||
export const BuilderLabel = styled.label`
|
||||
${labelFont}
|
||||
|
||||
text-transform: lowercase;
|
||||
`
|
||||
|
||||
export const BuilderCaption = styled.span`
|
||||
${labelFont}
|
||||
|
||||
color: #999;
|
||||
`
|
||||
73
frontend/components/customizer/copied-content-indicator.tsx
Normal file
73
frontend/components/customizer/copied-content-indicator.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import React, { useState, useImperativeHandle, forwardRef } from 'react'
|
||||
import posed from 'react-pose'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const ContentAnchor = styled.span`
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
`
|
||||
|
||||
// 100vw allows providing styled content which is wider than its container.
|
||||
const ContentContainer = styled.span`
|
||||
width: 100vw;
|
||||
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
|
||||
will-change: opacity, top;
|
||||
|
||||
pointer-events: none;
|
||||
`
|
||||
|
||||
const PosedContentContainer = posed(ContentContainer)({
|
||||
hidden: { opacity: 0, transition: { duration: 100 } },
|
||||
effectStart: { top: '-10px', opacity: 1.0, transition: { duration: 0 } },
|
||||
effectEnd: { top: '-75px', opacity: 0.5 },
|
||||
})
|
||||
|
||||
export interface CopiedContentIndicatorHandle {
|
||||
trigger: () => void
|
||||
}
|
||||
|
||||
// When `trigger()` is called, render copied content that floats up, then
|
||||
// disappears.
|
||||
function _CopiedContentIndicator(
|
||||
{
|
||||
copiedContent,
|
||||
children,
|
||||
}: {
|
||||
copiedContent: JSX.Element | string
|
||||
children: JSX.Element | JSX.Element[]
|
||||
},
|
||||
ref: React.Ref<CopiedContentIndicatorHandle>
|
||||
): JSX.Element {
|
||||
const [pose, setPose] = useState('hidden')
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
trigger() {
|
||||
setPose('effectStart')
|
||||
},
|
||||
}))
|
||||
|
||||
const handlePoseComplete = React.useCallback(
|
||||
function (): void {
|
||||
if (pose === 'effectStart') {
|
||||
setPose('effectEnd')
|
||||
} else {
|
||||
setPose('hidden')
|
||||
}
|
||||
},
|
||||
[pose, setPose]
|
||||
)
|
||||
|
||||
return (
|
||||
<ContentAnchor>
|
||||
<PosedContentContainer onPoseComplete={handlePoseComplete} pose={pose}>
|
||||
{copiedContent}
|
||||
</PosedContentContainer>
|
||||
{children}
|
||||
</ContentAnchor>
|
||||
)
|
||||
}
|
||||
export const CopiedContentIndicator = forwardRef(_CopiedContentIndicator)
|
||||
156
frontend/components/customizer/customizer.tsx
Normal file
156
frontend/components/customizer/customizer.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import clipboardCopy from 'clipboard-copy'
|
||||
import { staticBadgeUrl } from '../../../core/badge-urls/make-badge-url'
|
||||
import { generateMarkup, MarkupFormat } from '../../lib/generate-image-markup'
|
||||
import { Badge } from '../common'
|
||||
import PathBuilder from './path-builder'
|
||||
import QueryStringBuilder from './query-string-builder'
|
||||
import RequestMarkupButtom from './request-markup-button'
|
||||
import {
|
||||
CopiedContentIndicator,
|
||||
CopiedContentIndicatorHandle,
|
||||
} from './copied-content-indicator'
|
||||
|
||||
export default function Customizer({
|
||||
baseUrl,
|
||||
title,
|
||||
pattern,
|
||||
exampleNamedParams,
|
||||
exampleQueryParams,
|
||||
initialStyle,
|
||||
}: {
|
||||
baseUrl: string
|
||||
title: string
|
||||
pattern: string
|
||||
exampleNamedParams: { [k: string]: string }
|
||||
exampleQueryParams: { [k: string]: string }
|
||||
initialStyle?: string
|
||||
}): JSX.Element {
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041
|
||||
const indicatorRef =
|
||||
useRef<CopiedContentIndicatorHandle>() as React.MutableRefObject<CopiedContentIndicatorHandle>
|
||||
const [path, setPath] = useState('')
|
||||
const [queryString, setQueryString] = useState<string>()
|
||||
const [pathIsComplete, setPathIsComplete] = useState<boolean>()
|
||||
const [markup, setMarkup] = useState<string>()
|
||||
const [message, setMessage] = useState<string>()
|
||||
|
||||
const generateBuiltBadgeUrl = React.useCallback(
|
||||
function (): string {
|
||||
const suffix = queryString ? `?${queryString}` : ''
|
||||
return `${baseUrl}${path}${suffix}`
|
||||
},
|
||||
[baseUrl, path, queryString]
|
||||
)
|
||||
|
||||
function renderLivePreview(): JSX.Element {
|
||||
// There are some usability issues here. It would be better if the message
|
||||
// changed from a validation error to a loading message once the
|
||||
// parameters were filled in, and also switched back to loading when the
|
||||
// parameters changed.
|
||||
let src
|
||||
if (pathIsComplete) {
|
||||
src = generateBuiltBadgeUrl()
|
||||
} else {
|
||||
src = staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: 'preview',
|
||||
message: 'some parameters missing',
|
||||
})
|
||||
}
|
||||
return (
|
||||
<p>
|
||||
<Badge alt="preview badge" display="block" src={src} />
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
const copyMarkup = React.useCallback(
|
||||
async function (markupFormat: MarkupFormat): Promise<void> {
|
||||
const builtBadgeUrl = generateBuiltBadgeUrl()
|
||||
const markup = generateMarkup({
|
||||
badgeUrl: builtBadgeUrl,
|
||||
title,
|
||||
markupFormat,
|
||||
})
|
||||
|
||||
try {
|
||||
await clipboardCopy(markup)
|
||||
} catch (e) {
|
||||
setMessage('Copy failed')
|
||||
setMarkup(markup)
|
||||
return
|
||||
}
|
||||
|
||||
setMarkup(markup)
|
||||
if (indicatorRef.current) {
|
||||
indicatorRef.current.trigger()
|
||||
}
|
||||
},
|
||||
[generateBuiltBadgeUrl, title, setMessage, setMarkup]
|
||||
)
|
||||
|
||||
function renderMarkupAndLivePreview(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
{renderLivePreview()}
|
||||
<CopiedContentIndicator copiedContent="Copied" ref={indicatorRef}>
|
||||
<RequestMarkupButtom
|
||||
isDisabled={!pathIsComplete}
|
||||
onMarkupRequested={copyMarkup}
|
||||
/>
|
||||
</CopiedContentIndicator>
|
||||
{message && (
|
||||
<div>
|
||||
<p>{message}</p>
|
||||
<p>Markup: {markup}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const handlePathChange = React.useCallback(
|
||||
function ({
|
||||
path,
|
||||
isComplete,
|
||||
}: {
|
||||
path: string
|
||||
isComplete: boolean
|
||||
}): void {
|
||||
setPath(path)
|
||||
setPathIsComplete(isComplete)
|
||||
},
|
||||
[setPath, setPathIsComplete]
|
||||
)
|
||||
|
||||
const handleQueryStringChange = React.useCallback(
|
||||
function ({
|
||||
queryString,
|
||||
isComplete,
|
||||
}: {
|
||||
queryString: string
|
||||
isComplete: boolean
|
||||
}): void {
|
||||
setQueryString(queryString)
|
||||
},
|
||||
[setQueryString]
|
||||
)
|
||||
|
||||
return (
|
||||
<form action="">
|
||||
<PathBuilder
|
||||
exampleParams={exampleNamedParams}
|
||||
onChange={handlePathChange}
|
||||
pattern={pattern}
|
||||
/>
|
||||
<QueryStringBuilder
|
||||
exampleParams={exampleQueryParams}
|
||||
initialStyle={initialStyle}
|
||||
onChange={handleQueryStringChange}
|
||||
/>
|
||||
<div>{renderMarkupAndLivePreview()}</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
258
frontend/components/customizer/path-builder.tsx
Normal file
258
frontend/components/customizer/path-builder.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useState, useEffect, ChangeEvent } from 'react'
|
||||
import styled, { css } from 'styled-components'
|
||||
import { Token, Key, parse } from 'path-to-regexp'
|
||||
import humanizeString from 'humanize-string'
|
||||
import { patternToOptions } from '../../lib/pattern-helpers'
|
||||
import { noAutocorrect, StyledInput } from '../common'
|
||||
import {
|
||||
BuilderContainer,
|
||||
BuilderLabel,
|
||||
BuilderCaption,
|
||||
} from './builder-common'
|
||||
|
||||
interface PathBuilderColumnProps {
|
||||
pathContainsOnlyLiterals: boolean
|
||||
withHorizPadding?: boolean
|
||||
}
|
||||
|
||||
const PathBuilderColumn = styled.span<PathBuilderColumnProps>`
|
||||
height: ${({ pathContainsOnlyLiterals }) =>
|
||||
pathContainsOnlyLiterals ? '18px' : '78px'};
|
||||
|
||||
float: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
margin: 0;
|
||||
|
||||
${({ withHorizPadding }) =>
|
||||
withHorizPadding &&
|
||||
css`
|
||||
padding: 0 8px;
|
||||
`};
|
||||
`
|
||||
|
||||
interface PathLiteralProps {
|
||||
isFirstToken: boolean
|
||||
pathContainsOnlyLiterals: boolean
|
||||
}
|
||||
|
||||
const PathLiteral = styled.div<PathLiteralProps>`
|
||||
margin-top: ${({ pathContainsOnlyLiterals }) =>
|
||||
pathContainsOnlyLiterals ? '0px' : '39px'};
|
||||
${({ isFirstToken }) =>
|
||||
isFirstToken &&
|
||||
css`
|
||||
margin-left: 3px;
|
||||
`};
|
||||
`
|
||||
|
||||
const NamedParamLabelContainer = styled.span`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 37px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
`
|
||||
|
||||
const inputStyling = `
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
// 2px to align with input boxes alongside.
|
||||
const NamedParamInput = styled(StyledInput)`
|
||||
${inputStyling}
|
||||
margin-top: 2px;
|
||||
margin-bottom: 10px;
|
||||
`
|
||||
|
||||
const NamedParamSelect = styled.select`
|
||||
${inputStyling}
|
||||
margin-bottom: 9px;
|
||||
font-size: 10px;
|
||||
`
|
||||
|
||||
const NamedParamCaption = styled(BuilderCaption)`
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
export function constructPath({
|
||||
tokens,
|
||||
namedParams,
|
||||
}: {
|
||||
tokens: Token[]
|
||||
namedParams: { [k: string]: string }
|
||||
}): { path: string; isComplete: boolean } {
|
||||
let isComplete = true
|
||||
let path = tokens
|
||||
.map(token => {
|
||||
if (typeof token === 'string') {
|
||||
return token.trim()
|
||||
} else {
|
||||
const { prefix, name, modifier } = token
|
||||
const value = namedParams[name]
|
||||
if (value) {
|
||||
return `${prefix}${value.trim()}`
|
||||
} else if (modifier === '?' || modifier === '*') {
|
||||
return ''
|
||||
} else {
|
||||
isComplete = false
|
||||
return `${prefix}:${name}`
|
||||
}
|
||||
}
|
||||
})
|
||||
.join('')
|
||||
path = encodeURI(path)
|
||||
return { path, isComplete }
|
||||
}
|
||||
|
||||
export default function PathBuilder({
|
||||
pattern,
|
||||
exampleParams,
|
||||
onChange,
|
||||
}: {
|
||||
pattern: string
|
||||
exampleParams: { [k: string]: string }
|
||||
onChange: ({
|
||||
path,
|
||||
isComplete,
|
||||
}: {
|
||||
path: string
|
||||
isComplete: boolean
|
||||
}) => void
|
||||
}): JSX.Element {
|
||||
const [tokens] = useState(() => parse(pattern))
|
||||
const [namedParams, setNamedParams] = useState(() =>
|
||||
// `pathToRegexp.parse()` returns a mixed array of strings for literals
|
||||
// and objects for parameters. Filter out the literals and work with the
|
||||
// objects.
|
||||
tokens
|
||||
.filter(t => typeof t !== 'string')
|
||||
.map(t => t as Key)
|
||||
.reduce((accum, { name }) => {
|
||||
accum[name] = ''
|
||||
return accum
|
||||
}, {} as { [k: string]: string })
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
// Ensure the default style is applied right away.
|
||||
if (onChange) {
|
||||
const { path, isComplete } = constructPath({ tokens, namedParams })
|
||||
onChange({ path, isComplete })
|
||||
}
|
||||
}, [tokens, namedParams, onChange])
|
||||
|
||||
const handleTokenChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setNamedParams({
|
||||
...namedParams,
|
||||
[name]: value,
|
||||
})
|
||||
},
|
||||
[setNamedParams, namedParams]
|
||||
)
|
||||
|
||||
function renderLiteral(
|
||||
literal: string,
|
||||
tokenIndex: number,
|
||||
pathContainsOnlyLiterals: boolean
|
||||
): JSX.Element {
|
||||
return (
|
||||
<PathBuilderColumn
|
||||
key={`${tokenIndex}-${literal}`}
|
||||
pathContainsOnlyLiterals={pathContainsOnlyLiterals}
|
||||
>
|
||||
<PathLiteral
|
||||
isFirstToken={tokenIndex === 0}
|
||||
pathContainsOnlyLiterals={pathContainsOnlyLiterals}
|
||||
>
|
||||
{literal}
|
||||
</PathLiteral>
|
||||
</PathBuilderColumn>
|
||||
)
|
||||
}
|
||||
|
||||
function renderNamedParamInput(token: Key): JSX.Element {
|
||||
const { pattern } = token
|
||||
const name = `${token.name}`
|
||||
const options = patternToOptions(pattern)
|
||||
|
||||
const value = namedParams[name]
|
||||
|
||||
if (options) {
|
||||
return (
|
||||
<NamedParamSelect
|
||||
name={name}
|
||||
onChange={handleTokenChange}
|
||||
value={value}
|
||||
>
|
||||
<option key="empty" value="">
|
||||
{' '}
|
||||
</option>
|
||||
{options.map(option => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</NamedParamSelect>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<NamedParamInput
|
||||
name={name}
|
||||
onChange={handleTokenChange}
|
||||
type="text"
|
||||
value={value}
|
||||
{...noAutocorrect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function renderNamedParam(
|
||||
token: Key,
|
||||
tokenIndex: number,
|
||||
namedParamIndex: number
|
||||
): JSX.Element {
|
||||
const { prefix, modifier } = token
|
||||
const optional = modifier === '?' || modifier === '*'
|
||||
const name = `${token.name}`
|
||||
|
||||
const exampleValue = exampleParams[name] || '(not set)'
|
||||
|
||||
return (
|
||||
<React.Fragment key={token.name}>
|
||||
{renderLiteral(prefix, tokenIndex, false)}
|
||||
<PathBuilderColumn pathContainsOnlyLiterals={false} withHorizPadding>
|
||||
<NamedParamLabelContainer>
|
||||
<BuilderLabel htmlFor={name}>{humanizeString(name)}</BuilderLabel>
|
||||
{optional ? <BuilderLabel>(optional)</BuilderLabel> : null}
|
||||
</NamedParamLabelContainer>
|
||||
{renderNamedParamInput(token)}
|
||||
<NamedParamCaption>
|
||||
{namedParamIndex === 0 ? `e.g. ${exampleValue}` : exampleValue}
|
||||
</NamedParamCaption>
|
||||
</PathBuilderColumn>
|
||||
</React.Fragment>
|
||||
)
|
||||
}
|
||||
|
||||
let namedParamIndex = 0
|
||||
const pathContainsOnlyLiterals = tokens.every(
|
||||
token => typeof token === 'string'
|
||||
)
|
||||
return (
|
||||
<BuilderContainer>
|
||||
{tokens.map((token, tokenIndex) =>
|
||||
typeof token === 'string'
|
||||
? renderLiteral(token, tokenIndex, pathContainsOnlyLiterals)
|
||||
: renderNamedParam(token, tokenIndex, namedParamIndex++)
|
||||
)}
|
||||
</BuilderContainer>
|
||||
)
|
||||
}
|
||||
348
frontend/components/customizer/query-string-builder.tsx
Normal file
348
frontend/components/customizer/query-string-builder.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
} from 'react'
|
||||
import styled from 'styled-components'
|
||||
import humanizeString from 'humanize-string'
|
||||
import qs from 'query-string'
|
||||
import { advertisedStyles } from '../../lib/supported-features'
|
||||
import { noAutocorrect, StyledInput } from '../common'
|
||||
import {
|
||||
BuilderContainer,
|
||||
BuilderLabel,
|
||||
BuilderCaption,
|
||||
} from './builder-common'
|
||||
|
||||
const QueryParamLabel = styled(BuilderLabel)`
|
||||
margin: 5px;
|
||||
`
|
||||
|
||||
const QueryParamInput = styled(StyledInput)`
|
||||
margin: 5px 10px;
|
||||
`
|
||||
|
||||
const QueryParamCaption = styled(BuilderCaption)`
|
||||
margin: 5px;
|
||||
`
|
||||
|
||||
type BadgeOptionName = 'style' | 'label' | 'color' | 'logo' | 'logoColor'
|
||||
|
||||
interface BadgeOptionInfo {
|
||||
name: BadgeOptionName
|
||||
label?: string
|
||||
shieldsDefaultValue?: string
|
||||
}
|
||||
|
||||
const supportedBadgeOptions = [
|
||||
{ name: 'style', shieldsDefaultValue: 'flat' },
|
||||
{ name: 'label', label: 'override label' },
|
||||
{ name: 'color', label: 'override color' },
|
||||
{ name: 'logo', label: 'named logo' },
|
||||
{ name: 'logoColor', label: 'override logo color' },
|
||||
] as BadgeOptionInfo[]
|
||||
|
||||
function getBadgeOption(name: BadgeOptionName): BadgeOptionInfo {
|
||||
const result = supportedBadgeOptions.find(opt => opt.name === name)
|
||||
if (!result) {
|
||||
throw Error(`Unknown badge option: ${name}`)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function getQueryString({
|
||||
queryParams,
|
||||
badgeOptions,
|
||||
}: {
|
||||
queryParams: Record<string, string | boolean>
|
||||
badgeOptions: Record<BadgeOptionName, string | undefined>
|
||||
}): {
|
||||
queryString: string
|
||||
isComplete: boolean
|
||||
} {
|
||||
// Use `string | null`, because `query-string` renders e.g.
|
||||
// `{ compact_message: null }` as `?compact_message`. This is
|
||||
// what we want for boolean params that are true (see below).
|
||||
const outQuery = {} as Record<string, string | null>
|
||||
let isComplete = true
|
||||
|
||||
Object.entries(queryParams).forEach(([name, value]) => {
|
||||
// As above, there are two types of supported params: strings and
|
||||
// booleans.
|
||||
if (typeof value === 'string') {
|
||||
if (value) {
|
||||
outQuery[name] = value.trim()
|
||||
} else {
|
||||
// Skip empty params.
|
||||
isComplete = false
|
||||
}
|
||||
} else {
|
||||
// Generate empty query params for boolean parameters by translating
|
||||
// `{ compact_message: true }` to `?compact_message`. When values are
|
||||
// false, skip the param.
|
||||
if (value) {
|
||||
outQuery[name] = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Object.entries(badgeOptions).forEach(([name, value]) => {
|
||||
const { shieldsDefaultValue } = getBadgeOption(name as BadgeOptionName)
|
||||
if (value && value !== shieldsDefaultValue) {
|
||||
outQuery[name] = value
|
||||
}
|
||||
})
|
||||
|
||||
const queryString = qs.stringify(outQuery)
|
||||
|
||||
return { queryString, isComplete }
|
||||
}
|
||||
|
||||
function ServiceQueryParam({
|
||||
name,
|
||||
value,
|
||||
exampleValue,
|
||||
isStringParam,
|
||||
stringParamCount,
|
||||
handleServiceQueryParamChange,
|
||||
}: {
|
||||
name: string
|
||||
value: string | boolean
|
||||
exampleValue: string
|
||||
isStringParam: boolean
|
||||
stringParamCount?: number
|
||||
handleServiceQueryParamChange: ChangeEventHandler<HTMLInputElement>
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<QueryParamLabel htmlFor={name}>
|
||||
{humanizeString(name).toLowerCase()}
|
||||
</QueryParamLabel>
|
||||
</td>
|
||||
<td>
|
||||
{isStringParam && (
|
||||
<QueryParamCaption>
|
||||
{stringParamCount === 0 ? `e.g. ${exampleValue}` : exampleValue}
|
||||
</QueryParamCaption>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{isStringParam ? (
|
||||
<QueryParamInput
|
||||
name={name}
|
||||
onChange={handleServiceQueryParamChange}
|
||||
type="text"
|
||||
value={value as string}
|
||||
{...noAutocorrect}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
checked={value as boolean}
|
||||
name={name}
|
||||
onChange={handleServiceQueryParamChange}
|
||||
type="checkbox"
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function BadgeOptionInput({
|
||||
name,
|
||||
value,
|
||||
handleBadgeOptionChange,
|
||||
}: {
|
||||
name: BadgeOptionName
|
||||
value: string
|
||||
handleBadgeOptionChange: ChangeEventHandler<
|
||||
HTMLSelectElement | HTMLInputElement
|
||||
>
|
||||
}): JSX.Element {
|
||||
if (name === 'style') {
|
||||
return (
|
||||
<select name="style" onChange={handleBadgeOptionChange} value={value}>
|
||||
{advertisedStyles.map(style => (
|
||||
<option key={style} value={style}>
|
||||
{style}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<QueryParamInput
|
||||
name={name}
|
||||
onChange={handleBadgeOptionChange}
|
||||
type="text"
|
||||
value={value}
|
||||
{...noAutocorrect}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function BadgeOption({
|
||||
name,
|
||||
value,
|
||||
handleBadgeOptionChange,
|
||||
}: {
|
||||
name: BadgeOptionName
|
||||
value: string
|
||||
handleBadgeOptionChange: ChangeEventHandler<HTMLInputElement>
|
||||
}): JSX.Element {
|
||||
const {
|
||||
label = humanizeString(name),
|
||||
shieldsDefaultValue: hasShieldsDefaultValue,
|
||||
} = getBadgeOption(name)
|
||||
return (
|
||||
<tr>
|
||||
<td>
|
||||
<QueryParamLabel htmlFor={name}>{label}</QueryParamLabel>
|
||||
</td>
|
||||
<td>
|
||||
{!hasShieldsDefaultValue && (
|
||||
<QueryParamCaption>optional</QueryParamCaption>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<BadgeOptionInput
|
||||
handleBadgeOptionChange={handleBadgeOptionChange}
|
||||
name={name}
|
||||
value={value}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
// The UI for building the query string, which includes two kinds of settings:
|
||||
// 1. Custom query params defined by the service, stored in
|
||||
// `this.state.queryParams`
|
||||
// 2. The standard badge options which apply to all badges, stored in
|
||||
// `this.state.badgeOptions`
|
||||
export default function QueryStringBuilder({
|
||||
exampleParams,
|
||||
initialStyle = 'flat',
|
||||
onChange,
|
||||
}: {
|
||||
exampleParams: { [k: string]: string }
|
||||
initialStyle?: string
|
||||
onChange: ({
|
||||
queryString,
|
||||
isComplete,
|
||||
}: {
|
||||
queryString: string
|
||||
isComplete: boolean
|
||||
}) => void
|
||||
}): JSX.Element {
|
||||
const [queryParams, setQueryParams] = useState(() =>
|
||||
// For each of the custom query params defined in `exampleParams`,
|
||||
// create empty values in `queryParams`.
|
||||
Object.entries(exampleParams)
|
||||
.filter(
|
||||
// If the example defines a value for one of the standard supported
|
||||
// options, do not duplicate the corresponding parameter.
|
||||
([name]) => !supportedBadgeOptions.some(option => name === option.name)
|
||||
)
|
||||
.reduce((accum, [name, value]) => {
|
||||
// Custom query params are either string or boolean. Inspect the example
|
||||
// value to infer which one, and set empty values accordingly.
|
||||
// Throughout the component, these two types are supported in the same
|
||||
// manner: by inspecting this value type.
|
||||
const isStringParam = typeof value === 'string'
|
||||
accum[name] = isStringParam ? '' : true
|
||||
return accum
|
||||
}, {} as { [k: string]: string | boolean })
|
||||
)
|
||||
// For each of the standard badge options, create empty values in
|
||||
// `badgeOptions`. When `initialStyle` has been provided, use it.
|
||||
const [badgeOptions, setBadgeOptions] = useState(() =>
|
||||
supportedBadgeOptions.reduce((accum, { name }) => {
|
||||
if (name === 'style') {
|
||||
accum[name] = initialStyle
|
||||
} else {
|
||||
accum[name] = ''
|
||||
}
|
||||
return accum
|
||||
}, {} as Record<BadgeOptionName, string>)
|
||||
)
|
||||
|
||||
const handleServiceQueryParamChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, type: targetType, checked, value },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
const outValue = targetType === 'checkbox' ? checked : value
|
||||
setQueryParams({ ...queryParams, [name]: outValue })
|
||||
},
|
||||
[setQueryParams, queryParams]
|
||||
)
|
||||
|
||||
const handleBadgeOptionChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
setBadgeOptions({ ...badgeOptions, [name]: value })
|
||||
},
|
||||
[setBadgeOptions, badgeOptions]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (onChange) {
|
||||
const { queryString, isComplete } = getQueryString({
|
||||
queryParams,
|
||||
badgeOptions,
|
||||
})
|
||||
onChange({ queryString, isComplete })
|
||||
}
|
||||
}, [onChange, queryParams, badgeOptions])
|
||||
|
||||
const hasQueryParams = Boolean(Object.keys(queryParams).length)
|
||||
let stringParamCount = 0
|
||||
return (
|
||||
<>
|
||||
{hasQueryParams && (
|
||||
<BuilderContainer>
|
||||
<table>
|
||||
<tbody>
|
||||
{Object.entries(queryParams).map(([name, value]) => {
|
||||
const isStringParam = typeof value === 'string'
|
||||
return (
|
||||
<ServiceQueryParam
|
||||
exampleValue={exampleParams[name]}
|
||||
handleServiceQueryParamChange={
|
||||
handleServiceQueryParamChange
|
||||
}
|
||||
isStringParam={isStringParam}
|
||||
key={name}
|
||||
name={name}
|
||||
stringParamCount={
|
||||
isStringParam ? stringParamCount++ : undefined
|
||||
}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</BuilderContainer>
|
||||
)}
|
||||
<BuilderContainer>
|
||||
<table>
|
||||
<tbody>
|
||||
{Object.entries(badgeOptions).map(([name, value]) => (
|
||||
<BadgeOption
|
||||
handleBadgeOptionChange={handleBadgeOptionChange}
|
||||
key={name}
|
||||
name={name as BadgeOptionName}
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</BuilderContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
134
frontend/components/customizer/request-markup-button.tsx
Normal file
134
frontend/components/customizer/request-markup-button.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React, { useRef } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Select, { components } from 'react-select'
|
||||
import { MarkupFormat } from '../../lib/generate-image-markup'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
function ClickableControl(props: any): JSX.Element {
|
||||
return (
|
||||
<components.Control
|
||||
{...props}
|
||||
innerProps={{
|
||||
onMouseDown: props.selectProps.onControlMouseDown,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
interface Option {
|
||||
value: MarkupFormat
|
||||
label: string
|
||||
}
|
||||
|
||||
const MarkupFormatSelect = styled(Select)`
|
||||
width: 200px;
|
||||
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
font-family: 'Lato', sans-serif;
|
||||
font-size: 12px;
|
||||
|
||||
.markup-format__control {
|
||||
background-image: linear-gradient(-180deg, #00aeff 0%, #0076ff 100%);
|
||||
border: 1px solid rgba(238, 239, 241, 0.8);
|
||||
border-width: 0;
|
||||
box-shadow: unset;
|
||||
cursor: copy;
|
||||
}
|
||||
|
||||
.markup-format__control--is-disabled {
|
||||
background: rgba(0, 118, 255, 0.3);
|
||||
cursor: none;
|
||||
}
|
||||
|
||||
.markup-format__placeholder {
|
||||
color: #eeeff1;
|
||||
}
|
||||
|
||||
.markup-format__indicator {
|
||||
color: rgba(238, 239, 241, 0.81);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.markup-format__indicator:hover {
|
||||
color: #eeeff1;
|
||||
}
|
||||
|
||||
.markup-format__control--is-focused .markup-format__indicator,
|
||||
.markup-format__control--is-focused .markup-format__indicator:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.markup-format__option {
|
||||
text-align: left;
|
||||
cursor: copy;
|
||||
}
|
||||
`
|
||||
|
||||
const markupOptions: Option[] = [
|
||||
{ value: 'markdown', label: 'Copy Markdown' },
|
||||
{ value: 'rst', label: 'Copy reStructuredText' },
|
||||
{ value: 'asciidoc', label: 'Copy AsciiDoc' },
|
||||
{ value: 'html', label: 'Copy HTML' },
|
||||
]
|
||||
|
||||
export default function GetMarkupButton({
|
||||
onMarkupRequested,
|
||||
isDisabled,
|
||||
}: {
|
||||
onMarkupRequested: (markupFormat: MarkupFormat) => Promise<void>
|
||||
isDisabled: boolean
|
||||
}): JSX.Element {
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/35572
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/28884#issuecomment-471341041
|
||||
const selectRef = useRef<Select<Option>>() as React.MutableRefObject<
|
||||
Select<Option>
|
||||
>
|
||||
|
||||
const onControlMouseDown = React.useCallback(
|
||||
async function (event: MouseEvent): Promise<void> {
|
||||
if (onMarkupRequested) {
|
||||
await onMarkupRequested('link')
|
||||
}
|
||||
if (selectRef.current) {
|
||||
selectRef.current.blur()
|
||||
}
|
||||
},
|
||||
[onMarkupRequested, selectRef]
|
||||
)
|
||||
|
||||
const onOptionClick = React.useCallback(
|
||||
async function onOptionClick(
|
||||
// Eeesh.
|
||||
value: Option | readonly Option[] | null | undefined
|
||||
): Promise<void> {
|
||||
const { value: markupFormat } = value as Option
|
||||
if (onMarkupRequested) {
|
||||
await onMarkupRequested(markupFormat)
|
||||
}
|
||||
},
|
||||
[onMarkupRequested]
|
||||
)
|
||||
|
||||
return (
|
||||
// TODO It doesn't seem to be possible to check the types and wrap with
|
||||
// styled-components at the same time. To check the types, replace
|
||||
// `MarkupFormatSelect` with `Select<Option>`.
|
||||
<MarkupFormatSelect
|
||||
blurInputOnSelect
|
||||
classNamePrefix="markup-format"
|
||||
closeMenuOnScroll
|
||||
components={{ Control: ClickableControl }}
|
||||
isDisabled={isDisabled}
|
||||
isSearchable={false}
|
||||
menuPlacement="auto"
|
||||
onChange={onOptionClick}
|
||||
onControlMouseDown={onControlMouseDown}
|
||||
options={markupOptions}
|
||||
placeholder="Copy Badge URL"
|
||||
ref={selectRef}
|
||||
value={null}
|
||||
/>
|
||||
)
|
||||
}
|
||||
77
frontend/components/development/logo-page.tsx
Normal file
77
frontend/components/development/logo-page.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { staticBadgeUrl } from '../../../core/badge-urls/make-badge-url'
|
||||
import { getBaseUrl } from '../../constants'
|
||||
import { shieldsLogos, simpleIcons } from '../../lib/supported-features'
|
||||
import Meta from '../meta'
|
||||
import Header from '../header'
|
||||
import { H3, Badge } from '../common'
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border: 1px solid #ccc;
|
||||
border-collapse: collapse;
|
||||
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
`
|
||||
|
||||
function NamedLogoTable({ logoNames }: { logoNames: string[] }): JSX.Element {
|
||||
const baseUrl = getBaseUrl()
|
||||
return (
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Flat</td>
|
||||
<td>Social</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logoNames.map(name => (
|
||||
<tr key={name}>
|
||||
<td>
|
||||
<Badge
|
||||
alt={`logo: ${name}`}
|
||||
src={staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: 'named logo',
|
||||
message: name,
|
||||
color: 'blue',
|
||||
namedLogo: name,
|
||||
})}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
alt={`logo: ${name}`}
|
||||
src={staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: 'Named Logo',
|
||||
message: name,
|
||||
color: 'blue',
|
||||
namedLogo: name,
|
||||
style: 'social',
|
||||
})}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LogoPage(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Meta />
|
||||
<Header />
|
||||
<H3>Named logos</H3>
|
||||
<NamedLogoTable logoNames={shieldsLogos} />
|
||||
<H3>Simple-icons</H3>
|
||||
<NamedLogoTable logoNames={simpleIcons} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
168
frontend/components/development/style-page.tsx
Normal file
168
frontend/components/development/style-page.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { Fragment } from 'react'
|
||||
import styled from 'styled-components'
|
||||
// FIXME: is this needed?
|
||||
// @ts-ingnore
|
||||
import { staticBadgeUrl } from '../../../core/badge-urls/make-badge-url'
|
||||
import { getBaseUrl } from '../../constants'
|
||||
import Meta from '../meta'
|
||||
// ts-expect-error: because reasons?
|
||||
import Header from '../header'
|
||||
import { H3, Badge } from '../common'
|
||||
|
||||
const StyledTable = styled.table`
|
||||
border: 1px solid #ccc;
|
||||
border-collapse: collapse;
|
||||
|
||||
td {
|
||||
border: 1px solid #ccc;
|
||||
padding: 3px;
|
||||
text-align: left;
|
||||
}
|
||||
`
|
||||
|
||||
interface BadgeData {
|
||||
label: string
|
||||
message: string
|
||||
labelColor?: string
|
||||
color: string
|
||||
namedLogo?: string
|
||||
links?: string[]
|
||||
}
|
||||
|
||||
function Badges({
|
||||
baseUrl,
|
||||
style,
|
||||
badges,
|
||||
}: {
|
||||
baseUrl: string
|
||||
style: string
|
||||
badges: BadgeData[]
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{badges.map(({ label, message, labelColor, color, namedLogo, links }) => (
|
||||
<Fragment key={`${label}-${message}-${color}-${namedLogo}`}>
|
||||
<Badge
|
||||
alt="build"
|
||||
object={Boolean(links)}
|
||||
src={staticBadgeUrl({
|
||||
baseUrl,
|
||||
label,
|
||||
message,
|
||||
labelColor,
|
||||
color,
|
||||
namedLogo,
|
||||
style,
|
||||
links,
|
||||
})}
|
||||
/>
|
||||
<br />
|
||||
</Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const examples = [
|
||||
{
|
||||
title: 'Basic examples',
|
||||
badges: [
|
||||
{ label: 'build', message: 'passing', color: 'brightgreen' },
|
||||
{ label: 'tests', message: '5 passing, 1 failed', color: 'red' },
|
||||
{ label: 'python', message: '3.5 | 3.6 | 3.7', color: 'blue' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Logo',
|
||||
badges: [
|
||||
{
|
||||
label: 'build',
|
||||
message: 'passing',
|
||||
color: 'brightgreen',
|
||||
namedLogo: 'appveyor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'No left text',
|
||||
badges: [
|
||||
{ label: '', message: 'blueviolet', color: 'blueviolet' },
|
||||
{
|
||||
label: '',
|
||||
message: 'passing',
|
||||
color: 'brightgreen',
|
||||
namedLogo: 'appveyor',
|
||||
},
|
||||
{
|
||||
label: '',
|
||||
message: 'passing',
|
||||
color: 'brightgreen',
|
||||
labelColor: 'grey',
|
||||
namedLogo: 'appveyor',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Links',
|
||||
badges: [
|
||||
{
|
||||
label: 'badges',
|
||||
message: 'shields',
|
||||
color: 'blue',
|
||||
links: [
|
||||
'https://github.com/badges/',
|
||||
'https://github.com/badges/shields/',
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
function StyleTable({ style }: { style: string }): JSX.Element {
|
||||
const baseUrl = getBaseUrl()
|
||||
return (
|
||||
<StyledTable>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Description</td>
|
||||
<td>Badges (new)</td>
|
||||
<td>Badges (img.shields.io)</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{examples.map(({ title, badges }) => (
|
||||
<tr key={title}>
|
||||
<td>{title}</td>
|
||||
<td>
|
||||
<Badges badges={badges} baseUrl={baseUrl} style={style} />
|
||||
</td>
|
||||
<td>
|
||||
<Badges
|
||||
badges={badges}
|
||||
baseUrl="https://img.shields.io"
|
||||
style={style}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</StyledTable>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = ['flat', 'flat-square', 'for-the-badge', 'social', 'plastic']
|
||||
|
||||
export default function StylePage(): JSX.Element {
|
||||
return (
|
||||
<div>
|
||||
<Meta />
|
||||
<Header />
|
||||
{styles.map(style => (
|
||||
<Fragment key={style}>
|
||||
<H3>{style}</H3>
|
||||
<StyleTable style={style} />
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
16
frontend/components/donate.tsx
Normal file
16
frontend/components/donate.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
const Donate = styled.div`
|
||||
padding: 25px 50px;
|
||||
`
|
||||
|
||||
export default function DonateBox(): JSX.Element {
|
||||
return (
|
||||
<Donate>
|
||||
Love Shields? Please consider{' '}
|
||||
<a href="https://opencollective.com/shields">donating</a> to sustain our
|
||||
activities
|
||||
</Donate>
|
||||
)
|
||||
}
|
||||
103
frontend/components/dynamic-badge-maker.tsx
Normal file
103
frontend/components/dynamic-badge-maker.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import React, { useState, ChangeEvent } from 'react'
|
||||
import { dynamicBadgeUrl } from '../../core/badge-urls/make-badge-url'
|
||||
import { InlineInput } from './common'
|
||||
|
||||
type StateKey =
|
||||
| 'datatype'
|
||||
| 'label'
|
||||
| 'dataUrl'
|
||||
| 'query'
|
||||
| 'color'
|
||||
| 'prefix'
|
||||
| 'suffix'
|
||||
type State = Record<StateKey, string>
|
||||
|
||||
interface InputDef {
|
||||
name: StateKey
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
const inputs = [
|
||||
{ name: 'label' },
|
||||
{ name: 'dataUrl', placeholder: 'data url' },
|
||||
{ name: 'query' },
|
||||
{ name: 'color' },
|
||||
{ name: 'prefix' },
|
||||
{ name: 'suffix' },
|
||||
] as InputDef[]
|
||||
|
||||
export default function DynamicBadgeMaker({
|
||||
baseUrl = document.location.href,
|
||||
}: {
|
||||
baseUrl: string
|
||||
}): JSX.Element {
|
||||
const [values, setValues] = useState<State>({
|
||||
datatype: '',
|
||||
label: '',
|
||||
dataUrl: '',
|
||||
query: '',
|
||||
color: '',
|
||||
prefix: '',
|
||||
suffix: '',
|
||||
})
|
||||
|
||||
const isValid =
|
||||
values.datatype && values.label && values.dataUrl && values.query
|
||||
|
||||
const onChange = React.useCallback(
|
||||
function ({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setValues({
|
||||
...values,
|
||||
[name]: value,
|
||||
})
|
||||
},
|
||||
[values]
|
||||
)
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
function onSubmit(e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
|
||||
const { datatype, label, dataUrl, query, color, prefix, suffix } = values
|
||||
window.open(
|
||||
dynamicBadgeUrl({
|
||||
baseUrl,
|
||||
datatype,
|
||||
label,
|
||||
dataUrl,
|
||||
query,
|
||||
color,
|
||||
prefix,
|
||||
suffix,
|
||||
}),
|
||||
'_blank'
|
||||
)
|
||||
},
|
||||
[baseUrl, values]
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<select name="datatype" onChange={onChange} value={values.datatype}>
|
||||
<option disabled value="">
|
||||
data type
|
||||
</option>
|
||||
<option value="json">json</option>
|
||||
<option value="xml">xml</option>
|
||||
<option value="yaml">yaml</option>
|
||||
</select>{' '}
|
||||
{inputs.map(({ name, placeholder = name }) => (
|
||||
<InlineInput
|
||||
key={name}
|
||||
name={name}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
value={values[name]}
|
||||
/>
|
||||
))}
|
||||
<button disabled={!isValid}>Make Badge</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
83
frontend/components/footer.tsx
Normal file
83
frontend/components/footer.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { badgeUrlFromPath } from '../../core/badge-urls/make-badge-url'
|
||||
import { H2 } from './common'
|
||||
|
||||
const SpacedA = styled.a`
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
`
|
||||
|
||||
export default function Footer({ baseUrl }: { baseUrl: string }): JSX.Element {
|
||||
return (
|
||||
<section>
|
||||
<H2 id="like-this">Like This?</H2>
|
||||
|
||||
<p>
|
||||
<object
|
||||
data={badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path: '/twitter/follow/shields_io',
|
||||
queryParams: { label: 'Follow' },
|
||||
style: 'social',
|
||||
})}
|
||||
/>{' '}
|
||||
{}
|
||||
<object
|
||||
data={badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path: '/opencollective/backers/shields',
|
||||
queryParams: { link: 'https://opencollective.com/shields' },
|
||||
style: 'social',
|
||||
})}
|
||||
/>{' '}
|
||||
{}
|
||||
<object
|
||||
data={badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path: '/opencollective/sponsors/shields',
|
||||
queryParams: { link: 'https://opencollective.com/shields' },
|
||||
style: 'social',
|
||||
})}
|
||||
/>{' '}
|
||||
{}
|
||||
<object
|
||||
data={badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path: '/github/forks/badges/shields',
|
||||
queryParams: { label: 'Fork' },
|
||||
style: 'social',
|
||||
})}
|
||||
/>{' '}
|
||||
{}
|
||||
<object
|
||||
data={badgeUrlFromPath({
|
||||
baseUrl,
|
||||
path: '/discord/308323056592486420',
|
||||
queryParams: {
|
||||
label: 'Chat',
|
||||
link: 'https://discord.gg/HjJCwm5',
|
||||
},
|
||||
style: 'social',
|
||||
})}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Have an idea for an awesome new badge?
|
||||
<br />
|
||||
<a href="https://github.com/badges/shields/issues/new?labels=service-badge&template=3_Badge_request.md">
|
||||
Tell us about it
|
||||
</a>{' '}
|
||||
and we might bring it to you!
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<SpacedA href="/community">Community</SpacedA>
|
||||
<SpacedA href="https://stats.uptimerobot.com/PjXogHB5p">Status</SpacedA>
|
||||
<SpacedA href="https://metrics.shields.io">Metrics</SpacedA>
|
||||
<SpacedA href="https://github.com/badges/shields">GitHub</SpacedA>
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
26
frontend/components/header.tsx
Normal file
26
frontend/components/header.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Link } from 'gatsby'
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import Logo from '../images/logo.svg'
|
||||
import { VerticalSpace } from './common'
|
||||
|
||||
const Highlights = styled.p`
|
||||
font-style: italic;
|
||||
`
|
||||
|
||||
export default function Header(): JSX.Element {
|
||||
return (
|
||||
<section>
|
||||
<Link to="/">
|
||||
<Logo />
|
||||
</Link>
|
||||
|
||||
<VerticalSpace />
|
||||
|
||||
<Highlights>
|
||||
Pixel-perfect Retina-ready Fast Consistent
|
||||
Hackable No tracking
|
||||
</Highlights>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
185
frontend/components/main.tsx
Normal file
185
frontend/components/main.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useRef, useState } from 'react'
|
||||
import styled from 'styled-components'
|
||||
import groupBy from 'lodash.groupby'
|
||||
import {
|
||||
ServiceDefinition,
|
||||
Example,
|
||||
categories,
|
||||
findCategory,
|
||||
services,
|
||||
getDefinitionsForCategory,
|
||||
RenderableExample,
|
||||
} from '../lib/service-definitions'
|
||||
import ServiceDefinitionSetHelper from '../lib/service-definitions/service-definition-set-helper'
|
||||
import { getBaseUrl } from '../constants'
|
||||
import Meta from './meta'
|
||||
import Header from './header'
|
||||
import Search from './search'
|
||||
import DonateBox from './donate'
|
||||
import { MarkupModal } from './markup-modal'
|
||||
import Usage from './usage'
|
||||
import Footer from './footer'
|
||||
import {
|
||||
Category,
|
||||
CategoryHeading,
|
||||
CategoryHeadings,
|
||||
CategoryNav,
|
||||
} from './category-headings'
|
||||
import { BadgeExamples } from './badge-examples'
|
||||
import { BaseFont, GlobalStyle } from './common'
|
||||
|
||||
const AppContainer = styled(BaseFont)`
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
// `pageContext` is the `context` passed to `createPage()` in
|
||||
// `gatsby-node.js`. In the case of the index page, `pageContext` is empty.
|
||||
interface PageContext {
|
||||
category?: Category
|
||||
}
|
||||
|
||||
export default function Main({
|
||||
pageContext,
|
||||
}: {
|
||||
pageContext: PageContext
|
||||
}): JSX.Element {
|
||||
const [searchIsInProgress, setSearchIsInProgress] = useState(false)
|
||||
const [queryIsTooShort, setQueryIsTooShort] = useState(false)
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
[k: string]: ServiceDefinition[]
|
||||
}>()
|
||||
const [selectedExample, setSelectedExample] = useState<RenderableExample>()
|
||||
const searchTimeout = useRef(0)
|
||||
const baseUrl = getBaseUrl()
|
||||
|
||||
const performSearch = React.useCallback(
|
||||
function (query: string): void {
|
||||
setSearchIsInProgress(false)
|
||||
|
||||
setQueryIsTooShort(query.length === 1)
|
||||
|
||||
if (query.length >= 2) {
|
||||
const flat = ServiceDefinitionSetHelper.create(services)
|
||||
.notDeprecated()
|
||||
.search(query)
|
||||
.toArray()
|
||||
setSearchResults(groupBy(flat, 'category'))
|
||||
} else {
|
||||
setSearchResults(undefined)
|
||||
}
|
||||
},
|
||||
[setSearchIsInProgress, setQueryIsTooShort, setSearchResults]
|
||||
)
|
||||
|
||||
const searchQueryChanged = React.useCallback(
|
||||
function (query: string): void {
|
||||
/*
|
||||
Add a small delay before showing search results
|
||||
so that we wait until the user has stopped typing
|
||||
before we start loading stuff.
|
||||
|
||||
This
|
||||
a) reduces the amount of badges we will load and
|
||||
b) stops the page from 'flashing' as the user types, like this:
|
||||
https://user-images.githubusercontent.com/7288322/42600206-9b278470-85b5-11e8-9f63-eb4a0c31cb4a.gif
|
||||
*/
|
||||
setSearchIsInProgress(true)
|
||||
window.clearTimeout(searchTimeout.current)
|
||||
searchTimeout.current = window.setTimeout(() => performSearch(query), 500)
|
||||
},
|
||||
[setSearchIsInProgress, performSearch]
|
||||
)
|
||||
|
||||
const dismissMarkupModal = React.useCallback(
|
||||
function (): void {
|
||||
setSelectedExample(undefined)
|
||||
},
|
||||
[setSelectedExample]
|
||||
)
|
||||
|
||||
function Category({
|
||||
category,
|
||||
definitions,
|
||||
}: {
|
||||
category: Category
|
||||
definitions: ServiceDefinition[]
|
||||
}): JSX.Element {
|
||||
const flattened = definitions.reduce((accum, current) => {
|
||||
const { examples } = current
|
||||
return accum.concat(examples)
|
||||
}, [] as Example[])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<CategoryHeading category={category} />
|
||||
<BadgeExamples
|
||||
baseUrl={baseUrl}
|
||||
examples={flattened}
|
||||
onClick={setSelectedExample}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function renderMain(): JSX.Element | JSX.Element[] {
|
||||
const { category } = pageContext
|
||||
|
||||
if (searchIsInProgress) {
|
||||
return <div>searching...</div>
|
||||
} else if (queryIsTooShort) {
|
||||
return <div>Search term must have 2 or more characters</div>
|
||||
} else if (searchResults) {
|
||||
return Object.entries(searchResults).map(([categoryId, definitions]) => {
|
||||
const category = findCategory(categoryId)
|
||||
if (category === undefined) {
|
||||
throw Error(`Couldn't find category: ${categoryId}`)
|
||||
}
|
||||
return (
|
||||
<Category
|
||||
category={category}
|
||||
definitions={definitions}
|
||||
key={categoryId}
|
||||
/>
|
||||
)
|
||||
})
|
||||
} else if (category) {
|
||||
const definitions = ServiceDefinitionSetHelper.create(
|
||||
getDefinitionsForCategory(category.id)
|
||||
)
|
||||
.notDeprecated()
|
||||
.toArray()
|
||||
return (
|
||||
<div>
|
||||
<CategoryNav categories={categories} />
|
||||
<Category
|
||||
category={category}
|
||||
definitions={definitions}
|
||||
key={category.id}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return <CategoryHeadings categories={categories} />
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContainer id="app">
|
||||
<GlobalStyle />
|
||||
<Meta />
|
||||
<Header />
|
||||
<MarkupModal
|
||||
baseUrl={baseUrl}
|
||||
example={selectedExample}
|
||||
onRequestClose={dismissMarkupModal}
|
||||
/>
|
||||
<section>
|
||||
<Search queryChanged={searchQueryChanged} />
|
||||
<DonateBox />
|
||||
</section>
|
||||
{renderMain()}
|
||||
<Usage baseUrl={baseUrl} />
|
||||
<Footer baseUrl={baseUrl} />
|
||||
</AppContainer>
|
||||
)
|
||||
}
|
||||
35
frontend/components/markup-modal/index.tsx
Normal file
35
frontend/components/markup-modal/index.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react'
|
||||
import Modal from 'react-modal'
|
||||
import styled from 'styled-components'
|
||||
import { BaseFont } from '../common'
|
||||
import { RenderableExample } from '../../lib/service-definitions'
|
||||
import { MarkupModalContent } from './markup-modal-content'
|
||||
|
||||
const ContentContainer = styled(BaseFont)`
|
||||
text-align: center;
|
||||
`
|
||||
|
||||
export function MarkupModal({
|
||||
example,
|
||||
baseUrl,
|
||||
onRequestClose,
|
||||
}: {
|
||||
example: RenderableExample | undefined
|
||||
baseUrl: string
|
||||
onRequestClose: () => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<Modal
|
||||
ariaHideApp={false}
|
||||
contentLabel="Example Modal"
|
||||
isOpen={example !== undefined}
|
||||
onRequestClose={onRequestClose}
|
||||
>
|
||||
{example !== undefined && (
|
||||
<ContentContainer>
|
||||
<MarkupModalContent baseUrl={baseUrl} example={example} />
|
||||
</ContentContainer>
|
||||
)}
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
44
frontend/components/markup-modal/markup-modal-content.tsx
Normal file
44
frontend/components/markup-modal/markup-modal-content.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import styled from 'styled-components'
|
||||
import { Example, RenderableExample } from '../../lib/service-definitions'
|
||||
import { H3 } from '../common'
|
||||
import Customizer from '../customizer/customizer'
|
||||
|
||||
const Documentation = styled.div`
|
||||
max-width: 800px;
|
||||
margin: 35px auto 20px;
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
export function MarkupModalContent({
|
||||
example,
|
||||
baseUrl,
|
||||
}: {
|
||||
example: RenderableExample
|
||||
baseUrl: string
|
||||
}): JSX.Element {
|
||||
const { documentation } = example as Example
|
||||
|
||||
const {
|
||||
title,
|
||||
example: { pattern, namedParams, queryParams },
|
||||
preview: { style: initialStyle },
|
||||
} = example
|
||||
|
||||
return (
|
||||
<>
|
||||
<H3>{title}</H3>
|
||||
{documentation ? (
|
||||
<Documentation dangerouslySetInnerHTML={documentation} />
|
||||
) : null}
|
||||
<Customizer
|
||||
baseUrl={baseUrl}
|
||||
exampleNamedParams={namedParams}
|
||||
exampleQueryParams={queryParams}
|
||||
initialStyle={initialStyle}
|
||||
pattern={pattern}
|
||||
title={title}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
25
frontend/components/meta.tsx
Normal file
25
frontend/components/meta.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import React from 'react'
|
||||
import { Helmet } from 'react-helmet'
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
import favicon from '../images/favicon.png'
|
||||
import '@fontsource/lato'
|
||||
import '@fontsource/lekton'
|
||||
|
||||
const description = `We serve fast and scalable informational images as badges
|
||||
for GitHub, Travis CI, Jenkins, WordPress and many more services. Use them to
|
||||
track the state of your projects, or for promotional purposes.`
|
||||
|
||||
export default function Meta(): JSX.Element {
|
||||
return (
|
||||
<Helmet>
|
||||
<title>
|
||||
Shields.io: Quality metadata badges for open source projects
|
||||
</title>
|
||||
<meta charSet="utf-8" />
|
||||
<meta content="width=device-width,initial-scale=1" name="viewport" />
|
||||
<meta content={description} name="description" />
|
||||
<link href={favicon} rel="icon" type="image/png" />
|
||||
</Helmet>
|
||||
)
|
||||
}
|
||||
36
frontend/components/search.tsx
Normal file
36
frontend/components/search.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import React, { useRef, ChangeEvent } from 'react'
|
||||
import debounce from 'lodash.debounce'
|
||||
import { BlockInput } from './common'
|
||||
|
||||
export default function Search({
|
||||
queryChanged,
|
||||
}: {
|
||||
queryChanged: (query: string) => void
|
||||
}): JSX.Element {
|
||||
const queryChangedDebounced = useRef(
|
||||
debounce(queryChanged, 50, { leading: true })
|
||||
)
|
||||
|
||||
const onQueryChanged = React.useCallback(
|
||||
function ({
|
||||
target: { value: query },
|
||||
}: ChangeEvent<HTMLInputElement>): void {
|
||||
queryChangedDebounced.current(query)
|
||||
},
|
||||
[queryChangedDebounced]
|
||||
)
|
||||
|
||||
// TODO: Warning: A future version of React will block javascript: URLs as a security precaution
|
||||
// how else to do this?
|
||||
return (
|
||||
<section>
|
||||
<form action="javascript:void 0" autoComplete="off">
|
||||
<BlockInput
|
||||
autoComplete="off"
|
||||
onChange={onQueryChanged}
|
||||
placeholder="search"
|
||||
/>
|
||||
</form>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
61
frontend/components/snippet.tsx
Normal file
61
frontend/components/snippet.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react'
|
||||
import ClickToSelect from '@mapbox/react-click-to-select'
|
||||
import styled, { css } from 'styled-components'
|
||||
|
||||
interface CodeContainerProps {
|
||||
truncate?: boolean
|
||||
}
|
||||
|
||||
const CodeContainer = styled.span<CodeContainerProps>`
|
||||
position: relative;
|
||||
|
||||
vertical-align: middle;
|
||||
display: inline-block;
|
||||
|
||||
${({ truncate }) =>
|
||||
truncate &&
|
||||
css`
|
||||
max-width: 40%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`}
|
||||
`
|
||||
|
||||
export interface StyledCodeProps {
|
||||
fontSize?: string
|
||||
}
|
||||
|
||||
export const StyledCode = styled.code<StyledCodeProps>`
|
||||
line-height: 1.2em;
|
||||
padding: 0.1em 0.3em;
|
||||
|
||||
border-radius: 4px;
|
||||
background: #eef;
|
||||
font-family: Lekton;
|
||||
|
||||
${({ fontSize }) =>
|
||||
fontSize &&
|
||||
css`
|
||||
font-size: ${fontSize};
|
||||
`}
|
||||
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
export function Snippet({
|
||||
snippet,
|
||||
truncate = false,
|
||||
fontSize,
|
||||
}: {
|
||||
snippet: string
|
||||
truncate?: boolean
|
||||
fontSize?: string
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<CodeContainer truncate={truncate}>
|
||||
<ClickToSelect>
|
||||
<StyledCode fontSize={fontSize}>{snippet}</StyledCode>
|
||||
</ClickToSelect>
|
||||
</CodeContainer>
|
||||
)
|
||||
}
|
||||
77
frontend/components/static-badge-maker.tsx
Normal file
77
frontend/components/static-badge-maker.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import React, { useState, ChangeEvent } from 'react'
|
||||
import { staticBadgeUrl } from '../../core/badge-urls/make-badge-url'
|
||||
import { InlineInput } from './common'
|
||||
|
||||
type StateKey = 'label' | 'message' | 'color'
|
||||
type State = Record<StateKey, string>
|
||||
|
||||
export default function StaticBadgeMaker({
|
||||
baseUrl = document.location.href,
|
||||
}: {
|
||||
baseUrl: string
|
||||
}): JSX.Element {
|
||||
const [values, setValues] = useState<State>({
|
||||
label: '',
|
||||
message: '',
|
||||
color: '',
|
||||
})
|
||||
|
||||
const isValid = values.message && values.color
|
||||
|
||||
const onChange = React.useCallback(
|
||||
function onChange({
|
||||
target: { name, value },
|
||||
}: ChangeEvent<HTMLInputElement | HTMLSelectElement>): void {
|
||||
setValues({
|
||||
...values,
|
||||
[name]: value,
|
||||
})
|
||||
},
|
||||
[setValues, values]
|
||||
)
|
||||
|
||||
const onSubmit = React.useCallback(
|
||||
function (e: React.FormEvent): void {
|
||||
e.preventDefault()
|
||||
|
||||
const { label, message, color } = values
|
||||
window.open(staticBadgeUrl({ baseUrl, label, message, color }), '_blank')
|
||||
},
|
||||
[baseUrl, values]
|
||||
)
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<InlineInput
|
||||
name="label"
|
||||
onChange={onChange}
|
||||
placeholder="label"
|
||||
value={values.label}
|
||||
/>
|
||||
<InlineInput
|
||||
name="message"
|
||||
onChange={onChange}
|
||||
placeholder="message"
|
||||
value={values.message}
|
||||
/>
|
||||
<InlineInput
|
||||
list="default-colors"
|
||||
name="color"
|
||||
onChange={onChange}
|
||||
placeholder="color"
|
||||
value={values.color}
|
||||
/>
|
||||
<datalist id="default-colors">
|
||||
<option value="brightgreen" />
|
||||
<option value="green" />
|
||||
<option value="yellowgreen" />
|
||||
<option value="yellow" />
|
||||
<option value="orange" />
|
||||
<option value="red" />
|
||||
<option value="lightgrey" />
|
||||
<option value="blue" />
|
||||
</datalist>
|
||||
<button disabled={!isValid}>Make Badge</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
448
frontend/components/usage.tsx
Normal file
448
frontend/components/usage.tsx
Normal file
@@ -0,0 +1,448 @@
|
||||
import React from 'react'
|
||||
import { Link } from 'gatsby'
|
||||
import styled from 'styled-components'
|
||||
import { staticBadgeUrl } from '../../core/badge-urls/make-badge-url'
|
||||
import { advertisedStyles, shieldsLogos } from '../lib/supported-features'
|
||||
// ts-expect-error: because reasons?
|
||||
import StaticBadgeMaker from './static-badge-maker'
|
||||
import DynamicBadgeMaker from './dynamic-badge-maker'
|
||||
import { H2, H3, Badge, VerticalSpace } from './common'
|
||||
import { Snippet, StyledCode } from './snippet'
|
||||
|
||||
const LogoName = styled.span`
|
||||
white-space: nowrap;
|
||||
`
|
||||
|
||||
const Lhs = styled.td`
|
||||
text-align: right;
|
||||
`
|
||||
|
||||
const EscapingRuleTable = styled.table`
|
||||
margin: auto;
|
||||
`
|
||||
|
||||
const QueryParamTable = styled.table`
|
||||
min-width: 50%;
|
||||
margin: auto;
|
||||
table-layout: fixed;
|
||||
border-spacing: 20px 10px;
|
||||
`
|
||||
|
||||
const QueryParamSyntax = styled.td`
|
||||
max-width: 300px;
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
const QueryParamDocumentation = styled.td`
|
||||
max-width: 600px;
|
||||
text-align: left;
|
||||
`
|
||||
|
||||
function QueryParam({
|
||||
snippet,
|
||||
documentation,
|
||||
}: {
|
||||
snippet: string
|
||||
documentation: JSX.Element | JSX.Element[]
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
<QueryParamSyntax>
|
||||
<Snippet snippet={snippet} />
|
||||
</QueryParamSyntax>
|
||||
<QueryParamDocumentation>{documentation}</QueryParamDocumentation>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function EscapingConversion({
|
||||
lhs,
|
||||
rhs,
|
||||
}: {
|
||||
lhs: JSX.Element
|
||||
rhs: JSX.Element
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<tr>
|
||||
<Lhs>{lhs}</Lhs>
|
||||
<td>→</td>
|
||||
<td>{rhs}</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
function ColorExamples({
|
||||
baseUrl,
|
||||
colors,
|
||||
}: {
|
||||
baseUrl: string
|
||||
colors: string[]
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<span>
|
||||
{colors.map((color, i) => (
|
||||
<Badge
|
||||
alt={color}
|
||||
key={color}
|
||||
src={staticBadgeUrl({ baseUrl, label: '', message: color, color })}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function StyleExamples({ baseUrl }: { baseUrl: string }): JSX.Element {
|
||||
return (
|
||||
<QueryParamTable>
|
||||
<tbody>
|
||||
{advertisedStyles.map(style => {
|
||||
const snippet = `?style=${style}&logo=appveyor`
|
||||
const badgeUrl = staticBadgeUrl({
|
||||
baseUrl,
|
||||
label: 'style',
|
||||
message: style,
|
||||
color: 'green',
|
||||
namedLogo: 'appveyor',
|
||||
style,
|
||||
})
|
||||
return (
|
||||
<QueryParam
|
||||
documentation={<Badge alt={style} src={badgeUrl} />}
|
||||
key={style}
|
||||
snippet={snippet}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</QueryParamTable>
|
||||
)
|
||||
}
|
||||
|
||||
function NamedLogos(): JSX.Element {
|
||||
const renderLogo = (logo: string): JSX.Element => (
|
||||
<LogoName key={logo}>{logo}</LogoName>
|
||||
)
|
||||
const [first, ...rest] = shieldsLogos
|
||||
const result = ([renderLogo(first)] as (JSX.Element | string)[]).concat(
|
||||
rest.reduce(
|
||||
(result, logo) => result.concat([', ', renderLogo(logo)]),
|
||||
[] as (JSX.Element | string)[]
|
||||
)
|
||||
)
|
||||
return <>{result}</>
|
||||
}
|
||||
|
||||
function StaticBadgeEscapingRules(): JSX.Element {
|
||||
return (
|
||||
<EscapingRuleTable>
|
||||
<tbody>
|
||||
<EscapingConversion
|
||||
key="dashes"
|
||||
lhs={
|
||||
<span>
|
||||
Dashes <code>--</code>
|
||||
</span>
|
||||
}
|
||||
rhs={
|
||||
<span>
|
||||
<code>-</code> Dash
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<EscapingConversion
|
||||
key="underscores"
|
||||
lhs={
|
||||
<span>
|
||||
Underscores <code>__</code>
|
||||
</span>
|
||||
}
|
||||
rhs={
|
||||
<span>
|
||||
<code>_</code> Underscore
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<EscapingConversion
|
||||
key="spaces"
|
||||
lhs={
|
||||
<span>
|
||||
<code>_</code> or Space <code> </code>
|
||||
</span>
|
||||
}
|
||||
rhs={
|
||||
<span>
|
||||
<code> </code> Space
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</tbody>
|
||||
</EscapingRuleTable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Usage({ baseUrl }: { baseUrl: string }): JSX.Element {
|
||||
return (
|
||||
<section>
|
||||
<H2 id="your-badge">Your Badge</H2>
|
||||
|
||||
<H3>Static</H3>
|
||||
<StaticBadgeMaker baseUrl={baseUrl} />
|
||||
|
||||
<VerticalSpace />
|
||||
|
||||
<p>Using dash "-" separator</p>
|
||||
<p>
|
||||
<Snippet snippet={`${baseUrl}/badge/<LABEL>-<MESSAGE>-<COLOR>`} />
|
||||
</p>
|
||||
<StaticBadgeEscapingRules />
|
||||
<p>Using query string parameters</p>
|
||||
<p>
|
||||
<Snippet
|
||||
snippet={`${baseUrl}/static/v1?label=<LABEL>&message=<MESSAGE>&color=<COLOR>`}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<H3 id="colors">Colors</H3>
|
||||
<p>
|
||||
<ColorExamples
|
||||
baseUrl={baseUrl}
|
||||
colors={[
|
||||
'brightgreen',
|
||||
'green',
|
||||
'yellowgreen',
|
||||
'yellow',
|
||||
'orange',
|
||||
'red',
|
||||
'blue',
|
||||
'lightgrey',
|
||||
]}
|
||||
/>
|
||||
<br />
|
||||
<ColorExamples
|
||||
baseUrl={baseUrl}
|
||||
colors={[
|
||||
'success',
|
||||
'important',
|
||||
'critical',
|
||||
'informational',
|
||||
'inactive',
|
||||
]}
|
||||
/>
|
||||
<br />
|
||||
<ColorExamples
|
||||
baseUrl={baseUrl}
|
||||
colors={['blueviolet', 'ff69b4', '9cf']}
|
||||
/>
|
||||
</p>
|
||||
|
||||
<H3>Endpoint</H3>
|
||||
|
||||
<p>
|
||||
<Snippet snippet={`${baseUrl}/endpoint?url=<URL>&style<STYLE>`} />
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Create badges from <Link to="/endpoint">your own JSON endpoint</Link>.
|
||||
</p>
|
||||
|
||||
<H3 id="dynamic-badge">Dynamic</H3>
|
||||
|
||||
<DynamicBadgeMaker baseUrl={baseUrl} />
|
||||
|
||||
<p>
|
||||
<StyledCode>
|
||||
{baseUrl}
|
||||
/badge/dynamic/json?url=<URL>&label=<LABEL>&query=<
|
||||
<a
|
||||
href="https://jsonpath.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
title="JSONPath syntax"
|
||||
>
|
||||
$.DATA.SUBDATA
|
||||
</a>
|
||||
>&color=<COLOR>&prefix=<PREFIX>&suffix=<SUFFIX>
|
||||
</StyledCode>
|
||||
</p>
|
||||
<p>
|
||||
<StyledCode>
|
||||
{baseUrl}
|
||||
/badge/dynamic/xml?url=<URL>&label=<LABEL>&query=<
|
||||
<a
|
||||
href="http://xpather.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
title="XPath syntax"
|
||||
>
|
||||
//data/subdata
|
||||
</a>
|
||||
>&color=<COLOR>&prefix=<PREFIX>&suffix=<SUFFIX>
|
||||
</StyledCode>
|
||||
</p>
|
||||
<p>
|
||||
<StyledCode>
|
||||
{baseUrl}
|
||||
/badge/dynamic/yaml?url=<URL>&label=<LABEL>&query=<
|
||||
<a
|
||||
href="https://jsonpath.com"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
title="YAML (JSONPath) syntax"
|
||||
>
|
||||
$.DATA.SUBDATA
|
||||
</a>
|
||||
>&color=<COLOR>&prefix=<PREFIX>&suffix=<SUFFIX>
|
||||
</StyledCode>
|
||||
</p>
|
||||
|
||||
<VerticalSpace />
|
||||
|
||||
<H2 id="styles">Styles</H2>
|
||||
|
||||
<p>
|
||||
The following styles are available. Flat is the default. Examples are
|
||||
shown with an optional logo:
|
||||
</p>
|
||||
<StyleExamples baseUrl={baseUrl} />
|
||||
|
||||
<p>
|
||||
Here are a few other parameters you can use: (connecting several with
|
||||
"&" is possible)
|
||||
</p>
|
||||
<QueryParamTable>
|
||||
<tbody>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Override the default left-hand-side text (
|
||||
<a href="https://developer.mozilla.org/en-US/docs/Glossary/percent-encoding">
|
||||
URL-Encoding
|
||||
</a>
|
||||
{} needed for spaces or special characters!)
|
||||
</span>
|
||||
}
|
||||
key="label"
|
||||
snippet="?label=healthinesses"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Insert one of the named logos from (<NamedLogos />) or
|
||||
simple-icons. All simple-icons are referenced using icon slugs.
|
||||
You can click the icon title on{' '}
|
||||
<a
|
||||
href="https://simpleicons.org/"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
simple-icons
|
||||
</a>{' '}
|
||||
to copy the slug or they can be found in the{' '}
|
||||
<a href="https://github.com/simple-icons/simple-icons/blob/master/slugs.md">
|
||||
slugs.md file
|
||||
</a>{' '}
|
||||
in the simple-icons repository.
|
||||
</span>
|
||||
}
|
||||
key="logo"
|
||||
snippet="?logo=appveyor"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Insert custom logo image (≥ 14px high). There is a limit on the
|
||||
total size of request headers we can accept (8192 bytes). From a
|
||||
practical perspective, this means the base64-encoded image text
|
||||
is limited to somewhere slightly under 8192 bytes depending on
|
||||
the rest of the request header.
|
||||
</span>
|
||||
}
|
||||
key="logoSvg"
|
||||
snippet="?logo=data:image/png;base64,…"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Set the color of the logo (hex, rgb, rgba, hsl, hsla and css
|
||||
named colors supported). Supported for named logos and Shields
|
||||
logos but not for custom logos. For multicolor Shields logos,
|
||||
the corresponding named logo will be used and colored.
|
||||
</span>
|
||||
}
|
||||
key="logoColor"
|
||||
snippet="?logoColor=violet"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>Set the horizontal space to give to the logo</span>
|
||||
}
|
||||
key="logoWidth"
|
||||
snippet="?logoWidth=40"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Specify what clicking on the left/right of a badge should do.
|
||||
Note that this only works when integrating your badge in an
|
||||
<StyledCode><object></StyledCode> HTML tag, but not an
|
||||
<StyledCode><img></StyledCode> tag or a markup language.
|
||||
</span>
|
||||
}
|
||||
key="link"
|
||||
snippet="?link=http://left&link=http://right"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Set background of the left part (hex, rgb, rgba, hsl, hsla and
|
||||
css named colors supported). The legacy name "colorA" is also
|
||||
supported.
|
||||
</span>
|
||||
}
|
||||
key="labelColor"
|
||||
snippet="?labelColor=abcdef"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Set background of the right part (hex, rgb, rgba, hsl, hsla and
|
||||
css named colors supported). The legacy name "colorB" is also
|
||||
supported.
|
||||
</span>
|
||||
}
|
||||
key="color"
|
||||
snippet="?color=fedcba"
|
||||
/>
|
||||
<QueryParam
|
||||
documentation={
|
||||
<span>
|
||||
Set the HTTP cache lifetime (rules are applied to infer a
|
||||
default value on a per-badge basis, any values specified below
|
||||
the default will be ignored). The legacy name "maxAge" is also
|
||||
supported.
|
||||
</span>
|
||||
}
|
||||
key="cacheSeconds"
|
||||
snippet="?cacheSeconds=3600"
|
||||
/>
|
||||
</tbody>
|
||||
</QueryParamTable>
|
||||
|
||||
<p>
|
||||
We support <code>.svg</code> and <code>.json</code>. The default is{' '}
|
||||
<code>.svg</code>, which can be omitted from the URL.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
While we highly recommend using SVG, we also support <code>.png</code>{' '}
|
||||
for use cases where SVG will not work. These requests should be made to
|
||||
our raster server <code>https://raster.shields.io</code>. For example,
|
||||
the raster equivalent of{' '}
|
||||
<code>https://img.shields.io/npm/v/express</code> is{' '}
|
||||
<code>https://raster.shields.io/npm/v/express</code>. For backward
|
||||
compatibility, the badge server will redirect <code>.png</code> badges
|
||||
to the raster server.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
36
frontend/constants.ts
Normal file
36
frontend/constants.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const baseUrl = process.env.GATSBY_BASE_URL
|
||||
|
||||
export function getBaseUrl(): string {
|
||||
if (baseUrl) {
|
||||
return baseUrl
|
||||
}
|
||||
|
||||
/*
|
||||
This is a special case for production.
|
||||
|
||||
We want to be able to build the front end with no value set for
|
||||
`GATSBY_BASE_URL` so that we can deploy a build to staging
|
||||
and then promote the exact same build to production.
|
||||
|
||||
When deployed to staging, we want the frontend on
|
||||
https://staging.shields.io/ to generate badges with the base
|
||||
https://staging.shields.io/
|
||||
|
||||
When we promote to production we want https://shields.io/ and
|
||||
https://www.shields.io/ to both generate badges with the base
|
||||
https://img.shields.io/
|
||||
*/
|
||||
try {
|
||||
const { protocol, hostname, port } = window.location
|
||||
if (['shields.io', 'www.shields.io'].includes(hostname)) {
|
||||
return 'https://img.shields.io'
|
||||
}
|
||||
if (!port) {
|
||||
return `${protocol}//${hostname}`
|
||||
}
|
||||
return `${protocol}//${hostname}:${port}`
|
||||
} catch (e) {
|
||||
// server-side rendering
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# TODO
|
||||
@@ -1,122 +0,0 @@
|
||||
const lightCodeTheme = require('prism-react-renderer/themes/github')
|
||||
const darkCodeTheme = require('prism-react-renderer/themes/dracula')
|
||||
|
||||
/** @type {import('@docusaurus/types').Config} */
|
||||
const config = {
|
||||
title: 'Shields.io',
|
||||
tagline: 'Concise, consistent, and legible badges',
|
||||
url: 'https://shields.io',
|
||||
baseUrl: '/',
|
||||
onBrokenLinks: 'throw',
|
||||
onBrokenMarkdownLinks: 'warn',
|
||||
favicon: 'img/favicon.ico',
|
||||
organizationName: 'badges',
|
||||
projectName: 'shields',
|
||||
|
||||
themes: [
|
||||
[
|
||||
require.resolve('@easyops-cn/docusaurus-search-local'),
|
||||
/** @type {import("@easyops-cn/docusaurus-search-local").PluginOptions} */
|
||||
({
|
||||
hashed: true,
|
||||
indexPages: true,
|
||||
}),
|
||||
],
|
||||
],
|
||||
|
||||
presets: [
|
||||
[
|
||||
'docusaurus-preset-openapi',
|
||||
/** @type {import('docusaurus-preset-openapi').Options} */
|
||||
({
|
||||
docs: {
|
||||
sidebarPath: require.resolve('./sidebars.cjs'),
|
||||
editUrl: 'https://github.com/badges/shields/',
|
||||
},
|
||||
blog: {
|
||||
showReadingTime: true,
|
||||
editUrl: 'https://github.com/badges/shields/',
|
||||
},
|
||||
theme: {
|
||||
customCss: require.resolve('./src/css/custom.css'),
|
||||
},
|
||||
api: {
|
||||
path: 'categories',
|
||||
routeBasePath: 'badges',
|
||||
},
|
||||
}),
|
||||
],
|
||||
],
|
||||
|
||||
themeConfig:
|
||||
/** @type {import('docusaurus-preset-openapi').ThemeConfig} */
|
||||
({
|
||||
languageTabs: [],
|
||||
navbar: {
|
||||
title: 'Shields.io',
|
||||
logo: {
|
||||
alt: 'Shields Logo',
|
||||
src: 'img/logo.png',
|
||||
},
|
||||
items: [
|
||||
{ to: '/badges', label: 'Badges', position: 'left' },
|
||||
{ to: '/community', label: 'Community', position: 'left' },
|
||||
{
|
||||
href: 'https://github.com/badges/shields',
|
||||
label: 'GitHub',
|
||||
position: 'right',
|
||||
},
|
||||
],
|
||||
},
|
||||
footer: {
|
||||
style: 'dark',
|
||||
links: [
|
||||
{
|
||||
title: 'Community',
|
||||
items: [
|
||||
{
|
||||
label: 'GitHub',
|
||||
href: 'https://github.com/badges/shields',
|
||||
},
|
||||
{
|
||||
label: 'Open Collective',
|
||||
href: 'https://opencollective.com/shields',
|
||||
},
|
||||
{
|
||||
label: 'Discord',
|
||||
href: 'https://discord.gg/HjJCwm5',
|
||||
},
|
||||
{
|
||||
label: 'Twitter',
|
||||
href: 'https://twitter.com/shields_io',
|
||||
},
|
||||
{
|
||||
label: 'Awesome Badges',
|
||||
href: 'https://github.com/badges/awesome-badges',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Stats',
|
||||
items: [
|
||||
{
|
||||
label: 'Service Status',
|
||||
href: 'https://stats.uptimerobot.com/PjXogHB5p',
|
||||
},
|
||||
{
|
||||
label: 'Metrics dashboard',
|
||||
href: 'https://metrics.shields.io/',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
copyright: `Copyright © ${new Date().getFullYear()} Shields.io. Built with Docusaurus.`,
|
||||
},
|
||||
prism: {
|
||||
theme: lightCodeTheme,
|
||||
darkTheme: darkCodeTheme,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
module.exports = config
|
||||
18
frontend/gatsby-browser.js
Normal file
18
frontend/gatsby-browser.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import redirectLegacyRoutes from './lib/redirect-legacy-routes'
|
||||
|
||||
// Adapted from https://github.com/gatsbyjs/gatsby/issues/8413
|
||||
function scrollToElementId(id) {
|
||||
const el = document.querySelector(id)
|
||||
if (el) {
|
||||
return window.scrollTo(0, el.offsetTop - 20)
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function onRouteUpdate({ location: { hash } }) {
|
||||
if (hash) {
|
||||
redirectLegacyRoutes()
|
||||
window.setTimeout(() => scrollToElementId(hash), 10)
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user