Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -410,15 +410,19 @@ module.exports = function(eleventyConfig) {
// Do not rewrite absolute urls, in-page anchors or emails
continue
}
// */abc.md#anchor => */abc/#anchor
url = url.replace(/.md(#.*)?$/, '$1')
// */abc.md => */abc/ and */abc.md#anchor => */abc/#anchor
if (url.endsWith('.md')) {
url = url.slice(0, -3) + '/';
} else if (url.match(/\.md#/)) {
url = url.replace(/\.md(#.*)$/, '/$1');
}
// */README#anchor => */#anchor
url = url.replace(/README(#.*)?$/, '$1')
if (url[0] !== '/' && !isIndexPage) {
url = '../'+url
}

str = str.substring(0, match.index) + `${match[2]}="${url}"` + str.substring(match.index+match[1].length)
str = str.substring(0, match.index) + `${match[2]}="${url}"` + str.substring(match.index+match[0].length)
}
return str;
})
Expand Down
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.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import React from "https://esm.sh/react@18.2.0";
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So for each page we create an image, on demand and cache if for the maximum duration possible. That seems fair, though a week I think might be better?

import { ImageResponse } from "https://deno.land/x/og_edge/mod.ts";

async function loadGoogleFont(font: string, text: string) {
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: 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"
/>

{/* 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 && (
<div style={{ display: 'flex', alignItems: 'center', gap: '16px' }}>
<span style={{ color: '#D1D5DB' }}>•</span>
<span style={{ textTransform: 'capitalize', color: '#9CA3AF' }}>{section}</span>
</div>
)}
</div>
</div>

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

{/* Description with fade effect */}
{description && (
<div
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
maxWidth: '100%',
overflow: 'hidden',
maxHeight: '270px',
}}
>
<div
style={{
fontSize: '38px',
color: '#6B7280',
lineHeight: 1.4,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
lineClamp: 5,
display: 'flex',
}}
>
{description}
</div>
{/* Fade gradient overlay */}
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: '80px',
backgroundImage: 'linear-gradient(to bottom, rgba(255,255,255,0) 0%, rgba(255,255,255,1) 100%)',
}}
/>
</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",
};
85 changes: 85 additions & 0 deletions src/_data/eleventyComputed.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,42 @@
const fs = require('fs');

// Extract the first meaningful paragraph from a markdown file's raw source
function extractFirstParagraph(inputPath) {
try {
const content = fs.readFileSync(inputPath, 'utf-8');
// Strip frontmatter
const withoutFrontMatter = content.replace(/^---[\s\S]*?---/, '').trim();
// Split into blocks by double newlines
const blocks = withoutFrontMatter.split(/\n\n+/);
// Find the first block that looks like a paragraph (not a heading, list, image, code fence, or nunjucks tag)
const paragraph = blocks.find(b => {
const trimmed = b.trim();
return trimmed &&
!trimmed.startsWith('#') &&
!trimmed.startsWith('- ') &&
!trimmed.startsWith('* ') &&
!trimmed.match(/^\d+\.\s/) &&
!trimmed.startsWith('![') &&
!trimmed.startsWith('```') &&
!trimmed.startsWith('{%') &&
!trimmed.startsWith('|');
});
if (!paragraph) return null;
// Strip markdown syntax
return paragraph
.replace(/!\[.*?\]\(.*?\)/g, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/(\*\*|__)(.*?)\1/g, '$2')
.replace(/(\*|_)(.*?)\1/g, '$2')
.replace(/`([^`]+)`/g, '$1')
.replace(/\n/g, ' ')
.trim()
.substring(0, 200);
} catch (e) {
return null;
}
}

module.exports = {
meta: {
title: (data) => {
Expand All @@ -7,7 +46,53 @@ 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, extract first paragraph from markdown source
if (data.page.url && data.page.url.match(/\/handbook\/.+/)) {
return extractFirstParagraph(data.page.inputPath);
}

// 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\/.+/)) {
// 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 ? pathParts[0] : '';
const pageName = data.navTitle || data.title || pathParts[pathParts.length - 1] || 'Handbook';

// Title: path-based like "People Ops - FlowFuse Peopleops Handbook"
const title = encodeURIComponent(
`${pageName} - FlowFuse ${section.charAt(0).toUpperCase() + section.slice(1)} Handbook`
);

// Description: first paragraph from the page content
const description = encodeURIComponent(
data.description ||
extractFirstParagraph(data.page.inputPath) ||
''
);

// Construct edge function URL
return `/og-image?title=${title}&description=${description}&section=${encodeURIComponent(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" />
Comment on lines +134 to +139
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So only if the url isn't /blog/ or /handbook/`? If so, can we remove one if statement?

{% 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
Loading