diff --git a/.eleventy.js b/.eleventy.js
index 560761d6c9..01105ef345 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 @@
-
-
-
-
-
- Cloud
- {{ cloud }}
-
-
- Self-Hosted
- {{ selfHosted }}
-
-
-
diff --git a/nuxt/components/content/FeatureCatalog.vue b/nuxt/components/content/FeatureCatalog.vue
index bf1dd17669..a7b100aa3e 100644
--- a/nuxt/components/content/FeatureCatalog.vue
+++ b/nuxt/components/content/FeatureCatalog.vue
@@ -14,67 +14,56 @@
-
-
+
+
| Feature |
- FlowFuse Cloud |
- Self Hosted |
- Solutions |
+ Plans |
| {{ h.label }} |
+ :class="i === 0 ? 'border-l-2 border-l-gray-300' : 'border-r border-r-gray-100'"
+ >{{ plan.label }}
-
+
- | {{ section.label }} |
+ {{ section.title }} |
- └{{ feature.label }}
- {{ feature.label }}
+ └{{ feature.title }}
+ {{ feature.title }}
Beta
+ Not on pricing
{{ feature.description }}
Docs
-
- Changelog ({{ cl.release }})
+
+ Changelog ({{ entry.release }})
|
-
-
-
- ✓
- On request
- {{ (cellOf(feature, group, tier).options || []).join(', ') }}
- {{ cellOf(feature, group, tier).value }}
- –
- {{ cellOf(feature, group, tier).note }}
- |
-
- N/A |
-
-
+
{{ feature.solutions && feature.solutions.includes(sol) ? '✓' : '–' }} |
+ v-for="(plan, i) in PLANS"
+ :key="'plan-' + plan.id"
+ class="py-3 px-4 align-top text-center border-r border-gray-100"
+ :class="[
+ i === 0 ? 'border-l-2 border-l-gray-300' : '',
+ feature.tiers?.[plan.id] ? 'bg-green-50' : '',
+ ]"
+ >
+ TBD
+ ✓
+ –
+
@@ -84,56 +73,27 @@
@@ -150,9 +110,6 @@ onUnmounted(() => {
.ff-fc-fullscreen :deep(table) {
min-width: 100%;
}
-.ff-fc-toolbar {
- /* in fullscreen, keep the close button reachable */
-}
.ff-fc-fullscreen .ff-fc-toolbar {
position: sticky;
top: 0;
diff --git a/nuxt/components/content/FeatureReleaseLinks.vue b/nuxt/components/content/FeatureReleaseLinks.vue
new file mode 100644
index 0000000000..72919fa21c
--- /dev/null
+++ b/nuxt/components/content/FeatureReleaseLinks.vue
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Changelog:
+
+ | {{ entry.label }}
+
+
+
+ Docs: {{ props.docs.label }}
+
+
+
diff --git a/nuxt/components/content/FeatureTierBadges.vue b/nuxt/components/content/FeatureTierBadges.vue
new file mode 100644
index 0000000000..bc52acd6a1
--- /dev/null
+++ b/nuxt/components/content/FeatureTierBadges.vue
@@ -0,0 +1,47 @@
+
+
+
+
+ Available in
+ {{ badge.plan }}
+
+
diff --git a/nuxt/composables/useFeatureCatalog.ts b/nuxt/composables/useFeatureCatalog.ts
index 84afbb8d48..42af2cc782 100644
--- a/nuxt/composables/useFeatureCatalog.ts
+++ b/nuxt/composables/useFeatureCatalog.ts
@@ -1,50 +1,82 @@
-import { parse as parseYaml } from 'yaml'
-import featureCatalogRaw from '../../src/_data/featureCatalog.yaml?raw'
+// The lookups live in nuxt/lib/ as plain JS so `node --test` can run them directly
+// (same reason as docs-nav.mjs); this file is the typed surface components import.
+// @ts-ignore untyped module
+import { featurePlanLabels, findFeatureByChangelog, findFeatureByDocsPage } from '../lib/feature-catalog.mjs'
+// @ts-ignore untyped module
+import { injectReleaseFeatures, resolveReleaseFeatures } from '../lib/release-features.mjs'
-interface TierValue {
- value?: boolean | string | null
- dimmed?: boolean
-}
-
-interface FeatureTier {
- enterprise?: TierValue
+export interface FeatureTiers {
+ edge: boolean
+ hub: boolean
+ fleet: boolean
}
export interface CatalogFeature {
id: string
- cloud?: FeatureTier
- selfHosted?: FeatureTier
- changelog?: string | { url: string, release?: string } | Array
+ title: string
+ description?: string
+ docsLink?: string
+ changelog?: Array<{ url: string, release?: string }>
+ subfeature?: boolean
+ beta?: boolean
+ showOnPricing?: boolean
+ tiers?: FeatureTiers
}
-// `yaml`'s parse() is a pure parser (no arbitrary type construction); featureCatalog.yaml is trusted in-repo data.
-const featureCatalog = parseYaml(featureCatalogRaw) || { sections: [] }
-
-function allFeatures(): CatalogFeature[] {
- return (featureCatalog.sections || []).flatMap((section: any) => section.features || [])
+/**
+ * The whole catalog. Every caller shares the `featureCatalog` key, so the pricing page and
+ * a page full of changelog rows all read one fetch.
+ */
+export function useFeatureCatalog () {
+ const { data } = useAsyncData('featureCatalog', () => queryCollection('featureCatalog').first())
+ return data
}
-function changelogUrls(feature: CatalogFeature): string[] {
- if (!feature.changelog) return []
- const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog]
- return entries.map(entry => typeof entry === 'string' ? entry : entry.url)
+/**
+ * The plans that include the feature a changelog post shipped.
+ *
+ * Empty whenever the post is not tied to a catalog feature, or that feature's availability
+ * has not been settled. Both mean "publish no badge" rather than "publish an empty one".
+ */
+export function useChangelogPlans (path: MaybeRefOrGetter) {
+ const catalog = useFeatureCatalog()
+ return computed(() => featurePlanLabels(findFeatureByChangelog(catalog.value, toValue(path))))
}
-export function findFeatureByChangelog(changelogUrl: string): CatalogFeature | null {
- const normalized = changelogUrl.replace(/\/$/, '') + '/'
- for (const feature of allFeatures()) {
- if (changelogUrls(feature).some(url => (url.replace(/\/$/, '') + '/') === normalized)) return feature
- }
- return null
+/**
+ * The plans that include the feature a docs page documents. Empty on the many docs pages
+ * that are not tied to a catalog feature, and on those whose availability is unsettled.
+ */
+export function useDocsPlans (path: MaybeRefOrGetter) {
+ const catalog = useFeatureCatalog()
+ return computed(() => featurePlanLabels(findFeatureByDocsPage(catalog.value, toValue(path))))
}
-export function deriveTierLabel(tierData?: FeatureTier): string | null {
- if (!tierData) return null
- const enterprise = tierData.enterprise?.value
- const enterpriseDimmed = 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'
+/**
+ * A release blog with its `features:` frontmatter rendered: plan badges under each named
+ * heading, changelog and docs links at the end of that section.
+ *
+ * Returns the page unchanged for every post without a `release` and `features:` block, which
+ * is all of them except the release blogs.
+ */
+export function useReleaseFeaturePage (page: MaybeRefOrGetter) {
+ const catalog = useFeatureCatalog()
+
+ // Titles for the changelog links. Metadata only, and shared with the changelog pages by key.
+ const { data: changelogPosts } = useAsyncData(
+ 'changelog-titles',
+ () => queryCollection('changelog').select('path', 'title').all(),
+ )
+ const changelogTitles = computed>(() =>
+ Object.fromEntries((changelogPosts.value ?? []).map(post => [`${post.path.replace(/\/+$/, '')}/`, post.title])),
+ )
+
+ return computed(() => {
+ const value = toValue(page)
+ if (!value?.release || !value?.features?.length || !value?.body?.value) return value
+
+ const resolved = resolveReleaseFeatures(value.features, catalog.value, value.release, changelogTitles.value)
+ const body = injectReleaseFeatures(value.body.value, resolved)
+ return body === value.body.value ? value : { ...value, body: { ...value.body, value: body } }
+ })
}
diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts
index 261bf3a85c..6cba515b45 100644
--- a/nuxt/content.config.ts
+++ b/nuxt/content.config.ts
@@ -119,6 +119,21 @@ export default defineContentConfig({
image: z.string().optional(),
video: z.string().optional(),
tags: z.array(z.string()).optional(),
+ // Release blogs only. Read by nuxt/lib/release-features.mjs to hang plan
+ // badges and changelog links off the matching section heading. Undeclared
+ // keys are stripped by @nuxt/content, so both have to be listed here.
+ release: z.string().optional(),
+ features: z.array(z.object({
+ heading: z.string(),
+ // A string, or several ids when one heading covers several catalog features.
+ id: z.union([z.string(), z.array(z.string())]).optional(),
+ // For a section that is not a catalog feature at all, e.g. "What else is new?".
+ tiers: z.object({
+ edge: z.boolean(),
+ hub: z.boolean(),
+ fleet: z.boolean(),
+ }).optional(),
+ })).optional(),
tldr: z.union([z.string(), z.array(z.string())]).optional(),
cta: z.object({
type: z.string().optional(),
@@ -258,12 +273,28 @@ export default defineContentConfig({
title: z.string(),
note: z.string().optional(),
description: z.string().optional(),
+ docsLink: z.string().optional(),
+ changelog: z.array(z.object({
+ url: z.string(),
+ release: z.string().optional(),
+ })).optional(),
+ subfeature: z.boolean().optional(),
+ beta: z.boolean().optional(),
+ // Defaults to true. False keeps the feature off /pricing while it
+ // still carries a changelog or docs link for the badge lookups.
+ showOnPricing: z.boolean().optional(),
+ // Optional so a feature whose availability is not settled yet can
+ // omit it and publish no badge at all. Everything /pricing renders
+ // must have it, which the refine below enforces.
tiers: z.object({
edge: z.boolean(),
hub: z.boolean(),
fleet: z.boolean(),
- }),
- })),
+ }).optional(),
+ }).refine(
+ feature => feature.showOnPricing === false || !!feature.tiers,
+ { message: 'tiers is required unless showOnPricing is false', path: ['tiers'] },
+ )),
})),
})
}),
diff --git a/nuxt/content/feature-catalog.yml b/nuxt/content/feature-catalog.yml
index c58aab51a1..043b9f695c 100644
--- a/nuxt/content/feature-catalog.yml
+++ b/nuxt/content/feature-catalog.yml
@@ -1,3 +1,24 @@
+# The single feature catalog. Consumed by:
+# /pricing - plan cards, comparison table
+# /handbook/engineering/product/features - the full internal table
+# /changelog/* - plan availability badges
+# /docs/* - plan availability badges (matched on docsLink)
+#
+# Sections and the titles, descriptions and tiers of everything that reaches /pricing are
+# owned by the pricing page. Features that no longer appear there keep their entry with
+# showOnPricing: false so the handbook table, docs badges and changelog links still resolve.
+#
+# Fields:
+# tiers which plans include the feature. Required unless showOnPricing is false.
+# Omit entirely when availability is not settled yet: no tiers means no badge
+# is published, which is safer than publishing a wrong one. Set them on an
+# off-pricing feature too, so its docs and changelog badges still state where
+# it is available.
+# showOnPricing false keeps the feature out of /pricing while still letting it carry a
+# changelog or docs link, and badge them. Defaults to true.
+# subfeature renders indented under the preceding feature in the handbook table.
+# changelog every changelog post that shipped this feature, oldest first.
+# docsLink the canonical docs page. A fragment scopes it to a heading on that page.
sections:
- id: ai-automation
title: AI & Automation
@@ -5,10 +26,43 @@ sections:
- id: flowfuse-expert-ai
title: FlowFuse Expert AI
description: "Build industrial apps with agents, and query the state of the factory with an agent. Adapt your current hardware and machines so agentic work can be done against them, without ripping and replacing what's already on the plant floor."
+ docsLink: /docs/user/expert/
+ changelog:
+ - url: /changelog/2026/02/ff-expert-update-banner/
+ release: "2.28"
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: flowfuse-expert-support-mode
+ title: Support Mode
+ description: "Chat-based assistance for FlowFuse and Node-RED, including Node-RED instance management through natural language."
+ docsLink: /docs/user/expert/chat/#support-mode
+ changelog:
+ - url: /changelog/2026/02/ff-expert-debug-log-context/
+ release: "2.28"
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: flowfuse-expert-application-building
+ title: Application Building
+ description: "Describe what you want to build and FlowFuse Expert assembles it on your workspace, adding tabs, wiring nodes, and configuring properties."
+ docsLink: /docs/user/expert/chat/
+ changelog:
+ - url: /changelog/2026/05/expert-application-building/
+ release: "2.30"
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: flowfuse-expert-insights-mode
+ title: Insights Mode
+ description: "Connects FlowFuse Expert to MCP servers in your Node-RED instances, enabling real-time data queries and actions through a single chat interface."
+ docsLink: /docs/user/expert/chat/#insights-mode
+ subfeature: true
+ beta: true
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: mcp-servers
title: Agentic Operations
description: "Expose your Node-RED flows as tools an AI agent can call directly, so agents can query the state of the factory or trigger actions without custom integration work."
+ docsLink: /node-red/flowfuse/mcp/
tiers: { edge: true, hub: true, fleet: true }
- id: onnx-integration
title: ONNX Integration
@@ -21,28 +75,93 @@ sections:
- id: edge-development
title: Edge Development
description: "Develop and test Node-RED flows directly on edge devices with a remote editor proxy."
+ docsLink: /docs/device-agent/quickstart/
tiers: { edge: true, hub: false, fleet: true }
- id: private-npm-registry
title: Custom Node-RED Nodes
description: "Create and manage your own private npm registry for Node-RED nodes, so you can share custom nodes across your team and devices without publishing them publicly."
+ docsLink: /docs/user/custom-npm-packages/
tiers: { edge: true, hub: true, fleet: true }
- id: flowfuse-tables
title: FlowFuse Tables
description: "A managed PostgreSQL database for every application, so you can store and query structured data without standing up and maintaining your own database."
+ docsLink: /docs/user/ff-tables/
+ changelog:
+ - url: /changelog/2026/07/expert-tables-automation/
+ release: "2.33"
tiers: { edge: true, hub: true, fleet: true }
- id: persistent-files
title: File Storage
description: "Store and retrieve files from your Node-RED flows, with automatic replication and backup across your devices and hosted instances."
+ docsLink: /docs/install/file-storage/
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: persistent-context
+ title: Persistent Context
+ description: "In-memory values defined in a Node-RED flow persist across project restarts and upgrades."
+ docsLink: /docs/user/persistent-context/
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: static-assets
+ title: Static Assets
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: team-library
title: Team Library
+ description: "Set up standard nodes and flows that can be shared with all team members across your organisation."
+ docsLink: /docs/user/shared-library/
tiers: { edge: true, hub: true, fleet: true }
- id: personalised-multi-user-dashboards
title: Personalised Multi-User Dashboards
+ description: "Build applications that provide unique data to each logged-in user using personalised multi-user dashboards."
+ docsLink: https://dashboard.flowfuse.com/user/multi-tenancy.html
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: dashboards-view
+ title: Dashboards View
+ description: "Browse and open every dashboard across your team from a dedicated Dashboards view, at both team and application level, without leaving FlowFuse."
+ changelog:
+ - url: /changelog/2026/07/team-and-application-dashboards/
+ release: "2.33"
+ subfeature: true
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: blueprints-converge
title: Blueprints
description: "Ready-made starting points for your apps, from cross-team Converge templates to OT and IT specific blueprints."
+ docsLink: /docs/user/concepts/#blueprint
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: blueprints-ot-apps
+ title: Blueprints - OT APPS
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: false, fleet: true }
+ - id: blueprints-it-apps
+ title: Blueprints - IT APPS
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: false, hub: true, fleet: false }
+ - id: immersive-editor-snapshots
+ title: Snapshot Details in Immersive Editor
+ description: "View and manage snapshot details directly inside the immersive editor without leaving your editing session."
+ changelog:
+ - url: /changelog/2026/03/snapshot-detail-modal-immersive-editor/
+ release: "2.29"
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: immersive-editor-drawer
+ title: Customisable Immersive Editor Drawer
+ description: "Pin, move, resize, or full-screen the immersive editor drawer. Your preferences are remembered between sessions."
+ changelog:
+ - url: /changelog/2026/04/immersive-editor-drawer/
+ release: "2.30"
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: embedded-editor-tab-title
+ title: Embedded Editor Browser Tab Title
+ description: "The browser tab title updates to reflect the active Node-RED canvas tab when working in the embedded editor."
+ changelog:
+ - url: /changelog/2026/03/embedded-editor-tab-title/
+ release: "2.29"
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: deploy
@@ -50,22 +169,59 @@ sections:
features:
- id: hosted-instances
title: Cloud Instances
+ description: "Run Node-RED instances managed and hosted by FlowFuse."
+ docsLink: /docs/user/introduction/#creating-a-node-red-instance
tiers: { edge: true, hub: true, fleet: true }
- id: edge-devices
title: Edge Instances
description: "Deploy and mange your Node-RED instances on edge PLCs and gateways, with full visibility and control from the cloud."
+ docsLink: /docs/device-agent/introduction/
+ changelog:
+ - url: /changelog/2026/02/device-agent-nodejs-options/
+ release: "2.28"
tiers: { edge: true, hub: false, fleet: true }
- id: custom-hostnames
title: Custom Hostnames
+ description: "Access your Node-RED application via your own domain name."
+ docsLink: /docs/user/custom-hostnames/
tiers: { edge: false, hub: true, fleet: false }
- id: mqtt-broker
title: MQTT Broker
+ description: "Manage and create MQTT clients to transport data for efficient messaging and communication within your applications."
+ docsLink: /docs/user/teambroker/
tiers: { edge: true, hub: false, fleet: true }
+ - id: project-nodes
+ title: Project Nodes aka seamless project comms
+ description: "FlowFuse Project Nodes enable the passing of data and messages between your Node-RED projects."
+ docsLink: /docs/user/projectnodes/
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
- id: devops-pipelines
title: DevOps Pipelines
+ description: "Set up different environments for development, testing, and production Node-RED instances to support a full software delivery lifecycle."
+ docsLink: /docs/user/devops-pipelines/
tiers: { edge: true, hub: true, fleet: true }
- id: git-integration
title: Git Integration
+ description: "Back up your flows to a remote Git repository through a DevOps Pipeline. Supports GitHub and Azure DevOps repositories."
+ docsLink: /docs/user/devops-pipelines/#git-repository-stage
+ tiers: { edge: false, hub: true, fleet: false }
+ - id: git-integration-github
+ title: GitHub
+ description: "Push and pull snapshots to GitHub repositories through DevOps Pipeline Git Stages."
+ docsLink: /docs/user/devops-pipelines/#git-repository-stage
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: false, hub: true, fleet: false }
+ - id: git-integration-azure
+ title: Azure DevOps
+ description: "Push and pull snapshots to Azure DevOps repositories through DevOps Pipeline Git Stages."
+ docsLink: /docs/user/devops-pipelines/#git-repository-stage
+ changelog:
+ - url: /changelog/2026/03/azure-dev-ops-gitops/
+ release: "2.29"
+ subfeature: true
+ showOnPricing: false
tiers: { edge: false, hub: true, fleet: false }
- id: operate-maintain
@@ -74,23 +230,73 @@ sections:
- id: snapshots
title: Snapshots & Version History
description: "Automatic snapshots on remote devices and hosted instances, plus a full version history timeline so you can roll back to any prior state."
+ docsLink: /docs/user/snapshots/
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: auto-snapshot-remote
+ title: Auto Snapshot (Remote)
+ description: "Automatically capture a snapshot every time a remote instance is deployed, so you always have a recoverable history of what was running on each device."
+ docsLink: /docs/user/snapshots/#auto-snapshots
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: auto-snapshot-hosted
+ title: Auto Snapshot (Hosted)
+ description: "Automatically capture a snapshot every time a hosted instance is deployed, so you always have a recoverable history of what was running."
+ docsLink: /docs/user/snapshots/#auto-snapshots
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: snapshot-comparison
+ title: Snapshot Comparison
+ description: "Compare two snapshots side-by-side with a navigable diff view. Step through every changed, added, or deleted node and see property and code diffs."
+ changelog:
+ - url: /changelog/2026/04/snapshot-diff-viewer/
+ release: "2.29"
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: version-history-timeline
+ title: Version History Timeline
+ subfeature: true
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: unlimited-workflow-executions
title: Unlimited Workflow Executions
tiers: { edge: true, hub: true, fleet: true }
- id: device-fleet-updates
title: Device Fleet Updates
+ description: "Connect to edge devices to quickly assess and update logic. Debug one device and roll out improvements to your fleet in minutes, securely without requiring full device access for your whole organisation."
tiers: { edge: true, hub: false, fleet: true }
- id: device-group-management
title: Device Group Management
+ description: "Logically group devices assigned to an application and integrate device groups into your DevOps Pipeline for coordinated fleet updates."
+ docsLink: /docs/user/device-groups/
tiers: { edge: true, hub: false, fleet: true }
- id: high-availability
title: High Availability
+ description: "Leverage horizontal scaling for reliable and scalable processing of your data through Node-RED."
+ docsLink: /docs/user/high-availability/
tiers: { edge: false, hub: true, fleet: false }
- id: performance-monitoring
title: Performance Monitoring & Alerts
description: "Track CPU, memory, and event loop performance across your instances and devices, with email alerts when something needs your attention."
tiers: { edge: true, hub: true, fleet: true }
+ - id: instance-monitoring
+ title: Instance Monitoring
+ description: "Enable alerts to be sent via email when your Node-RED instances encounter issues."
+ docsLink: /docs/user/instance-settings/#alerts
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: email-alerts
+ title: Email Alerts
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: api-debug-length-limit
+ title: API/Debug Length Limit
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
- id: protected-instances
title: Protected Instances
tiers: { edge: false, hub: true, fleet: false }
@@ -100,13 +306,21 @@ sections:
features:
- id: single-sign-on
title: 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: /docs/admin/sso/
+ changelog:
+ - url: /changelog/2026/07/application-sso-groups/
+ release: "2.33"
tiers: { edge: true, hub: true, fleet: true }
- id: two-factor-authentication
title: Two-Factor Authentication
+ description: "Two-factor authentication adds an extra layer of security to your FlowFuse account."
+ docsLink: /docs/user/user-settings/#two-factor-authentication
tiers: { edge: true, hub: true, fleet: true }
- id: certified-nodes-it
title: Certified Nodes - IT
description: "IT certified node bundle includes: Redis, MQTT, HTTP Request, AI nodes (Gemini, Claude, ChatGPT, Ollama), MCP Server"
+ docsLink: /blog/2025/07/certified-nodes-v2/
tiers: { edge: false, hub: true, fleet: true }
- id: certified-nodes-ot
title: Certified OT Connections - OPC-UA, Modbus, etc.
@@ -114,13 +328,46 @@ sections:
tiers: { edge: true, hub: false, fleet: false }
- id: audit-log
title: 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: /docs/user/logs/#audit-log
tiers: { edge: true, hub: true, fleet: true }
- id: role-based-access-control
title: Role-Based Access Control
description: "Control who can do what at both the team and application level, from viewers to admins."
+ docsLink: /docs/user/role-based-access-control/
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: application-level-rbac
+ title: 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
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: scoped-personal-access-tokens
+ title: Scoped Personal Access Tokens
+ description: "Restrict a Personal Access Token to specific teams, limit it to read-only operations, or control whether it carries admin privileges."
+ changelog:
+ - url: /changelog/2026/07/scoped-pats/
+ release: "2.33"
+ subfeature: true
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: team-members
+ title: Team Members
+ description: "Invite multiple team members to collaborate on the same Node-RED flows."
+ docsLink: /docs/user/team/#teams
+ showOnPricing: false
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: endpoint-security
+ title: Endpoint Security
+ description: "Secure HTTP endpoints for hosted Node-RED instances using FlowFuse credentials."
+ docsLink: /docs/user/instance-settings/#security
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: baa-for-hipaa
title: BAA for HIPAA
+ description: "FlowFuse can sign a Business Associate Agreement to ensure proper safeguarding of protected health information handled on your behalf."
+ docsLink: /handbook/sales/subscription-agreement-1.5/
tiers: { edge: false, hub: true, fleet: false }
- id: sbom
title: Software Bill of Materials
@@ -132,7 +379,13 @@ sections:
features:
- id: installation-support
title: Installation Support
+ docsLink: /docs/install/introduction/#do-you-need-help-installation-service
+ tiers: { edge: true, hub: true, fleet: true }
+ - id: live-chat-support
+ title: Live Chat Support
+ showOnPricing: false
tiers: { edge: true, hub: true, fleet: true }
- id: enterprise-support
title: Enterprise Support
+ docsLink: /docs/premium-support/
tiers: { edge: true, hub: true, fleet: true }
diff --git a/nuxt/content/handbook/engineering/product/features.md b/nuxt/content/handbook/engineering/product/features.md
index 2c9a71aea4..3dcdba0527 100644
--- a/nuxt/content/handbook/engineering/product/features.md
+++ b/nuxt/content/handbook/engineering/product/features.md
@@ -4,7 +4,9 @@ title: "Feature Catalog"
# Feature Catalog
-Complete reference of all FlowFuse features across Cloud and Self-Hosted deployments. Not all features are shown on the public pricing page, this is the full list.
+Complete reference of every FlowFuse feature and the plans it is included in. Rows marked "Not on pricing" are deliberately kept off the public pricing page; they are in the catalog so their changelog and docs links resolve. A row showing "TBD" in the plan columns has no settled availability yet, so it publishes no badge anywhere.
+
+This table and the [pricing page](/pricing/) both read [`feature-catalog.yml`](https://github.com/FlowFuse/website/blob/main/nuxt/content/feature-catalog.yml), as do the availability badges on changelog posts, docs pages and release blogs.
::feature-catalog
::
diff --git a/nuxt/content/handbook/engineering/releases/release-blogs.md b/nuxt/content/handbook/engineering/releases/release-blogs.md
index b63da2fa70..f92a90415f 100644
--- a/nuxt/content/handbook/engineering/releases/release-blogs.md
+++ b/nuxt/content/handbook/engineering/releases/release-blogs.md
@@ -38,9 +38,25 @@ Every post requires the following fields:
| `image` | Path to the hero image. Coordinate with design. |
| `tags` | Always include `flowfuse`, `news`, and `releases`. |
| `release` | The release number as a string, e.g. `"2.29"`. |
-| `features` | List of feature anchors for the in-page navigation. Each entry needs a `heading` and, where a changelog entry exists, an `id` matching the feature catalog slug. |
+| `features` | Ties a section of the post to the feature catalog. Each entry needs a `heading` that matches one of the post's headings exactly, and an `id` matching a feature in the catalog. |
+
+Each `features` entry renders three things into the post: plan availability badges directly under its heading, and a changelog link and docs link at the end of that section. Only changelog posts belonging to this `release` are linked, so a feature that also shipped something in an earlier release does not drag old links in.
+
+```yaml
+release: "2.34"
+features:
+ # One catalog feature.
+ - id: flowfuse-tables
+ heading: "Ask the Expert About Your Tables"
+ # Several, when the heading covers a group. The badge shows the union of their plans.
+ - id: [certified-nodes-it, certified-nodes-ot]
+ heading: "Certified Nodes for industrial connectivity and AI"
+ # A section that is not a catalog feature, e.g. the round-up at the end.
+ - heading: "What else is new?"
+ tiers: { edge: true, hub: true, fleet: true }
+```
-Before populating `features`: Confirm that `featureCatalog.yml` has been updated for every feature in this release. The `id` values here must match slugs in the catalog, if a feature is missing from the catalog, the in-page navigation will silently break and tier badges will not render. The changelog posts for each feature should have already triggered this update; if any are missing, flag them before the blog goes live. See [Writing Changelog Posts](/handbook/engineering/releases/writing-changelog/#feature-catalog-and-availability) for how catalog entries work.
+Before populating `features`: confirm that [`feature-catalog.yml`](https://github.com/FlowFuse/website/blob/main/nuxt/content/feature-catalog.yml) has an entry for every feature in this release, with the `id` values here matching the catalog's. An `id` the catalog does not have renders nothing, silently, so a stale id costs you the badge; a unit test fails the build if one appears. The changelog posts for each feature should have already triggered the catalog update; if any are missing, flag them before the blog goes live. See [Writing Changelog Posts](/handbook/engineering/releases/writing-changelog/#feature-catalog-and-availability) for how catalog entries work.
## Structure
diff --git a/nuxt/content/handbook/engineering/releases/writing-changelog.md b/nuxt/content/handbook/engineering/releases/writing-changelog.md
index f1fcbbda65..6c6ba08d2f 100644
--- a/nuxt/content/handbook/engineering/releases/writing-changelog.md
+++ b/nuxt/content/handbook/engineering/releases/writing-changelog.md
@@ -158,9 +158,9 @@ Replace the following:
### Feature catalog and availability
-Ideally, tie each changelog post to a feature defined in [`featureCatalog.yaml`](https://github.com/FlowFuse/website/blob/main/src/_data/featureCatalog.yaml). The catalog is the single source of truth for tier availability across FlowFuse.
+Ideally, tie each changelog post to a feature defined in [`feature-catalog.yml`](https://github.com/FlowFuse/website/blob/main/nuxt/content/feature-catalog.yml). The catalog is the single source of truth for plan availability across FlowFuse, and it drives the pricing page, the docs badges and the changelog badges from one file.
-When you ship a changelog post for a catalogued feature, update the feature's entry in `featureCatalog.yaml` to include the changelog `url` and `release` number:
+When you ship a changelog post for a catalogued feature, add the changelog `url` and `release` number to that feature's entry:
```yaml
changelog:
@@ -168,13 +168,13 @@ changelog:
release: "2.29"
```
-Once that entry is in place, the build injects tier availability badges into the changelog post automatically. You need no additional markup in the post itself.
+Once that entry is in place, the post renders "Available in Edge / Hub / Fleet" badges from the feature's `tiers`. You need no additional markup in the post itself. A post that is not tied to a catalog feature renders no badges, which is the safe default.
-If the feature does not yet exist in the catalog, add it or flag it for someone to add before the post goes live. If the feature's tier availability has changed alongside this release, update the relevant fields in the catalog too. Product must review any additions or availability changes in `featureCatalog.yaml` before merging.
+If the feature does not yet exist in the catalog, add it or flag it for someone to add before the post goes live. A feature that only exists to carry a changelog link, rather than to be sold, gets `showOnPricing: false` so it stays off the pricing page. Give it `tiers` all the same, so its badges still state where it is available. If availability is genuinely undecided, leave `tiers` off the entry entirely: no badge is better than a wrong one. Product must review any additions or availability changes before merging.
-Always write an availability note in the post as well. Tier badges communicate the tier, but prose tells the user what it means for them. Write it like this:
+Always write an availability note in the post as well. Badges name the plans, but prose tells the user what it means for them. Write it like this:
-> This feature is available to Enterprise tier users of FlowFuse Cloud and Enterprise Licensed Self Hosted users from vX.Y.
+> This feature is available on the Edge and Fleet plans from vX.Y.
Put it at the end of the post, or immediately after the main announcement if it affects whether the user can access the feature at all.
diff --git a/nuxt/lib/feature-catalog.mjs b/nuxt/lib/feature-catalog.mjs
new file mode 100644
index 0000000000..b4955aa2fe
--- /dev/null
+++ b/nuxt/lib/feature-catalog.mjs
@@ -0,0 +1,140 @@
+// Lookups over nuxt/content/feature-catalog.yml. Kept free of Nuxt and Vue imports so it
+// can be unit tested with `node --test`; the composable in composables/useFeatureCatalog.ts
+// is the thin Vue wrapper around this.
+
+// Mirrors the plan files in nuxt/content/plans/. Order is the order badges render in, which
+// is the same left-to-right order the pricing table uses. A test asserts the two stay in step.
+export const PLANS = [
+ { id: 'edge', label: 'Edge' },
+ { id: 'hub', label: 'Hub' },
+ { id: 'fleet', label: 'Fleet' },
+]
+
+/**
+ * The page a plan badge links to: that plan's product page, e.g. /product/edge/.
+ *
+ * The product page explains what the plan is and carries its own "View pricing" CTA, so a
+ * reader who wants the comparison table is one click further on. Linking the table directly
+ * sets up the wrong expectation, that the badge leads to the feature it was clicked from.
+ *
+ * `id` is the plan file's `tierId`, which is also the product page's route parameter, so the
+ * test that keeps PLANS in step with nuxt/content/plans/ covers these paths too.
+ *
+ * Returns null for a label that names no plan, so a caller renders it as plain text rather
+ * than as a link to a page that does not exist.
+ */
+export function planHref (label) {
+ const plan = PLANS.find(candidate => candidate.label === 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.
+ *
+ * Catalog entries are hand written, so `/docs/user/expert`, `/docs/user/expert/` and
+ * `https://flowfuse.com/docs/user/expert/#chat` all turn up and all mean the same page.
+ */
+export function normalizePath (url) {
+ if (typeof url !== 'string' || !url) return null
+
+ const withoutOrigin = url.replace(/^https?:\/\/flowfuse\.com/, '')
+ // An off-site docsLink (dashboard.flowfuse.com) can never match a page on this site.
+ if (/^https?:\/\//.test(withoutOrigin)) return null
+
+ const withoutFragment = withoutOrigin.replace(/#.*$/, '')
+ return withoutFragment.replace(/\/+$/, '') + '/'
+}
+
+/**
+ * Flatten the catalog's sections into a single ordered feature list.
+ */
+export function allFeatures (catalog) {
+ return (catalog?.sections ?? []).flatMap(section => section.features ?? [])
+}
+
+function changelogPaths (feature) {
+ return (feature.changelog ?? []).map(entry => normalizePath(entry.url)).filter(Boolean)
+}
+
+/**
+ * Find the feature a changelog post shipped, or null when the post is not catalogued.
+ *
+ * Most posts are not catalogued, and that is the intended default: a post only gets a badge
+ * once someone has decided which plans the feature belongs to.
+ */
+export function findFeatureByChangelog (catalog, changelogPath) {
+ const target = normalizePath(changelogPath)
+ if (!target) return null
+
+ return allFeatures(catalog).find(feature => changelogPaths(feature).includes(target)) ?? null
+}
+
+/**
+ * Find the feature a docs page documents, or null.
+ *
+ * Subfeatures are skipped because their docsLink points at a heading on a parent's page
+ * (`/docs/user/expert/chat/#support-mode`); matching them here would badge the whole page
+ * with a subfeature's availability. The first match wins, which matters for the pages two
+ * features share, e.g. Edge Devices and Device Fleet Updates both document the device agent.
+ */
+export function findFeatureByDocsPage (catalog, docsPath) {
+ const target = normalizePath(docsPath)
+ if (!target) return null
+
+ return allFeatures(catalog).find(feature =>
+ !feature.subfeature && normalizePath(feature.docsLink) === target,
+ ) ?? null
+}
+
+/**
+ * The plans a feature is included in, as render-ready labels.
+ *
+ * Returns an empty array when the feature has no `tiers` at all (availability not settled)
+ * and when it is in no plan, so callers can treat "nothing to say" as one case.
+ */
+export function planLabels (tiers) {
+ if (!tiers) return []
+ return PLANS.filter(plan => tiers[plan.id]).map(plan => plan.label)
+}
+
+/**
+ * Whether a feature has a row in the pricing comparison table.
+ *
+ * The catalog also carries features that exist only to hang a changelog or docs link off
+ * (subfeatures, shipped improvements), marked `showOnPricing: false`. Pricing shows the rest.
+ */
+export function onPricing (feature) {
+ return feature?.showOnPricing !== false
+}
+
+/**
+ * A feature's plan labels, whether or not it has a row on the pricing page.
+ *
+ * Being off the pricing page used to suppress the badge, back when a badge linked to the
+ * comparison table: clicking through and finding no row for the feature read as deprecated
+ * or as a mistake. A badge now links to that plan's product page, which makes no promise of
+ * a feature list, so the reader is never sent looking for a row that is not there. Stating
+ * availability wherever we know it beats stating it nowhere.
+ *
+ * Still empty for a missing feature and for one whose `tiers` are unset, which is the only
+ * remaining "publish no badge" case.
+ */
+export function featurePlanLabels (feature) {
+ return planLabels(feature?.tiers)
+}
diff --git a/nuxt/lib/feature-catalog.test.mjs b/nuxt/lib/feature-catalog.test.mjs
new file mode 100644
index 0000000000..3ab39b03d8
--- /dev/null
+++ b/nuxt/lib/feature-catalog.test.mjs
@@ -0,0 +1,222 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync, readdirSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+// js-yaml rather than yaml: it is the declared root dependency, and `node --test` resolves
+// it as raw ESM without Vite's CommonJS interop in the way.
+import jsYaml from 'js-yaml'
+
+import {
+ PLANS,
+ allFeatures,
+ featurePlanLabels,
+ findFeatureByChangelog,
+ findFeatureByDocsPage,
+ normalizePath,
+ onPricing,
+ planBadges,
+ planHref,
+ planLabels,
+} from './feature-catalog.mjs'
+
+const contentDir = join(dirname(fileURLToPath(import.meta.url)), '..', 'content')
+const catalog = jsYaml.load(readFileSync(join(contentDir, 'feature-catalog.yml'), 'utf8'))
+
+const fixture = {
+ sections: [{
+ id: 'empower',
+ title: 'Empower',
+ features: [
+ {
+ id: 'expert',
+ title: 'FlowFuse Expert',
+ docsLink: '/docs/user/expert',
+ changelog: [{ url: '/changelog/2026/02/banner/', release: '2.28' }],
+ tiers: { edge: true, hub: true, fleet: true },
+ },
+ {
+ id: 'expert-support',
+ title: 'Support Mode',
+ docsLink: '/docs/user/expert/chat/#support-mode',
+ subfeature: true,
+ tiers: { edge: true, hub: false, fleet: false },
+ },
+ {
+ id: 'unsettled',
+ title: 'Not decided yet',
+ changelog: [{ url: '/changelog/2026/07/unsettled/' }],
+ showOnPricing: false,
+ },
+ {
+ id: 'retired',
+ title: 'Shipped, but off the pricing table',
+ docsLink: '/docs/user/retired/',
+ changelog: [{ url: '/changelog/2026/07/retired/' }],
+ showOnPricing: false,
+ tiers: { edge: true, hub: true, fleet: true },
+ },
+ ],
+ }],
+}
+
+test('normalizePath makes hand written catalog paths comparable', () => {
+ assert.equal(normalizePath('/docs/user/expert'), '/docs/user/expert/')
+ assert.equal(normalizePath('/docs/user/expert/'), '/docs/user/expert/')
+ assert.equal(normalizePath('https://flowfuse.com/docs/user/expert/'), '/docs/user/expert/')
+ assert.equal(normalizePath('/docs/user/expert/chat/#support-mode'), '/docs/user/expert/chat/')
+})
+
+test('normalizePath rejects off-site links and empty input', () => {
+ assert.equal(normalizePath('https://dashboard.flowfuse.com/user/multi-tenancy.html'), null)
+ assert.equal(normalizePath(''), null)
+ assert.equal(normalizePath(undefined), null)
+})
+
+test('findFeatureByChangelog matches regardless of trailing slash', () => {
+ assert.equal(findFeatureByChangelog(fixture, '/changelog/2026/02/banner/')?.id, 'expert')
+ assert.equal(findFeatureByChangelog(fixture, '/changelog/2026/02/banner')?.id, 'expert')
+})
+
+test('findFeatureByChangelog returns null for an uncatalogued post', () => {
+ assert.equal(findFeatureByChangelog(fixture, '/changelog/2026/02/something-else/'), null)
+})
+
+test('findFeatureByDocsPage ignores the fragment on the page it is given', () => {
+ assert.equal(findFeatureByDocsPage(fixture, '/docs/user/expert/')?.id, 'expert')
+})
+
+test('findFeatureByDocsPage skips subfeatures so a heading does not badge a whole page', () => {
+ assert.equal(findFeatureByDocsPage(fixture, '/docs/user/expert/chat/'), null)
+})
+
+test('planLabels lists the plans a feature is in, in plan order', () => {
+ assert.deepEqual(planLabels({ edge: true, hub: false, fleet: true }), ['Edge', 'Fleet'])
+ assert.deepEqual(planLabels({ edge: true, hub: true, fleet: true }), ['Edge', 'Hub', 'Fleet'])
+})
+
+test('planLabels returns nothing when availability is unset or empty', () => {
+ assert.deepEqual(planLabels(undefined), [])
+ assert.deepEqual(planLabels({ edge: false, hub: false, fleet: false }), [])
+})
+
+test('planHref points a badge at its own plan page', () => {
+ assert.equal(planHref('Edge'), '/product/edge/')
+ assert.equal(planHref('Hub'), '/product/hub/')
+ assert.equal(planHref('Fleet'), '/product/fleet/')
+})
+
+test('planHref returns nothing for a label that names no plan', () => {
+ assert.equal(planHref('Enterprise'), null)
+ assert.equal(planHref(''), null)
+ 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', () => {
+ const products = readdirSync(join(contentDir, 'products'))
+ .filter(file => file.endsWith('.yml'))
+ .map(file => jsYaml.load(readFileSync(join(contentDir, 'products', file), 'utf8')).tierId)
+
+ assert.deepEqual(PLANS.map(plan => plan.id).filter(id => !products.includes(id)), [])
+})
+
+test('onPricing defaults to true, so omitting the key keeps a feature on the table', () => {
+ assert.equal(onPricing({ title: 'No key' }), true)
+ assert.equal(onPricing({ showOnPricing: true }), true)
+ assert.equal(onPricing({ showOnPricing: false }), false)
+})
+
+test('featurePlanLabels badges a feature that has a row on the pricing page', () => {
+ const expert = findFeatureByDocsPage(fixture, '/docs/user/expert/')
+ assert.deepEqual(featurePlanLabels(expert), ['Edge', 'Hub', 'Fleet'])
+})
+
+// The badge links to a plan's product page, not to the pricing comparison table, so it never
+// sends the reader looking for a row that is not there. Availability is stated wherever known.
+test('featurePlanLabels badges a feature that is off the pricing page', () => {
+ const retired = findFeatureByChangelog(fixture, '/changelog/2026/07/retired/')
+ assert.deepEqual(featurePlanLabels(retired), ['Edge', 'Hub', 'Fleet'])
+})
+
+test('featurePlanLabels publishes no badge when availability is unsettled', () => {
+ const unsettled = findFeatureByChangelog(fixture, '/changelog/2026/07/unsettled/')
+ assert.equal(unsettled.tiers, undefined)
+ assert.deepEqual(featurePlanLabels(unsettled), [])
+})
+
+test('featurePlanLabels treats a missing feature as nothing to say', () => {
+ assert.deepEqual(featurePlanLabels(null), [])
+ assert.deepEqual(featurePlanLabels(undefined), [])
+})
+
+// The one remaining reason a catalog entry publishes no badge.
+test('every feature in the shipped catalog that declares tiers badges', () => {
+ const silent = allFeatures(catalog)
+ .filter(feature => !featurePlanLabels(feature).length && feature.tiers)
+ .map(feature => feature.id)
+
+ assert.deepEqual(silent, [])
+})
+
+test('PLANS matches the plan files that drive the pricing table', () => {
+ const plansDir = join(contentDir, 'plans')
+ const plans = readdirSync(plansDir)
+ .filter(file => file.endsWith('.yml'))
+ .map(file => jsYaml.load(readFileSync(join(plansDir, file), 'utf8')))
+ .sort((a, b) => a.order - b.order)
+
+ assert.deepEqual(PLANS, plans.map(plan => ({ id: plan.tierId, label: plan.title })))
+})
+
+test('every catalog feature shown on pricing declares its tiers', () => {
+ const missing = allFeatures(catalog)
+ .filter(feature => feature.showOnPricing !== false && !feature.tiers)
+ .map(feature => feature.id)
+
+ assert.deepEqual(missing, [])
+})
+
+test('catalog feature ids are unique', () => {
+ const ids = allFeatures(catalog).map(feature => feature.id)
+ assert.deepEqual(ids.filter((id, index) => ids.indexOf(id) !== index), [])
+})
+
+test('every catalog changelog url points at a changelog post that exists', () => {
+ const changelogDir = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'changelog')
+ const posts = new Set()
+ const walk = (dir, prefix) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isDirectory()) walk(join(dir, entry.name), `${prefix}${entry.name}/`)
+ else if (entry.name.endsWith('.md')) posts.add(`${prefix}${entry.name.replace(/\.md$/, '')}/`)
+ }
+ }
+ walk(changelogDir, '/changelog/')
+
+ const broken = allFeatures(catalog)
+ .flatMap(feature => (feature.changelog ?? []).map(entry => entry.url))
+ .filter(url => !posts.has(normalizePath(url)))
+
+ assert.deepEqual(broken, [])
+})
diff --git a/nuxt/lib/release-features.mjs b/nuxt/lib/release-features.mjs
new file mode 100644
index 0000000000..d869fd385f
--- /dev/null
+++ b/nuxt/lib/release-features.mjs
@@ -0,0 +1,140 @@
+// Turns a release blog's `features:` frontmatter into rendered availability badges and
+// related links, by splicing component nodes into the parsed markdown body.
+//
+// Kept free of Nuxt and Vue imports so `node --test` can run it. The Eleventy transform this
+// replaces rewrote the output HTML with regexes; working on the parsed tree instead means a
+// heading containing a link or inline code still matches, and nothing can be injected into
+// an attribute by accident.
+
+import { allFeatures, normalizePath, planLabels } from './feature-catalog.mjs'
+
+const HEADING = /^h([2-6])$/
+
+/**
+ * MDC "minimal" nodes are `[tag, props, ...children]`. Return the heading level, or 0.
+ */
+function headingLevel (node) {
+ if (!Array.isArray(node) || typeof node[0] !== 'string') return 0
+ const match = node[0].match(HEADING)
+ return match ? Number(match[1]) : 0
+}
+
+/**
+ * The visible text of a node, with any nested inline markup flattened away.
+ */
+export function nodeText (node) {
+ if (typeof node === 'string') return node
+ if (!Array.isArray(node)) return ''
+ return node.slice(2).map(nodeText).join('')
+}
+
+function featureById (catalog, id) {
+ return allFeatures(catalog).find(feature => feature.id === id) ?? null
+}
+
+/**
+ * Resolve one `features:` entry against the catalog.
+ *
+ * `id` may name several features, for a heading that covers more than one catalog entry
+ * (Certified Nodes is IT and OT). Their plans are unioned, because the heading is about the
+ * group and a reader wants to know which plans have any of it.
+ *
+ * An entry with no `id` may carry `tiers` inline, for a section that is not a catalog
+ * feature at all ("What else is new?").
+ *
+ * Only changelog posts from this release are linked. A feature accumulates changelog entries
+ * across releases, and the 2.31 blog linking the 2.28 post would just be noise.
+ */
+export function resolveFeatureEntry (entry, catalog, release, changelogTitles = {}) {
+ const ids = entry.id ? (Array.isArray(entry.id) ? entry.id : [entry.id]) : []
+ const features = ids.map(id => featureById(catalog, id)).filter(Boolean)
+
+ if (ids.length && !features.length) return null
+
+ // Every feature under the heading contributes, whether or not it has a row on the pricing
+ // page, for the reason in featurePlanLabels: the badge links to a plan's product page, so
+ // it no longer sends the reader looking for the feature in a comparison table.
+ const tiers = features.length
+ ? features.reduce((merged, feature) => ({
+ edge: merged.edge || !!feature.tiers?.edge,
+ hub: merged.hub || !!feature.tiers?.hub,
+ fleet: merged.fleet || !!feature.tiers?.fleet,
+ }), { edge: false, hub: false, fleet: false })
+ : entry.tiers
+
+ // A feature with no `tiers` at all resolves to all-false above. That is the "availability
+ // not settled" case, and it must publish no badge, same as everywhere else.
+ const anyTiers = features.length ? features.some(feature => feature.tiers) : !!entry.tiers
+
+ const changelog = features.flatMap(feature =>
+ (feature.changelog ?? [])
+ .filter(item => item.release === release)
+ .map(item => ({ url: item.url, label: changelogTitles[normalizePath(item.url)] ?? `Changelog ${item.release}` })),
+ )
+
+ const withDocs = features.find(feature => feature.docsLink)
+
+ return {
+ heading: entry.heading,
+ plans: anyTiers ? planLabels(tiers) : [],
+ changelog,
+ docs: withDocs ? { href: withDocs.docsLink, label: withDocs.title } : null,
+ }
+}
+
+export function resolveReleaseFeatures (features, catalog, release, changelogTitles) {
+ return (features ?? [])
+ .map(entry => resolveFeatureEntry(entry, catalog, release, changelogTitles))
+ .filter(resolved => resolved && (resolved.plans.length || resolved.changelog.length || resolved.docs))
+}
+
+/**
+ * Splice badges and related links into a parsed body.
+ *
+ * Badges go directly after their heading. Links go at the end of that heading's section,
+ * which is the next heading at the same or a higher level, because they are about everything
+ * the section just described rather than about its first paragraph.
+ *
+ * Returns the original array untouched when there is nothing to add, so a post without a
+ * `features:` block costs nothing.
+ */
+export function injectReleaseFeatures (body, resolved) {
+ if (!Array.isArray(body) || !resolved?.length) return body
+
+ const headings = body
+ .map((node, index) => ({ index, level: headingLevel(node), text: nodeText(node).trim() }))
+ .filter(heading => heading.level > 0)
+
+ // Collected first and applied back to front, so an earlier splice cannot shift a later index.
+ const inserts = []
+
+ for (const feature of resolved) {
+ const position = headings.findIndex(heading => heading.text === feature.heading)
+ if (position === -1) continue
+ const heading = headings[position]
+
+ if (feature.plans.length) {
+ // Comma separated, not an array: MDC's propsToData joins an all-strings array prop
+ // with spaces (it is written for `class`), which would collapse the list into one
+ // string and make the component's v-for iterate its characters. Object props, like
+ // the links below, are passed through untouched and need no such care.
+ inserts.push({ index: heading.index + 1, node: ['feature-tier-badges', { plans: feature.plans.join(',') }] })
+ }
+
+ if (feature.changelog.length || feature.docs) {
+ const nextPeer = headings.find((other, i) => i > position && other.level <= heading.level)
+ inserts.push({
+ index: nextPeer ? nextPeer.index : body.length,
+ node: ['feature-release-links', { changelog: feature.changelog, docs: feature.docs }],
+ })
+ }
+ }
+
+ if (!inserts.length) return body
+
+ const next = body.slice()
+ for (const insert of inserts.sort((a, b) => b.index - a.index)) {
+ next.splice(insert.index, 0, insert.node)
+ }
+ return next
+}
diff --git a/nuxt/lib/release-features.test.mjs b/nuxt/lib/release-features.test.mjs
new file mode 100644
index 0000000000..ee2c98cab6
--- /dev/null
+++ b/nuxt/lib/release-features.test.mjs
@@ -0,0 +1,199 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readFileSync, readdirSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+import jsYaml from 'js-yaml'
+
+import { allFeatures } from './feature-catalog.mjs'
+import {
+ injectReleaseFeatures,
+ nodeText,
+ resolveFeatureEntry,
+ resolveReleaseFeatures,
+} from './release-features.mjs'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const catalog = jsYaml.load(readFileSync(join(here, '..', 'content', 'feature-catalog.yml'), 'utf8'))
+
+const fixture = {
+ sections: [{
+ id: 'x',
+ title: 'X',
+ features: [
+ {
+ id: 'expert',
+ title: 'FlowFuse Expert',
+ docsLink: '/docs/user/expert/',
+ changelog: [
+ { url: '/changelog/2026/02/old/', release: '2.28' },
+ { url: '/changelog/2026/06/new/', release: '2.31' },
+ ],
+ tiers: { edge: true, hub: true, fleet: true },
+ },
+ { id: 'nodes-it', title: 'Certified Nodes - IT', tiers: { edge: false, hub: true, fleet: true } },
+ { id: 'nodes-ot', title: 'Certified Nodes - OT', tiers: { edge: true, hub: false, fleet: false } },
+ { id: 'unsettled', title: 'Undecided', changelog: [{ url: '/changelog/2026/07/u/', release: '2.33' }] },
+ {
+ id: 'retired',
+ title: 'Off the pricing table',
+ docsLink: '/docs/user/retired/',
+ changelog: [{ url: '/changelog/2026/07/r/', release: '2.33' }],
+ showOnPricing: false,
+ tiers: { edge: true, hub: true, fleet: true },
+ },
+ ],
+ }],
+}
+
+test('nodeText flattens inline markup inside a heading', () => {
+ assert.equal(nodeText(['h2', {}, 'Plain heading']), 'Plain heading')
+ assert.equal(nodeText(['h2', {}, 'Use ', ['code', {}, 'ff'], ' now']), 'Use ff now')
+})
+
+test('resolveFeatureEntry links only the changelog posts from this release', () => {
+ const resolved = resolveFeatureEntry({ id: 'expert', heading: 'H' }, fixture, '2.31')
+ assert.deepEqual(resolved.changelog.map(entry => entry.url), ['/changelog/2026/06/new/'])
+})
+
+test('resolveFeatureEntry uses the changelog post title when one is known', () => {
+ const titles = { '/changelog/2026/06/new/': 'Expert builds your app' }
+ const resolved = resolveFeatureEntry({ id: 'expert', heading: 'H' }, fixture, '2.31', titles)
+ assert.equal(resolved.changelog[0].label, 'Expert builds your app')
+
+ const noTitle = resolveFeatureEntry({ id: 'expert', heading: 'H' }, fixture, '2.31')
+ assert.equal(noTitle.changelog[0].label, 'Changelog 2.31')
+})
+
+test('resolveFeatureEntry unions the plans when a heading covers several features', () => {
+ const resolved = resolveFeatureEntry({ id: ['nodes-it', 'nodes-ot'], heading: 'H' }, fixture, '2.31')
+ assert.deepEqual(resolved.plans, ['Edge', 'Hub', 'Fleet'])
+})
+
+test('resolveFeatureEntry publishes no badge for a feature with unsettled availability', () => {
+ const resolved = resolveFeatureEntry({ id: 'unsettled', heading: 'H' }, fixture, '2.33')
+ assert.deepEqual(resolved.plans, [])
+ assert.equal(resolved.changelog.length, 1)
+})
+
+// The badge links to a plan's product page rather than to the pricing comparison table, so a
+// feature with no row there still states where it is available.
+test('resolveFeatureEntry badges a feature that is off the pricing page', () => {
+ const resolved = resolveFeatureEntry({ id: 'retired', heading: 'H' }, fixture, '2.33')
+ assert.deepEqual(resolved.plans, ['Edge', 'Hub', 'Fleet'])
+ assert.deepEqual(resolved.changelog.map(entry => entry.url), ['/changelog/2026/07/r/'])
+ assert.equal(resolved.docs.href, '/docs/user/retired/')
+})
+
+test('resolveFeatureEntry unions every feature under a grouped heading, priced or not', () => {
+ const resolved = resolveFeatureEntry({ id: ['nodes-ot', 'retired'], heading: 'H' }, fixture, '2.33')
+ assert.deepEqual(resolved.plans, ['Edge', 'Hub', 'Fleet'])
+})
+
+test('resolveFeatureEntry accepts inline tiers for a section that is not a catalog feature', () => {
+ const resolved = resolveFeatureEntry({ heading: 'What else is new?', tiers: { edge: true, hub: true, fleet: true } }, fixture, '2.31')
+ assert.deepEqual(resolved.plans, ['Edge', 'Hub', 'Fleet'])
+ assert.deepEqual(resolved.changelog, [])
+})
+
+test('resolveFeatureEntry drops an entry naming an id the catalog does not have', () => {
+ assert.equal(resolveFeatureEntry({ id: 'ghost', heading: 'H' }, fixture, '2.31'), null)
+})
+
+test('resolveReleaseFeatures drops entries that would render nothing', () => {
+ const resolved = resolveReleaseFeatures(
+ [{ heading: 'Bare heading' }, { id: 'expert', heading: 'H' }],
+ fixture, '2.31',
+ )
+ assert.deepEqual(resolved.map(entry => entry.heading), ['H'])
+})
+
+const body = () => [
+ ['h2', { id: 'a' }, 'First feature'],
+ ['p', {}, 'body of first'],
+ ['h3', { id: 'a1' }, 'A detail'],
+ ['p', {}, 'detail body'],
+ ['h2', { id: 'b' }, 'Second feature'],
+ ['p', {}, 'body of second'],
+]
+
+test('injectReleaseFeatures puts badges after the heading and links at the end of the section', () => {
+ const resolved = [{
+ heading: 'First feature',
+ plans: ['Edge'],
+ changelog: [{ url: '/changelog/x/', label: 'X' }],
+ docs: { href: '/docs/x/', label: 'X' },
+ }]
+ const out = injectReleaseFeatures(body(), resolved)
+
+ assert.deepEqual(out.map(node => node[0]), [
+ 'h2', 'feature-tier-badges', 'p', 'h3', 'p', 'feature-release-links', 'h2', 'p',
+ ])
+ assert.equal(out[1][1].plans, 'Edge')
+})
+
+test('injectReleaseFeatures ends a section at the next same-or-higher heading, not the next heading', () => {
+ // The h3 sits inside the h2's section, so the h2's links belong after it, before the next h2.
+ const out = injectReleaseFeatures(body(), [{
+ heading: 'First feature', plans: [], changelog: [{ url: '/c/', label: 'C' }], docs: null,
+ }])
+ assert.equal(out.findIndex(node => node[0] === 'feature-release-links'), 4)
+})
+
+test('injectReleaseFeatures places the last section links at the end of the post', () => {
+ const out = injectReleaseFeatures(body(), [{
+ heading: 'Second feature', plans: [], changelog: [{ url: '/c/', label: 'C' }], docs: null,
+ }])
+ assert.equal(out.at(-1)[0], 'feature-release-links')
+})
+
+test('injectReleaseFeatures keeps several features in the right order', () => {
+ const out = injectReleaseFeatures(body(), [
+ { heading: 'First feature', plans: ['Edge'], changelog: [{ url: '/1/', label: '1' }], docs: null },
+ { heading: 'Second feature', plans: ['Hub'], changelog: [], docs: null },
+ ])
+ assert.deepEqual(out.map(node => node[0]), [
+ 'h2', 'feature-tier-badges', 'p', 'h3', 'p', 'feature-release-links', 'h2', 'feature-tier-badges', 'p',
+ ])
+})
+
+test('injectReleaseFeatures matches a heading that contains inline markup', () => {
+ const withCode = [['h2', {}, 'Use ', ['code', {}, 'ff'], ' now'], ['p', {}, 'x']]
+ const out = injectReleaseFeatures(withCode, [{ heading: 'Use ff now', plans: ['Edge'], changelog: [], docs: null }])
+ assert.equal(out[1][0], 'feature-tier-badges')
+})
+
+test('injectReleaseFeatures leaves a body alone when nothing resolves or a heading is missing', () => {
+ const original = body()
+ assert.equal(injectReleaseFeatures(original, []), original)
+ assert.equal(injectReleaseFeatures(original, [{ heading: 'Nowhere', plans: ['Edge'], changelog: [], docs: null }]), original)
+})
+
+test('every release blog features entry names a catalog feature that exists', () => {
+ const blogDir = join(here, '..', '..', 'src', 'blog')
+ const ids = new Set(allFeatures(catalog).map(feature => feature.id))
+ const posts = []
+ const walk = (dir) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ if (entry.isDirectory()) walk(join(dir, entry.name))
+ else if (entry.name.endsWith('.md')) posts.push(join(dir, entry.name))
+ }
+ }
+ walk(blogDir)
+
+ const broken = []
+ for (const post of posts) {
+ const match = readFileSync(post, 'utf8').match(/^---\n([\s\S]*?)\n---/)
+ if (!match) continue
+ let frontmatter
+ try { frontmatter = jsYaml.load(match[1]) } catch { continue }
+ for (const entry of frontmatter?.features ?? []) {
+ for (const id of [entry.id ?? []].flat()) {
+ if (!ids.has(id)) broken.push(`${post.split('/src/')[1]}: ${id}`)
+ }
+ }
+ }
+
+ assert.deepEqual(broken, [])
+})
diff --git a/nuxt/pages/blog/[...slug].vue b/nuxt/pages/blog/[...slug].vue
index 7a32215ca8..1ad4633dc3 100644
--- a/nuxt/pages/blog/[...slug].vue
+++ b/nuxt/pages/blog/[...slug].vue
@@ -1,6 +1,12 @@