From d10e15a58a782886f5653c54c099cb11eb13cc25 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 10 Sep 2026 18:15:04 +0200 Subject: [PATCH 1/5] node-red: serve /node-red/ from Nuxt The rest of that prefix (the core-node reference and the hardware guides) already moved into /docs/; this was the last page under it. The certified-nodes grid was driven by src/_data/certifiedNodes.js, whose own comment said it "deliberately duplicates the fetch rather than importing across the boundary" while the site was mid-migration. That boundary is gone: the page reads /api/certified-nodes, which derives the list from the same two feeds nuxt/utils/integrations.ts already fetches for /integrations/. The data file goes away with 11ty. fetchCatalogue() could not be used directly: it merges the certified feeds with the npm catalogue and the flowfuse-nodes feed, and tags that last group `certified` too, so filtering its result by tier returned 9 nodes where the two certified catalogues hold 5. fetchCertifiedCatalogues() is exported for exactly those two. Display names are title-cased from the id rather than taken from `name`, which normalizeCatalogueModule fills with the id's last segment when the feed carries none - as all of these do - so the cards read "Modbus" and "RTSP" rather than "modbus" and "rtsp". BlogFaq's renderer gained ordered and unordered list support, unit tested. Two answers here carried
    / - See more customer stories + See more customer stories @@ -231,7 +231,7 @@ useSchemaOrg([
    - +

    {{ section.title }}

    {{ section.description }}

    @@ -245,7 +245,7 @@ useSchemaOrg([
    - + FlowFuse Certified

    @@ -268,7 +268,7 @@ useSchemaOrg([ {{ node.name }} - + {{ node.collectionLabel }} diff --git a/nuxt/utils/siteArt.ts b/nuxt/utils/siteArt.ts deleted file mode 100644 index 3404c74719..0000000000 --- a/nuxt/utils/siteArt.ts +++ /dev/null @@ -1,196 +0,0 @@ -// SVG files that page content included raw, with no wrapper: {% include "components/icons/ -// x.svg" %} rather than the `navoption`/`ffIconLg` shortcodes that NavIcon reproduces. -// -// They cannot go through NavIcon or an : -// - NavIcon wraps its payload in an outer carrying a fixed ff-icon size class, -// which would override the size the caller's box sets. -// - breaks `currentColor`, and most of these are coloured by their container. -// -// GENERATED by scripts/gen-site-art.mjs from the keys the pages and content collections -// actually reference, so the bundle carries only what is used - an eager glob of that -// directory inlines ~137 KB, most of it unused art. Re-run it after adding a page that -// names a new icon. - -import aAcademicCap from '../../src/_includes/components/icons/academic-cap.svg?raw' -import aAdjustmentsHorizontal from '../../src/_includes/components/icons/adjustments-horizontal.svg?raw' -import aAdjustmentsVertical from '../../src/_includes/components/icons/adjustments-vertical.svg?raw' -import aAirGappedDeployment from '../../src/_includes/components/icons/air-gapped-deployment.svg?raw' -import aAirplane from '../../src/_includes/components/icons/airplane.svg?raw' -import aArchiveBoxXMark from '../../src/_includes/components/icons/archive-box-x-mark.svg?raw' -import aArrowLongRight from '../../src/_includes/components/icons/arrow-long-right.svg?raw' -import aArrowPath from '../../src/_includes/components/icons/arrow-path.svg?raw' -import aArrowPathRoundedSquare from '../../src/_includes/components/icons/arrow-path-rounded-square.svg?raw' -import aArrowSmallDown from '../../src/_includes/components/icons/arrow-small-down.svg?raw' -import aArrowTopRightOnSquare from '../../src/_includes/components/icons/arrow-top-right-on-square.svg?raw' -import aArrowTrendingUp from '../../src/_includes/components/icons/arrow-trending-up.svg?raw' -import aArrows from '../../src/_includes/components/icons/arrows.svg?raw' -import aArrowsPointingOut from '../../src/_includes/components/icons/arrows-pointing-out.svg?raw' -import aArrowsRightLeft from '../../src/_includes/components/icons/arrows-right-left.svg?raw' -import aAuditLogs from '../../src/_includes/components/icons/audit-logs.svg?raw' -import aBellAlert from '../../src/_includes/components/icons/bell-alert.svg?raw' -import aBolt from '../../src/_includes/components/icons/bolt.svg?raw' -import aBookOpen from '../../src/_includes/components/icons/book-open.svg?raw' -import aBuildingOffice2 from '../../src/_includes/components/icons/building-office-2.svg?raw' -import aCalendar from '../../src/_includes/components/icons/calendar.svg?raw' -import aCamera from '../../src/_includes/components/icons/camera.svg?raw' -import aCar from '../../src/_includes/components/icons/car.svg?raw' -import aCertificate from '../../src/_includes/components/icons/certificate.svg?raw' -import aCertifiedNode from '../../src/_includes/components/icons/certified-node.svg?raw' -import aChart from '../../src/_includes/components/icons/chart.svg?raw' -import aChatBubbleBottomCenterText from '../../src/_includes/components/icons/chat-bubble-bottom-center-text.svg?raw' -import aChatBubbleLeftRightSm from '../../src/_includes/components/icons/chat-bubble-left-right-sm.svg?raw' -import aCheckBadge from '../../src/_includes/components/icons/check-badge.svg?raw' -import aCheckCircle from '../../src/_includes/components/icons/check-circle.svg?raw' -import aChevronDown from '../../src/_includes/components/icons/chevron-down.svg?raw' -import aChevronLeft from '../../src/_includes/components/icons/chevron-left.svg?raw' -import aChevronRight from '../../src/_includes/components/icons/chevron-right.svg?raw' -import aChevronRightSm from '../../src/_includes/components/icons/chevron-right-sm.svg?raw' -import aChip from '../../src/_includes/components/icons/chip.svg?raw' -import aCircleStack from '../../src/_includes/components/icons/circle-stack.svg?raw' -import aClipList from '../../src/_includes/components/icons/clip-list.svg?raw' -import aClipboardDocumentCheck from '../../src/_includes/components/icons/clipboard-document-check.svg?raw' -import aClock from '../../src/_includes/components/icons/clock.svg?raw' -import aCloud from '../../src/_includes/components/icons/cloud.svg?raw' -import aCodeBracket from '../../src/_includes/components/icons/code-bracket.svg?raw' -import aCodeblock from '../../src/_includes/components/icons/codeblock.svg?raw' -import aCog from '../../src/_includes/components/icons/cog.svg?raw' -import aCog6Tooth from '../../src/_includes/components/icons/cog-6-tooth.svg?raw' -import aComputerDesktop from '../../src/_includes/components/icons/computer-desktop.svg?raw' -import aCubeTransparent from '../../src/_includes/components/icons/cube-transparent.svg?raw' -import aCursorArrowRays from '../../src/_includes/components/icons/cursor-arrow-rays.svg?raw' -import aData from '../../src/_includes/components/icons/data.svg?raw' -import aDatabase from '../../src/_includes/components/icons/database.svg?raw' -import aDocumentArrowUp from '../../src/_includes/components/icons/document-arrow-up.svg?raw' -import aDocumentChartBar from '../../src/_includes/components/icons/document-chart-bar.svg?raw' -import aDocumentCheck from '../../src/_includes/components/icons/document-check.svg?raw' -import aDocumentText from '../../src/_includes/components/icons/document-text.svg?raw' -import aEye from '../../src/_includes/components/icons/eye.svg?raw' -import aEyeSlash from '../../src/_includes/components/icons/eye-slash.svg?raw' -import aFactory from '../../src/_includes/components/icons/factory.svg?raw' -import aGlobeAlt from '../../src/_includes/components/icons/globe-alt.svg?raw' -import aHandRaised from '../../src/_includes/components/icons/hand-raised.svg?raw' -import aLayers from '../../src/_includes/components/icons/layers.svg?raw' -import aLifebuoy from '../../src/_includes/components/icons/lifebuoy.svg?raw' -import aLightBulb from '../../src/_includes/components/icons/light-bulb.svg?raw' -import aLink from '../../src/_includes/components/icons/link.svg?raw' -import aLinkSlash from '../../src/_includes/components/icons/link-slash.svg?raw' -import aLock from '../../src/_includes/components/icons/lock.svg?raw' -import aLockClosed from '../../src/_includes/components/icons/lock-closed.svg?raw' -import aLockOpen from '../../src/_includes/components/icons/lock-open.svg?raw' -import aPin from '../../src/_includes/components/icons/pin.svg?raw' -import aPinSlash from '../../src/_includes/components/icons/pin-slash.svg?raw' -import aPulse from '../../src/_includes/components/icons/pulse.svg?raw' -import aPuzzlePiece from '../../src/_includes/components/icons/puzzle-piece.svg?raw' -import aQueueList from '../../src/_includes/components/icons/queue-list.svg?raw' -import aRectangleStack from '../../src/_includes/components/icons/rectangle-stack.svg?raw' -import aRocketLaunch from '../../src/_includes/components/icons/rocket-launch.svg?raw' -import aRoleBasedAccess from '../../src/_includes/components/icons/role-based-access.svg?raw' -import aServerStack from '../../src/_includes/components/icons/server-stack.svg?raw' -import aShare from '../../src/_includes/components/icons/share.svg?raw' -import aShieldCheck from '../../src/_includes/components/icons/shield-check.svg?raw' -import aShieldExclamation from '../../src/_includes/components/icons/shield-exclamation.svg?raw' -import aSingleSignOn from '../../src/_includes/components/icons/single-sign-on.svg?raw' -import aSnowflake from '../../src/_includes/components/icons/snowflake.svg?raw' -import aSparkles from '../../src/_includes/components/icons/sparkles.svg?raw' -import aSquares2x2 from '../../src/_includes/components/icons/squares-2x2.svg?raw' -import aSquaresPlus from '../../src/_includes/components/icons/squares-plus.svg?raw' -import aTargetView from '../../src/_includes/components/icons/target-view.svg?raw' -import aUns from '../../src/_includes/components/icons/uns.svg?raw' -import aUserGroup from '../../src/_includes/components/icons/user-group.svg?raw' -import aUsers from '../../src/_includes/components/icons/users.svg?raw' -import aWifi from '../../src/_includes/components/icons/wifi.svg?raw' -import aWindTurbine from '../../src/_includes/components/icons/wind-turbine.svg?raw' -import aWrenchScrewdriver from '../../src/_includes/components/icons/wrench-screwdriver.svg?raw' - -export const siteArt: Record = { - 'academic-cap': aAcademicCap, - 'adjustments-horizontal': aAdjustmentsHorizontal, - 'adjustments-vertical': aAdjustmentsVertical, - 'air-gapped-deployment': aAirGappedDeployment, - 'airplane': aAirplane, - 'archive-box-x-mark': aArchiveBoxXMark, - 'arrow-long-right': aArrowLongRight, - 'arrow-path': aArrowPath, - 'arrow-path-rounded-square': aArrowPathRoundedSquare, - 'arrow-small-down': aArrowSmallDown, - 'arrow-top-right-on-square': aArrowTopRightOnSquare, - 'arrow-trending-up': aArrowTrendingUp, - 'arrows': aArrows, - 'arrows-pointing-out': aArrowsPointingOut, - 'arrows-right-left': aArrowsRightLeft, - 'audit-logs': aAuditLogs, - 'bell-alert': aBellAlert, - 'bolt': aBolt, - 'book-open': aBookOpen, - 'building-office-2': aBuildingOffice2, - 'calendar': aCalendar, - 'camera': aCamera, - 'car': aCar, - 'certificate': aCertificate, - 'certified-node': aCertifiedNode, - 'chart': aChart, - 'chat-bubble-bottom-center-text': aChatBubbleBottomCenterText, - 'chat-bubble-left-right-sm': aChatBubbleLeftRightSm, - 'check-badge': aCheckBadge, - 'check-circle': aCheckCircle, - 'chevron-down': aChevronDown, - 'chevron-left': aChevronLeft, - 'chevron-right': aChevronRight, - 'chevron-right-sm': aChevronRightSm, - 'chip': aChip, - 'circle-stack': aCircleStack, - 'clip-list': aClipList, - 'clipboard-document-check': aClipboardDocumentCheck, - 'clock': aClock, - 'cloud': aCloud, - 'code-bracket': aCodeBracket, - 'codeblock': aCodeblock, - 'cog': aCog, - 'cog-6-tooth': aCog6Tooth, - 'computer-desktop': aComputerDesktop, - 'cube-transparent': aCubeTransparent, - 'cursor-arrow-rays': aCursorArrowRays, - 'data': aData, - 'database': aDatabase, - 'document-arrow-up': aDocumentArrowUp, - 'document-chart-bar': aDocumentChartBar, - 'document-check': aDocumentCheck, - 'document-text': aDocumentText, - 'eye': aEye, - 'eye-slash': aEyeSlash, - 'factory': aFactory, - 'globe-alt': aGlobeAlt, - 'hand-raised': aHandRaised, - 'layers': aLayers, - 'lifebuoy': aLifebuoy, - 'light-bulb': aLightBulb, - 'link': aLink, - 'link-slash': aLinkSlash, - 'lock': aLock, - 'lock-closed': aLockClosed, - 'lock-open': aLockOpen, - 'pin': aPin, - 'pin-slash': aPinSlash, - 'pulse': aPulse, - 'puzzle-piece': aPuzzlePiece, - 'queue-list': aQueueList, - 'rectangle-stack': aRectangleStack, - 'rocket-launch': aRocketLaunch, - 'role-based-access': aRoleBasedAccess, - 'server-stack': aServerStack, - 'share': aShare, - 'shield-check': aShieldCheck, - 'shield-exclamation': aShieldExclamation, - 'single-sign-on': aSingleSignOn, - 'snowflake': aSnowflake, - 'sparkles': aSparkles, - 'squares-2x2': aSquares2x2, - 'squares-plus': aSquaresPlus, - 'target-view': aTargetView, - 'uns': aUns, - 'user-group': aUserGroup, - 'users': aUsers, - 'wifi': aWifi, - 'wind-turbine': aWindTurbine, - 'wrench-screwdriver': aWrenchScrewdriver, -} diff --git a/scripts/gen-site-art.mjs b/scripts/gen-site-art.mjs deleted file mode 100755 index cf57a0dbaf..0000000000 --- a/scripts/gen-site-art.mjs +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -// Regenerate nuxt/utils/siteArt.ts from the icon keys the site actually references. -// -// inlines an SVG from src/_includes/components/icons/ verbatim, which is what -// page content that used a bare {% include %} needs. The registry is written out rather -// than globbed, so the bundle carries only the icons in use - an eager glob of that -// directory inlines ~137 KB, most of it unused art. -// -// Keeping it hand-maintained across ~50 keys is not realistic, so this scans for every -// key that is referenced and rewrites the file. Run it after adding a page that names a -// new icon; the build fails loudly on a missing import, and warns in dev on a -// key the registry does not have. -// -// Usage: node scripts/gen-site-art.mjs [repo-root] -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' - -const ICON_DIR = 'src/_includes/components/icons' - -function filesUnder (dir, ext) { - if (!existsSync(dir)) return [] - return readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith(ext)) - .map(entry => join(entry.parentPath ?? entry.path, entry.name)) -} - -function matchAll (text, pattern) { - return [...text.matchAll(pattern)].map(match => match[1]) -} - -// `explicit` are literal uses - a missing file there is a broken -// page, so it fails the run. `harvested` are svgPath:/icon: values in content and page -// consts, which a renderer *may* pass to SiteArt but may equally resolve some other way -// (contact-us has its own icon map, for one), so a miss there is only reported. -function referencedKeys (root) { - const vues = [...filesUnder(join(root, 'nuxt/pages'), '.vue'), ...filesUnder(join(root, 'nuxt/components'), '.vue')] - const explicit = new Set() - for (const file of vues) { - for (const key of matchAll(readFileSync(file, 'utf8'), / name resolved from an - // installed icon set, not a file in this repo, so it is not SiteArt's to provide. - if (!key.startsWith('i-')) harvested.add(key) - } - } - return { explicit, harvested } -} - -const ident = key => 'a' + key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('') - -const HEADER = `// SVG files that page content included raw, with no wrapper: {% include "components/icons/ -// x.svg" %} rather than the \`navoption\`/\`ffIconLg\` shortcodes that NavIcon reproduces. -// -// They cannot go through NavIcon or an : -// - NavIcon wraps its payload in an outer carrying a fixed ff-icon size class, -// which would override the size the caller's box sets. -// - breaks \`currentColor\`, and most of these are coloured by their container. -// -// GENERATED by scripts/gen-site-art.mjs from the keys the pages and content collections -// actually reference, so the bundle carries only what is used - an eager glob of that -// directory inlines ~137 KB, most of it unused art. Re-run it after adding a page that -// names a new icon. -` - -const root = resolve(process.argv[2] ?? '.') -const { explicit, harvested } = referencedKeys(root) - -// Keys that resolve through NavIcon (the navoption/ffIconLg wrapper) or a hand-written -// Vue icon component are not SiteArt's job. -const navFile = join(root, 'nuxt/utils/navIcons.ts') -const navKeys = existsSync(navFile) ? new Set(matchAll(readFileSync(navFile, 'utf8'), /^ {4}'([a-z0-9-]+)':/gm)) : new Set() - -const usable = [] -const missing = [] -for (const key of [...new Set([...explicit, ...harvested])].sort()) { - (existsSync(join(root, ICON_DIR, `${key}.svg`)) ? usable : missing).push(key) -} - -const lines = [HEADER] -for (const key of usable) lines.push(`import ${ident(key)} from '../../${ICON_DIR}/${key}.svg?raw'`) -lines.push('\nexport const siteArt: Record = {') -for (const key of usable) lines.push(` '${key}': ${ident(key)},`) -lines.push('}\n') - -const out = join(root, 'nuxt/utils/siteArt.ts') -writeFileSync(out, lines.join('\n')) -console.log(`${out}: ${usable.length} icons`) - -const alsoInNav = usable.filter(key => navKeys.has(key)) -if (alsoInNav.length) console.log(` (also in navIcons, which is fine - different wrapper: ${alsoInNav.slice(0, 6).join(', ')}…)`) -if (missing.length) console.log(` no SVG file (resolved elsewhere, not bundled here): ${missing.join(', ')}`) - -const broken = missing.filter(key => explicit.has(key)) -if (broken.length) { - console.log(` ERROR: is asked for these and there is no file: ${broken.join(', ')}`) - process.exit(1) -} From 41d8e42e7b8377ed1a7f433f86cf5ab16e072e38 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 24 Sep 2026 16:35:25 +0200 Subject: [PATCH 4/5] node-red: unit test the certified-nodes derivation The name overrides, the one-card-per-collection split, the docs fallback and the sort move out of the Nitro handler into nuxt/lib/certified-nodes.mjs, so `npm test` covers them. The handler keeps the fetch and reuses COLLECTION_LABELS from types/integrations instead of its own copy of the same map. --- nuxt/lib/certified-nodes.mjs | 51 ++++++++++++++++++ nuxt/lib/certified-nodes.test.mjs | 72 ++++++++++++++++++++++++++ nuxt/server/api/certified-nodes.get.ts | 57 ++++---------------- 3 files changed, 132 insertions(+), 48 deletions(-) create mode 100644 nuxt/lib/certified-nodes.mjs create mode 100644 nuxt/lib/certified-nodes.test.mjs diff --git a/nuxt/lib/certified-nodes.mjs b/nuxt/lib/certified-nodes.mjs new file mode 100644 index 0000000000..921132dc72 --- /dev/null +++ b/nuxt/lib/certified-nodes.mjs @@ -0,0 +1,51 @@ +// Turns the two FlowFuse Certified catalogue feeds into the cards /node-red/ renders. +// Kept free of Nuxt imports so it can be unit tested with `node --test`; +// server/api/certified-nodes.get.ts is the only caller and does the fetching. + +// The catalogues carry no display `name`, and the ids are lowercase slugs, so +// title-casing alone gives "Opcua" and "Rtsp". Acronyms cannot be inferred. +const NAME_OVERRIDES = { + '@flowfuse-certified-nodes/opcua': 'OPC UA', + '@flowfuse-certified-nodes/rtsp': 'RTSP', + '@flowfuse-certified-nodes/cip-suite': 'CIP Suite', +} + +// Last resort when there is no override: "@flowfuse-certified-nodes/google-sheets" +// becomes "Google Sheets", which reads correctly for names that are ordinary words +// ("Redis", "Modbus", "Kafka"). +function displayNameFromId (id) { + return (id.split('/').pop() ?? id) + .split('-') + .map(word => word.replace(/^\w/, char => char.toUpperCase())) + .join(' ') +} + +// Not entry.name: normalizeCatalogueModule falls back to the id's last segment when the +// feed carries no name, which every one of these does, so trusting it would print +// "modbus" and "rtsp" rather than "Modbus" and "RTSP". +export function certifiedNodeName (id) { + return NAME_OVERRIDES[id] ?? displayNameFromId(id) +} + +// One card per (node, collection) pair, because the badge names the collection and a node +// can be certified in both. That is what the .njk rendered. `feeds` is one entry list per +// catalogue, as fetchCertifiedCatalogues() returns them. +export function certifiedNodeCards (feeds) { + const cards = feeds.flat().flatMap(entry => (entry.collections ?? []).map(collection => ({ + id: entry._id, + name: certifiedNodeName(entry._id), + description: entry.description, + version: entry.version, + collection, + // docsUrl is already normalised (flowfuse.com paths only, trailing slash added, + // per-node overrides applied); the collection index is the fallback, as it was in + // the .njk. Some publisher URLs still point at /node-red/flowfuse/… paths that now + // 301 into /docs/ - that is true of production too, and it is the catalogue + // entries that need updating, not this. + docsPath: entry.docsUrl ?? `/docs/flowfuse-nodes/${collection}/`, + }))) + + // Stable, so a node certified in both collections keeps feed order: Hub, then Edge. + cards.sort((a, b) => a.name.localeCompare(b.name)) + return cards +} diff --git a/nuxt/lib/certified-nodes.test.mjs b/nuxt/lib/certified-nodes.test.mjs new file mode 100644 index 0000000000..936144774b --- /dev/null +++ b/nuxt/lib/certified-nodes.test.mjs @@ -0,0 +1,72 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' + +import { certifiedNodeCards, certifiedNodeName } from './certified-nodes.mjs' + +const SCOPE = '@flowfuse-certified-nodes' + +function entry (slug, collections, extra = {}) { + return { + _id: `${SCOPE}/${slug}`, + name: slug, + description: `${slug} description`, + version: '1.0.0', + collections, + ...extra, + } +} + +test('acronyms come from the override map', () => { + assert.equal(certifiedNodeName(`${SCOPE}/opcua`), 'OPC UA') + assert.equal(certifiedNodeName(`${SCOPE}/rtsp`), 'RTSP') + assert.equal(certifiedNodeName(`${SCOPE}/cip-suite`), 'CIP Suite') +}) + +test('other slugs are title-cased word by word', () => { + assert.equal(certifiedNodeName(`${SCOPE}/modbus`), 'Modbus') + assert.equal(certifiedNodeName(`${SCOPE}/google-sheets`), 'Google Sheets') + assert.equal(certifiedNodeName('redis'), 'Redis') +}) + +test('the catalogue name is ignored, since it is only the lowercase slug', () => { + const [card] = certifiedNodeCards([[entry('rtsp', ['edge'])]]) + assert.equal(card.name, 'RTSP') +}) + +test('a node certified in two collections gets one card per collection', () => { + const cards = certifiedNodeCards([[entry('modbus', ['hub', 'edge'])]]) + assert.deepEqual(cards.map(card => card.collection), ['hub', 'edge']) + assert.ok(cards.every(card => card.id === `${SCOPE}/modbus`)) +}) + +test('an entry with no collections gets no card', () => { + assert.deepEqual(certifiedNodeCards([[entry('kafka', undefined), entry('redis', [])]]), []) +}) + +test('docsPath keeps the normalised docsUrl and falls back to the collection index', () => { + const [withDocs, withoutDocs] = certifiedNodeCards([[ + entry('kafka', ['hub'], { docsUrl: '/docs/flowfuse-nodes/hub/kafka/' }), + entry('modbus', ['edge']), + ]]) + assert.equal(withDocs.docsPath, '/docs/flowfuse-nodes/hub/kafka/') + assert.equal(withoutDocs.docsPath, '/docs/flowfuse-nodes/edge/') +}) + +test('cards from both feeds are sorted together by display name', () => { + const hub = [entry('redis', ['hub']), entry('opcua', ['hub'])] + const edge = [entry('modbus', ['edge']), entry('cip-suite', ['edge'])] + assert.deepEqual( + certifiedNodeCards([hub, edge]).map(card => card.name), + ['CIP Suite', 'Modbus', 'OPC UA', 'Redis'], + ) +}) + +test('the same node in both feeds stays in feed order after sorting', () => { + const cards = certifiedNodeCards([[entry('kafka', ['hub'])], [entry('kafka', ['edge'])]]) + assert.deepEqual(cards.map(card => card.collection), ['hub', 'edge']) +}) + +test('no feeds, or empty feeds after a fetch failure, give no cards', () => { + assert.deepEqual(certifiedNodeCards([]), []) + assert.deepEqual(certifiedNodeCards([[], []]), []) +}) diff --git a/nuxt/server/api/certified-nodes.get.ts b/nuxt/server/api/certified-nodes.get.ts index 418f132007..402099c8d8 100644 --- a/nuxt/server/api/certified-nodes.get.ts +++ b/nuxt/server/api/certified-nodes.get.ts @@ -4,13 +4,16 @@ // duplicates the fetch rather than importing across the boundary" while the site was // mid-migration. That boundary is gone: fetchCatalogue() already reads the same two // certified feeds (CERTIFIED_HUB_API and CERTIFIED_EDGE_API), so this derives the display -// shape from it instead of fetching the feeds a second time. +// shape from it instead of fetching the feeds a second time. The derivation itself (names, +// one card per collection, docs fallback, order) is in lib/certified-nodes.mjs, where +// `npm test` covers it. // // A feed being unreachable returns an empty list rather than failing: fetchCatalogueFeed // already swallows per-feed errors, and the page skips its whole section when the list is // empty, as the .njk's `{% if certifiedNodes.all.length %}` did. +import { certifiedNodeCards } from '../../lib/certified-nodes.mjs' import { fetchCertifiedCatalogues } from '../../utils/integrations' -import type { CertifiedCollection } from '../../types/integrations' +import { COLLECTION_LABELS, type CertifiedCollection } from '../../types/integrations' export interface CertifiedNode { id: string @@ -22,52 +25,10 @@ export interface CertifiedNode { docsPath: string } -// The catalogues carry no display `name`, and the ids are lowercase slugs, so -// title-casing alone gives "Opcua" and "Rtsp". Acronyms cannot be inferred. -const NAME_OVERRIDES: Record = { - '@flowfuse-certified-nodes/opcua': 'OPC UA', - '@flowfuse-certified-nodes/rtsp': 'RTSP', - '@flowfuse-certified-nodes/cip-suite': 'CIP Suite', -} - -const COLLECTION_LABELS: Record = { - hub: 'Hub', - edge: 'Edge', -} - -// Last resort when there is neither an override nor a catalogue name: -// "@flowfuse-certified-nodes/cip-suite" becomes "Cip Suite", which reads correctly for -// names that are ordinary words ("Redis", "Modbus", "Kafka"). -function displayNameFromId(id: string): string { - return (id.split('/').pop() ?? id) - .split('-') - .map(word => word.replace(/^\w/, char => char.toUpperCase())) - .join(' ') -} - export default defineEventHandler(async (): Promise => { const feeds = await fetchCertifiedCatalogues() - - // One card per (node, collection) pair, because the badge names the collection and a - // node can be certified in both. That is what the .njk rendered. - const nodes = feeds.flat().flatMap(entry => (entry.collections ?? []).map(collection => ({ - id: entry._id, - // Not entry.name: normalizeCatalogueModule falls back to the id's last segment - // when the feed carries no name, which every one of these does, so trusting it - // would print "modbus" and "rtsp" rather than "Modbus" and "RTSP". - name: NAME_OVERRIDES[entry._id] ?? displayNameFromId(entry._id), - description: entry.description, - version: entry.version, - collection, - collectionLabel: COLLECTION_LABELS[collection], - // docsUrl is already normalised (flowfuse.com paths only, trailing slash added, - // per-node overrides applied); the collection index is the fallback, as it was in - // the .njk. Some publisher URLs still point at /node-red/flowfuse/… paths that - // now 301 into /docs/ - that is true of production too, and it is the catalogue - // entries that need updating, not this. - docsPath: entry.docsUrl ?? `/docs/flowfuse-nodes/${collection}/`, - }))) - - nodes.sort((a, b) => a.name.localeCompare(b.name)) - return nodes + return certifiedNodeCards(feeds).map((card: Omit) => ({ + ...card, + collectionLabel: COLLECTION_LABELS[card.collection], + })) }) From 975483a6e4bb92dd966f9ad9e84fc440bf5c24c9 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 24 Sep 2026 16:35:25 +0200 Subject: [PATCH 5/5] node-red: keep the section title inside its
    as phrasing content --- nuxt/pages/node-red/index.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuxt/pages/node-red/index.vue b/nuxt/pages/node-red/index.vue index d7df3a7822..ab30eb5d60 100644 --- a/nuxt/pages/node-red/index.vue +++ b/nuxt/pages/node-red/index.vue @@ -174,7 +174,7 @@ useSchemaOrg([

    -
    {{ section.title }}
    + {{ section.title }}