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( + ( +
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 %}