diff --git a/.github/workflows/validate-build.yml b/.github/workflows/validate-build.yml
index cf8c3729..6293337c 100644
--- a/.github/workflows/validate-build.yml
+++ b/.github/workflows/validate-build.yml
@@ -95,6 +95,8 @@ jobs:
run: npm run test:hero-scroll
- name: Test docs page weight (lazy images, trimmed Prism, on-demand highlighting, head)
run: npm run test:docs-page-weight
+ - name: Test the introduced-in version badge
+ run: npm run test:introduced-in-badge
- name: Test the Ask AI latest-version tool
run: npm run test:latest-version-tool
- name: Cache generated files
diff --git a/package.json b/package.json
index 6a907c5f..878a0336 100644
--- a/package.json
+++ b/package.json
@@ -97,11 +97,12 @@
"test:hero-scroll": "node --test tests/hero-scroll/*.test.js",
"test:docs-page-weight": "node --test tests/docs-page-weight/*.test.js",
"test:callout-preservation": "npx gulp generate:prism && node tests/callout-preservation/test-runner.js",
- "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:latest-version-tool && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:anon-quota && npm run test:callout-preservation",
+ "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache && npm run test:head-meta && npm run test:latest-version-tool && npm run test:introduced-in-badge && npm run test:property-tooltips && npm run test:signin-nudge && npm run test:chat-panel-navigation && npm run test:toc-collapsible && npm run test:toc-active-anchor && npm run test:nav-scroll && npm run test:kapa-source-groups && npm run test:tooltip-touch && npm run test:tooltip-single-open && npm run test:lazy-askai && npm run test:nav-lazy-buckets && npm run test:has-code-blocks && npm run test:docs-page-weight && npm run test:anon-quota && npm run test:callout-preservation",
"build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .",
"copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/",
"serve:playground": "npx serve .",
"test:toc-active-anchor": "node --test tests/toc-active-anchor/*.test.js",
+ "test:introduced-in-badge": "node --test tests/introduced-in-badge/*.test.js",
"test:latest-version-tool": "node --test tests/latest-version-tool/*.test.js"
},
"dependencies": {
diff --git a/preview-src/introduced-in-cloud-test.adoc b/preview-src/introduced-in-cloud-test.adoc
new file mode 100644
index 00000000..a8e9f455
--- /dev/null
+++ b/preview-src/introduced-in-cloud-test.adoc
@@ -0,0 +1,10 @@
+= Introduced-In Badge on Cloud
+:page-cloud: true
+:page-introduced-in: 4.43.0
+:description: This page checks that the version badge never shows on Cloud pages.
+
+This page sets `:page-cloud: true` and `:page-introduced-in: 4.43.0`.
+
+== What to check
+
+* The sticky header shows no "Since" badge. A self-managed version doesn't apply to Cloud, so the badge is hidden on Cloud pages even when a single-sourced page carries the attribute.
diff --git a/preview-src/introduced-in-combo-test.adoc b/preview-src/introduced-in-combo-test.adoc
new file mode 100644
index 00000000..7f071c71
--- /dev/null
+++ b/preview-src/introduced-in-combo-test.adoc
@@ -0,0 +1,14 @@
+= Introduced-In Badge With Other Badges
+:page-beta: true
+:page-limited-availability: true
+:page-introduced-in: v4.43.0
+:page-introduced-in-product: Redpanda Connect
+:description: This page tests the version badge next to the Beta and Limited Availability badges.
+
+This page sets `:page-beta:`, `:page-limited-availability:`, `:page-introduced-in: v4.43.0` and `:page-introduced-in-product: Redpanda Connect`.
+
+== What to check
+
+* The sticky header shows the Beta, Limited Availability and "Since 4.43.0" badges in that order, and wraps cleanly at narrow widths.
+* The leading `v` is stripped: the badge reads "Since 4.43.0", not "Since v4.43.0".
+* Hovering the version badge shows the tooltip "Introduced in Redpanda Connect 4.43.0."
diff --git a/preview-src/introduced-in-test.adoc b/preview-src/introduced-in-test.adoc
new file mode 100644
index 00000000..77da9098
--- /dev/null
+++ b/preview-src/introduced-in-test.adoc
@@ -0,0 +1,30 @@
+= Introduced-In Badge Test Page
+:page-introduced-in: 4.43.0
+:description: This page tests the version badge that shows the release a feature was introduced in.
+
+This page sets `:page-introduced-in: 4.43.0`, so the sticky header shows a gray "Since 4.43.0" badge.
+
+== What to check
+
+* The "Since 4.43.0" badge appears in the sticky header.
+* Hovering the badge shows the tooltip "Introduced in version 4.43.0."
+* The badge is readable in both light and dark themes.
+* Pages that don't set `:page-introduced-in:`, such as the Limited Availability Test page, show no version badge.
+* The Introduced-In Badge With Other Badges page shows the badge next to Beta and Limited Availability, with the product named in the tooltip.
+* The Introduced-In Badge on Cloud page sets `:page-introduced-in:` but shows no version badge.
+
+== Page content
+
+Some content so you can scroll and check that the badge stays in the sticky header.
+
+=== Subsection 1
+
+Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.
+
+=== Subsection 2
+
+Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
+
+=== Subsection 3
+
+Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
diff --git a/preview-src/ui-model.yml b/preview-src/ui-model.yml
index 9e7f50bb..32adcbff 100644
--- a/preview-src/ui-model.yml
+++ b/preview-src/ui-model.yml
@@ -240,6 +240,15 @@ page:
- content: Limited Availability Test
url: /limited-availability-test.html
urlType: internal
+ - content: Introduced-In Badge Test
+ url: /introduced-in-test.html
+ urlType: internal
+ - content: Introduced-In Badge With Other Badges
+ url: /introduced-in-combo-test.html
+ urlType: internal
+ - content: Introduced-In Badge on Cloud
+ url: /introduced-in-cloud-test.html
+ urlType: internal
- content: Preview Status Test
url: /preview-status-test.html
urlType: internal
diff --git a/src/css/doc.css b/src/css/doc.css
index a648ae26..a9d616a5 100644
--- a/src/css/doc.css
+++ b/src/css/doc.css
@@ -439,7 +439,7 @@ html[data-theme="dark"] .component-indicator-sticky .sm-ver-pill-caret {
}
}
-/* Status badges (Beta, Limited Availability) in sticky header */
+/* Status badges (Beta, Preview, Limited Availability, Since) in sticky header */
.status-badge {
display: inline-flex;
align-items: center;
@@ -485,7 +485,9 @@ html[data-theme="dark"] .status-badge--beta {
border-color: rgba(251, 191, 36, 0.3);
}
-html[data-theme="dark"] .status-badge--preview {
+/* Since shares the neutral dark palette with Preview */
+html[data-theme="dark"] .status-badge--preview,
+html[data-theme="dark"] .status-badge--since {
background: rgba(148, 163, 184, 0.15);
color: #cbd5e1;
border-color: rgba(148, 163, 184, 0.3);
@@ -511,6 +513,26 @@ html[data-theme="dark"] .status-badge--cloud {
border-color: rgba(14, 165, 233, 0.3);
}
+.status-badge--since {
+ background: #f3f4f6;
+ color: #374151;
+ border: 1px solid #d1d5db;
+ font-weight: 500;
+}
+
+/* Full badge text for screen readers; the visible "Since X" is aria-hidden */
+.status-badge__sr-text {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
/* Availability badges: links to the other platform's version of this page */
a.status-badge--cloud-link,
a.status-badge--self-managed-link {
diff --git a/src/helpers/normalize-version.js b/src/helpers/normalize-version.js
new file mode 100644
index 00000000..c9fb51f3
--- /dev/null
+++ b/src/helpers/normalize-version.js
@@ -0,0 +1,9 @@
+'use strict'
+
+// Normalizes a hand-set version string for display: trims whitespace, drops a
+// leading "v" (v4.43.0 -> 4.43.0) and any trailing periods, so a value of
+// "4.43.0." doesn't print "4.43.0.." in a sentence. Returns '' for no value.
+module.exports = (val) => {
+ if (val == null || typeof val === 'object') return ''
+ return String(val).trim().replace(/^v(?=\d)/i, '').replace(/\.+$/, '')
+}
diff --git a/src/partials/article.hbs b/src/partials/article.hbs
index 94bd7dc4..bd69162d 100644
--- a/src/partials/article.hbs
+++ b/src/partials/article.hbs
@@ -24,7 +24,7 @@
{{/with}}
{{> version-selector}}
- {{!-- Status badges: Beta, Preview, Limited Availability --}}
+ {{!-- Status badges: Beta, Preview, Limited Availability, Since (version) --}}
{{#if page.attributes.beta}}
beta
{{/if}}
@@ -39,6 +39,19 @@
Limited Availability
{{/if}}
+ {{!-- Version badge: the release that introduced the page's feature.
+ :page-introduced-in: takes a bare X.Y.Z (a leading v or trailing period
+ is stripped). :page-introduced-in-product: names the product in the
+ tooltip, for example "Redpanda Connect"; without it the tooltip says
+ "version". Never shown on Cloud pages: a self-managed version doesn't
+ apply there, even if a single-sourced page carries the attribute. --}}
+ {{#if (and page.attributes.introduced-in (not page.attributes.cloud))}}
+ {{#with (normalize-version page.attributes.introduced-in)}}
+ {{!-- One source for the tooltip and the screen reader text, so they can't drift --}}
+ {{#*inline "introduced-in-text"}}Introduced in {{#if @root.page.attributes.introduced-in-product}}{{@root.page.attributes.introduced-in-product}}{{else}}version{{/if}} {{this}}.{{/inline}}
+ Since {{this}}{{> introduced-in-text}}
+ {{/with}}
+ {{/if}}
{{!-- ADP platform badges: only show when limited to one platform --}}
{{#if page.attributes.adp}}
{{#if page.attributes.byoc-only}}
diff --git a/tests/introduced-in-badge/introduced-in-badge.test.js b/tests/introduced-in-badge/introduced-in-badge.test.js
new file mode 100644
index 00000000..bbab7a68
--- /dev/null
+++ b/tests/introduced-in-badge/introduced-in-badge.test.js
@@ -0,0 +1,82 @@
+'use strict'
+
+// Verifies the "Since X" version badge in src/partials/article.hbs, rendered
+// with the REAL helpers this repo ships (and.js, not.js, normalize-version.js).
+// The badge block is sliced out of the partial, so a change to the template is
+// what gets tested, not a copy of it.
+
+const test = require('node:test')
+const assert = require('node:assert/strict')
+const path = require('node:path')
+const fs = require('node:fs')
+const Handlebars = require('handlebars')
+
+const ROOT = path.join(__dirname, '..', '..')
+const hbs = Handlebars.create()
+hbs.registerHelper('and', require(path.join(ROOT, 'src/helpers/and.js')))
+hbs.registerHelper('not', require(path.join(ROOT, 'src/helpers/not.js')))
+hbs.registerHelper('normalize-version', require(path.join(ROOT, 'src/helpers/normalize-version.js')))
+
+const ARTICLE_SRC = fs.readFileSync(path.join(ROOT, 'src/partials/article.hbs'), 'utf8')
+const FROM = '{{!-- Status badges:'
+const TO = '{{!-- ADP platform badges'
+const start = ARTICLE_SRC.indexOf(FROM)
+const end = ARTICLE_SRC.indexOf(TO)
+assert.ok(start >= 0 && end > start, `article.hbs changed under this test: could not find ${FROM} .. ${TO}`)
+const BADGES = hbs.compile(ARTICLE_SRC.slice(start, end))
+
+const render = (attributes) => BADGES({ page: { attributes } })
+const sinceBadges = (html) => html.match(/status-badge--since/g) || []
+const tooltip = (html) => (html.match(/status-badge--since" data-tippy-content="([^"]*)"/) || [])[1]
+const visible = (html) => (html.match(/([^<]*)<\/span>/) || [])[1]
+const srText = (html) => (html.match(/([^<]*)<\/span>/) || [])[1]
+
+test('renders the badge when the page sets introduced-in', () => {
+ const html = render({ 'introduced-in': '4.43.0' })
+ assert.equal(sinceBadges(html).length, 1)
+ assert.equal(visible(html), 'Since 4.43.0')
+ assert.equal(tooltip(html), 'Introduced in version 4.43.0.')
+ assert.equal(srText(html), tooltip(html), 'screen reader text matches the tooltip')
+})
+
+test('renders nothing when introduced-in is absent or empty', () => {
+ assert.equal(sinceBadges(render({})).length, 0)
+ assert.equal(sinceBadges(render({ 'introduced-in': '' })).length, 0)
+ const blank = render({ 'introduced-in': ' ' })
+ assert.equal(sinceBadges(blank).length, 0, 'a value that normalizes to nothing renders nothing')
+})
+
+test('never renders on Cloud pages', () => {
+ const html = render({ 'introduced-in': '4.43.0', cloud: 'true' })
+ assert.equal(sinceBadges(html).length, 0)
+})
+
+test('names the product in the tooltip and screen reader text when introduced-in-product is set', () => {
+ const html = render({ 'introduced-in': '4.43.0', 'introduced-in-product': 'Redpanda Connect' })
+ assert.equal(visible(html), 'Since 4.43.0')
+ assert.equal(tooltip(html), 'Introduced in Redpanda Connect 4.43.0.')
+ assert.equal(srText(html), tooltip(html), 'screen reader text matches the tooltip')
+})
+
+test('normalizes a leading v and trailing periods', () => {
+ for (const raw of ['v4.43.0', 'V4.43.0', '4.43.0.', ' v4.43.0.. ']) {
+ const html = render({ 'introduced-in': raw })
+ assert.equal(visible(html), 'Since 4.43.0', raw)
+ assert.equal(tooltip(html), 'Introduced in version 4.43.0.', raw)
+ assert.equal(srText(html), tooltip(html), raw)
+ }
+})
+
+test('escapes attribute values', () => {
+ const html = render({ 'introduced-in': '1.0">