diff --git a/services/polymart/polymart-base.js b/services/polymart/polymart-base.js
new file mode 100644
index 0000000000000..9eec32824368c
--- /dev/null
+++ b/services/polymart/polymart-base.js
@@ -0,0 +1,51 @@
+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 = `
+
You can find your resource ID in the url for your resource page.
+Example: https://polymart.org/resource/polymart-plugin.323
- Here the Resource ID is 323.
`
+
+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 }
diff --git a/services/polymart/polymart-downloads.service.js b/services/polymart/polymart-downloads.service.js
new file mode 100644
index 0000000000000..fe2d4f641ccea
--- /dev/null
+++ b/services/polymart/polymart-downloads.service.js
@@ -0,0 +1,35 @@
+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 })
+ }
+}
diff --git a/services/polymart/polymart-downloads.tester.js b/services/polymart/polymart-downloads.tester.js
new file mode 100644
index 0000000000000..4bc7f3c356646
--- /dev/null
+++ b/services/polymart/polymart-downloads.tester.js
@@ -0,0 +1,13 @@
+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',
+})
diff --git a/services/polymart/polymart-latest-version.service.js b/services/polymart/polymart-latest-version.service.js
new file mode 100644
index 0000000000000..aa6083374e049
--- /dev/null
+++ b/services/polymart/polymart-latest-version.service.js
@@ -0,0 +1,38 @@
+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,
+ })
+ }
+}
diff --git a/services/polymart/polymart-latest-version.tester.js b/services/polymart/polymart-latest-version.tester.js
new file mode 100644
index 0000000000000..bff2a8e3c37f6
--- /dev/null
+++ b/services/polymart/polymart-latest-version.tester.js
@@ -0,0 +1,13 @@
+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',
+})
diff --git a/services/polymart/polymart-rating.service.js b/services/polymart/polymart-rating.service.js
new file mode 100644
index 0000000000000..fa067c5861d60
--- /dev/null
+++ b/services/polymart/polymart-rating.service.js
@@ -0,0 +1,65 @@
+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),
+ })
+ }
+}
diff --git a/services/polymart/polymart-rating.tester.js b/services/polymart/polymart-rating.tester.js
new file mode 100644
index 0000000000000..547d0d988f6c3
--- /dev/null
+++ b/services/polymart/polymart-rating.tester.js
@@ -0,0 +1,27 @@
+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',
+})