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] diff --git a/netlify/edge-functions/og-image.jsx b/netlify/edge-functions/og-image.jsx new file mode 100644 index 0000000000..8f15b14906 --- /dev/null +++ b/netlify/edge-functions/og-image.jsx @@ -0,0 +1,169 @@ +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); + + // Get parameters from URL + const title = searchParams.get('title') || 'Handbook'; + const description = searchParams.get('description') || ''; + const section = searchParams.get('section') || ''; + + // Combine all text for font loading + const allText = `${title} ${description} ${section} Handbook`; + + // 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( + ( +
+ {/* 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, + fonts: [ + { + name: 'Heebo', + data: heeboRegular, + weight: 400, + style: 'normal', + }, + { + name: 'Heebo', + data: heeboSemibold, + weight: 600, + style: 'normal', + }, + ], + 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 fb22e8586f..f24233b414 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,60 @@ 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, generate dynamic OG image + if (data.page.url && data.page.url.match(/\/handbook\/.+/)) { + 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 + return null; }, people: (data) => { return {...data.team, ...data.guests} 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 2f9e34565c..83c51c3b52 100644 --- a/src/_includes/layouts/base.njk +++ b/src/_includes/layouts/base.njk @@ -130,15 +130,24 @@ eleventyComputed: {% endif %} + + {% if page.url and page.url.match('\/handbook\/.+') %} + + {% elif type == 'post' %} + + {% else %} + + {% endif %} + {% if type == 'post' %} {% 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}}