diff --git a/nuxt/components/integrations/CertifiedHero.vue b/nuxt/components/integrations/CertifiedHero.vue new file mode 100644 index 0000000000..16920aead3 --- /dev/null +++ b/nuxt/components/integrations/CertifiedHero.vue @@ -0,0 +1,98 @@ + + + diff --git a/nuxt/components/integrations/CertifiedIcon.vue b/nuxt/components/integrations/CertifiedIcon.vue new file mode 100644 index 0000000000..a30a9481d9 --- /dev/null +++ b/nuxt/components/integrations/CertifiedIcon.vue @@ -0,0 +1,15 @@ + diff --git a/nuxt/components/integrations/FlowRenderer.client.vue b/nuxt/components/integrations/FlowRenderer.client.vue new file mode 100644 index 0000000000..c9259edb6e --- /dev/null +++ b/nuxt/components/integrations/FlowRenderer.client.vue @@ -0,0 +1,42 @@ + + + diff --git a/nuxt/components/integrations/InstallBox.vue b/nuxt/components/integrations/InstallBox.vue new file mode 100644 index 0000000000..c56fa2a328 --- /dev/null +++ b/nuxt/components/integrations/InstallBox.vue @@ -0,0 +1,47 @@ + + + diff --git a/nuxt/components/integrations/IntegrationCard.vue b/nuxt/components/integrations/IntegrationCard.vue new file mode 100644 index 0000000000..3810a2bda9 --- /dev/null +++ b/nuxt/components/integrations/IntegrationCard.vue @@ -0,0 +1,70 @@ + + + diff --git a/nuxt/components/integrations/IntegrationCardSkeleton.vue b/nuxt/components/integrations/IntegrationCardSkeleton.vue new file mode 100644 index 0000000000..cb23400175 --- /dev/null +++ b/nuxt/components/integrations/IntegrationCardSkeleton.vue @@ -0,0 +1,18 @@ + + + diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index 6e20c39e0b..b4a726c856 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -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. }) diff --git a/nuxt/pages/integrations/[...id].vue b/nuxt/pages/integrations/[...id].vue new file mode 100644 index 0000000000..4db8f40acb --- /dev/null +++ b/nuxt/pages/integrations/[...id].vue @@ -0,0 +1,280 @@ + + + diff --git a/nuxt/pages/integrations/index.vue b/nuxt/pages/integrations/index.vue new file mode 100644 index 0000000000..81082d92f5 --- /dev/null +++ b/nuxt/pages/integrations/index.vue @@ -0,0 +1,220 @@ + + + diff --git a/nuxt/server/api/integrations/[...id].get.ts b/nuxt/server/api/integrations/[...id].get.ts new file mode 100644 index 0000000000..76fe20d02b --- /dev/null +++ b/nuxt/server/api/integrations/[...id].get.ts @@ -0,0 +1,17 @@ +import { defineEventHandler, getRouterParam, createError } from 'h3' +import { buildEnrichedIntegrations } from '../../utils/integrations-enrich' + +export default defineEventHandler(async (event) => { + const id = getRouterParam(event, 'id') + if (!id) { + throw createError({ statusCode: 400, statusMessage: 'Missing id' }) + } + + const all = await buildEnrichedIntegrations() + const node = all.find(n => n._id === id) + if (!node) { + throw createError({ statusCode: 404, statusMessage: `Integration not found: ${id}` }) + } + + return node +}) diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index 770d8a9037..fe847d7e42 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -1,8 +1,11 @@ -import { proxyRequest } from 'h3' +import { defineEventHandler, proxyRequest } from 'h3' // Routes that are handled by Nuxt pages (not proxied to 11ty). // Extend this list as pages are migrated. Trailing slashes are matched automatically. -const NUXT_ROUTES = new Set(['/terms', '/privacy-policy']) +const NUXT_ROUTES = new Set(['/terms', '/privacy-policy', '/integrations']) + +// Path prefixes handled by Nuxt. Used for dynamic routes like /integrations/{id}. +const NUXT_ROUTE_PREFIXES = ['/integrations/'] export default defineEventHandler(async (event) => { if (process.env.NODE_ENV !== 'development') return @@ -13,8 +16,10 @@ export default defineEventHandler(async (event) => { if (path.startsWith('/_nuxt/') || path.startsWith('/api/') || path.startsWith('/__')) return // Let Nuxt handle migrated pages (strip trailing slash and query string before matching) - const normalised = path.split('?')[0].replace(/\/$/, '') || '/' + const pathWithoutQuery = path.split('?')[0] + const normalised = pathWithoutQuery.replace(/\/$/, '') || '/' if (NUXT_ROUTES.has(normalised)) return + if (NUXT_ROUTE_PREFIXES.some(prefix => pathWithoutQuery.startsWith(prefix))) return // Proxy everything else to the 11ty dev server return proxyRequest(event, `http://localhost:8080${path}`) diff --git a/nuxt/server/utils/build-cache.ts b/nuxt/server/utils/build-cache.ts new file mode 100644 index 0000000000..96885022e9 --- /dev/null +++ b/nuxt/server/utils/build-cache.ts @@ -0,0 +1,72 @@ +/** + * Build-time HTTP cache. + * + * Mirrors the role of @11ty/eleventy-fetch in the old Eleventy data pipeline: + * persists GET responses to `.cache/integrations/` with per-call TTLs. Used by + * the integrations composable to avoid hammering npm / GitHub on every build + * and to survive GitHub's 60/hr anonymous rate limit. + * + * Built on `unstorage` (the same library Nitro uses internally) so the cache + * layer is idiomatic to Nuxt and trivially swappable to another driver later. + */ +import { createHash } from 'node:crypto' +import { resolve } from 'node:path' +import { ofetch } from 'ofetch' +import { createStorage } from 'unstorage' +import fsDriver from 'unstorage/drivers/fs-lite' + +// cwd, not import.meta.url — Nitro bundles this file and the rewritten URL resolves above filesystem root. +const CACHE_DIR = resolve(process.cwd(), '.cache/integrations') + +const storage = createStorage({ + driver: fsDriver({ base: CACHE_DIR }) +}) + +export type CachedResponseType = 'json' | 'text' + +export interface CachedFetchOptions { + /** Cache lifetime in milliseconds. Defaults to 1h. */ + ttlMs?: number + /** Response parsing mode. */ + type?: CachedResponseType + /** Extra HTTP headers (e.g. GitHub User-Agent). */ + headers?: Record + /** Tag for cache key naming so different call-sites don't collide. */ + namespace?: string +} + +interface CacheEntry { + url: string + fetchedAt: number + data: T +} + +function cacheKey (namespace: string, url: string): string { + const hash = createHash('sha1').update(url).digest('hex').slice(0, 16) + return `${namespace}:${hash}.json` +} + +/** + * Fetch a URL with a disk-backed cache. Failed fetches are NOT cached, so a + * transient outage doesn't poison the cache. + */ +export async function cachedFetch (url: string, opts: CachedFetchOptions = {}): Promise { + const { ttlMs = 60 * 60 * 1000, type = 'json', headers, namespace = 'fetch' } = opts + const key = cacheKey(namespace, url) + + const cached = await storage.getItem>(key) + if (cached && Date.now() - cached.fetchedAt < ttlMs) { + return cached.data + } + + const data = await ofetch(url, { + headers, + // Retry transient network failures so a single blip doesn't kill the build. + // (Eleventy-fetch retried internally; ofetch defaults to 1.) + retry: 2, + retryDelay: 500, + parseResponse: type === 'text' ? ((t: string) => t) as never : undefined + }) + await storage.setItem(key, { url, fetchedAt: Date.now(), data }) + return data +} diff --git a/nuxt/server/utils/integrations-enrich.ts b/nuxt/server/utils/integrations-enrich.ts new file mode 100644 index 0000000000..d6ec967a5d --- /dev/null +++ b/nuxt/server/utils/integrations-enrich.ts @@ -0,0 +1,318 @@ +/// +import MarkdownIt from 'markdown-it' +import MarkdownItAnchor from 'markdown-it-anchor' +import MarkdownItFootnote from 'markdown-it-footnote' +import MarkdownItAttrs from 'markdown-it-attrs' +// Relative paths (not `~/` aliases) because this file is also imported by +// nuxt.config.ts via jiti, which doesn't resolve Nuxt's path aliases. +import type { + Integration, + IntegrationCatalogEntry, + IntegrationExample +} from '../../types/integrations' +import { INTEGRATIONS_API } from '../../types/integrations' +import { cachedFetch } from './build-cache' + +const MAX_EXAMPLES_PER_NODE = 20 + +// Cache durations match the previous Eleventy data pipeline: +// - catalog/npm: change frequently (new versions daily) → 1h +// - GitHub directory listings + flow file contents: change rarely → 6h +const TTL_CATALOG_MS = 60 * 60 * 1000 +const TTL_NPM_MS = 60 * 60 * 1000 +const TTL_GITHUB_MS = 6 * 60 * 60 * 1000 + +const GITHUB_HEADERS = { + 'User-Agent': 'FlowFuse-Website', + Accept: 'application/vnd.github+json' +} + +// Markdown-it configured to match the Eleventy markdownLib (line ~1366 of .eleventy.js): +// html: true, plus anchor + footnote + attrs. (The code-clipboard plugin is Eleventy- +// runtime specific and intentionally not ported.) +const md = new MarkdownIt({ html: true }) + .use(MarkdownItAnchor, { permalink: MarkdownItAnchor.permalink.headerLink() }) + .use(MarkdownItFootnote) + .use(MarkdownItAttrs) + +/** + * Build the enriched node list for detail pages. + * + * Mirrors src/_data/integrations.js: top-50 by weekly downloads + all + * ffCertified nodes, each augmented with README + GitHub examples. + * + * Memoised at module level so the ~67 detail pages share a single fetch + * during `nuxt generate`. On rejection the cache is cleared so dev sessions + * can recover without a full restart. + */ +let _enrichedCache: Promise | null = null +export function buildEnrichedIntegrations (): Promise { + if (!_enrichedCache) { + _enrichedCache = _buildEnrichedIntegrations() + _enrichedCache.catch(() => { _enrichedCache = null }) + } + return _enrichedCache +} + +async function _buildEnrichedIntegrations (): Promise { + interface ApiResponse { catalogue: IntegrationCatalogEntry[] } + const data = await cachedFetch(INTEGRATIONS_API, { + ttlMs: TTL_CATALOG_MS, + namespace: 'catalog' + }) + const catalogue = data.catalogue ?? [] + + // Top 50 by weekly downloads + const topNodes = [...catalogue] + .sort((a, b) => (b.downloads?.week ?? 0) - (a.downloads?.week ?? 0)) + .slice(0, 50) + + const topNodesMap = new Map( + topNodes.map(node => [node._id, node]) + ) + + // Ensure all certified nodes are included even if outside the top 50 + for (const node of catalogue) { + if (node.ffCertified && !topNodesMap.has(node._id)) { + topNodes.push(node) + topNodesMap.set(node._id, node) + } + } + + const enriched = await Promise.all(topNodes.map(node => enrichNode(node))) + return enriched.sort(sortCertifiedThenDownloads) +} + +function sortCertifiedThenDownloads (a: Integration, b: Integration): number { + if (a.ffCertified && !b.ffCertified) return -1 + if (!a.ffCertified && b.ffCertified) return 1 + return (b.downloads?.week ?? 0) - (a.downloads?.week ?? 0) +} + +async function enrichNode (entry: IntegrationCatalogEntry): Promise { + const node: Integration = { ...entry } + + if (!node.categories) node.categories = [] + + // Mirror Eleventy's "catalogue_" prefix + "catalogue" group + node.categories = node.categories.map(c => c.includes('catalogue') ? c : `catalogue_${c}`) + if (!node.categories.includes('catalogue')) node.categories.push('catalogue') + + try { + const npm = await cachedFetch( + `https://registry.npmjs.org/${node._id}`, + { ttlMs: TTL_NPM_MS, namespace: 'npm' } + ) + + node.author = npm.author ?? node.author + node.maintainers = npm.maintainers ?? [] + node.homepage = npm.homepage + node.bugs = npm.bugs + node.repository = npm.repository + node.time = npm.time + node.lastUpdated = npm.time?.modified ?? npm.time?.[node.version] + node.created = npm.time?.created + node.license = npm.license ?? npm.versions?.[node.version]?.license + + if (npm.repository?.url) { + const repoUrl = cleanGitUrl(npm.repository.url) + const match = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/) + if (match && match[1] && match[2]) { + const owner = match[1] + const repo = match[2] + node.githubOwner = owner + node.githubRepo = repo + node.examples = await fetchExamples(owner, repo) + } + } + + if (npm.readme) { + const withAbsoluteAssets = rewriteRelativeAssets(npm.readme, node.githubOwner, node.githubRepo) + const rendered = md.render(withAbsoluteAssets) + const linked = rewriteIntegrationLinks(rendered, node) + // Strip / -{% endif %} -{% endfor %} - - - -{% endif %} diff --git a/src/integrations/index.njk b/src/integrations/index.njk deleted file mode 100644 index 4c68f7b313..0000000000 --- a/src/integrations/index.njk +++ /dev/null @@ -1,474 +0,0 @@ ---- -layout: default -sitemapPriority: 0.9 -title: Integrations -description: - Explore the list of integrations and modules available for your Node-RED projects. Created (and curated) by FlowFuse and the Node-RED community. -meta: - title: Integrations ---- - -{% extends 'layouts/catalog.njk' %} - -{% block title %} -Integrations -{% endblock %} - -{% block description %} -Explore the list of integrations and modules available for your Node-RED projects. Created (and curated) by FlowFuse and the Node-RED community. -{% endblock %} - -{% block content %} - -
-
-
-
- FlowFuse Certified -

Certified Nodes, backed by their authors and supported long-term

-

- 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. -

-
- - {# 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. #} - - Learn more {% include "components/icons/arrow-long-right.svg" %} - -
-
-
    -
  • - -
    -

    Vetted authors

    -

    Every Certified Node comes from a developer with a track record in their domain — not an anonymous npm publisher.

    -
    -
  • -
  • - -
    -

    Supported through production

    -

    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.

    -
    -
  • -
  • - -
    -

    Free or commercial, same bar

    -

    Some Certified Nodes are free and open; others target specific enterprise needs. The certification standard is the same.

    -
    -
  • -
-
-
-
-
- -
- -
- -
Loading...
-
-
    - - - -
-
    - -
    -
    -{% endblock %} - - diff --git a/src/integrations/integrations.njk b/src/integrations/integrations.njk deleted file mode 100644 index de2dd2e7d0..0000000000 --- a/src/integrations/integrations.njk +++ /dev/null @@ -1,24 +0,0 @@ ---- -pagination: - data: integrations - size: 1 - alias: integration -layout: layouts/integration.njk -eleventyComputed: - permalink: /integrations/{{ integration._id }}/ - title: "{{ integration._id }}" - description: "{{ integration.description }}" - certified: "{{ integration.ffCertified }}" - npmPackage: "{{ integration._id }}" - version: "{{ integration.version }}" - weeklyDownloads: "{{ integration.downloads.week }}" - npmScope: "{{ integration.npmScope or integration.npmOwners[0] }}" - repository: "{{ integration.repository }}" - authorName: "{{ integration.author.name or integration.author }}" - authorUrl: "{{ integration.author.url }}" - lastUpdated: "{{ integration.lastUpdated }}" - created: "{{ integration.created }}" - githubOwner: "{{ integration.githubOwner }}" - githubRepo: "{{ integration.githubRepo }}" ---- -{{ integration.readme | md | rewriteIntegrationLinks(integration) | safe }} diff --git a/tailwind.config.js b/tailwind.config.js index c9f5ae6ac8..c55a1181d9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,7 @@ const plugin = require('tailwindcss/plugin') module.exports = { - content: ['src/**/*.html','src/**/*.njk','src/**/*.md','src/**/*.svg','src/**/*.js','.eleventy.js'], + content: ['src/**/*.html','src/**/*.njk','src/**/*.md','src/**/*.svg','src/**/*.js','.eleventy.js','nuxt/components/**/*.vue','nuxt/pages/**/*.vue','nuxt/layouts/**/*.vue','nuxt/app.vue'], safelist: [ 'ml-4', 'ml-8',