Skip to content

Commit c2f64f5

Browse files
committed
Merge branch 'main' into certified-nodes-redesign
2 parents a1e3864 + 3f41db1 commit c2f64f5

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1302
-252
lines changed

.eleventy.js

Lines changed: 218 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,222 @@ module.exports = function(eleventyConfig) {
634634

635635
eleventyConfig.addShortcode("year", () => `${new Date().getFullYear()}`);
636636

637+
// Feature catalog helpers for tier badges
638+
const featureCatalog = yaml.load(fs.readFileSync("./src/_data/featureCatalog.yaml", "utf8"));
639+
640+
function changelogTitle(url) {
641+
const slug = url.replace(/\/$/, '').split('/').pop();
642+
const parts = url.replace(/\/$/, '').split('/').filter(Boolean);
643+
// url: /changelog/2026/02/slug/ -> src/changelog/2026/02/slug.md
644+
const filePath = path.join("./src", parts.join('/') + '.md');
645+
try {
646+
const content = fs.readFileSync(filePath, 'utf8');
647+
const match = content.match(/^---[\s\S]*?title:\s*["']?(.+?)["']?\s*$/m);
648+
if (match) return match[1];
649+
} catch (e) { /* file not found, fall back */ }
650+
return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
651+
}
652+
653+
function findFeatureById(id) {
654+
for (const section of featureCatalog.sections) {
655+
for (const feature of section.features) {
656+
if (feature.id === id) return feature;
657+
}
658+
}
659+
return null;
660+
}
661+
662+
function getChangelogUrls(feature) {
663+
if (!feature.changelog) return [];
664+
const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog];
665+
return entries.map(entry => typeof entry === 'string' ? entry : entry.url);
666+
}
667+
668+
function getChangelogUrlsForRelease(feature, release) {
669+
if (!feature.changelog) return [];
670+
const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog];
671+
return entries
672+
.filter(entry => typeof entry === 'object' && entry.release === release)
673+
.map(entry => entry.url);
674+
}
675+
676+
function findFeatureByChangelog(changelogUrl) {
677+
const normalized = changelogUrl.replace(/\/$/, '') + '/';
678+
for (const section of featureCatalog.sections) {
679+
for (const feature of section.features) {
680+
const urls = getChangelogUrls(feature);
681+
for (const url of urls) {
682+
if ((url.replace(/\/$/, '') + '/') === normalized) return feature;
683+
}
684+
}
685+
}
686+
return null;
687+
}
688+
689+
function deriveTierLabel(tierData) {
690+
if (!tierData) return null;
691+
const starter = tierData.starter && tierData.starter.value;
692+
const pro = tierData.pro && tierData.pro.value;
693+
const enterprise = tierData.enterprise && tierData.enterprise.value;
694+
if (starter && pro && enterprise) return "All tiers";
695+
if (pro && enterprise) return "Pro+";
696+
if (enterprise === 'contact') return "Enterprise (on request)";
697+
if (enterprise) return "Enterprise";
698+
return "Not available";
699+
}
700+
701+
function renderTierBadges(feature) {
702+
if (!feature) return '';
703+
const cloudLabel = deriveTierLabel(feature.cloud);
704+
const selfHostedLabel = deriveTierLabel(feature.selfHosted);
705+
if (!cloudLabel && !selfHostedLabel) return '';
706+
let html = `<div class="ff-tier-badges">`;
707+
if (cloudLabel) {
708+
const unavailable = cloudLabel === 'Not available';
709+
html += `<span class="ff-tier-badge ${unavailable ? 'ff-tier--unavailable' : 'ff-tier--available'}">`;
710+
html += `<span class="ff-tier-badge__label">Cloud</span>`;
711+
html += `<span class="ff-tier-badge__value">${cloudLabel}</span>`;
712+
html += `</span>`;
713+
}
714+
if (selfHostedLabel) {
715+
const unavailable = selfHostedLabel === 'Not available';
716+
html += `<span class="ff-tier-badge ${unavailable ? 'ff-tier--unavailable' : 'ff-tier--available'}">`;
717+
html += `<span class="ff-tier-badge__label">Self-Hosted</span>`;
718+
html += `<span class="ff-tier-badge__value">${selfHostedLabel}</span>`;
719+
html += `</span>`;
720+
}
721+
html += '</div>';
722+
return html;
723+
}
724+
725+
function renderChangelogLinks(urls) {
726+
if (!urls || urls.length === 0) return '';
727+
let html = '<div class="ff-related-changelogs">Changelog: ';
728+
const links = urls.map(url => {
729+
const label = changelogTitle(url);
730+
return `<a href="${url}">${label}</a>`;
731+
});
732+
html += links.join(' | ');
733+
html += '</div>';
734+
return html;
735+
}
736+
737+
// Inject tier badges and changelog links into release blog posts based on frontmatter
738+
eleventyConfig.addTransform("releaseFeatures", function(content) {
739+
if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content;
740+
741+
// Transforms don't have access to template data, so parse frontmatter from source
742+
const inputPath = this.page.inputPath;
743+
if (!inputPath || !inputPath.endsWith('.md')) return content;
744+
745+
let frontmatter;
746+
try {
747+
const source = fs.readFileSync(inputPath, 'utf8');
748+
const fmMatch = source.match(/^---\n([\s\S]*?)\n---/);
749+
if (!fmMatch) return content;
750+
frontmatter = yaml.load(fmMatch[1]);
751+
} catch (e) { return content; }
752+
753+
const features = frontmatter.features;
754+
const release = frontmatter.release;
755+
if (!features || !Array.isArray(features) || features.length === 0) return content;
756+
757+
// Build injection map: heading text -> { badges HTML, changelogs HTML }
758+
const injections = [];
759+
for (const entry of features) {
760+
let badges = '';
761+
let changelogs = '';
762+
763+
if (entry.id) {
764+
// Feature from featureCatalog
765+
const feature = findFeatureById(entry.id);
766+
if (!feature) continue;
767+
badges = renderTierBadges(feature);
768+
const changelogUrls = release ? getChangelogUrlsForRelease(feature, release) : getChangelogUrls(feature);
769+
changelogs = renderChangelogLinks(changelogUrls);
770+
} else if (entry.tiers) {
771+
// Inline tier specification (no feature ID)
772+
const inlineFeature = {};
773+
if (entry.tiers.cloud) {
774+
// Convert shorthand ("all", "pro+", "enterprise") to tier structure
775+
const t = entry.tiers.cloud;
776+
inlineFeature.cloud = {
777+
starter: { value: t === 'all' ? true : null },
778+
pro: { value: (t === 'all' || t === 'pro+') ? true : null },
779+
enterprise: { value: true }
780+
};
781+
}
782+
if (entry.tiers.selfHosted) {
783+
const t = entry.tiers.selfHosted;
784+
inlineFeature.selfHosted = {
785+
starter: { value: t === 'all' ? true : null },
786+
pro: { value: (t === 'all' || t === 'pro+') ? true : null },
787+
enterprise: { value: true }
788+
};
789+
}
790+
badges = renderTierBadges(inlineFeature);
791+
}
792+
793+
if (badges || changelogs) {
794+
injections.push({ heading: entry.heading, badges, changelogs });
795+
}
796+
}
797+
798+
if (injections.length === 0) return content;
799+
800+
// Find all headings (h2-h6) in the HTML with their positions
801+
const headingRegex = /<h([2-6])\s[^>]*>.*?<\/h\1>/gs;
802+
const headingMatches = [];
803+
let match;
804+
while ((match = headingRegex.exec(content)) !== null) {
805+
// Extract text content from heading (strip HTML tags)
806+
const textContent = match[0].replace(/<[^>]+>/g, '').trim();
807+
headingMatches.push({ index: match.index, length: match[0].length, text: textContent, level: parseInt(match[1]) });
808+
}
809+
810+
// Process injections in reverse order so indices stay valid
811+
const ops = []; // { index, html } — insert html at index
812+
813+
for (const injection of injections) {
814+
// Find matching heading
815+
const headingIdx = headingMatches.findIndex(h => h.text === injection.heading);
816+
if (headingIdx === -1) continue;
817+
818+
const heading = headingMatches[headingIdx];
819+
820+
// Insert badges right after the heading tag, adding heading-level class for spacing
821+
if (injection.badges) {
822+
const badgesWithLevel = injection.badges.replace('class="ff-tier-badges"', `class="ff-tier-badges ff-tier-badges--h${heading.level}"`);
823+
ops.push({ index: heading.index + heading.length, html: badgesWithLevel });
824+
}
825+
826+
// Insert changelogs before the next heading at the same or higher level
827+
// H2 changelogs go before the next H2; H3 changelogs go before the next H2 or H3
828+
if (injection.changelogs) {
829+
const nextPeer = headingMatches.find((h, i) => i > headingIdx && h.level <= heading.level);
830+
const insertBefore = nextPeer ? nextPeer.index : content.length;
831+
ops.push({ index: insertBefore, html: injection.changelogs });
832+
}
833+
}
834+
835+
// Sort by index descending so we can splice without shifting
836+
ops.sort((a, b) => b.index - a.index);
837+
for (const op of ops) {
838+
content = content.slice(0, op.index) + op.html + content.slice(op.index);
839+
}
840+
841+
return content;
842+
});
843+
844+
// Make helpers available to changelog layout via filters
845+
eleventyConfig.addFilter("featureForChangelog", function(url) {
846+
return findFeatureByChangelog(url);
847+
});
848+
849+
eleventyConfig.addFilter("tierLabel", function(tierData) {
850+
return deriveTierLabel(tierData);
851+
});
852+
637853
function loadSVG (file) {
638854
let relativeFilePath = `./src/_includes/components/icons/${file}.svg`;
639855
let data = fs.readFileSync(relativeFilePath, function(err, contents) {
@@ -697,7 +913,7 @@ module.exports = function(eleventyConfig) {
697913
return await imageHandler(src, alt, title, widths, sizes, currentWorkingFilePath, eleventyConfig, async=true, DEV_MODE)
698914
});
699915

700-
eleventyConfig.addAsyncShortcode("tileImage", async function(item, image, defaultImage, defaultDescription, imageSize, title = null) {
916+
eleventyConfig.addAsyncShortcode("tileImage", async function(item, image, defaultImage, defaultDescription, imageSize, title = null, priority = false) {
701917
let imageSrc, imageDescription;
702918

703919
if (item && item.data && item.data.image) {
@@ -716,7 +932,7 @@ module.exports = function(eleventyConfig) {
716932

717933
const currentWorkingFilePath = this.page.inputPath;
718934

719-
return await imageHandler(imageSrc, imageDescription, title, [imageSize], null, currentWorkingFilePath, eleventyConfig, async=true, DEV_MODE);
935+
return await imageHandler(imageSrc, imageDescription, title, [imageSize], null, currentWorkingFilePath, eleventyConfig, async=true, DEV_MODE, priority);
720936
});
721937

722938
// Create a collection for sidebar navigation

.github/workflows/build.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ jobs:
2424
path: 'flowfuse'
2525
- name: Generate a token
2626
id: generate_token
27-
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
27+
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
2828
with:
29-
app_id: ${{ secrets.GH_BOT_APP_ID }}
30-
private_key: ${{ secrets.GH_BOT_APP_KEY }}
29+
app-id: ${{ secrets.GH_BOT_APP_ID }}
30+
private-key: ${{ secrets.GH_BOT_APP_KEY }}
31+
owner: ${{ github.repository_owner }}
3132
- name: Check out FlowFuse/blueprint-library repository (to access the blueprints)
3233
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
3334
with:
@@ -37,7 +38,7 @@ jobs:
3738
token: ${{ steps.generate_token.outputs.token }}
3839
- name: Install jq
3940
run: sudo apt-get -qy install jq
40-
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
41+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
4142
with:
4243
cache: 'npm'
4344
cache-dependency-path: './website/package-lock.json'

.github/workflows/sast-scan.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,4 @@ concurrency:
1616
jobs:
1717
scan:
1818
name: SAST Scan
19-
uses : flowfuse/github-actions-workflows/.github/workflows/sast_scan.yaml@v0.51.0
19+
uses : flowfuse/github-actions-workflows/.github/workflows/sast_scan.yaml@v0.52.0

.github/workflows/test.yml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,33 @@ jobs:
66
test_website:
77
runs-on: ubuntu-latest
88
steps:
9+
- name: Generate a token
10+
id: generate_token
11+
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
12+
with:
13+
app-id: ${{ secrets.GH_BOT_APP_ID }}
14+
private-key: ${{ secrets.GH_BOT_APP_KEY }}
15+
owner: ${{ github.repository_owner }}
916
- name: Check out website repository
1017
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
1118
with:
1219
path: 'website'
20+
token: ${{ steps.generate_token.outputs.token }}
1321
- name: Check out FlowFuse/flowfuse repository (to access the docs)
1422
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
1523
with:
1624
repository: 'FlowFuse/flowfuse'
1725
ref: main
1826
path: 'flowfuse'
19-
- name: Generate a token
20-
id: generate_token
21-
uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a # v2.1.0
22-
with:
23-
app_id: ${{ secrets.GH_BOT_APP_ID }}
24-
private_key: ${{ secrets.GH_BOT_APP_KEY }}
27+
token: ${{ steps.generate_token.outputs.token }}
2528
- name: Check out FlowFuse/blueprint-library repository (to access the blueprints)
2629
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2730
with:
2831
repository: 'FlowFuse/blueprint-library'
2932
ref: main
3033
path: 'blueprint-library'
3134
token: ${{ steps.generate_token.outputs.token }}
32-
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
35+
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
3336
with:
3437
cache: 'npm'
3538
cache-dependency-path: './website/package-lock.json'

lib/image-handler.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,8 @@ module.exports = function imageHandler(
131131
currentFilePath = null,
132132
eleventyConfig = null,
133133
async = true,
134-
DEV_MODE = false
134+
DEV_MODE = false,
135+
priority = false
135136
) {
136137
const eleventyInputFolderPath = eleventyConfig.dir.input
137138
const eleventyOutputFolderPath = eleventyConfig.dir.output
@@ -179,8 +180,9 @@ module.exports = function imageHandler(
179180
...(parsedTitle.title && { title: parsedTitle.title }), // skip if null
180181
alt: imgAlt,
181182
sizes: htmlSizes.join(", "),
182-
loading: "lazy",
183+
loading: priority ? "eager" : "lazy",
183184
decoding: "async",
185+
...(priority && { fetchpriority: "high" }),
184186
}
185187

186188
if (parsedTitle.skip || imgSrc.startsWith('http')) {

package-lock.json

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)