Compare commits
114 Commits
server-202
...
github-oau
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e31d7f32 | ||
|
|
3aadb79325 | ||
|
|
b8412fd80b | ||
|
|
345188e34b | ||
|
|
a92dc72ff5 | ||
|
|
bf469f10df | ||
|
|
994e752fd1 | ||
|
|
f6fd8eac4a | ||
|
|
c41d45100e | ||
|
|
e66b266800 | ||
|
|
1ac7ccc231 | ||
|
|
d728749886 | ||
|
|
354fb7db99 | ||
|
|
961e13b229 | ||
|
|
6bb62e4c0b | ||
|
|
721d0142ff | ||
|
|
13a53f123f | ||
|
|
ca63f21113 | ||
|
|
042ae1c45f | ||
|
|
2f52b1617d | ||
|
|
e91da33016 | ||
|
|
a76df09c35 | ||
|
|
70874e2d5b | ||
|
|
68dbf71d42 | ||
|
|
f4bddb9964 | ||
|
|
cb52deec1c | ||
|
|
047b14b52a | ||
|
|
dfb68efffb | ||
|
|
8284545e22 | ||
|
|
8a1c69ead6 | ||
|
|
1b871a97b4 | ||
|
|
0342a3d7c6 | ||
|
|
77871a9f7b | ||
|
|
15be262ba5 | ||
|
|
05fe731290 | ||
|
|
58310f7363 | ||
|
|
570c2750e2 | ||
|
|
62af78c488 | ||
|
|
c48cd071fe | ||
|
|
a9c9e7d679 | ||
|
|
4a47b9a364 | ||
|
|
901dd7b9b6 | ||
|
|
6dff73065a | ||
|
|
a5f803ff2b | ||
|
|
9780da024e | ||
|
|
12b5e8891f | ||
|
|
5472c733a6 | ||
|
|
fcab8a52dc | ||
|
|
a111e9cba8 | ||
|
|
7a7cda6d4b | ||
|
|
76ca283775 | ||
|
|
b02471ff42 | ||
|
|
7b9d1d340b | ||
|
|
48faded3f7 | ||
|
|
be3fa207b5 | ||
|
|
2624b0fb00 | ||
|
|
b0b986f31f | ||
|
|
61139bbccb | ||
|
|
c5ee05c3fd | ||
|
|
c8fcee3bff | ||
|
|
54844c0892 | ||
|
|
22995e4e35 | ||
|
|
92772de68e | ||
|
|
1aea78a5d0 | ||
|
|
6919b0a49c | ||
|
|
8fd54b1b8d | ||
|
|
779c1ffaad | ||
|
|
e8c78d55b3 | ||
|
|
1c36dd2bd0 | ||
|
|
45def37a0d | ||
|
|
8c3b20b5b8 | ||
|
|
dd63a0b71f | ||
|
|
96a13b8749 | ||
|
|
f28ba52562 | ||
|
|
9a78f0a0c2 | ||
|
|
11bf09bf0f | ||
|
|
44a3e9abb6 | ||
|
|
66dbcb5ca6 | ||
|
|
dabc907fc5 | ||
|
|
71bbcd527b | ||
|
|
70947e1ff0 | ||
|
|
48b7f70ef2 | ||
|
|
96c27c2627 | ||
|
|
17beba4ebf | ||
|
|
95f6611837 | ||
|
|
eb7352e5ae | ||
|
|
c81a1c6196 | ||
|
|
64c6533849 | ||
|
|
8468ddbb07 | ||
|
|
169d51412a | ||
|
|
51e70bae8d | ||
|
|
8be2da17ed | ||
|
|
8e0788ccdc | ||
|
|
36d80af111 | ||
|
|
5b583cc9ef | ||
|
|
1444b5624c | ||
|
|
45a9c90782 | ||
|
|
a4c93c56e7 | ||
|
|
f4781804f6 | ||
|
|
21742a50b4 | ||
|
|
2cb52494c3 | ||
|
|
fbef4a5d5d | ||
|
|
64b747605c | ||
|
|
ea071535e3 | ||
|
|
3b5f915443 | ||
|
|
8b17c7b175 | ||
|
|
ec028c5158 | ||
|
|
b901b2b216 | ||
|
|
ff8f4432b3 | ||
|
|
ed07b61e7d | ||
|
|
087a824a85 | ||
|
|
e18de8c8be | ||
|
|
63fd51b428 | ||
|
|
37e83641ab |
14
.github/actions/close-bot/package-lock.json
generated
vendored
14
.github/actions/close-bot/package-lock.json
generated
vendored
@@ -9,14 +9,14 @@
|
||||
"version": "0.0.0",
|
||||
"license": "CC0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.4.0",
|
||||
"@actions/core": "^1.5.0",
|
||||
"@actions/github": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@actions/core": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.4.0.tgz",
|
||||
"integrity": "sha512-CGx2ilGq5i7zSLgiiGUtBCxhRRxibJYU6Fim0Q1Wg2aQL2LTnF27zbqZOrxfvFQ55eSBW0L8uVStgtKMpa0Qlg=="
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-eDOLH1Nq9zh+PJlYLqEMkS/jLQxhksPNmUGNBHfa4G+tQmnIhzpctxmchETtVGyBOvXgOVVpYuE40+eS4cUnwQ=="
|
||||
},
|
||||
"node_modules/@actions/github": {
|
||||
"version": "5.0.0",
|
||||
@@ -193,9 +193,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/core": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.4.0.tgz",
|
||||
"integrity": "sha512-CGx2ilGq5i7zSLgiiGUtBCxhRRxibJYU6Fim0Q1Wg2aQL2LTnF27zbqZOrxfvFQ55eSBW0L8uVStgtKMpa0Qlg=="
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@actions/core/-/core-1.5.0.tgz",
|
||||
"integrity": "sha512-eDOLH1Nq9zh+PJlYLqEMkS/jLQxhksPNmUGNBHfa4G+tQmnIhzpctxmchETtVGyBOvXgOVVpYuE40+eS4cUnwQ=="
|
||||
},
|
||||
"@actions/github": {
|
||||
"version": "5.0.0",
|
||||
|
||||
2
.github/actions/close-bot/package.json
vendored
2
.github/actions/close-bot/package.json
vendored
@@ -10,7 +10,7 @@
|
||||
"author": "chris48s",
|
||||
"license": "CC0",
|
||||
"dependencies": {
|
||||
"@actions/core": "^1.4.0",
|
||||
"@actions/core": "^1.5.0",
|
||||
"@actions/github": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
2
.github/workflows/auto-close.yml
vendored
2
.github/workflows/auto-close.yml
vendored
@@ -2,7 +2,7 @@ name: Auto close
|
||||
on: pull_request_target
|
||||
|
||||
permissions:
|
||||
pull_request: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
20
.github/workflows/build-docker-image.yml
vendored
Normal file
20
.github/workflows/build-docker-image.yml
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
name: Build Docker Image
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Build
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: false
|
||||
tags: shieldsio/shields:pr-validation
|
||||
@@ -1,11 +1,11 @@
|
||||
name: Tag Release
|
||||
name: Create Release
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
tag-release:
|
||||
create-release:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'closed' &&
|
||||
@@ -24,8 +24,24 @@ jobs:
|
||||
with:
|
||||
ref: 'master'
|
||||
|
||||
- name: Tag Release
|
||||
- name: Tag release in GitHub
|
||||
uses: tvdias/github-tagger@v0.0.2
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
tag: server-${{ steps.date.outputs.date }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push snapshot release to DockerHub
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: shieldsio/shields:server-${{ steps.date.outputs.date }}
|
||||
30
.github/workflows/publish-docker-snapshot.yml
vendored
30
.github/workflows/publish-docker-snapshot.yml
vendored
@@ -1,30 +0,0 @@
|
||||
name: Build and Publish Snapshot Docker Image
|
||||
on: create
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/server-') }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get tag name
|
||||
id: get_version
|
||||
run: echo ::set-output name=TAG_NAME::$(echo $GITHUB_REF | cut -d / -f 3)
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: shieldsio/shields:${{ steps.get_version.outputs.TAG_NAME }}
|
||||
13
CHANGELOG.md
13
CHANGELOG.md
@@ -4,6 +4,19 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m
|
||||
|
||||
---
|
||||
|
||||
## server-2021-09-01
|
||||
|
||||
- use multi-stage build to reduce size of docker images [#6938](https://github.com/badges/shields/issues/6938)
|
||||
- remove disableStrictSsl param from [jenkins] [#6887](https://github.com/badges/shields/issues/6887)
|
||||
- refactor(GitHubCommitActivity): switch to v4/GraphQL API [#6959](https://github.com/badges/shields/issues/6959)
|
||||
- feat: add freecodecamp badge [#6958](https://github.com/badges/shields/issues/6958)
|
||||
- use the right version of NPM in docker build [#6941](https://github.com/badges/shields/issues/6941)
|
||||
- [TwitchExtensionVersion] New badge [#6900](https://github.com/badges/shields/issues/6900)
|
||||
- enforce strict SSL checking for [coverity] [#6886](https://github.com/badges/shields/issues/6886)
|
||||
- Update self hosting docs [#6877](https://github.com/badges/shields/issues/6877)
|
||||
- Support optionalDependencies in [GithubPackageJson] [#6749](https://github.com/badges/shields/issues/6749)
|
||||
- Dependency updates
|
||||
|
||||
## server-2021-08-01
|
||||
|
||||
- use v5 API for [AUR] badges [#6836](https://github.com/badges/shields/issues/6836)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:14-alpine
|
||||
FROM node:14-alpine AS Builder
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
RUN mkdir /usr/src/app/private
|
||||
@@ -8,6 +8,7 @@ COPY package.json package-lock.json /usr/src/app/
|
||||
# Without the badge-maker package.json and CLI script in place, `npm ci` will fail.
|
||||
COPY badge-maker /usr/src/app/badge-maker/
|
||||
|
||||
RUN npm install -g "npm@>=7"
|
||||
# We need dev deps to build the front end. We don't need Cypress, though.
|
||||
RUN NODE_ENV=development CYPRESS_INSTALL_BINARY=0 npm ci
|
||||
|
||||
@@ -16,9 +17,14 @@ RUN npm run build
|
||||
RUN npm prune --production
|
||||
RUN npm cache clean --force
|
||||
|
||||
# Use multi-stage build to reduce size
|
||||
FROM node:14-alpine
|
||||
# Run the server using production configs.
|
||||
ENV NODE_ENV production
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
COPY --from=Builder /usr/src/app /usr/src/app
|
||||
|
||||
CMD node server
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -73,7 +73,7 @@ This repo hosts:
|
||||
[Make your own badges!][custom badges]
|
||||
(Quick example: `https://img.shields.io/badge/left-right-f39f37`)
|
||||
|
||||
[custom badges]: http://shields.io/#your-badge
|
||||
[custom badges]: https://shields.io/#your-badge
|
||||
|
||||
### Quickstart
|
||||
|
||||
|
||||
@@ -402,6 +402,126 @@ exports['The badge generator "flat" template badge generation should match snaps
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "flat" template badge generation should match snapshots: black text when the label color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="20"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
|
||||
<stop offset="1" stop-opacity=".1" />
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff" /></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="20" fill="#f3f3f3" />
|
||||
<rect x="45" width="45" height="20" fill="#000" />
|
||||
<rect width="90" height="20" fill="url(#s)" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="235"
|
||||
y="150"
|
||||
fill="#ccc"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
cactus
|
||||
</text>
|
||||
<text x="235" y="140" transform="scale(.1)" fill="#333" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="665"
|
||||
y="150"
|
||||
fill="#010101"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
grown
|
||||
</text>
|
||||
<text x="665" y="140" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "flat" template badge generation should match snapshots: black text when the message color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="20"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
|
||||
<stop offset="1" stop-opacity=".1" />
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff" /></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="20" fill="#000" />
|
||||
<rect x="45" width="45" height="20" fill="#e2ffe1" />
|
||||
<rect width="90" height="20" fill="url(#s)" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="235"
|
||||
y="150"
|
||||
fill="#010101"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
cactus
|
||||
</text>
|
||||
<text x="235" y="140" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="665"
|
||||
y="150"
|
||||
fill="#ccc"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
grown
|
||||
</text>
|
||||
<text x="665" y="140" transform="scale(.1)" fill="#333" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "flat-square" template badge generation should match snapshots: message/label, no logo 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -609,6 +729,70 @@ exports['The badge generator "flat-square" template badge generation should matc
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "flat-square" template badge generation should match snapshots: black text when the label color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="20"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="45" height="20" fill="#f3f3f3" />
|
||||
<rect x="45" width="45" height="20" fill="#000" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text x="235" y="140" transform="scale(.1)" fill="#333" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text x="665" y="140" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "flat-square" template badge generation should match snapshots: black text when the message color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="20"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="45" height="20" fill="#000" />
|
||||
<rect x="45" width="45" height="20" fill="#e2ffe1" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text x="235" y="140" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text x="665" y="140" transform="scale(.1)" fill="#333" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "plastic" template badge generation should match snapshots: message/label, no logo 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -965,6 +1149,130 @@ exports['The badge generator "plastic" template badge generation should match sn
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "plastic" template badge generation should match snapshots: black text when the label color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="18"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7" />
|
||||
<stop offset=".1" stop-color="#aaa" stop-opacity=".1" />
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3" />
|
||||
<stop offset="1" stop-color="#000" stop-opacity=".5" />
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="90" height="18" rx="4" fill="#fff" /></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="18" fill="#f3f3f3" />
|
||||
<rect x="45" width="45" height="18" fill="#000" />
|
||||
<rect width="90" height="18" fill="url(#s)" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="235"
|
||||
y="140"
|
||||
fill="#ccc"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
cactus
|
||||
</text>
|
||||
<text x="235" y="130" transform="scale(.1)" fill="#333" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="665"
|
||||
y="140"
|
||||
fill="#010101"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
grown
|
||||
</text>
|
||||
<text x="665" y="130" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "plastic" template badge generation should match snapshots: black text when the message color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="18"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7" />
|
||||
<stop offset=".1" stop-color="#aaa" stop-opacity=".1" />
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3" />
|
||||
<stop offset="1" stop-color="#000" stop-opacity=".5" />
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="90" height="18" rx="4" fill="#fff" /></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="18" fill="#000" />
|
||||
<rect x="45" width="45" height="18" fill="#e2ffe1" />
|
||||
<rect width="90" height="18" fill="url(#s)" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="235"
|
||||
y="140"
|
||||
fill="#010101"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
cactus
|
||||
</text>
|
||||
<text x="235" y="130" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="665"
|
||||
y="140"
|
||||
fill="#ccc"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
grown
|
||||
</text>
|
||||
<text x="665" y="130" transform="scale(.1)" fill="#333" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "for-the-badge" template badge generation should match snapshots: message/label, no logo 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -1218,6 +1526,84 @@ exports['The badge generator "for-the-badge" template badge generation should ma
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "for-the-badge" template badge generation should match snapshots: black text when the label color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="146.75"
|
||||
height="28"
|
||||
role="img"
|
||||
aria-label="CACTUS: GROWN"
|
||||
>
|
||||
<title>CACTUS: GROWN</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="72.5" height="28" fill="#f3f3f3" />
|
||||
<rect x="72.5" width="74.25" height="28" fill="#000" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="100"
|
||||
>
|
||||
<text transform="scale(.1)" x="362.5" y="175" textLength="485" fill="#333">
|
||||
CACTUS
|
||||
</text>
|
||||
<text
|
||||
transform="scale(.1)"
|
||||
x="1096.25"
|
||||
y="175"
|
||||
textLength="502.5"
|
||||
fill="#fff"
|
||||
font-weight="bold"
|
||||
>
|
||||
GROWN
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "for-the-badge" template badge generation should match snapshots: black text when the message color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="146.75"
|
||||
height="28"
|
||||
role="img"
|
||||
aria-label="CACTUS: GROWN"
|
||||
>
|
||||
<title>CACTUS: GROWN</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="72.5" height="28" fill="#000" />
|
||||
<rect x="72.5" width="74.25" height="28" fill="#e2ffe1" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="100"
|
||||
>
|
||||
<text transform="scale(.1)" x="362.5" y="175" textLength="485" fill="#fff">
|
||||
CACTUS
|
||||
</text>
|
||||
<text
|
||||
transform="scale(.1)"
|
||||
x="1096.25"
|
||||
y="175"
|
||||
textLength="502.5"
|
||||
fill="#333"
|
||||
font-weight="bold"
|
||||
>
|
||||
GROWN
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator "social" template badge generation should match snapshots: message/label, no logo 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -1843,102 +2229,3 @@ exports['The badge generator badges with logos should always produce the same ba
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator text colors should use black text when the label color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="90"
|
||||
height="20"
|
||||
role="img"
|
||||
aria-label="cactus: grown"
|
||||
>
|
||||
<title>cactus: grown</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1" />
|
||||
<stop offset="1" stop-opacity=".1" />
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="90" height="20" rx="3" fill="#fff" /></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="45" height="20" fill="#f3f3f3" />
|
||||
<rect x="45" width="45" height="20" fill="#000" />
|
||||
<rect width="90" height="20" fill="url(#s)" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="110"
|
||||
>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="235"
|
||||
y="150"
|
||||
fill="#ccc"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
cactus
|
||||
</text>
|
||||
<text x="235" y="140" transform="scale(.1)" fill="#333" textLength="350">
|
||||
cactus
|
||||
</text>
|
||||
<text
|
||||
aria-hidden="true"
|
||||
x="665"
|
||||
y="150"
|
||||
fill="#010101"
|
||||
fill-opacity=".3"
|
||||
transform="scale(.1)"
|
||||
textLength="350"
|
||||
>
|
||||
grown
|
||||
</text>
|
||||
<text x="665" y="140" transform="scale(.1)" fill="#fff" textLength="350">
|
||||
grown
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
exports['The badge generator text colors should use black text when the message color is light 1'] = `
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
width="146.75"
|
||||
height="28"
|
||||
role="img"
|
||||
aria-label="CACTUS: GROWN"
|
||||
>
|
||||
<title>CACTUS: GROWN</title>
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="72.5" height="28" fill="#000" />
|
||||
<rect x="72.5" width="74.25" height="28" fill="#e2ffe1" />
|
||||
</g>
|
||||
<g
|
||||
fill="#fff"
|
||||
text-anchor="middle"
|
||||
font-family="Verdana,Geneva,DejaVu Sans,sans-serif"
|
||||
text-rendering="geometricPrecision"
|
||||
font-size="100"
|
||||
>
|
||||
<text transform="scale(.1)" x="362.5" y="175" textLength="485" fill="#fff">
|
||||
CACTUS
|
||||
</text>
|
||||
<text
|
||||
transform="scale(.1)"
|
||||
x="1096.25"
|
||||
y="175"
|
||||
textLength="502.5"
|
||||
fill="#333"
|
||||
font-weight="bold"
|
||||
>
|
||||
GROWN
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
`
|
||||
|
||||
2
app.json
2
app.json
@@ -4,7 +4,7 @@
|
||||
"keywords": ["badge", "github", "svg", "status"],
|
||||
"website": "https://shields.io/",
|
||||
"repository": "https://github.com/badges/shields",
|
||||
"logo": "http://shields.io/favicon.png",
|
||||
"logo": "https://shields.io/favicon.png",
|
||||
"env": {
|
||||
"CYPRESS_INSTALL_BINARY": {
|
||||
"description": "Disable the cypress binary installation",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Changelog
|
||||
|
||||
## 4.0.0 [WIP]
|
||||
|
||||
- Drop compatibility with Node 10
|
||||
|
||||
## 3.3.1
|
||||
|
||||
- Improve font measuring in for-the-badge and social styles
|
||||
|
||||
@@ -2,23 +2,22 @@
|
||||
|
||||
const anafanafo = require('anafanafo')
|
||||
const { brightness } = require('./color')
|
||||
const { XmlElement, escapeXml } = require('./xml')
|
||||
const { XmlElement, ElementList } = require('./xml')
|
||||
|
||||
// https://github.com/badges/shields/pull/1132
|
||||
const FONT_SCALE_UP_FACTOR = 10
|
||||
const FONT_SCALE_DOWN_VALUE = 'scale(.1)'
|
||||
|
||||
const FONT_FAMILY = 'Verdana,Geneva,DejaVu Sans,sans-serif'
|
||||
const fontFamily = `font-family="${FONT_FAMILY}"`
|
||||
const socialFontFamily =
|
||||
'font-family="Helvetica Neue,Helvetica,Arial,sans-serif"'
|
||||
const brightnessThreshold = 0.69
|
||||
const WIDTH_FONT = '11px Verdana'
|
||||
const SOCIAL_FONT_FAMILY = 'Helvetica Neue,Helvetica,Arial,sans-serif'
|
||||
|
||||
function capitalize(s) {
|
||||
return `${s.charAt(0).toUpperCase()}${s.slice(1)}`
|
||||
}
|
||||
|
||||
function colorsForBackground(color) {
|
||||
const brightnessThreshold = 0.69
|
||||
if (brightness(color) <= brightnessThreshold) {
|
||||
return { textColor: '#fff', shadowColor: '#010101' }
|
||||
} else {
|
||||
@@ -53,127 +52,61 @@ function shouldWrapBodyWithLink({ links }) {
|
||||
return hasLeftLink && !hasRightLink
|
||||
}
|
||||
|
||||
function renderAriaAttributes({ accessibleText, links }) {
|
||||
const { hasLink } = hasLinks({ links })
|
||||
return hasLink ? '' : `role="img" aria-label="${escapeXml(accessibleText)}"`
|
||||
}
|
||||
|
||||
function renderTitle({ accessibleText, links }) {
|
||||
const { hasLink } = hasLinks({ links })
|
||||
return hasLink ? '' : `<title>${escapeXml(accessibleText)}</title>`
|
||||
}
|
||||
|
||||
function renderLogo({
|
||||
logo,
|
||||
badgeHeight,
|
||||
horizPadding,
|
||||
logoWidth = 14,
|
||||
logoPadding = 0,
|
||||
}) {
|
||||
if (logo) {
|
||||
const logoHeight = 14
|
||||
const y = (badgeHeight - logoHeight) / 2
|
||||
const x = horizPadding
|
||||
return {
|
||||
hasLogo: true,
|
||||
totalLogoWidth: logoWidth + logoPadding,
|
||||
renderedLogo: `<image x="${x}" y="${y}" width="${logoWidth}" height="${logoHeight}" xlink:href="${escapeXml(
|
||||
logo
|
||||
)}"/>`,
|
||||
}
|
||||
} else {
|
||||
return { hasLogo: false, totalLogoWidth: 0, renderedLogo: '' }
|
||||
}
|
||||
}
|
||||
|
||||
function renderLink({
|
||||
link,
|
||||
height,
|
||||
textLength,
|
||||
horizPadding,
|
||||
leftMargin,
|
||||
renderedText,
|
||||
}) {
|
||||
const rectHeight = height
|
||||
const rectWidth = textLength + horizPadding * 2
|
||||
const rectX = leftMargin > 1 ? leftMargin + 1 : 0
|
||||
return `<a target="_blank" xlink:href="${escapeXml(link)}">
|
||||
<rect width="${rectWidth}" x="${rectX}" height="${rectHeight}" fill="rgba(0,0,0,0)" />
|
||||
${renderedText}
|
||||
</a>`
|
||||
}
|
||||
|
||||
function renderText({
|
||||
leftMargin,
|
||||
horizPadding = 0,
|
||||
content,
|
||||
link,
|
||||
height,
|
||||
verticalMargin = 0,
|
||||
shadow = false,
|
||||
color,
|
||||
}) {
|
||||
if (!content.length) {
|
||||
return { renderedText: '', width: 0 }
|
||||
}
|
||||
|
||||
const textLength = preferredWidthOf(content, { font: '11px Verdana' })
|
||||
const escapedContent = escapeXml(content)
|
||||
|
||||
const shadowMargin = 150 + verticalMargin
|
||||
const textMargin = 140 + verticalMargin
|
||||
|
||||
const outTextLength = 10 * textLength
|
||||
const x = 10 * (leftMargin + 0.5 * textLength + horizPadding)
|
||||
|
||||
let renderedText = ''
|
||||
const { textColor, shadowColor } = colorsForBackground(color)
|
||||
if (shadow) {
|
||||
renderedText = `<text aria-hidden="true" x="${x}" y="${shadowMargin}" fill="${shadowColor}" fill-opacity=".3" transform="scale(.1)" textLength="${outTextLength}">${escapedContent}</text>`
|
||||
}
|
||||
renderedText += `<text x="${x}" y="${textMargin}" transform="scale(.1)" fill="${textColor}" textLength="${outTextLength}">${escapedContent}</text>`
|
||||
|
||||
return {
|
||||
renderedText: link
|
||||
? renderLink({
|
||||
link,
|
||||
height,
|
||||
textLength,
|
||||
horizPadding,
|
||||
leftMargin,
|
||||
renderedText,
|
||||
})
|
||||
: renderedText,
|
||||
width: textLength,
|
||||
}
|
||||
function getLogoElement({ logo, horizPadding, badgeHeight, logoWidth }) {
|
||||
const logoHeight = 14
|
||||
if (!logo) return ''
|
||||
return new XmlElement({
|
||||
name: 'image',
|
||||
attrs: {
|
||||
x: horizPadding,
|
||||
y: 0.5 * (badgeHeight - logoHeight),
|
||||
width: logoWidth,
|
||||
height: logoHeight,
|
||||
'xlink:href': logo,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function renderBadge(
|
||||
{ links, leftWidth, rightWidth, height, accessibleText },
|
||||
main
|
||||
content
|
||||
) {
|
||||
const width = leftWidth + rightWidth
|
||||
const leftLink = escapeXml(links[0])
|
||||
const leftLink = links[0]
|
||||
const { hasLink } = hasLinks({ links })
|
||||
|
||||
return `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${width}" height="${height}" ${renderAriaAttributes(
|
||||
{ links, accessibleText }
|
||||
)}>
|
||||
const title = hasLink
|
||||
? ''
|
||||
: new XmlElement({ name: 'title', content: [accessibleText] })
|
||||
|
||||
${renderTitle({ accessibleText, links })}
|
||||
${
|
||||
shouldWrapBodyWithLink({ links })
|
||||
? `<a target="_blank" xlink:href="${leftLink}">${main}</a>`
|
||||
: main
|
||||
}
|
||||
</svg>`
|
||||
const body = shouldWrapBodyWithLink({ links })
|
||||
? new XmlElement({
|
||||
name: 'a',
|
||||
content,
|
||||
attrs: { target: '_blank', 'xlink:href': leftLink },
|
||||
})
|
||||
: new ElementList({ content })
|
||||
|
||||
const svgAttrs = {
|
||||
xmlns: 'http://www.w3.org/2000/svg',
|
||||
'xmlns:xlink': 'http://www.w3.org/1999/xlink',
|
||||
width,
|
||||
height,
|
||||
}
|
||||
if (!hasLink) {
|
||||
svgAttrs.role = 'img'
|
||||
svgAttrs['aria-label'] = accessibleText
|
||||
}
|
||||
|
||||
const svg = new XmlElement({
|
||||
name: 'svg',
|
||||
content: [title, body],
|
||||
attrs: svgAttrs,
|
||||
})
|
||||
return svg.render()
|
||||
}
|
||||
|
||||
class Badge {
|
||||
static get fontFamily() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
|
||||
static get height() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
@@ -197,41 +130,25 @@ class Badge {
|
||||
labelColor,
|
||||
}) {
|
||||
const horizPadding = 5
|
||||
const { hasLogo, totalLogoWidth, renderedLogo } = renderLogo({
|
||||
logo,
|
||||
badgeHeight: this.constructor.height,
|
||||
horizPadding,
|
||||
logoWidth,
|
||||
logoPadding,
|
||||
})
|
||||
const hasLogo = !!logo
|
||||
const totalLogoWidth = logoWidth + logoPadding
|
||||
const accessibleText = createAccessibleText({ label, message })
|
||||
|
||||
const hasLabel = label.length || labelColor
|
||||
if (labelColor == null) {
|
||||
labelColor = '#555'
|
||||
}
|
||||
|
||||
const [leftLink, rightLink] = links
|
||||
|
||||
labelColor = hasLabel || hasLogo ? labelColor : color
|
||||
labelColor = escapeXml(labelColor)
|
||||
color = escapeXml(color)
|
||||
|
||||
const labelMargin = totalLogoWidth + 1
|
||||
|
||||
const { renderedText: renderedLabel, width: labelWidth } = renderText({
|
||||
leftMargin: labelMargin,
|
||||
horizPadding,
|
||||
content: label,
|
||||
link: !shouldWrapBodyWithLink({ links }) && leftLink,
|
||||
height: this.constructor.height,
|
||||
verticalMargin: this.constructor.verticalMargin,
|
||||
shadow: this.constructor.shadow,
|
||||
color: labelColor,
|
||||
})
|
||||
|
||||
const labelWidth = label.length
|
||||
? preferredWidthOf(label, { font: WIDTH_FONT })
|
||||
: 0
|
||||
const leftWidth = hasLabel
|
||||
? labelWidth + 2 * horizPadding + totalLogoWidth
|
||||
: 0
|
||||
|
||||
const messageWidth = preferredWidthOf(message, { font: WIDTH_FONT })
|
||||
let messageMargin = leftWidth - (message.length ? 1 : 0)
|
||||
if (!hasLabel) {
|
||||
if (hasLogo) {
|
||||
@@ -240,18 +157,6 @@ class Badge {
|
||||
messageMargin = messageMargin + 1
|
||||
}
|
||||
}
|
||||
|
||||
const { renderedText: renderedMessage, width: messageWidth } = renderText({
|
||||
leftMargin: messageMargin,
|
||||
horizPadding,
|
||||
content: message,
|
||||
link: rightLink,
|
||||
height: this.constructor.height,
|
||||
verticalMargin: this.constructor.verticalMargin,
|
||||
shadow: this.constructor.shadow,
|
||||
color,
|
||||
})
|
||||
|
||||
let rightWidth = messageWidth + 2 * horizPadding
|
||||
if (hasLogo && !hasLabel) {
|
||||
rightWidth += totalLogoWidth + horizPadding - 1
|
||||
@@ -259,9 +164,12 @@ class Badge {
|
||||
|
||||
const width = leftWidth + rightWidth
|
||||
|
||||
const accessibleText = createAccessibleText({ label, message })
|
||||
|
||||
this.horizPadding = horizPadding
|
||||
this.labelMargin = labelMargin
|
||||
this.messageMargin = messageMargin
|
||||
this.links = links
|
||||
this.labelWidth = labelWidth
|
||||
this.messageWidth = messageWidth
|
||||
this.leftWidth = leftWidth
|
||||
this.rightWidth = rightWidth
|
||||
this.width = width
|
||||
@@ -270,25 +178,174 @@ class Badge {
|
||||
this.label = label
|
||||
this.message = message
|
||||
this.accessibleText = accessibleText
|
||||
this.renderedLogo = renderedLogo
|
||||
this.renderedLabel = renderedLabel
|
||||
this.renderedMessage = renderedMessage
|
||||
|
||||
this.logoElement = getLogoElement({
|
||||
logo,
|
||||
horizPadding,
|
||||
badgeHeight: this.constructor.height,
|
||||
logoWidth,
|
||||
})
|
||||
this.foregroundGroupElement = this.getForegroundGroupElement()
|
||||
}
|
||||
|
||||
static render(params) {
|
||||
return new this(params).render()
|
||||
}
|
||||
|
||||
getTextElement({ leftMargin, content, link, color, textWidth, linkWidth }) {
|
||||
if (!content.length) return ''
|
||||
|
||||
const { textColor, shadowColor } = colorsForBackground(color)
|
||||
const x =
|
||||
FONT_SCALE_UP_FACTOR * (leftMargin + 0.5 * textWidth + this.horizPadding)
|
||||
|
||||
const text = new XmlElement({
|
||||
name: 'text',
|
||||
content: [content],
|
||||
attrs: {
|
||||
x,
|
||||
y: 140 + this.constructor.verticalMargin,
|
||||
transform: FONT_SCALE_DOWN_VALUE,
|
||||
fill: textColor,
|
||||
textLength: FONT_SCALE_UP_FACTOR * textWidth,
|
||||
},
|
||||
})
|
||||
|
||||
const shadowText = new XmlElement({
|
||||
name: 'text',
|
||||
content: [content],
|
||||
attrs: {
|
||||
'aria-hidden': 'true',
|
||||
x,
|
||||
y: 150 + this.constructor.verticalMargin,
|
||||
fill: shadowColor,
|
||||
'fill-opacity': '.3',
|
||||
transform: FONT_SCALE_DOWN_VALUE,
|
||||
textLength: FONT_SCALE_UP_FACTOR * textWidth,
|
||||
},
|
||||
})
|
||||
const shadow = this.constructor.shadow ? shadowText : ''
|
||||
|
||||
if (!link) {
|
||||
return new ElementList({ content: [shadow, text] })
|
||||
}
|
||||
|
||||
const rect = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
width: linkWidth,
|
||||
x: leftMargin > 1 ? leftMargin + 1 : 0,
|
||||
height: this.constructor.height,
|
||||
fill: 'rgba(0,0,0,0)',
|
||||
},
|
||||
})
|
||||
return new XmlElement({
|
||||
name: 'a',
|
||||
content: [rect, shadow, text],
|
||||
attrs: { target: '_blank', 'xlink:href': link },
|
||||
})
|
||||
}
|
||||
|
||||
getLabelElement() {
|
||||
const leftLink = this.links[0]
|
||||
return this.getTextElement({
|
||||
leftMargin: this.labelMargin,
|
||||
content: this.label,
|
||||
link: !shouldWrapBodyWithLink({ links: this.links })
|
||||
? leftLink
|
||||
: undefined,
|
||||
color: this.labelColor,
|
||||
textWidth: this.labelWidth,
|
||||
linkWidth: this.leftWidth,
|
||||
})
|
||||
}
|
||||
|
||||
getMessageElement() {
|
||||
const rightLink = this.links[1]
|
||||
return this.getTextElement({
|
||||
leftMargin: this.messageMargin,
|
||||
content: this.message,
|
||||
link: rightLink,
|
||||
color: this.color,
|
||||
textWidth: this.messageWidth,
|
||||
linkWidth: this.rightWidth,
|
||||
})
|
||||
}
|
||||
|
||||
getClipPathElement(rx) {
|
||||
return new XmlElement({
|
||||
name: 'clipPath',
|
||||
content: [
|
||||
new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
width: this.width,
|
||||
height: this.constructor.height,
|
||||
rx,
|
||||
fill: '#fff',
|
||||
},
|
||||
}),
|
||||
],
|
||||
attrs: { id: 'r' },
|
||||
})
|
||||
}
|
||||
|
||||
getBackgroundGroupElement({ withGradient, attrs }) {
|
||||
const leftRect = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
width: this.leftWidth,
|
||||
height: this.constructor.height,
|
||||
fill: this.labelColor,
|
||||
},
|
||||
})
|
||||
const rightRect = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
x: this.leftWidth,
|
||||
width: this.rightWidth,
|
||||
height: this.constructor.height,
|
||||
fill: this.color,
|
||||
},
|
||||
})
|
||||
const gradient = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
width: this.width,
|
||||
height: this.constructor.height,
|
||||
fill: 'url(#s)',
|
||||
},
|
||||
})
|
||||
const content = withGradient
|
||||
? [leftRect, rightRect, gradient]
|
||||
: [leftRect, rightRect]
|
||||
return new XmlElement({ name: 'g', content, attrs })
|
||||
}
|
||||
|
||||
getForegroundGroupElement() {
|
||||
return new XmlElement({
|
||||
name: 'g',
|
||||
content: [
|
||||
this.logoElement,
|
||||
this.getLabelElement(),
|
||||
this.getMessageElement(),
|
||||
],
|
||||
attrs: {
|
||||
fill: '#fff',
|
||||
'text-anchor': 'middle',
|
||||
'font-family': FONT_FAMILY,
|
||||
'text-rendering': 'geometricPrecision',
|
||||
'font-size': 110,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
render() {
|
||||
throw new Error('Not implemented')
|
||||
}
|
||||
}
|
||||
|
||||
class Plastic extends Badge {
|
||||
static get fontFamily() {
|
||||
return fontFamily
|
||||
}
|
||||
|
||||
static get height() {
|
||||
return 18
|
||||
}
|
||||
@@ -302,6 +359,36 @@ class Plastic extends Badge {
|
||||
}
|
||||
|
||||
render() {
|
||||
const gradient = new XmlElement({
|
||||
name: 'linearGradient',
|
||||
content: [
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 0, 'stop-color': '#fff', 'stop-opacity': '.7' },
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: '.1', 'stop-color': '#aaa', 'stop-opacity': '.1' },
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: '.9', 'stop-color': '#000', 'stop-opacity': '.3' },
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 1, 'stop-color': '#000', 'stop-opacity': '.5' },
|
||||
}),
|
||||
],
|
||||
attrs: { id: 's', x2: 0, y2: '100%' },
|
||||
})
|
||||
|
||||
const clipPath = this.getClipPathElement(4)
|
||||
|
||||
const backgroundGroup = this.getBackgroundGroupElement({
|
||||
withGradient: true,
|
||||
attrs: { 'clip-path': 'url(#r)' },
|
||||
})
|
||||
|
||||
return renderBadge(
|
||||
{
|
||||
links: this.links,
|
||||
@@ -310,38 +397,12 @@ class Plastic extends Badge {
|
||||
accessibleText: this.accessibleText,
|
||||
height: this.constructor.height,
|
||||
},
|
||||
`
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#000" stop-opacity=".5"/>
|
||||
</linearGradient>
|
||||
|
||||
<clipPath id="r">
|
||||
<rect width="${this.width}" height="${this.constructor.height}" rx="4" fill="#fff"/>
|
||||
</clipPath>
|
||||
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${this.leftWidth}" height="${this.constructor.height}" fill="${this.labelColor}"/>
|
||||
<rect x="${this.leftWidth}" width="${this.rightWidth}" height="${this.constructor.height}" fill="${this.color}"/>
|
||||
<rect width="${this.width}" height="${this.constructor.height}" fill="url(#s)"/>
|
||||
</g>
|
||||
|
||||
<g fill="#fff" text-anchor="middle" ${this.constructor.fontFamily} text-rendering="geometricPrecision" font-size="110">
|
||||
${this.renderedLogo}
|
||||
${this.renderedLabel}
|
||||
${this.renderedMessage}
|
||||
</g>`
|
||||
[gradient, clipPath, backgroundGroup, this.foregroundGroupElement]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class Flat extends Badge {
|
||||
static get fontFamily() {
|
||||
return fontFamily
|
||||
}
|
||||
|
||||
static get height() {
|
||||
return 20
|
||||
}
|
||||
@@ -355,6 +416,28 @@ class Flat extends Badge {
|
||||
}
|
||||
|
||||
render() {
|
||||
const gradient = new XmlElement({
|
||||
name: 'linearGradient',
|
||||
content: [
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 0, 'stop-color': '#bbb', 'stop-opacity': '.1' },
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 1, 'stop-opacity': '.1' },
|
||||
}),
|
||||
],
|
||||
attrs: { id: 's', x2: 0, y2: '100%' },
|
||||
})
|
||||
|
||||
const clipPath = this.getClipPathElement(3)
|
||||
|
||||
const backgroundGroup = this.getBackgroundGroupElement({
|
||||
withGradient: true,
|
||||
attrs: { 'clip-path': 'url(#r)' },
|
||||
})
|
||||
|
||||
return renderBadge(
|
||||
{
|
||||
links: this.links,
|
||||
@@ -363,36 +446,12 @@ class Flat extends Badge {
|
||||
accessibleText: this.accessibleText,
|
||||
height: this.constructor.height,
|
||||
},
|
||||
`
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
|
||||
<clipPath id="r">
|
||||
<rect width="${this.width}" height="${this.constructor.height}" rx="3" fill="#fff"/>
|
||||
</clipPath>
|
||||
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${this.leftWidth}" height="${this.constructor.height}" fill="${this.labelColor}"/>
|
||||
<rect x="${this.leftWidth}" width="${this.rightWidth}" height="${this.constructor.height}" fill="${this.color}"/>
|
||||
<rect width="${this.width}" height="${this.constructor.height}" fill="url(#s)"/>
|
||||
</g>
|
||||
|
||||
<g fill="#fff" text-anchor="middle" ${this.constructor.fontFamily} text-rendering="geometricPrecision" font-size="110">
|
||||
${this.renderedLogo}
|
||||
${this.renderedLabel}
|
||||
${this.renderedMessage}
|
||||
</g>`
|
||||
[gradient, clipPath, backgroundGroup, this.foregroundGroupElement]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class FlatSquare extends Badge {
|
||||
static get fontFamily() {
|
||||
return fontFamily
|
||||
}
|
||||
|
||||
static get height() {
|
||||
return 20
|
||||
}
|
||||
@@ -406,6 +465,11 @@ class FlatSquare extends Badge {
|
||||
}
|
||||
|
||||
render() {
|
||||
const backgroundGroup = this.getBackgroundGroupElement({
|
||||
withGradient: false,
|
||||
attrs: { 'shape-rendering': 'crispEdges' },
|
||||
})
|
||||
|
||||
return renderBadge(
|
||||
{
|
||||
links: this.links,
|
||||
@@ -414,17 +478,7 @@ class FlatSquare extends Badge {
|
||||
accessibleText: this.accessibleText,
|
||||
height: this.constructor.height,
|
||||
},
|
||||
`
|
||||
<g shape-rendering="crispEdges">
|
||||
<rect width="${this.leftWidth}" height="${this.constructor.height}" fill="${this.labelColor}"/>
|
||||
<rect x="${this.leftWidth}" width="${this.rightWidth}" height="${this.constructor.height}" fill="${this.color}"/>
|
||||
</g>
|
||||
|
||||
<g fill="#fff" text-anchor="middle" ${this.constructor.fontFamily} text-rendering="geometricPrecision" font-size="110">
|
||||
${this.renderedLogo}
|
||||
${this.renderedLabel}
|
||||
${this.renderedMessage}
|
||||
</g>`
|
||||
[backgroundGroup, this.foregroundGroupElement]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -448,13 +502,7 @@ function social({
|
||||
const labelHorizPadding = 5
|
||||
const messageHorizPadding = 4
|
||||
const horizGutter = 6
|
||||
const { totalLogoWidth, renderedLogo } = renderLogo({
|
||||
logo,
|
||||
badgeHeight: externalHeight,
|
||||
horizPadding: labelHorizPadding,
|
||||
logoWidth,
|
||||
logoPadding,
|
||||
})
|
||||
const totalLogoWidth = logoWidth + logoPadding
|
||||
const hasMessage = message.length
|
||||
|
||||
const font = 'bold 11px Helvetica'
|
||||
@@ -463,75 +511,235 @@ function social({
|
||||
const labelRectWidth = labelTextWidth + totalLogoWidth + 2 * labelHorizPadding
|
||||
const messageRectWidth = messageTextWidth + 2 * messageHorizPadding
|
||||
|
||||
let [leftLink, rightLink] = links
|
||||
leftLink = escapeXml(leftLink)
|
||||
rightLink = escapeXml(rightLink)
|
||||
const [leftLink, rightLink] = links
|
||||
const { hasLeftLink, hasRightLink, hasLink } = hasLinks({ links })
|
||||
|
||||
const accessibleText = createAccessibleText({ label, message })
|
||||
|
||||
function renderMessageBubble() {
|
||||
function getMessageBubble() {
|
||||
if (!hasMessage) return ''
|
||||
|
||||
const messageBubbleMainX = labelRectWidth + horizGutter + 0.5
|
||||
const messageBubbleNotchX = labelRectWidth + horizGutter
|
||||
return `
|
||||
<rect x="${messageBubbleMainX}" y="0.5" width="${messageRectWidth}" height="${internalHeight}" rx="2" fill="#fafafa"/>
|
||||
<rect x="${messageBubbleNotchX}" y="7.5" width="0.5" height="5" stroke="#fafafa"/>
|
||||
<path d="M${messageBubbleMainX} 6.5 l-3 3v1 l3 3" stroke="d5d5d5" fill="#fafafa"/>
|
||||
`
|
||||
const content = [
|
||||
new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
x: messageBubbleMainX,
|
||||
y: 0.5,
|
||||
width: messageRectWidth,
|
||||
height: internalHeight,
|
||||
rx: 2,
|
||||
fill: '#fafafa',
|
||||
},
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
x: messageBubbleNotchX,
|
||||
y: 7.5,
|
||||
width: 0.5,
|
||||
height: 5,
|
||||
stroke: '#fafafa',
|
||||
},
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'path',
|
||||
attrs: {
|
||||
d: `M${messageBubbleMainX} 6.5 l-3 3v1 l3 3`,
|
||||
stroke: 'd5d5d5',
|
||||
fill: '#fafafa',
|
||||
},
|
||||
}),
|
||||
]
|
||||
return new ElementList({ content })
|
||||
}
|
||||
|
||||
function renderLabelText() {
|
||||
function getLabelText() {
|
||||
const labelTextX =
|
||||
10 * (totalLogoWidth + labelTextWidth / 2 + labelHorizPadding)
|
||||
const labelTextLength = 10 * labelTextWidth
|
||||
const escapedLabel = escapeXml(label)
|
||||
FONT_SCALE_UP_FACTOR *
|
||||
(totalLogoWidth + labelTextWidth / 2 + labelHorizPadding)
|
||||
const labelTextLength = FONT_SCALE_UP_FACTOR * labelTextWidth
|
||||
const shouldWrapWithLink = hasLeftLink && !shouldWrapBodyWithLink({ links })
|
||||
|
||||
const rect = `<rect id="llink" stroke="#d5d5d5" fill="url(#a)" x=".5" y=".5" width="${labelRectWidth}" height="${internalHeight}" rx="2" />`
|
||||
const shadow = `<text aria-hidden="true" x="${labelTextX}" y="150" fill="#fff" transform="scale(.1)" textLength="${labelTextLength}">${escapedLabel}</text>`
|
||||
const text = `<text x="${labelTextX}" y="140" transform="scale(.1)" textLength="${labelTextLength}">${escapedLabel}</text>`
|
||||
const rect = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
id: 'llink',
|
||||
stroke: '#d5d5d5',
|
||||
fill: 'url(#a)',
|
||||
x: '.5',
|
||||
y: '.5',
|
||||
width: labelRectWidth,
|
||||
height: internalHeight,
|
||||
rx: 2,
|
||||
},
|
||||
})
|
||||
const shadow = new XmlElement({
|
||||
name: 'text',
|
||||
content: [label],
|
||||
attrs: {
|
||||
'aria-hidden': 'true',
|
||||
x: labelTextX,
|
||||
y: 150,
|
||||
fill: '#fff',
|
||||
transform: FONT_SCALE_DOWN_VALUE,
|
||||
textLength: labelTextLength,
|
||||
},
|
||||
})
|
||||
const text = new XmlElement({
|
||||
name: 'text',
|
||||
content: [label],
|
||||
attrs: {
|
||||
x: labelTextX,
|
||||
y: 140,
|
||||
transform: FONT_SCALE_DOWN_VALUE,
|
||||
textLength: labelTextLength,
|
||||
},
|
||||
})
|
||||
|
||||
return shouldWrapWithLink
|
||||
? `
|
||||
<a target="_blank" xlink:href="${leftLink}">
|
||||
${shadow}
|
||||
${text}
|
||||
${rect}
|
||||
</a>
|
||||
`
|
||||
: `
|
||||
${rect}
|
||||
${shadow}
|
||||
${text}
|
||||
`
|
||||
? new XmlElement({
|
||||
name: 'a',
|
||||
content: [shadow, text, rect],
|
||||
attrs: { target: '_blank', 'xlink:href': leftLink },
|
||||
})
|
||||
: new ElementList({ content: [rect, shadow, text] })
|
||||
}
|
||||
|
||||
function renderMessageText() {
|
||||
const messageTextX =
|
||||
10 * (labelRectWidth + horizGutter + messageRectWidth / 2)
|
||||
const messageTextLength = 10 * messageTextWidth
|
||||
const escapedMessage = escapeXml(message)
|
||||
function getMessageText() {
|
||||
if (!hasMessage) return ''
|
||||
|
||||
const rect = `<rect width="${messageRectWidth + 1}" x="${
|
||||
labelRectWidth + horizGutter
|
||||
}" height="${internalHeight + 1}" fill="rgba(0,0,0,0)" />`
|
||||
const shadow = `<text aria-hidden="true" x="${messageTextX}" y="150" fill="#fff" transform="scale(.1)" textLength="${messageTextLength}">${escapedMessage}</text>`
|
||||
const text = `<text id="rlink" x="${messageTextX}" y="140" transform="scale(.1)" textLength="${messageTextLength}">${escapedMessage}</text>`
|
||||
const messageTextX =
|
||||
FONT_SCALE_UP_FACTOR *
|
||||
(labelRectWidth + horizGutter + messageRectWidth / 2)
|
||||
const messageTextLength = FONT_SCALE_UP_FACTOR * messageTextWidth
|
||||
|
||||
const rect = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
width: messageRectWidth + 1,
|
||||
x: labelRectWidth + horizGutter,
|
||||
height: internalHeight + 1,
|
||||
fill: 'rgba(0,0,0,0)',
|
||||
},
|
||||
})
|
||||
const shadow = new XmlElement({
|
||||
name: 'text',
|
||||
content: [message],
|
||||
attrs: {
|
||||
'aria-hidden': 'true',
|
||||
x: messageTextX,
|
||||
y: 150,
|
||||
fill: '#fff',
|
||||
transform: FONT_SCALE_DOWN_VALUE,
|
||||
textLength: messageTextLength,
|
||||
},
|
||||
})
|
||||
const text = new XmlElement({
|
||||
name: 'text',
|
||||
content: [message],
|
||||
attrs: {
|
||||
id: 'rlink',
|
||||
x: messageTextX,
|
||||
y: 140,
|
||||
transform: FONT_SCALE_DOWN_VALUE,
|
||||
textLength: messageTextLength,
|
||||
},
|
||||
})
|
||||
|
||||
return hasRightLink
|
||||
? `
|
||||
<a target="_blank" xlink:href="${rightLink}">
|
||||
${rect}
|
||||
${shadow}
|
||||
${text}
|
||||
</a>
|
||||
`
|
||||
: `
|
||||
${shadow}
|
||||
${text}
|
||||
`
|
||||
? new XmlElement({
|
||||
name: 'a',
|
||||
content: [rect, shadow, text],
|
||||
attrs: { target: '_blank', 'xlink:href': rightLink },
|
||||
})
|
||||
: new ElementList({ content: [shadow, text] })
|
||||
}
|
||||
|
||||
const style = new XmlElement({
|
||||
name: 'style',
|
||||
content: [
|
||||
'a:hover #llink{fill:url(#b);stroke:#ccc}a:hover #rlink{fill:#4183c4}',
|
||||
],
|
||||
})
|
||||
const gradients = new ElementList({
|
||||
content: [
|
||||
new XmlElement({
|
||||
name: 'linearGradient',
|
||||
content: [
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: {
|
||||
offset: 0,
|
||||
'stop-color': '#fcfcfc',
|
||||
'stop-opacity': 0,
|
||||
},
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 1, 'stop-opacity': '.1' },
|
||||
}),
|
||||
],
|
||||
attrs: { id: 'a', x2: 0, y2: '100%' },
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'linearGradient',
|
||||
content: [
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 0, 'stop-color': '#ccc', 'stop-opacity': '.1' },
|
||||
}),
|
||||
new XmlElement({
|
||||
name: 'stop',
|
||||
attrs: { offset: 1, 'stop-opacity': '.1' },
|
||||
}),
|
||||
],
|
||||
attrs: { id: 'b', x2: 0, y2: '100%' },
|
||||
}),
|
||||
],
|
||||
})
|
||||
const labelRect = new XmlElement({
|
||||
name: 'rect',
|
||||
attrs: {
|
||||
stroke: 'none',
|
||||
fill: '#fcfcfc',
|
||||
x: 0.5,
|
||||
y: 0.5,
|
||||
width: labelRectWidth,
|
||||
height: internalHeight,
|
||||
rx: 2,
|
||||
},
|
||||
})
|
||||
const messageBubble = getMessageBubble()
|
||||
const labelText = getLabelText()
|
||||
const messageText = getMessageText()
|
||||
const backgroundGroup = new XmlElement({
|
||||
name: 'g',
|
||||
content: [labelRect, messageBubble],
|
||||
attrs: { stroke: '#d5d5d5' },
|
||||
})
|
||||
const foregroundGroup = new XmlElement({
|
||||
name: 'g',
|
||||
content: [labelText, messageText],
|
||||
attrs: {
|
||||
'aria-hidden': `${!hasLink}`,
|
||||
fill: '#333',
|
||||
'text-anchor': 'middle',
|
||||
'font-family': SOCIAL_FONT_FAMILY,
|
||||
'text-rendering': 'geometricPrecision',
|
||||
'font-weight': 700,
|
||||
'font-size': '110px',
|
||||
'line-height': '14px',
|
||||
},
|
||||
})
|
||||
const logoElement = getLogoElement({
|
||||
logo,
|
||||
horizPadding: labelHorizPadding,
|
||||
badgeHeight: externalHeight,
|
||||
logoWidth,
|
||||
})
|
||||
|
||||
return renderBadge(
|
||||
{
|
||||
links,
|
||||
@@ -540,26 +748,7 @@ function social({
|
||||
accessibleText,
|
||||
height: externalHeight,
|
||||
},
|
||||
`
|
||||
<style>a:hover #llink{fill:url(#b);stroke:#ccc}a:hover #rlink{fill:#4183c4}</style>
|
||||
<linearGradient id="a" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fcfcfc" stop-opacity="0"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="b" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#ccc" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<g stroke="#d5d5d5">
|
||||
<rect stroke="none" fill="#fcfcfc" x="0.5" y="0.5" width="${labelRectWidth}" height="${internalHeight}" rx="2"/>
|
||||
${hasMessage ? renderMessageBubble() : ''}
|
||||
</g>
|
||||
${renderedLogo}
|
||||
<g aria-hidden="${!hasLink}" fill="#333" text-anchor="middle" ${socialFontFamily} text-rendering="geometricPrecision" font-weight="700" font-size="110px" line-height="14px">
|
||||
${renderLabelText()}
|
||||
${hasMessage ? renderMessageText() : ''}
|
||||
</g>
|
||||
`
|
||||
[style, gradients, backgroundGroup, logoElement, foregroundGroup]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -574,7 +763,6 @@ function forTheBadge({
|
||||
}) {
|
||||
const FONT_SIZE = 10
|
||||
const BADGE_HEIGHT = 28
|
||||
const LOGO_HEIGHT = 14
|
||||
const TEXT_MARGIN = 12
|
||||
const LOGO_MARGIN = 9
|
||||
const LOGO_TEXT_GUTTER = 6
|
||||
@@ -641,15 +829,11 @@ function forTheBadge({
|
||||
}
|
||||
}
|
||||
|
||||
const logoElement = new XmlElement({
|
||||
name: 'image',
|
||||
attrs: {
|
||||
x: logoMinX,
|
||||
y: 0.5 * (BADGE_HEIGHT - LOGO_HEIGHT),
|
||||
width: logoWidth,
|
||||
height: LOGO_HEIGHT,
|
||||
'xlink:href': logo,
|
||||
},
|
||||
const logoElement = getLogoElement({
|
||||
logo,
|
||||
horizPadding: logoMinX,
|
||||
badgeHeight: BADGE_HEIGHT,
|
||||
logoWidth,
|
||||
})
|
||||
|
||||
function getLabelElement() {
|
||||
@@ -772,7 +956,7 @@ function forTheBadge({
|
||||
const foregroundGroup = new XmlElement({
|
||||
name: 'g',
|
||||
content: [
|
||||
logo ? logoElement : '',
|
||||
logoElement,
|
||||
hasLabel ? getLabelElement() : '',
|
||||
getMessageElement(),
|
||||
],
|
||||
@@ -794,7 +978,7 @@ function forTheBadge({
|
||||
accessibleText: createAccessibleText({ label, message }),
|
||||
height: BADGE_HEIGHT,
|
||||
},
|
||||
[backgroundGroup.render(), foregroundGroup.render()].join('')
|
||||
[backgroundGroup, foregroundGroup]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,28 @@ describe('The badge generator', function () {
|
||||
links: ['https://shields.io/', 'https://www.google.co.uk/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the label color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the message color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('"flat-square" template badge generation', function () {
|
||||
@@ -280,6 +302,28 @@ describe('The badge generator', function () {
|
||||
links: ['https://shields.io/', 'https://www.google.co.uk/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the label color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the message color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat-square',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('"plastic" template badge generation', function () {
|
||||
@@ -350,6 +394,28 @@ describe('The badge generator', function () {
|
||||
links: ['https://shields.io/', 'https://www.google.co.uk/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the label color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the message color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'plastic',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('"for-the-badge" template badge generation', function () {
|
||||
@@ -447,6 +513,28 @@ describe('The badge generator', function () {
|
||||
links: ['https://shields.io/', 'https://www.google.co.uk/'],
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the label color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
})
|
||||
})
|
||||
|
||||
it('should match snapshots: black text when the message color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('"social" template badge generation', function () {
|
||||
@@ -556,28 +644,4 @@ describe('The badge generator', function () {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('text colors', function () {
|
||||
it('should use black text when the label color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'flat',
|
||||
color: '#000',
|
||||
labelColor: '#f3f3f3',
|
||||
})
|
||||
})
|
||||
|
||||
it('should use black text when the message color is light', function () {
|
||||
expectBadgeToMatchSnapshot({
|
||||
label: 'cactus',
|
||||
message: 'grown',
|
||||
format: 'svg',
|
||||
style: 'for-the-badge',
|
||||
color: '#e2ffe1',
|
||||
labelColor: '#000',
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -58,7 +58,7 @@ class XmlElement {
|
||||
if (this.content.length > 0) {
|
||||
const content = this.content
|
||||
.map(function (el) {
|
||||
if (el instanceof XmlElement) {
|
||||
if (typeof el.render === 'function') {
|
||||
return el.render()
|
||||
} else {
|
||||
return escapeXml(el)
|
||||
@@ -73,4 +73,24 @@ class XmlElement {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { escapeXml, stripXmlWhitespace, XmlElement }
|
||||
/**
|
||||
* Convenience class. Sometimes it is useful to return an object that behaves
|
||||
* like an XmlElement but renders multiple XML tags (not wrapped in a <g>).
|
||||
*/
|
||||
class ElementList {
|
||||
constructor({ content = [] }) {
|
||||
this.content = content
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.content.reduce(
|
||||
(acc, el) =>
|
||||
typeof el.render === 'function'
|
||||
? acc + el.render()
|
||||
: acc + escapeXml(el),
|
||||
''
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { escapeXml, stripXmlWhitespace, XmlElement, ElementList }
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"bugs": {
|
||||
"url": "https://github.com/badges/shields/issues"
|
||||
},
|
||||
"homepage": "http://shields.io",
|
||||
"homepage": "https://shields.io",
|
||||
"bin": {
|
||||
"badge": "lib/badge-cli.js"
|
||||
},
|
||||
|
||||
@@ -40,6 +40,8 @@ public:
|
||||
debug:
|
||||
enabled: 'GITHUB_DEBUG_ENABLED'
|
||||
intervalSeconds: 'GITHUB_DEBUG_INTERVAL_SECONDS'
|
||||
gitlab:
|
||||
authorizedOrigins: 'GITLAB_ORIGINS'
|
||||
jenkins:
|
||||
authorizedOrigins: 'JENKINS_ORIGINS'
|
||||
jira:
|
||||
@@ -77,6 +79,7 @@ private:
|
||||
gh_client_id: 'GH_CLIENT_ID'
|
||||
gh_client_secret: 'GH_CLIENT_SECRET'
|
||||
gh_token: 'GH_TOKEN'
|
||||
gitlab_token: 'GITLAB_TOKEN'
|
||||
jenkins_user: 'JENKINS_USER'
|
||||
jenkins_pass: 'JENKINS_PASS'
|
||||
jira_user: 'JIRA_USER'
|
||||
|
||||
@@ -3,6 +3,7 @@ private:
|
||||
discord_bot_token: ...
|
||||
gh_client_id: ...
|
||||
gh_client_secret: ...
|
||||
gitlab_token: ...
|
||||
redis_url: ...
|
||||
sentry_dsn: ...
|
||||
shields_secret: ...
|
||||
|
||||
@@ -5,6 +5,7 @@ private:
|
||||
# you can also set these values through environment variables, which may be
|
||||
# preferable for self hosting.
|
||||
gh_token: '...'
|
||||
gitlab_token: '...'
|
||||
twitch_client_id: '...'
|
||||
twitch_client_secret: '...'
|
||||
weblate_api_key: '...'
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('Influx metrics', function () {
|
||||
})
|
||||
|
||||
it('should send metrics', async function () {
|
||||
const scope = nock('http://shields-metrics.io/', {
|
||||
const scope = nock('https://shields-metrics.io/', {
|
||||
reqheaders: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
@@ -101,7 +101,7 @@ describe('Influx metrics', function () {
|
||||
.reply(200)
|
||||
process.env.INSTANCE_ID = 'instance2'
|
||||
influxMetrics = new InfluxMetrics(metricInstance, {
|
||||
url: 'http://shields-metrics.io/metrics',
|
||||
url: 'https://shields-metrics.io/metrics',
|
||||
timeoutMillseconds: 100,
|
||||
intervalSeconds: 0.001,
|
||||
username: 'metrics-username',
|
||||
@@ -132,7 +132,7 @@ describe('Influx metrics', function () {
|
||||
})
|
||||
|
||||
const influxMetrics = new InfluxMetrics(metricInstance, {
|
||||
url: 'http://shields-metrics.io/metrics',
|
||||
url: 'https://shields-metrics.io/metrics',
|
||||
timeoutMillseconds: 50,
|
||||
intervalSeconds: 0,
|
||||
username: 'metrics-username',
|
||||
@@ -149,14 +149,14 @@ describe('Influx metrics', function () {
|
||||
.and(
|
||||
sinon.match.has(
|
||||
'message',
|
||||
'Cannot push metrics. Cause: RequestError: Nock: Disallowed net connect for "shields-metrics.io:80/metrics"'
|
||||
'Cannot push metrics. Cause: RequestError: Nock: Disallowed net connect for "shields-metrics.io:443/metrics"'
|
||||
)
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
it('should log error responses', async function () {
|
||||
nock('http://shields-metrics.io/').persist().post('/metrics').reply(400)
|
||||
nock('https://shields-metrics.io/').persist().post('/metrics').reply(400)
|
||||
|
||||
await influxMetrics.sendMetrics()
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('Influx metrics', function () {
|
||||
.and(
|
||||
sinon.match.has(
|
||||
'message',
|
||||
'Cannot push metrics. http://shields-metrics.io/metrics responded with status code 400'
|
||||
'Cannot push metrics. https://shields-metrics.io/metrics responded with status code 400'
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
@@ -125,6 +125,7 @@ const publicConfigSchema = Joi.object({
|
||||
intervalSeconds: Joi.number().integer().min(1).required(),
|
||||
},
|
||||
},
|
||||
gitlab: defaultService,
|
||||
jira: defaultService,
|
||||
jenkins: Joi.object({
|
||||
authorizedOrigins: origins,
|
||||
@@ -161,6 +162,7 @@ const privateConfigSchema = Joi.object({
|
||||
gh_client_id: Joi.string(),
|
||||
gh_client_secret: Joi.string(),
|
||||
gh_token: Joi.string(),
|
||||
gitlab_token: Joi.string(),
|
||||
jenkins_user: Joi.string(),
|
||||
jenkins_pass: Joi.string(),
|
||||
jira_user: Joi.string(),
|
||||
|
||||
@@ -188,6 +188,10 @@ class TokenPool {
|
||||
this.priorityQueue = new PriorityQueue(this.constructor.compareTokens)
|
||||
}
|
||||
|
||||
count() {
|
||||
return this.tokenIds.size
|
||||
}
|
||||
|
||||
/**
|
||||
* compareTokens
|
||||
*
|
||||
|
||||
@@ -90,20 +90,20 @@ Each service has a directory for its files:
|
||||
All service badge classes inherit from [BaseService] or another class which extends it.
|
||||
Other classes implement useful behavior on top of [BaseService].
|
||||
|
||||
- [BaseJsonService](https://contributing.shields.io/module-core_base-service_base-json-basejsonservice)
|
||||
- [BaseJsonService](https://contributing.shields.io/module-core_base-service_base-json-BaseJsonService.html)
|
||||
implements methods for performing requests to a JSON API and schema validation.
|
||||
- [BaseXmlService](https://contributing.shields.io/module-core_base-service_base-xml-basexmlservice)
|
||||
- [BaseXmlService](https://contributing.shields.io/module-core_base-service_base-xml-BaseXmlService.html)
|
||||
implements methods for performing requests to an XML API and schema validation.
|
||||
- [BaseYamlService](https://contributing.shields.io/module-core_base-service_base-yaml-baseyamlservice)
|
||||
- [BaseYamlService](https://contributing.shields.io/module-core_base-service_base-yaml-BaseYamlService.html)
|
||||
implements methods for performing requests to a YAML API and schema validation.
|
||||
- [BaseSvgScrapingService](https://contributing.shields.io/module-core_base-service_base-svg-scraping-basesvgscrapingservice)
|
||||
- [BaseSvgScrapingService](https://contributing.shields.io/module-core_base-service_base-svg-scraping-BaseSvgScrapingService.html)
|
||||
implements methods for retrieving information from existing third-party badges.
|
||||
- [BaseGraphqlService](https://contributing.shields.io/module-core_base-service_base-graphql-basegraphqlservice)
|
||||
- [BaseGraphqlService](https://contributing.shields.io/module-core_base-service_base-graphql-BaseGraphqlService.html)
|
||||
implements methods for performing requests to a GraphQL API and schema validation.
|
||||
- If you are contributing to a _service family_, you may define a common super
|
||||
class for the badges or one may already exist.
|
||||
|
||||
[baseservice]: https://contributing.shields.io/module-core_base-service_base-baseservice
|
||||
[baseservice]: https://contributing.shields.io/module-core_base-service_base.html
|
||||
|
||||
As a first step we will look at the code for an example which generates a badge without contacting an API.
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ Production hosting is managed by the Shields ops team:
|
||||
| Twitch | OAuth app | @PyvesB |
|
||||
| Discord | OAuth app | @PyvesB |
|
||||
| YouTube | Account owner | @PyvesB |
|
||||
| GitLab | Account owner | @calebcartwright |
|
||||
| GitLab | Account access | @calebcartwright, @chris48s, @paulmelnikow, @PyvesB |
|
||||
| OpenStreetMap (for Wheelmap) | Account owner | @paulmelnikow |
|
||||
| DNS | Account owner | @olivierlacan |
|
||||
| DNS | Read-only account access | @espadrine, @paulmelnikow, @chris48s |
|
||||
@@ -102,7 +104,7 @@ hosted on [Zeit Now][]. It's managed in the
|
||||
|
||||
Both the badge server and frontend are served from Heroku.
|
||||
|
||||
After merging a commit to master, heroku should create a staging deploy. Check this has deployed correctly in the `shields-staging` pipeline and review http://shields-staging.herokuapp.com/
|
||||
After merging a commit to master, heroku should create a staging deploy. Check this has deployed correctly in the `shields-staging` pipeline and review https://shields-staging.herokuapp.com/
|
||||
|
||||
If we're happy with it, "promote to production". This will deploy what's on staging to the `shields-production-eu` and `shields-production-us` pieplines.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ You will need Node 14 or later, which you can install using a
|
||||
On Ubuntu / Debian:
|
||||
|
||||
```sh
|
||||
curl -sL https://deb.nodesource.com/setup_12.x | sudo -E bash -; sudo apt-get install -y nodejs
|
||||
curl -sL https://deb.nodesource.com/setup_14.x | sudo -E bash -; sudo apt-get install -y nodejs
|
||||
```
|
||||
|
||||
```sh
|
||||
@@ -140,11 +140,6 @@ preconfigured raster server.
|
||||
[raster server]: https://github.com/badges/svg-to-image-proxy
|
||||
[micro]: https://github.com/zeit/micro
|
||||
|
||||
## Persistence
|
||||
|
||||
To enable Redis-backed GitHub token persistence, point `REDIS_URL` to your
|
||||
Redis installation.
|
||||
|
||||
## Server secrets
|
||||
|
||||
You can add your own server secrets in environment variables or `config/local.yml`.
|
||||
|
||||
@@ -147,6 +147,15 @@ These settings are used by shields.io for GitHub OAuth app authorization
|
||||
but will not be necessary for most self-hosted installations. See
|
||||
[production-hosting.md](./production-hosting.md).
|
||||
|
||||
### GitLab
|
||||
|
||||
- `GITLAB_ORIGINS` (yml: `public.services.gitlab.authorizedOrigins`)
|
||||
- `GITLAB_TOKEN` (yml: `private.gitlab_token`)
|
||||
|
||||
A GitLab [Personal Access Token][gitlab-pat] is required for accessing private content. If you need a GitLab token for your self-hosted Shields server then we recommend limiting the scopes to the minimal set necessary for the badges you are using.
|
||||
|
||||
[gitlab-pat]: https://docs.gitlab.com/ee/user/profile/personal_access_tokens.html
|
||||
|
||||
### Jenkins CI
|
||||
|
||||
- `JENKINS_ORIGINS` (yml: `public.services.jenkins.authorizedOrigins`)
|
||||
|
||||
@@ -139,7 +139,7 @@ function StyleTable({ style }: { style: string }): JSX.Element {
|
||||
<td>
|
||||
<Badges
|
||||
badges={badges}
|
||||
baseUrl="http://img.shields.io"
|
||||
baseUrl="https://img.shields.io"
|
||||
style={style}
|
||||
/>
|
||||
</td>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"description": "Shields.io frontend",
|
||||
"private": true,
|
||||
"homepage": "http://shields.io",
|
||||
"homepage": "https://shields.io",
|
||||
"license": "CC0-1.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
6048
package-lock.json
generated
6048
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
82
package.json
82
package.json
@@ -10,7 +10,7 @@
|
||||
"image",
|
||||
"shields.io"
|
||||
],
|
||||
"homepage": "http://shields.io",
|
||||
"homepage": "https://shields.io",
|
||||
"bugs": {
|
||||
"url": "https://github.com/badges/shields/issues",
|
||||
"email": "thaddee.tyl@gmail.com"
|
||||
@@ -24,7 +24,7 @@
|
||||
"dependencies": {
|
||||
"@fontsource/lato": "^4.5.0",
|
||||
"@fontsource/lekton": "^4.5.0",
|
||||
"@sentry/node": "^6.10.0",
|
||||
"@sentry/node": "^6.12.0",
|
||||
"@shields_io/camp": "^18.1.1",
|
||||
"badge-maker": "file:badge-maker",
|
||||
"bytes": "^3.1.0",
|
||||
@@ -37,14 +37,14 @@
|
||||
"decamelize": "^5.0.0",
|
||||
"emojic": "^1.1.16",
|
||||
"escape-string-regexp": "^4.0.0",
|
||||
"fast-xml-parser": "^3.19.0",
|
||||
"fast-xml-parser": "^3.20.0",
|
||||
"glob": "^7.1.7",
|
||||
"global-agent": "^3.0.0",
|
||||
"got": "11.8.2",
|
||||
"graphql": "^15.5.1",
|
||||
"graphql": "^15.5.3",
|
||||
"graphql-tag": "^2.12.5",
|
||||
"ioredis": "4.27.6",
|
||||
"joi": "17.4.1",
|
||||
"ioredis": "4.27.9",
|
||||
"joi": "17.4.2",
|
||||
"joi-extension-semver": "5.0.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"jsonpath": "~1.1.1",
|
||||
@@ -57,12 +57,12 @@
|
||||
"path-to-regexp": "^6.2.0",
|
||||
"pretty-bytes": "^5.6.0",
|
||||
"priorityqueuejs": "^2.0.0",
|
||||
"prom-client": "^13.1.0",
|
||||
"prom-client": "^13.2.0",
|
||||
"qs": "^6.10.1",
|
||||
"query-string": "^7.0.1",
|
||||
"request": "~2.88.2",
|
||||
"semver": "~7.3.5",
|
||||
"simple-icons": "5.8.0",
|
||||
"simple-icons": "5.14.0",
|
||||
"webextension-store-meta": "^1.0.4",
|
||||
"xmldom": "~0.6.0",
|
||||
"xpath": "~0.0.32"
|
||||
@@ -142,25 +142,25 @@
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.14.8",
|
||||
"@babel/core": "^7.15.5",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/register": "7.14.5",
|
||||
"@babel/register": "7.15.3",
|
||||
"@mapbox/react-click-to-select": "^2.2.1",
|
||||
"@types/chai": "^4.2.21",
|
||||
"@types/lodash.debounce": "^4.0.6",
|
||||
"@types/lodash.groupby": "^4.6.6",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^16.4.7",
|
||||
"@types/node": "^16.7.10",
|
||||
"@types/react-helmet": "^6.1.2",
|
||||
"@types/react-modal": "^3.12.1",
|
||||
"@types/react-select": "^4.0.17",
|
||||
"@types/styled-components": "5.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "^4.28.0",
|
||||
"@typescript-eslint/parser": "^4.27.0",
|
||||
"@types/styled-components": "5.1.14",
|
||||
"@typescript-eslint/eslint-plugin": "^4.31.0",
|
||||
"@typescript-eslint/parser": "^4.30.0",
|
||||
"babel-plugin-inline-react-svg": "^2.0.1",
|
||||
"babel-plugin-istanbul": "^6.0.0",
|
||||
"babel-preset-gatsby": "^1.2.0",
|
||||
"c8": "^7.8.0",
|
||||
"babel-preset-gatsby": "^1.13.0",
|
||||
"c8": "^7.9.0",
|
||||
"caller": "^1.0.1",
|
||||
"chai": "^4.3.4",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
@@ -168,20 +168,20 @@
|
||||
"chai-string": "^1.4.0",
|
||||
"child-process-promise": "^2.2.1",
|
||||
"clipboard-copy": "^4.0.1",
|
||||
"concurrently": "^6.2.0",
|
||||
"cypress": "^8.1.0",
|
||||
"concurrently": "^6.2.1",
|
||||
"cypress": "^8.4.0",
|
||||
"danger": "^10.6.6",
|
||||
"danger-plugin-no-test-shortcuts": "^2.0.0",
|
||||
"deepmerge": "^4.2.2",
|
||||
"eslint": "^7.31.0",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-config-prettier": "^8.3.0",
|
||||
"eslint-config-standard": "^16.0.3",
|
||||
"eslint-config-standard-jsx": "^10.0.0",
|
||||
"eslint-config-standard-react": "^11.0.1",
|
||||
"eslint-plugin-chai-friendly": "^0.7.1",
|
||||
"eslint-plugin-cypress": "^2.11.3",
|
||||
"eslint-plugin-import": "^2.23.4",
|
||||
"eslint-plugin-jsdoc": "^36.0.6",
|
||||
"eslint-plugin-chai-friendly": "^0.7.2",
|
||||
"eslint-plugin-cypress": "^2.12.1",
|
||||
"eslint-plugin-import": "^2.24.2",
|
||||
"eslint-plugin-jsdoc": "^36.1.0",
|
||||
"eslint-plugin-mocha": "^9.0.0",
|
||||
"eslint-plugin-no-extension-in-require": "^0.2.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
@@ -191,35 +191,34 @@
|
||||
"eslint-plugin-sort-class-members": "^1.11.0",
|
||||
"fetch-ponyfill": "^7.1.0",
|
||||
"form-data": "^4.0.0",
|
||||
"gatsby": "3.10.2",
|
||||
"gatsby-plugin-catch-links": "^3.1.0",
|
||||
"gatsby-plugin-page-creator": "^3.10.0",
|
||||
"gatsby-plugin-react-helmet": "^4.1.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^3.1.0",
|
||||
"gatsby-plugin-styled-components": "^4.6.0",
|
||||
"gatsby": "3.13.1",
|
||||
"gatsby-plugin-catch-links": "^3.13.0",
|
||||
"gatsby-plugin-page-creator": "^3.13.0",
|
||||
"gatsby-plugin-react-helmet": "^4.13.0",
|
||||
"gatsby-plugin-remove-trailing-slashes": "^3.13.0",
|
||||
"gatsby-plugin-styled-components": "^4.13.0",
|
||||
"gatsby-plugin-typescript": "^3.2.0",
|
||||
"humanize-string": "^2.1.0",
|
||||
"husky": "^4.3.8",
|
||||
"icedfrisby": "4.0.0",
|
||||
"icedfrisby-nock": "^2.1.0",
|
||||
"is-svg": "^4.3.1",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdoc": "^3.6.7",
|
||||
"lint-staged": "^11.1.1",
|
||||
"lint-staged": "^11.1.2",
|
||||
"lodash.debounce": "^4.0.8",
|
||||
"lodash.difference": "^4.5.0",
|
||||
"minimist": "^1.2.5",
|
||||
"mocha": "^9.0.3",
|
||||
"mocha": "^9.1.1",
|
||||
"mocha-env-reporter": "^4.0.0",
|
||||
"mocha-junit-reporter": "^2.0.0",
|
||||
"mocha-yaml-loader": "^1.0.3",
|
||||
"nock": "13.1.1",
|
||||
"nock": "13.1.3",
|
||||
"node-mocks-http": "^1.10.1",
|
||||
"nodemon": "^2.0.12",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"open-cli": "^7.0.0",
|
||||
"open-cli": "^7.0.1",
|
||||
"portfinder": "^1.0.28",
|
||||
"prettier": "2.3.2",
|
||||
"prettier": "2.4.1",
|
||||
"react": "^17.0.2",
|
||||
"react-dom": "^17.0.2",
|
||||
"react-error-overlay": "^6.0.9",
|
||||
@@ -231,14 +230,15 @@
|
||||
"redis-server": "^1.2.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"sazerac": "^2.0.0",
|
||||
"simple-git-hooks": "^2.6.1",
|
||||
"sinon": "^11.1.2",
|
||||
"sinon-chai": "^3.7.0",
|
||||
"snap-shot-it": "^7.9.6",
|
||||
"start-server-and-test": "1.13.1",
|
||||
"styled-components": "^5.3.0",
|
||||
"start-server-and-test": "1.14.0",
|
||||
"styled-components": "^5.3.1",
|
||||
"ts-mocha": "^8.0.0",
|
||||
"tsd": "^0.17.0",
|
||||
"typescript": "^4.3.5"
|
||||
"typescript": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^14.17.1",
|
||||
@@ -259,9 +259,7 @@
|
||||
"url": "https://opencollective.com/shields",
|
||||
"logo": "https://opencollective.com/opencollective/logo.txt"
|
||||
},
|
||||
"husky": {
|
||||
"hooks": {
|
||||
"pre-commit": "lint-staged"
|
||||
}
|
||||
"simple-git-hooks": {
|
||||
"pre-commit": "npx lint-staged"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
/* eslint-disable import/order */
|
||||
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
// Set up Sentry reporting as early in the process as possible.
|
||||
import configModule from 'config'
|
||||
import Sentry from '@sentry/node'
|
||||
|
||||
import Server from './core/server/server.js'
|
||||
|
||||
// Set up Sentry reporting as early in the process as possible.
|
||||
const config = configModule.util.toObject()
|
||||
const disabledIntegrations = ['Console', 'Http']
|
||||
Sentry.init({
|
||||
|
||||
@@ -27,5 +27,5 @@ t.create('Bugzilla valid bug status with custom baseUrl')
|
||||
})
|
||||
|
||||
t.create('Bugzilla invalid bug status')
|
||||
.get('/102.json?baseUrl=https://bugzilla.gnome.org')
|
||||
.get('/001.json')
|
||||
.expectBadge({ label: 'bugzilla', message: 'not found' })
|
||||
|
||||
@@ -48,14 +48,6 @@ export default class CoverityScan extends BaseJsonService {
|
||||
const json = await this._requestJson({
|
||||
url,
|
||||
schema,
|
||||
options: {
|
||||
// Coverity has an issue in their certificate chain that requires
|
||||
// disabling the default strict SSL check in order to call their API.
|
||||
// For more information see:
|
||||
// https://github.com/badges/shields/issues/3334
|
||||
// https://github.com/badges/shields/pull/3336
|
||||
strictSSL: false,
|
||||
},
|
||||
errorMessages: {
|
||||
// At the moment Coverity returns an HTTP 200 with an HTML page
|
||||
// displaying the text 404 when project is not found.
|
||||
|
||||
@@ -15,7 +15,7 @@ t.create('docker version (valid, library with tag)')
|
||||
})
|
||||
|
||||
t.create('docker version (valid, user)')
|
||||
.get('/datadog/agent.json')
|
||||
.get('/datadog/dogstatsd.json')
|
||||
.expectBadge({
|
||||
label: 'version',
|
||||
message: isSemver,
|
||||
|
||||
76
services/freecodecamp/freecodecamp-points.service.js
Normal file
76
services/freecodecamp/freecodecamp-points.service.js
Normal file
@@ -0,0 +1,76 @@
|
||||
import Joi from 'joi'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { BaseJsonService, InvalidResponse, NotFound } from '../index.js'
|
||||
|
||||
/**
|
||||
* Validates that the schema response is what we're expecting.
|
||||
* The username pattern should match the freeCodeCamp repository.
|
||||
*
|
||||
* @see https://github.com/freeCodeCamp/freeCodeCamp/blob/main/utils/validate.js#L14
|
||||
*/
|
||||
const schema = Joi.object({
|
||||
entities: Joi.object({
|
||||
user: Joi.object()
|
||||
.required()
|
||||
.pattern(/^[a-zA-Z0-9\-_+]*$/, {
|
||||
points: Joi.number().allow(null).required(),
|
||||
}),
|
||||
}).optional(),
|
||||
}).required()
|
||||
|
||||
/**
|
||||
* This badge displays the total number of points a student has accumulated
|
||||
* from completing challenges on freeCodeCamp.
|
||||
*/
|
||||
export default class FreeCodeCampPoints extends BaseJsonService {
|
||||
static category = 'other'
|
||||
static route = {
|
||||
base: 'freecodecamp/points',
|
||||
pattern: ':username',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'freeCodeCamp points',
|
||||
namedParams: { username: 'sethi' },
|
||||
staticPreview: this.render({ points: 934 }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'points', color: 'info' }
|
||||
|
||||
static render({ points }) {
|
||||
return { message: metric(points) }
|
||||
}
|
||||
|
||||
async fetch({ username }) {
|
||||
return this._requestJson({
|
||||
schema,
|
||||
url: `https://api.freecodecamp.org/api/users/get-public-profile`,
|
||||
options: {
|
||||
qs: {
|
||||
username,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static transform(response, username) {
|
||||
const { entities } = response
|
||||
|
||||
if (entities === undefined)
|
||||
throw new NotFound({ prettyMessage: 'profile not found' })
|
||||
|
||||
const { points } = entities.user[username]
|
||||
|
||||
if (points === null) throw new InvalidResponse({ prettyMessage: 'private' })
|
||||
|
||||
return points
|
||||
}
|
||||
|
||||
async handle({ username }) {
|
||||
const response = await this.fetch({ username })
|
||||
const points = this.constructor.transform(response, username)
|
||||
return this.constructor.render({ points })
|
||||
}
|
||||
}
|
||||
15
services/freecodecamp/freecodecamp-points.tester.js
Normal file
15
services/freecodecamp/freecodecamp-points.tester.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { createServiceTester } from '../tester.js'
|
||||
import { isMetric } from '../test-validators.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('Total Points Valid')
|
||||
.get('/sethi.json')
|
||||
.expectBadge({ label: 'points', message: isMetric })
|
||||
|
||||
t.create('Total Points Private')
|
||||
.get('/set.json')
|
||||
.expectBadge({ label: 'points', message: 'private' })
|
||||
|
||||
t.create('Total Points Invalid')
|
||||
.get('/invalid@username.json')
|
||||
.expectBadge({ label: 'points', message: 'profile not found' })
|
||||
@@ -3,7 +3,7 @@ import request from 'request'
|
||||
import { userAgent } from '../../../core/base-service/legacy-request-handler.js'
|
||||
import log from '../../../core/server/log.js'
|
||||
|
||||
function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
function setRoutes({ server, authHelper, onTokenAccepted, tokenScopes }) {
|
||||
const baseUrl = process.env.GATSBY_BASE_URL || 'https://img.shields.io'
|
||||
|
||||
server.route(/^\/github-auth$/, (data, match, end, ask) => {
|
||||
@@ -15,6 +15,7 @@ function setRoutes({ server, authHelper, onTokenAccepted }) {
|
||||
// it's not setting a bad example.
|
||||
client_id: authHelper._user,
|
||||
redirect_uri: `${baseUrl}/github-auth/done`,
|
||||
scope: tokenScopes,
|
||||
})
|
||||
ask.res.setHeader(
|
||||
'Location',
|
||||
|
||||
@@ -41,6 +41,7 @@ describe('Github token acceptor', function () {
|
||||
server: camp,
|
||||
authHelper: oauthHelper,
|
||||
onTokenAccepted,
|
||||
tokenScopes: 'read:packages',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +53,7 @@ describe('Github token acceptor', function () {
|
||||
const qs = queryString.stringify({
|
||||
client_id: fakeClientId,
|
||||
redirect_uri: 'https://img.shields.io/github-auth/done',
|
||||
scope: 'read:packages',
|
||||
})
|
||||
const expectedLocationHeader = `https://github.com/login/oauth/authorize?${qs}`
|
||||
expect(res.headers.location).to.equal(expectedLocationHeader)
|
||||
|
||||
@@ -30,11 +30,10 @@ describe('Github API provider', function () {
|
||||
it('should be able to run 10 requests', async function () {
|
||||
this.timeout('20s')
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
await githubApiProvider.requestAsPromise(
|
||||
await githubApiProvider.requestAsPromise({
|
||||
request,
|
||||
'/repos/rust-lang/rust',
|
||||
{}
|
||||
)
|
||||
url: '/repos/rust-lang/rust',
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -52,11 +51,10 @@ describe('Github API provider', function () {
|
||||
|
||||
const headers = []
|
||||
async function performOneRequest() {
|
||||
const { res } = await githubApiProvider.requestAsPromise(
|
||||
const { res } = await githubApiProvider.requestAsPromise({
|
||||
request,
|
||||
'/repos/rust-lang/rust',
|
||||
{}
|
||||
)
|
||||
url: '/repos/rust-lang/rust',
|
||||
})
|
||||
expect(res.statusCode).to.equal(200)
|
||||
headers.push(res.headers)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class GithubApiProvider {
|
||||
onTokenInvalidated = tokenString => {},
|
||||
globalToken,
|
||||
reserveFraction = 0.25,
|
||||
tokenScopeNames = {},
|
||||
}) {
|
||||
Object.assign(this, {
|
||||
baseUrl,
|
||||
@@ -45,12 +46,14 @@ class GithubApiProvider {
|
||||
onTokenInvalidated,
|
||||
globalToken,
|
||||
reserveFraction,
|
||||
tokenScopeNames,
|
||||
})
|
||||
|
||||
if (this.withPooling) {
|
||||
this.standardTokens = new TokenPool({ batchSize: 25 })
|
||||
this.searchTokens = new TokenPool({ batchSize: 5 })
|
||||
this.graphqlTokens = new TokenPool({ batchSize: 25 })
|
||||
this.packageScopedTokens = new TokenPool({ batchSize: 25 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,17 +63,41 @@ class GithubApiProvider {
|
||||
standardTokens: this.standardTokens.serializeDebugInfo({ sanitize }),
|
||||
searchTokens: this.searchTokens.serializeDebugInfo({ sanitize }),
|
||||
graphqlTokens: this.graphqlTokens.serializeDebugInfo({ sanitize }),
|
||||
packageScopedTokens: this.packageScopedTokens.serializeDebugInfo({
|
||||
sanitize,
|
||||
}),
|
||||
}
|
||||
} else {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
addToken(tokenString) {
|
||||
numReservedScopedTokens() {
|
||||
return this.packageScopedTokens.count()
|
||||
}
|
||||
|
||||
addReservedScopedToken(tokenString, data) {
|
||||
if (!this.withPooling) {
|
||||
throw Error('When not using a token pool, do not provide tokens')
|
||||
}
|
||||
|
||||
const { scopes } = data
|
||||
if (!scopes) {
|
||||
throw new Error('Cannot add unscoped token to reserved token pools')
|
||||
}
|
||||
|
||||
scopes.split('%20').forEach(scope => {
|
||||
if (scope === this.tokenScopeNames.readPackages) {
|
||||
this.packageScopedTokens.add(tokenString, data)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
addToken(tokenString, data) {
|
||||
if (this.withPooling) {
|
||||
this.standardTokens.add(tokenString)
|
||||
this.searchTokens.add(tokenString)
|
||||
this.graphqlTokens.add(tokenString)
|
||||
this.standardTokens.add(tokenString, data)
|
||||
this.searchTokens.add(tokenString, data)
|
||||
this.graphqlTokens.add(tokenString, data)
|
||||
} else {
|
||||
throw Error('When not using a token pool, do not provide tokens')
|
||||
}
|
||||
@@ -141,7 +168,11 @@ class GithubApiProvider {
|
||||
this.onTokenInvalidated(token.id)
|
||||
}
|
||||
|
||||
tokenForUrl(url) {
|
||||
tokenForUrl(url, { needsPackageScope }) {
|
||||
if (needsPackageScope) {
|
||||
return this.packageScopedTokens.next()
|
||||
}
|
||||
|
||||
if (url.startsWith('/search')) {
|
||||
return this.searchTokens.next()
|
||||
} else if (url.startsWith('/graphql')) {
|
||||
@@ -154,14 +185,14 @@ class GithubApiProvider {
|
||||
// Act like request(), but tweak headers and query to avoid hitting a rate
|
||||
// limit. Inject `request` so we can pass in `cachingRequest` from
|
||||
// `request-handler.js`.
|
||||
request(request, url, options = {}, callback) {
|
||||
request({ request, url, options = {}, neededScopes = {}, callback }) {
|
||||
const { baseUrl } = this
|
||||
|
||||
let token
|
||||
let tokenString
|
||||
if (this.withPooling) {
|
||||
try {
|
||||
token = this.tokenForUrl(url)
|
||||
token = this.tokenForUrl(url, neededScopes)
|
||||
} catch (e) {
|
||||
callback(e)
|
||||
return
|
||||
@@ -178,7 +209,6 @@ class GithubApiProvider {
|
||||
baseUrl,
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
Authorization: `token ${tokenString}`,
|
||||
...options.headers,
|
||||
},
|
||||
@@ -199,14 +229,20 @@ class GithubApiProvider {
|
||||
})
|
||||
}
|
||||
|
||||
requestAsPromise(request, url, options) {
|
||||
requestAsPromise({ request, url, options, neededScopes }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.request(request, url, options, (err, res, buffer) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve({ res, buffer })
|
||||
}
|
||||
this.request({
|
||||
request,
|
||||
url,
|
||||
options,
|
||||
neededScopes,
|
||||
callback: (err, res, buffer) => {
|
||||
if (err) {
|
||||
reject(err)
|
||||
} else {
|
||||
resolve({ res, buffer })
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,7 +6,11 @@ describe('Github API provider', function () {
|
||||
const baseUrl = 'https://github-api.example.com'
|
||||
const reserveFraction = 0.333
|
||||
|
||||
let mockStandardToken, mockSearchToken, mockGraphqlToken, provider
|
||||
let mockStandardToken,
|
||||
mockSearchToken,
|
||||
mockGraphqlToken,
|
||||
mockPackagesScopedToken,
|
||||
provider
|
||||
beforeEach(function () {
|
||||
provider = new GithubApiProvider({ baseUrl, reserveFraction })
|
||||
|
||||
@@ -18,6 +22,11 @@ describe('Github API provider', function () {
|
||||
|
||||
mockGraphqlToken = { update: sinon.spy(), invalidate: sinon.spy() }
|
||||
sinon.stub(provider.graphqlTokens, 'next').returns(mockGraphqlToken)
|
||||
|
||||
mockPackagesScopedToken = { update: sinon.spy(), invalidate: sinon.spy() }
|
||||
sinon
|
||||
.stub(provider.packageScopedTokens, 'next')
|
||||
.returns(mockPackagesScopedToken)
|
||||
})
|
||||
|
||||
context('a search API request', function () {
|
||||
@@ -25,12 +34,16 @@ describe('Github API provider', function () {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/search', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).to.have.been.calledOnce
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/search',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).to.have.been.calledOnce
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -40,12 +53,37 @@ describe('Github API provider', function () {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('a request requiring the read:packages scope', function () {
|
||||
const mockRequest = (options, callback) => {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
neededScopes: { needsPackageScope: true },
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).not.to.have.been.called
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
expect(provider.packageScopedTokens.next).to.have.been.calledOnce
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -55,12 +93,16 @@ describe('Github API provider', function () {
|
||||
callback()
|
||||
}
|
||||
it('should obtain an appropriate token', function (done) {
|
||||
provider.request(mockRequest, '/repo', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).to.have.been.calledOnce
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/repo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.undefined
|
||||
expect(provider.searchTokens.next).not.to.have.been.called
|
||||
expect(provider.standardTokens.next).to.have.been.calledOnce
|
||||
expect(provider.graphqlTokens.next).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,25 +126,33 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should invoke the callback', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockStandardToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockStandardToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockStandardToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockStandardToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -132,25 +182,33 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should invoke the callback', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(Object.is(res, mockResponse)).to.be.true
|
||||
expect(Object.is(buffer, mockBuffer)).to.be.true
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/graphql', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockGraphqlToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockGraphqlToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/graphql',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
const expectedUsesRemaining =
|
||||
remaining - Math.ceil(reserveFraction * rateLimit)
|
||||
expect(mockGraphqlToken.update).to.have.been.calledWith(
|
||||
expectedUsesRemaining,
|
||||
nextReset
|
||||
)
|
||||
expect(mockGraphqlToken.invalidate).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -164,11 +222,15 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should invoke the callback and update the token with the expected values', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(mockStandardToken.invalidate).to.have.been.calledOnce
|
||||
expect(mockStandardToken.update).not.to.have.been.called
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.equal(null)
|
||||
expect(mockStandardToken.invalidate).to.have.been.calledOnce
|
||||
expect(mockStandardToken.update).not.to.have.been.called
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -180,10 +242,14 @@ describe('Github API provider', function () {
|
||||
}
|
||||
|
||||
it('should pass the error to the callback', function (done) {
|
||||
provider.request(mockRequest, '/foo', {}, (err, res, buffer) => {
|
||||
expect(err).to.be.an.instanceof(Error)
|
||||
expect(err.message).to.equal('connection timeout')
|
||||
done()
|
||||
provider.request({
|
||||
request: mockRequest,
|
||||
url: '/foo',
|
||||
callback: (err, res, buffer) => {
|
||||
expect(err).to.be.an.instanceof(Error)
|
||||
expect(err.message).to.equal('connection timeout')
|
||||
done()
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,21 +2,22 @@ import gql from 'graphql-tag'
|
||||
import { mergeQueries } from '../../core/base-service/graphql.js'
|
||||
import { BaseGraphqlService, BaseJsonService } from '../index.js'
|
||||
|
||||
function createRequestFetcher(context, config) {
|
||||
function createRequestFetcher(context, config, neededScopes) {
|
||||
const { sendAndCacheRequestWithCallbacks, githubApiProvider } = context
|
||||
|
||||
return async (url, options) =>
|
||||
githubApiProvider.requestAsPromise(
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
githubApiProvider.requestAsPromise({
|
||||
request: sendAndCacheRequestWithCallbacks,
|
||||
url,
|
||||
options
|
||||
)
|
||||
options,
|
||||
neededScopes,
|
||||
})
|
||||
}
|
||||
|
||||
class GithubAuthV3Service extends BaseJsonService {
|
||||
constructor(context, config) {
|
||||
constructor(context, config, neededScopes) {
|
||||
super(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config, neededScopes)
|
||||
this.staticAuthConfigured = true
|
||||
}
|
||||
}
|
||||
@@ -27,10 +28,10 @@ class GithubAuthV3Service extends BaseJsonService {
|
||||
// useful when consuming GitHub endpoints which are not rate-limited: it
|
||||
// avoids wasting API quota on them in production.
|
||||
class ConditionalGithubAuthV3Service extends BaseJsonService {
|
||||
constructor(context, config) {
|
||||
constructor(context, config, neededScopes) {
|
||||
super(context, config)
|
||||
if (context.githubApiProvider.globalToken) {
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config, neededScopes)
|
||||
this.staticAuthConfigured = true
|
||||
} else {
|
||||
this.staticAuthConfigured = false
|
||||
@@ -39,9 +40,9 @@ class ConditionalGithubAuthV3Service extends BaseJsonService {
|
||||
}
|
||||
|
||||
class GithubAuthV4Service extends BaseGraphqlService {
|
||||
constructor(context, config) {
|
||||
constructor(context, config, neededScopes) {
|
||||
super(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config)
|
||||
this._requestFetcher = createRequestFetcher(context, config, neededScopes)
|
||||
this.staticAuthConfigured = true
|
||||
}
|
||||
|
||||
|
||||
90
services/github/github-auth-service.spec.js
Normal file
90
services/github/github-auth-service.spec.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import Joi from 'joi'
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { GithubAuthV3Service } from './github-auth-service.js'
|
||||
import GithubApiProvider from './github-api-provider.js'
|
||||
|
||||
describe('GithubAuthV3Service', function () {
|
||||
class DummyGithubAuthV3Service extends GithubAuthV3Service {
|
||||
static category = 'build'
|
||||
static route = { base: 'runs' }
|
||||
|
||||
async handle() {
|
||||
const { requiredString } = await this._requestJson({
|
||||
schema: Joi.object({
|
||||
requiredString: Joi.string().required(),
|
||||
}).required(),
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
options: {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
},
|
||||
},
|
||||
})
|
||||
return { message: requiredString }
|
||||
}
|
||||
}
|
||||
|
||||
class ScopedDummyGithubAuthV3Service extends DummyGithubAuthV3Service {
|
||||
constructor(context, config) {
|
||||
super(context, config, { needsPackageScope: true })
|
||||
}
|
||||
}
|
||||
|
||||
let sendAndCacheRequestWithCallbacks, mockToken
|
||||
const githubApiProvider = new GithubApiProvider({
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
sendAndCacheRequestWithCallbacks = sinon.stub().returns(
|
||||
Promise.resolve({
|
||||
buffer: '{"requiredString": "some-string"}',
|
||||
res: { statusCode: 200 },
|
||||
})
|
||||
)
|
||||
mockToken = { id: 'abc123', update: sinon.mock(), invalidate: sinon.mock() }
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
it('forwards custom Accept header', async function () {
|
||||
sinon.stub(githubApiProvider.standardTokens, 'next').returns(mockToken)
|
||||
|
||||
DummyGithubAuthV3Service.invoke({
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
githubApiProvider,
|
||||
})
|
||||
|
||||
expect(sendAndCacheRequestWithCallbacks).to.have.been.calledOnceWith({
|
||||
headers: {
|
||||
'User-Agent': 'Shields.io/2003a',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token abc123',
|
||||
},
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
})
|
||||
|
||||
it('uses token with correct read scope', function () {
|
||||
sinon.stub(githubApiProvider.packageScopedTokens, 'next').returns(mockToken)
|
||||
|
||||
ScopedDummyGithubAuthV3Service.invoke({
|
||||
sendAndCacheRequestWithCallbacks,
|
||||
githubApiProvider,
|
||||
})
|
||||
|
||||
expect(sendAndCacheRequestWithCallbacks).to.have.been.calledOnceWith({
|
||||
headers: {
|
||||
'User-Agent': 'Shields.io/2003a',
|
||||
Accept: 'application/vnd.github.antiope-preview+json',
|
||||
Authorization: 'token abc123',
|
||||
},
|
||||
url: 'https://github-api.example.com/repos/badges/shields/check-runs',
|
||||
baseUrl: 'https://github-api.example.com',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,22 +1,28 @@
|
||||
import gql from 'graphql-tag'
|
||||
import Joi from 'joi'
|
||||
import { InvalidResponse } from '../index.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { GithubAuthV3Service } from './github-auth-service.js'
|
||||
import { errorMessagesFor, documentation } from './github-helpers.js'
|
||||
import { GithubAuthV4Service } from './github-auth-service.js'
|
||||
import { transformErrors, documentation } from './github-helpers.js'
|
||||
|
||||
const schema = Joi.array()
|
||||
.items(
|
||||
Joi.object({
|
||||
total: nonNegativeInteger,
|
||||
})
|
||||
)
|
||||
.required()
|
||||
const schema = Joi.object({
|
||||
data: Joi.object({
|
||||
repository: Joi.object({
|
||||
object: Joi.object({
|
||||
history: Joi.object({
|
||||
totalCount: nonNegativeInteger,
|
||||
}).required(),
|
||||
}),
|
||||
}).required(),
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
export default class GithubCommitActivity extends GithubAuthV3Service {
|
||||
export default class GitHubCommitActivity extends GithubAuthV4Service {
|
||||
static category = 'activity'
|
||||
static route = {
|
||||
base: 'github/commit-activity',
|
||||
pattern: ':interval(y|m|4w|w)/:user/:repo',
|
||||
pattern: ':interval(y|m|4w|w)/:user/:repo/:branch*',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
@@ -29,6 +35,20 @@ export default class GithubCommitActivity extends GithubAuthV3Service {
|
||||
keywords: ['commits'],
|
||||
documentation,
|
||||
},
|
||||
{
|
||||
title: 'GitHub commit activity (branch)',
|
||||
// Override the pattern to omit the deprecated interval "4w".
|
||||
pattern: ':interval(y|m|w)/:user/:repo/:branch*',
|
||||
namedParams: {
|
||||
interval: 'm',
|
||||
user: 'badges',
|
||||
repo: 'squint',
|
||||
branch: 'main',
|
||||
},
|
||||
staticPreview: this.render({ interval: 'm', commitCount: 5 }),
|
||||
keywords: ['commits'],
|
||||
documentation,
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'commit activity', color: 'blue' }
|
||||
@@ -46,47 +66,67 @@ export default class GithubCommitActivity extends GithubAuthV3Service {
|
||||
}
|
||||
}
|
||||
|
||||
static transform({ interval, weekData }) {
|
||||
const weekTotals = weekData.map(({ total }) => total)
|
||||
|
||||
if (interval === 'm') {
|
||||
// To approximate the value for the past month, get the sum for the last
|
||||
// four weeks and add a weighted value for the fifth week.
|
||||
const fourWeeksValue = weekTotals
|
||||
.slice(-4)
|
||||
.reduce((sum, weekTotal) => sum + weekTotal, 0)
|
||||
const fifthWeekValue = weekTotals.slice(-5)[0]
|
||||
const averageWeeksPerMonth = 365 / 12 / 7
|
||||
return (
|
||||
fourWeeksValue + Math.round((averageWeeksPerMonth - 4) * fifthWeekValue)
|
||||
)
|
||||
}
|
||||
|
||||
let wantedWeekData
|
||||
switch (interval) {
|
||||
case 'y':
|
||||
wantedWeekData = weekTotals
|
||||
break
|
||||
case '4w':
|
||||
wantedWeekData = weekTotals.slice(-4)
|
||||
break
|
||||
case 'w':
|
||||
wantedWeekData = weekTotals.slice(-2, -1)
|
||||
break
|
||||
default:
|
||||
throw Error('Unhandled case')
|
||||
}
|
||||
|
||||
return wantedWeekData.reduce((sum, weekTotal) => sum + weekTotal, 0)
|
||||
async fetch({ interval, user, repo, branch = 'HEAD' }) {
|
||||
const since = this.constructor.getIntervalQueryStartDate({ interval })
|
||||
return this._requestGraphql({
|
||||
query: gql`
|
||||
query (
|
||||
$user: String!
|
||||
$repo: String!
|
||||
$branch: String!
|
||||
$since: GitTimestamp!
|
||||
) {
|
||||
repository(owner: $user, name: $repo) {
|
||||
object(expression: $branch) {
|
||||
... on Commit {
|
||||
history(since: $since) {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
user,
|
||||
repo,
|
||||
branch,
|
||||
since,
|
||||
},
|
||||
schema,
|
||||
transformErrors,
|
||||
})
|
||||
}
|
||||
|
||||
async handle({ interval, user, repo }) {
|
||||
const weekData = await this._requestJson({
|
||||
url: `/repos/${user}/${repo}/stats/commit_activity`,
|
||||
schema,
|
||||
errorMessages: errorMessagesFor(),
|
||||
})
|
||||
const commitCount = this.constructor.transform({ interval, weekData })
|
||||
static transform({ data }) {
|
||||
const {
|
||||
repository: { object: repo },
|
||||
} = data
|
||||
|
||||
if (!repo) {
|
||||
throw new InvalidResponse({ prettyMessage: 'invalid branch' })
|
||||
}
|
||||
|
||||
return repo.history.totalCount
|
||||
}
|
||||
|
||||
static getIntervalQueryStartDate({ interval }) {
|
||||
const now = new Date()
|
||||
|
||||
if (interval === 'y') {
|
||||
now.setUTCFullYear(now.getUTCFullYear() - 1)
|
||||
} else if (interval === 'm' || interval === '4w') {
|
||||
now.setUTCDate(now.getUTCDate() - 30)
|
||||
} else {
|
||||
now.setUTCDate(now.getUTCDate() - 7)
|
||||
}
|
||||
|
||||
return now.toISOString()
|
||||
}
|
||||
|
||||
async handle({ interval, user, repo, branch }) {
|
||||
const json = await this.fetch({ interval, user, repo, branch })
|
||||
const commitCount = this.constructor.transform(json)
|
||||
return this.constructor.render({ interval, commitCount })
|
||||
}
|
||||
}
|
||||
|
||||
64
services/github/github-commit-activity.spec.js
Normal file
64
services/github/github-commit-activity.spec.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import { InvalidResponse } from '../index.js'
|
||||
import GitHubCommitActivity from './github-commit-activity.service.js'
|
||||
|
||||
describe('GitHubCommitActivity', function () {
|
||||
describe('transform', function () {
|
||||
it('throws InvalidResponse on invalid branch and null object', function () {
|
||||
expect(() =>
|
||||
GitHubCommitActivity.transform({
|
||||
data: { repository: { object: null } },
|
||||
})
|
||||
)
|
||||
.to.throw(InvalidResponse)
|
||||
.with.property('prettyMessage', 'invalid branch')
|
||||
})
|
||||
})
|
||||
describe('getIntervalQueryStartDate', function () {
|
||||
/** @type {sinon.SinonFakeTimers} */
|
||||
let clock
|
||||
beforeEach(function () {
|
||||
clock = sinon.useFakeTimers()
|
||||
})
|
||||
afterEach(function () {
|
||||
clock.restore()
|
||||
})
|
||||
|
||||
it('provides correct value for yearly interval', function () {
|
||||
clock.tick(new Date('2021-08-28T02:21:34.000Z').getTime())
|
||||
expect(
|
||||
GitHubCommitActivity.getIntervalQueryStartDate({
|
||||
interval: 'y',
|
||||
})
|
||||
).to.equal('2020-08-28T02:21:34.000Z')
|
||||
})
|
||||
|
||||
it('provides correct value for simple monthly interval', function () {
|
||||
clock.tick(new Date('2021-03-31T02:21:34.000Z').getTime())
|
||||
expect(
|
||||
GitHubCommitActivity.getIntervalQueryStartDate({
|
||||
interval: 'm',
|
||||
})
|
||||
).to.equal('2021-03-01T02:21:34.000Z')
|
||||
})
|
||||
|
||||
it('provides correct value for fun monthly interval', function () {
|
||||
clock.tick(new Date('2021-03-07T02:21:34.000Z').getTime())
|
||||
expect(
|
||||
GitHubCommitActivity.getIntervalQueryStartDate({
|
||||
interval: '4w',
|
||||
})
|
||||
).to.equal('2021-02-05T02:21:34.000Z')
|
||||
})
|
||||
|
||||
it('provides correct value for weekly interval', function () {
|
||||
clock.tick(new Date('2021-12-31T23:59:34.000Z').getTime())
|
||||
expect(
|
||||
GitHubCommitActivity.getIntervalQueryStartDate({
|
||||
interval: 'w',
|
||||
})
|
||||
).to.equal('2021-12-24T23:59:34.000Z')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -33,6 +33,13 @@ t.create('commit activity (1 week)').get('/w/eslint/eslint.json').expectBadge({
|
||||
message: isCommitActivity,
|
||||
})
|
||||
|
||||
t.create('commit activity (custom branch)')
|
||||
.get('/y/badges/squint/main.json')
|
||||
.expectBadge({
|
||||
label: 'commit activity',
|
||||
message: isCommitActivity,
|
||||
})
|
||||
|
||||
t.create('commit activity (repo not found)')
|
||||
.get('/w/badges/helmets.json')
|
||||
.expectBadge({
|
||||
|
||||
@@ -5,6 +5,11 @@ import GithubApiProvider from './github-api-provider.js'
|
||||
import { setRoutes as setAdminRoutes } from './auth/admin.js'
|
||||
import { setRoutes as setAcceptorRoutes } from './auth/acceptor.js'
|
||||
|
||||
const readPackagesScope = 'read:packages'
|
||||
// Multiple scopes need to be uri-encoded space delimited
|
||||
const tokenScopes = `${readPackagesScope}`
|
||||
const persistenceScopeDelimiter = '.scopes.'
|
||||
|
||||
// Convenience class with all the stuff related to the Github API and its
|
||||
// authorization tokens, to simplify server initialization.
|
||||
class GithubConstellation {
|
||||
@@ -24,6 +29,8 @@ class GithubConstellation {
|
||||
this._debugEnabled = config.service.debug.enabled
|
||||
this._debugIntervalSeconds = config.service.debug.intervalSeconds
|
||||
this.shieldsSecret = config.private.shields_secret
|
||||
this._tokenScopes = {}
|
||||
this._maxNumReservedScopedTokens = 0
|
||||
|
||||
const { redis_url: redisUrl, gh_token: globalToken } = config.private
|
||||
if (redisUrl) {
|
||||
@@ -38,6 +45,9 @@ class GithubConstellation {
|
||||
baseUrl: process.env.GITHUB_URL || 'https://api.github.com',
|
||||
globalToken,
|
||||
withPooling: !globalToken,
|
||||
tokenScopeNames: {
|
||||
readPackages: readPackagesScope,
|
||||
},
|
||||
onTokenInvalidated: tokenString => this.onTokenInvalidated(tokenString),
|
||||
})
|
||||
|
||||
@@ -70,8 +80,21 @@ class GithubConstellation {
|
||||
log.error(e)
|
||||
}
|
||||
|
||||
// Reserve a subset of scoped tokens from the total set
|
||||
// to be used for queries which require an explicit scope,
|
||||
// while leaving a sufficient amount of tokens (scoped or unscoped)
|
||||
// for the bulk of our requests which don't care about scopes.
|
||||
this._maxNumReservedScopedTokens = Math.floor(tokens.length * 0.15)
|
||||
tokens.forEach(tokenString => {
|
||||
this.apiProvider.addToken(tokenString)
|
||||
const [token, scopes] = tokenString.split(persistenceScopeDelimiter)
|
||||
this._tokenScopes[token] = scopes || null
|
||||
const data = { scopes }
|
||||
const numReserved = this.apiProvider.numReservedScopedTokens()
|
||||
if (scopes && numReserved < this._maxNumReservedScopedTokens) {
|
||||
this.apiProvider.addReservedScopedToken(token, data)
|
||||
} else {
|
||||
this.apiProvider.addToken(token, data)
|
||||
}
|
||||
})
|
||||
|
||||
const { shieldsSecret, apiProvider } = this
|
||||
@@ -81,19 +104,53 @@ class GithubConstellation {
|
||||
setAcceptorRoutes({
|
||||
server,
|
||||
authHelper: this.oauthHelper,
|
||||
onTokenAccepted: tokenString => this.onTokenAdded(tokenString),
|
||||
tokenScopes,
|
||||
onTokenAccepted: tokenString =>
|
||||
this.onTokenAdded(tokenString, tokenScopes),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onTokenAdded(tokenString) {
|
||||
onTokenAdded(tokenString, tokenScopes) {
|
||||
if (!this.persistence) {
|
||||
throw Error('Token persistence is not configured')
|
||||
}
|
||||
this.apiProvider.addToken(tokenString)
|
||||
const data = { scopes: tokenScopes }
|
||||
const numReserved = this.apiProvider.numReservedScopedTokens()
|
||||
if (numReserved < this._maxNumReservedScopedTokens) {
|
||||
this.apiProvider.addReservedScopedToken(tokenString, data)
|
||||
} else {
|
||||
this.apiProvider.addToken(tokenString, data)
|
||||
}
|
||||
|
||||
process.nextTick(async () => {
|
||||
try {
|
||||
await this.persistence.noteTokenAdded(tokenString)
|
||||
// To avoid having multiple set entries for re-authorized/re-scoped
|
||||
// tokens we need to first remove the previous entry that had different scopes
|
||||
if (
|
||||
Object.prototype.hasOwnProperty.call(this._tokenScopes, tokenString)
|
||||
) {
|
||||
const currentScopes = this._tokenScopes[tokenString]
|
||||
// These scopes shouldn't match in practice, as that would
|
||||
// indicate the function has somehow been invoked with an existing
|
||||
// token but without any scope changes. Nevertheless, the conditional
|
||||
// guard is here in case there are circumstances that assumption fails
|
||||
// to be upheld.
|
||||
if (currentScopes !== tokenScopes) {
|
||||
const token = currentScopes
|
||||
? `${tokenString}${persistenceScopeDelimiter}${currentScopes}`
|
||||
: tokenString
|
||||
await this.persistence.noteTokenRemoved(token)
|
||||
}
|
||||
}
|
||||
// It's unlikely that we'd evert revert back to no longer requesting any scopes
|
||||
// but handling that scenario regardless so we don't end up
|
||||
// with junk like `abc123.scopes.undefined` in redis
|
||||
const token = tokenScopes
|
||||
? `${tokenString}${persistenceScopeDelimiter}${tokenScopes}`
|
||||
: tokenString
|
||||
await this.persistence.noteTokenAdded(token)
|
||||
this._tokenScopes[tokenString] = tokenScopes || null
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
@@ -104,7 +161,12 @@ class GithubConstellation {
|
||||
if (this.persistence) {
|
||||
process.nextTick(async () => {
|
||||
try {
|
||||
await this.persistence.noteTokenRemoved(tokenString)
|
||||
const scopes = this._tokenScopes[tokenString]
|
||||
const token = scopes
|
||||
? `${tokenString}${persistenceScopeDelimiter}${scopes}`
|
||||
: tokenString
|
||||
await this.persistence.noteTokenRemoved(token)
|
||||
delete this._tokenScopes[tokenString]
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
|
||||
208
services/github/github-constellation.spec.js
Normal file
208
services/github/github-constellation.spec.js
Normal file
@@ -0,0 +1,208 @@
|
||||
import { expect } from 'chai'
|
||||
import sinon from 'sinon'
|
||||
import log from '../../core/server/log.js'
|
||||
import RedisTokenPersistence from '../../core/token-pooling/redis-token-persistence.js'
|
||||
import GithubConstellation from './github-constellation.js'
|
||||
import GithubApiProvider from './github-api-provider.js'
|
||||
|
||||
describe('GithubConstellation', function () {
|
||||
const tokens = [
|
||||
'abc123',
|
||||
'def4567.scopes.read:packages%20read:user',
|
||||
'def789.scopes.read:packages',
|
||||
'ghi012',
|
||||
'fff444.scopes.read:packages',
|
||||
'555eee.scopes.read:packages',
|
||||
'ddd666',
|
||||
'777ccc',
|
||||
'bbb888',
|
||||
'999aaa',
|
||||
'000111.scopes.read:packages',
|
||||
'222333.scopes.read:packages',
|
||||
'111111',
|
||||
'888888',
|
||||
]
|
||||
const config = {
|
||||
private: {
|
||||
redis_url: 'localhost',
|
||||
},
|
||||
service: {
|
||||
debug: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
const server = { ajax: { on: sinon.stub() } }
|
||||
|
||||
beforeEach(function () {
|
||||
sinon.stub(log, 'log')
|
||||
sinon
|
||||
.stub(GithubConstellation, '_createOauthHelper')
|
||||
.returns({ isConfigured: false })
|
||||
sinon.stub(GithubConstellation.prototype, 'scheduleDebugLogging')
|
||||
sinon.stub(RedisTokenPersistence.prototype, 'initialize').returns(tokens)
|
||||
sinon.stub(RedisTokenPersistence.prototype, 'noteTokenAdded')
|
||||
sinon.stub(RedisTokenPersistence.prototype, 'noteTokenRemoved')
|
||||
sinon.spy(GithubApiProvider.prototype, 'addToken')
|
||||
sinon.spy(GithubApiProvider.prototype, 'addReservedScopedToken')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
sinon.restore()
|
||||
})
|
||||
|
||||
context('initialize', function () {
|
||||
it('does not fetch tokens when pooling disabled', async function () {
|
||||
const constellation = new GithubConstellation({
|
||||
...config,
|
||||
...{ private: { gh_token: 'secret' } },
|
||||
})
|
||||
await constellation.initialize(server)
|
||||
expect(RedisTokenPersistence.prototype.initialize).not.to.have.been.called
|
||||
})
|
||||
|
||||
it('loads both scoped and unscoped tokens', async function () {
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
expect(constellation.apiProvider.graphqlTokens.count()).to.equal(12)
|
||||
expect(constellation.apiProvider.searchTokens.count()).to.equal(12)
|
||||
expect(constellation.apiProvider.standardTokens.count()).to.equal(12)
|
||||
expect(constellation.apiProvider.packageScopedTokens.count()).to.equal(2)
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly('def4567', {
|
||||
scopes: 'read:packages%20read:user',
|
||||
})
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly('def789', {
|
||||
scopes: 'read:packages',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
context('onTokenAdded', function () {
|
||||
it('adds new scoped token with met reserves', async function () {
|
||||
const token = 'shh_secret'
|
||||
sinon
|
||||
.stub(GithubApiProvider.prototype, 'numReservedScopedTokens')
|
||||
.returns(2)
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation._maxNumReservedScopedTokens = 2
|
||||
constellation.onTokenAdded(token, 'read:packages')
|
||||
await clock.tickAsync()
|
||||
expect(GithubApiProvider.prototype.addToken).to.be.calledWithExactly(
|
||||
token,
|
||||
{ scopes: 'read:packages' }
|
||||
)
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.not.be.calledWith(token)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
`${token}.scopes.read:packages`
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.not.be.called
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(15)
|
||||
expect(constellation._tokenScopes[token]).to.equal('read:packages')
|
||||
})
|
||||
|
||||
it('adds new scoped token with unmet reserves', async function () {
|
||||
const token = 'shh_secret'
|
||||
sinon
|
||||
.stub(GithubApiProvider.prototype, 'numReservedScopedTokens')
|
||||
.returns(2)
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation._maxNumReservedScopedTokens = 3
|
||||
constellation.onTokenAdded(token, 'read:packages')
|
||||
await clock.tickAsync()
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly(token, { scopes: 'read:packages' })
|
||||
expect(GithubApiProvider.prototype.addToken).to.not.be.calledWith(token)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
`${token}.scopes.read:packages`
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.not.be.called
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(15)
|
||||
expect(constellation._tokenScopes[token]).to.equal('read:packages')
|
||||
})
|
||||
|
||||
it('adds new unscoped token', async function () {
|
||||
const token = '1234567890987654321'
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation.onTokenAdded(token)
|
||||
await clock.tickAsync()
|
||||
expect(GithubApiProvider.prototype.addToken).to.be.calledWithExactly(
|
||||
token,
|
||||
{ scopes: undefined }
|
||||
)
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.not.be.calledWith(token)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
token
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.not.be.called
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(15)
|
||||
expect(constellation._tokenScopes[token]).to.equal(null)
|
||||
})
|
||||
|
||||
it('updates scopes on existing token', async function () {
|
||||
const existingToken = 'abc123'
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
sinon
|
||||
.stub(GithubApiProvider.prototype, 'numReservedScopedTokens')
|
||||
.returns(1)
|
||||
constellation.onTokenAdded(existingToken, 'read:packages')
|
||||
await clock.tickAsync()
|
||||
expect(
|
||||
GithubApiProvider.prototype.addReservedScopedToken
|
||||
).to.be.calledWithExactly(existingToken, { scopes: 'read:packages' })
|
||||
expect(GithubApiProvider.prototype.addToken.callCount).to.equal(12)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenAdded).to.be.calledWith(
|
||||
`${existingToken}.scopes.read:packages`
|
||||
)
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.be.calledWith(
|
||||
existingToken
|
||||
)
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(14)
|
||||
expect(constellation._tokenScopes[existingToken]).to.equal(
|
||||
'read:packages'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
context('onTokenInvalidated', function () {
|
||||
it('removes scoped token', async function () {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation.onTokenInvalidated('def789')
|
||||
await clock.tickAsync()
|
||||
expect(RedisTokenPersistence.prototype.noteTokenRemoved).to.be.calledWith(
|
||||
'def789.scopes.read:packages'
|
||||
)
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(13)
|
||||
})
|
||||
|
||||
it('removes unscoped token', async function () {
|
||||
const clock = sinon.useFakeTimers()
|
||||
const constellation = new GithubConstellation(config)
|
||||
await constellation.initialize(server)
|
||||
constellation.onTokenInvalidated('888888')
|
||||
await clock.tickAsync()
|
||||
expect(
|
||||
RedisTokenPersistence.prototype.noteTokenRemoved
|
||||
).to.be.calledWithExactly('888888')
|
||||
expect(Object.keys(constellation._tokenScopes).length).to.equal(13)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,12 +14,10 @@ t.create('Lerna version (independent)')
|
||||
message: 'independent',
|
||||
})
|
||||
|
||||
t.create('Lerna version (branch)')
|
||||
.get('/facebook/jest/master.json')
|
||||
.expectBadge({
|
||||
label: 'lerna@master',
|
||||
message: isSemver,
|
||||
})
|
||||
t.create('Lerna version (branch)').get('/facebook/jest/main.json').expectBadge({
|
||||
label: 'lerna@main',
|
||||
message: isSemver,
|
||||
})
|
||||
|
||||
t.create('Lerna version (lerna.json missing)')
|
||||
.get('/PyvesB/empty-repo.json')
|
||||
|
||||
@@ -75,7 +75,7 @@ class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service
|
||||
static route = {
|
||||
base: 'github/package-json/dependency-version',
|
||||
pattern:
|
||||
':user/:repo/:kind(dev|peer)?/:scope(@[^/]+)?/:packageName/:branch*',
|
||||
':user/:repo/:kind(dev|peer|optional)?/:scope(@[^/]+)?/:packageName/:branch*',
|
||||
queryParamSchema: dependencyQueryParamSchema,
|
||||
}
|
||||
|
||||
@@ -146,14 +146,18 @@ class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service
|
||||
{ user, repo, kind, branch = 'HEAD', scope, packageName },
|
||||
{ filename = 'package.json' }
|
||||
) {
|
||||
const { dependencies, devDependencies, peerDependencies } =
|
||||
await fetchJsonFromRepo(this, {
|
||||
schema: isPackageJsonWithDependencies,
|
||||
user,
|
||||
repo,
|
||||
branch,
|
||||
filename,
|
||||
})
|
||||
const {
|
||||
dependencies,
|
||||
devDependencies,
|
||||
peerDependencies,
|
||||
optionalDependencies,
|
||||
} = await fetchJsonFromRepo(this, {
|
||||
schema: isPackageJsonWithDependencies,
|
||||
user,
|
||||
repo,
|
||||
branch,
|
||||
filename,
|
||||
})
|
||||
|
||||
const wantedDependency = scope ? `${scope}/${packageName}` : packageName
|
||||
const { range } = getDependencyVersion({
|
||||
@@ -162,6 +166,7 @@ class GithubPackageJsonDependencyVersion extends ConditionalGithubAuthV3Service
|
||||
dependencies,
|
||||
devDependencies,
|
||||
peerDependencies,
|
||||
optionalDependencies,
|
||||
})
|
||||
|
||||
return this.constructor.render({
|
||||
|
||||
@@ -47,6 +47,13 @@ t.create('Peer dependency version')
|
||||
message: semverRange,
|
||||
})
|
||||
|
||||
t.create('Optional dependency version')
|
||||
.get('/dependency-version/IcedFrisby/IcedFrisby/optional/@hapi/joi.json')
|
||||
.expectBadge({
|
||||
label: '@hapi/joi',
|
||||
message: semverRange,
|
||||
})
|
||||
|
||||
t.create('Dev dependency version')
|
||||
.get(
|
||||
'/dependency-version/paulmelnikow/react-boxplot/dev/react.json?label=react%20tested'
|
||||
|
||||
19
services/gitlab/gitlab-base.js
Normal file
19
services/gitlab/gitlab-base.js
Normal file
@@ -0,0 +1,19 @@
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
export default class GitLabBase extends BaseJsonService {
|
||||
static auth = {
|
||||
passKey: 'gitlab_token',
|
||||
serviceKey: 'gitlab',
|
||||
}
|
||||
|
||||
async fetch({ url, options, schema, errorMessages }) {
|
||||
return this._requestJson(
|
||||
this.authHelper.withBasicAuth({
|
||||
schema,
|
||||
url,
|
||||
options,
|
||||
errorMessages,
|
||||
})
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ t.create('Pipeline status (nonexistent repo)')
|
||||
})
|
||||
|
||||
t.create('Pipeline status (custom gitlab URL)')
|
||||
.get('/GNOME/pango/master.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.get('/GNOME/pango/main.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectBadge({
|
||||
label: 'build',
|
||||
message: isBuildStatus,
|
||||
|
||||
131
services/gitlab/gitlab-tag.service.js
Normal file
131
services/gitlab/gitlab-tag.service.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import Joi from 'joi'
|
||||
import { version as versionColor } from '../color-formatters.js'
|
||||
import { optionalUrl } from '../validators.js'
|
||||
import { latest } from '../version.js'
|
||||
import { addv } from '../text-formatters.js'
|
||||
import { NotFound } from '../index.js'
|
||||
import GitLabBase from './gitlab-base.js'
|
||||
|
||||
const schema = Joi.array().items(
|
||||
Joi.object({
|
||||
name: Joi.string().required(),
|
||||
})
|
||||
)
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
gitlab_url: optionalUrl,
|
||||
include_prereleases: Joi.equal(''),
|
||||
sort: Joi.string().valid('date', 'semver').default('date'),
|
||||
}).required()
|
||||
|
||||
export default class GitlabTag extends GitLabBase {
|
||||
static category = 'version'
|
||||
|
||||
static route = {
|
||||
base: 'gitlab/v/tag',
|
||||
pattern: ':user/:repo',
|
||||
queryParamSchema,
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'GitLab tag (latest by date)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
queryParams: { sort: 'date' },
|
||||
staticPreview: this.render({ version: 'v2.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (latest by SemVer)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
queryParams: { sort: 'semver' },
|
||||
staticPreview: this.render({ version: 'v4.0.0' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (latest by SemVer pre-release)',
|
||||
namedParams: {
|
||||
user: 'shields-ops-group',
|
||||
repo: 'tag-test',
|
||||
},
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
},
|
||||
staticPreview: this.render({ version: 'v5.0.0-beta.1', sort: 'semver' }),
|
||||
},
|
||||
{
|
||||
title: 'GitLab tag (custom instance)',
|
||||
namedParams: {
|
||||
user: 'GNOME',
|
||||
repo: 'librsvg',
|
||||
},
|
||||
queryParams: {
|
||||
sort: 'semver',
|
||||
include_prereleases: null,
|
||||
gitlab_url: 'https://gitlab.gnome.org',
|
||||
},
|
||||
staticPreview: this.render({ version: 'v2.51.4' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = { label: 'tag' }
|
||||
|
||||
static render({ version, sort }) {
|
||||
return {
|
||||
message: addv(version),
|
||||
color: sort === 'semver' ? versionColor(version) : 'blue',
|
||||
}
|
||||
}
|
||||
|
||||
async fetch({ user, repo, baseUrl }) {
|
||||
// https://docs.gitlab.com/ee/api/tags.html
|
||||
// N.B. the documentation has contradictory information about default sort order.
|
||||
// As of 2020-10-11 the default is by date, but we add the `order_by` query param
|
||||
// explicitly in case that changes upstream.
|
||||
return super.fetch({
|
||||
schema,
|
||||
url: `${baseUrl}/api/v4/projects/${user}%2F${repo}/repository/tags`,
|
||||
options: { qs: { order_by: 'updated' } },
|
||||
errorMessages: {
|
||||
404: 'repo not found',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
static transform({ tags, sort, includePrereleases }) {
|
||||
if (tags.length === 0) {
|
||||
throw new NotFound({ prettyMessage: 'no tags found' })
|
||||
}
|
||||
|
||||
if (sort === 'date') {
|
||||
return tags[0].name
|
||||
}
|
||||
|
||||
return latest(
|
||||
tags.map(t => t.name),
|
||||
{ pre: includePrereleases }
|
||||
)
|
||||
}
|
||||
|
||||
async handle(
|
||||
{ user, repo },
|
||||
{
|
||||
gitlab_url: baseUrl = 'https://gitlab.com',
|
||||
include_prereleases: pre,
|
||||
sort,
|
||||
}
|
||||
) {
|
||||
const tags = await this.fetch({ user, repo, baseUrl })
|
||||
const version = this.constructor.transform({
|
||||
tags,
|
||||
sort,
|
||||
includePrereleases: pre !== undefined,
|
||||
})
|
||||
return this.constructor.render({ version, sort })
|
||||
}
|
||||
}
|
||||
47
services/gitlab/gitlab-tag.spec.js
Normal file
47
services/gitlab/gitlab-tag.spec.js
Normal file
@@ -0,0 +1,47 @@
|
||||
import { expect } from 'chai'
|
||||
import nock from 'nock'
|
||||
import { cleanUpNockAfterEach, defaultContext } from '../test-helpers.js'
|
||||
import GitLabTag from './gitlab-tag.service.js'
|
||||
|
||||
describe('GitLabTag', function () {
|
||||
describe('auth', function () {
|
||||
cleanUpNockAfterEach()
|
||||
|
||||
const fakeToken = 'abc123'
|
||||
const config = {
|
||||
public: {
|
||||
services: {
|
||||
gitlab: {
|
||||
authorizedOrigins: ['https://gitlab.com'],
|
||||
},
|
||||
},
|
||||
},
|
||||
private: {
|
||||
gitlab_token: fakeToken,
|
||||
},
|
||||
}
|
||||
|
||||
it('sends the auth information as configured', async function () {
|
||||
const scope = nock('https://gitlab.com/')
|
||||
.get('/api/v4/projects/foo%2Fbar/repository/tags?order_by=updated')
|
||||
// This ensures that the expected credentials are actually being sent with the HTTP request.
|
||||
// Without this the request wouldn't match and the test would fail.
|
||||
.basicAuth({ user: '', pass: fakeToken })
|
||||
.reply(200, [{ name: '1.9' }])
|
||||
|
||||
expect(
|
||||
await GitLabTag.invoke(
|
||||
defaultContext,
|
||||
config,
|
||||
{ user: 'foo', repo: 'bar' },
|
||||
{}
|
||||
)
|
||||
).to.deep.equal({
|
||||
message: 'v1.9',
|
||||
color: 'blue',
|
||||
})
|
||||
|
||||
scope.done()
|
||||
})
|
||||
})
|
||||
})
|
||||
27
services/gitlab/gitlab-tag.tester.js
Normal file
27
services/gitlab/gitlab-tag.tester.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import { isSemver } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('Tag (latest by date)')
|
||||
.get('/shields-ops-group/tag-test.json')
|
||||
.expectBadge({ label: 'tag', message: 'v2.0.0', color: 'blue' })
|
||||
|
||||
t.create('Tag (latest by SemVer)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver')
|
||||
.expectBadge({ label: 'tag', message: 'v4.0.0', color: 'blue' })
|
||||
|
||||
t.create('Tag (latest by SemVer pre-release)')
|
||||
.get('/shields-ops-group/tag-test.json?sort=semver&include_prereleases')
|
||||
.expectBadge({ label: 'tag', message: 'v5.0.0-beta.1', color: 'orange' })
|
||||
|
||||
t.create('Tag (custom instance')
|
||||
.get('/GNOME/librsvg.json?gitlab_url=https://gitlab.gnome.org')
|
||||
.expectBadge({ label: 'tag', message: isSemver, color: 'blue' })
|
||||
|
||||
t.create('Tag (repo not found)')
|
||||
.get('/fdroid/nonexistant.json')
|
||||
.expectBadge({ label: 'tag', message: 'repo not found' })
|
||||
|
||||
t.create('Tag (no tags)')
|
||||
.get('/fdroid/fdroiddata.json')
|
||||
.expectBadge({ label: 'tag', message: 'no tags found' })
|
||||
@@ -12,15 +12,11 @@ export default class JenkinsBase extends BaseJsonService {
|
||||
schema,
|
||||
qs,
|
||||
errorMessages = { 404: 'instance or job not found' },
|
||||
disableStrictSSL,
|
||||
}) {
|
||||
return this._requestJson(
|
||||
this.authHelper.withBasicAuth({
|
||||
url,
|
||||
options: {
|
||||
qs,
|
||||
strictSSL: disableStrictSSL === undefined,
|
||||
},
|
||||
options: { qs },
|
||||
schema,
|
||||
errorMessages,
|
||||
})
|
||||
|
||||
@@ -68,12 +68,11 @@ export default class JenkinsBuild extends JenkinsBase {
|
||||
return { status: colorStatusMap[json.color] }
|
||||
}
|
||||
|
||||
async handle(namedParams, { jobUrl, disableStrictSSL }) {
|
||||
async handle(namedParams, { jobUrl }) {
|
||||
const json = await this.fetch({
|
||||
url: buildUrl({ jobUrl, lastCompletedBuild: false }),
|
||||
schema,
|
||||
qs: buildTreeParamQueryString('color'),
|
||||
disableStrictSSL,
|
||||
})
|
||||
const { status } = this.transform({ json })
|
||||
return this.constructor.render({ status })
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import Joi from 'joi'
|
||||
import { optionalUrl } from '../validators.js'
|
||||
|
||||
const queryParamSchema = Joi.object({
|
||||
disableStrictSSL: Joi.equal(''),
|
||||
jobUrl: optionalUrl,
|
||||
}).required()
|
||||
const queryParamSchema = Joi.object({ jobUrl: optionalUrl }).required()
|
||||
|
||||
const buildRedirectUrl = ({ protocol, host, job }) => {
|
||||
const jobPrefix = job.indexOf('/') > -1 ? '' : 'job/'
|
||||
|
||||
@@ -112,14 +112,13 @@ export default class JenkinsCoverage extends JenkinsBase {
|
||||
}
|
||||
}
|
||||
|
||||
async handle({ format }, { jobUrl, disableStrictSSL }) {
|
||||
async handle({ format }, { jobUrl }) {
|
||||
const { schema, transform, treeQueryParam, pluginSpecificPath } =
|
||||
formatMap[format]
|
||||
const json = await this.fetch({
|
||||
url: buildUrl({ jobUrl, plugin: pluginSpecificPath }),
|
||||
schema,
|
||||
qs: buildTreeParamQueryString(treeQueryParam),
|
||||
disableStrictSSL,
|
||||
errorMessages: {
|
||||
404: 'job or coverage not found',
|
||||
},
|
||||
|
||||
@@ -107,7 +107,6 @@ export default class JenkinsTests extends JenkinsBase {
|
||||
async handle(
|
||||
namedParams,
|
||||
{
|
||||
disableStrictSSL,
|
||||
jobUrl,
|
||||
compact_message: compactMessage,
|
||||
passed_label: passedLabel,
|
||||
@@ -119,7 +118,6 @@ export default class JenkinsTests extends JenkinsBase {
|
||||
url: buildUrl({ jobUrl }),
|
||||
schema,
|
||||
qs: buildTreeParamQueryString('actions[failCount,skipCount,totalCount]'),
|
||||
disableStrictSSL,
|
||||
})
|
||||
const { passed, failed, skipped, total } = this.transform({ json })
|
||||
return this.constructor.render({
|
||||
|
||||
@@ -30,10 +30,10 @@ const documentation = `
|
||||
<p>
|
||||
In order for this badge to work, the host of your room must allow guest accounts or dummy accounts to register, and the room must be world readable (chat history visible to anyone).
|
||||
</br>
|
||||
The following steps will show you how to setup the badge URL using the Riot.im Matrix client.
|
||||
The following steps will show you how to setup the badge URL using the Element Matrix client.
|
||||
</br>
|
||||
<ul>
|
||||
<li>Select the desired room inside the Riot.im client</li>
|
||||
<li>Select the desired room inside the Element client</li>
|
||||
<li>Click on the room settings button (gear icon) located near the top right of the client</li>
|
||||
<li>Scroll to the very bottom of the settings page and look under the <code>Addresses</code> section</li>
|
||||
<li>You should see one or more <code>room addresses (or aliases)</code>, which can be easily identified with their starting hash (<code>#</code>) character (ex: <code>#twim:matrix.org</code>)</li>
|
||||
|
||||
@@ -25,11 +25,11 @@ class MyGetVersionService extends Version {
|
||||
title: 'MyGet tenant',
|
||||
pattern: ':tenant.myget/:feed/v/:packageName',
|
||||
namedParams: {
|
||||
tenant: 'cefsharp',
|
||||
feed: 'cefsharp',
|
||||
packageName: 'cef.sdk',
|
||||
tenant: 'tizen',
|
||||
feed: 'dotnet',
|
||||
packageName: 'Tizen.NET',
|
||||
},
|
||||
staticPreview: this.render({ version: '91.1.1' }),
|
||||
staticPreview: this.render({ version: '9.0.0.16564' }),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -48,7 +48,7 @@ class MyGetDownloadService extends Downloads {
|
||||
namedParams: {
|
||||
tenant: 'cefsharp',
|
||||
feed: 'cefsharp',
|
||||
packageName: 'CefSharp',
|
||||
packageName: 'CefSharp.Common',
|
||||
},
|
||||
staticPreview: this.render({ downloads: 9748 }),
|
||||
},
|
||||
|
||||
@@ -69,9 +69,9 @@ t.create('version (valid)')
|
||||
})
|
||||
|
||||
t.create('version (tenant)')
|
||||
.get('/cefsharp.myget/cefsharp/v/cef.sdk.json')
|
||||
.get('/tizen.myget/dotnet/v/Tizen.NET.json')
|
||||
.expectBadge({
|
||||
label: 'cefsharp',
|
||||
label: 'dotnet',
|
||||
message: isVPlusDottedVersionNClausesWithOptionalSuffix,
|
||||
})
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const isPackageJsonWithDependencies = Joi.object({
|
||||
dependencies: isDependencyMap,
|
||||
devDependencies: isDependencyMap,
|
||||
peerDependencies: isDependencyMap,
|
||||
optionalDependencies: isDependencyMap,
|
||||
}).required()
|
||||
|
||||
function getDependencyVersion({
|
||||
@@ -21,19 +22,19 @@ function getDependencyVersion({
|
||||
dependencies,
|
||||
devDependencies,
|
||||
peerDependencies,
|
||||
optionalDependencies,
|
||||
}) {
|
||||
let dependenciesOfKind
|
||||
if (kind === 'peer') {
|
||||
dependenciesOfKind = peerDependencies
|
||||
} else if (kind === 'dev') {
|
||||
dependenciesOfKind = devDependencies
|
||||
} else if (kind === 'prod') {
|
||||
dependenciesOfKind = dependencies
|
||||
} else {
|
||||
throw Error(`Not very kind: ${kind}`)
|
||||
const dependencyMaps = {
|
||||
peer: peerDependencies,
|
||||
optional: optionalDependencies,
|
||||
dev: devDependencies,
|
||||
prod: dependencies,
|
||||
}
|
||||
|
||||
const range = dependenciesOfKind[wantedDependency]
|
||||
if (!(kind in dependencyMaps)) {
|
||||
throw Error(`Not very kind: ${kind}`)
|
||||
}
|
||||
const range = dependencyMaps[kind][wantedDependency]
|
||||
if (range === undefined) {
|
||||
throw new InvalidParameter({
|
||||
prettyMessage: `${kind} dependency not found`,
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Joi from 'joi'
|
||||
import { nonNegativeInteger } from '../validators.js'
|
||||
import { anyInteger } from '../validators.js'
|
||||
import { metric } from '../text-formatters.js'
|
||||
import { BaseJsonService } from '../index.js'
|
||||
|
||||
const schema = Joi.object({
|
||||
data: Joi.object({
|
||||
link_karma: nonNegativeInteger,
|
||||
comment_karma: nonNegativeInteger,
|
||||
link_karma: anyInteger,
|
||||
comment_karma: anyInteger,
|
||||
}).required(),
|
||||
}).required()
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export default class RequiresIo extends BaseJsonService {
|
||||
{
|
||||
title: 'Requires.io',
|
||||
pattern: ':service/:user/:repo',
|
||||
namedParams: { service: 'github', user: 'celery', repo: 'celery' },
|
||||
namedParams: { service: 'github', user: 'zulip', repo: 'zulip' },
|
||||
staticPreview: this.render({ status: 'up-to-date' }),
|
||||
},
|
||||
{
|
||||
@@ -25,8 +25,8 @@ export default class RequiresIo extends BaseJsonService {
|
||||
pattern: ':service/:user/:repo/:branch',
|
||||
namedParams: {
|
||||
service: 'github',
|
||||
user: 'celery',
|
||||
repo: 'celery',
|
||||
user: 'zulip',
|
||||
repo: 'zulip',
|
||||
branch: 'master',
|
||||
},
|
||||
staticPreview: this.render({ status: 'up-to-date' }),
|
||||
|
||||
@@ -7,14 +7,14 @@ const isRequireStatus = Joi.string().regex(
|
||||
)
|
||||
|
||||
t.create('requirements (valid, without branch)')
|
||||
.get('/github/celery/celery.json')
|
||||
.get('/github/zulip/zulip.json')
|
||||
.expectBadge({
|
||||
label: 'requirements',
|
||||
message: isRequireStatus,
|
||||
})
|
||||
|
||||
t.create('requirements (valid, with branch)')
|
||||
.get('/github/celery/celery/master.json')
|
||||
.get('/github/zulip/zulip/master.json')
|
||||
.expectBadge({
|
||||
label: 'requirements',
|
||||
message: isRequireStatus,
|
||||
|
||||
@@ -2,16 +2,16 @@ import { isFileSize } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('EssentialsX (id 9089)')
|
||||
.get('/9089.json')
|
||||
t.create('EssentialsX (hosted resource)')
|
||||
.get('/771.json')
|
||||
.expectBadge({ label: 'size', message: isFileSize })
|
||||
|
||||
t.create('Pet Master (id 15904)').get('/15904.json').expectBadge({
|
||||
t.create('Pet Master (external resource)').get('/15904.json').expectBadge({
|
||||
lavel: 'size',
|
||||
message: 'resource hosted externally',
|
||||
})
|
||||
|
||||
t.create('Invalid Resource (id 1)').get('/1.json').expectBadge({
|
||||
t.create('Invalid Resource').get('/1.json').expectBadge({
|
||||
label: 'size',
|
||||
message: 'not found',
|
||||
})
|
||||
|
||||
@@ -75,10 +75,10 @@ async function githubLicense(githubApiProvider, user, repo) {
|
||||
|
||||
let link = `https://github.com/${repoSlug}`
|
||||
|
||||
const { buffer } = await githubApiProvider.requestAsPromise(
|
||||
const { buffer } = await githubApiProvider.requestAsPromise({
|
||||
request,
|
||||
`/repos/${repoSlug}/license`
|
||||
)
|
||||
url: `/repos/${repoSlug}/license`,
|
||||
})
|
||||
try {
|
||||
const data = JSON.parse(buffer)
|
||||
if ('html_url' in data) {
|
||||
|
||||
@@ -93,7 +93,9 @@ const isPercentage = Joi.alternatives().try(
|
||||
isDecimalPercentage
|
||||
)
|
||||
|
||||
const isFileSize = withRegex(/^[0-9]*[.]?[0-9]+\s(B|kB|MB|GB|TB|PB|EB|ZB|YB)$/)
|
||||
const isFileSize = withRegex(
|
||||
/^[0-9]*[.]?[0-9]+\s(B|kB|KB|MB|GB|TB|PB|EB|ZB|YB)$/
|
||||
)
|
||||
|
||||
const isFormattedDate = Joi.alternatives().try(
|
||||
Joi.equal('today', 'yesterday'),
|
||||
|
||||
51
services/twitch/twitch-extension-version.service.js
Normal file
51
services/twitch/twitch-extension-version.service.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import Joi from 'joi'
|
||||
import { renderVersionBadge } from '../version.js'
|
||||
import TwitchBase from './twitch-base.js'
|
||||
|
||||
const helixSchema = Joi.object({
|
||||
data: Joi.array()
|
||||
.items(Joi.object({ version: Joi.string().required() }).required())
|
||||
.min(1)
|
||||
.required(),
|
||||
})
|
||||
|
||||
export default class TwitchExtensionVersion extends TwitchBase {
|
||||
static category = 'version'
|
||||
|
||||
static route = {
|
||||
base: 'twitch/extension/v',
|
||||
pattern: ':extensionId',
|
||||
}
|
||||
|
||||
static examples = [
|
||||
{
|
||||
title: 'Twitch Extension Version',
|
||||
namedParams: {
|
||||
extensionId: '2nq5cu1nc9f4p75b791w8d3yo9d195',
|
||||
},
|
||||
staticPreview: renderVersionBadge({ version: '1.0.0' }),
|
||||
},
|
||||
]
|
||||
|
||||
static defaultBadgeData = {
|
||||
label: 'twitch extension',
|
||||
}
|
||||
|
||||
async fetch({ extensionId }) {
|
||||
const data = this._requestJson({
|
||||
schema: helixSchema,
|
||||
url: `https://api.twitch.tv/helix/extensions/released`,
|
||||
options: {
|
||||
qs: { extension_id: extensionId },
|
||||
},
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
async handle({ extensionId }) {
|
||||
const data = await this.fetch({ extensionId })
|
||||
|
||||
return renderVersionBadge({ version: data.data[0].version })
|
||||
}
|
||||
}
|
||||
16
services/twitch/twitch-extension-version.tester.js
Normal file
16
services/twitch/twitch-extension-version.tester.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { isSemver } from '../test-validators.js'
|
||||
import { createServiceTester } from '../tester.js'
|
||||
import { noToken } from '../test-helpers.js'
|
||||
import _noTwitchToken from './twitch.service.js'
|
||||
const noTwitchToken = noToken(_noTwitchToken)
|
||||
export const t = await createServiceTester()
|
||||
|
||||
t.create('gets the released version of Schedule with Google Calendar')
|
||||
.skipWhen(noTwitchToken)
|
||||
.get('/2nq5cu1nc9f4p75b791w8d3yo9d195.json')
|
||||
.expectBadge({ label: 'twitch extension', message: isSemver })
|
||||
|
||||
t.create('invalid extension id')
|
||||
.skipWhen(noTwitchToken)
|
||||
.get('/will-never-exist.json')
|
||||
.expectBadge({ label: 'twitch extension', message: 'not found' })
|
||||
@@ -40,6 +40,13 @@ export default class VisualStudioMarketplaceRating extends VisualStudioMarketpla
|
||||
}
|
||||
|
||||
static render({ format, averageRating, ratingCount }) {
|
||||
if (ratingCount < 1) {
|
||||
return {
|
||||
message: 'no ratings',
|
||||
color: 'inactive',
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
format === 'r'
|
||||
? `${averageRating.toFixed(1)}/5 (${ratingCount})`
|
||||
|
||||
@@ -83,8 +83,8 @@ t.create('zero rating')
|
||||
)
|
||||
.expectBadge({
|
||||
label: 'rating',
|
||||
message: '0.0/5 (0)',
|
||||
color: 'red',
|
||||
message: 'no ratings',
|
||||
color: 'lightgrey',
|
||||
})
|
||||
|
||||
t.create('stars')
|
||||
|
||||
@@ -12,12 +12,12 @@ t.create('status of https://shields.io')
|
||||
.expectBadge({ label: 'website', message: 'up', color: 'brightgreen' })
|
||||
|
||||
t.create('status of nonexistent domain')
|
||||
.get('/website.json?url=http://shields.io.io')
|
||||
.get('/website.json?url=https://shields.io.io')
|
||||
.timeout(15000)
|
||||
.expectBadge({ label: 'website', message: 'down', color: 'red' })
|
||||
|
||||
t.create('status when network is off')
|
||||
.get('/website.json?url=http://shields.io')
|
||||
.get('/website.json?url=https://shields.io')
|
||||
.networkOff()
|
||||
.expectBadge({ label: 'website', message: 'down', color: 'red' })
|
||||
|
||||
|
||||
@@ -29,4 +29,4 @@ As you can see below, without increasing the footprint of these badges, I've tri
|
||||
|
||||

|
||||
|
||||
This badge design corresponds to an old and now deprecated version which has since been replaced by beautiful and scalable SVG versions that can be found on [shields.io](http://shields.io)
|
||||
This badge design corresponds to an old and now deprecated version which has since been replaced by beautiful and scalable SVG versions that can be found on [shields.io](https://shields.io)
|
||||
|
||||
Reference in New Issue
Block a user