Skip to content
Draft
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
98 changes: 98 additions & 0 deletions nuxt/components/integrations/CertifiedHero.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<script setup>
defineProps({
// Null while the catalog fetch is in flight; resolves to a number after.
Copy link
Copy Markdown
Contributor Author

@n-lark n-lark May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update this file based on changes in #5036 - updated for first round of feedback.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also may need to migrate the cookies banner to nuxt before this can merge - TBD

count: { validator: v => v === null || typeof v === 'number', default: null },
pressed: { type: Boolean, required: true }
})
defineEmits(['toggle'])
</script>

<template>
<section class="certified-hero mb-8">
<div class="container m-auto md:max-w-6xl">
<div class="certified-hero--card rounded-lg border-[3px] border-indigo-200 p-6 md:p-10 grid md:grid-cols-12 gap-8 md:gap-10 items-start">
<div class="md:col-span-5">
<span class="certified-eyebrow">
<IntegrationsCertifiedIcon />
<span>FlowFuse Certified</span>
</span>
<h2 class="certified-hero--title text-balance mb-8">
Certified Nodes, backed by their authors and supported long-term
</h2>
<p class="certified-hero--lede">
Choosing a Node-RED node for production raises questions you can't always answer from a README. Is it actively maintained? Is it secure? Will the maintainer still be around in two years? Certified Nodes answer those questions.
</p>
<div class="certified-hero--actions mt-8">
<button
id="certified-pill-toggle"
type="button"
class="ff-btn ff-btn--primary uppercase certified-toggle"
:aria-pressed="pressed ? 'true' : 'false'"
@click="$emit('toggle')"
>
<IntegrationsCertifiedIcon />
<span>Show only Certified</span>
<span v-if="count !== null" class="certified-toggle--count"> ({{ count }})</span>
</button>
<!-- TODO: repoint to a proper FlowFuse-owned Certified Nodes explainer page when one exists. A year-old blog post is not the long-term destination. -->
<a
class="certified-hero--link inline-flex items-center gap-1 uppercase"
href="/blog/2025/07/certified-nodes-v2/"
>
Learn more
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3"
/>
</svg>
</a>
</div>
</div>
<ul class="certified-pillars md:col-span-7">
<li class="certified-pillar">
<span class="certified-pillar--icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="100%" height="100%">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z" />
</svg>
</span>
<div>
<h3 class="certified-pillar--title">Vetted authors</h3>
<p class="certified-pillar--body">Every Certified Node comes from a developer with a track record in their domain — not an anonymous npm publisher.</p>
</div>
</li>
<li class="certified-pillar">
<span class="certified-pillar--icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="100%" height="100%">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75m-3-7.036A11.959 11.959 0 0 1 3.598 6 11.99 11.99 0 0 0 3 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285Z" />
</svg>
</span>
<div>
<h3 class="certified-pillar--title">Supported through production</h3>
<p class="certified-pillar--body">FlowFuse stands behind every Certified Node after launch — patching CVEs on our own timeline. Each node is vetted for reliability, security posture, and current documentation before shipping.</p>
</div>
</li>
<li class="certified-pillar">
<span class="certified-pillar--icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" width="100%" height="100%">
<path stroke-linecap="round" stroke-linejoin="round" d="m6.75 7.5 3 2.25-3 2.25m4.5 0h3m-9 8.25h13.5A2.25 2.25 0 0 0 21 18V6a2.25 2.25 0 0 0-2.25-2.25H5.25A2.25 2.25 0 0 0 3 6v12a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
</span>
<div>
<h3 class="certified-pillar--title">Free or commercial, same bar</h3>
<p class="certified-pillar--body">Some Certified Nodes are free and open; others target specific enterprise needs. The certification standard is the same.</p>
</div>
</li>
</ul>
</div>
</div>
</section>
</template>
15 changes: 15 additions & 0 deletions nuxt/components/integrations/CertifiedIcon.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<template>
<svg
class="certified-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M8.603 3.799A4.49 4.49 0 0 1 12 2.25c1.357 0 2.573.6 3.397 1.549a4.49 4.49 0 0 1 3.498 1.307 4.491 4.491 0 0 1 1.307 3.497A4.49 4.49 0 0 1 21.75 12a4.49 4.49 0 0 1-1.549 3.397 4.491 4.491 0 0 1-1.307 3.497 4.491 4.491 0 0 1-3.497 1.307A4.49 4.49 0 0 1 12 21.75a4.49 4.49 0 0 1-3.397-1.549 4.49 4.49 0 0 1-3.498-1.306 4.491 4.491 0 0 1-1.307-3.498A4.49 4.49 0 0 1 2.25 12c0-1.357.6-2.573 1.549-3.397a4.49 4.49 0 0 1 1.307-3.497 4.49 4.49 0 0 1 3.497-1.307Zm7.007 6.387a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
/>
</svg>
</template>
42 changes: 42 additions & 0 deletions nuxt/components/integrations/FlowRenderer.client.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<script setup>
const props = defineProps({
// Already-sanitised flow JSON string (escaped at build time in the composable).
flow: { type: String, required: true },
// Index used for the underlying DOM id, mirroring the Eleventy template.
index: { type: Number, required: true }
})

const container = ref(null)
let cleanedUp = false

async function loadAndRender () {
if (!container.value || cleanedUp) return
try {
// Dynamic import of the global JS lib bundled into Nuxt's public/js/ by build:js:nuxt.
// The /* @vite-ignore */ tells Vite to treat this as a runtime URL, not a bundled module.
const flowrendererUrl = '/js/flowrenderer.min.js'
const mod = await import(/* @vite-ignore */ flowrendererUrl)
const Renderer = mod.default ?? mod
const renderer = new Renderer()
// The flow string was JSON.stringify'd in the composable; parse it back here.
const parsed = JSON.parse(props.flow)
renderer.renderFlows(parsed, {
container: container.value,
direction: 'LR',
gridLines: true,
zoom: true,
labels: true,
autoZoom: true
})
} catch (err) {
console.error('FlowRenderer failed:', err)
}
}

onMounted(() => { void loadAndRender() })
onBeforeUnmount(() => { cleanedUp = true })
</script>

<template>
<div :id="`flow-renderer-${index}`" ref="container" class="flow-renderer-container" style="height: 400px;"></div>
</template>
47 changes: 47 additions & 0 deletions nuxt/components/integrations/InstallBox.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<script setup>
const installGifUrl = '/img/installing-node-red-node.gif'
const installPngUrl = '/images/integrations/palette-manager-install.png'
</script>

<template>
<div class="bg-white border border-gray-200 rounded-lg p-6 shadow-sm overflow-hidden">
<h3 class="text-lg font-bold mb-3 flex items-center gap-2">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-5 h-5"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M17.25 6.75 22.5 12l-5.25 5.25m-10.5 0L1.5 12l5.25-5.25m7.5-3-4.5 16.5" />
</svg>
Installation
</h3>
<p class="text-sm text-gray-600 mb-3">
Install in Node-RED via the
<a
href="https://flowfuse.com/node-red/getting-started/library/#using-the-palette-manager"
class="text-indigo-600 hover:text-indigo-800 font-semibold"
target="_blank"
rel="noopener noreferrer"
>palette manager</a>.
</p>
<img
:src="installGifUrl"
alt="Animation of the Node-RED palette manager: open Manage Palette, search for the node, then click Install."
loading="lazy"
width="1326"
height="720"
class="motion-reduce:hidden block w-full rounded border border-gray-200"
/>
<img
:src="installPngUrl"
alt="Node-RED Palette Manager dialog with the node selected and the Install button visible."
loading="lazy"
width="707"
height="376"
class="motion-safe:hidden block w-full rounded border border-gray-200"
/>
</div>
</template>
70 changes: 70 additions & 0 deletions nuxt/components/integrations/IntegrationCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<script setup>
const props = defineProps({
node: { type: Object, required: true },
// Set of node IDs that have a generated detail page on this site.
generatedIds: { type: Set, required: true }
})

const hasGeneratedPage = computed(() => props.generatedIds.has(props.node._id))
const href = computed(() =>
hasGeneratedPage.value
? `/integrations/${props.node._id}/`
: `https://flows.nodered.org/node/${props.node._id}`
)
const externalAttrs = computed(() =>
hasGeneratedPage.value
? {}
: { target: '_blank', rel: 'noopener noreferrer' }
)
const scope = computed(() => props.node.npmScope || props.node.npmOwners?.[0] || '')
const shortDescription = computed(() => {
if (!props.node.description) return ''
const words = props.node.description.split(' ')
return words.length > 15
? words.slice(0, 15).join(' ') + '...'
: props.node.description
})
</script>

<template>
<li class="integration-card group border border-gray-300 rounded-xl bg-white drop-shadow-md">
<a :href="href" v-bind="externalAttrs" class="h-48 flex flex-col">
<div class="integration-card--details p-3 grow min-h-0">
<div class="flex justify-between text-sm items-center gap-2">
<span class="truncate">
@{{ scope }}
<svg
v-if="!hasGeneratedPage"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4 inline-block ml-1 opacity-60"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
</span>
<span v-if="node.ffCertified" class="certified-pill" title="FlowFuse Certified">
<IntegrationsCertifiedIcon />
<span>Certified</span>
</span>
</div>
<label class="group-hover:text-indigo-600 cursor-pointer">{{ node.name }}</label>
<p class="text-sm my-2 leading-5">{{ shortDescription }}</p>
</div>
<div class="integration-card--meta flex justify-between bg-indigo-50/50 group-hover:bg-indigo-50 p-3 text-sm">
<div class="integration-card--stats">
<span>v{{ node.version }}<span class="ff-helper left-0 after:left-1/4">Version Number</span></span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9.75v6.75m0 0-3-3m3 3 3-3m-8.25 6a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
</svg>
{{ node.downloads.week }}
<span class="ff-helper right-0 after:left-3/4">Weekly Downloads</span>
</span>
</div>
</div>
</a>
</li>
</template>
18 changes: 18 additions & 0 deletions nuxt/components/integrations/IntegrationCardSkeleton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script setup></script>

<template>
<li class="integration-card pointer-events-none border border-gray-300 rounded-xl bg-white drop-shadow-md">
<span class="h-48 flex flex-col">
<div class="integration-card--details p-3 grow min-h-0">
<span class="placeholder-bar block w-1/2 h-5 mt-1 mb-3"><span class="placeholder-gradient"></span></span>
<span class="placeholder-bar block w-full h-16 opacity-50"><span class="placeholder-gradient"></span></span>
</div>
<div class="integration-card--meta flex justify-between bg-indigo-50/50 p-3 text-sm">
<div class="integration-card--stats">
<span class="placeholder-bar block h-5 w-5"><span class="placeholder-gradient"></span></span>
<span class="placeholder-bar block h-5 w-5"><span class="placeholder-gradient"></span></span>
</div>
</div>
</span>
</li>
</template>
21 changes: 20 additions & 1 deletion nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,30 @@ export default defineNuxtConfig({
nitro: {
preset: 'static',
prerender: {
routes: ['/terms', '/privacy-policy'],
routes: ['/terms', '/privacy-policy', '/integrations'],
crawlLinks: false
}
},

hooks: {
// Enumerate dynamic /integrations/{id}/ routes at build time so SSG generates them all.
// Uses `ofetch`/`cachedFetch` (NOT Nuxt's $fetch) because $fetch is only initialised
// at nitro runtime — this hook runs at config-time.
async 'nitro:config' (nitroConfig: import('nitropack').NitroConfig) {
if (nitroConfig.dev) return
const { buildEnrichedIntegrations } = await import('./server/utils/integrations-enrich')
const integrations = await buildEnrichedIntegrations()
if (integrations.length === 0) {
throw new Error('[nuxt] integrations enumeration returned 0 nodes — refusing to build a site with no detail pages')
}
const routes = integrations.map(n => `/integrations/${n._id}/`)
nitroConfig.prerender = nitroConfig.prerender || {}
// Dedup defensively in case the hook fires more than once.
nitroConfig.prerender.routes = [...new Set([...(nitroConfig.prerender.routes || []), ...routes])]
console.log(`[nuxt] enumerated ${routes.length} /integrations/{id}/ routes for prerender`)
}
},

// Dev proxying to 11ty is handled by server/middleware/legacy.ts
// to allow per-route exclusions as pages are migrated.
})
Loading