From 734e6d390e250ee63de764b7a7766c2b549eca5b Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 6 Aug 2026 13:38:14 +0200 Subject: [PATCH 01/10] Feature catalog: one file, Edge/Hub/Fleet availability badges The site carried two catalogs. src/_data/featureCatalog.yaml had the docs links, changelog links, solutions and subfeature structure, but its tier axis was Cloud/Self-Hosted x Enterprise, which predates the Edge/Hub/Fleet repackaging. nuxt/content/feature-catalog.yml had the right plans but none of the metadata. Only a minority of feature ids overlapped. Merge them into nuxt/content/feature-catalog.yml and delete the legacy file. Features that exist only to carry a changelog or docs link are marked showOnPricing: false, so the pricing page renders exactly the rows it rendered before, in the same order, with the same tier values. Badges now name the plans a feature is included in rather than a tier that no longer exists. They come back on changelog entries and, more importantly, on docs pages: the docs badge injection had been silently dead since the docs moved to Nuxt, because the Eleventy transform still wrote HTML into nuxt/public/ where the Nuxt route shadows it. Those transforms are removed along with the orphaned tier-badges include. A feature with no settled availability can omit tiers entirely and publishes no badge; a schema refine keeps that from leaking onto the pricing page. The release blog features: injection was dead for the same reason and is not rebuilt here. The handbook now says so rather than describing automation that does not run. --- .eleventy.js | 330 ------- nuxt/components/ChangelogListItem.vue | 3 + nuxt/components/ChangelogTierBadges.vue | 28 - nuxt/components/FeatureTierBadges.vue | 29 + nuxt/components/content/FeatureCatalog.vue | 131 ++- nuxt/composables/useFeatureCatalog.ts | 80 +- nuxt/content.config.ts | 21 +- nuxt/content/feature-catalog.yml | 251 +++++ .../handbook/engineering/product/features.md | 4 +- .../engineering/releases/release-blogs.md | 4 +- .../engineering/releases/writing-changelog.md | 12 +- nuxt/lib/feature-catalog.mjs | 80 ++ nuxt/lib/feature-catalog.test.mjs | 131 +++ nuxt/pages/changelog/[...slug].vue | 3 + nuxt/pages/docs/[...slug].vue | 4 + nuxt/pages/pricing/index.vue | 10 +- src/_data/featureCatalog.yaml | 859 ------------------ src/_includes/components/tier-badges.njk | 28 - src/css/style.css | 33 +- 19 files changed, 644 insertions(+), 1397 deletions(-) delete mode 100644 nuxt/components/ChangelogTierBadges.vue create mode 100644 nuxt/components/FeatureTierBadges.vue create mode 100644 nuxt/lib/feature-catalog.mjs create mode 100644 nuxt/lib/feature-catalog.test.mjs delete mode 100644 src/_data/featureCatalog.yaml delete mode 100644 src/_includes/components/tier-badges.njk diff --git a/.eleventy.js b/.eleventy.js index 961d0fd542..d86f68034f 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -594,336 +594,6 @@ module.exports = function(eleventyConfig) { eleventyConfig.addShortcode("year", () => `${new Date().getFullYear()}`); - // Feature catalog helpers for tier badges - const featureCatalog = yaml.load(fs.readFileSync("./src/_data/featureCatalog.yaml", "utf8")); - - function changelogTitle(url) { - const slug = url.replace(/\/$/, '').split('/').pop(); - const parts = url.replace(/\/$/, '').split('/').filter(Boolean); - // url: /changelog/2026/02/slug/ -> src/changelog/2026/02/slug.md - const filePath = path.join("./src", parts.join('/') + '.md'); - try { - const content = fs.readFileSync(filePath, 'utf8'); - const match = content.match(/^---[\s\S]*?title:\s*["']?(.+?)["']?\s*$/m); - if (match) return match[1]; - } catch (e) { /* file not found, fall back */ } - return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - } - - function findFeatureById(id) { - for (const section of featureCatalog.sections) { - for (const feature of section.features) { - if (feature.id === id) return feature; - } - } - return null; - } - - function getChangelogUrls(feature) { - if (!feature.changelog) return []; - const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog]; - return entries.map(entry => typeof entry === 'string' ? entry : entry.url); - } - - function getChangelogUrlsForRelease(feature, release) { - if (!feature.changelog) return []; - const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog]; - return entries - .filter(entry => typeof entry === 'object' && entry.release === release) - .map(entry => entry.url); - } - - function deriveTierLabel(tierData) { - if (!tierData) return null; - const enterprise = tierData.enterprise && tierData.enterprise.value; - const enterpriseDimmed = tierData.enterprise && tierData.enterprise.dimmed; - if (enterprise === 'contact' || (typeof enterprise === 'string' && enterprise.toLowerCase().includes('contact'))) return "Enterprise (contact us)"; - if (enterpriseDimmed) return "Enterprise (on request)"; - if (enterprise === 'time') return "Coming soon"; - if (enterprise) return "Enterprise"; - return "Not available"; - } - - function renderTierBadges(feature) { - if (!feature) return ''; - const cloudLabel = deriveTierLabel(feature.cloud); - const selfHostedLabel = deriveTierLabel(feature.selfHosted); - const showCloud = cloudLabel && cloudLabel !== 'Not available'; - const showSelfHosted = selfHostedLabel && selfHostedLabel !== 'Not available'; - if (!showCloud && !showSelfHosted) return ''; - let html = `
`; - if (showCloud) { - html += `
`; - html += `Cloud`; - html += `${cloudLabel}`; - html += `
`; - } - if (showSelfHosted) { - html += `
`; - html += `Self-Hosted`; - html += `${selfHostedLabel}`; - html += `
`; - } - html += '
'; - return html; - } - - function renderChangelogLinks(urls) { - if (!urls || urls.length === 0) return ''; - let html = ''; - return html; - } - - function renderDocsLink(feature) { - if (!feature || !feature.docsLink) return ''; - const label = feature.label || 'Documentation'; - return ``; - } - - // Inject tier badges, changelog links, and a docs link into release blog posts based on frontmatter - eleventyConfig.addTransform("releaseFeatures", function(content) { - if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content; - - // Transforms don't have access to template data, so parse frontmatter from source - const inputPath = this.page.inputPath; - if (!inputPath || !inputPath.endsWith('.md')) return content; - - let frontmatter; - try { - const source = fs.readFileSync(inputPath, 'utf8'); - const fmMatch = source.match(/^---\n([\s\S]*?)\n---/); - if (!fmMatch) return content; - frontmatter = yaml.load(fmMatch[1]); - } catch (e) { return content; } - - const features = frontmatter.features; - const release = frontmatter.release; - if (!release || !features || !Array.isArray(features) || features.length === 0) return content; - - // Build injection map: heading text -> { badges HTML, changelogs HTML } - const injections = []; - for (const entry of features) { - let badges = ''; - let changelogs = ''; - let docs = ''; - - if (entry.id) { - // Feature from featureCatalog - const feature = findFeatureById(entry.id); - if (!feature) continue; - badges = renderTierBadges(feature); - const changelogUrls = release ? getChangelogUrlsForRelease(feature, release) : getChangelogUrls(feature); - changelogs = renderChangelogLinks(changelogUrls); - docs = renderDocsLink(feature); - } else if (entry.tiers) { - // Inline tier specification (no feature ID) - const inlineFeature = {}; - if (entry.tiers.cloud) { - // Convert shorthand to tier structure - inlineFeature.cloud = { - enterprise: { value: true } - }; - } - if (entry.tiers.selfHosted) { - inlineFeature.selfHosted = { - enterprise: { value: true } - }; - } - badges = renderTierBadges(inlineFeature); - } - - if (badges || changelogs || docs) { - // Docs link sits on its own line below the changelog line - injections.push({ heading: entry.heading, badges, related: changelogs + docs }); - } - } - - if (injections.length === 0) return content; - - // Find all headings (h2-h6) in the HTML with their positions - const headingRegex = /]*>.*?<\/h\1>/gs; - const headingMatches = []; - let match; - while ((match = headingRegex.exec(content)) !== null) { - // Extract text content from heading (strip HTML tags) - const textContent = match[0].replace(/<[^>]+>/g, '').trim(); - headingMatches.push({ index: match.index, length: match[0].length, text: textContent, level: parseInt(match[1]) }); - } - - // Process injections in reverse order so indices stay valid - const ops = []; // { index, html } — insert html at index - - for (const injection of injections) { - // Find matching heading - const headingIdx = headingMatches.findIndex(h => h.text === injection.heading); - if (headingIdx === -1) continue; - - const heading = headingMatches[headingIdx]; - - // Insert badges right after the heading tag, adding heading-level class for spacing - if (injection.badges) { - const badgesWithLevel = injection.badges.replace('class="ff-tier-badges"', `class="ff-tier-badges ff-tier-badges--h${heading.level}"`); - ops.push({ index: heading.index + heading.length, html: badgesWithLevel }); - } - - // Insert changelog + docs links before the next heading at the same or higher level - // H2 links go before the next H2; H3 links go before the next H2 or H3 - if (injection.related) { - const nextPeer = headingMatches.find((h, i) => i > headingIdx && h.level <= heading.level); - const insertBefore = nextPeer ? nextPeer.index : content.length; - ops.push({ index: insertBefore, html: injection.related }); - } - } - - // Sort by index descending so we can splice without shifting - ops.sort((a, b) => b.index - a.index); - for (const op of ops) { - content = content.slice(0, op.index) + op.html + content.slice(op.index); - } - - return content; - }); - - function findFeatureByDocsLink(pageUrl) { - if (!pageUrl) return null; - const normalizedPage = pageUrl.replace(/\/$/, '') + '/'; - for (const section of featureCatalog.sections) { - for (const feature of section.features) { - if (!feature.docsLink || feature.subfeature) continue; - let link = feature.docsLink; - // Strip full domain if present - link = link.replace(/^https?:\/\/flowfuse\.com/, ''); - // Strip fragment - link = link.replace(/#.*$/, ''); - const normalizedLink = link.replace(/\/$/, '') + '/'; - if (normalizedPage === normalizedLink) return feature; - } - } - return null; - } - - function findSubfeaturesForDocsPage(pageUrl) { - if (!pageUrl) return []; - const normalizedPage = pageUrl.replace(/\/$/, '') + '/'; - const results = []; - for (const section of featureCatalog.sections) { - for (const feature of section.features) { - if (!feature.docsLink || !feature.subfeature) continue; - let link = feature.docsLink; - link = link.replace(/^https?:\/\/flowfuse\.com/, ''); - const fragment = (link.match(/#(.+)/) || [])[1]; - if (!fragment) continue; - const linkPath = link.replace(/#.*/, '').replace(/\/$/, '') + '/'; - if (normalizedPage === linkPath) { - results.push({ feature, fragment }); - } - } - } - return results; - } - - // Inject tier badges into node-red pages: parent feature after H1, subfeatures after their headings - eleventyConfig.addTransform("docsFeatureBadges", function(content) { - if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content; - if (!this.page.url || !/^\/node-red\//.test(this.page.url)) return content; - - const parentFeature = findFeatureByDocsLink(this.page.url); - const subfeatures = findSubfeaturesForDocsPage(this.page.url); - - // Parse frontmatter for features array — but skip pages with `release` (handled by releaseFeatures) - let fmFeatures = []; - const inputPath = this.page.inputPath; - if (inputPath && inputPath.endsWith('.md')) { - try { - const source = fs.readFileSync(inputPath, 'utf8'); - const fmMatch = source.match(/^---\n([\s\S]*?)\n---/); - if (fmMatch) { - const fm = yaml.load(fmMatch[1]); - if (fm.release) return content; - if (fm.features && Array.isArray(fm.features)) { - fmFeatures = fm.features; - } - } - } catch (e) { /* ignore */ } - } - - if (!parentFeature && subfeatures.length === 0 && fmFeatures.length === 0) return content; - - const ops = []; - - // Inject parent feature badges after the first H1 - if (parentFeature) { - const h1Regex = /]*>.*?<\/h1>/s; - const h1Match = h1Regex.exec(content); - if (h1Match) { - const badges = renderTierBadges(parentFeature); - if (badges) { - const wrapped = badges.replace('class="ff-tier-badges"', 'class="ff-tier-badges not-prose"'); - ops.push({ index: h1Match.index + h1Match[0].length, html: wrapped }); - } - } - } - - // Scan headings for subfeature and frontmatter-based injections - if (subfeatures.length > 0 || fmFeatures.length > 0) { - const headingRegex = /]*id="([^"]*)"[^>]*>.*?<\/h\1>/gs; - const headingMatches = []; - let hmatch; - while ((hmatch = headingRegex.exec(content)) !== null) { - const textContent = hmatch[0].replace(/<[^>]+>/g, '').trim(); - headingMatches.push({ index: hmatch.index, length: hmatch[0].length, id: hmatch[2], text: textContent, level: parseInt(hmatch[1]) }); - } - - // Frontmatter features take priority — track handled heading IDs - const handledHeadingIds = new Set(); - for (const entry of fmFeatures) { - if (!entry.id || !entry.heading) continue; - const feature = findFeatureById(entry.id); - if (!feature) continue; - const heading = headingMatches.find(h => h.text === entry.heading); - if (!heading) continue; - handledHeadingIds.add(heading.id); - const badges = renderTierBadges(feature); - if (badges) { - const wrapped = badges.replace('class="ff-tier-badges"', 'class="ff-tier-badges not-prose"'); - ops.push({ index: heading.index + heading.length, html: wrapped }); - } - } - - // Subfeatures matched by docsLink fragment (skip if frontmatter already handled) - for (const { feature, fragment } of subfeatures) { - if (handledHeadingIds.has(fragment)) continue; - const heading = headingMatches.find(h => h.id === fragment); - if (!heading) continue; - const badges = renderTierBadges(feature); - if (badges) { - const wrapped = badges.replace('class="ff-tier-badges"', 'class="ff-tier-badges not-prose"'); - ops.push({ index: heading.index + heading.length, html: wrapped }); - } - } - } - - ops.sort((a, b) => b.index - a.index); - for (const op of ops) { - content = content.slice(0, op.index) + op.html + content.slice(op.index); - } - return content; - }); - - eleventyConfig.addFilter("featureForDocsPage", function(url) { - return findFeatureByDocsLink(url); - }); - - eleventyConfig.addFilter("tierLabel", function(tierData) { - return deriveTierLabel(tierData); - }); - function loadSVG (file) { let relativeFilePath = `./src/_includes/components/icons/${file}.svg`; let data = fs.readFileSync(relativeFilePath, function(err, contents) { diff --git a/nuxt/components/ChangelogListItem.vue b/nuxt/components/ChangelogListItem.vue index 6dd6def902..e3e6097263 100644 --- a/nuxt/components/ChangelogListItem.vue +++ b/nuxt/components/ChangelogListItem.vue @@ -5,6 +5,8 @@ const props = defineProps<{ const authorNames = computed(() => useAuthorNames(props.entry.authors)) +const plans = useChangelogPlans(() => props.entry.path) + const formattedDate = computed(() => new Date(props.entry.date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' })) @@ -18,6 +20,7 @@ const formattedDate = computed(() => new Date(props.entry.date).toLocaleDateStri
{{ authorNames }}
+
diff --git a/nuxt/components/ChangelogTierBadges.vue b/nuxt/components/ChangelogTierBadges.vue deleted file mode 100644 index 16184062f2..0000000000 --- a/nuxt/components/ChangelogTierBadges.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/nuxt/components/FeatureTierBadges.vue b/nuxt/components/FeatureTierBadges.vue new file mode 100644 index 0000000000..4eca07cd41 --- /dev/null +++ b/nuxt/components/FeatureTierBadges.vue @@ -0,0 +1,29 @@ + + + diff --git a/nuxt/components/content/FeatureCatalog.vue b/nuxt/components/content/FeatureCatalog.vue index bf1dd17669..deb0ca780c 100644 --- a/nuxt/components/content/FeatureCatalog.vue +++ b/nuxt/components/content/FeatureCatalog.vue @@ -14,67 +14,65 @@
- - + + - - - + + + :class="i === 0 ? 'border-l-2 border-l-gray-300' : 'border-r border-r-gray-100'" + >{{ plan.label }} + - From c94b51de1ba093c88f07b35c5ef39b696b890c46 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 6 Aug 2026 14:54:28 +0200 Subject: [PATCH 05/10] Feature catalog: keep cell text at the normal colour The green background carries the scanning on its own. Tinting the check marks, dashes and TBD labels on top of it only made them harder to read. --- nuxt/components/content/FeatureCatalog.vue | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nuxt/components/content/FeatureCatalog.vue b/nuxt/components/content/FeatureCatalog.vue index 0a01416341..a7b100aa3e 100644 --- a/nuxt/components/content/FeatureCatalog.vue +++ b/nuxt/components/content/FeatureCatalog.vue @@ -60,9 +60,9 @@ feature.tiers?.[plan.id] ? 'bg-green-50' : '', ]" > - - - + + + From 3563dbc9034754c44818ee63d5508662595a9ac2 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 7 Aug 2026 14:04:58 +0200 Subject: [PATCH 06/10] Release blogs: pass the injected components to ContentRenderer Nuxt Content resolves a markdown component tag against a registry it builds while parsing, and reaches it through an async loader. The badge and changelog-link nodes are spliced in after parsing, so that path renders nothing in a production build even though it works in dev. Passing the components to ContentRenderer directly skips the registry. --- nuxt/pages/blog/[...slug].vue | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/nuxt/pages/blog/[...slug].vue b/nuxt/pages/blog/[...slug].vue index 2380ca4430..5fc34b19ea 100644 --- a/nuxt/pages/blog/[...slug].vue +++ b/nuxt/pages/blog/[...slug].vue @@ -1,5 +1,11 @@ diff --git a/nuxt/lib/feature-catalog.mjs b/nuxt/lib/feature-catalog.mjs index 2225a8cf07..b4955aa2fe 100644 --- a/nuxt/lib/feature-catalog.mjs +++ b/nuxt/lib/feature-catalog.mjs @@ -28,6 +28,22 @@ export function planHref (label) { return plan ? `/product/${plan.id}/` : null } +/** + * The badges to render for a list of plan labels: each label that names a plan, paired with + * the page it links to. + * + * A label naming no plan is dropped rather than kept as an unlinked badge. An unlinked badge + * reads the same as a real one, so keeping it would state availability on a plan that does + * not exist. Everything the catalog produces is a plan name, so this only bites hand written + * markup carrying a typo or a retired plan name, and there rendering nothing is the safer + * failure: a missing badge gets noticed and fixed, a wrong one gets believed. + */ +export function planBadges (labels) { + return (labels ?? []) + .map(plan => ({ plan, href: planHref(plan) })) + .filter(badge => badge.href) +} + /** * Reduce a site path to a comparable form: no origin, no fragment, exactly one trailing slash. * diff --git a/nuxt/lib/feature-catalog.test.mjs b/nuxt/lib/feature-catalog.test.mjs index fe29ee431e..3ab39b03d8 100644 --- a/nuxt/lib/feature-catalog.test.mjs +++ b/nuxt/lib/feature-catalog.test.mjs @@ -16,6 +16,7 @@ import { findFeatureByDocsPage, normalizePath, onPricing, + planBadges, planHref, planLabels, } from './feature-catalog.mjs' @@ -112,6 +113,25 @@ test('planHref returns nothing for a label that names no plan', () => { assert.equal(planHref(undefined), null) }) +test('planBadges pairs each plan label with the page it links to', () => { + assert.deepEqual(planBadges(['Edge', 'Fleet']), [ + { plan: 'Edge', href: '/product/edge/' }, + { plan: 'Fleet', href: '/product/fleet/' }, + ]) +}) + +// An unlinked badge reads the same as a real one, so a label naming no plan would state +// availability on a plan that does not exist. Hand written markup is the only way to get one. +test('planBadges drops a label that names no plan rather than rendering it unlinked', () => { + assert.deepEqual(planBadges(['Edge', 'Enterprise']), [{ plan: 'Edge', href: '/product/edge/' }]) + assert.deepEqual(planBadges(['Starter', 'Team']), []) +}) + +test('planBadges treats nothing to badge as nothing to render', () => { + assert.deepEqual(planBadges([]), []) + assert.deepEqual(planBadges(undefined), []) +}) + // The href is built from the plan id, so a plan whose product page is missing would badge a // 404. /product/[tier] resolves its page out of nuxt/content/products/. test('every plan a badge can link to has a product page', () => {
FeatureFlowFuse CloudSelf HostedSolutionsPlansSolutions
{{ h.label }}{{ solution.label }}