Compare commits

..

1 Commits

Author SHA1 Message Date
chris48s
ae1f3c3710 do nothing 2023-04-11 19:27:40 +01:00
338 changed files with 32953 additions and 24867 deletions

View File

@@ -3,7 +3,6 @@ shields.env
.git/
.gitignore
.vscode/
fly.toml
# Improve layer cacheability.
Dockerfile

View File

@@ -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'

View File

@@ -35,6 +35,7 @@ function allChangelogLinesAreVersionBump(changelogLines) {
function isPointlessVersionBump(body) {
const pointlessBumpLinks = [
'https://github.com/gatsbyjs/gatsby',
'https://github.com/typescript-eslint/typescript-eslint',
]

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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\"}"

View File

@@ -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 }}

View File

@@ -1,7 +1,7 @@
name: Danger
on:
pull_request_target:
types: [opened, reopened, synchronize]
types: [opened, edited, reopened, synchronize]
permissions:
checks: write

View File

@@ -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 }}

View File

@@ -1,7 +1,7 @@
name: 'Dependency Review'
on:
pull_request:
types: [opened, reopened, synchronize]
types: [opened, edited, reopened, synchronize]
jobs:
enforce-dependency-review:

View File

@@ -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
View 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

View File

@@ -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:

View File

@@ -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:

View File

@@ -1,7 +1,7 @@
name: Lint
on:
pull_request:
types: [opened, reopened, synchronize]
types: [opened, edited, reopened, synchronize]
push:
branches-ignore:
- 'gh-pages'

View File

@@ -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'

View File

@@ -1,7 +1,7 @@
name: Main
on:
pull_request:
types: [opened, reopened, synchronize]
types: [opened, edited, reopened, synchronize]
push:
branches-ignore:
- 'gh-pages'

View File

@@ -1,7 +1,7 @@
name: Package CLI
on:
pull_request:
types: [opened, reopened, synchronize]
types: [opened, edited, reopened, synchronize]
push:
branches-ignore:
- 'gh-pages'

View File

@@ -1,7 +1,7 @@
name: Package Library
on:
pull_request:
types: [opened, reopened, synchronize]
types: [opened, edited, reopened, synchronize]
push:
branches-ignore:
- 'gh-pages'

View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1,5 @@
reporter: mocha-env-reporter
require:
- '@babel/polyfill'
- '@babel/register'
- mocha-yaml-loader

10
.nycrc-frontend.json Normal file
View 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"]
}

View File

@@ -10,5 +10,5 @@ public
private/*.json
/.nyc_output
analytics.json
frontend/.docusaurus
frontend/categories
supported-features.json
service-definitions.yml

View File

@@ -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)

View File

@@ -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:

View File

@@ -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.

View File

@@ -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')
})

View File

@@ -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',

View File

@@ -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',

View File

@@ -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
View 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

View File

@@ -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,
}

View 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('')
)
})
})

View File

@@ -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) {

View File

@@ -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)

View File

@@ -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 = {

View File

@@ -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) {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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 })
}
}

View File

@@ -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: {

View File

@@ -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.

View File

@@ -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 () {

View File

@@ -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]

View File

@@ -1,3 +1,3 @@
class BadNoBaseService {}
class BadService {}
export default BadNoBaseService
export default BadService

View File

@@ -1,4 +1,4 @@
class BadBaseService {}
class BadChildService extends BadBaseService {}
class BadService extends BadBaseService {}
export default BadChildService
export default BadService

View File

@@ -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]

View File

@@ -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 }

View File

@@ -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,
}

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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,
}

View File

@@ -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',

View File

@@ -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

View 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)
})
})
})
})

View 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)
}
}
}

View File

@@ -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)
})
})
})

View File

@@ -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')

View File

@@ -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',
},

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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',
}

View File

@@ -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"

View File

@@ -1,3 +0,0 @@
module.exports = {
presets: [require.resolve('@docusaurus/core/lib/babel/preset')],
}

View 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>
)
}

View 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>
)
}

View 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;
`

View 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;
`

View 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)

View 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>
)
}

View 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>
)
}

View 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>
</>
)
}

View 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}
/>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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 &nbsp; Retina-ready &nbsp; Fast &nbsp; Consistent &nbsp;
Hackable &nbsp; No tracking
</Highlights>
</section>
)
}

View 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>
)
}

View 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>
)
}

View 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}
/>
</>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>&nbsp;</code>
</span>
}
rhs={
<span>
<code>&nbsp;</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=&lt;URL&gt;&amp;label=&lt;LABEL&gt;&amp;query=&lt;
<a
href="https://jsonpath.com"
rel="noopener noreferrer"
target="_blank"
title="JSONPath syntax"
>
$.DATA.SUBDATA
</a>
&gt;&amp;color=&lt;COLOR&gt;&amp;prefix=&lt;PREFIX&gt;&amp;suffix=&lt;SUFFIX&gt;
</StyledCode>
</p>
<p>
<StyledCode>
{baseUrl}
/badge/dynamic/xml?url=&lt;URL&gt;&amp;label=&lt;LABEL&gt;&amp;query=&lt;
<a
href="http://xpather.com"
rel="noopener noreferrer"
target="_blank"
title="XPath syntax"
>
&#x2F;&#x2F;data/subdata
</a>
&gt;&amp;color=&lt;COLOR&gt;&amp;prefix=&lt;PREFIX&gt;&amp;suffix=&lt;SUFFIX&gt;
</StyledCode>
</p>
<p>
<StyledCode>
{baseUrl}
/badge/dynamic/yaml?url=&lt;URL&gt;&amp;label=&lt;LABEL&gt;&amp;query=&lt;
<a
href="https://jsonpath.com"
rel="noopener noreferrer"
target="_blank"
title="YAML (JSONPath) syntax"
>
$.DATA.SUBDATA
</a>
&gt;&amp;color=&lt;COLOR&gt;&amp;prefix=&lt;PREFIX&gt;&amp;suffix=&lt;SUFFIX&gt;
</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>&lt;object&gt;</StyledCode> HTML tag, but not an
<StyledCode>&lt;img&gt;</StyledCode> tag or a markup language.
</span>
}
key="link"
snippet="?link=http://left&amp;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
View 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 ''
}
}

View File

@@ -1,5 +0,0 @@
---
sidebar_position: 1
---
# TODO

View File

@@ -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

View 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