diff --git a/.eleventy.js b/.eleventy.js
index ff6620584f..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 = [];
@@ -872,11 +872,139 @@ 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;
+ }
+
+ 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);
+
+ // 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 = [];
+
+ // 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;
+ });
+
// 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 4a2513fa5e..55a1dbbffd 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
@@ -836,7 +836,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
@@ -993,7 +993,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