Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions nuxt/components/CertifiedNodes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ const ACCENT: Record<string, string> = { indigo: 'bg-indigo-600', red: 'bg-red-6
const accentClass = (b: Bundle) => ACCENT[b.accent] ?? 'bg-gray-500'
const tileClass = (b: Bundle, node: { both?: boolean }) =>
node.both ? 'bg-gradient-to-br from-indigo-600 to-red-600' : accentClass(b)

// Same event the /node-red/ certified grid emits (src/node-red/index.njk), so
// clicks on a connector aggregate across every place it is featured.
const capture = useCapture()
const captureNodeClick = (name: string, bundle: Bundle) =>
capture('certified-node-click', { node: name, collection: bundle.tier, page: location.pathname })
</script>

<template>
Expand Down Expand Up @@ -56,6 +62,8 @@ const tileClass = (b: Bundle, node: { both?: boolean }) =>
:name="node.name"
:description="node.description"
:tile-class="tileClass(b, node)"
:href="node.url"
@click="captureNodeClick(node.name, b)"
/>
</div>
</div>
Expand Down
23 changes: 20 additions & 3 deletions nuxt/components/certified/NodeTile.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,35 @@
<script setup lang="ts">
/*
Certified connector tile, shared by the /pricing/ and /integrations/ showcases.

`href` is the node's documentation page, which lives under /node-red/ and is
served by Eleventy, not by Nuxt. That is why this is a plain <a> and not a
<NuxtLink>: vue-router would resolve the path against the Nuxt route table,
find nothing, and render the Nuxt 404 instead of requesting the page. A tile
with no target stays a <div> rather than becoming a link that goes nowhere.

Callers attach their own click handler (for analytics); with a single root
element it lands on the tile itself.
*/
defineProps<{
abbr: string
name: string
description?: string
tileClass: string
href?: string | null
}>()
</script>

<template>
<div class="flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 transition hover:border-gray-300 hover:shadow-sm">
<component
:is="href ? 'a' : 'div'"
:href="href || undefined"
class="group flex items-start gap-3 rounded-xl border border-gray-200 bg-white p-3 transition hover:border-gray-300 hover:shadow-sm hover:no-underline"
>
<span class="flex-none grid place-items-center w-8 h-8 rounded-lg text-white font-semibold text-[11px]" :class="tileClass">{{ abbr }}</span>
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 leading-tight">{{ name }}</div>
<div class="text-sm font-medium text-gray-900 leading-tight" :class="{ 'group-hover:text-indigo-600': !!href }">{{ name }}</div>
<div class="text-xs text-gray-500 leading-snug mt-0.5">{{ description }}</div>
</div>
</div>
</component>
</template>
11 changes: 2 additions & 9 deletions nuxt/components/integrations/Card.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,17 @@
<script setup lang="ts">
import type { IntegrationCatalogEntry } from '../../types/integrations'
import { INTEGRATION_CATEGORIES, PRODUCT_LABELS } from '../../types/integrations'
import { monogram, nodeProducts, tileClass } from '../../utils/integrations-ui'
import { certifiedHref, monogram, nodeProducts, tileClass } from '../../utils/integrations-ui'

const props = defineProps<{
node: IntegrationCatalogEntry
generatedIds: Set<string>
}>()

const hasGeneratedPage = computed(() => props.generatedIds.has(props.node._id))
// A certified node only gets a docsUrl when its catalogue `url` is a flowfuse.com
// link or it has a DOCS_URL_OVERRIDES entry (see nuxt/utils/integrations.ts).
// Publishers can and do point `url` at their own repository, so fall back to the
// collection index, which always exists, rather than rendering a dead card.
const certifiedFallback = computed<string | null>(() =>
props.node.collection ? `/node-red/flowfuse/${props.node.collection}/` : null
)
const href = computed<string | null>(() => {
if (props.node.docsUrl) return props.node.docsUrl
if (props.node.tier === 'certified') return certifiedFallback.value
if (props.node.tier === 'certified') return certifiedHref(props.node)
return hasGeneratedPage.value
? `/integrations/${props.node._id}/`
: `https://flows.nodered.org/node/${props.node._id}`
Expand Down
10 changes: 9 additions & 1 deletion nuxt/components/integrations/CertifiedShowcase.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<script setup lang="ts">
import type { CertifiedCollection, IntegrationCatalogEntry } from '../../types/integrations'
import { INTEGRATION_CATEGORIES } from '../../types/integrations'
import { monogram, nodeProducts, tileClass } from '../../utils/integrations-ui'
import { certifiedHref, monogram, nodeProducts, tileClass } from '../../utils/integrations-ui'

const props = defineProps<{
nodes: IntegrationCatalogEntry[]
Expand Down Expand Up @@ -29,6 +29,12 @@ const groups = computed(() => {
return [...map.values()]
})

// Same event the /node-red/ certified grid emits (src/node-red/index.njk), so
// clicks on a connector aggregate across every place it is featured.
const capture = useCapture()
const captureNodeClick = (node: IntegrationCatalogEntry) =>
capture('certified-node-click', { node: node._id, collection: props.product, page: location.pathname })

const productOptions: CertifiedCollection[] = ['hub', 'edge']
function onSelect (p: 'all' | CertifiedCollection) {
if (p !== 'all') emit('update:product', p)
Expand Down Expand Up @@ -63,6 +69,8 @@ function onSelect (p: 'all' | CertifiedCollection) {
:name="node.name"
:description="node.description"
:tile-class="tileClass(node)"
:href="certifiedHref(node)"
@click="captureNodeClick(node)"
/>
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,7 @@ export default defineContentConfig({
abbr: z.string(),
description: z.string(),
both: z.boolean().optional(),
url: z.string(),
})),
})),
})),
Expand Down
14 changes: 14 additions & 0 deletions nuxt/content/certified-nodes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ intro: >-
by FlowFuse, so a production connection never rests on an unmaintained community
package. Hub covers the systems your business runs on; Edge covers the plant floor.

# `url` is the node's documentation page. Keep these pointing at the same targets
# the header's "By Integration" menu uses (nuxt/components/AppHeader.vue), so a
# connector resolves to one canonical page wherever it is featured.
bundles:
- id: it
label: Certified Nodes — IT
Expand All @@ -16,22 +19,27 @@ bundles:
- name: Redis
abbr: RE
description: In-memory cache and fast key/value store.
url: /node-red/flowfuse/hub/redis/
- name: MQTT
abbr: MQ
description: Lightweight pub/sub messaging for IoT and event-driven flows.
url: /node-red/flowfuse/mqtt/
- name: HTTP Request
abbr: HT
description: Call REST APIs and web services.
url: /node-red/core-nodes/http-request/
- label: AI
nodes:
- name: AI nodes
abbr: AI
description: LLM-powered flows with Gemini, Claude, ChatGPT, and Ollama.
both: true
url: /node-red/flowfuse/ai/llm-nodes/
- name: MCP Server
abbr: MCP
description: Turn your flows into a Model Context Protocol server so AI agents can call your logic as tools.
both: true
url: /node-red/flowfuse/mcp/

- id: ot
label: Certified Nodes — OT
Expand All @@ -44,22 +52,28 @@ bundles:
- name: OPC-UA
abbr: OP
description: The standard for industrial machine interoperability.
url: /node-red/flowfuse/edge/opcua/
- name: Modbus TCP & RTU
abbr: MO
description: Ubiquitous protocol for PLCs and field devices.
url: /node-red/flowfuse/edge/modbus/
- name: RTSP
abbr: RT
description: Stream video from IP cameras.
url: /node-red/flowfuse/edge/rtsp/
- name: EtherNet/IP
abbr: EI
description: Rockwell / Allen-Bradley industrial Ethernet protocol.
url: /node-red/flowfuse/edge/cip-suite/
- label: AI
nodes:
- name: AI nodes
abbr: AI
description: LLM-powered flows with Gemini, Claude, ChatGPT, and Ollama.
both: true
url: /node-red/flowfuse/ai/llm-nodes/
- name: MCP Server
abbr: MCP
description: Turn your flows into a Model Context Protocol server so AI agents can call your logic as tools.
both: true
url: /node-red/flowfuse/mcp/
13 changes: 13 additions & 0 deletions nuxt/utils/integrations-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ export function nodeProducts (node: Pick<IntegrationCatalogEntry, 'collections'>
return node.collections ?? []
}

/*
Documentation target for a certified node. An entry only carries a docsUrl
when the publisher pointed the catalogue `url` at flowfuse.com or it has a
DOCS_URL_OVERRIDES entry (see nuxt/utils/integrations.ts) — opcua, for one,
points at Sterfive's repository. Fall back to the collection index, which
always exists, rather than rendering a card that goes nowhere.
*/
export function certifiedHref (node: Pick<IntegrationCatalogEntry, 'docsUrl' | 'collections'>): string | null {
if (node.docsUrl) return node.docsUrl
const collection = nodeProducts(node)[0]
return collection ? `/node-red/flowfuse/${collection}/` : null
}

export function tileClass (node: Pick<IntegrationCatalogEntry, 'collections' | 'tier'>): string {
const products = nodeProducts(node)
if (products.includes('hub') && products.includes('edge')) return 'bg-gradient-to-br from-indigo-600 to-red-600'
Expand Down
6 changes: 5 additions & 1 deletion nuxt/utils/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ function docPathFromCatalogueUrl (url: string | undefined): string | undefined {
try {
const parsed = new URL(url)
if (parsed.hostname !== 'flowfuse.com' && parsed.hostname !== 'www.flowfuse.com') return undefined
return parsed.pathname + parsed.search + parsed.hash
// Publishers are inconsistent about the trailing slash (the redis entry
// omits it). The site serves directory-style paths, so normalise rather
// than send every click through a redirect.
const path = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname}/`
return path + parsed.search + parsed.hash
} catch {
return undefined
}
Expand Down