Compare commits

..

29 Commits

Author SHA1 Message Date
chris48s
b0ef5046d0 use credentials if available 2023-01-15 12:36:30 +00:00
chris48s
f60c2058fa fix PAT check 2023-01-15 11:32:01 +00:00
chris48s
39e4d9bcbc rename GithubGist to Gist 2023-01-15 11:22:41 +00:00
chris48s
2bf863fb09 use a PAT if available 2023-01-12 20:40:49 +00:00
chris48s
ed277d4e79 give token contents:read 2023-01-11 20:33:03 +00:00
chris48s
fdc81c2e3a give token gist:read 2023-01-11 20:24:50 +00:00
chris48s
6ef9dcaba5 use workflow token for github service tests 2023-01-11 20:05:58 +00:00
chris48s
541cb9acf2 update triggers 2023-01-11 19:53:56 +00:00
chris48s
2492ff79f7 Merge branch 'master' into services-gha 2023-01-11 19:42:32 +00:00
chris48s
9c692cd53a fail + report if tests are pending 2022-09-19 18:25:51 +01:00
chris48s
7410bf2e97 do nothing 2022-09-19 18:16:02 +01:00
chris48s
a83cfa4fb6 do nothing 2022-09-19 18:11:29 +01:00
chris48s
4d64969738 do nothing 2022-09-19 18:08:14 +01:00
chris48s
ade213c6d3 pass PR title safely 2022-09-19 17:26:14 +01:00
chris48s
1135fba9f6 remove spurious debug 2022-09-19 17:09:09 +01:00
chris48s
0234fb077f improve report 2022-09-19 17:04:57 +01:00
chris48s
ed86c1de21 debug statement 2022-09-19 17:00:53 +01:00
chris48s
a47f770c82 better summary 2022-09-19 16:30:45 +01:00
chris48s
3176d6f7f3 Revert "break some stuff on purpose"
This reverts commit 0763d8ec66.
2022-09-19 16:20:00 +01:00
chris48s
0763d8ec66 break some stuff on purpose 2022-09-19 16:14:17 +01:00
chris48s
3fcb959ed2 single-quote the PR title 2022-09-19 16:08:42 +01:00
chris48s
f562dfe868 Revert "pass the PR title from the workflow to the action"
This reverts commit 22aee48544.
2022-09-19 16:06:44 +01:00
chris48s
22aee48544 pass the PR title from the workflow to the action 2022-09-19 16:02:56 +01:00
chris48s
098a24cae5 do nothing 2022-09-19 15:56:46 +01:00
chris48s
a2c8ed27b8 only run summary if there were any tests to run 2022-09-19 15:35:51 +01:00
chris48s
2dccd3d040 only run summary if there were any tests to run 2022-09-19 15:27:14 +01:00
chris48s
b8ce38a041 fix titles 2022-09-19 15:25:01 +01:00
chris48s
0784a14153 fix runs-on 2022-09-19 15:21:11 +01:00
chris48s
afc7b283bc migrate service tests to GH actions 2022-09-19 15:16:09 +01:00
56 changed files with 1048 additions and 2326 deletions

3
.circleci/config.yml Normal file
View File

@@ -0,0 +1,3 @@
version: 2
# Do nothing
# TODO: disable Circle

View File

@@ -24,7 +24,6 @@ plugins:
- chai-friendly
- jsdoc
- mocha
- icedfrisby
- no-extension-in-require
- sort-class-members
- import
@@ -114,16 +113,9 @@ overrides:
mocha: true
rules:
mocha/no-exclusive-tests: 'error'
mocha/no-skipped-tests: 'error'
mocha/no-mocha-arrows: 'error'
mocha/prefer-arrow-callback: 'error'
- files:
- 'services/**/*.tester.js'
rules:
icedfrisby/no-exclusive-tests: 'error'
icedfrisby/no-skipped-tests: 'error'
rules:
# Disable some rules from eslint:recommended.
no-empty: ['error', { 'allowEmptyCatch': true }]

View File

@@ -7,19 +7,11 @@ inputs:
runs:
using: 'composite'
steps:
- name: Migrate DB
if: always()
run: npm run migrate up
env:
POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/ci_test
shell: bash
- name: Integration Tests
if: always()
run: npm run test:integration -- --reporter json --reporter-option 'output=reports/integration-tests.json'
env:
GH_TOKEN: '${{ inputs.github-token }}'
POSTGRES_URL: postgresql://postgres:postgres@localhost:5432/ci_test
shell: bash
- name: Write Markdown Summary

View File

@@ -11,14 +11,12 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
version: v0.9.1
- name: Set Git Short SHA
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Build
uses: docker/build-push-action@v4
uses: docker/build-push-action@v3
with:
context: .
push: false

View File

@@ -35,8 +35,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
version: v0.9.1
- name: Login to DockerHub
uses: docker/login-action@v2
@@ -45,7 +43,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push snapshot release to DockerHub
uses: docker/build-push-action@v4
uses: docker/build-push-action@v3
with:
context: .
push: true

View File

@@ -11,7 +11,7 @@ permissions:
jobs:
danger:
runs-on: ubuntu-latest
if: github.actor != 'dependabot[bot]' && github.actor != 'repo-ranger[bot]'
if: github.actor != 'dependabot[bot]'
steps:
- name: Checkout
uses: actions/checkout@v3

View File

@@ -16,13 +16,10 @@ jobs:
with:
persist-credentials: false
- name: Setup
uses: ./.github/actions/setup
with:
node-version: 16
- name: Build
run: npm run build-docs
run: |
npm ci
npm run build-docs
- name: Deploy
uses: JamesIves/github-pages-deploy-action@v4

View File

@@ -13,8 +13,6 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
version: v0.9.1
- name: Login to DockerHub
uses: docker/login-action@v2
@@ -26,7 +24,7 @@ jobs:
run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV
- name: Build and push
uses: docker/build-push-action@v4
uses: docker/build-push-action@v3
with:
context: .
push: true

View File

@@ -23,19 +23,6 @@ jobs:
--health-retries 5
ports:
- 6379:6379
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ci_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout

View File

@@ -23,19 +23,6 @@ jobs:
--health-retries 5
ports:
- 6379:6379
postgres:
image: postgres
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: ci_test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- name: Checkout

3
.gitignore vendored
View File

@@ -117,6 +117,3 @@ service-definitions.yml
# Flamebearer
flamegraph.html
# config file for node-pg-migrate
migrations-config.json

View File

@@ -4,26 +4,6 @@ Note: this changelog is for the shields.io server. The changelog for the badge-m
---
## server-2023-03-01
**Deprecation:** For users who need to maintain a Github Token pool, storage has been provided via the `RedisTokenPersistence` and `REDIS_URL` settings. As of this release, the `RedisTokenPersistence` backend is now deprecated and will be removed in a future release. If you are using this feature, you will need to migrate to using the `SQLTokenPersistence` backend for storage and provide a postgres connection string via the `POSTGRES_URL` setting. [#8922](https://github.com/badges/shields/issues/8922)
- fix: for crates.io versions, use max_stable_version if it exists [#8687](https://github.com/badges/shields/issues/8687)
- don't autofocus search [#8927](https://github.com/badges/shields/issues/8927)
- Add [Vcpkg] version service [#8923](https://github.com/badges/shields/issues/8923)
- fix: Set uid/gid in docker image to 0 [#8908](https://github.com/badges/shields/issues/8908)
- expose port 443 in Dockerfile [#8889](https://github.com/badges/shields/issues/8889)
- Dependency updates
## server-2023-02-01
- replace [twitter] badge with static fallback [#8842](https://github.com/badges/shields/issues/8842)
- Add various [Polymart] badges [#8811](https://github.com/badges/shields/issues/8811)
- update [githubpipenv] tests/examples [#8797](https://github.com/badges/shields/issues/8797)
- deprecate [apm] service [#8773](https://github.com/badges/shields/issues/8773)
- deprecate lgtm [#8771](https://github.com/badges/shields/issues/8771)
- Dependency updates
## server-2023-01-01
- Breaking change: Routes for GitHub workflows badge have changed. See https://github.com/badges/shields/issues/8671 for more details

View File

@@ -30,8 +30,8 @@ LABEL fly.version=$version
ENV NODE_ENV production
WORKDIR /usr/src/app
COPY --from=Builder --chown=0:0 /usr/src/app /usr/src/app
COPY --from=Builder /usr/src/app /usr/src/app
CMD node server
EXPOSE 80 443
EXPOSE 80

View File

@@ -94,7 +94,6 @@ private:
obs_user: 'OBS_USER'
obs_pass: 'OBS_PASS'
redis_url: 'REDIS_URL'
postgres_url: 'POSTGRES_URL'
sentry_dsn: 'SENTRY_DSN'
sl_insight_userUuid: 'SL_INSIGHT_USER_UUID'
sl_insight_apiToken: 'SL_INSIGHT_API_TOKEN'

View File

@@ -27,13 +27,11 @@ class InvalidService extends Error {
}
}
function getServicePaths(pattern) {
return glob.sync(toUnixPath(path.join(serviceDir, '**', pattern)))
}
async function loadServiceClasses(servicePaths) {
if (!servicePaths) {
servicePaths = getServicePaths('*.service.js')
servicePaths = glob.sync(
toUnixPath(path.join(serviceDir, '**', '*.service.js'))
)
}
const serviceClasses = []
@@ -104,16 +102,15 @@ async function collectDefinitions() {
async function loadTesters() {
return Promise.all(
getServicePaths('*.tester.js').map(
async path => await import(`file://${path}`)
)
glob
.sync(path.join(serviceDir, '**', '*.tester.js'))
.map(async path => await import(`file://${path}`))
)
}
export {
InvalidService,
loadServiceClasses,
getServicePaths,
checkNames,
collectDefinitions,
loadTesters,

View File

@@ -2,11 +2,7 @@ import path from 'path'
import { fileURLToPath } from 'url'
import chai from 'chai'
import chaiAsPromised from 'chai-as-promised'
import {
loadServiceClasses,
getServicePaths,
InvalidService,
} from './loader.js'
import { loadServiceClasses, InvalidService } from './loader.js'
chai.use(chaiAsPromised)
const { expect } = chai
@@ -69,15 +65,3 @@ describe('loadServiceClasses function', function () {
).to.eventually.have.length(5)
})
})
describe('getServicePaths', function () {
// these tests just make sure we discover a
// plausibly large number of .service and .tester files
it('finds a non-zero number of services in the project', function () {
expect(getServicePaths('*.service.js')).to.have.length.above(400)
})
it('finds a non-zero number of testers in the project', function () {
expect(getServicePaths('*.tester.js')).to.have.length.above(400)
})
})

View File

@@ -183,7 +183,6 @@ const privateConfigSchema = Joi.object({
obs_user: Joi.string(),
obs_pass: Joi.string(),
redis_url: Joi.string().uri({ scheme: ['redis', 'rediss'] }),
postgres_url: Joi.string().uri({ scheme: 'postgresql' }),
sentry_dsn: Joi.string(),
sl_insight_userUuid: Joi.string(),
sl_insight_apiToken: Joi.string(),

View File

@@ -84,6 +84,7 @@ describe('Redis token persistence', function () {
const toRemove = expected.pop()
await persistence.initialize()
await persistence.noteTokenRemoved(toRemove)
const savedTokens = await redis.smembers(key)

View File

@@ -1,103 +0,0 @@
import pg from 'pg'
import { expect } from 'chai'
import configModule from 'config'
import SqlTokenPersistence from './sql-token-persistence.js'
const config = configModule.util.toObject()
const postgresUrl = config?.private?.postgres_url
const tableName = 'token_persistence_integration_test'
describe('SQL token persistence', function () {
let pool
let persistence
before('Mock db connection and load app', async function () {
// Create a new pool with a connection limit of 1
pool = new pg.Pool({
connectionString: postgresUrl,
// Reuse the connection to make sure we always hit the same pg_temp schema
max: 1,
// Disable auto-disconnection of idle clients to make sure we always hit the same pg_temp schema
idleTimeoutMillis: 0,
})
persistence = new SqlTokenPersistence({
url: postgresUrl,
table: tableName,
})
})
after(async function () {
if (persistence) {
await persistence.stop()
persistence = undefined
}
})
beforeEach('Create temporary table', async function () {
await pool.query(
`CREATE TEMPORARY TABLE ${tableName} (LIKE github_user_tokens INCLUDING ALL);`
)
})
afterEach('Drop temporary table', async function () {
await pool.query(`DROP TABLE IF EXISTS pg_temp.${tableName};`)
})
context('when the key does not exist', function () {
it('does nothing', async function () {
const tokens = await persistence.initialize(pool)
expect(tokens).to.deep.equal([])
})
})
context('when the key exists', function () {
const initialTokens = ['a', 'b', 'c'].map(char => char.repeat(40))
beforeEach(async function () {
initialTokens.forEach(async token => {
await pool.query(
`INSERT INTO pg_temp.${tableName} (token) VALUES ($1::text);`,
[token]
)
})
})
it('loads the contents', async function () {
const tokens = await persistence.initialize(pool)
expect(tokens.sort()).to.deep.equal(initialTokens)
})
context('when tokens are added', function () {
it('saves the change', async function () {
const newToken = 'e'.repeat(40)
const expected = initialTokens.slice()
expected.push(newToken)
await persistence.initialize(pool)
await persistence.noteTokenAdded(newToken)
const result = await pool.query(
`SELECT token FROM pg_temp.${tableName};`
)
const savedTokens = result.rows.map(row => row.token)
expect(savedTokens.sort()).to.deep.equal(expected)
})
})
context('when tokens are removed', function () {
it('saves the change', async function () {
const expected = Array.from(initialTokens)
const toRemove = expected.pop()
await persistence.initialize(pool)
await persistence.noteTokenRemoved(toRemove)
const result = await pool.query(
`SELECT token FROM pg_temp.${tableName};`
)
const savedTokens = result.rows.map(row => row.token)
expect(savedTokens.sort()).to.deep.equal(expected)
})
})
})
})

View File

@@ -1,55 +0,0 @@
import pg from 'pg'
import log from '../server/log.js'
export default class SqlTokenPersistence {
constructor({ url, table }) {
this.url = url
this.table = table
this.noteTokenAdded = this.noteTokenAdded.bind(this)
this.noteTokenRemoved = this.noteTokenRemoved.bind(this)
}
async initialize(pool) {
if (pool) {
this.pool = pool
} else {
this.pool = new pg.Pool({ connectionString: this.url })
}
const result = await this.pool.query(`SELECT token FROM ${this.table};`)
return result.rows.map(row => row.token)
}
async stop() {
await this.pool.end()
}
async onTokenAdded(token) {
return await this.pool.query(
`INSERT INTO ${this.table} (token) VALUES ($1::text) ON CONFLICT (token) DO NOTHING;`,
[token]
)
}
async onTokenRemoved(token) {
return await this.pool.query(
`DELETE FROM ${this.table} WHERE token=$1::text;`,
[token]
)
}
async noteTokenAdded(token) {
try {
await this.onTokenAdded(token)
} catch (e) {
log.error(e)
}
}
async noteTokenRemoved(token) {
try {
await this.onTokenRemoved(token)
} catch (e) {
log.error(e)
}
}
}

View File

@@ -11,6 +11,7 @@
// DANGER_GITHUB_API_TOKEN=your-github-api-token npm run danger -- pr https://github.com/badges/shields/pull/2665
const { danger, fail, message, warn } = require('danger')
const { default: noTestShortcuts } = require('danger-plugin-no-test-shortcuts')
const { fileMatch } = danger.git
const documentation = fileMatch(
@@ -172,3 +173,11 @@ affectedServices.forEach(service => {
)
}
})
// Prevent merging exclusive services tests.
noTestShortcuts({
testFilePredicate: filePath => filePath.endsWith('.tester.js'),
patterns: {
only: ['only()'],
},
})

View File

@@ -125,17 +125,11 @@ Because of GitHub rate limits, you will need to provide a token, or else badges
will stop working once you hit 60 requests per hour, the
[unauthenticated rate limit][github rate limit].
You can [create a personal access token][personal access tokens] (PATs) through the
You can [create a personal access token][personal access tokens] through the
GitHub website. When you create the token, you can choose to give read access
to your repositories. If you do that, your self-hosted Shields installation
will have access to your private repositories.
For most users we recommend using a classic PAT as opposed to a [fine-grained PAT][fine-grained pat].
It is possible to request a fairly large subset of the GitHub badge suite using a
fine-grained PAT for authentication but there are also some badges that won't work.
This is because some of our badges make use of GitHub's v4 GraphQL API and the
GraphQL API only supports authentication with a classic PAT.
When a `gh_token` is specified, it is used in place of the Shields token
rotation logic.
@@ -145,7 +139,6 @@ token, though it's not required.
[github rate limit]: https://developer.github.com/v3/#rate-limiting
[personal access tokens]: https://github.com/settings/tokens
[fine-grained pat]: https://github.blog/2022-10-18-introducing-fine-grained-personal-access-tokens-for-github/
- `GH_CLIENT_ID` (yml: `private.gh_client_id`)
- `GH_CLIENT_SECRET` (yml: `private.gh_client_secret`)

View File

@@ -27,6 +27,7 @@ export default function Search({
<form action="javascript:void 0" autoComplete="off">
<BlockInput
autoComplete="off"
autoFocus
onChange={onQueryChanged}
placeholder="search"
/>

View File

@@ -1,14 +0,0 @@
/* eslint-disable camelcase */
exports.shorthands = undefined
exports.up = pgm => {
pgm.createTable('github_user_tokens', {
id: 'id',
token: { type: 'varchar(1000)', notNull: true, unique: true },
})
}
exports.down = pgm => {
pgm.dropTable('github_user_tokens')
}

2503
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -24,29 +24,29 @@
"@fontsource/lato": "^4.5.10",
"@fontsource/lekton": "^4.5.11",
"@renovate/pep440": "^1.0.0",
"@renovatebot/ruby-semver": "^2.1.8",
"@sentry/node": "^7.38.0",
"@shields_io/camp": "^18.1.2",
"@renovatebot/ruby-semver": "^1.1.7",
"@sentry/node": "^7.29.0",
"@shields_io/camp": "^18.1.1",
"badge-maker": "file:badge-maker",
"bytes": "^3.1.2",
"camelcase": "^7.0.1",
"chalk": "^5.2.0",
"check-node-version": "^4.2.1",
"cloudflare-middleware": "^1.0.4",
"config": "^3.3.9",
"config": "^3.3.8",
"cross-env": "^7.0.3",
"dayjs": "^1.11.7",
"decamelize": "^3.2.0",
"emojic": "^1.1.17",
"escape-string-regexp": "^4.0.0",
"fast-xml-parser": "^4.1.2",
"glob": "^8.1.0",
"fast-xml-parser": "^4.0.12",
"glob": "^8.0.3",
"global-agent": "^3.0.0",
"got": "^12.5.3",
"graphql": "^15.6.1",
"graphql-tag": "^2.12.6",
"ioredis": "5.3.1",
"joi": "17.8.3",
"ioredis": "5.2.4",
"joi": "17.7.0",
"joi-extension-semver": "5.0.0",
"js-yaml": "^4.1.0",
"jsonpath": "~1.1.1",
@@ -54,17 +54,15 @@
"lodash.groupby": "^4.6.0",
"lodash.times": "^4.3.2",
"node-env-flag": "^0.1.0",
"node-pg-migrate": "^6.2.2",
"parse-link-header": "^2.0.0",
"path-to-regexp": "^6.2.1",
"pg": "^8.9.0",
"pretty-bytes": "^6.1.0",
"pretty-bytes": "^6.0.0",
"priorityqueuejs": "^2.0.0",
"prom-client": "^14.1.1",
"qs": "^6.11.0",
"query-string": "^8.1.0",
"semver": "~7.3.8",
"simple-icons": "8.5.0",
"simple-icons": "8.2.0",
"webextension-store-meta": "^1.0.5",
"xmldom": "~0.6.0",
"xpath": "~0.0.32"
@@ -119,8 +117,7 @@
"e2e": "start-server-and-test start http://localhost:3000 test:e2e",
"e2e-on-build": "cross-env CYPRESS_baseUrl=http://localhost:8080 start-server-and-test start:server:e2e-on-build http://localhost:8080 test:e2e",
"badge": "cross-env NODE_CONFIG_ENV=test TRACE_SERVICES=true node scripts/badge-cli.js",
"build-docs": "rimraf api-docs/ && jsdoc --pedantic -c ./jsdoc.json . && echo 'contributing.shields.io' > api-docs/CNAME",
"migrate": "node scripts/write-migrations-config.js > migrations-config.json && node-pg-migrate --config-file=migrations-config.json"
"build-docs": "rimraf api-docs/ && jsdoc --pedantic -c ./jsdoc.json . && echo 'contributing.shields.io' > api-docs/CNAME"
},
"lint-staged": {
"**/*.@(js|ts|tsx)": [
@@ -145,9 +142,9 @@
]
},
"devDependencies": {
"@babel/core": "^7.21.0",
"@babel/core": "^7.20.12",
"@babel/polyfill": "^7.12.1",
"@babel/register": "7.21.0",
"@babel/register": "7.18.9",
"@istanbuljs/schema": "^0.1.3",
"@mapbox/react-click-to-select": "^2.2.1",
"@types/chai": "^4.3.4",
@@ -159,11 +156,11 @@
"@types/react-modal": "^3.13.1",
"@types/react-select": "^4.0.17",
"@types/styled-components": "5.1.26",
"@typescript-eslint/eslint-plugin": "^5.53.0",
"@typescript-eslint/eslint-plugin": "^5.48.0",
"@typescript-eslint/parser": "^5.46.0",
"babel-plugin-inline-react-svg": "^2.0.2",
"babel-plugin-inline-react-svg": "^2.0.1",
"babel-preset-gatsby": "^2.22.0",
"c8": "^7.13.0",
"c8": "^7.12.0",
"caller": "^1.1.0",
"chai": "^4.3.7",
"chai-as-promised": "^7.1.1",
@@ -172,10 +169,11 @@
"child-process-promise": "^2.2.1",
"clipboard-copy": "^4.0.1",
"concurrently": "^7.6.0",
"cypress": "^12.6.0",
"cypress": "^12.3.0",
"cypress-wait-for-stable-dom": "^0.1.0",
"danger": "^11.2.3",
"deepmerge": "^4.3.0",
"danger": "^11.2.1",
"danger-plugin-no-test-shortcuts": "^2.0.0",
"deepmerge": "^4.2.2",
"eslint": "^7.32.0",
"eslint-config-prettier": "^8.6.0",
"eslint-config-standard": "^16.0.3",
@@ -183,14 +181,13 @@
"eslint-config-standard-react": "^11.0.1",
"eslint-plugin-chai-friendly": "^0.7.2",
"eslint-plugin-cypress": "^2.12.1",
"eslint-plugin-icedfrisby": "^0.1.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsdoc": "^40.0.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jsdoc": "^39.6.4",
"eslint-plugin-mocha": "^10.1.0",
"eslint-plugin-no-extension-in-require": "^0.2.0",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.2.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react": "^7.31.11",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-sort-class-members": "^1.16.0",
"fetch-ponyfill": "^7.1.0",
@@ -207,22 +204,22 @@
"icedfrisby-nock": "^2.1.0",
"is-svg": "^4.3.2",
"js-yaml-loader": "^1.2.2",
"jsdoc": "^4.0.2",
"lint-staged": "^13.1.2",
"jsdoc": "^4.0.0",
"lint-staged": "^13.1.0",
"lodash.debounce": "^4.0.8",
"lodash.difference": "^4.5.0",
"minimist": "^1.2.8",
"minimist": "^1.2.7",
"mocha": "^10.2.0",
"mocha-env-reporter": "^4.0.0",
"mocha-junit-reporter": "^2.2.0",
"mocha-yaml-loader": "^1.0.3",
"nock": "13.3.0",
"nock": "13.2.9",
"node-mocks-http": "^1.12.1",
"nodemon": "^2.0.20",
"npm-run-all": "^4.1.5",
"open-cli": "^7.1.0",
"portfinder": "^1.0.32",
"prettier": "2.8.4",
"prettier": "2.8.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-error-overlay": "^6.0.11",
@@ -232,17 +229,17 @@
"react-select": "^4.3.1",
"read-all-stdin-sync": "^1.0.5",
"redis-server": "^1.2.2",
"rimraf": "^4.1.2",
"rimraf": "^3.0.2",
"sazerac": "^2.0.0",
"simple-git-hooks": "^2.8.1",
"sinon": "^15.0.1",
"sinon-chai": "^3.7.0",
"snap-shot-it": "^7.9.10",
"start-server-and-test": "1.15.4",
"start-server-and-test": "1.15.2",
"styled-components": "^5.3.6",
"ts-mocha": "^10.0.0",
"tsd": "^0.25.0",
"typescript": "^4.9.5",
"typescript": "^4.9.4",
"url": "^0.11.0"
},
"engines": {

View File

@@ -1,11 +0,0 @@
import configModule from 'config'
const config = configModule.util.toObject()
const postgresUrl = config?.private?.postgres_url
if (!postgresUrl) {
process.exit(1)
}
process.stdout.write(JSON.stringify({ url: postgresUrl }))
process.exit(0)

View File

@@ -42,15 +42,6 @@ if (fs.existsSync('.env')) {
process.exit(1)
}
if (config.private.redis_url != null) {
console.warn(
'RedisTokenPersistence is deprecated for token pooling and will be removed in a future release. Migrate to SqlTokenPersistence'
)
console.warn(
'See https://github.com/badges/shields/blob/master/CHANGELOG.md#server-2023-03-01 for more info'
)
}
const legacySecretsPath = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'private',

View File

@@ -19,14 +19,12 @@ export default class CratesVersion extends BaseCratesService {
if (json.errors) {
throw new InvalidResponse({ prettyMessage: json.errors[0].detail })
}
return json.crate.max_stable_version
? json.crate.max_stable_version
: json.crate.max_version
return { version: json.version ? json.version.num : json.crate.max_version }
}
async handle({ crate }) {
const json = await this.fetch({ crate })
const version = this.transform(json)
const { version } = this.transform(json)
return renderVersionBadge({ version })
}
}

View File

@@ -5,10 +5,8 @@ import CratesVersion from './crates-version.service.js'
describe('CratesVersion', function () {
test(CratesVersion.prototype.transform, () => {
given({ crate: { max_version: '1.1.0' } }).expect('1.1.0')
given({
crate: { max_stable_version: '1.1.0', max_version: '1.9.0-alpha' },
}).expect('1.1.0')
given({ version: { num: '1.0.0' } }).expect({ version: '1.0.0' })
given({ crate: { max_version: '1.1.0' } }).expect({ version: '1.1.0' })
})
it('throws InvalidResponse on error response', function () {

View File

@@ -1,6 +1,5 @@
import { AuthHelper } from '../../core/base-service/auth-helper.js'
import RedisTokenPersistence from '../../core/token-pooling/redis-token-persistence.js'
import SqlTokenPersistence from '../../core/token-pooling/sql-token-persistence.js'
import log from '../../core/server/log.js'
import GithubApiProvider from './github-api-provider.js'
import { setRoutes as setAcceptorRoutes } from './auth/acceptor.js'
@@ -24,18 +23,8 @@ class GithubConstellation {
this._debugEnabled = config.service.debug.enabled
this._debugIntervalSeconds = config.service.debug.intervalSeconds
const {
postgres_url: pgUrl,
redis_url: redisUrl,
gh_token: globalToken,
} = config.private
if (pgUrl) {
log.log('Token persistence configured with dbUrl')
this.persistence = new SqlTokenPersistence({
url: pgUrl,
table: 'github_user_tokens',
})
} else if (redisUrl) {
const { redis_url: redisUrl, gh_token: globalToken } = config.private
if (redisUrl) {
log.log('Token persistence configured with redisUrl')
this.persistence = new RedisTokenPersistence({
url: redisUrl,

View File

@@ -18,6 +18,7 @@ const documentation = `
badge can be added to the project readme to encourage potential
contributors to review the suggested issues and to celebrate the
contributions that have already been made.
The badge displays three pieces of information:
<ul>
<li>
@@ -32,6 +33,7 @@ const documentation = `
</li>
<li>The number of days left of October.</li>
</ul>
</p>
${githubDocumentation}

View File

@@ -66,9 +66,9 @@ class GithubPipenvLockedPythonVersion extends ConditionalGithubAuthV3Service {
namedParams: {
user: 'metabolize',
repo: 'rq-dashboard-on-heroku',
branch: 'main',
branch: 'master',
},
staticPreview: this.render({ version: '3.7', branch: 'main' }),
staticPreview: this.render({ version: '3.7', branch: 'master' }),
documentation,
keywords,
},
@@ -135,7 +135,7 @@ class GithubPipenvLockedDependencyVersion extends ConditionalGithubAuthV3Service
repo: 'rq-dashboard-on-heroku',
kind: 'dev',
packageName: 'black',
branch: 'main',
branch: 'master',
},
staticPreview: this.render({ dependency: 'black', version: '19.3b0' }),
documentation,

View File

@@ -47,7 +47,7 @@ t.create('Locked version of default dependency')
t.create('Locked version of default dependency (branch)')
.get(
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard/main.json'
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/rq-dashboard/master.json'
)
.expectBadge({
label: 'rq-dashboard',
@@ -65,7 +65,7 @@ t.create('Locked version of dev dependency')
t.create('Locked version of dev dependency (branch)')
.get(
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black/main.json'
'/locked/dependency-version/metabolize/rq-dashboard-on-heroku/dev/black/master.json'
)
.expectBadge({
label: 'black',

View File

@@ -14,7 +14,7 @@ const queryParamSchema = Joi.object({
const documentation = `
<p>To find your user id, you can use <a link target="_blank" href="https://prouser123.me/misc/mastodon-userid-lookup.html">this tool</a>.</p><br>
<p>Alternatively you can make a request to <code>https://your.mastodon.server/.well-known/webfinger?resource=acct:{user}@{domain}</code></p>
<p>Alternatively you can make a request to <code><br>https://your.mastodon.server/.well-known/webfinger?resource=acct:{user}@{domain}</br></code></p>
<p>Failing that, you can also visit your profile page, where your user ID will be in the header in a tag like this: <code>&lt;link href='https://your.mastodon.server/api/salmon/{your-user-id}' rel='salmon'></code></p>
`

View File

@@ -29,9 +29,9 @@ const matrixStateSchema = Joi.array()
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>
</br>
The following steps will show you how to setup the badge URL using the Element Matrix client.
<br>
</br>
<ul>
<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>
@@ -41,11 +41,11 @@ const documentation = `
<li>Remove the starting hash character (<code>#</code>)</li>
<li>The final badge URL should look something like this <code>/matrix/twim:matrix.org.svg</code></li>
</ul>
<br>
</br>
Some Matrix homeservers don't hold a server name matching where they live (e.g. if the homeserver <code>example.com</code> that created the room alias <code>#mysuperroom:example.com</code> lives at <code>matrix.example.com</code>).
<br>
</br>
If that is the case of the homeserver that created the room alias used for generating the badge, you will need to add the server's FQDN (fully qualified domain name) as a query parameter.
<br>
</br>
The final badge URL should then look something like this <code>/matrix/mysuperroom:example.com.svg?server_fqdn=matrix.example.com</code>.
</p>
`

View File

@@ -31,11 +31,11 @@ const documentation = `
is a set of tools to analyze your website
and inform you if you are utilizing the many available methods to secure it.
</p>
<p>
</p>
By default the scan result is hidden from the public result list.
You can activate the publication of the scan result
by setting the <code>publish</code> parameter.
</p>
<p>
<p>
The badge returns a cached site result if the site has been scanned anytime in the previous 24 hours.
If you need to force invalidating the cache,

View File

@@ -81,7 +81,8 @@ export default class Nexus extends BaseJsonService {
staticPreview: this.render({
version: '3.9',
}),
documentation: `<p>
documentation: `
<p>
Specifying 'nexusVersion=3' when targeting Nexus 3 servers will speed up the badge rendering.
Note that you can use this query parameter with any Nexus badge type (Releases, Snapshots, or Repository).
</p>
@@ -131,7 +132,8 @@ export default class Nexus extends BaseJsonService {
staticPreview: this.render({
version: '7.0.1-SNAPSHOT',
}),
documentation: `<p>
documentation: `
<p>
Note that you can use query options with any Nexus badge type (Releases, Snapshots, or Repository).
</p>
<p>
@@ -142,7 +144,7 @@ export default class Nexus extends BaseJsonService {
<ul>
<li><a href="https://nexus.pentaho.org/swagger-ui/#/search/search">All Nexus 3 badges</a></li>
<li><a href="https://repository.sonatype.org/nexus-restlet1x-plugin/default/docs/path__artifact_maven_resolve.html">Nexus 2 Releases and Snapshots badges</a></li>
<li><a href="https://repository.sonatype.org/nexus-indexer-lucene-plugin/default/docs/path__lucene_search.html">Nexus 2 Repository badges</a></li>
<li><a href=https://repository.sonatype.org/nexus-indexer-lucene-plugin/default/docs/path__lucene_search.html">Nexus 2 Repository badges</a></li>
</ul>
</p>
`,

View File

@@ -34,7 +34,7 @@ const resourceSchema = Joi.object({
const documentation = `
<p>Your Plugin ID is the name of your plugin in lowercase, without any spaces or dashes.</p>
<p>Example: <code>https://ore.spongepowered.org/Erigitic/Total-Economy</code> - Here the Plugin ID is <code>totaleconomy</code>.</p>`
<p>Example: <code>https://ore.spongepowered.org/Erigitic/Total-Economy</code> - Here the Plugin ID is <code>totaleconomy<code/>.`
const keywords = ['sponge', 'spongemc', 'spongepowered']

View File

@@ -164,12 +164,14 @@ class BasePackagistService extends BaseJsonService {
return versions.filter(version => version.version === release)[0]
}
}
const customServerDocumentationFragment = `<p>
const customServerDocumentationFragment = `
<p>
Note that only network-accessible packagist.org and other self-hosted Packagist instances are supported.
</p>
`
const cacheDocumentationFragment = `<p>
const cacheDocumentationFragment = `
<p>
Displayed data may be slightly outdated.
Due to performance reasons, data fetched from packagist JSON API is cached for twelve hours on packagist infrastructure.
For more information please refer to <a target="_blank" href="https://packagist.org/apidoc#get-package-data">official packagist documentation</a>.

View File

@@ -1,51 +0,0 @@
import Joi from 'joi'
import { BaseJsonService } from '../index.js'
const resourceSchema = Joi.object({
response: Joi.object({
resource: Joi.object({
price: Joi.number().required(),
downloads: Joi.string().required(),
reviews: Joi.object({
count: Joi.number().required(),
stars: Joi.number().required(),
}).required(),
updates: Joi.object({
latest: Joi.object({
version: Joi.string().required(),
}).required(),
}).required(),
}).required(),
}).required(),
}).required()
const notFoundResourceSchema = Joi.object({
response: Joi.object({
success: Joi.boolean().required(),
errors: Joi.object().required(),
}).required(),
})
const resourceFoundOrNotSchema = Joi.alternatives(
resourceSchema,
notFoundResourceSchema
)
const documentation = `
<p>You can find your resource ID in the url for your resource page.</p>
<p>Example: <code>https://polymart.org/resource/polymart-plugin.323</code> - Here the Resource ID is 323.</p>`
class BasePolymartService extends BaseJsonService {
async fetch({
resourceId,
schema = resourceFoundOrNotSchema,
url = `https://api.polymart.org/v1/getResourceInfo/?resource_id=${resourceId}`,
}) {
return this._requestJson({
schema,
url,
})
}
}
export { documentation, BasePolymartService }

View File

@@ -1,35 +0,0 @@
import { NotFound } from '../../core/base-service/errors.js'
import { renderDownloadsBadge } from '../downloads.js'
import { BasePolymartService, documentation } from './polymart-base.js'
export default class PolymartDownloads extends BasePolymartService {
static category = 'downloads'
static route = {
base: 'polymart/downloads',
pattern: ':resourceId',
}
static examples = [
{
title: 'Polymart Downloads',
namedParams: {
resourceId: '323',
},
staticPreview: renderDownloadsBadge({ downloads: 655 }),
documentation,
},
]
static defaultBadgeData = {
label: 'downloads',
}
async handle({ resourceId }) {
const { response } = await this.fetch({ resourceId })
if (!response.resource) {
throw new NotFound()
}
return renderDownloadsBadge({ downloads: response.resource.downloads })
}
}

View File

@@ -1,13 +0,0 @@
import { isMetric } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('Polymart Plugin (id 323)').get('/323.json').expectBadge({
label: 'downloads',
message: isMetric,
})
t.create('Invalid Resource (id 0)').get('/0.json').expectBadge({
label: 'downloads',
message: 'not found',
})

View File

@@ -1,38 +0,0 @@
import { NotFound } from '../../core/base-service/errors.js'
import { renderVersionBadge } from '../version.js'
import { BasePolymartService, documentation } from './polymart-base.js'
export default class PolymartLatestVersion extends BasePolymartService {
static category = 'version'
static route = {
base: 'polymart/version',
pattern: ':resourceId',
}
static examples = [
{
title: 'Polymart Version',
namedParams: {
resourceId: '323',
},
staticPreview: renderVersionBadge({
version: 'v1.2.9',
}),
documentation,
},
]
static defaultBadgeData = {
label: 'polymart',
}
async handle({ resourceId }) {
const { response } = await this.fetch({ resourceId })
if (!response.resource) {
throw new NotFound()
}
return renderVersionBadge({
version: response.resource.updates.latest.version,
})
}
}

View File

@@ -1,13 +0,0 @@
import { isVPlusDottedVersionNClauses } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('Polymart Plugin (id 323)').get('/323.json').expectBadge({
label: 'polymart',
message: isVPlusDottedVersionNClauses,
})
t.create('Invalid Resource (id 0)').get('/0.json').expectBadge({
label: 'polymart',
message: 'not found',
})

View File

@@ -1,65 +0,0 @@
import { starRating, metric } from '../text-formatters.js'
import { floorCount } from '../color-formatters.js'
import { NotFound } from '../../core/base-service/errors.js'
import { BasePolymartService, documentation } from './polymart-base.js'
export default class PolymartRatings extends BasePolymartService {
static category = 'rating'
static route = {
base: 'polymart',
pattern: ':format(rating|stars)/:resourceId',
}
static examples = [
{
title: 'Polymart Stars',
pattern: 'stars/:resourceId',
namedParams: {
resourceId: '323',
},
staticPreview: this.render({
format: 'stars',
total: 14,
average: 5,
}),
documentation,
},
{
title: 'Polymart Rating',
pattern: 'rating/:resourceId',
namedParams: {
resourceId: '323',
},
staticPreview: this.render({ total: 14, average: 5 }),
documentation,
},
]
static defaultBadgeData = {
label: 'rating',
}
static render({ format, total, average }) {
const message =
format === 'stars'
? starRating(average)
: `${average}/5 (${metric(total)})`
return {
message,
color: floorCount(average, 2, 3, 4),
}
}
async handle({ format, resourceId }) {
const { response } = await this.fetch({ resourceId })
if (!response.resource) {
throw new NotFound()
}
return this.constructor.render({
format,
total: response.resource.reviews.count,
average: response.resource.reviews.stars.toFixed(2),
})
}
}

View File

@@ -1,27 +0,0 @@
import { isStarRating, withRegex } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('Stars - Polymart Plugin (id 323)')
.get('/stars/323.json')
.expectBadge({
label: 'rating',
message: isStarRating,
})
t.create('Stars - Invalid Resource (id 0)').get('/stars/0.json').expectBadge({
label: 'rating',
message: 'not found',
})
t.create('Rating - Polymart Plugin (id 323)')
.get('/rating/323.json')
.expectBadge({
label: 'rating',
message: withRegex(/^(\d*\.\d+)(\/5 \()(\d+)(\))$/),
})
t.create('Rating - Invalid Resource (id 0)').get('/rating/0.json').expectBadge({
label: 'rating',
message: 'not found',
})

View File

@@ -13,9 +13,9 @@ const documentation = `
provide an easy mechanism to analyze HTTP response headers and
give information on how to deploy missing headers.
</p>
<p>
The scan result will be hidden from the public result list and follow redirects will be on too.
</p>
The scan result will be hidden from the public result list and follow redirects will be on too.
<p>
`
export default class SecurityHeaders extends BaseService {

View File

@@ -25,7 +25,8 @@ export default class SnykVulnerabilityGitHub extends SynkVulnerabilityBase {
manifestFilePath: 'badge-maker/package.json',
},
staticPreview: this.render({ vulnerabilities: '0' }),
documentation: `<p>
documentation: `
<p>
Provide the path to your target manifest file relative to the base of your repository.
Snyk does not support using a specific branch for this, so do not include "blob" nor a branch name.
</p>

View File

@@ -18,7 +18,7 @@ const documentation = `
</p>
<img
src="https://user-images.githubusercontent.com/7288322/46567027-27c83400-c987-11e8-9850-ab67d987202f.png"
alt="Right-Click and 'Copy Page URL'" />
alt="Right-Click and 'Copy Page URL'">
`
const steamCollectionSchema = Joi.object({

View File

@@ -1,6 +1,7 @@
import Joi from 'joi'
import { metric } from '../text-formatters.js'
import { optionalUrl } from '../validators.js'
import { BaseService, BaseJsonService } from '../index.js'
import { BaseService, BaseJsonService, NotFound } from '../index.js'
const queryParamSchema = Joi.object({
url: optionalUrl.required(),
@@ -32,8 +33,6 @@ class TwitterUrl extends BaseService {
},
]
static _cacheLength = 86400
static defaultBadgeData = {
namedLogo: 'twitter',
}
@@ -52,19 +51,8 @@ class TwitterUrl extends BaseService {
}
}
/*
This badge is unusual.
const schema = Joi.any()
We don't usually host badges that don't show any dynamic information.
Also when an upstream API is removed, we usually deprecate/remove badges
according to the process in
https://github.com/badges/shields/blob/master/doc/deprecating-badges.md
In the case of twitter, we decided to provide a static fallback instead
due to how widely used the badge was. See
https://github.com/badges/shields/issues/8837
for related discussion.
*/
class TwitterFollow extends BaseJsonService {
static category = 'social'
@@ -77,40 +65,51 @@ class TwitterFollow extends BaseJsonService {
{
title: 'Twitter Follow',
namedParams: {
user: 'shields_io',
user: 'espadrine',
},
queryParams: { label: 'Follow' },
// hard code the static preview
// because link[] is not allowed in examples
staticPreview: {
label: 'Follow @shields_io',
message: '',
label: 'Follow',
message: '393',
style: 'social',
},
},
]
static _cacheLength = 86400
static defaultBadgeData = {
namedLogo: 'twitter',
}
static render({ user }) {
static render({ user, followers }) {
return {
label: `follow @${user}`,
message: '',
message: metric(followers),
style: 'social',
link: [
`https://twitter.com/intent/follow?screen_name=${encodeURIComponent(
user
)}`,
`https://twitter.com/${encodeURIComponent(user)}/followers`,
],
}
}
async fetch({ user }) {
return this._requestJson({
schema,
url: 'http://cdn.syndication.twimg.com/widgets/followbutton/info.json',
options: { searchParams: { screen_names: user } },
})
}
async handle({ user }) {
return this.constructor.render({ user })
const data = await this.fetch({ user })
if (!Array.isArray(data) || data.length === 0) {
throw new NotFound({ prettyMessage: 'invalid user' })
}
return this.constructor.render({ user, followers: data[0].followers_count })
}
}

View File

@@ -1,3 +1,4 @@
import { isMetric } from '../test-validators.js'
import { ServiceTester } from '../tester.js'
export const t = new ServiceTester({
@@ -9,8 +10,25 @@ t.create('Followers')
.get('/follow/shields_io.json')
.expectBadge({
label: 'follow @shields_io',
message: '',
link: ['https://twitter.com/intent/follow?screen_name=shields_io'],
message: isMetric,
link: [
'https://twitter.com/intent/follow?screen_name=shields_io',
'https://twitter.com/shields_io/followers',
],
})
t.create('Invalid Username Specified (non-existent user)')
.get('/follow/invalidusernamethatshouldnotexist.json?label=Follow')
.expectBadge({
label: 'Follow',
message: 'invalid user',
})
t.create('Invalid Username Specified (only spaces)')
.get('/follow/%20%20.json?label=Follow')
.expectBadge({
label: 'Follow',
message: 'invalid user',
})
t.create('URL')

View File

@@ -1,49 +0,0 @@
import Joi from 'joi'
import { ConditionalGithubAuthV3Service } from '../github/github-auth-service.js'
import { fetchJsonFromRepo } from '../github/github-common-fetch.js'
import { renderVersionBadge } from '../version.js'
import { NotFound } from '../index.js'
const vcpkgManifestSchema = Joi.object({
version: Joi.string().required(),
}).required()
export default class VcpkgVersion extends ConditionalGithubAuthV3Service {
static category = 'version'
static route = { base: 'vcpkg/v', pattern: ':portName' }
static examples = [
{
title: 'Vcpkg',
namedParams: { portName: 'entt' },
staticPreview: this.render({ version: '3.11.1' }),
},
]
static defaultBadgeData = { label: 'vcpkg' }
static render({ version }) {
return renderVersionBadge({ version })
}
async handle({ portName }) {
try {
const { version } = await fetchJsonFromRepo(this, {
schema: vcpkgManifestSchema,
user: 'microsoft',
repo: 'vcpkg',
branch: 'master',
filename: `ports/${portName}/vcpkg.json`,
})
return this.constructor.render({ version })
} catch (error) {
if (error instanceof NotFound) {
throw new NotFound({
prettyMessage: 'port not found',
})
}
throw error
}
}
}

View File

@@ -1,16 +0,0 @@
import { isSemver } from '../test-validators.js'
import { createServiceTester } from '../tester.js'
export const t = await createServiceTester()
t.create('gets the port version of entt')
.get('/entt.json')
.expectBadge({ label: 'vcpkg', message: isSemver })
t.create('returns not found for invalid port')
.get('/this-port-does-not-exist.json')
.expectBadge({
label: 'vcpkg',
color: 'red',
message: 'port not found',
})

View File

@@ -102,10 +102,11 @@ const documentation = `
This badge relies on the <a target="_blank" href="https://validator.nu/">https://validator.nu/</a> service to perform the validation.
Please refer to <a target="_blank" href="https://about.validator.nu/">https://about.validator.nu/</a> for the full documentation and Terms of service.
The following are required from the consumer for the badge to function.
<ul class="note">
<li>
Path:
<ul>
<ul>
<li>
parser: The parser that is used for validation. This is a passthru value to the service
<ul>
@@ -114,8 +115,8 @@ const documentation = `
<li>xml <i>(XML; dont load external entities)</i></li>
<li>xmldtd <i>(XML; load external entities)</i></li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li>
Query string:
@@ -139,7 +140,7 @@ const documentation = `
<li>SVG 1.1, URL, XHTML, MathML 3.0</li>
</ul>
</li>
</ul>
</ul>
</li>
</ul>
</p>

View File

@@ -13,6 +13,7 @@ const documentation = `
<li><code>ParserFunctions</code></li>
<li><code>parserFunctions</code></li>
</ul>
However, the following are invalid:
<ul>
<li><code>parserfunctions</code></li>