Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
330 changes: 0 additions & 330 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -594,336 +594,6 @@ module.exports = function(eleventyConfig) {

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

// Feature catalog helpers for tier badges
const featureCatalog = yaml.load(fs.readFileSync("./src/_data/featureCatalog.yaml", "utf8"));

function changelogTitle(url) {
const slug = url.replace(/\/$/, '').split('/').pop();
const parts = url.replace(/\/$/, '').split('/').filter(Boolean);
// url: /changelog/2026/02/slug/ -> src/changelog/2026/02/slug.md
const filePath = path.join("./src", parts.join('/') + '.md');
try {
const content = fs.readFileSync(filePath, 'utf8');
const match = content.match(/^---[\s\S]*?title:\s*["']?(.+?)["']?\s*$/m);
if (match) return match[1];
} catch (e) { /* file not found, fall back */ }
return slug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}

function findFeatureById(id) {
for (const section of featureCatalog.sections) {
for (const feature of section.features) {
if (feature.id === id) return feature;
}
}
return null;
}

function getChangelogUrls(feature) {
if (!feature.changelog) return [];
const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog];
return entries.map(entry => typeof entry === 'string' ? entry : entry.url);
}

function getChangelogUrlsForRelease(feature, release) {
if (!feature.changelog) return [];
const entries = Array.isArray(feature.changelog) ? feature.changelog : [feature.changelog];
return entries
.filter(entry => typeof entry === 'object' && entry.release === release)
.map(entry => entry.url);
}

function deriveTierLabel(tierData) {
if (!tierData) return null;
const enterprise = tierData.enterprise && tierData.enterprise.value;
const enterpriseDimmed = tierData.enterprise && tierData.enterprise.dimmed;
if (enterprise === 'contact' || (typeof enterprise === 'string' && enterprise.toLowerCase().includes('contact'))) return "Enterprise (contact us)";
if (enterpriseDimmed) return "Enterprise (on request)";
if (enterprise === 'time') return "Coming soon";
if (enterprise) return "Enterprise";
return "Not available";
}

function renderTierBadges(feature) {
if (!feature) return '';
const cloudLabel = deriveTierLabel(feature.cloud);
const selfHostedLabel = deriveTierLabel(feature.selfHosted);
const showCloud = cloudLabel && cloudLabel !== 'Not available';
const showSelfHosted = selfHostedLabel && selfHostedLabel !== 'Not available';
if (!showCloud && !showSelfHosted) return '';
let html = `<div class="ff-tier-badges">`;
if (showCloud) {
html += `<div class="ff-tier-badge ff-tier--available" onclick="capture('tier-badge-click',{hosting:'cloud',tier:'${cloudLabel}',page:location.pathname})">`;
html += `<span class="ff-tier-badge__label">Cloud</span>`;
html += `<span class="ff-tier-badge__value">${cloudLabel}</span>`;
html += `</div>`;
}
if (showSelfHosted) {
html += `<div class="ff-tier-badge ff-tier--available" onclick="capture('tier-badge-click',{hosting:'self-hosted',tier:'${selfHostedLabel}',page:location.pathname})">`;
html += `<span class="ff-tier-badge__label">Self-Hosted</span>`;
html += `<span class="ff-tier-badge__value">${selfHostedLabel}</span>`;
html += `</div>`;
}
html += '</div>';
return html;
}

function renderChangelogLinks(urls) {
if (!urls || urls.length === 0) return '';
let html = '<div class="ff-related-changelogs">Changelog: ';
const links = urls.map(url => {
const label = changelogTitle(url);
return `<a href="${url}">${label}</a>`;
});
html += links.join(' | ');
html += '</div>';
return html;
}

function renderDocsLink(feature) {
if (!feature || !feature.docsLink) return '';
const label = feature.label || 'Documentation';
return `<div class="ff-related-docs">Docs: <a href="${feature.docsLink}">${label}</a></div>`;
}

// Inject tier badges, changelog links, and a docs link into release blog posts based on frontmatter
eleventyConfig.addTransform("releaseFeatures", function(content) {
if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content;

// Transforms don't have access to template data, so parse frontmatter from source
const inputPath = this.page.inputPath;
if (!inputPath || !inputPath.endsWith('.md')) return content;

let frontmatter;
try {
const source = fs.readFileSync(inputPath, 'utf8');
const fmMatch = source.match(/^---\n([\s\S]*?)\n---/);
if (!fmMatch) return content;
frontmatter = yaml.load(fmMatch[1]);
} catch (e) { return content; }

const features = frontmatter.features;
const release = frontmatter.release;
if (!release || !features || !Array.isArray(features) || features.length === 0) return content;

// Build injection map: heading text -> { badges HTML, changelogs HTML }
const injections = [];
for (const entry of features) {
let badges = '';
let changelogs = '';
let docs = '';

if (entry.id) {
// Feature from featureCatalog
const feature = findFeatureById(entry.id);
if (!feature) continue;
badges = renderTierBadges(feature);
const changelogUrls = release ? getChangelogUrlsForRelease(feature, release) : getChangelogUrls(feature);
changelogs = renderChangelogLinks(changelogUrls);
docs = renderDocsLink(feature);
} else if (entry.tiers) {
// Inline tier specification (no feature ID)
const inlineFeature = {};
if (entry.tiers.cloud) {
// Convert shorthand to tier structure
inlineFeature.cloud = {
enterprise: { value: true }
};
}
if (entry.tiers.selfHosted) {
inlineFeature.selfHosted = {
enterprise: { value: true }
};
}
badges = renderTierBadges(inlineFeature);
}

if (badges || changelogs || docs) {
// Docs link sits on its own line below the changelog line
injections.push({ heading: entry.heading, badges, related: changelogs + docs });
}
}

if (injections.length === 0) return content;

// Find all headings (h2-h6) in the HTML with their positions
const headingRegex = /<h([2-6])\s[^>]*>.*?<\/h\1>/gs;
const headingMatches = [];
let match;
while ((match = headingRegex.exec(content)) !== null) {
// Extract text content from heading (strip HTML tags)
const textContent = match[0].replace(/<[^>]+>/g, '').trim();
headingMatches.push({ index: match.index, length: match[0].length, text: textContent, level: parseInt(match[1]) });
}

// Process injections in reverse order so indices stay valid
const ops = []; // { index, html } — insert html at index

for (const injection of injections) {
// Find matching heading
const headingIdx = headingMatches.findIndex(h => h.text === injection.heading);
if (headingIdx === -1) continue;

const heading = headingMatches[headingIdx];

// Insert badges right after the heading tag, adding heading-level class for spacing
if (injection.badges) {
const badgesWithLevel = injection.badges.replace('class="ff-tier-badges"', `class="ff-tier-badges ff-tier-badges--h${heading.level}"`);
ops.push({ index: heading.index + heading.length, html: badgesWithLevel });
}

// Insert changelog + docs links before the next heading at the same or higher level
// H2 links go before the next H2; H3 links go before the next H2 or H3
if (injection.related) {
const nextPeer = headingMatches.find((h, i) => i > headingIdx && h.level <= heading.level);
const insertBefore = nextPeer ? nextPeer.index : content.length;
ops.push({ index: insertBefore, html: injection.related });
}
}

// Sort by index descending so we can splice without shifting
ops.sort((a, b) => b.index - a.index);
for (const op of ops) {
content = content.slice(0, op.index) + op.html + content.slice(op.index);
}

return content;
});

function findFeatureByDocsLink(pageUrl) {
if (!pageUrl) return null;
const normalizedPage = pageUrl.replace(/\/$/, '') + '/';
for (const section of featureCatalog.sections) {
for (const feature of section.features) {
if (!feature.docsLink || feature.subfeature) continue;
let link = feature.docsLink;
// Strip full domain if present
link = link.replace(/^https?:\/\/flowfuse\.com/, '');
// Strip fragment
link = link.replace(/#.*$/, '');
const normalizedLink = link.replace(/\/$/, '') + '/';
if (normalizedPage === normalizedLink) return feature;
}
}
return null;
}

function findSubfeaturesForDocsPage(pageUrl) {
if (!pageUrl) return [];
const normalizedPage = pageUrl.replace(/\/$/, '') + '/';
const results = [];
for (const section of featureCatalog.sections) {
for (const feature of section.features) {
if (!feature.docsLink || !feature.subfeature) continue;
let link = feature.docsLink;
link = link.replace(/^https?:\/\/flowfuse\.com/, '');
const fragment = (link.match(/#(.+)/) || [])[1];
if (!fragment) continue;
const linkPath = link.replace(/#.*/, '').replace(/\/$/, '') + '/';
if (normalizedPage === linkPath) {
results.push({ feature, fragment });
}
}
}
return results;
}

// Inject tier badges into node-red pages: parent feature after H1, subfeatures after their headings
eleventyConfig.addTransform("docsFeatureBadges", function(content) {
if (!this.page.outputPath || !this.page.outputPath.endsWith(".html")) return content;
if (!this.page.url || !/^\/node-red\//.test(this.page.url)) return content;

const parentFeature = findFeatureByDocsLink(this.page.url);
const subfeatures = findSubfeaturesForDocsPage(this.page.url);

// Parse frontmatter for features array — but skip pages with `release` (handled by releaseFeatures)
let fmFeatures = [];
const inputPath = this.page.inputPath;
if (inputPath && inputPath.endsWith('.md')) {
try {
const source = fs.readFileSync(inputPath, 'utf8');
const fmMatch = source.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const fm = yaml.load(fmMatch[1]);
if (fm.release) return content;
if (fm.features && Array.isArray(fm.features)) {
fmFeatures = fm.features;
}
}
} catch (e) { /* ignore */ }
}

if (!parentFeature && subfeatures.length === 0 && fmFeatures.length === 0) return content;

const ops = [];

// Inject parent feature badges after the first H1
if (parentFeature) {
const h1Regex = /<h1[^>]*>.*?<\/h1>/s;
const h1Match = h1Regex.exec(content);
if (h1Match) {
const badges = renderTierBadges(parentFeature);
if (badges) {
const wrapped = badges.replace('class="ff-tier-badges"', 'class="ff-tier-badges not-prose"');
ops.push({ index: h1Match.index + h1Match[0].length, html: wrapped });
}
}
}

// Scan headings for subfeature and frontmatter-based injections
if (subfeatures.length > 0 || fmFeatures.length > 0) {
const headingRegex = /<h([2-6])\s[^>]*id="([^"]*)"[^>]*>.*?<\/h\1>/gs;
const headingMatches = [];
let hmatch;
while ((hmatch = headingRegex.exec(content)) !== null) {
const textContent = hmatch[0].replace(/<[^>]+>/g, '').trim();
headingMatches.push({ index: hmatch.index, length: hmatch[0].length, id: hmatch[2], text: textContent, level: parseInt(hmatch[1]) });
}

// Frontmatter features take priority — track handled heading IDs
const handledHeadingIds = new Set();
for (const entry of fmFeatures) {
if (!entry.id || !entry.heading) continue;
const feature = findFeatureById(entry.id);
if (!feature) continue;
const heading = headingMatches.find(h => h.text === entry.heading);
if (!heading) continue;
handledHeadingIds.add(heading.id);
const badges = renderTierBadges(feature);
if (badges) {
const wrapped = badges.replace('class="ff-tier-badges"', 'class="ff-tier-badges not-prose"');
ops.push({ index: heading.index + heading.length, html: wrapped });
}
}

// Subfeatures matched by docsLink fragment (skip if frontmatter already handled)
for (const { feature, fragment } of subfeatures) {
if (handledHeadingIds.has(fragment)) continue;
const heading = headingMatches.find(h => h.id === fragment);
if (!heading) continue;
const badges = renderTierBadges(feature);
if (badges) {
const wrapped = badges.replace('class="ff-tier-badges"', 'class="ff-tier-badges not-prose"');
ops.push({ index: heading.index + heading.length, html: wrapped });
}
}
}

ops.sort((a, b) => b.index - a.index);
for (const op of ops) {
content = content.slice(0, op.index) + op.html + content.slice(op.index);
}
return content;
});

eleventyConfig.addFilter("featureForDocsPage", function(url) {
return findFeatureByDocsLink(url);
});

eleventyConfig.addFilter("tierLabel", function(tierData) {
return deriveTierLabel(tierData);
});

function loadSVG (file) {
let relativeFilePath = `./src/_includes/components/icons/${file}.svg`;
let data = fs.readFileSync(relativeFilePath, function(err, contents) {
Expand Down
3 changes: 3 additions & 0 deletions nuxt/components/ChangelogListItem.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ const props = defineProps<{

const authorNames = computed(() => useAuthorNames(props.entry.authors))

const plans = useChangelogPlans(() => props.entry.path)

const formattedDate = computed(() => new Date(props.entry.date).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }))
</script>

Expand All @@ -18,6 +20,7 @@ const formattedDate = computed(() => new Date(props.entry.date).toLocaleDateStri
<div class="author">{{ authorNames }}</div>
</div>
</NuxtLink>
<FeatureTierBadges :plans="plans" />
</div>
<div class="flex-grow pt-4">
<div class="prose">
Expand Down
28 changes: 0 additions & 28 deletions nuxt/components/ChangelogTierBadges.vue

This file was deleted.

Loading
Loading