diff --git a/.eleventy.js b/.eleventy.js
index 43c6881c3c..b63b3ce151 100644
--- a/.eleventy.js
+++ b/.eleventy.js
@@ -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,
@@ -132,7 +132,6 @@ module.exports = function(eleventyConfig) {
// Naive copy of images for backwards compatibility of non short-code image handling (use of `
});
- eleventyConfig.addShortcode("renderCompanyTile", function (company) {
- return `
`
- });
-
eleventyConfig.addShortcode("renderIntegration", function (integration) {
return `
diff --git a/.gitignore b/.gitignore
index 33e937894b..ec3c5c932c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/nuxt/components/BlueprintCard.vue b/nuxt/components/BlueprintCard.vue
new file mode 100644
index 0000000000..bc23715d6a
--- /dev/null
+++ b/nuxt/components/BlueprintCard.vue
@@ -0,0 +1,57 @@
+
+
+
+
Looking for help with your project? Contact us; our experts will be happy to provide a solution for your needs.
+
+
diff --git a/nuxt/composables/useBlueprintList.ts b/nuxt/composables/useBlueprintList.ts
new file mode 100644
index 0000000000..50ced6c3a7
--- /dev/null
+++ b/nuxt/composables/useBlueprintList.ts
@@ -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
and
+// `meta.title` the ; 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) {
+ const { data: allEntries } = useAsyncData('blueprints-all', () =>
+ queryCollection('blueprints')
+ .select('path', 'title', 'description', 'image', 'tags', 'author', 'blueprintId')
+ .order(ORDER_FIELD, 'DESC')
+ .all() as Promise
+ )
+
+ 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 }
+}
diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts
index a7ad8765f6..acd81d3e76 100644
--- a/nuxt/content.config.ts
+++ b/nuxt/content.config.ts
@@ -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 /.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
diff --git a/nuxt/lib/blueprint-display.mjs b/nuxt/lib/blueprint-display.mjs
new file mode 100644
index 0000000000..59235e671f
--- /dev/null
+++ b/nuxt/lib/blueprint-display.mjs
@@ -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 ?? '')}`
+}
diff --git a/nuxt/lib/blueprint-display.test.mjs b/nuxt/lib/blueprint-display.test.mjs
new file mode 100644
index 0000000000..076e34bbc3
--- /dev/null
+++ b/nuxt/lib/blueprint-display.test.mjs
@@ -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=')
+})
diff --git a/nuxt/lib/blueprints-markdown.mjs b/nuxt/lib/blueprints-markdown.mjs
new file mode 100644
index 0000000000..29fc5fd6b0
--- /dev/null
+++ b/nuxt/lib/blueprints-markdown.mjs
@@ -0,0 +1,103 @@
+// Pure transforms applied to a blueprint README from FlowFuse/blueprint-library before
+// @nuxt/content parses it. Kept free of Nuxt and filesystem imports so they can be
+// unit tested with `node --test`.
+//
+// A blueprint is authored as a README next to its own screenshots, so every image path in
+// it is relative to the blueprint's directory. Under 11ty that was fine: `scripts/copy_blueprints.js`
+// copied the whole directory into src/ and eleventy-img resolved each path against the page's
+// input file. There is no equivalent here - the markdown lands in nuxt/content/ and the
+// screenshots in nuxt/public/ - so the paths are rewritten to the public URL the assets end
+// up at, which is the one place both halves agree on.
+
+/** The public URL prefix a blueprint's own assets are copied to. */
+export function assetBaseFor (category, slug) {
+ return `/blueprints/${category}/${slug}`
+}
+
+// Everything up to the closing delimiter is frontmatter; a README with no frontmatter is
+// left entirely in the body, so an unfronted file is never silently reinterpreted.
+const FRONTMATTER = /^---[ \t]*\r?\n([\s\S]*?\r?\n)---[ \t]*\r?\n?/
+
+export function splitFrontmatter (content) {
+ const match = FRONTMATTER.exec(content)
+ if (!match) return { frontmatter: '', body: content }
+ return { frontmatter: match[1], body: content.slice(match[0].length) }
+}
+
+function joinFrontmatter (frontmatter, body) {
+ return frontmatter ? `---\n${frontmatter}---\n${body}` : body
+}
+
+/**
+ * Turn one authored image reference into the URL its file is served from.
+ *
+ * Left alone: anything already absolute, an external URL, and a data: URI. A blueprint
+ * that points at /images/... is pointing into the website's own asset tree, not its own
+ * directory, and rewriting it would break the reference.
+ */
+export function resolveAssetPath (assetBase, value) {
+ const trimmed = value.trim().replace(/^["']|["']$/g, '')
+ if (!trimmed) return trimmed
+ if (/^(?:[a-z][a-z0-9+.-]*:|\/\/|\/)/i.test(trimmed)) return trimmed
+ return `${assetBase}/${trimmed.replace(/^\.\//, '')}`
+}
+
+const FRONTMATTER_IMAGE = /^image:[ \t]*(\S.*?)[ \t]*$/m
+
+/** Point the card/OG image at the copied file. Read on /blueprints/, so it must be absolute. */
+export function rewriteFrontmatterImage (content, assetBase) {
+ const { frontmatter, body } = splitFrontmatter(content)
+ return joinFrontmatter(
+ frontmatter.replace(FRONTMATTER_IMAGE, (_, value) => `image: ${resolveAssetPath(assetBase, value)}`),
+ body
+ )
+}
+
+// A markdown image, split into its destination and its optional title string.
+//
+// The destination allows one level of balanced parentheses, which CommonMark permits and
+// three of the multi-user-dashboard screenshots rely on (`multi-users-dashboard(1).png`);
+// matching only up to the first ')' would rewrite half a filename. The title is captured
+// separately so it is put back untouched rather than treated as part of the path.
+const BODY_IMAGE = /(!\[[^\]]*\]\()((?:[^()\s]+|\([^()]*\))+)(\s+"[^"]*"|\s+'[^']*')?(\))/g
+
+/** Rewrite the relative image destinations in the body. */
+export function rewriteBodyImages (content, assetBase) {
+ const { frontmatter, body } = splitFrontmatter(content)
+ const rewritten = body.replace(
+ BODY_IMAGE,
+ (_, open, dest, title, close) => `${open}${resolveAssetPath(assetBase, dest)}${title ?? ''}${close}`
+ )
+ return joinFrontmatter(frontmatter, rewritten)
+}
+
+/**
+ * Record when the source README last changed, for the sitemap's lastmod.
+ *
+ * A blank date is left out rather than written as an empty key: YAML reads `updated:` with
+ * no value as null, which the collection's `z.string().optional()` rejects, and a schema
+ * failure drops the whole page. That happens whenever the library is not a git checkout.
+ */
+export function injectUpdated (content, updated) {
+ if (!updated) return content
+ const { frontmatter, body } = splitFrontmatter(content)
+ // A README that sets its own `updated:` keeps it: prepending regardless would write the
+ // key twice, and a duplicate YAML key is a parse error that drops the page.
+ if (/^updated:/m.test(frontmatter)) return content
+ return joinFrontmatter(`updated: ${updated}\n${frontmatter}`, body)
+}
+
+/** Drop the Eleventy-only frontmatter field that named the Nunjucks layout. */
+export function stripLayout (content) {
+ // Split first, as rewriteFrontmatterImage does: an unanchored pass over the whole file
+ // would delete a body line that happens to begin "layout:" when there is no such key.
+ const { frontmatter, body } = splitFrontmatter(content)
+ return joinFrontmatter(frontmatter.replace(/^layout:[^\n]*\n/m, ''), body)
+}
+
+export function processBlueprint (content, { assetBase, updated }) {
+ let out = stripLayout(content)
+ out = rewriteFrontmatterImage(out, assetBase)
+ out = rewriteBodyImages(out, assetBase)
+ return injectUpdated(out, updated)
+}
diff --git a/nuxt/lib/blueprints-markdown.test.mjs b/nuxt/lib/blueprints-markdown.test.mjs
new file mode 100644
index 0000000000..fb82454fc4
--- /dev/null
+++ b/nuxt/lib/blueprints-markdown.test.mjs
@@ -0,0 +1,134 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+
+import {
+ assetBaseFor,
+ injectUpdated,
+ processBlueprint,
+ resolveAssetPath,
+ rewriteBodyImages,
+ rewriteFrontmatterImage,
+ splitFrontmatter,
+ stripLayout,
+} from './blueprints-markdown.mjs'
+
+const BASE = assetBaseFor('manufacturing', 'oee-calculator')
+
+test('assetBaseFor builds the public prefix for a blueprint', () => {
+ assert.equal(BASE, '/blueprints/manufacturing/oee-calculator')
+})
+
+test('resolveAssetPath resolves the shapes the library actually authors', () => {
+ assert.equal(resolveAssetPath(BASE, 'oee-calculator.png'), `${BASE}/oee-calculator.png`)
+ assert.equal(resolveAssetPath(BASE, './oee-calculator.png'), `${BASE}/oee-calculator.png`)
+ assert.equal(resolveAssetPath(BASE, '"./oee-calculator.png"'), `${BASE}/oee-calculator.png`)
+ assert.equal(resolveAssetPath(BASE, 'images/flow.png'), `${BASE}/images/flow.png`)
+})
+
+test('resolveAssetPath leaves anything that is not blueprint-relative alone', () => {
+ assert.equal(resolveAssetPath(BASE, '/images/og-blog.jpg'), '/images/og-blog.jpg')
+ assert.equal(resolveAssetPath(BASE, 'https://example.com/a.png'), 'https://example.com/a.png')
+ assert.equal(resolveAssetPath(BASE, '//example.com/a.png'), '//example.com/a.png')
+ assert.equal(resolveAssetPath(BASE, 'data:image/png;base64,AAAA'), 'data:image/png;base64,AAAA')
+})
+
+test('splitFrontmatter keeps a file with no frontmatter entirely in the body', () => {
+ const { frontmatter, body } = splitFrontmatter('# Title\n\nbody\n')
+ assert.equal(frontmatter, '')
+ assert.equal(body, '# Title\n\nbody\n')
+})
+
+test('rewriteFrontmatterImage rewrites only the image key', () => {
+ const input = '---\ntitle: OEE Calculator\nimage: "./oee-calculator.png"\nblueprintId: PaRL4JNeBM\n---\n\n'
+ const out = rewriteFrontmatterImage(input, BASE)
+ assert.match(out, /^image: \/blueprints\/manufacturing\/oee-calculator\/oee-calculator\.png$/m)
+ assert.match(out, /^title: OEE Calculator$/m)
+ // The body is this function's business only through rewriteBodyImages.
+ assert.match(out, /!\[shot\]\(\.\/oee-calculator\.png\)/)
+})
+
+test('rewriteBodyImages rewrites relative destinations and leaves absolute ones', () => {
+ const input = '---\nimage: ./a.png\n---\n\n\n\n'
+ const out = rewriteBodyImages(input, BASE)
+ assert.match(out, /!\[one\]\(\/blueprints\/manufacturing\/oee-calculator\/images\/flow\.png\)/)
+ assert.match(out, /!\[two\]\(\/blueprints\/manufacturing\/oee-calculator\/b\.png\)/)
+ assert.match(out, /!\[three\]\(\/images\/og-blog\.jpg\)/)
+ // Frontmatter is left to rewriteFrontmatterImage.
+ assert.match(out, /^image: \.\/a\.png$/m)
+})
+
+test('rewriteBodyImages keeps the {attr} suffix markdown-it-attrs authored', () => {
+ const out = rewriteBodyImages('{data-zoomable}\n', BASE)
+ assert.equal(out, `{data-zoomable}\n`)
+})
+
+test('rewriteBodyImages keeps a parenthesised filename whole', () => {
+ const out = rewriteBodyImages('.png)\n', BASE)
+ assert.equal(out, `.png)\n`)
+})
+
+test('rewriteBodyImages puts a title string back after the rewritten path', () => {
+ // The form multi-user-dashboard authors: a balanced-paren filename *and* a title.
+ const out = rewriteBodyImages('.png "User view")\n', BASE)
+ assert.equal(out, `.png "User view")\n`)
+ assert.equal(
+ rewriteBodyImages("\n", BASE),
+ `\n`
+ )
+})
+
+test('stripLayout drops the Nunjucks layout field', () => {
+ const out = stripLayout('---\ntitle: x\nlayout: layouts/blueprint.njk\nblueprintId: y\n---\nbody\n')
+ assert.equal(out, '---\ntitle: x\nblueprintId: y\n---\nbody\n')
+})
+
+test('processBlueprint applies every transform once', () => {
+ const input = [
+ '---',
+ 'title: Multi-User Dashboard',
+ 'image: multi-users-dashboard(1).png',
+ 'layout: layouts/blueprint.njk',
+ 'blueprintId: MaEL1KN326',
+ '---',
+ '.png "User view")',
+ '',
+ ].join('\n')
+
+ const base = assetBaseFor('flowfuse-dashboard', 'multi-user-dashboard')
+ const out = processBlueprint(input, { assetBase: base, updated: '2026-06-16 18:32:27 +0200' })
+
+ assert.equal(out, [
+ '---',
+ 'updated: 2026-06-16 18:32:27 +0200',
+ 'title: Multi-User Dashboard',
+ `image: ${base}/multi-users-dashboard(1).png`,
+ 'blueprintId: MaEL1KN326',
+ '---',
+ `.png "User view")`,
+ '',
+ ].join('\n'))
+})
+
+test('injectUpdated leaves the content alone when there is no date to record', () => {
+ const input = '---\ntitle: x\n---\nbody\n'
+ assert.equal(injectUpdated(input, ''), input)
+ assert.equal(processBlueprint(input, { assetBase: BASE, updated: '' }), input)
+})
+
+test('processBlueprint gives a README with no frontmatter one, rather than dropping the date', () => {
+ const out = processBlueprint('# Title\n', { assetBase: BASE, updated: '2026-01-01 00:00:00 +0000' })
+ assert.equal(out, '---\nupdated: 2026-01-01 00:00:00 +0000\n---\n# Title\n')
+})
+
+// stripLayout used to run unanchored over the whole file.
+test('stripLayout leaves a body line that begins "layout:" alone', () => {
+ const content = ['---', 'title: Example', '---', '', 'layout: how the flow is arranged', ''].join('\n')
+ assert.match(stripLayout(content), /^layout: how the flow is arranged$/m)
+})
+
+// A duplicate YAML key is a parse error, and a page that fails to parse is a page that
+// silently does not exist.
+test('injectUpdated leaves a README that already sets updated alone', () => {
+ const content = ['---', 'title: Example', 'updated: 2026-01-01', '---', '', 'Body', ''].join('\n')
+ assert.equal(injectUpdated(content, '2026-09-14 10:00:00 +0000'), content)
+})
diff --git a/nuxt/lib/blueprints-sync.mjs b/nuxt/lib/blueprints-sync.mjs
index 65c472456c..b76ef2b43f 100644
--- a/nuxt/lib/blueprints-sync.mjs
+++ b/nuxt/lib/blueprints-sync.mjs
@@ -1,20 +1,35 @@
-// Resolves the FlowFuse blueprint library for a build and copies its content into
-// src/blueprints (11ty's blueprint source - see .eleventy.js's setUseGitIgnore(false)
-// note). Mirrors nuxt/lib/docs-sync.mjs's local -> sibling -> clone precedence, but the
-// source repo (FlowFuse/blueprint-library) is private, so the clone step authenticates
-// with a minted GitHub App installation token instead of cloning anonymously.
+// Resolves the FlowFuse Blueprint Library for a build and copies it into
+// nuxt/content/blueprints (the markdown) and nuxt/public/blueprints (the screenshots and
+// flow exports). Kept free of Nuxt imports so `node --test` can exercise it directly.
+//
+// This replaces scripts/copy_blueprints.mjs, which wrote into src/blueprints/ for 11ty.
+// The library is a separate, private repository, so the clone step authenticates with a
+// minted GitHub App installation token rather than cloning anonymously - the same path
+// nuxt/lib/docs-sync.mjs takes for the public docs repo, with credentials added.
+//
+// Precedence is local -> sibling -> clone -> whatever is already on disk. That last case
+// is a contributor without access to FlowFuse/blueprint-library: the build continues and
+// /blueprints/ is empty rather than failing for them. A production deploy has the App
+// credentials and so always reaches the clone, which is why an empty result there is
+// fatal (see nuxt/modules/blueprints-source.ts).
import { execFileSync } from 'node:child_process'
-import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync, cpSync } from 'node:fs'
-import { basename, join, relative } from 'node:path'
+import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
+import { basename, join } from 'node:path'
-// Imported lazily (inside cloneBlueprints, not here) because it pulls in @octokit/auth-app.
-// CI checks out blueprint-library as a sibling and calls `npm run blueprints` before
-// `npm install` runs - see nuxt/lib/docs-sync.mjs's own note on staying dependency-free -
-// so a static import here would crash a build that never even takes the clone path. Only
-// Netlify's production build (no sibling checkout) reaches the clone path, and by then
-// npm install has already completed.
+import { assetBaseFor, processBlueprint } from './blueprints-markdown.mjs'
+
+// Whatever checkout sits next to the website repo wins, which is where the Build Site
+// workflow puts it.
+export const SIBLING_PATHS = ['../blueprint-library']
+
+// What a blueprint directory is allowed to publish. Everything production served came
+// down to screenshots and the flow export; an allowlist keeps a stray file in the library
+// from being republished from flowfuse.com by accident. `package.json` carries each
+// blueprint's Node-RED dependencies and is deliberately not one of them.
+const ASSET_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.json'])
+const ASSET_DENYLIST = new Set(['package.json', 'package-lock.json'])
const REPO_OWNER = 'FlowFuse'
const REPO_NAME = 'blueprint-library'
@@ -25,35 +40,42 @@ const CLONE_BACKOFF_MS = 2000
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
+export const MANIFEST_FILE = '.source.json'
+
/**
- * Decide where the blueprints come from. Pure: touches nothing, so the precedence is
- * testable.
+ * Decide where the blueprints come from. Pure: touches nothing but `exists`, so the
+ * precedence is testable.
*
- * 1. `BLUEPRINTS_LOCAL` - an explicit checkout path
+ * 1. `FLOWFUSE_BLUEPRINTS_LOCAL` - an explicit checkout path
* 2. a sibling checkout of blueprint-library
- * 3. a clone, authenticated with the GitHub App - only if credentials are configured
- * 4. skip - matches the previous copy_blueprints.js behaviour for contributors without
- * access to the (private) blueprint-library repo
+ * 3. an authenticated clone, when the GitHub App credentials are configured
+ * 4. nothing, in which case the caller keeps the tree it already has
*/
export function resolveSource ({ repoRoot, env = process.env, exists = existsSync }) {
- const local = env.BLUEPRINTS_LOCAL
+ const local = env.FLOWFUSE_BLUEPRINTS_LOCAL
if (local) {
+ // A typo here would otherwise fall through to the committed tree and quietly
+ // publish yesterday's blueprints while looking like it honoured the variable.
if (!exists(local)) {
- throw new Error(`BLUEPRINTS_LOCAL is set but ${local} does not exist`)
+ throw new Error(`FLOWFUSE_BLUEPRINTS_LOCAL is set but ${local} does not exist`)
}
- return { kind: 'local', dir: local }
+ return { kind: 'local', libraryDir: local }
}
- const sibling = join(repoRoot, '..', 'blueprint-library')
- if (exists(sibling)) {
- return { kind: 'sibling', dir: sibling }
+ for (const sibling of SIBLING_PATHS) {
+ const libraryDir = join(repoRoot, sibling)
+ if (exists(libraryDir)) {
+ return { kind: 'sibling', libraryDir }
+ }
}
+ // Only a build with the App credentials can reach the private library; everyone else
+ // falls through to whatever is already on disk.
if (env.GH_BOT_APP_ID && env.GH_BOT_APP_KEY) {
return { kind: 'clone', ref: env.BLUEPRINTS_REF || DEFAULT_REF }
}
- return { kind: 'skip' }
+ return { kind: 'prebuilt' }
}
/**
@@ -125,136 +147,174 @@ function gitOutput (cwd, args) {
}
}
+function isAsset (name) {
+ if (ASSET_DENYLIST.has(name)) return false
+ const dot = name.lastIndexOf('.')
+ return dot > 0 && ASSET_EXTENSIONS.has(name.slice(dot).toLowerCase())
+}
+
/**
- * Copy one blueprint markdown file, stamping it with its last-commit date and rewriting
- * its `image:` frontmatter path to match where it lands under src/blueprints. Ported
- * as-is from the previous scripts/copy_blueprints.js.
+ * A blueprint is `//README.md`. Directory names are lower-cased on the way
+ * out, as copy_blueprints.js did, so the published URL never depends on how the directory
+ * happened to be capitalised in the library.
*/
-function writeBlueprintMarkdown ({ sourceRoot, srcPath, destPath, inputRelDir }) {
- const relPath = relative(sourceRoot, srcPath)
- const updated = gitOutput(sourceRoot, ['log', '-1', '--pretty=format:%ci', '--', relPath])
-
- const content = readFileSync(srcPath, 'utf8')
- let body = `---\nupdated: ${updated}\n---\n${content}`
- if (/^---/.test(content)) {
- // The original file starts with yaml front-matter, so remove the double-delimiter
- // we've just introduced.
- body = body.replace(/---\r?\n---\r?\n/s, '')
- }
-
- // tileImage's shortcode (.eleventy.js) resolves item.data.image relative to 11ty's
- // input folder (src/), not as a filesystem or site-root path - so this stays relative,
- // e.g. "blueprints/foo/bar/img.png", never "src/blueprints/..." or "/blueprints/...".
- const imageRegex = /^image:\s*(\S.+)$/m
- if (imageRegex.test(body)) {
- body = body.replace(imageRegex, (match, p1) => {
- const relImage = p1.replace(/^"\.\//, '').replace(/"$/, '')
- return `image: ${join(inputRelDir, relImage)}`
- })
+function collectSourceBlueprints (libraryDir, skipped = []) {
+ const found = []
+ for (const category of readdirSync(libraryDir, { withFileTypes: true })) {
+ if (!category.isDirectory() || category.name.startsWith('.')) continue
+ for (const slug of readdirSync(join(libraryDir, category.name), { withFileTypes: true })) {
+ if (!slug.isDirectory() || slug.name.startsWith('.')) continue
+ // A directory with no README.md is not a blueprint page. Record it: a rename
+ // upstream (README.markdown, readme.md on a case-sensitive runner) looks exactly
+ // like this and would otherwise drop a live page with nothing in the log.
+ if (!existsSync(join(libraryDir, category.name, slug.name, 'README.md'))) {
+ skipped.push(join(category.name, slug.name))
+ continue
+ }
+ found.push({
+ sourceDir: join(category.name, slug.name),
+ category: category.name.toLowerCase(),
+ slug: slug.name.toLowerCase(),
+ })
+ }
}
-
- writeFileSync(destPath, body)
+ return found
}
-// Removes only the entries under destDir that no longer exist in the source - never
-// submit.njk (this repo's own "Submit Your Own" page, not something blueprint-library
-// provides) and never an entry copyTree/writeBlueprints is about to repopulate anyway.
-// Deliberately narrower than docs-sync.mjs's full wipe: the rest of copyTree already
-// overwrites every file in place on each sync (11ty sees a cheap "changed" event), so
-// wiping unaffected entries too would turn that into a delete+recreate of the entire tree
-// on every sync - noisy for 11ty's watcher and briefly 404s a page mid-rebuild for no reason.
-function clearOrphans (destDir, currentNames) {
- if (!existsSync(destDir)) return
- for (const entry of readdirSync(destDir, { withFileTypes: true })) {
- if (entry.name === 'submit.njk' || currentNames.has(entry.name)) continue
- rmSync(join(destDir, entry.name), { recursive: true, force: true })
+function copyAssets ({ libraryDir, sourceDir, destDir, relDir = '' }) {
+ for (const entry of readdirSync(join(libraryDir, sourceDir, relDir), { withFileTypes: true })) {
+ if (entry.name.startsWith('.')) continue
+ const relPath = join(relDir, entry.name)
+ if (entry.isDirectory()) {
+ copyAssets({ libraryDir, sourceDir, destDir, relDir: relPath })
+ } else if (isAsset(entry.name)) {
+ const destPath = join(destDir, relPath)
+ mkdirSync(join(destPath, '..'), { recursive: true })
+ cpSync(join(libraryDir, sourceDir, relPath), destPath)
+ }
}
}
-// The name an entry lands under once copied - directories are lower-cased and a README
-// becomes that section's index, same transforms copyTree itself applies below.
-function destinationName (entry) {
- return entry.isDirectory() ? entry.name.toLowerCase() : entry.name.replace(/README/, 'index')
+function writeBlueprints ({ libraryDir, contentDir, publicDir, skipped = [] }) {
+ const sources = collectSourceBlueprints(libraryDir, skipped)
+
+ rmSync(contentDir, { recursive: true, force: true })
+ rmSync(publicDir, { recursive: true, force: true })
+ // Only the per-blueprint loop below recreates contentDir, so a library that resolves but
+ // holds nothing would leave the manifest write with nowhere to go - a raw ENOENT, after
+ // the committed trees are already gone, instead of the "no blueprints" report the
+ // callers are written to give.
+ mkdirSync(contentDir, { recursive: true })
+
+ const entries = []
+ for (const { sourceDir, category, slug } of sources) {
+ // Argument array, not a shell string: the path comes from directory names in the
+ // source repo, so quoting it into a shell command would be an injection path.
+ const updated = gitOutput(libraryDir, ['log', '-1', '--pretty=format:%ci', '--', join(sourceDir, 'README.md')])
+ const raw = readFileSync(join(libraryDir, sourceDir, 'README.md'), 'utf8')
+
+ const destPath = join(contentDir, category, `${slug}.md`)
+ mkdirSync(join(destPath, '..'), { recursive: true })
+ writeFileSync(destPath, processBlueprint(raw, { assetBase: assetBaseFor(category, slug), updated }), 'utf8')
+
+ copyAssets({ libraryDir, sourceDir, destDir: join(publicDir, category, slug) })
+ entries.push({ category, slug })
+ }
+ return entries.sort((a, b) => `${a.category}/${a.slug}`.localeCompare(`${b.category}/${b.slug}`))
}
-function copyTree (srcDir, destDir, sourceRoot, inputRelDir) {
- mkdirSync(destDir, { recursive: true })
- const entries = readdirSync(srcDir, { withFileTypes: true }).filter(entry => !entry.name.startsWith('.'))
- // Prunes at every level copyTree recurses into - a single file removed from an
- // otherwise-unchanged blueprint (an old screenshot, a renamed README) is caught here
- // too, not just a whole blueprint folder disappearing.
- clearOrphans(destDir, new Set(entries.map(destinationName)))
-
- for (const entry of entries) {
- const srcPath = join(srcDir, entry.name)
- if (entry.isDirectory()) {
- const lowerCaseName = entry.name.toLowerCase()
- copyTree(srcPath, join(destDir, lowerCaseName), sourceRoot, join(inputRelDir, lowerCaseName))
- continue
- }
-
- const destPath = join(destDir, entry.name.replace(/README/, 'index'))
- if (entry.name.endsWith('.md')) {
- writeBlueprintMarkdown({ sourceRoot, srcPath, destPath, inputRelDir })
- } else {
- cpSync(srcPath, destPath)
+/** What is already on disk, for the case where no library checkout is available. */
+export function collectPublishedBlueprints (contentDir) {
+ if (!existsSync(contentDir)) return []
+ const entries = []
+ for (const category of readdirSync(contentDir, { withFileTypes: true })) {
+ if (!category.isDirectory() || category.name.startsWith('.')) continue
+ for (const file of readdirSync(join(contentDir, category.name))) {
+ if (file.endsWith('.md')) entries.push({ category: category.name, slug: basename(file, '.md') })
}
}
+ return entries.sort((a, b) => `${a.category}/${a.slug}`.localeCompare(`${b.category}/${b.slug}`))
}
/**
- * Populate src/blueprints from `dir` (one category folder per top-level entry, one
- * blueprint per folder below that) and return the manifest describing what was published.
- * The top-level category list is pruned here, since `dir` itself also holds files
- * (LICENSE, README.md) that copyTree would otherwise treat as content to copy; everything
- * below a category is pruned by copyTree itself as it recurses.
+ * Populate nuxt/content/blueprints and nuxt/public/blueprints, and return a manifest
+ * describing what was published.
+ */
+/**
+ * Write one resolved checkout into nuxt/content/blueprints and nuxt/public/blueprints.
+ * Split out of syncBlueprints so the cloned and already-on-disk routes share it.
*/
-function writeBlueprints ({ dir, websiteRoot, kind, ref }) {
- const destRoot = join(websiteRoot, 'src', 'blueprints')
- const categories = readdirSync(dir, { withFileTypes: true })
- .filter(entry => entry.isDirectory() && !entry.name.startsWith('.'))
- clearOrphans(destRoot, new Set(categories.map(entry => entry.name)))
-
- for (const category of categories) {
- const categorySrcDir = join(dir, category.name)
- copyTree(categorySrcDir, join(destRoot, basename(categorySrcDir)), dir, join('blueprints', basename(categorySrcDir)))
+function publish ({ libraryDir, kind, contentDir, publicDir, logger }) {
+
+ // What the last sync published, read before writeBlueprints clears the tree. The
+ // workflow commits whatever is on disk afterwards, so a library that resolves but is
+ // incomplete would quietly unpublish live pages on a green build. Only zero entries is
+ // fatal (modules/blueprints-source.ts), so a shrink has to be visible in the log.
+ const published = collectPublishedBlueprints(contentDir)
+
+ const skipped = []
+ const entries = writeBlueprints({ libraryDir: libraryDir, contentDir, publicDir, skipped })
+
+ if (skipped.length) {
+ logger.warn(`Skipped ${skipped.length} director(ies) under ${libraryDir} with no README.md: ${skipped.join(', ')}`)
+ }
+ const synced = new Set(entries.map(({ category, slug }) => `${category}/${slug}`))
+ const dropped = published
+ .map(({ category, slug }) => `${category}/${slug}`)
+ .filter(key => !synced.has(key))
+ if (dropped.length) {
+ logger.warn(`${dropped.length} blueprint page(s) published before are not in this sync and will be unpublished: ${dropped.join(', ')}`)
}
- return {
+ const manifest = {
source: kind,
- ref: ref || gitOutput(dir, ['rev-parse', '--abbrev-ref', 'HEAD']),
- sha: gitOutput(dir, ['rev-parse', 'HEAD']),
+ ref: gitOutput(libraryDir, ['rev-parse', '--abbrev-ref', 'HEAD']),
+ sha: gitOutput(libraryDir, ['rev-parse', 'HEAD']),
syncedAt: new Date().toISOString(),
+ count: entries.length,
}
+ writeFileSync(join(contentDir, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`, 'utf8')
+
+ logger.info(`Blueprints synced from ${manifest.source} (${manifest.ref || 'unknown'} ${manifest.sha.slice(0, 8) || 'unknown'}): ${entries.length} page(s)`)
+ return { ...manifest, entries }
}
/**
- * Populate src/blueprints and return the manifest describing what was published, or null
- * if there was no source to sync from (matches the previous copy_blueprints.js's
- * "skipping" behaviour for contributors without access to blueprint-library).
+ * Populate nuxt/content/blueprints and nuxt/public/blueprints, and return a manifest
+ * describing what was published.
+ *
+ * Async because the clone route awaits a minted installation token; the callers in
+ * scripts/sync_blueprints.mjs and nuxt/modules/blueprints-source.ts await it.
*/
-export async function syncBlueprints ({ repoRoot, env = process.env, logger = console } = {}) {
+export async function syncBlueprints ({ repoRoot, nuxtRoot, env = process.env, logger = console } = {}) {
+ const contentDir = join(nuxtRoot, 'content', 'blueprints')
+ const publicDir = join(nuxtRoot, 'public', 'blueprints')
const source = resolveSource({ repoRoot, env })
- if (source.kind === 'skip') {
- logger.info('Blueprint library not found and no GH_BOT_APP_ID/GH_BOT_APP_KEY configured - skipping')
- return null
+ if (source.kind === 'prebuilt') {
+ const entries = collectPublishedBlueprints(contentDir)
+ logger.info(`No blueprint-library checkout found; using the ${entries.length} blueprint page(s) already in nuxt/content/blueprints`)
+ // The two trees are committed together by the Build Site workflow. Pages without
+ // their screenshots would build and deploy silently, showing broken images on
+ // every blueprint, so say so here rather than leave it to be noticed on the site.
+ if (entries.length && !existsSync(publicDir)) {
+ logger.warn(`${entries.length} blueprint page(s) are published but ${publicDir} is missing, so their screenshots will 404`)
+ }
+ return { source: source.kind, ref: '', sha: '', entries }
}
- let manifest
if (source.kind === 'clone') {
logger.info(`Cloning ${REPO_OWNER}/${REPO_NAME} from ${source.ref}...`)
const tmpDir = await cloneBlueprints(source.ref, env, logger)
try {
- manifest = writeBlueprints({ dir: tmpDir, websiteRoot: repoRoot, kind: source.kind, ref: source.ref })
+ return publish({ libraryDir: tmpDir, kind: source.kind, contentDir, publicDir, logger })
} finally {
+ // The clone is a few hundred MB of blobless history; a build that runs this
+ // twice would otherwise leave both copies behind in the runner's tmp.
if (existsSync(tmpDir)) rmSync(tmpDir, { recursive: true, force: true })
}
- } else {
- logger.info(`Using ${source.kind} blueprints from ${source.dir}`)
- manifest = writeBlueprints({ dir: source.dir, websiteRoot: repoRoot, kind: source.kind })
}
- logger.info(`Blueprints synced from ${manifest.source} (${manifest.ref} ${manifest.sha.slice(0, 8) || 'unknown'})`)
- return manifest
+ logger.info(`Using ${source.kind} blueprints from ${source.libraryDir}`)
+ return publish({ libraryDir: source.libraryDir, kind: source.kind, contentDir, publicDir, logger })
}
diff --git a/nuxt/lib/blueprints-sync.test.mjs b/nuxt/lib/blueprints-sync.test.mjs
new file mode 100644
index 0000000000..ebed760716
--- /dev/null
+++ b/nuxt/lib/blueprints-sync.test.mjs
@@ -0,0 +1,277 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { collectPublishedBlueprints, resolveSource, syncBlueprints } from './blueprints-sync.mjs'
+
+const repoRoot = '/repo/website'
+
+const resolve = (env, present = []) => resolveSource({
+ repoRoot,
+ env,
+ exists: (path) => present.includes(path),
+})
+
+test('an explicit path wins over a sibling checkout', () => {
+ const source = resolve(
+ { FLOWFUSE_BLUEPRINTS_LOCAL: '/elsewhere/blueprint-library' },
+ ['/elsewhere/blueprint-library', '/repo/blueprint-library'],
+ )
+
+ assert.deepEqual(source, { kind: 'local', libraryDir: '/elsewhere/blueprint-library' })
+})
+
+test('a mistyped explicit path throws rather than falling back', () => {
+ assert.throws(
+ () => resolve({ FLOWFUSE_BLUEPRINTS_LOCAL: '/typo' }, ['/repo/blueprint-library']),
+ /FLOWFUSE_BLUEPRINTS_LOCAL is set but/,
+ )
+})
+
+test('a sibling checkout is used when no path is set', () => {
+ const source = resolve({}, ['/repo/blueprint-library'])
+ assert.deepEqual(source, { kind: 'sibling', libraryDir: '/repo/blueprint-library' })
+})
+
+// The library is private, so there is no clone fallback: a checkout without it keeps
+// whatever pages the build workflow already committed.
+test('the App credentials route to a clone, ahead of the published tree', () => {
+ assert.deepEqual(
+ resolveSource({
+ repoRoot: '/repo',
+ env: { GH_BOT_APP_ID: 'id', GH_BOT_APP_KEY: 'key' },
+ exists: () => false,
+ }),
+ { kind: 'clone', ref: 'main' },
+ )
+})
+
+test('BLUEPRINTS_REF picks the ref the clone checks out', () => {
+ assert.equal(
+ resolveSource({
+ repoRoot: '/repo',
+ env: { GH_BOT_APP_ID: 'id', GH_BOT_APP_KEY: 'key', BLUEPRINTS_REF: 'staging' },
+ exists: () => false,
+ }).ref,
+ 'staging',
+ )
+})
+
+// A sibling checkout is cheaper and is what CI provides, so it must win even where the
+// credentials are also present - otherwise every CI build would clone needlessly.
+test('a sibling checkout wins over the App credentials', () => {
+ assert.equal(
+ resolveSource({
+ repoRoot: '/repo',
+ env: { GH_BOT_APP_ID: 'id', GH_BOT_APP_KEY: 'key' },
+ exists: (path) => path === join('/repo', '..', 'blueprint-library'),
+ }).kind,
+ 'sibling',
+ )
+})
+
+test('no checkout anywhere falls back to the published tree', () => {
+ assert.deepEqual(resolve({}, []), { kind: 'prebuilt' })
+})
+
+async function withTempDirs (run) {
+ const root = mkdtempSync(join(tmpdir(), 'blueprints-sync-'))
+ try {
+ await run(root)
+ } finally {
+ rmSync(root, { recursive: true, force: true })
+ }
+}
+
+const silentLogger = { info () {}, warn () {} }
+
+function writeLibrary (libraryDir) {
+ mkdirSync(join(libraryDir, 'manufacturing', 'oee-calculator', 'images'), { recursive: true })
+ writeFileSync(join(libraryDir, 'manufacturing', 'oee-calculator', 'README.md'), [
+ '---',
+ 'title: OEE Calculator',
+ 'image: "./oee-calculator.png"',
+ 'layout: layouts/blueprint.njk',
+ 'blueprintId: PaRL4JNeBM',
+ '---',
+ '',
+ '',
+ ].join('\n'))
+ writeFileSync(join(libraryDir, 'manufacturing', 'oee-calculator', 'oee-calculator.png'), 'png')
+ writeFileSync(join(libraryDir, 'manufacturing', 'oee-calculator', 'images', 'flow.png'), 'png')
+ writeFileSync(join(libraryDir, 'manufacturing', 'oee-calculator', 'flow.json'), '{}')
+ writeFileSync(join(libraryDir, 'manufacturing', 'oee-calculator', 'package.json'), '{}')
+ // A category directory holding no README is not a blueprint.
+ mkdirSync(join(libraryDir, 'other', 'work-in-progress'), { recursive: true })
+}
+
+test('syncBlueprints publishes the markdown, its assets and nothing else', async () => {
+ await withTempDirs(async (root) => {
+ const libraryDir = join(root, 'blueprint-library')
+ const nuxtRoot = join(root, 'website', 'nuxt')
+ writeLibrary(libraryDir)
+
+ const manifest = await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot,
+ env: { FLOWFUSE_BLUEPRINTS_LOCAL: libraryDir },
+ logger: silentLogger,
+ })
+
+ assert.equal(manifest.source, 'local')
+ assert.deepEqual(manifest.entries, [{ category: 'manufacturing', slug: 'oee-calculator' }])
+
+ const page = readFileSync(join(nuxtRoot, 'content', 'blueprints', 'manufacturing', 'oee-calculator.md'), 'utf8')
+ assert.match(page, /^image: \/blueprints\/manufacturing\/oee-calculator\/oee-calculator\.png$/m)
+ assert.match(page, /!\[flow\]\(\/blueprints\/manufacturing\/oee-calculator\/images\/flow\.png\)/)
+ assert.doesNotMatch(page, /layout:/)
+ // The fixture is a plain directory, not a git checkout, so there is no date to
+ // record and the key is left out entirely rather than written empty. See
+ // injectUpdated in blueprints-markdown.mjs for why that matters.
+ assert.doesNotMatch(page, /^updated:/m)
+
+ const publicDir = join(nuxtRoot, 'public', 'blueprints', 'manufacturing', 'oee-calculator')
+ assert.ok(existsSync(join(publicDir, 'oee-calculator.png')))
+ assert.ok(existsSync(join(publicDir, 'images', 'flow.png')))
+ assert.ok(existsSync(join(publicDir, 'flow.json')))
+ assert.ok(!existsSync(join(publicDir, 'package.json')))
+ })
+})
+
+test('syncBlueprints replaces a page that has gone from the library', async () => {
+ await withTempDirs(async (root) => {
+ const libraryDir = join(root, 'blueprint-library')
+ const nuxtRoot = join(root, 'website', 'nuxt')
+ writeLibrary(libraryDir)
+
+ const stale = join(nuxtRoot, 'content', 'blueprints', 'other', 'retired.md')
+ mkdirSync(join(stale, '..'), { recursive: true })
+ writeFileSync(stale, '---\ntitle: Retired\n---\n')
+
+ await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot,
+ env: { FLOWFUSE_BLUEPRINTS_LOCAL: libraryDir },
+ logger: silentLogger,
+ })
+
+ assert.ok(!existsSync(stale))
+ })
+})
+
+test('syncBlueprints keeps the published tree when no library is available', async () => {
+ await withTempDirs(async (root) => {
+ const nuxtRoot = join(root, 'website', 'nuxt')
+ const contentDir = join(nuxtRoot, 'content', 'blueprints')
+ mkdirSync(join(contentDir, 'other'), { recursive: true })
+ writeFileSync(join(contentDir, 'other', 'mobile-alerting.md'), '---\ntitle: Mobile Alerting\n---\n')
+
+ const manifest = await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot,
+ env: {},
+ logger: silentLogger,
+ })
+
+ assert.equal(manifest.source, 'prebuilt')
+ assert.deepEqual(manifest.entries, [{ category: 'other', slug: 'mobile-alerting' }])
+ assert.ok(existsSync(join(contentDir, 'other', 'mobile-alerting.md')))
+ })
+})
+
+test('syncBlueprints warns when the published pages have no screenshots beside them', async () => {
+ await withTempDirs(async (root) => {
+ const nuxtRoot = join(root, 'website', 'nuxt')
+ const contentDir = join(nuxtRoot, 'content', 'blueprints')
+ mkdirSync(join(contentDir, 'other'), { recursive: true })
+ writeFileSync(join(contentDir, 'other', 'mobile-alerting.md'), '---\ntitle: Mobile Alerting\n---\n')
+
+ const warnings = []
+ await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot,
+ env: {},
+ logger: { info () {}, warn: (message) => warnings.push(message) },
+ })
+
+ assert.equal(warnings.length, 1)
+ assert.match(warnings[0], /screenshots will 404/)
+ })
+})
+
+// The Build Site workflow commits whatever the sync leaves on disk and force-pushes it, so
+// an incomplete library unpublishes live pages on a green build. Only zero entries is fatal,
+// which makes the log the one place a shrink can show up.
+test('syncBlueprints names the pages a shrunken library would unpublish', async () => {
+ await withTempDirs(async (root) => {
+ const nuxtRoot = join(root, 'website', 'nuxt')
+ const contentDir = join(nuxtRoot, 'content', 'blueprints')
+ mkdirSync(join(contentDir, 'other'), { recursive: true })
+ writeFileSync(join(contentDir, 'other', 'mobile-alerting.md'), '---\ntitle: Mobile Alerting\n---\n')
+
+ const libraryDir = join(root, 'blueprint-library')
+ writeLibrary(libraryDir)
+
+ const warnings = []
+ await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot,
+ env: { FLOWFUSE_BLUEPRINTS_LOCAL: libraryDir },
+ logger: { info () {}, warn: (message) => warnings.push(message) },
+ })
+
+ assert.equal(warnings.length, 2)
+ assert.ok(warnings.some(message => /will be unpublished: other\/mobile-alerting/.test(message)))
+ })
+})
+
+// A README renamed upstream looks identical to a directory that was never a blueprint.
+test('syncBlueprints names a library directory it skipped for a missing README', async () => {
+ await withTempDirs(async (root) => {
+ const nuxtRoot = join(root, 'website', 'nuxt')
+ const libraryDir = join(root, 'blueprint-library')
+ writeLibrary(libraryDir)
+ // A blueprint directory whose README has been renamed away upstream. Named rather
+ // than lowercased, because a case-insensitive filesystem would still find readme.md.
+ mkdirSync(join(libraryDir, 'manufacturing', 'downtime-tracker'), { recursive: true })
+ writeFileSync(join(libraryDir, 'manufacturing', 'downtime-tracker', 'index.md'), '# Downtime\n')
+
+ const warnings = []
+ await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot,
+ env: { FLOWFUSE_BLUEPRINTS_LOCAL: libraryDir },
+ logger: { info () {}, warn: (message) => warnings.push(message) },
+ })
+
+ assert.equal(warnings.length, 1)
+ assert.match(warnings[0], /manufacturing\/downtime-tracker/)
+ assert.match(warnings[0], /other\/work-in-progress/)
+ })
+})
+
+// An empty-but-present library is what a stale or half-finished checkout looks like. It has
+// to reach the callers as zero entries, which is what they are written to report on, rather
+// than as an ENOENT from writing the manifest into a directory the sync just deleted.
+test('syncBlueprints reports zero entries for a library that holds no blueprints', async () => {
+ await withTempDirs(async (root) => {
+ const libraryDir = join(root, 'blueprint-library')
+ mkdirSync(libraryDir, { recursive: true })
+
+ const manifest = await syncBlueprints({
+ repoRoot: join(root, 'website'),
+ nuxtRoot: join(root, 'website', 'nuxt'),
+ env: { FLOWFUSE_BLUEPRINTS_LOCAL: libraryDir },
+ logger: silentLogger,
+ })
+
+ assert.equal(manifest.source, 'local')
+ assert.deepEqual(manifest.entries, [])
+ })
+})
+
+test('collectPublishedBlueprints reports nothing for a tree that was never written', () => {
+ assert.deepEqual(collectPublishedBlueprints('/nonexistent/content/blueprints'), [])
+})
diff --git a/nuxt/modules/blueprints-source.ts b/nuxt/modules/blueprints-source.ts
new file mode 100644
index 0000000000..5b49e73ab1
--- /dev/null
+++ b/nuxt/modules/blueprints-source.ts
@@ -0,0 +1,47 @@
+import { defineNuxtModule, useLogger } from '@nuxt/kit'
+import { dirname } from 'node:path'
+
+// Lives in nuxt/lib/, not alongside this file: Nuxt auto-registers everything in
+// nuxt/modules/ as a Nuxt module, so a plain helper there fails the build.
+// @ts-ignore untyped module, kept as plain JS so `node --test` can run it directly
+import { syncBlueprints } from '../lib/blueprints-sync.mjs'
+import { BLUEPRINTS_PAGE_SIZE } from '../composables/useBlueprintList'
+
+const logger = useLogger('blueprints-source')
+
+export default defineNuxtModule({
+ meta: { name: 'blueprints-source' },
+ async setup (_options, nuxt) {
+ const nuxtRoot = nuxt.options.rootDir
+ const repoRoot = dirname(nuxtRoot)
+
+ const { entries } = await syncBlueprints({ repoRoot, nuxtRoot, logger })
+
+ // A production deploy has the GitHub App credentials, so it always reaches the
+ // clone; resolving nothing there means the clone produced an empty library.
+ // Publishing an empty Blueprint Library looks like a content change rather than a
+ // broken build, and on the netlify preset the pages would still SSR, so nothing
+ // else would flag it.
+ //
+ // Only that context is fatal. A deploy preview and a contributor without access to
+ // the private library legitimately resolve nothing, and should still get a site.
+ if (entries.length === 0) {
+ const message = '[blueprints-source] no blueprints resolved - check out FlowFuse/blueprint-library beside this repo, set FLOWFUSE_BLUEPRINTS_LOCAL, or configure GH_BOT_APP_ID/GH_BOT_APP_KEY'
+ if (process.env.CONTEXT === 'production') throw new Error(message)
+ logger.warn(`${message} (continuing: /blueprints/ will be empty)`)
+ }
+
+ const pageCount = Math.max(1, Math.ceil(entries.length / BLUEPRINTS_PAGE_SIZE))
+ const routes = [
+ '/blueprints/',
+ ...Array.from({ length: pageCount - 1 }, (_, i) => `/blueprints/${i + 2}/`),
+ '/blueprints/submit/',
+ ...entries.map(({ category, slug }: { category: string, slug: string }) => `/blueprints/${category}/${slug}/`),
+ ]
+
+ nuxt.options.nitro.prerender ??= {}
+ const existing = (nuxt.options.nitro.prerender.routes as string[] | undefined) ?? []
+ nuxt.options.nitro.prerender.routes = [...existing, ...routes]
+ logger.info(`Added ${routes.length} blueprint routes for prerendering`)
+ },
+})
diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts
index 33c2631785..2834295bc2 100644
--- a/nuxt/nuxt.config.ts
+++ b/nuxt/nuxt.config.ts
@@ -161,7 +161,7 @@ const blogAuthorRoutes = collectAuthorRoutes(blogFiles, [join(__dirname, '../src
// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
- modules: ['@nuxt/ui', '@nuxt/content', '@nuxtjs/seo', 'nuxt-studio', '@nuxt/image', './modules/docs-source', 'nuxt-llms'],
+ modules: ['@nuxt/ui', '@nuxt/content', '@nuxtjs/seo', 'nuxt-studio', '@nuxt/image', './modules/docs-source', './modules/blueprints-source', 'nuxt-llms'],
// Captured at build time (Netlify sets CONTEXT during the build, but passes only URL,
// SITE_NAME and SITE_ID to the deployed Function at runtime), then baked in via
diff --git a/nuxt/pages/blueprints/[category]/[slug].vue b/nuxt/pages/blueprints/[category]/[slug].vue
new file mode 100644
index 0000000000..c8316b2fdc
--- /dev/null
+++ b/nuxt/pages/blueprints/[category]/[slug].vue
@@ -0,0 +1,90 @@
+
+
+
+
diff --git a/src/_includes/blueprints/template.njk b/src/_includes/blueprints/template.njk
deleted file mode 100644
index f257ffbab0..0000000000
--- a/src/_includes/blueprints/template.njk
+++ /dev/null
@@ -1,25 +0,0 @@
-{% extends 'layouts/catalog.njk' %}
-
-{% block title %}
-Blueprint Library
-{% endblock %}
-
-{% block 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!
-{% endblock %}
-
-{% block actions %}
-Submit Your Own
-{% endblock %}
-
-{% block content %}
-
-
- {%- asyncEach item in blueprints -%}
- {% include "blueprints/blueprint-card.njk" %}
- {%- endeach -%}
-
- {% include "blog/pagination.njk" %}
- {% include "contact-us-cta-line.njk" %}
-
-{% endblock %}
\ No newline at end of file
diff --git a/src/_includes/contact-us-cta-line.njk b/src/_includes/contact-us-cta-line.njk
deleted file mode 100644
index 2fe00cd032..0000000000
--- a/src/_includes/contact-us-cta-line.njk
+++ /dev/null
@@ -1,3 +0,0 @@
-
-
Looking for help with your project? Contact us; our experts will be happy to provide a solution for your needs.
-
\ No newline at end of file
diff --git a/src/_includes/homepage_blueprints.njk b/src/_includes/homepage_blueprints.njk
deleted file mode 100644
index 4129e347d2..0000000000
--- a/src/_includes/homepage_blueprints.njk
+++ /dev/null
@@ -1,24 +0,0 @@
-{% set blueprintIds = ["KoV08ENaBx", "e85N3lmyX6", "x5pLEb06ed"] %}
-{%- for item in collections.blueprints | reverse -%}
- {% if item.data.blueprintId in blueprintIds %}
-
-
- {%- for tag in item.data.tags -%}
- {% if tag !== "blueprints" %}
-
- {% endif %}
- {%- endfor %}
-
\ No newline at end of file
diff --git a/src/blueprints.njk b/src/blueprints.njk
deleted file mode 100644
index b23077f138..0000000000
--- a/src/blueprints.njk
+++ /dev/null
@@ -1,17 +0,0 @@
----
-layout: default
-sitemapPriority: 0.9
-pagination:
- data: collections.blueprints
- size: 12
- alias: blueprints
- reverse: true
-title: Blueprint Library
-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!
-meta:
- title: Blueprints Library
----
-
-{% include "blueprints/template.njk" %}
\ No newline at end of file
diff --git a/src/blueprints/submit.njk b/src/blueprints/submit.njk
deleted file mode 100644
index 9a6bde8544..0000000000
--- a/src/blueprints/submit.njk
+++ /dev/null
@@ -1,52 +0,0 @@
----
-layout: default
-title: Submit Your Blueprint
-description:
Share your Blueprints to help the FlowFuse community build a best-in-class library of Node-RED templates and build recognition of yourself as a Node-RED expert.
We are accepting submissions of Blueprints that are useful and professionally well-constructed. Submissions will be reviewed by the FlowFuse team. Accepted submissions will be featured on the Blueprints page and announced on social media.
-sitemapPriority: 0.7
-pagination:
- data: collections.blueprints
- size: 3
- alias: blueprints
- reverse: true
-meta:
- description: Submit your own Blueprints for publishing in the FlowFuse Blueprint Library
-hubspot:
- script: "hubspot/hs-form.njk"
- formId: c627fbcb-a3e0-46dd-978b-122461b7835c
- cta: "blueprint-upload"
- reference: "blueprint-upload"
----
-
-
-
-
-
{{ title }}
-
Share your Blueprints to help the FlowFuse community build best-in-class Node-RED templates and build recognition of yourself as a Node-RED expert.
-
We are accepting submissions of Blueprints that are useful and professionally well-constructed.
Submissions will be reviewed by the FlowFuse team.
-
Accepted submissions will be featured on the Blueprints page and announced on social media.
-
-
-
- {% include hubspot.script %}
-
-
-
-
-
-
Existing Blueprints
-
Here are a few examples of Blueprints from our collection. You can take a look at the full collection in our Blueprint Library