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