From 689d45dfbb41ddf7d48d1f7ea70c671b8122200e Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 20 Mar 2026 20:07:00 +0100 Subject: [PATCH 1/3] Add pricing tier badges to docs feature pages Wire up featureCatalog.yaml to the documentation layout so that docs pages matching a feature's docsLink automatically display Cloud/Self-Hosted tier availability badges at the top of the page. Changes: - Add findFeatureByDocsLink() helper and featureForDocsPage filter in .eleventy.js to match page URLs against docsLink values - Update documentation.njk layout to render tier-badges component when a matching feature is found - Populate docsLink for 11 features that have corresponding docs pages: RBAC, Custom Hostnames, Persistent Context, MQTT Broker, DevOps Pipelines, Device Groups, High Availability, Tables, Team Library, Audit Log, SSO --- .eleventy.js | 22 ++++++++++++++++++++++ src/_data/featureCatalog.yaml | 22 +++++++++++----------- src/_includes/layouts/documentation.njk | 8 ++++++++ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/.eleventy.js b/.eleventy.js index ff6620584f..010ebfe40c 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -872,11 +872,33 @@ module.exports = function(eleventyConfig) { 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; + } + // Make helpers available to changelog layout via filters eleventyConfig.addFilter("featureForChangelog", function(url) { return findFeatureByChangelog(url); }); + eleventyConfig.addFilter("featureForDocsPage", function(url) { + return findFeatureByDocsLink(url); + }); + eleventyConfig.addFilter("tierLabel", function(tierData) { return deriveTierLabel(tierData); }); diff --git a/src/_data/featureCatalog.yaml b/src/_data/featureCatalog.yaml index eb408ab57b..7c43eeccdf 100644 --- a/src/_data/featureCatalog.yaml +++ b/src/_data/featureCatalog.yaml @@ -304,7 +304,7 @@ sections: - id: custom-hostname label: Custom Hostnames description: "Access your Node-RED application via your own domain name." - docsLink: null + docsLink: /docs/user/custom-hostnames/ changelog: null solutions: [] showOnPricing: true @@ -343,7 +343,7 @@ sections: - id: persistent-context label: Persistent Context description: "In-memory values defined in a Node-RED flow persist across project restarts and upgrades." - docsLink: null + docsLink: /docs/user/persistent-context/ changelog: null solutions: [mes, data-integration] showOnPricing: true @@ -385,7 +385,7 @@ sections: - id: mqtt-broker label: MQTT Broker description: "Manage and create MQTT clients to transport data for efficient messaging and communication within your applications." - docsLink: null + docsLink: /docs/user/teambroker/ changelog: null solutions: [uns, it-ot-middleware, data-integration, mes, scada] showOnPricing: true @@ -480,7 +480,7 @@ sections: - id: pipelines label: DevOps Pipelines description: "Set up different environments for development, testing, and production Node-RED instances to support a full software delivery lifecycle." - docsLink: null + docsLink: /docs/user/devops-pipelines/ changelog: null solutions: [mes, scada, uns, it-ot-middleware, data-integration] showOnPricing: true @@ -549,7 +549,7 @@ sections: - id: device-groups label: Device Group Management description: "Logically group devices assigned to an application and integrate device groups into your DevOps Pipeline for coordinated fleet updates." - docsLink: null + docsLink: /docs/user/device-groups/ changelog: null solutions: [mes, scada, edge-connectivity] showOnPricing: true @@ -595,7 +595,7 @@ sections: - id: ha label: High Availability description: "Leverage horizontal scaling for reliable and scalable processing of your data through Node-RED." - docsLink: null + docsLink: /docs/user/high-availability/ changelog: null solutions: [mes, scada, uns] showOnPricing: true @@ -641,7 +641,7 @@ sections: - id: tables label: FlowFuse Tables description: "Integrated database feature for storing, reading, writing, and querying data within FlowFuse." - docsLink: null + docsLink: /docs/user/ff-tables/ changelog: null solutions: [mes, scada, data-integration] showOnPricing: true @@ -667,7 +667,7 @@ sections: - id: audit-log label: Audit Log description: "Keep track of everything going on in your Node-RED instances and FlowFuse. Audit Logs provide details on what actions have taken place, when they happened, and who did them." - docsLink: null + docsLink: /docs/user/logs/ changelog: null solutions: [mes, scada, uns, it-ot-middleware] showOnPricing: true @@ -690,7 +690,7 @@ sections: - id: rbac label: Role-Based Access Control description: "Intuitive team management tooling makes it easy to control who has access to what across your FlowFuse organisation." - docsLink: null + docsLink: /docs/user/role-based-access-control/ changelog: null solutions: [mes, scada, uns, it-ot-middleware, data-integration] showOnPricing: true @@ -811,7 +811,7 @@ sections: - id: sso label: Single Sign-On (SSO) description: "Configure FlowFuse to work with your own SSO provider, allowing users to access FlowFuse with a single set of login credentials." - docsLink: null + docsLink: /docs/admin/sso/ changelog: null solutions: [mes, scada, uns, it-ot-middleware] showOnPricing: true @@ -968,7 +968,7 @@ sections: - id: team-library label: Team Library description: "Set up standard nodes and flows that can be shared with all team members across your organisation." - docsLink: null + docsLink: /docs/user/shared-library/ changelog: null solutions: [mes, scada, uns] showOnPricing: true diff --git a/src/_includes/layouts/documentation.njk b/src/_includes/layouts/documentation.njk index 35e86bb932..ab29d7cb2a 100644 --- a/src/_includes/layouts/documentation.njk +++ b/src/_includes/layouts/documentation.njk @@ -29,6 +29,14 @@ date: git Last Modified
+ {% set catalogFeature = page.url | featureForDocsPage %} + {% if catalogFeature %} + {% set tierCloud = catalogFeature.cloud | tierLabel %} + {% set tierSelfHosted = catalogFeature.selfHosted | tierLabel %} +
+ {% include "components/tier-badges.njk" %} +
+ {% endif %}
{{ content | rewriteHandbookLinks(page) | safe }}
From 53dd1a8ed2323647eb133a8e2079907452c80cbd Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 20 Mar 2026 20:30:19 +0100 Subject: [PATCH 2/3] Add pricing tier badges to docs feature pages Wire up featureCatalog.yaml to docs pages via an Eleventy transform that injects tier availability badges: - Parent features: badges injected after the H1 heading - Subfeatures: badges injected after their matching heading, identified by the URL fragment in docsLink (e.g. #application-level-rbac) Implementation: - findFeatureByDocsLink() matches page URL to parent features - findSubfeaturesForDocsPage() finds subfeatures whose docsLink path matches the page and extracts the fragment for heading matching - docsFeatureBadges transform scans headings by id attribute and injects renderTierBadges() HTML with not-prose class to escape Tailwind Typography styling - Reuses existing tier-badges CSS and tierLabel/renderTierBadges helpers from changelog feature Data changes: - Add rbac-application subfeature (Enterprise only) with docsLink fragment pointing to #application-level-rbac - Populate docsLink for 11 features: RBAC, Custom Hostnames, Persistent Context, MQTT Broker, DevOps Pipelines, Device Groups, High Availability, Tables, Team Library, Audit Log, SSO --- .eleventy.js | 70 +++++++++++++++++++++++++ src/_data/featureCatalog.yaml | 25 +++++++++ src/_includes/layouts/documentation.njk | 8 --- 3 files changed, 95 insertions(+), 8 deletions(-) diff --git a/.eleventy.js b/.eleventy.js index 010ebfe40c..5b7fbb21e9 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -890,6 +890,76 @@ module.exports = function(eleventyConfig) { 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 docs 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; + + const parentFeature = findFeatureByDocsLink(this.page.url); + const subfeatures = findSubfeaturesForDocsPage(this.page.url); + if (!parentFeature && subfeatures.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 }); + } + } + } + + // Inject subfeature badges after their matching headings + if (subfeatures.length > 0) { + const headingRegex = /]*id="([^"]*)"[^>]*>.*?<\/h\1>/gs; + const headingMatches = []; + let hmatch; + while ((hmatch = headingRegex.exec(content)) !== null) { + headingMatches.push({ index: hmatch.index, length: hmatch[0].length, id: hmatch[2], level: parseInt(hmatch[1]) }); + } + + for (const { feature, fragment } of subfeatures) { + 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; + }); + // Make helpers available to changelog layout via filters eleventyConfig.addFilter("featureForChangelog", function(url) { return findFeatureByChangelog(url); diff --git a/src/_data/featureCatalog.yaml b/src/_data/featureCatalog.yaml index 7c43eeccdf..15229f0756 100644 --- a/src/_data/featureCatalog.yaml +++ b/src/_data/featureCatalog.yaml @@ -692,6 +692,7 @@ sections: description: "Intuitive team management tooling makes it easy to control who has access to what across your FlowFuse organisation." docsLink: /docs/user/role-based-access-control/ changelog: null + subfeature: false solutions: [mes, scada, uns, it-ot-middleware, data-integration] showOnPricing: true tags: [cloud, self-hosted] @@ -710,6 +711,30 @@ sections: enterprise: value: true + - id: rbac-application + label: Application-Level RBAC + description: "Fine-grained access control per application, allowing team members to have different permission levels across different applications without requiring separate teams." + docsLink: /docs/user/role-based-access-control/#application-level-rbac + changelog: null + subfeature: true + solutions: [mes, scada, uns, it-ot-middleware, data-integration] + showOnPricing: true + tags: [cloud, self-hosted] + cloud: + starter: + value: null + pro: + value: null + enterprise: + value: true + selfHosted: + starter: + value: null + pro: + value: null + enterprise: + value: true + - id: security label: Endpoint Security description: "Secure HTTP endpoints for hosted Node-RED instances using FlowFuse credentials." diff --git a/src/_includes/layouts/documentation.njk b/src/_includes/layouts/documentation.njk index ab29d7cb2a..35e86bb932 100644 --- a/src/_includes/layouts/documentation.njk +++ b/src/_includes/layouts/documentation.njk @@ -29,14 +29,6 @@ date: git Last Modified
- {% set catalogFeature = page.url | featureForDocsPage %} - {% if catalogFeature %} - {% set tierCloud = catalogFeature.cloud | tierLabel %} - {% set tierSelfHosted = catalogFeature.selfHosted | tierLabel %} -
- {% include "components/tier-badges.njk" %} -
- {% endif %}
{{ content | rewriteHandbookLinks(page) | safe }}
From 1725fe563d70fbe4b76b949f1b4f28382fbc4689 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 20 Mar 2026 20:48:53 +0100 Subject: [PATCH 3/3] Support frontmatter-based tier badges on docs pages Extend the docsFeatureBadges transform to support a features array in docs page frontmatter (same format as changelog posts): features: - id: rbac heading: "Team-Level RBAC" - id: rbac-application heading: "Application-Level RBAC" This keeps badge config co-located with the content it references in the FlowFuse docs repo, so heading renames don't silently break badges. - Frontmatter features are matched by heading text and looked up in featureCatalog by id - Frontmatter takes priority: if a heading is handled by frontmatter, the subfeature docsLink fragment injection is skipped (dedup) - The releaseFeatures transform now requires the release field, preventing it from processing docs pages with features frontmatter - docsLink fragments on subfeatures still work as "Learn more" links and as a fallback when no frontmatter is present --- .eleventy.js | 46 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 41 insertions(+), 5 deletions(-) diff --git a/.eleventy.js b/.eleventy.js index 5b7fbb21e9..9f0a0eb150 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -783,7 +783,7 @@ module.exports = function(eleventyConfig) { const features = frontmatter.features; const release = frontmatter.release; - if (!features || !Array.isArray(features) || features.length === 0) return content; + if (!release || !features || !Array.isArray(features) || features.length === 0) return content; // Build injection map: heading text -> { badges HTML, changelogs HTML } const injections = []; @@ -916,7 +916,24 @@ module.exports = function(eleventyConfig) { const parentFeature = findFeatureByDocsLink(this.page.url); const subfeatures = findSubfeaturesForDocsPage(this.page.url); - if (!parentFeature && subfeatures.length === 0) return content; + + // Parse frontmatter for features array (same format as changelog posts) + 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.features && Array.isArray(fm.features)) { + fmFeatures = fm.features; + } + } + } catch (e) { /* ignore */ } + } + + if (!parentFeature && subfeatures.length === 0 && fmFeatures.length === 0) return content; const ops = []; @@ -933,16 +950,35 @@ module.exports = function(eleventyConfig) { } } - // Inject subfeature badges after their matching headings - if (subfeatures.length > 0) { + // 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) { - headingMatches.push({ index: hmatch.index, length: hmatch[0].length, id: hmatch[2], level: parseInt(hmatch[1]) }); + 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);