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
10 changes: 1 addition & 9 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ console.info(`[11ty] Image build profile: ${IMAGE_BUILD_PROFILE}`)
module.exports = function(eleventyConfig) {

eleventyConfig.addDataExtension("yaml", contents => yaml.load(contents)); // Add support for YAML data files
eleventyConfig.setUseGitIgnore(false); // Blueprints are generated into gitignored src/blueprints/, so they must not be ignored
eleventyConfig.setUseGitIgnore(false); // Nothing generated into src/ is gitignored any more; kept until 11ty itself is retired
eleventyConfig.setWatchThrottleWaitTime(500); // in milliseconds
eleventyConfig.setFrontMatterParsingOptions({
excerpt: true,
Expand Down Expand Up @@ -132,7 +132,6 @@ module.exports = function(eleventyConfig) {

// Naive copy of images for backwards compatibility of non short-code image handling (use of <img or in CSS)
eleventyConfig.addPassthroughCopy("src/**/images/**/*");
eleventyConfig.addPassthroughCopy("src/blueprints/**/flow.json");
eleventyConfig.addPassthroughCopy("src/events/hm25-invite.ics");
eleventyConfig.addPassthroughCopy("src/webinars/2025/simplifying-opc-ua/opc-ua-webinar-flows.zip");
eleventyConfig.addPassthroughCopy("src/js/ai-expert-modal.js");
Expand Down Expand Up @@ -589,13 +588,6 @@ module.exports = function(eleventyConfig) {
</div>`
});

eleventyConfig.addShortcode("renderCompanyTile", function (company) {
return `<div class="company-tile">
<img class="company-tile-logo" src="${company.img}" />
<a href="${company.url}" class="no-underline text-gray-700">${company.name}</a>
</div>`
});

eleventyConfig.addShortcode("renderIntegration", function (integration) {
return `<div class="integration-tile">
<img class="integration-tile-icon" src="${integration.img}" />
Expand Down
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ _site
node_modules
src/handbook/media
nuxt/content/docs/
src/blueprints/*
!src/blueprints/*.njk
# Synced from FlowFuse/blueprint-library by nuxt/modules/blueprints-source.ts. The
# Build Site workflow force-adds this tree to the `live` branch that Netlify deploys,
# because the library is private and cannot be cloned from a build.
nuxt/content/blueprints/

# Local development config
.vscode/
Expand Down
57 changes: 57 additions & 0 deletions nuxt/components/BlueprintCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<script setup lang="ts">
// src/_includes/blueprints/blueprint-card.njk - one tile in the Blueprint Library grid.
//
// What the port changes on purpose:
// - The description is still printed unescaped, as `| safe` did. It comes from a README
// in FlowFuse/blueprint-library - a private repo whose submissions the team reviews -
// so it is repo content like any other page here, not visitor input.
// - `tileImage` ran each screenshot through eleventy-img at 380px. The image is now a
// plain path into nuxt/public/blueprints/, resized by @nuxt/image's Netlify provider.
import { blueprintAuthor, blueprintTagLabel, blueprintTags, deployUrl } from '../lib/blueprint-display.mjs'
import type { BlueprintListEntry } from '../composables/useBlueprintList'

const props = defineProps<{ entry: BlueprintListEntry }>()

const FALLBACK_IMAGE = '/images/og-blog.jpg'
const FALLBACK_IMAGE_ALT = 'Image with logo and the slogan: Elevate Node-RED with Flowfuse'

const tags = computed(() => blueprintTags(props.entry.tags))
const author = computed(() => blueprintAuthor(props.entry.author))
const image = computed(() => props.entry.image || FALLBACK_IMAGE)
const imageAlt = computed(() => props.entry.image ? `Image representing ${props.entry.title}` : FALLBACK_IMAGE_ALT)
</script>

<template>
<li class="flex flex-col h-full">
<div class="mb-2">
<label
v-for="tag in tags"
:key="tag"
class="text-gray-700 text-xs font-light rounded-sm bg-indigo-50 py-1.5 px-2 inline-block w-auto"
>{{ blueprintTagLabel(tag) }}</label>
</div>
<div class="grid bg-white ff-image-cover blueprint rounded-lg border drop-shadow-md hover:drop-shadow-lg grow">
<NuxtLink :to="`${entry.path}/`" class="w-full flex flex-col group hover:no-underline">
<div class="transition-transform group-hover:scale-105 ff-image-cover aspect-video border-b">
<img :src="image" :alt="imageAlt" width="380" loading="lazy" class="w-full h-auto">
</div>
<h5 class="mt-4 mb-0 group-hover:underline px-4 font-medium text-lg leading-6">{{ entry.title }}</h5>
<!-- eslint-disable-next-line vue/no-v-html -->
<p class="text-sm leading-normal font-light pt-1 mb-4 mt-3 px-4 text-gray-500" v-html="entry.description" />
</NuxtLink>
<div class="justify-self-end flex flex-row justify-between items-center w-full px-4 py-2 bg-indigo-50/50 mt-auto border-t">
<div class="flex flex-col">
<label class="text-xs">Author:</label>
<BlueprintCompanyTile :company="author" />
</div>
<a
v-if="entry.blueprintId"
:href="deployUrl(entry.blueprintId)"
class="ff-btn ff-btn--primary-outlined flex gap-2"
target="_blank"
rel="noopener"
>DEPLOY <UIcon name="i-heroicons-rocket-launch" class="size-6 shrink-0" /></a>
</div>
</div>
</li>
</template>
22 changes: 22 additions & 0 deletions nuxt/components/BlueprintCompanyTile.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<script setup lang="ts">
// src/_includes/blueprints/ - the `renderCompanyTile` shortcode from .eleventy.js, whose
// only two callers were the blueprint card and the blueprint layout. The .company-tile
// classes it emitted are still styled by src/css/style.css.
//
// The shortcode interpolated the company's fields straight into an HTML string. These come
// from a data file rather than page content, but they are still values from another
// repository, so they are bound as attributes and text here instead.
//
// The logo is decorative: the company name sits next to it as text, so an alt would just
// repeat it. The shortcode emitted no alt at all.
defineProps<{
company: { name: string, img: string, url: string }
}>()
</script>

<template>
<div class="company-tile">
<img class="company-tile-logo" :src="company.img" alt="">
<a :href="company.url" class="no-underline text-gray-700">{{ company.name }}</a>
</div>
</template>
35 changes: 35 additions & 0 deletions nuxt/components/BlueprintListing.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<script setup lang="ts">
// src/blueprints.njk plus src/_includes/blueprints/template.njk and the
// layouts/catalog.njk hero it extended: the paginated Blueprint Library grid.
//
// Shared by pages/blueprints/index.vue and pages/blueprints/[page].vue, the way
// <BlogListing> is shared by the blog's listing routes.
import { BLUEPRINTS_DESCRIPTION, BLUEPRINTS_TITLE } from '../composables/useBlueprintList'

const props = defineProps<{ page: number }>()

const { entries, totalPages } = useBlueprintList(() => props.page)
</script>

<template>
<div class="w-full px-6 page hero catalog-hero">
<div class="container m-auto text-center flex py-8 max-w-lg md:max-w-6xl">
<div class="text-left w-full">
<h1>{{ BLUEPRINTS_TITLE }}</h1>
<p class="md:w-9/12">{{ BLUEPRINTS_DESCRIPTION }}</p>
<div class="flex gap-2 mt-8">
<NuxtLink to="/blueprints/submit/" class="ff-btn ff-btn--primary flex-col uppercase">Submit Your Own</NuxtLink>
</div>
</div>
</div>
<div>
<div class="container m-auto text-left max-w-lg md:max-w-6xl pt-8 pb-12 w-full ff-full-bg gap-4">
<ul class="flex flex-col sm:grid md:grid-cols-2 lg:grid-cols-3 gap-x-6 gap-y-12 pb-10 border-b">
<BlueprintCard v-for="entry in entries" :key="entry.path" :entry="entry" />
</ul>
<Pagination base-path="/blueprints" :page="page" :total-pages="totalPages" />
<CtaContactUsLine />
</div>
</div>
</div>
</template>
16 changes: 16 additions & 0 deletions nuxt/components/CtaContactUsLine.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script setup lang="ts">

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I thought we already had a CTA component?

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 do, but they are all buttons through cta/CtaButton.vue. This one is the one-line text band with an inline link, ported from contact-us-cta-line.njk, so there is no button to share. Renamed it to CtaContactUsLine.vue so it sits with the rest of the family.

The wider point stands though: the teardown branch ends up with thirteen CTA-ish components. Consolidating them touches pages across five of the open migration PRs, so better as its own PR once 5777 is done.

// src/_includes/contact-us-cta-line.njk - the one-line band under the Blueprint Library
// grid and in the blueprint detail sidebar. A text band with an inline link, not a
// button, so it does not go through cta/CtaButton.vue like the other Cta* components.
// It shares their destination registry so the href stays in one place, but like the .njk
// it fires no event: adding tracking here would be a behaviour change, not a port.
import { CTA_DESTINATIONS } from '../lib/cta-destinations'

const HREF = CTA_DESTINATIONS.contactUs.href
</script>

<template>
<div class="bg-indigo-50 py-1 px-4 rounded-md w-full mx-auto text-center mt-12">
<p>Looking for help with your project? <NuxtLink :to="HREF" class="underline">Contact us</NuxtLink>; our experts will be happy to provide a solution for your needs.</p>
</div>
</template>
46 changes: 46 additions & 0 deletions nuxt/composables/useBlueprintList.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// The Blueprint Library listing, as src/blueprints.njk paginated it: 12 per page.
export const BLUEPRINTS_PAGE_SIZE = 12

// The listing copy, from src/blueprints.njk's frontmatter. `title` was the <h1> and
// `meta.title` the <title>; the wording differs between them in the source and is kept.
export const BLUEPRINTS_TITLE = 'Blueprint Library'
export const BLUEPRINTS_META_TITLE = 'Blueprints Library'
export const BLUEPRINTS_DESCRIPTION = 'Explore FlowFuse Blueprints, choose templates for quick setups, perfect for learning and fast solution-building. Customizable for unique needs. Simplify your Node-RED projects with FlowFuse Blueprints!'

export interface BlueprintListEntry {
path: string
title: string
description?: string
image?: string
tags?: string[]
author?: string
blueprintId?: string
}

// Ordered by path, descending. 11ty ordered the collection by date and reversed it, but no
// blueprint README carries a date, so every entry fell back to its file mtime - the CI
// checkout time, identical for all of them - and the tie broke on input path. Reverse path
// order is that same order, made explicit. The one blueprint that does set a `date`
// (manufacturing/oee-dashboard) sorted to the very end there and now sits with its
// category, which is the only visible difference.
const ORDER_FIELD = 'path'

// `page` is read through toValue so the slice follows a client-side move between listing
// pages, which reuses this component rather than remounting it.
export function useBlueprintList (page: MaybeRefOrGetter<number>) {
const { data: allEntries } = useAsyncData('blueprints-all', () =>
queryCollection('blueprints')
.select('path', 'title', 'description', 'image', 'tags', 'author', 'blueprintId')
.order(ORDER_FIELD, 'DESC')
.all() as Promise<BlueprintListEntry[]>
)

const totalPages = computed(() => Math.max(1, Math.ceil((allEntries.value || []).length / BLUEPRINTS_PAGE_SIZE)))

const entries = computed(() => {
const start = (Math.max(1, toValue(page)) - 1) * BLUEPRINTS_PAGE_SIZE
return (allEntries.value || []).slice(start, start + BLUEPRINTS_PAGE_SIZE)
})

return { entries, totalPages }
}
22 changes: 22 additions & 0 deletions nuxt/content.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,28 @@ export default defineContentConfig({
}).optional(),
})
}),
// Copied into nuxt/content/blueprints by modules/blueprints-source.ts from the
// separate FlowFuse/blueprint-library repository - see nuxt/lib/blueprints-sync.mjs.
// One file per blueprint, at <category>/<slug>.md, so the path is the route.
blueprints: defineCollection({
type: 'page',
source: 'blueprints/**/*.md',
schema: z.object({
description: z.string().optional(),
// Site-absolute by the time it lands here; the sync rewrites the
// blueprint-relative path the README authors.
image: z.string().optional(),
tags: z.array(z.string()).optional(),
// The id app.flowfuse.com deploys from. Optional so a new blueprint without
// one still builds; its Deploy button is then hidden rather than broken.
blueprintId: z.string().optional(),
// A partner slug resolved by nuxt/lib/blueprint-display.mjs. Unset means FlowFuse.
author: z.string().optional(),
// When the source README last changed, from the library's git history.
// Feeds the sitemap's lastmod the way docs' `updated` does.
updated: z.string().optional(),
})
}),
// Source files stay at src/customer-stories/ (11ty's historical location) rather than
// being copied into nuxt/content/ - keeps this migration a content-config-only change.
// The directory data file (src/customer-stories/customer-stories.json) sets
Expand Down
47 changes: 47 additions & 0 deletions nuxt/lib/blueprint-display.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// Display helpers for the Blueprint Library, ported from src/_includes/blueprints/.
// Kept free of Nuxt and Vue imports so it can be unit tested with `node --test`; the
// blueprint pages and cards are the only callers.
//
// Everything here reads data that lives in FlowFuse/blueprint-library, so each helper is
// deliberately tolerant of a value it has never seen.

// Was src/_data/companies/*.json, read only by the two blueprint templates. A blueprint
// names its author in frontmatter (`author: signl`); everything unattributed is FlowFuse's,
// which is the fallback both templates spelled out as `companies["flowfuse"]`.
const COMPANIES = {
flowfuse: { name: 'FlowFuse', img: '/images/flowfuse-icon.png', url: 'https://flowfuse.com' },
signl: { name: 'SIGNL4', img: '/images/signl4_logo.png', url: 'https://www.signl4.com/' },
}

export function blueprintAuthor (author) {
return COMPANIES[author] || COMPANIES.flowfuse
}

// Every blueprint carries the `blueprints` tag, which is what put it in the collection;
// only the rest describe it.
export function blueprintTags (tags) {
return (tags || []).filter(tag => tag !== 'blueprints')
}

/**
* The label for one tag, reproducing what the Nunjucks card built with
* `replace('-', ' ') | replace('20', '2.0') | title`.
*
* An already-uppercase tag is printed as authored, which is how MES and HMI keep their
* capitals. That check is also why `ai` renders as "Ai": the fix for that is to capitalise
* the tag in the library's frontmatter, not to keep a list of acronyms here.
*/
export function blueprintTagLabel (tag) {
if (tag === tag.toUpperCase()) return tag
return tag
.replace(/-/g, ' ')
.replace(/20/g, '2.0')
.split(' ')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
}

/** The "Deploy" button target. The id comes from the library, so it is encoded here. */
export function deployUrl (blueprintId) {
return `https://app.flowfuse.com/deploy/blueprint?blueprintId=${encodeURIComponent(blueprintId ?? '')}`
}
37 changes: 37 additions & 0 deletions nuxt/lib/blueprint-display.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { test } from 'node:test'
import assert from 'node:assert/strict'

import { blueprintAuthor, blueprintTagLabel, blueprintTags, deployUrl } from './blueprint-display.mjs'

test('blueprintAuthor resolves a named author', () => {
assert.deepEqual(blueprintAuthor('signl'), { name: 'SIGNL4', img: '/images/signl4_logo.png', url: 'https://www.signl4.com/' })
})

test('blueprintAuthor falls back to FlowFuse for an unattributed or unknown author', () => {
assert.equal(blueprintAuthor(undefined).name, 'FlowFuse')
assert.equal(blueprintAuthor('a-partner-with-no-data-file').name, 'FlowFuse')
})

test('blueprintTags drops the tag that defines the collection', () => {
assert.deepEqual(blueprintTags(['blueprints', 'manufacturing', 'MES']), ['manufacturing', 'MES'])
assert.deepEqual(blueprintTags(undefined), [])
})

test('blueprintTagLabel reproduces every label the library currently produces', () => {
assert.equal(blueprintTagLabel('manufacturing'), 'Manufacturing')
assert.equal(blueprintTagLabel('getting-started'), 'Getting Started')
assert.equal(blueprintTagLabel('dashboard-20'), 'Dashboard 2.0')
assert.equal(blueprintTagLabel('dashboard-2.0'), 'Dashboard 2.0')
assert.equal(blueprintTagLabel('other'), 'Other')
// Acronyms are authored in capitals and printed as authored.
assert.equal(blueprintTagLabel('MES'), 'MES')
assert.equal(blueprintTagLabel('HMI'), 'HMI')
// The one label the source tag gets wrong; see the helper's comment.
assert.equal(blueprintTagLabel('ai'), 'Ai')
})

test('deployUrl encodes the blueprint id', () => {
assert.equal(deployUrl('PaRL4JNeBM'), 'https://app.flowfuse.com/deploy/blueprint?blueprintId=PaRL4JNeBM')
assert.equal(deployUrl('a&b=c'), 'https://app.flowfuse.com/deploy/blueprint?blueprintId=a%26b%3Dc')
assert.equal(deployUrl(undefined), 'https://app.flowfuse.com/deploy/blueprint?blueprintId=')
})
Loading