From bf6b217f24f285f30c5e712f812bc98068d79cfc Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 22 Jan 2026 22:59:33 +0100 Subject: [PATCH 01/10] Fix handbook Slack unfurl with better meta tags - Extract first paragraph from handbook pages for og:description - Use handbook logo for og:image instead of generic tile - Set og:type to "article" for handbook pages - Add article:published_time and article:modified_time metadata This improves how handbook links appear when shared in Slack and other social platforms by showing page-specific content instead of generic site descriptions. --- src/_data/eleventyComputed.js | 59 ++++++++++++++++++++++++++++++++++ src/_includes/layouts/base.njk | 11 ++++++- 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/_data/eleventyComputed.js b/src/_data/eleventyComputed.js index fb22e8586f..22f38ce3ea 100644 --- a/src/_data/eleventyComputed.js +++ b/src/_data/eleventyComputed.js @@ -1,3 +1,22 @@ +// Helper function to extract first paragraph from HTML content +function extractFirstParagraph(content) { + if (!content) return null; + + // Match first

tag content + const match = content.match(/]*>([\s\S]*?)<\/p>/); + if (match) { + // Strip HTML tags and clean up + let text = match[1].replace(/<\/?[^>]+>/gi, '').trim(); + // Limit to reasonable length (160 chars is optimal for meta descriptions) + if (text.length > 160) { + text = text.substring(0, 157) + '...'; + } + return text; + } + + return null; +} + module.exports = { meta: { title: (data) => { @@ -7,7 +26,47 @@ module.exports = { } else { return title } + }, + description: (data) => { + // If description is already set, use it + if (data.description || data.meta?.description) { + return data.description || data.meta?.description; + } + + // For handbook pages, try to extract first paragraph + if (data.page.url && data.page.url.match(/\/handbook\/.+/) && data.content) { + // Extract first paragraph from rendered content + const firstParagraph = extractFirstParagraph(data.content); + if (firstParagraph) { + return firstParagraph; + } + + // Fallback: generate from page path + const pathParts = data.page.url.split('/').filter(p => p && p !== 'handbook'); + if (pathParts.length > 0) { + const section = pathParts[0]; + const pageName = data.navTitle || data.title || pathParts[pathParts.length - 1]; + return `${pageName} - FlowFuse ${section.charAt(0).toUpperCase() + section.slice(1)} Handbook`; + } + } + + // Return null to let base.njk use site subtitle + return null; + } + }, + image: (data) => { + // If image is already set in frontmatter, use it + if (data.image) { + return data.image; + } + + // For handbook pages without an image, use handbook logo + if (data.page.url && data.page.url.match(/\/handbook\/.+/)) { + return '/handbook/images/logos/ff-logo--square--dark.png'; } + + // Otherwise, let base.njk handle the fallback + return null; }, people: (data) => { return {...data.team, ...data.guests} diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index 2f9e34565c..8fdda6f44c 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -130,7 +130,16 @@ eleventyComputed: {% endif %} - {% if type == 'post' %} + + {% if page.url and page.url.match('\/handbook\/.+') %} + + {% elif type == 'post' %} + + {% else %} + + {% endif %} + + {% if type == 'post' or (page.url and page.url.match('\/handbook\/.+')) %} {% if date %} From 8bfce7119e19509662b3f5dd5f059e93e03d9583 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 22 Jan 2026 23:43:09 +0100 Subject: [PATCH 02/10] Add dynamic OG image generation for handbook pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates custom social preview images for handbook pages with: - FlowFuse logo and "Handbook • section" header (48px) - Page title (64px bold, max 2 lines) - Description text (38px, max 5 lines with gradient fade) - 1200x630px optimized for Slack/social platforms Implementation: - Netlify Edge Function using og_edge library - Generates images on-demand at /og-image endpoint - Takes title, description, section as query parameters - 1-week cache for performance (604800s) Improves handbook link unfurls by showing page-specific content instead of generic site descriptions and images. --- netlify/edge-functions/og-image.jsx | 133 ++++++++++++++++++++++++++++ src/_data/eleventyComputed.js | 17 +++- 2 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 netlify/edge-functions/og-image.jsx diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx new file mode 100644 index 0000000000..d2016c0227 --- /dev/null +++ b/netlify/edge-functions/og-image.jsx @@ -0,0 +1,133 @@ +import React from "https://esm.sh/react@18.2.0"; +import { ImageResponse } from "https://esm.sh/og_edge"; + +export default async (request) => { + const { searchParams } = new URL(request.url); + + // Get parameters from URL + const title = searchParams.get('title') || 'Handbook'; + const description = searchParams.get('description') || ''; + const section = searchParams.get('section') || ''; + + return new ImageResponse( + ( +

+ {/* Header with logo and badge */} +
+ {/* FlowFuse Logo */} + FlowFuse + + {/* Handbook badge with section */} +
+ Handbook + {section && ( + <> + + {section} + + )} +
+
+ + {/* Title */} +
+ {title} +
+ + {/* Description with fade effect */} + {description && ( +
+
+ {description} +
+ {/* Fade gradient overlay */} +
+
+ )} +
+ ), + { + width: 1200, + height: 630, + headers: { + 'Cache-Control': 'public, max-age=604800, immutable', // 1 week cache + }, + } + ); +}; + +export const config = { + path: "/og-image", +}; diff --git a/src/_data/eleventyComputed.js b/src/_data/eleventyComputed.js index 22f38ce3ea..f24233b414 100644 --- a/src/_data/eleventyComputed.js +++ b/src/_data/eleventyComputed.js @@ -60,9 +60,22 @@ module.exports = { return data.image; } - // For handbook pages without an image, use handbook logo + // For handbook pages without an image, generate dynamic OG image if (data.page.url && data.page.url.match(/\/handbook\/.+/)) { - return '/handbook/images/logos/ff-logo--square--dark.png'; + const title = encodeURIComponent(data.navTitle || data.title || 'Handbook'); + const description = encodeURIComponent( + data.description || + data.meta?.description || + extractFirstParagraph(data.content) || + '' + ); + + // Extract section from URL (e.g., "product", "sales", "design") + const pathParts = data.page.url.split('/').filter(p => p && p !== 'handbook'); + const section = pathParts.length > 0 ? encodeURIComponent(pathParts[0]) : ''; + + // Construct edge function URL + return `/og-image?title=${title}&description=${description}§ion=${section}`; } // Otherwise, let base.njk handle the fallback From 145feb2426b7a7abf340860cc4f812cab565d156 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Thu, 22 Jan 2026 23:57:42 +0100 Subject: [PATCH 03/10] Fix date formatting errors in templates by ensuring Date objects Add toDate filter before dateToRfc3339 to safely convert all date values to Date objects before calling toISOString. Fixes build failure when rendering handbook pages that have auto-assigned dates. --- src/_includes/jsonld.njk | 4 ++-- src/_includes/layouts/base.njk | 6 +++--- src/_includes/layouts/post-changelog.njk | 2 +- src/_includes/layouts/post.njk | 4 ++-- src/feed-changelog.njk | 4 ++-- src/feed.njk | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/src/_includes/jsonld.njk b/src/_includes/jsonld.njk index bf876f8804..4598433d98 100644 --- a/src/_includes/jsonld.njk +++ b/src/_includes/jsonld.njk @@ -79,8 +79,8 @@ {% if image %} "image": "{% if image.startsWith('http') %}{{ image }}{% else %}https://flowfuse.com{{ image }}{% endif %}", {% endif %} - "datePublished": "{{ date | dateToRfc3339 }}", - "dateModified": "{{ (lastUpdated or date) | dateToRfc3339 }}", + "datePublished": "{{ date | toDate | dateToRfc3339 }}", + "dateModified": "{{ (lastUpdated or date) | toDate | dateToRfc3339 }}", "author": { "@type": "Person", "name": "{{ authors }}", diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index 8fdda6f44c..5c9a330e77 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -142,12 +142,12 @@ eleventyComputed: {% if type == 'post' or (page.url and page.url.match('\/handbook\/.+')) %} {% if date %} - + {% endif %} {% if lastUpdated %} - + {% elif date %} - + {% endif %} {% endif %} diff --git a/src/_includes/layouts/post-changelog.njk b/src/_includes/layouts/post-changelog.njk index 2b5192f904..5f7f42df2f 100644 --- a/src/_includes/layouts/post-changelog.njk +++ b/src/_includes/layouts/post-changelog.njk @@ -38,7 +38,7 @@ hubspot: {% renderTeamMember people[author] %} {% endif %} {% endfor %} -

Published on:

+

Published on:

{% if issues and issues.length > 0 %}

Related GitHub Issues

diff --git a/src/_includes/layouts/post.njk b/src/_includes/layouts/post.njk index 071f90e930..000f3cf7ba 100644 --- a/src/_includes/layouts/post.njk +++ b/src/_includes/layouts/post.njk @@ -36,9 +36,9 @@ {%- endfor %} {%- if lastUpdated -%} - + {%- else -%} - + {%- endif -%}
diff --git a/src/feed-changelog.njk b/src/feed-changelog.njk index d774680d7f..8ed61b81fe 100644 --- a/src/feed-changelog.njk +++ b/src/feed-changelog.njk @@ -7,7 +7,7 @@ eleventyExcludeFromCollections: true FlowFuse - Changelog - {{ collections.changelog | getNewestCollectionItemDate | dateToRfc3339 }} + {{ collections.changelog | getNewestCollectionItemDate | toDate | dateToRfc3339 }} https://flowfuse.com/changelog {%- for post in collections.changelog | reverse %} {% set absolutePostUrl %}{{ post.url | url | absoluteUrl("https://flowfuse.com/changelog/") }}{% endset %} @@ -15,7 +15,7 @@ eleventyExcludeFromCollections: true {{ absolutePostUrl }} {{ post.data.title }} {{ post.data.subtitle }} - {{ post.date | dateToRfc3339 }} + {{ post.date | toDate | dateToRfc3339 }} {%- for author in post.data.authors %} {{team[author].name}} diff --git a/src/feed.njk b/src/feed.njk index b561cab668..70eceedb58 100644 --- a/src/feed.njk +++ b/src/feed.njk @@ -7,7 +7,7 @@ eleventyExcludeFromCollections: true FlowFuse - {{ collections.posts | getNewestCollectionItemDate | dateToRfc3339 }} + {{ collections.posts | getNewestCollectionItemDate | toDate | dateToRfc3339 }} https://flowfuse.com/blog {%- for post in collections.posts | reverse %} {% set absolutePostUrl %}{{ post.url | url | absoluteUrl("https://flowfuse.com/blog/") }}{% endset %} @@ -15,7 +15,7 @@ eleventyExcludeFromCollections: true {{ absolutePostUrl }} {{ post.data.title }} {{ post.data.subtitle }} - {{ post.date | dateToRfc3339 }} + {{ post.date | toDate | dateToRfc3339 }} {%- for author in post.data.authors %} {{team[author].name}} From a3eda3c13c80465e21e0cf097517acaaac4b8135 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 23 Jan 2026 00:02:47 +0100 Subject: [PATCH 04/10] Remove article date metadata from handbook pages Handbook pages don't have valid publication dates and shouldn't include article:published_time or article:modified_time metadata. Only posts should have these date fields. --- src/_includes/layouts/base.njk | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/_includes/layouts/base.njk b/src/_includes/layouts/base.njk index 5c9a330e77..83c51c3b52 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -139,7 +139,7 @@ eleventyComputed: {% endif %} - {% if type == 'post' or (page.url and page.url.match('\/handbook\/.+')) %} + {% if type == 'post' %} {% if date %} From 620280ab8b637294ca6b0255af564f10f37f8d0a Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 23 Jan 2026 00:12:01 +0100 Subject: [PATCH 05/10] Add headers configuration for OG image endpoint Configure /og-image endpoint to allow embedding and caching: - Allow CORS for social media platforms - Set 1-week cache for performance - Exempt from frame-ancestors restrictions This fixes Netlify header validation checks. --- netlify.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netlify.toml b/netlify.toml index 3e67b86da7..67c8958280 100644 --- a/netlify.toml +++ b/netlify.toml @@ -1,3 +1,9 @@ +[[headers]] + for = "/og-image" + [headers.values] + Cache-Control = "public, max-age=604800, immutable" + Access-Control-Allow-Origin = "*" + [[headers]] for = "/*" [headers.values] From 7720a2ce6dee4ace324504e2d56c7ad26ce44cff Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Mon, 26 Jan 2026 19:54:32 +0100 Subject: [PATCH 06/10] Use Heebo font and brand colors in OG images Updates the handbook OG image edge function to align with FlowFuse brand guidelines: - Change font from Inter to Heebo (brand standard for web/materials) - Replace generic hex colors with official brand gray palette from Tailwind config (Grey 900, 500, 400, 300) Addresses design feedback from @Yndira-E in #4432 --- netlify/edge-functions/og-image.jsx | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx index d2016c0227..90510622e6 100644 --- a/netlify/edge-functions/og-image.jsx +++ b/netlify/edge-functions/og-image.jsx @@ -19,7 +19,7 @@ export default async (request) => { height: '100%', backgroundColor: '#ffffff', padding: '60px', - fontFamily: 'Inter, system-ui, -apple-system, sans-serif', + fontFamily: 'Heebo, system-ui, -apple-system, sans-serif', }} > {/* Header with logo and badge */} @@ -43,18 +43,18 @@ export default async (request) => {
- Handbook + Handbook {section && ( <> - - {section} + + {section} )}
@@ -65,7 +65,7 @@ export default async (request) => { style={{ fontSize: '64px', fontWeight: 'bold', - color: '#1a1a1a', + color: '#111827', lineHeight: 1.1, marginBottom: '30px', maxWidth: '100%', @@ -90,7 +90,7 @@ export default async (request) => {
Date: Tue, 27 Jan 2026 00:04:20 +0100 Subject: [PATCH 07/10] Fix edge function import for og-edge Use correct Deno registry URL for og_edge package. The package is hosted on deno.land/x, not esm.sh. This fixes the Netlify build failure where the module could not be found. --- netlify/edge-functions/og-image.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx index 90510622e6..56c62fd50a 100644 --- a/netlify/edge-functions/og-image.jsx +++ b/netlify/edge-functions/og-image.jsx @@ -1,5 +1,5 @@ import React from "https://esm.sh/react@18.2.0"; -import { ImageResponse } from "https://esm.sh/og_edge"; +import { ImageResponse } from "https://deno.land/x/og_edge/mod.ts"; export default async (request) => { const { searchParams } = new URL(request.url); From cc96138eab9be00948f067d28af682f89ea3f6dd Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Tue, 27 Jan 2026 00:24:10 +0100 Subject: [PATCH 08/10] Load Heebo font properly in OG image edge function Implement Google Fonts loading using the Vercel OG pattern: - Fetch font CSS and extract font file URL - Load font as ArrayBuffer - Pass to ImageResponse fonts option This fixes the 502 error caused by font not being available in the Deno edge runtime environment. --- netlify/edge-functions/og-image.jsx | 30 ++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx index 56c62fd50a..341bb61021 100644 --- a/netlify/edge-functions/og-image.jsx +++ b/netlify/edge-functions/og-image.jsx @@ -1,6 +1,21 @@ import React from "https://esm.sh/react@18.2.0"; import { ImageResponse } from "https://deno.land/x/og_edge/mod.ts"; +async function loadGoogleFont(font, text) { + const url = `https://fonts.googleapis.com/css2?family=${font}&text=${encodeURIComponent(text)}`; + const css = await (await fetch(url)).text(); + const resource = css.match(/src: url\((.+)\) format\('(opentype|truetype)'\)/); + + if (resource) { + const response = await fetch(resource[1]); + if (response.status == 200) { + return await response.arrayBuffer(); + } + } + + throw new Error('failed to load font data'); +} + export default async (request) => { const { searchParams } = new URL(request.url); @@ -9,6 +24,12 @@ export default async (request) => { const description = searchParams.get('description') || ''; const section = searchParams.get('section') || ''; + // Combine all text for font loading + const allText = `${title} ${description} ${section} Handbook`; + + // Load Heebo font + const heeboFontData = await loadGoogleFont('Heebo:wght@400;600', allText); + return new ImageResponse( (
{ height: '100%', backgroundColor: '#ffffff', padding: '60px', - fontFamily: 'Heebo, system-ui, -apple-system, sans-serif', + fontFamily: 'Heebo', }} > {/* Header with logo and badge */} @@ -121,6 +142,13 @@ export default async (request) => { { width: 1200, height: 630, + fonts: [ + { + name: 'Heebo', + data: heeboFontData, + style: 'normal', + }, + ], headers: { 'Cache-Control': 'public, max-age=604800, immutable', // 1 week cache }, From 10509de7bc2030f27e544d04106c2bcad817446b Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Tue, 27 Jan 2026 00:32:56 +0100 Subject: [PATCH 09/10] Fix Google Fonts API syntax to load single weight The loadGoogleFont function only captures the first @font-face rule. Using multiple weights (wght@400;600) returns multiple rules but only the first is captured. Simplified to load regular weight (400). --- netlify/edge-functions/og-image.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx index 341bb61021..f0eebaf11c 100644 --- a/netlify/edge-functions/og-image.jsx +++ b/netlify/edge-functions/og-image.jsx @@ -27,8 +27,8 @@ export default async (request) => { // Combine all text for font loading const allText = `${title} ${description} ${section} Handbook`; - // Load Heebo font - const heeboFontData = await loadGoogleFont('Heebo:wght@400;600', allText); + // Load Heebo font (regular weight - 400) + const heeboFontData = await loadGoogleFont('Heebo', allText); return new ImageResponse( ( From a554057c452e52a561ed790907d324f58997d90b Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Tue, 27 Jan 2026 00:33:41 +0100 Subject: [PATCH 10/10] Load both regular and semibold Heebo font weights Load weight 400 (regular) and 600 (semibold) separately since loadGoogleFont extracts one weight at a time. The title uses bold/semibold weight while body text uses regular. --- netlify/edge-functions/og-image.jsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx index f0eebaf11c..8f15b14906 100644 --- a/netlify/edge-functions/og-image.jsx +++ b/netlify/edge-functions/og-image.jsx @@ -27,8 +27,9 @@ export default async (request) => { // Combine all text for font loading const allText = `${title} ${description} ${section} Handbook`; - // Load Heebo font (regular weight - 400) - const heeboFontData = await loadGoogleFont('Heebo', allText); + // Load Heebo fonts (regular and semibold) + const heeboRegular = await loadGoogleFont('Heebo:wght@400', allText); + const heeboSemibold = await loadGoogleFont('Heebo:wght@600', allText); return new ImageResponse( ( @@ -145,7 +146,14 @@ export default async (request) => { fonts: [ { name: 'Heebo', - data: heeboFontData, + data: heeboRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Heebo', + data: heeboSemibold, + weight: 600, style: 'normal', }, ],