From bbfaabfad8fed0147ae3c15b7827b5588ee590f6 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 14 Sep 2026 10:35:20 +0200 Subject: [PATCH 1/5] blueprints: serve /blueprints/ from Nuxt Ports the Blueprint Library, the last route group still generated by 11ty. src/blueprints.njk, src/blueprints/submit.njk and the templates they pulled in are replaced by pages under nuxt/pages/blueprints/. The blueprints themselves live in the separate FlowFuse/blueprint-library repo, so this also moves the build step that brings them in. scripts/copy_blueprints.js wrote a Nunjucks-shaped tree into src/blueprints/; nuxt/lib/blueprints-sync.mjs writes the markdown into nuxt/content/blueprints/ and the screenshots and flow exports into nuxt/public/blueprints/, and nuxt/modules/blueprints-source.ts runs it during a Nuxt build or dev start the way docs-source does for the product docs. scripts/sync_blueprints.mjs is the same sync for callers with no Nuxt yet, which is what the Build Site workflow runs before committing to the `live` branch Netlify deploys. The library is private, so unlike the docs there is no clone fallback. Source precedence is FLOWFUSE_BLUEPRINTS_LOCAL, then a sibling checkout, then the tree already committed by that workflow. A production build that resolves nothing fails rather than publishing an empty library. --- .eleventy.js | 10 +- .gitignore | 6 +- nuxt/components/BlueprintCard.vue | 57 ++++ nuxt/components/BlueprintCompanyTile.vue | 22 ++ nuxt/components/BlueprintListing.vue | 35 ++ nuxt/components/ContactUsCtaLine.vue | 10 + nuxt/components/SiteArt.vue | 20 ++ nuxt/composables/useBlueprintList.ts | 46 +++ nuxt/content.config.ts | 22 ++ nuxt/lib/blueprint-display.mjs | 47 +++ nuxt/lib/blueprint-display.test.mjs | 37 +++ nuxt/lib/blueprints-markdown.mjs | 103 ++++++ nuxt/lib/blueprints-markdown.test.mjs | 134 ++++++++ nuxt/lib/blueprints-sync.mjs | 300 +++++++++++------- nuxt/lib/blueprints-sync.test.mjs | 277 ++++++++++++++++ nuxt/modules/blueprints-source.ts | 47 +++ nuxt/nuxt.config.ts | 2 +- nuxt/pages/blueprints/[category]/[slug].vue | 90 ++++++ nuxt/pages/blueprints/[page].vue | 24 ++ nuxt/pages/blueprints/index.vue | 21 ++ nuxt/pages/blueprints/submit.vue | 89 ++++++ .../api/__sitemap__/content-urls.get.ts | 7 + nuxt/server/middleware/legacy.ts | 2 +- nuxt/utils/siteArt.ts | 196 ++++++++++++ package.json | 14 +- scripts/copy_blueprints.mjs | 12 - scripts/gen-site-art.mjs | 103 ++++++ scripts/sync_blueprints.mjs | 23 ++ src/_data/companies/flowfuse.json | 6 - src/_data/companies/signl.json | 6 - src/_includes/blog/pagination.njk | 10 - src/_includes/blueprints/blueprint-card.njk | 38 --- src/_includes/blueprints/template.njk | 25 -- src/_includes/contact-us-cta-line.njk | 3 - src/_includes/homepage_blueprints.njk | 24 -- src/_includes/layouts/blueprint.njk | 45 --- src/_includes/layouts/catalog.njk | 19 -- src/blueprints.njk | 17 - src/blueprints/submit.njk | 52 --- 39 files changed, 1604 insertions(+), 397 deletions(-) create mode 100644 nuxt/components/BlueprintCard.vue create mode 100644 nuxt/components/BlueprintCompanyTile.vue create mode 100644 nuxt/components/BlueprintListing.vue create mode 100644 nuxt/components/ContactUsCtaLine.vue create mode 100644 nuxt/components/SiteArt.vue create mode 100644 nuxt/composables/useBlueprintList.ts create mode 100644 nuxt/lib/blueprint-display.mjs create mode 100644 nuxt/lib/blueprint-display.test.mjs create mode 100644 nuxt/lib/blueprints-markdown.mjs create mode 100644 nuxt/lib/blueprints-markdown.test.mjs create mode 100644 nuxt/lib/blueprints-sync.test.mjs create mode 100644 nuxt/modules/blueprints-source.ts create mode 100644 nuxt/pages/blueprints/[category]/[slug].vue create mode 100644 nuxt/pages/blueprints/[page].vue create mode 100644 nuxt/pages/blueprints/index.vue create mode 100644 nuxt/pages/blueprints/submit.vue create mode 100644 nuxt/utils/siteArt.ts delete mode 100644 scripts/copy_blueprints.mjs create mode 100755 scripts/gen-site-art.mjs create mode 100644 scripts/sync_blueprints.mjs delete mode 100644 src/_data/companies/flowfuse.json delete mode 100644 src/_data/companies/signl.json delete mode 100644 src/_includes/blog/pagination.njk delete mode 100644 src/_includes/blueprints/blueprint-card.njk delete mode 100644 src/_includes/blueprints/template.njk delete mode 100644 src/_includes/contact-us-cta-line.njk delete mode 100644 src/_includes/homepage_blueprints.njk delete mode 100644 src/_includes/layouts/blueprint.njk delete mode 100644 src/_includes/layouts/catalog.njk delete mode 100644 src/blueprints.njk delete mode 100644 src/blueprints/submit.njk diff --git a/.eleventy.js b/.eleventy.js index 348c8ca69e..c88f498e27 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, @@ -134,7 +134,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 `
- - ${company.name} -
` - }); - 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..a52cb6c5c8 --- /dev/null +++ b/nuxt/components/BlueprintCard.vue @@ -0,0 +1,57 @@ + + + diff --git a/nuxt/components/BlueprintCompanyTile.vue b/nuxt/components/BlueprintCompanyTile.vue new file mode 100644 index 0000000000..1af10198ca --- /dev/null +++ b/nuxt/components/BlueprintCompanyTile.vue @@ -0,0 +1,22 @@ + + + diff --git a/nuxt/components/BlueprintListing.vue b/nuxt/components/BlueprintListing.vue new file mode 100644 index 0000000000..6ee26c2e65 --- /dev/null +++ b/nuxt/components/BlueprintListing.vue @@ -0,0 +1,35 @@ + + + diff --git a/nuxt/components/ContactUsCtaLine.vue b/nuxt/components/ContactUsCtaLine.vue new file mode 100644 index 0000000000..9e93a58e95 --- /dev/null +++ b/nuxt/components/ContactUsCtaLine.vue @@ -0,0 +1,10 @@ + + + diff --git a/nuxt/components/SiteArt.vue b/nuxt/components/SiteArt.vue new file mode 100644 index 0000000000..7d0d8acee0 --- /dev/null +++ b/nuxt/components/SiteArt.vue @@ -0,0 +1,20 @@ + + + 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<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 } +} diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index 798a23ee20..4ced86b81c 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 <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 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![shot](./oee-calculator.png)\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![one](./images/flow.png)\n![two](b.png)\n![three](/images/og-blog.jpg)\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('![shot](./images/a.png){data-zoomable}\n', BASE) + assert.equal(out, `![shot](${BASE}/images/a.png){data-zoomable}\n`) +}) + +test('rewriteBodyImages keeps a parenthesised filename whole', () => { + const out = rewriteBodyImages('![admin view](./multi-users-dashboard(1).png)\n', BASE) + assert.equal(out, `![admin view](${BASE}/multi-users-dashboard(1).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('![user view](./multi-users-dashboard(1).png "User view")\n', BASE) + assert.equal(out, `![user view](${BASE}/multi-users-dashboard(1).png "User view")\n`) + assert.equal( + rewriteBodyImages("![shot](./a.png 'A title')\n", BASE), + `![shot](${BASE}/a.png 'A title')\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', + '---', + '![user view](./multi-users-dashboard(1).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', + '---', + `![user view](${base}/multi-users-dashboard(1).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 `<category>/<slug>/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', + '---', + '![flow](./images/flow.png)', + '', + ].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 fbcfd8847f..d7559280c1 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -150,7 +150,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..58326d1045 --- /dev/null +++ b/nuxt/pages/blueprints/[category]/[slug].vue @@ -0,0 +1,90 @@ +<script setup lang="ts"> +// Ported from src/_includes/layouts/blueprint.njk (11ty), which this replaces: one +// blueprint's page. The body is the README from FlowFuse/blueprint-library, copied into +// nuxt/content/blueprints by modules/blueprints-source.ts. +// +// 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. +// - Screenshots are served from nuxt/public/blueprints/ and resized by @nuxt/image +// instead of by eleventy-img. The `{data-zoomable}` attribute the READMEs carry stays +// an inert attribute, exactly as it now does on the migrated /docs pages. +// - The layout had an `{% if dependencies %}` "Integrations:" block whose body was +// commented out, and no blueprint sets `dependencies`. It is not carried over. +// - The Deploy button is hidden rather than pointing at an empty id when a blueprint +// carries no `blueprintId`. +import { blueprintAuthor, deployUrl } from '../../../lib/blueprint-display.mjs' + +definePageMeta({ layout: 'default' }) + +const route = useRoute() +const contentPath = computed(() => `/blueprints/${route.params.category}/${route.params.slug}`) + +const { data: page } = await useAsyncData( + () => `blueprint-${contentPath.value}`, + () => queryCollection('blueprints').path(contentPath.value).first() +) + +if (!page.value) { + throw createError({ statusCode: 404, statusMessage: 'Page not found' }) +} + +const author = computed(() => blueprintAuthor(page.value?.author)) +const canonicalUrl = computed(() => `https://flowfuse.com${route.path}`) +const absoluteImage = computed(() => { + const image = page.value?.image + if (!image) return undefined + return image.startsWith('http') ? image : `https://flowfuse.com${image}` +}) + +useSeoMeta({ + title: () => page.value?.title, + description: () => page.value?.description, + ogDescription: () => page.value?.description, + ogImage: absoluteImage, + ogUrl: canonicalUrl, + twitterCard: 'summary_large_image', + twitterSite: '@FlowFuseinc', +}) +</script> + +<template> + <div v-if="page" class="w-full page post"> + <div class="post-title container m-auto text-center max-lg:px-6 flex mt-6 mb-6 md:max-w-screen-lg md:mt-12"> + <div class="text-left md:pr-32"> + <label>Blueprint</label> + <h1>{{ page.title }}</h1> + <!-- eslint-disable-next-line vue/no-v-html --> + <h4 v-if="page.description" v-html="page.description" /> + </div> + </div> + <div class="blog nohero w-full pb-12"> + <div class="container flex flex-col md:flex-row m-auto text-left max-lg:px-6 md:max-w-screen-lg gap-8 items-stretch"> + <div class="ff-prose min-w-0"> + <NuxtLink to="/blueprints/" class="inline-flex align-center gap-1 mb-4"> + <SiteArt name="chevron-left" /> + Back to Blueprints Library + </NuxtLink> + <div class="prose w-full flex-grow"> + <ContentRenderer :value="page" /> + </div> + </div> + <div class="w-72 max-w-full flex-shrink-0"> + <div class="sticky top-20 mt-6 flex flex-col"> + <a + v-if="page.blueprintId" + :href="deployUrl(page.blueprintId)" + class="ff-btn ff-btn--primary flex gap-2 mb-6 mt-4 uppercase" + target="_blank" + rel="noopener" + >Deploy <SiteArt name="rocket-launch" /></a> + <h3 class="mb-3">Author:</h3> + <BlueprintCompanyTile :company="author" /> + <ContactUsCtaLine /> + </div> + </div> + </div> + </div> + </div> +</template> diff --git a/nuxt/pages/blueprints/[page].vue b/nuxt/pages/blueprints/[page].vue new file mode 100644 index 0000000000..02217ed929 --- /dev/null +++ b/nuxt/pages/blueprints/[page].vue @@ -0,0 +1,24 @@ +<script setup lang="ts"> +// Pages 2 and up of the Blueprint Library listing. Page 1 is pages/blueprints/index.vue. +// +// Only a number matches: /blueprints/<anything-else>/ is a 404, as it was under 11ty, +// which generated no page for a category on its own. The blueprint detail pages sit one +// level deeper, in pages/blueprints/[category]/[slug].vue. +import { BLUEPRINTS_DESCRIPTION, BLUEPRINTS_META_TITLE } from '../../composables/useBlueprintList' + +const route = useRoute() +const page = computed(() => Number(route.params.page)) + +if (!/^\d+$/.test(String(route.params.page))) { + throw createError({ statusCode: 404, statusMessage: 'Page not found' }) +} + +useSeoMeta({ + title: BLUEPRINTS_META_TITLE, + description: BLUEPRINTS_DESCRIPTION, +}) +</script> + +<template> + <BlueprintListing :page="page" /> +</template> diff --git a/nuxt/pages/blueprints/index.vue b/nuxt/pages/blueprints/index.vue new file mode 100644 index 0000000000..17f47eda42 --- /dev/null +++ b/nuxt/pages/blueprints/index.vue @@ -0,0 +1,21 @@ +<script setup lang="ts"> +// Ported from src/blueprints.njk (11ty), which this replaces: page 1 of the Blueprint +// Library. Pages 2 and up are pages/blueprints/[page].vue; both render <BlueprintListing>. +// +// What the port changes on purpose: +// - 11ty numbered its pagination from zero, so page 2 was /blueprints/1/. Nuxt numbers +// from one, matching the blog and changelog listings already served here, so page 2 is +// /blueprints/2/. As on /blog/1/, the old URL still resolves, now to page 1. +// - The blueprints themselves come from FlowFuse/blueprint-library rather than from a +// generated src/blueprints/ tree - see nuxt/modules/blueprints-source.ts. +import { BLUEPRINTS_DESCRIPTION, BLUEPRINTS_META_TITLE } from '../../composables/useBlueprintList' + +useSeoMeta({ + title: BLUEPRINTS_META_TITLE, + description: BLUEPRINTS_DESCRIPTION, +}) +</script> + +<template> + <BlueprintListing :page="1" /> +</template> diff --git a/nuxt/pages/blueprints/submit.vue b/nuxt/pages/blueprints/submit.vue new file mode 100644 index 0000000000..d4d8dcadd5 --- /dev/null +++ b/nuxt/pages/blueprints/submit.vue @@ -0,0 +1,89 @@ +<script setup lang="ts"> +// Ported from src/blueprints/submit.njk (11ty), which this replaces. Same page, same copy, +// same classes from src/css/style.css. +// +// What the port changes on purpose: +// - The .njk used 11ty pagination (size 3) purely to slice three blueprints for the +// "Existing Blueprints" row. That generated five further pages nobody links to +// (/blueprints/submit/1/ through /5/, each an identical form with three other +// examples). This queries the first three of the same ordering instead, so only +// /blueprints/submit/ exists. +// - hubspot/hs-form.njk becomes <HubSpotForm> with the same form id, cta and reference. +// - The frontmatter carried a `description` full of raw <p> tags for the layout to print +// with `| safe`; the visible copy was written out again in the body. Only the body copy +// is kept, and the meta description is the `meta.description` the .njk set. +// - The .njk never closed its outer container div, so the browser put the "Existing +// Blueprints" section inside it anyway. That nesting is written out here. + +const EXAMPLE_COUNT = 3 +const FALLBACK_IMAGE = '/images/og-blog.jpg' +const FALLBACK_IMAGE_ALT = 'Image with logo and the slogan: Elevate Node-RED with Flowfuse' + +// Same ordering as the listing (see useBlueprintList), just the first few of it. +const { data: examples } = await useAsyncData('blueprints-submit-examples', () => + queryCollection('blueprints') + .select('path', 'title', 'image') + .order('path', 'DESC') + .limit(EXAMPLE_COUNT) + .all() +) + +useSeoMeta({ + title: 'Submit Your Blueprint', + description: 'Submit your own Blueprints for publishing in the FlowFuse Blueprint Library', +}) +</script> + +<template> + <div class="max-w-full"> + <div class="m-auto sm:max-w-xl md:max-w-6xl px-4"> + <div class="grid gap-12 pt-24 md:grid-cols-2"> + <div> + <h1 class="mb-10">Submit Your Blueprint</h1> + <p>Share your Blueprints to help the FlowFuse community build <span class="inline-block">best-in-class</span> Node-RED templates and build recognition of yourself as a Node-RED expert.</p> + <p>We are accepting submissions of Blueprints that are <b>useful and professionally well-constructed</b>.</p> + <p>Submissions will be reviewed by the FlowFuse team.</p> + <p>Accepted submissions will be featured on the Blueprints page and announced on social media.</p> + </div> + <div class="w-full"> + <HubSpotForm + form-id="c627fbcb-a3e0-46dd-978b-122461b7835c" + cta="blueprint-upload" + reference="blueprint-upload" + /> + </div> + </div> + <div class="w-full pt-10 pb-16"> + <div class="m-auto md:max-w-6xl border-t pt-8"> + <div class="grid lg:grid-cols-3 lg:gap-6"> + <h2 class="mt-2">Existing Blueprints</h2> + <p class="col-span-2"> + Here are a few examples of Blueprints from our collection. You can take a look at + the full collection in our <NuxtLink to="/blueprints/">Blueprint Library</NuxtLink> + </p> + </div> + <ul class="grid md:grid-cols-3 gap-6 mt-6"> + <li + v-for="example in examples" + :key="example.path" + class="grid max-md:text-center max-md:mx-auto max-md:max-w-md bg-white ff-image-cover blueprint rounded-lg border drop-shadow-md hover:drop-shadow-lg grow" + > + <NuxtLink :to="`${example.path}/`" class="w-full flex flex-col group hover:no-underline"> + <div class="ff-image-cover aspect-video border-b"> + <img + :src="example.image || FALLBACK_IMAGE" + :alt="example.image ? `Image representing ${example.title}` : FALLBACK_IMAGE_ALT" + width="285" + loading="lazy" + class="w-full h-auto" + > + </div> + <h5 class="my-4 group-hover:underline px-4 font-medium text-lg leading-6">{{ example.title }}</h5> + </NuxtLink> + </li> + </ul> + </div> + </div> + </div> + </div> +</template> diff --git a/nuxt/server/api/__sitemap__/content-urls.get.ts b/nuxt/server/api/__sitemap__/content-urls.get.ts index e398c0b6ae..f32d343235 100644 --- a/nuxt/server/api/__sitemap__/content-urls.get.ts +++ b/nuxt/server/api/__sitemap__/content-urls.get.ts @@ -58,6 +58,13 @@ const CONTENT_SOURCES: ContentSource[] = [ filter: entry => entry.layout !== 'redirect', }, { collection: 'handbook', fileRoot: 'nuxt/content' }, + { + collection: 'blueprints', + // Already git-derived once, at sync time, against the blueprint-library repo this + // content came from - not this repo's history. + lastmod: entry => stringField(entry, 'updated'), + images: entry => [stringField(entry, 'image')].filter((path): path is string => Boolean(path)), + }, { collection: 'changelog', fileRoot: 'src' }, { collection: 'blog', diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index e55d21ae74..df20f2c8f2 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -19,7 +19,7 @@ const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs // Top-level routes still on 11ty, not yet ported to Nuxt (everything not listed above // already falls through to the 11ty proxy by default). Remove entries here as they migrate: -// /about, /blueprints, /careers, /community, /events +// /about, /careers, /community, /events // /free-consultation, /industries, /landing, /node-red, /partners, /platform, // /use-cases, /webinars diff --git a/nuxt/utils/siteArt.ts b/nuxt/utils/siteArt.ts new file mode 100644 index 0000000000..3404c74719 --- /dev/null +++ b/nuxt/utils/siteArt.ts @@ -0,0 +1,196 @@ +// SVG files that page content included raw, with no wrapper: {% include "components/icons/ +// x.svg" %} rather than the `navoption`/`ffIconLg` shortcodes that NavIcon reproduces. +// +// They cannot go through NavIcon or an <img>: +// - NavIcon wraps its payload in an outer <svg> carrying a fixed ff-icon size class, +// which would override the size the caller's box sets. +// - <img> breaks `currentColor`, and most of these are coloured by their container. +// +// GENERATED by scripts/gen-site-art.mjs from the keys the pages and content collections +// actually reference, so the bundle carries only what is used - an eager glob of that +// directory inlines ~137 KB, most of it unused art. Re-run it after adding a page that +// names a new icon. + +import aAcademicCap from '../../src/_includes/components/icons/academic-cap.svg?raw' +import aAdjustmentsHorizontal from '../../src/_includes/components/icons/adjustments-horizontal.svg?raw' +import aAdjustmentsVertical from '../../src/_includes/components/icons/adjustments-vertical.svg?raw' +import aAirGappedDeployment from '../../src/_includes/components/icons/air-gapped-deployment.svg?raw' +import aAirplane from '../../src/_includes/components/icons/airplane.svg?raw' +import aArchiveBoxXMark from '../../src/_includes/components/icons/archive-box-x-mark.svg?raw' +import aArrowLongRight from '../../src/_includes/components/icons/arrow-long-right.svg?raw' +import aArrowPath from '../../src/_includes/components/icons/arrow-path.svg?raw' +import aArrowPathRoundedSquare from '../../src/_includes/components/icons/arrow-path-rounded-square.svg?raw' +import aArrowSmallDown from '../../src/_includes/components/icons/arrow-small-down.svg?raw' +import aArrowTopRightOnSquare from '../../src/_includes/components/icons/arrow-top-right-on-square.svg?raw' +import aArrowTrendingUp from '../../src/_includes/components/icons/arrow-trending-up.svg?raw' +import aArrows from '../../src/_includes/components/icons/arrows.svg?raw' +import aArrowsPointingOut from '../../src/_includes/components/icons/arrows-pointing-out.svg?raw' +import aArrowsRightLeft from '../../src/_includes/components/icons/arrows-right-left.svg?raw' +import aAuditLogs from '../../src/_includes/components/icons/audit-logs.svg?raw' +import aBellAlert from '../../src/_includes/components/icons/bell-alert.svg?raw' +import aBolt from '../../src/_includes/components/icons/bolt.svg?raw' +import aBookOpen from '../../src/_includes/components/icons/book-open.svg?raw' +import aBuildingOffice2 from '../../src/_includes/components/icons/building-office-2.svg?raw' +import aCalendar from '../../src/_includes/components/icons/calendar.svg?raw' +import aCamera from '../../src/_includes/components/icons/camera.svg?raw' +import aCar from '../../src/_includes/components/icons/car.svg?raw' +import aCertificate from '../../src/_includes/components/icons/certificate.svg?raw' +import aCertifiedNode from '../../src/_includes/components/icons/certified-node.svg?raw' +import aChart from '../../src/_includes/components/icons/chart.svg?raw' +import aChatBubbleBottomCenterText from '../../src/_includes/components/icons/chat-bubble-bottom-center-text.svg?raw' +import aChatBubbleLeftRightSm from '../../src/_includes/components/icons/chat-bubble-left-right-sm.svg?raw' +import aCheckBadge from '../../src/_includes/components/icons/check-badge.svg?raw' +import aCheckCircle from '../../src/_includes/components/icons/check-circle.svg?raw' +import aChevronDown from '../../src/_includes/components/icons/chevron-down.svg?raw' +import aChevronLeft from '../../src/_includes/components/icons/chevron-left.svg?raw' +import aChevronRight from '../../src/_includes/components/icons/chevron-right.svg?raw' +import aChevronRightSm from '../../src/_includes/components/icons/chevron-right-sm.svg?raw' +import aChip from '../../src/_includes/components/icons/chip.svg?raw' +import aCircleStack from '../../src/_includes/components/icons/circle-stack.svg?raw' +import aClipList from '../../src/_includes/components/icons/clip-list.svg?raw' +import aClipboardDocumentCheck from '../../src/_includes/components/icons/clipboard-document-check.svg?raw' +import aClock from '../../src/_includes/components/icons/clock.svg?raw' +import aCloud from '../../src/_includes/components/icons/cloud.svg?raw' +import aCodeBracket from '../../src/_includes/components/icons/code-bracket.svg?raw' +import aCodeblock from '../../src/_includes/components/icons/codeblock.svg?raw' +import aCog from '../../src/_includes/components/icons/cog.svg?raw' +import aCog6Tooth from '../../src/_includes/components/icons/cog-6-tooth.svg?raw' +import aComputerDesktop from '../../src/_includes/components/icons/computer-desktop.svg?raw' +import aCubeTransparent from '../../src/_includes/components/icons/cube-transparent.svg?raw' +import aCursorArrowRays from '../../src/_includes/components/icons/cursor-arrow-rays.svg?raw' +import aData from '../../src/_includes/components/icons/data.svg?raw' +import aDatabase from '../../src/_includes/components/icons/database.svg?raw' +import aDocumentArrowUp from '../../src/_includes/components/icons/document-arrow-up.svg?raw' +import aDocumentChartBar from '../../src/_includes/components/icons/document-chart-bar.svg?raw' +import aDocumentCheck from '../../src/_includes/components/icons/document-check.svg?raw' +import aDocumentText from '../../src/_includes/components/icons/document-text.svg?raw' +import aEye from '../../src/_includes/components/icons/eye.svg?raw' +import aEyeSlash from '../../src/_includes/components/icons/eye-slash.svg?raw' +import aFactory from '../../src/_includes/components/icons/factory.svg?raw' +import aGlobeAlt from '../../src/_includes/components/icons/globe-alt.svg?raw' +import aHandRaised from '../../src/_includes/components/icons/hand-raised.svg?raw' +import aLayers from '../../src/_includes/components/icons/layers.svg?raw' +import aLifebuoy from '../../src/_includes/components/icons/lifebuoy.svg?raw' +import aLightBulb from '../../src/_includes/components/icons/light-bulb.svg?raw' +import aLink from '../../src/_includes/components/icons/link.svg?raw' +import aLinkSlash from '../../src/_includes/components/icons/link-slash.svg?raw' +import aLock from '../../src/_includes/components/icons/lock.svg?raw' +import aLockClosed from '../../src/_includes/components/icons/lock-closed.svg?raw' +import aLockOpen from '../../src/_includes/components/icons/lock-open.svg?raw' +import aPin from '../../src/_includes/components/icons/pin.svg?raw' +import aPinSlash from '../../src/_includes/components/icons/pin-slash.svg?raw' +import aPulse from '../../src/_includes/components/icons/pulse.svg?raw' +import aPuzzlePiece from '../../src/_includes/components/icons/puzzle-piece.svg?raw' +import aQueueList from '../../src/_includes/components/icons/queue-list.svg?raw' +import aRectangleStack from '../../src/_includes/components/icons/rectangle-stack.svg?raw' +import aRocketLaunch from '../../src/_includes/components/icons/rocket-launch.svg?raw' +import aRoleBasedAccess from '../../src/_includes/components/icons/role-based-access.svg?raw' +import aServerStack from '../../src/_includes/components/icons/server-stack.svg?raw' +import aShare from '../../src/_includes/components/icons/share.svg?raw' +import aShieldCheck from '../../src/_includes/components/icons/shield-check.svg?raw' +import aShieldExclamation from '../../src/_includes/components/icons/shield-exclamation.svg?raw' +import aSingleSignOn from '../../src/_includes/components/icons/single-sign-on.svg?raw' +import aSnowflake from '../../src/_includes/components/icons/snowflake.svg?raw' +import aSparkles from '../../src/_includes/components/icons/sparkles.svg?raw' +import aSquares2x2 from '../../src/_includes/components/icons/squares-2x2.svg?raw' +import aSquaresPlus from '../../src/_includes/components/icons/squares-plus.svg?raw' +import aTargetView from '../../src/_includes/components/icons/target-view.svg?raw' +import aUns from '../../src/_includes/components/icons/uns.svg?raw' +import aUserGroup from '../../src/_includes/components/icons/user-group.svg?raw' +import aUsers from '../../src/_includes/components/icons/users.svg?raw' +import aWifi from '../../src/_includes/components/icons/wifi.svg?raw' +import aWindTurbine from '../../src/_includes/components/icons/wind-turbine.svg?raw' +import aWrenchScrewdriver from '../../src/_includes/components/icons/wrench-screwdriver.svg?raw' + +export const siteArt: Record<string, string> = { + 'academic-cap': aAcademicCap, + 'adjustments-horizontal': aAdjustmentsHorizontal, + 'adjustments-vertical': aAdjustmentsVertical, + 'air-gapped-deployment': aAirGappedDeployment, + 'airplane': aAirplane, + 'archive-box-x-mark': aArchiveBoxXMark, + 'arrow-long-right': aArrowLongRight, + 'arrow-path': aArrowPath, + 'arrow-path-rounded-square': aArrowPathRoundedSquare, + 'arrow-small-down': aArrowSmallDown, + 'arrow-top-right-on-square': aArrowTopRightOnSquare, + 'arrow-trending-up': aArrowTrendingUp, + 'arrows': aArrows, + 'arrows-pointing-out': aArrowsPointingOut, + 'arrows-right-left': aArrowsRightLeft, + 'audit-logs': aAuditLogs, + 'bell-alert': aBellAlert, + 'bolt': aBolt, + 'book-open': aBookOpen, + 'building-office-2': aBuildingOffice2, + 'calendar': aCalendar, + 'camera': aCamera, + 'car': aCar, + 'certificate': aCertificate, + 'certified-node': aCertifiedNode, + 'chart': aChart, + 'chat-bubble-bottom-center-text': aChatBubbleBottomCenterText, + 'chat-bubble-left-right-sm': aChatBubbleLeftRightSm, + 'check-badge': aCheckBadge, + 'check-circle': aCheckCircle, + 'chevron-down': aChevronDown, + 'chevron-left': aChevronLeft, + 'chevron-right': aChevronRight, + 'chevron-right-sm': aChevronRightSm, + 'chip': aChip, + 'circle-stack': aCircleStack, + 'clip-list': aClipList, + 'clipboard-document-check': aClipboardDocumentCheck, + 'clock': aClock, + 'cloud': aCloud, + 'code-bracket': aCodeBracket, + 'codeblock': aCodeblock, + 'cog': aCog, + 'cog-6-tooth': aCog6Tooth, + 'computer-desktop': aComputerDesktop, + 'cube-transparent': aCubeTransparent, + 'cursor-arrow-rays': aCursorArrowRays, + 'data': aData, + 'database': aDatabase, + 'document-arrow-up': aDocumentArrowUp, + 'document-chart-bar': aDocumentChartBar, + 'document-check': aDocumentCheck, + 'document-text': aDocumentText, + 'eye': aEye, + 'eye-slash': aEyeSlash, + 'factory': aFactory, + 'globe-alt': aGlobeAlt, + 'hand-raised': aHandRaised, + 'layers': aLayers, + 'lifebuoy': aLifebuoy, + 'light-bulb': aLightBulb, + 'link': aLink, + 'link-slash': aLinkSlash, + 'lock': aLock, + 'lock-closed': aLockClosed, + 'lock-open': aLockOpen, + 'pin': aPin, + 'pin-slash': aPinSlash, + 'pulse': aPulse, + 'puzzle-piece': aPuzzlePiece, + 'queue-list': aQueueList, + 'rectangle-stack': aRectangleStack, + 'rocket-launch': aRocketLaunch, + 'role-based-access': aRoleBasedAccess, + 'server-stack': aServerStack, + 'share': aShare, + 'shield-check': aShieldCheck, + 'shield-exclamation': aShieldExclamation, + 'single-sign-on': aSingleSignOn, + 'snowflake': aSnowflake, + 'sparkles': aSparkles, + 'squares-2x2': aSquares2x2, + 'squares-plus': aSquaresPlus, + 'target-view': aTargetView, + 'uns': aUns, + 'user-group': aUserGroup, + 'users': aUsers, + 'wifi': aWifi, + 'wind-turbine': aWindTurbine, + 'wrench-screwdriver': aWrenchScrewdriver, +} diff --git a/package.json b/package.json index 24c24d6bcb..6ec5df6c91 100644 --- a/package.json +++ b/package.json @@ -11,19 +11,19 @@ ], "scripts": { "test": "node --test nuxt/server/lib/*.test.mjs nuxt/lib/*.test.mjs", - "dev": "concurrently \"npm run dev:eleventy\" \"npm run dev:docs\" \"npm run dev:blueprints\" \"npm run dev:postcss\" \"npm run dev:postcss-nuxt\" \"dotenv -- npm run dev --workspace=nuxt\"", - "start": "npm-run-all2 clean:dev build:js blueprints --parallel dev:*", + "dev": "concurrently \"npm run dev:eleventy\" \"npm run dev:docs\" \"npm run dev:postcss\" \"npm run dev:postcss-nuxt\" \"dotenv -- npm run dev --workspace=nuxt\"", + "start": "npm-run-all2 clean:dev build:js --parallel dev:*", "build:js": "terser -c -m -o _site/js/cc.min.js node_modules/vanilla-cookieconsent/dist/cookieconsent.umd.js src/js/cookieconsent-config.js && cp node_modules/@flowfuse/flow-renderer/index.min.js _site/js/flowrenderer.min.js", "build": "dotenv -v NODE_ENV=production -- npm-run-all2 clean build:js --parallel prod:*", "build:skip-images": "dotenv -v SKIP_IMAGES=true -- npm run build", - "clean:dev": "dotenv -- npx del-cli '_site/!(img)' 'src/blueprints/**/!(*submit.njk)' && dotenv -- npx mkdirp '_site/js/flows'", + "clean:dev": "dotenv -- npx del-cli '_site/!(img)' && dotenv -- npx mkdirp '_site/js/flows'", "clean": "dotenv -- npx del-cli '_site/!(img)' && dotenv -- mkdir -p '_site/js'", "dev:blueprints": "node scripts/watch_blueprints.js", "dev:docs": "node scripts/watch_docs.mjs", "dev:netlify": "npx netlify dev -c \"dotenv -- npx @11ty/eleventy --serve --quiet --incremental\"", "dev:postcss": "dotenv -v TAILWIND_MODE=watch -- npx postcss ./src/css/style.css -o ./_site/css/style.css --config ./postcss.config.js -w", "dev:postcss-nuxt": "dotenv -v TAILWIND_MODE=watch -- npx postcss ./src/css/style.css -o ./nuxt/public/css/style.css --config ./postcss.config.js -w", - "blueprints": "node scripts/copy_blueprints.mjs", + "blueprints": "node scripts/sync_blueprints.mjs", "docs": "node scripts/sync_docs.mjs", "index:algolia": "node scripts/index-algolia.js", "dev:eleventy": "dotenv -- npx @11ty/eleventy --serve --port 8080 --quiet", @@ -31,13 +31,13 @@ "old_dev:eleventy": "dotenv -v NODE_ENV=development -- ELEVENTY_ENV=development npx @11ty/eleventy --serve --quiet", "prod:eleventy": "npx @11ty/eleventy", "prod:postcss": "postcss ./src/css/style.css -o ./_site/css/style.css --config ./postcss.config.js", - "clean:nuxt": "npx del-cli 'nuxt/public/!(img|handbook|images|docs)' 'nuxt/.output' 'nuxt/.netlify' && npx mkdirp 'nuxt/public/js' 'nuxt/public/css'", + "clean:nuxt": "npx del-cli 'nuxt/public/!(img|handbook|images|docs|blueprints)' 'nuxt/.output' 'nuxt/.netlify' && npx mkdirp 'nuxt/public/js' 'nuxt/public/css'", "build:js:nuxt": "terser -c -m -o nuxt/public/js/cc.min.js node_modules/vanilla-cookieconsent/dist/cookieconsent.umd.js src/js/cookieconsent-config.js && cp node_modules/@flowfuse/flow-renderer/index.min.js nuxt/public/js/flowrenderer.min.js", "prod:postcss-nuxt": "postcss ./src/css/style.css -o ./nuxt/public/css/style.css --config ./postcss.config.js", "prod:eleventy-nuxt": "npx @11ty/eleventy --output=./nuxt/public/", "prod:nuxt": "npm run build --workspace=nuxt", - "build:nuxt": "dotenv -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt", - "build:nuxt:skip-images": "dotenv -v SKIP_IMAGES=true -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt blueprints prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt" + "build:nuxt": "dotenv -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt", + "build:nuxt:skip-images": "dotenv -v SKIP_IMAGES=true -v NODE_ENV=production -- npm-run-all2 clean:nuxt build:js:nuxt prod:postcss-nuxt prod:eleventy-nuxt prod:nuxt" }, "devDependencies": { "@11ty/eleventy": "^3.1.2", diff --git a/scripts/copy_blueprints.mjs b/scripts/copy_blueprints.mjs deleted file mode 100644 index 7884ff0493..0000000000 --- a/scripts/copy_blueprints.mjs +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env node -// Populates src/blueprints outside of a Nuxt build, so `npm start`/the 11ty build can -// resolve blueprints the same way a production build does. Mirrors scripts/sync_docs.mjs. - -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -import { syncBlueprints } from '../nuxt/lib/blueprints-sync.mjs' - -const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') - -await syncBlueprints({ repoRoot }) diff --git a/scripts/gen-site-art.mjs b/scripts/gen-site-art.mjs new file mode 100755 index 0000000000..cf57a0dbaf --- /dev/null +++ b/scripts/gen-site-art.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// Regenerate nuxt/utils/siteArt.ts from the icon keys the site actually references. +// +// <SiteArt> inlines an SVG from src/_includes/components/icons/ verbatim, which is what +// page content that used a bare {% include %} needs. The registry is written out rather +// than globbed, so the bundle carries only the icons in use - an eager glob of that +// directory inlines ~137 KB, most of it unused art. +// +// Keeping it hand-maintained across ~50 keys is not realistic, so this scans for every +// key that is referenced and rewrites the file. Run it after adding a page that names a +// new icon; the build fails loudly on a missing import, and <SiteArt> warns in dev on a +// key the registry does not have. +// +// Usage: node scripts/gen-site-art.mjs [repo-root] +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const ICON_DIR = 'src/_includes/components/icons' + +function filesUnder (dir, ext) { + if (!existsSync(dir)) return [] + return readdirSync(dir, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith(ext)) + .map(entry => join(entry.parentPath ?? entry.path, entry.name)) +} + +function matchAll (text, pattern) { + return [...text.matchAll(pattern)].map(match => match[1]) +} + +// `explicit` are literal <SiteArt name="x" /> uses - a missing file there is a broken +// page, so it fails the run. `harvested` are svgPath:/icon: values in content and page +// consts, which a renderer *may* pass to SiteArt but may equally resolve some other way +// (contact-us has its own icon map, for one), so a miss there is only reported. +function referencedKeys (root) { + const vues = [...filesUnder(join(root, 'nuxt/pages'), '.vue'), ...filesUnder(join(root, 'nuxt/components'), '.vue')] + const explicit = new Set() + for (const file of vues) { + for (const key of matchAll(readFileSync(file, 'utf8'), /<SiteArt\s+name="([a-z0-9-]+)"/g)) explicit.add(key) + } + // svgPath:/icon: keys in the content collections, which the renderers pass to SiteArt, + // and the same keys declared in a page's own const arrays. Both quote styles: YAML + // here is double-quoted, the .vue consts are single-quoted. + const harvested = new Set() + for (const file of [...filesUnder(join(root, 'nuxt/content'), '.yml'), ...vues]) { + for (const key of matchAll(readFileSync(file, 'utf8'), /(?:svgPath|icon|eyebrowIcon|badge):\s*["']([a-z0-9-]+)["']/g)) { + // An "i-heroicons-x" / "i-lucide-x" value is a <UIcon> name resolved from an + // installed icon set, not a file in this repo, so it is not SiteArt's to provide. + if (!key.startsWith('i-')) harvested.add(key) + } + } + return { explicit, harvested } +} + +const ident = key => 'a' + key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('') + +const HEADER = `// SVG files that page content included raw, with no wrapper: {% include "components/icons/ +// x.svg" %} rather than the \`navoption\`/\`ffIconLg\` shortcodes that NavIcon reproduces. +// +// They cannot go through NavIcon or an <img>: +// - NavIcon wraps its payload in an outer <svg> carrying a fixed ff-icon size class, +// which would override the size the caller's box sets. +// - <img> breaks \`currentColor\`, and most of these are coloured by their container. +// +// GENERATED by scripts/gen-site-art.mjs from the keys the pages and content collections +// actually reference, so the bundle carries only what is used - an eager glob of that +// directory inlines ~137 KB, most of it unused art. Re-run it after adding a page that +// names a new icon. +` + +const root = resolve(process.argv[2] ?? '.') +const { explicit, harvested } = referencedKeys(root) + +// Keys that resolve through NavIcon (the navoption/ffIconLg wrapper) or a hand-written +// Vue icon component are not SiteArt's job. +const navFile = join(root, 'nuxt/utils/navIcons.ts') +const navKeys = existsSync(navFile) ? new Set(matchAll(readFileSync(navFile, 'utf8'), /^ {4}'([a-z0-9-]+)':/gm)) : new Set() + +const usable = [] +const missing = [] +for (const key of [...new Set([...explicit, ...harvested])].sort()) { + (existsSync(join(root, ICON_DIR, `${key}.svg`)) ? usable : missing).push(key) +} + +const lines = [HEADER] +for (const key of usable) lines.push(`import ${ident(key)} from '../../${ICON_DIR}/${key}.svg?raw'`) +lines.push('\nexport const siteArt: Record<string, string> = {') +for (const key of usable) lines.push(` '${key}': ${ident(key)},`) +lines.push('}\n') + +const out = join(root, 'nuxt/utils/siteArt.ts') +writeFileSync(out, lines.join('\n')) +console.log(`${out}: ${usable.length} icons`) + +const alsoInNav = usable.filter(key => navKeys.has(key)) +if (alsoInNav.length) console.log(` (also in navIcons, which is fine - different wrapper: ${alsoInNav.slice(0, 6).join(', ')}…)`) +if (missing.length) console.log(` no SVG file (resolved elsewhere, not bundled here): ${missing.join(', ')}`) + +const broken = missing.filter(key => explicit.has(key)) +if (broken.length) { + console.log(` ERROR: <SiteArt> is asked for these and there is no file: ${broken.join(', ')}`) + process.exit(1) +} diff --git a/scripts/sync_blueprints.mjs b/scripts/sync_blueprints.mjs new file mode 100644 index 0000000000..f63600c447 --- /dev/null +++ b/scripts/sync_blueprints.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +// Populates nuxt/content/blueprints and nuxt/public/blueprints outside of a Nuxt build. +// Uses only node builtins beyond the clone path, so it runs before dependencies are +// installed. Netlify's production build reaches the same sync through +// nuxt/modules/blueprints-source.ts, so this is only needed where there is no Nuxt yet. +// +// This replaces scripts/copy_blueprints.mjs, which wrote into src/blueprints/ for 11ty. + +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +import { syncBlueprints } from '../nuxt/lib/blueprints-sync.mjs' + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..') + +const { entries } = await syncBlueprints({ repoRoot, nuxtRoot: join(repoRoot, 'nuxt') }) + +// Called explicitly, so unlike a build there is no committed tree to fall back on and +// nothing else that would report the library never having been found. +if (entries.length === 0) { + console.error('No blueprints were published. Check out FlowFuse/blueprint-library beside this repo, set FLOWFUSE_BLUEPRINTS_LOCAL, or configure GH_BOT_APP_ID/GH_BOT_APP_KEY to clone it.') + process.exit(1) +} diff --git a/src/_data/companies/flowfuse.json b/src/_data/companies/flowfuse.json deleted file mode 100644 index 44fa338b03..0000000000 --- a/src/_data/companies/flowfuse.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "flowfuse", - "name": "FlowFuse", - "img": "/images/flowfuse-icon.png", - "url": "https://flowfuse.com" -} \ No newline at end of file diff --git a/src/_data/companies/signl.json b/src/_data/companies/signl.json deleted file mode 100644 index d69c00113b..0000000000 --- a/src/_data/companies/signl.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "id": "signl4", - "name": "SIGNL4", - "img": "/images/signl4_logo.png", - "url": "https://www.signl4.com/" -} \ No newline at end of file diff --git a/src/_includes/blog/pagination.njk b/src/_includes/blog/pagination.njk deleted file mode 100644 index 0946f3480a..0000000000 --- a/src/_includes/blog/pagination.njk +++ /dev/null @@ -1,10 +0,0 @@ - - <nav aria-label="Pagination" class="pagination mt-4"> - <ol class="flex flex-row w-full justify-between text-gray-600"> - <li class="flex md:flex-initial w-40 justify-start pl-2 ff-nav-blog-p"{% if not pagination.href.previous %} style="opacity: 0; pointer-events: none;"{% endif %}><a href="{{ pagination.href.previous }}">Previous</a></li> - <li> - <span>{{ pagination.pageNumber + 1 }} of {{ pagination.pages.length }}</span> - </li> - <li class="flex md:flex-initial w-40 justify-end pr-2 ff-nav-blog-n"{% if not pagination.href.next %} style="opacity: 0; pointer-events: none;;"{% endif %}><a href="{{ pagination.href.next }}">Next</a></li> - </ol> - </nav> \ No newline at end of file diff --git a/src/_includes/blueprints/blueprint-card.njk b/src/_includes/blueprints/blueprint-card.njk deleted file mode 100644 index 842c26d8a1..0000000000 --- a/src/_includes/blueprints/blueprint-card.njk +++ /dev/null @@ -1,38 +0,0 @@ -<li class="flex flex-col h-full"> - <div class="mb-2"> - {%- for tag in item.data.tags -%} - {% if tag !== "blueprints" %} - <label class="text-gray-700 text-xs font-light rounded-sm bg-indigo-50 py-1.5 px-2 inline-block w-auto"> - {%- if tag == tag.toUpperCase() -%} - {{ tag }} - {%- else -%} - {{ tag | replace('-', ' ') | replace('20', '2.0') | title }} - {%- endif -%} - </label> - {% endif %} - {%- endfor %} - </div> - <div class="grid bg-white ff-image-cover blueprint rounded-lg border drop-shadow-md hover:drop-shadow-lg grow"> - <a href="{{ item.url }}" 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"> - {% tileImage item, "./images/og-blog.jpg", null, "Image with logo and the slogan: Elevate Node-RED with Flowfuse", 380 %} - </div> - <h5 class="mt-4 mb-0 group-hover:underline px-4 font-medium text-lg leading-6">{{ item.data.title }}</h5> - <p class="text-sm leading-normal font-light pt-1 mb-4 mt-3 px-4 text-gray-500"> - {{ item.data.description | safe }} - </p> - </a> - <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 for="Author" class="text-xs">Author:</label> - {% if item.data.author %} - {% renderCompanyTile companies[item.data.author] %} - {% else %} - {% renderCompanyTile companies["flowfuse"] %} - {% endif %} - </div> - <a href="https://app.flowfuse.com/deploy/blueprint?blueprintId={{ item.data.blueprintId }}" - class="ff-btn ff-btn--primary-outlined flex gap-2" target="_blank">DEPLOY {% include "components/icons/rocket-launch.svg" %}</a> - </div> - </div> -</li> 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 %} -<a href="/blueprints/submit" class="ff-btn ff-btn--primary flex-col uppercase">Submit Your Own</a> -{% endblock %} - -{% block content %} -<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"> - {%- asyncEach item in blueprints -%} - {% include "blueprints/blueprint-card.njk" %} - {%- endeach -%} - </ul> - {% include "blog/pagination.njk" %} - {% include "contact-us-cta-line.njk" %} -</div> -{% 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 @@ -<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? <a href="/contact-us/" class="underline">Contact us</a>; our experts will be happy to provide a solution for your needs.  </p> -</div> \ 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 %} - <li class="flex flex-col h-full"> - <div class="mb-2"> - {%- for tag in item.data.tags -%} - {% if tag !== "blueprints" %} - <label class="text-gray-700 text-xs font-light rounded-sm bg-indigo-50 py-1.5 px-2 inline-block w-auto">{{ tag | replace('-', ' ') | replace('20', '2.0') | title }}</label> - {% endif %} - {%- endfor %} - </div> - <div class="grid bg-indigo-50 ff-image-cover blueprint rounded-lg border drop-shadow-md hover:drop-shadow-lg grow pb-4"> - <a href="{{ item.url }}" 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"> - {% tileImage item, "./images/og-blog.jpg", null, "Image with logo and the slogan: Elevate Node-RED with Flowfuse", 340 %} - </div> - <div class="pt-4 my-auto"> - <h5 class="my-auto group-hover:underline px-4 font-medium text-lg leading-6">{{ item.data.title }}</h5> - </div> - </a> - </div> - </li> - {% endif %} -{%- endfor -%} \ No newline at end of file diff --git a/src/_includes/layouts/blueprint.njk b/src/_includes/layouts/blueprint.njk deleted file mode 100644 index 4bbb2d585b..0000000000 --- a/src/_includes/layouts/blueprint.njk +++ /dev/null @@ -1,45 +0,0 @@ ---- -layout: layouts/base.njk ---- -<div class="w-full page post"> - <div class="post-title container m-auto text-center max-lg:px-6 flex mt-6 mb-6 md:max-w-screen-lg md:mt-12"> - <div class="text-left md:pr-32"> - <label>Blueprint</label> - <h1>{{ title }}</h1> - {% if description %} - <h4>{{ description | safe }}</h4> - {% endif %} - </div> - </div> - <div class="blog nohero w-full pb-12"> - <div class="container flex flex-col md:flex-row m-auto text-left max-lg:px-6 md:max-w-screen-lg gap-8 items-stretch"> - <div class="ff-prose min-w-0"> - <a class="inline-flex align-center gap-1 mb-4" href="/blueprints"> - {% include "components/icons/chevron-left.svg" %} - Back to Blueprints Library - </a> - <div class="prose w-full flex-grow"> - {{ content | safe }} - </div> - </div> - <div class="w-72 max-w-full flex-shrink-0"> - <div class="sticky top-20 mt-6 flex flex-col"> - <a href="https://app.flowfuse.com/deploy/blueprint?blueprintId={{ blueprintId }}" class="ff-btn ff-btn--primary flex gap-2 mb-6 mt-4 uppercase" target="_blank">Deploy {% include "components/icons/rocket-launch.svg" %}</a> - <h3 class="mb-3">Author:</h3> - {% if author %} - {% renderCompanyTile companies[author] %} - {% else %} - {% renderCompanyTile companies["flowfuse"] %} - {% endif %} - {% if dependencies %} - <h3 class="mb-3 pt-6">Integrations:</h3> - {# {% for dependency in dependencies %} - {% renderIntegration integrations[dependency] %} - {% endfor %} #} - {% endif %} - {% include "contact-us-cta-line.njk" %} - </div> - </div> - </div> - </div> -</div> \ No newline at end of file diff --git a/src/_includes/layouts/catalog.njk b/src/_includes/layouts/catalog.njk deleted file mode 100644 index 65e8828e26..0000000000 --- a/src/_includes/layouts/catalog.njk +++ /dev/null @@ -1,19 +0,0 @@ -<!--Hero Content--> -<div class="w-full px-6 page hero catalog-hero"> - {% if (title and not hideTitle) %} - <div class="container m-auto text-center flex py-8 max-w-lg md:max-w-6xl"> - <div class="text-left w-full"> - <h1>{% block title %}Title{% endblock %}</h1> - <p class="md:w-9/12">{% block description %}Description{% endblock %}</p> - <div class="flex gap-2 mt-8"> - {% block actions %}{% endblock %} - </div> - </div> - </div> - {% endif %} - <div> - {% block content %} - {{ content | safe }} - {% endblock %} - </div> -</div> \ 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: <p>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.</p><p>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.</p> -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" ---- -<div class="max-w-full"> - <div class="m-auto sm:max-w-xl md:max-w-6xl px-4"> - <div class="grid gap-12 pt-24 md:grid-cols-2"> - <div> - <h1 class="mb-10">{{ title }}</h1> - <p>Share your Blueprints to help the FlowFuse community build <span class="inline-block">best-in-class</span> Node-RED templates and build recognition of yourself as a Node-RED expert.</p> - <p>We are accepting submissions of Blueprints that are <b>useful and professionally well-constructed</b>.</p> <p>Submissions will be reviewed by the FlowFuse team.</p> - <p>Accepted submissions will be featured on the Blueprints page and announced on social media.</p> - </div> - <!-- Form / Calendar Script --> - <div class="w-full"> - {% include hubspot.script %} - </div> - </div> - <div class="w-full pt-10 pb-16"> - <div class="m-auto md:max-w-6xl border-t pt-8"> - <div class="grid lg:grid-cols-3 lg:gap-6"> - <h2 class="mt-2">Existing Blueprints</h2> - <p class="col-span-2">Here are a few examples of Blueprints from our collection. You can take a look at the full collection in our <a href="/blueprints">Blueprint Library</a></p> - </div> - <ul class="grid md:grid-cols-3 gap-6 mt-6"> - {%- asyncEach item in blueprints -%} - <li class="grid max-md:text-center max-md:mx-auto max-md:max-w-md bg-white ff-image-cover blueprint rounded-lg border drop-shadow-md hover:drop-shadow-lg grow"> - <a href="{{ item.url }}" class="w-full flex flex-col group hover:no-underline"> - <div class="ff-image-cover aspect-video border-b"> - {% tileImage item, "./images/og-blog.jpg", "Image with logo and the slogan: Elevate Node-RED with Flowfuse", 285 %} - </div> - <h5 class="my-4 group-hover:underline px-4 font-medium text-lg leading-6">{{ item.data.title }}</h5> - </a> - </li> - {%- endeach -%} - </ul> - </div> -</div> \ No newline at end of file From b3ab87f778df085342c10c9309810f29f986eb52 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra <hi@dimitr.ie> Date: Tue, 15 Sep 2026 13:27:05 +0200 Subject: [PATCH 2/5] blueprints: name the contact band CtaContactUsLine, alongside the other Cta* components --- nuxt/components/BlueprintListing.vue | 2 +- nuxt/components/{ContactUsCtaLine.vue => CtaContactUsLine.vue} | 3 ++- nuxt/pages/blueprints/[category]/[slug].vue | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) rename nuxt/components/{ContactUsCtaLine.vue => CtaContactUsLine.vue} (71%) diff --git a/nuxt/components/BlueprintListing.vue b/nuxt/components/BlueprintListing.vue index 6ee26c2e65..ec625e7279 100644 --- a/nuxt/components/BlueprintListing.vue +++ b/nuxt/components/BlueprintListing.vue @@ -28,7 +28,7 @@ const { entries, totalPages } = useBlueprintList(() => props.page) <BlueprintCard v-for="entry in entries" :key="entry.path" :entry="entry" /> </ul> <Pagination base-path="/blueprints" :page="page" :total-pages="totalPages" /> - <ContactUsCtaLine /> + <CtaContactUsLine /> </div> </div> </div> diff --git a/nuxt/components/ContactUsCtaLine.vue b/nuxt/components/CtaContactUsLine.vue similarity index 71% rename from nuxt/components/ContactUsCtaLine.vue rename to nuxt/components/CtaContactUsLine.vue index 9e93a58e95..3374e496af 100644 --- a/nuxt/components/ContactUsCtaLine.vue +++ b/nuxt/components/CtaContactUsLine.vue @@ -1,6 +1,7 @@ <script setup lang="ts"> // src/_includes/contact-us-cta-line.njk - the one-line band under the Blueprint Library -// grid and in the blueprint detail sidebar. +// 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. </script> <template> diff --git a/nuxt/pages/blueprints/[category]/[slug].vue b/nuxt/pages/blueprints/[category]/[slug].vue index 58326d1045..232c556436 100644 --- a/nuxt/pages/blueprints/[category]/[slug].vue +++ b/nuxt/pages/blueprints/[category]/[slug].vue @@ -81,7 +81,7 @@ useSeoMeta({ >Deploy <SiteArt name="rocket-launch" /></a> <h3 class="mb-3">Author:</h3> <BlueprintCompanyTile :company="author" /> - <ContactUsCtaLine /> + <CtaContactUsLine /> </div> </div> </div> From b0a557c2494938d4df4e9f51116cca9a1b78ea6f Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra <dimitrie@flowfuse.com> Date: Wed, 16 Sep 2026 10:53:37 +0200 Subject: [PATCH 3/5] blueprints: take the contact band's href from the CTA destination registry Keeps the URL in the one place the other Cta* components read it from. The band stays its own component because it is prose with an inline link rather than a button through cta/CtaButton.vue, and it stays untracked because the .njk it ports was. --- nuxt/components/CtaContactUsLine.vue | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/nuxt/components/CtaContactUsLine.vue b/nuxt/components/CtaContactUsLine.vue index 3374e496af..c51bdfc2db 100644 --- a/nuxt/components/CtaContactUsLine.vue +++ b/nuxt/components/CtaContactUsLine.vue @@ -2,10 +2,15 @@ // 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="/contact-us/" class="underline">Contact us</NuxtLink>; our experts will be happy to provide a solution for your needs.</p> + <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> From 37cd7eb0cc4de94e6d325ee4debfe5bfdb297834 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra <dimitrie@flowfuse.com> Date: Wed, 16 Sep 2026 10:59:46 +0200 Subject: [PATCH 4/5] blueprints: serve /blueprints/ from Nuxt in dev too Without the prefix the dev middleware keeps proxying to 11ty, so npm run dev shows the old page. The middleware returns early unless NODE_ENV is development, which is why CI and the deploy preview both looked correct. --- nuxt/server/middleware/legacy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index df20f2c8f2..e2e732a294 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -15,7 +15,7 @@ const NUXT_ROUTE_PREFIXES = ['/integrations/', '/raw/'] // left once its one referring blog post pointed at /contact-us/ instead) - but they stay // listed so their 301s in nuxt/redirects.ts are served by Nitro in dev rather than being // proxied to 11ty, which has nothing there either. -const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs', '/changelog', '/application-guide', '/blog', '/product', '/customer-stories', '/thank-you', '/resources', '/webinars', '/free-consultation', '/vs'] +const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs', '/changelog', '/application-guide', '/blog', '/product', '/customer-stories', '/thank-you', '/resources', '/webinars', '/free-consultation', '/vs', '/blueprints'] // Top-level routes still on 11ty, not yet ported to Nuxt (everything not listed above // already falls through to the 11ty proxy by default). Remove entries here as they migrate: From c18b8b98194bc864795edea5d57c3253bd347a50 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra <dimitrie@flowfuse.com> Date: Wed, 16 Sep 2026 11:07:40 +0200 Subject: [PATCH 5/5] blueprints: use Heroicons directly, drop SiteArt Both glyphs are stock Heroicons, byte-identical to the files SiteArt inlined, so they go through <UIcon> and the component, its generated util and its generator script come out. Each call site carries the size the source SVG had. --- nuxt/components/BlueprintCard.vue | 2 +- nuxt/components/SiteArt.vue | 20 -- nuxt/pages/blueprints/[category]/[slug].vue | 4 +- nuxt/utils/siteArt.ts | 196 -------------------- scripts/gen-site-art.mjs | 103 ---------- 5 files changed, 3 insertions(+), 322 deletions(-) delete mode 100644 nuxt/components/SiteArt.vue delete mode 100644 nuxt/utils/siteArt.ts delete mode 100755 scripts/gen-site-art.mjs diff --git a/nuxt/components/BlueprintCard.vue b/nuxt/components/BlueprintCard.vue index a52cb6c5c8..bc23715d6a 100644 --- a/nuxt/components/BlueprintCard.vue +++ b/nuxt/components/BlueprintCard.vue @@ -50,7 +50,7 @@ const imageAlt = computed(() => props.entry.image ? `Image representing ${props. class="ff-btn ff-btn--primary-outlined flex gap-2" target="_blank" rel="noopener" - >DEPLOY <SiteArt name="rocket-launch" /></a> + >DEPLOY <UIcon name="i-heroicons-rocket-launch" class="size-6 shrink-0" /></a> </div> </div> </li> diff --git a/nuxt/components/SiteArt.vue b/nuxt/components/SiteArt.vue deleted file mode 100644 index 7d0d8acee0..0000000000 --- a/nuxt/components/SiteArt.vue +++ /dev/null @@ -1,20 +0,0 @@ -<script setup lang="ts"> -// Inlines one of the raw-included SVGs from utils/siteArt.ts. `display: contents` keeps -// this wrapper out of the layout, so the SVG sits in the caller's box exactly as the -// {% include %} placed it. -const props = defineProps<{ name?: string }>() - -const markup = computed(() => { - if (!props.name) return '' - const svg = siteArt[props.name] - if (!svg && import.meta.dev) { - console.warn(`[SiteArt] no art for "${props.name}" - add it to nuxt/utils/siteArt.ts`) - } - return svg ?? '' -}) -</script> - -<template> - <!-- eslint-disable-next-line vue/no-v-html --> - <span v-if="markup" class="contents" v-html="markup" /> -</template> diff --git a/nuxt/pages/blueprints/[category]/[slug].vue b/nuxt/pages/blueprints/[category]/[slug].vue index 232c556436..c8316b2fdc 100644 --- a/nuxt/pages/blueprints/[category]/[slug].vue +++ b/nuxt/pages/blueprints/[category]/[slug].vue @@ -63,7 +63,7 @@ useSeoMeta({ <div class="container flex flex-col md:flex-row m-auto text-left max-lg:px-6 md:max-w-screen-lg gap-8 items-stretch"> <div class="ff-prose min-w-0"> <NuxtLink to="/blueprints/" class="inline-flex align-center gap-1 mb-4"> - <SiteArt name="chevron-left" /> + <UIcon name="i-heroicons-chevron-left" class="w-5 h-5 shrink-0" /> Back to Blueprints Library </NuxtLink> <div class="prose w-full flex-grow"> @@ -78,7 +78,7 @@ useSeoMeta({ class="ff-btn ff-btn--primary flex gap-2 mb-6 mt-4 uppercase" target="_blank" rel="noopener" - >Deploy <SiteArt name="rocket-launch" /></a> + >Deploy <UIcon name="i-heroicons-rocket-launch" class="size-6 shrink-0" /></a> <h3 class="mb-3">Author:</h3> <BlueprintCompanyTile :company="author" /> <CtaContactUsLine /> diff --git a/nuxt/utils/siteArt.ts b/nuxt/utils/siteArt.ts deleted file mode 100644 index 3404c74719..0000000000 --- a/nuxt/utils/siteArt.ts +++ /dev/null @@ -1,196 +0,0 @@ -// SVG files that page content included raw, with no wrapper: {% include "components/icons/ -// x.svg" %} rather than the `navoption`/`ffIconLg` shortcodes that NavIcon reproduces. -// -// They cannot go through NavIcon or an <img>: -// - NavIcon wraps its payload in an outer <svg> carrying a fixed ff-icon size class, -// which would override the size the caller's box sets. -// - <img> breaks `currentColor`, and most of these are coloured by their container. -// -// GENERATED by scripts/gen-site-art.mjs from the keys the pages and content collections -// actually reference, so the bundle carries only what is used - an eager glob of that -// directory inlines ~137 KB, most of it unused art. Re-run it after adding a page that -// names a new icon. - -import aAcademicCap from '../../src/_includes/components/icons/academic-cap.svg?raw' -import aAdjustmentsHorizontal from '../../src/_includes/components/icons/adjustments-horizontal.svg?raw' -import aAdjustmentsVertical from '../../src/_includes/components/icons/adjustments-vertical.svg?raw' -import aAirGappedDeployment from '../../src/_includes/components/icons/air-gapped-deployment.svg?raw' -import aAirplane from '../../src/_includes/components/icons/airplane.svg?raw' -import aArchiveBoxXMark from '../../src/_includes/components/icons/archive-box-x-mark.svg?raw' -import aArrowLongRight from '../../src/_includes/components/icons/arrow-long-right.svg?raw' -import aArrowPath from '../../src/_includes/components/icons/arrow-path.svg?raw' -import aArrowPathRoundedSquare from '../../src/_includes/components/icons/arrow-path-rounded-square.svg?raw' -import aArrowSmallDown from '../../src/_includes/components/icons/arrow-small-down.svg?raw' -import aArrowTopRightOnSquare from '../../src/_includes/components/icons/arrow-top-right-on-square.svg?raw' -import aArrowTrendingUp from '../../src/_includes/components/icons/arrow-trending-up.svg?raw' -import aArrows from '../../src/_includes/components/icons/arrows.svg?raw' -import aArrowsPointingOut from '../../src/_includes/components/icons/arrows-pointing-out.svg?raw' -import aArrowsRightLeft from '../../src/_includes/components/icons/arrows-right-left.svg?raw' -import aAuditLogs from '../../src/_includes/components/icons/audit-logs.svg?raw' -import aBellAlert from '../../src/_includes/components/icons/bell-alert.svg?raw' -import aBolt from '../../src/_includes/components/icons/bolt.svg?raw' -import aBookOpen from '../../src/_includes/components/icons/book-open.svg?raw' -import aBuildingOffice2 from '../../src/_includes/components/icons/building-office-2.svg?raw' -import aCalendar from '../../src/_includes/components/icons/calendar.svg?raw' -import aCamera from '../../src/_includes/components/icons/camera.svg?raw' -import aCar from '../../src/_includes/components/icons/car.svg?raw' -import aCertificate from '../../src/_includes/components/icons/certificate.svg?raw' -import aCertifiedNode from '../../src/_includes/components/icons/certified-node.svg?raw' -import aChart from '../../src/_includes/components/icons/chart.svg?raw' -import aChatBubbleBottomCenterText from '../../src/_includes/components/icons/chat-bubble-bottom-center-text.svg?raw' -import aChatBubbleLeftRightSm from '../../src/_includes/components/icons/chat-bubble-left-right-sm.svg?raw' -import aCheckBadge from '../../src/_includes/components/icons/check-badge.svg?raw' -import aCheckCircle from '../../src/_includes/components/icons/check-circle.svg?raw' -import aChevronDown from '../../src/_includes/components/icons/chevron-down.svg?raw' -import aChevronLeft from '../../src/_includes/components/icons/chevron-left.svg?raw' -import aChevronRight from '../../src/_includes/components/icons/chevron-right.svg?raw' -import aChevronRightSm from '../../src/_includes/components/icons/chevron-right-sm.svg?raw' -import aChip from '../../src/_includes/components/icons/chip.svg?raw' -import aCircleStack from '../../src/_includes/components/icons/circle-stack.svg?raw' -import aClipList from '../../src/_includes/components/icons/clip-list.svg?raw' -import aClipboardDocumentCheck from '../../src/_includes/components/icons/clipboard-document-check.svg?raw' -import aClock from '../../src/_includes/components/icons/clock.svg?raw' -import aCloud from '../../src/_includes/components/icons/cloud.svg?raw' -import aCodeBracket from '../../src/_includes/components/icons/code-bracket.svg?raw' -import aCodeblock from '../../src/_includes/components/icons/codeblock.svg?raw' -import aCog from '../../src/_includes/components/icons/cog.svg?raw' -import aCog6Tooth from '../../src/_includes/components/icons/cog-6-tooth.svg?raw' -import aComputerDesktop from '../../src/_includes/components/icons/computer-desktop.svg?raw' -import aCubeTransparent from '../../src/_includes/components/icons/cube-transparent.svg?raw' -import aCursorArrowRays from '../../src/_includes/components/icons/cursor-arrow-rays.svg?raw' -import aData from '../../src/_includes/components/icons/data.svg?raw' -import aDatabase from '../../src/_includes/components/icons/database.svg?raw' -import aDocumentArrowUp from '../../src/_includes/components/icons/document-arrow-up.svg?raw' -import aDocumentChartBar from '../../src/_includes/components/icons/document-chart-bar.svg?raw' -import aDocumentCheck from '../../src/_includes/components/icons/document-check.svg?raw' -import aDocumentText from '../../src/_includes/components/icons/document-text.svg?raw' -import aEye from '../../src/_includes/components/icons/eye.svg?raw' -import aEyeSlash from '../../src/_includes/components/icons/eye-slash.svg?raw' -import aFactory from '../../src/_includes/components/icons/factory.svg?raw' -import aGlobeAlt from '../../src/_includes/components/icons/globe-alt.svg?raw' -import aHandRaised from '../../src/_includes/components/icons/hand-raised.svg?raw' -import aLayers from '../../src/_includes/components/icons/layers.svg?raw' -import aLifebuoy from '../../src/_includes/components/icons/lifebuoy.svg?raw' -import aLightBulb from '../../src/_includes/components/icons/light-bulb.svg?raw' -import aLink from '../../src/_includes/components/icons/link.svg?raw' -import aLinkSlash from '../../src/_includes/components/icons/link-slash.svg?raw' -import aLock from '../../src/_includes/components/icons/lock.svg?raw' -import aLockClosed from '../../src/_includes/components/icons/lock-closed.svg?raw' -import aLockOpen from '../../src/_includes/components/icons/lock-open.svg?raw' -import aPin from '../../src/_includes/components/icons/pin.svg?raw' -import aPinSlash from '../../src/_includes/components/icons/pin-slash.svg?raw' -import aPulse from '../../src/_includes/components/icons/pulse.svg?raw' -import aPuzzlePiece from '../../src/_includes/components/icons/puzzle-piece.svg?raw' -import aQueueList from '../../src/_includes/components/icons/queue-list.svg?raw' -import aRectangleStack from '../../src/_includes/components/icons/rectangle-stack.svg?raw' -import aRocketLaunch from '../../src/_includes/components/icons/rocket-launch.svg?raw' -import aRoleBasedAccess from '../../src/_includes/components/icons/role-based-access.svg?raw' -import aServerStack from '../../src/_includes/components/icons/server-stack.svg?raw' -import aShare from '../../src/_includes/components/icons/share.svg?raw' -import aShieldCheck from '../../src/_includes/components/icons/shield-check.svg?raw' -import aShieldExclamation from '../../src/_includes/components/icons/shield-exclamation.svg?raw' -import aSingleSignOn from '../../src/_includes/components/icons/single-sign-on.svg?raw' -import aSnowflake from '../../src/_includes/components/icons/snowflake.svg?raw' -import aSparkles from '../../src/_includes/components/icons/sparkles.svg?raw' -import aSquares2x2 from '../../src/_includes/components/icons/squares-2x2.svg?raw' -import aSquaresPlus from '../../src/_includes/components/icons/squares-plus.svg?raw' -import aTargetView from '../../src/_includes/components/icons/target-view.svg?raw' -import aUns from '../../src/_includes/components/icons/uns.svg?raw' -import aUserGroup from '../../src/_includes/components/icons/user-group.svg?raw' -import aUsers from '../../src/_includes/components/icons/users.svg?raw' -import aWifi from '../../src/_includes/components/icons/wifi.svg?raw' -import aWindTurbine from '../../src/_includes/components/icons/wind-turbine.svg?raw' -import aWrenchScrewdriver from '../../src/_includes/components/icons/wrench-screwdriver.svg?raw' - -export const siteArt: Record<string, string> = { - 'academic-cap': aAcademicCap, - 'adjustments-horizontal': aAdjustmentsHorizontal, - 'adjustments-vertical': aAdjustmentsVertical, - 'air-gapped-deployment': aAirGappedDeployment, - 'airplane': aAirplane, - 'archive-box-x-mark': aArchiveBoxXMark, - 'arrow-long-right': aArrowLongRight, - 'arrow-path': aArrowPath, - 'arrow-path-rounded-square': aArrowPathRoundedSquare, - 'arrow-small-down': aArrowSmallDown, - 'arrow-top-right-on-square': aArrowTopRightOnSquare, - 'arrow-trending-up': aArrowTrendingUp, - 'arrows': aArrows, - 'arrows-pointing-out': aArrowsPointingOut, - 'arrows-right-left': aArrowsRightLeft, - 'audit-logs': aAuditLogs, - 'bell-alert': aBellAlert, - 'bolt': aBolt, - 'book-open': aBookOpen, - 'building-office-2': aBuildingOffice2, - 'calendar': aCalendar, - 'camera': aCamera, - 'car': aCar, - 'certificate': aCertificate, - 'certified-node': aCertifiedNode, - 'chart': aChart, - 'chat-bubble-bottom-center-text': aChatBubbleBottomCenterText, - 'chat-bubble-left-right-sm': aChatBubbleLeftRightSm, - 'check-badge': aCheckBadge, - 'check-circle': aCheckCircle, - 'chevron-down': aChevronDown, - 'chevron-left': aChevronLeft, - 'chevron-right': aChevronRight, - 'chevron-right-sm': aChevronRightSm, - 'chip': aChip, - 'circle-stack': aCircleStack, - 'clip-list': aClipList, - 'clipboard-document-check': aClipboardDocumentCheck, - 'clock': aClock, - 'cloud': aCloud, - 'code-bracket': aCodeBracket, - 'codeblock': aCodeblock, - 'cog': aCog, - 'cog-6-tooth': aCog6Tooth, - 'computer-desktop': aComputerDesktop, - 'cube-transparent': aCubeTransparent, - 'cursor-arrow-rays': aCursorArrowRays, - 'data': aData, - 'database': aDatabase, - 'document-arrow-up': aDocumentArrowUp, - 'document-chart-bar': aDocumentChartBar, - 'document-check': aDocumentCheck, - 'document-text': aDocumentText, - 'eye': aEye, - 'eye-slash': aEyeSlash, - 'factory': aFactory, - 'globe-alt': aGlobeAlt, - 'hand-raised': aHandRaised, - 'layers': aLayers, - 'lifebuoy': aLifebuoy, - 'light-bulb': aLightBulb, - 'link': aLink, - 'link-slash': aLinkSlash, - 'lock': aLock, - 'lock-closed': aLockClosed, - 'lock-open': aLockOpen, - 'pin': aPin, - 'pin-slash': aPinSlash, - 'pulse': aPulse, - 'puzzle-piece': aPuzzlePiece, - 'queue-list': aQueueList, - 'rectangle-stack': aRectangleStack, - 'rocket-launch': aRocketLaunch, - 'role-based-access': aRoleBasedAccess, - 'server-stack': aServerStack, - 'share': aShare, - 'shield-check': aShieldCheck, - 'shield-exclamation': aShieldExclamation, - 'single-sign-on': aSingleSignOn, - 'snowflake': aSnowflake, - 'sparkles': aSparkles, - 'squares-2x2': aSquares2x2, - 'squares-plus': aSquaresPlus, - 'target-view': aTargetView, - 'uns': aUns, - 'user-group': aUserGroup, - 'users': aUsers, - 'wifi': aWifi, - 'wind-turbine': aWindTurbine, - 'wrench-screwdriver': aWrenchScrewdriver, -} diff --git a/scripts/gen-site-art.mjs b/scripts/gen-site-art.mjs deleted file mode 100755 index cf57a0dbaf..0000000000 --- a/scripts/gen-site-art.mjs +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env node -// Regenerate nuxt/utils/siteArt.ts from the icon keys the site actually references. -// -// <SiteArt> inlines an SVG from src/_includes/components/icons/ verbatim, which is what -// page content that used a bare {% include %} needs. The registry is written out rather -// than globbed, so the bundle carries only the icons in use - an eager glob of that -// directory inlines ~137 KB, most of it unused art. -// -// Keeping it hand-maintained across ~50 keys is not realistic, so this scans for every -// key that is referenced and rewrites the file. Run it after adding a page that names a -// new icon; the build fails loudly on a missing import, and <SiteArt> warns in dev on a -// key the registry does not have. -// -// Usage: node scripts/gen-site-art.mjs [repo-root] -import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs' -import { join, resolve } from 'node:path' - -const ICON_DIR = 'src/_includes/components/icons' - -function filesUnder (dir, ext) { - if (!existsSync(dir)) return [] - return readdirSync(dir, { recursive: true, withFileTypes: true }) - .filter(entry => entry.isFile() && entry.name.endsWith(ext)) - .map(entry => join(entry.parentPath ?? entry.path, entry.name)) -} - -function matchAll (text, pattern) { - return [...text.matchAll(pattern)].map(match => match[1]) -} - -// `explicit` are literal <SiteArt name="x" /> uses - a missing file there is a broken -// page, so it fails the run. `harvested` are svgPath:/icon: values in content and page -// consts, which a renderer *may* pass to SiteArt but may equally resolve some other way -// (contact-us has its own icon map, for one), so a miss there is only reported. -function referencedKeys (root) { - const vues = [...filesUnder(join(root, 'nuxt/pages'), '.vue'), ...filesUnder(join(root, 'nuxt/components'), '.vue')] - const explicit = new Set() - for (const file of vues) { - for (const key of matchAll(readFileSync(file, 'utf8'), /<SiteArt\s+name="([a-z0-9-]+)"/g)) explicit.add(key) - } - // svgPath:/icon: keys in the content collections, which the renderers pass to SiteArt, - // and the same keys declared in a page's own const arrays. Both quote styles: YAML - // here is double-quoted, the .vue consts are single-quoted. - const harvested = new Set() - for (const file of [...filesUnder(join(root, 'nuxt/content'), '.yml'), ...vues]) { - for (const key of matchAll(readFileSync(file, 'utf8'), /(?:svgPath|icon|eyebrowIcon|badge):\s*["']([a-z0-9-]+)["']/g)) { - // An "i-heroicons-x" / "i-lucide-x" value is a <UIcon> name resolved from an - // installed icon set, not a file in this repo, so it is not SiteArt's to provide. - if (!key.startsWith('i-')) harvested.add(key) - } - } - return { explicit, harvested } -} - -const ident = key => 'a' + key.split('-').map(part => part.charAt(0).toUpperCase() + part.slice(1)).join('') - -const HEADER = `// SVG files that page content included raw, with no wrapper: {% include "components/icons/ -// x.svg" %} rather than the \`navoption\`/\`ffIconLg\` shortcodes that NavIcon reproduces. -// -// They cannot go through NavIcon or an <img>: -// - NavIcon wraps its payload in an outer <svg> carrying a fixed ff-icon size class, -// which would override the size the caller's box sets. -// - <img> breaks \`currentColor\`, and most of these are coloured by their container. -// -// GENERATED by scripts/gen-site-art.mjs from the keys the pages and content collections -// actually reference, so the bundle carries only what is used - an eager glob of that -// directory inlines ~137 KB, most of it unused art. Re-run it after adding a page that -// names a new icon. -` - -const root = resolve(process.argv[2] ?? '.') -const { explicit, harvested } = referencedKeys(root) - -// Keys that resolve through NavIcon (the navoption/ffIconLg wrapper) or a hand-written -// Vue icon component are not SiteArt's job. -const navFile = join(root, 'nuxt/utils/navIcons.ts') -const navKeys = existsSync(navFile) ? new Set(matchAll(readFileSync(navFile, 'utf8'), /^ {4}'([a-z0-9-]+)':/gm)) : new Set() - -const usable = [] -const missing = [] -for (const key of [...new Set([...explicit, ...harvested])].sort()) { - (existsSync(join(root, ICON_DIR, `${key}.svg`)) ? usable : missing).push(key) -} - -const lines = [HEADER] -for (const key of usable) lines.push(`import ${ident(key)} from '../../${ICON_DIR}/${key}.svg?raw'`) -lines.push('\nexport const siteArt: Record<string, string> = {') -for (const key of usable) lines.push(` '${key}': ${ident(key)},`) -lines.push('}\n') - -const out = join(root, 'nuxt/utils/siteArt.ts') -writeFileSync(out, lines.join('\n')) -console.log(`${out}: ${usable.length} icons`) - -const alsoInNav = usable.filter(key => navKeys.has(key)) -if (alsoInNav.length) console.log(` (also in navIcons, which is fine - different wrapper: ${alsoInNav.slice(0, 6).join(', ')}…)`) -if (missing.length) console.log(` no SVG file (resolved elsewhere, not bundled here): ${missing.join(', ')}`) - -const broken = missing.filter(key => explicit.has(key)) -if (broken.length) { - console.log(` ERROR: <SiteArt> is asked for these and there is no file: ${broken.join(', ')}`) - process.exit(1) -}