diff --git a/.eleventy.js b/.eleventy.js index 560761d6c9..01105ef345 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -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 = `
`; - if (showCloud) { - html += `
`; - html += `Cloud`; - html += `${cloudLabel}`; - html += `
`; - } - if (showSelfHosted) { - html += `
`; - html += `Self-Hosted`; - html += `${selfHostedLabel}`; - html += `
`; - } - html += '
'; - return html; - } - - function renderChangelogLinks(urls) { - if (!urls || urls.length === 0) return ''; - let html = ''; - return html; - } - - function renderDocsLink(feature) { - if (!feature || !feature.docsLink) return ''; - const label = feature.label || 'Documentation'; - return ``; - } - - // 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\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>/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 = /]*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) { diff --git a/nuxt/components/ChangelogListItem.vue b/nuxt/components/ChangelogListItem.vue index 6dd6def902..e3e6097263 100644 --- a/nuxt/components/ChangelogListItem.vue +++ b/nuxt/components/ChangelogListItem.vue @@ -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' })) @@ -18,6 +20,7 @@ const formattedDate = computed(() => new Date(props.entry.date).toLocaleDateStri
{{ authorNames }}
+
diff --git a/nuxt/components/ChangelogTierBadges.vue b/nuxt/components/ChangelogTierBadges.vue deleted file mode 100644 index 16184062f2..0000000000 --- a/nuxt/components/ChangelogTierBadges.vue +++ /dev/null @@ -1,28 +0,0 @@ - - - diff --git a/nuxt/components/content/FeatureCatalog.vue b/nuxt/components/content/FeatureCatalog.vue index bf1dd17669..a7b100aa3e 100644 --- a/nuxt/components/content/FeatureCatalog.vue +++ b/nuxt/components/content/FeatureCatalog.vue @@ -14,67 +14,56 @@
- - + + - - - + + :class="i === 0 ? 'border-l-2 border-l-gray-300' : 'border-r border-r-gray-100'" + >{{ plan.label }} -
FeatureFlowFuse CloudSelf HostedSolutionsPlans
{{ h.label }}