Repository: badges/shields (js)

Issue:
don't add v prefix to commit hash "version"s
### Discussed in https://github.com/badges/shields/discussions/10590

<div type='discussions-op-text'>

<sup>Originally posted by **ingenarel** October  6, 2024</sup>
here's what i mean:
i've made a scoop bucket that's aur -git package inspired. the version of a package is the git commit hash
![image](https://github.com/user-attachments/assets/f5d4d1d1-871f-45ed-be6c-945155a99284)

now the thing is the version says 1769fa..

but on the readme, i can see a `v` before it.
![image](https://github.com/user-attachments/assets/410273ac-41dd-443f-8cb0-ddc75e262680)

is there any way to not do that?</div>

Pull request description:
remove prefix v for commit hash version
ignore commit hash in addv

Fixes #10591 - see issue for more info

Patch:
diff --git a/migrations/1727809177709_add-created.cjs b/migrations/1727809177709_add-created.cjs
new file mode 100644
index 0000000000..ceafc9a897
--- /dev/null
+++ b/migrations/1727809177709_add-created.cjs
@@ -0,0 +1,15 @@
+exports.shorthands = undefined
+
+exports.up = pgm => {
+  pgm.addColumn('github_user_tokens', {
+    created: {
+      type: 'TIMESTAMP',
+      notNull: true,
+      default: pgm.func('CURRENT_TIMESTAMP'),
+    },
+  })
+}
+
+exports.down = pgm => {
+  pgm.dropColumn('github_user_tokens', 'created')
+}
diff --git a/services/dynamic/dynamic-xml.service.js b/services/dynamic/dynamic-xml.service.js
index bc24a20a87..8654863c6d 100644
--- a/services/dynamic/dynamic-xml.service.js
+++ b/services/dynamic/dynamic-xml.service.js
@@ -70,7 +70,7 @@ export default class DynamicXml extends BaseService {
 
   static defaultBadgeData = { label: 'custom badge' }
 
-  transform({ pathExpression, buffer }) {
+  transform({ pathExpression, buffer, contentType = 'text/xml' }) {
     // e.g. //book[2]/@id
     const pathIsAttr = (
       pathExpression.split('/').slice(-1)[0] || ''
@@ -78,14 +78,20 @@ export default class DynamicXml extends BaseService {
 
     let parsed
     try {
-      parsed = new DOMParser().parseFromString(buffer, 'text/xml')
+      parsed = new DOMParser().parseFromString(buffer, contentType)
     } catch (e) {
       throw new InvalidResponse({ prettyMessage: e.message })
     }
 
     let values
     try {
-      values = xpath.select(pathExpression, parsed)
+      if (contentType === 'text/html') {
+        values = xpath
+          .parse(pathExpression)
+          .select({ node: parsed, isHtml: true })
+      } else {
+        values = xpath.select(pathExpression, parsed)
+      }
     } catch (e) {
       throw new InvalidParameter({ prettyMessage: e.message })
     }
@@ -122,16 +128,25 @@ export default class DynamicXml extends BaseService {
   }
 
   async handle(_namedParams, { url, query: pathExpression, prefix, suffix }) {
-    const { buffer } = await this._request({
+    const { buffer, res } = await this._request({
       url,
       options: { headers: { Accept: 'application/xml, text/xml' } },
       httpErrors,
       logErrors: [],
     })
 
+    let contentType = 'text/xml'
+    if (
+      res.headers['content-type'] &&
+      res.headers['content-type'].includes('text/html')
+    ) {
+      contentType = 'text/html'
+    }
+
     const { values: value } = this.transform({
       pathExpression,
       buffer,
+      contentType,
     })
 
     return renderDynamicBadge({ value, prefix, suffix })
diff --git a/services/dynamic/dynamic-xml.spec.js b/services/dynamic/dynamic-xml.spec.js
index e239545656..64d2509c85 100644
--- a/services/dynamic/dynamic-xml.spec.js
+++ b/services/dynamic/dynamic-xml.spec.js
@@ -20,6 +20,29 @@ const exampleXml = `<?xml version="1.0"?>
 </catalog>
 `
 
+const exampleHtml = `<!DOCTYPE html>
+<html>
+  <head>
+  </head>
+  <body>
+      <h1>Herman Melville - Moby-Dick</h1>
+      <div>
+        <p>
+          Availing himself of the mild, summer-cool weather that now reigned in these
+          latitudes, and in preparation for the peculiarly active pursuits shortly to
+          be anticipated, Perth, the begrimed, blistered old blacksmith, had not
+          removed his portable forge to the hold again, after concluding his
+          contributory work for Ahab's leg, but still retained it on deck, fast lashed
+          to ringbolts by the foremast; being now almost incessantly invoked by the
+          headsmen, and harpooneers, and bowsmen to do some little job for them;
+          altering, or repairing, or new shaping their various weapons and boat
+          furniture.
+        </p>
+      </div>
+  </body>
+</html>
+`
+
 describe('DynamicXml', function () {
   describe('transform()', function () {
     beforeEach(function () {
@@ -126,5 +149,12 @@ describe('DynamicXml', function () {
     }).expect({
       values: ["XML Developer's Guide", '44.95'],
     })
+    given({
+      pathExpression: '//h1[1]',
+      buffer: exampleHtml,
+      contentType: 'text/html',
+    }).expect({
+      values: ['Herman Melville - Moby-Dick'],
+    })
   })
 })
diff --git a/services/maven-central/maven-central-base.js b/services/maven-central/maven-central-base.js
new file mode 100644
index 0000000000..c5bd1489f4
--- /dev/null
+++ b/services/maven-central/maven-central-base.js
@@ -0,0 +1,13 @@
+import { BaseXmlService } from '../index.js'
+
+export default class MavenCentralBase extends BaseXmlService {
+  async fetch({ groupId, artifactId, schema }) {
+    const group = encodeURIComponent(groupId).replace(/\./g, '/')
+    const artifact = encodeURIComponent(artifactId)
+    return this._requestXml({
+      schema,
+      url: `https://repo1.maven.org/maven2/${group}/${artifact}/maven-metadata.xml`,
+      httpErrors: { 404: 'artifact not found' },
+    })
+  }
+}
diff --git a/services/maven-central/maven-central-last-update.service.js b/services/maven-central/maven-central-last-update.service.js
new file mode 100644
index 0000000000..86f48d301d
--- /dev/null
+++ b/services/maven-central/maven-central-last-update.service.js
@@ -0,0 +1,66 @@
+import Joi from 'joi'
+import customParseFormat from 'dayjs/plugin/customParseFormat.js'
+import dayjs from 'dayjs'
+import { InvalidResponse, pathParams } from '../index.js'
+import { nonNegativeInteger } from '../validators.js'
+import { formatDate } from '../text-formatters.js'
+import { age as ageColor } from '../color-formatters.js'
+import MavenCentralBase from './maven-central-base.js'
+dayjs.extend(customParseFormat)
+
+const updateResponseSchema = Joi.object({
+  metadata: Joi.object({
+    versioning: Joi.object({
+      lastUpdated: nonNegativeInteger,
+    }).required(),
+  }).required(),
+}).required()
+
+export default class MavenCentralLastUpdate extends MavenCentralBase {
+  static category = 'activity'
+
+  static route = {
+    base: 'maven-central/last-update',
+    pattern: ':groupId/:artifactId',
+  }
+
+  static openApi = {
+    '/maven-central/last-update/{groupId}/{artifactId}': {
+      get: {
+        summary: 'Maven Central Last Update',
+        parameters: pathParams(
+          { name: 'groupId', example: 'com.google.guava' },
+          { name: 'artifactId', example: 'guava' },
+        ),
+      },
+    },
+  }
+
+  static defaultBadgeData = { label: 'last updated' }
+
+  static render({ date }) {
+    return {
+      message: formatDate(date),
+      color: ageColor(date),
+    }
+  }
+
+  async handle({ groupId, artifactId }) {
+    const { metadata } = await this.fetch({
+      groupId,
+      artifactId,
+      schema: updateResponseSchema,
+    })
+
+    const date = dayjs(
+      String(metadata.versioning.lastUpdated),
+      'YYYYMMDDHHmmss',
+    )
+
+    if (!date.isValid) {
+      throw new InvalidResponse({ prettyMessage: 'invalid date' })
+    }
+
+    return this.constructor.render({ date })
+  }
+}
diff --git a/services/text-formatters.js b/services/text-formatters.js
index a1eb171005..185170e023 100644
--- a/services/text-formatters.js
+++ b/services/text-formatters.js
@@ -127,11 +127,12 @@ function omitv(version) {
   return version
 }
 
-const ignoredVersionPatterns = /^[^0-9]|[0-9]{4}-[0-9]{2}-[0-9]{2}/
+const ignoredVersionPatterns =
+  /^[^0-9]|[0-9]{4}-[0-9]{2}-[0-9]{2}|^[a-f0-9]{7,40}$/
 
 /**
- * Add a starting v to the version unless it doesn't starts with a digit or is a date (yyyy-mm-dd)
- * For example, addv("1.2.3") returns "v1.2.3", but addv("hello") or addv("2021-10-31"), returns "hello" and "2021-10-31" respectively.
+ * Add a starting v to the version unless it doesn't starts with a digit, is a date (yyyy-mm-dd), or is a commit hash.
+ * For example, addv("1.2.3") returns "v1.2.3", but addv("hello"), addv("2021-10-31"), addv("abcdef1"), returns "hello", "2021-10-31", and "abcdef1" respectively.
  *
  * @param {string} version - Version string
  * @returns {string} Version string with the starting v
diff --git a/services/text-formatters.spec.js b/services/text-formatters.spec.js
index 5985a4a465..8dedef13a3 100644
--- a/services/text-formatters.spec.js
+++ b/services/text-formatters.spec.js
@@ -96,6 +96,10 @@ describe('Text formatters', function () {
     given('v0.6').expect('v0.6')
     given('hello').expect('hello')
     given('2017-05-05-Release-2.3.17').expect('2017-05-05-Release-2.3.17')
+    given('5aa272da7924fa76581fd5ea83b24cfbb3528b8a').expect(
+      '5aa272da7924fa76581fd5ea83b24cfbb3528b8a',
+    )
+    given('5aa272da79').expect('5aa272da79')
   })
 
   test(maybePluralize, () => {


Test patch:
diff --git a/services/dynamic/dynamic-xml.tester.js b/services/dynamic/dynamic-xml.tester.js
index ffaaa2ebb9..ac51f4c1b8 100644
--- a/services/dynamic/dynamic-xml.tester.js
+++ b/services/dynamic/dynamic-xml.tester.js
@@ -215,3 +215,16 @@ t.create('query with type conversion to number')
     message: '44.95',
     color: 'blue',
   })
+
+t.create('query HTML document')
+  .get(
+    `.json?${queryString.stringify({
+      url: 'https://httpbin.org/html',
+      query: '//h1[1]',
+    })}`,
+  )
+  .expectBadge({
+    label: 'custom badge',
+    message: 'Herman Melville - Moby-Dick',
+    color: 'blue',
+  })
diff --git a/services/maven-central/maven-central-last-update.tester.js b/services/maven-central/maven-central-last-update.tester.js
new file mode 100644
index 0000000000..46fe38b7f2
--- /dev/null
+++ b/services/maven-central/maven-central-last-update.tester.js
@@ -0,0 +1,15 @@
+import { isFormattedDate } from '../test-validators.js'
+import { createServiceTester } from '../tester.js'
+export const t = await createServiceTester()
+
+t.create('last update date').get('/com.google.guava/guava.json').expectBadge({
+  label: 'last updated',
+  message: isFormattedDate,
+})
+
+t.create('last update when artifact not found')
+  .get('/com.fail.test/this-does-not-exist.json')
+  .expectBadge({
+    label: 'last updated',
+    message: 'artifact not found',
+  })