Skip to content
Open
6 changes: 6 additions & 0 deletions netlify.toml
Original file line number Diff line number Diff line change
@@ -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]
Expand Down
169 changes: 169 additions & 0 deletions netlify/edge-functions/og-image.jsx
Original file line number Diff line number Diff line change
@@ -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(
(
<div
style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
backgroundColor: '#ffffff',
padding: '60px',
fontFamily: 'Heebo',
}}
>
{/* Header with logo and badge */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '20px',
marginBottom: '40px',
}}
>
{/* FlowFuse Logo */}
<img
src="https://flowfuse.com/handbook/images/logos/ff-icon--light.png"
width="80"
height="80"
alt="FlowFuse"
/>

{/* Handbook badge with section */}
<div
style={{
fontSize: '48px',
color: '#6B7280',
fontWeight: '600',
display: 'flex',
alignItems: 'center',
gap: '16px',
}}
>
<span style={{ color: '#111827' }}>Handbook</span>
{section && (
<>
<span style={{ color: '#D1D5DB' }}>•</span>
<span style={{ textTransform: 'capitalize', color: '#9CA3AF' }}>{section}</span>
</>
)}
</div>
</div>

{/* Title */}
<div
style={{
fontSize: '64px',
fontWeight: 'bold',
color: '#111827',
lineHeight: 1.1,
marginBottom: '30px',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{title}
</div>

{/* Description with fade effect */}
{description && (
<div
style={{
position: 'relative',
maxWidth: '100%',
}}
>
<div
style={{
fontSize: '38px',
color: '#6B7280',
lineHeight: 1.4,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 5,
WebkitBoxOrient: 'vertical',
}}
>
{description}
</div>
{/* Fade gradient overlay */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '80px',
background: 'linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%)',
pointerEvents: 'none',
}}
/>
</div>
)}
</div>
),
{
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",
};
72 changes: 72 additions & 0 deletions src/_data/eleventyComputed.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,22 @@
// Helper function to extract first paragraph from HTML content
function extractFirstParagraph(content) {
if (!content) return null;

// Match first <p> tag content
const match = content.match(/<p[^>]*>([\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) => {
Expand All @@ -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}&section=${section}`;
}

// Otherwise, let base.njk handle the fallback
return null;
},
people: (data) => {
return {...data.team, ...data.guests}
Expand Down
4 changes: 2 additions & 2 deletions src/_includes/jsonld.njk
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}",
Expand Down
15 changes: 12 additions & 3 deletions src/_includes/layouts/base.njk
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,24 @@ eleventyComputed:
<meta property="article:section" content="{{ nav }}" />
{% endif %}

<!-- Open Graph Type -->
{% if page.url and page.url.match('\/handbook\/.+') %}
<meta property="og:type" content="article" />
{% elif type == 'post' %}
<meta property="og:type" content="article" />
{% else %}
<meta property="og:type" content="website" />
{% endif %}

{% if type == 'post' %}
<!-- Article dates -->
{% if date %}
<meta property="article:published_time" content="{{ date | dateToRfc3339 }}" />
<meta property="article:published_time" content="{{ date | toDate | dateToRfc3339 }}" />
{% endif %}
{% if lastUpdated %}
<meta property="article:modified_time" content="{{ lastUpdated | dateToRfc3339 }}" />
<meta property="article:modified_time" content="{{ lastUpdated | toDate | dateToRfc3339 }}" />
{% elif date %}
<meta property="article:modified_time" content="{{ date | dateToRfc3339 }}" />
<meta property="article:modified_time" content="{{ date | toDate | dateToRfc3339 }}" />
{% endif %}
{% endif %}

Expand Down
2 changes: 1 addition & 1 deletion src/_includes/layouts/post-changelog.njk
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ hubspot:
{% renderTeamMember people[author] %}
{% endif %}
{% endfor %}
<p>Published on: <time value="{{ date | dateToRfc3339 }}">{{ date | shortDate }}</time></p>
<p>Published on: <time value="{{ date | toDate | dateToRfc3339 }}">{{ date | shortDate }}</time></p>
{% if issues and issues.length > 0 %}
<div class="py-6 border-t-2">
<h3 class="mb-3">Related GitHub Issues</h3>
Expand Down
4 changes: 2 additions & 2 deletions src/_includes/layouts/post.njk
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,9 @@
{%- endfor %}
<span class="text-gray-400">•</span>
{%- if lastUpdated -%}
<time value="{{ lastUpdated | dateToRfc3339 }}">{{ lastUpdated | shortDate }}</time>
<time value="{{ lastUpdated | toDate | dateToRfc3339 }}">{{ lastUpdated | shortDate }}</time>
{%- else -%}
<time value="{{ date | dateToRfc3339 }}">{{ date | shortDate }}</time>
<time value="{{ date | toDate | dateToRfc3339 }}">{{ date | shortDate }}</time>
{%- endif -%}
</div>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/feed-changelog.njk
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ eleventyExcludeFromCollections: true
<title>FlowFuse - Changelog</title>
<link href="https://flowfuse.com/changelog/index.xml" rel="self"/>
<link href="https://flowfuse.com/changelog"/>
<updated>{{ collections.changelog | getNewestCollectionItemDate | dateToRfc3339 }}</updated>
<updated>{{ collections.changelog | getNewestCollectionItemDate | toDate | dateToRfc3339 }}</updated>
<id>https://flowfuse.com/changelog</id>
{%- for post in collections.changelog | reverse %}
{% set absolutePostUrl %}{{ post.url | url | absoluteUrl("https://flowfuse.com/changelog/") }}{% endset %}
<entry>
<id>{{ absolutePostUrl }}</id>
<title>{{ post.data.title }}</title>
<summary>{{ post.data.subtitle }}</summary>
<updated>{{ post.date | dateToRfc3339 }}</updated>
<updated>{{ post.date | toDate | dateToRfc3339 }}</updated>
<link href="{{ absolutePostUrl }}"/>
{%- for author in post.data.authors %}
<author><name>{{team[author].name}}</name></author>
Expand Down
4 changes: 2 additions & 2 deletions src/feed.njk
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,15 @@ eleventyExcludeFromCollections: true
<title>FlowFuse</title>
<link href="https://flowfuse.com/blog/index.xml" rel="self"/>
<link href="https://flowfuse.com/blog"/>
<updated>{{ collections.posts | getNewestCollectionItemDate | dateToRfc3339 }}</updated>
<updated>{{ collections.posts | getNewestCollectionItemDate | toDate | dateToRfc3339 }}</updated>
<id>https://flowfuse.com/blog</id>
{%- for post in collections.posts | reverse %}
{% set absolutePostUrl %}{{ post.url | url | absoluteUrl("https://flowfuse.com/blog/") }}{% endset %}
<entry>
<id>{{ absolutePostUrl }}</id>
<title>{{ post.data.title }}</title>
<summary>{{ post.data.subtitle }}</summary>
<updated>{{ post.date | dateToRfc3339 }}</updated>
<updated>{{ post.date | toDate | dateToRfc3339 }}</updated>
<link href="{{ absolutePostUrl }}"/>
{%- for author in post.data.authors %}
<author><name>{{team[author].name}}</name></author>
Expand Down