From 688c37070d54f233b0686f18b427da42e499fc13 Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Mon, 17 Aug 2026 11:51:24 +0200 Subject: [PATCH 1/5] Include full post body in blog and changelog RSS feeds --- nuxt/server/routes/blog/index.xml.ts | 59 ++++++++++++++++++++++- nuxt/server/routes/changelog/index.xml.ts | 59 ++++++++++++++++++++++- 2 files changed, 116 insertions(+), 2 deletions(-) diff --git a/nuxt/server/routes/blog/index.xml.ts b/nuxt/server/routes/blog/index.xml.ts index 907d446fd7..6e520ce1f3 100644 --- a/nuxt/server/routes/blog/index.xml.ts +++ b/nuxt/server/routes/blog/index.xml.ts @@ -23,6 +23,59 @@ function isFuturePost(date: string | Date): boolean { return new Date(date) > new Date() && process.env.CONTEXT === 'production' } +// entry.body is a minimark tree: { value: MinimarkNode[] } where a node is +// either a text string or an element array [tag, props, ...children]. +type MinimarkNode = string | [string, Record | null, ...MinimarkNode[]] + +const VOID_TAGS = new Set(['img', 'br', 'hr']) + +function absoluteUrl(url: string): string { + if (/^https?:\/\//.test(url)) return url + return `https://flowfuse.com${url.startsWith('/') ? '' : '/'}${url}` +} + +function toKebabCase(key: string): string { + return key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`) +} + +function attrsToHtml(props: Record | null): string { + if (!props) return '' + const attrs: string[] = [] + for (const [rawKey, value] of Object.entries(props)) { + if (value == null || value === false) continue + const key = rawKey === 'className' ? 'class' : toKebabCase(rawKey) + if (key === 'class') { + attrs.push(`class="${escapeXml(String(Array.isArray(value) ? value.join(' ') : value))}"`) + continue + } + if ((key === 'href' || key === 'src') && typeof value === 'string') { + attrs.push(`${key}="${escapeXml(absoluteUrl(value))}"`) + continue + } + if (value === true) { + attrs.push(key) + continue + } + attrs.push(`${key}="${escapeXml(String(value))}"`) + } + return attrs.length ? ` ${attrs.join(' ')}` : '' +} + +function minimarkToHtml(node: MinimarkNode): string { + if (typeof node === 'string') return escapeXml(node) + const [tag, props, ...children] = node + // Custom Vue components (e.g. cta-image, feature-tier-badges) have no + // meaningful standalone markup, so the feed omits them entirely. + if (tag.includes('-')) return '' + const innerHtml = children.map(minimarkToHtml).join('') + if (VOID_TAGS.has(tag)) return `<${tag}${attrsToHtml(props)}/>` + return `<${tag}${attrsToHtml(props)}>${innerHtml}` +} + +function renderBodyToHtml(body: { value?: MinimarkNode[] } | undefined): string { + return (body?.value || []).map(minimarkToHtml).join('') +} + export default defineEventHandler(async (event) => { const [allEntries, teamPeople, guestPeople] = await Promise.all([ queryCollection(event, 'blog').order('date', 'DESC').all(), @@ -30,7 +83,9 @@ export default defineEventHandler(async (event) => { loadPeople('guests'), ]) const people = { ...teamPeople, ...guestPeople } - const entries = allEntries.filter(entry => !isFuturePost(entry.date)) + // Full post bodies are heavy - cap the feed to the most recent posts rather + // than shipping the entire multi-megabyte blog archive on every request. + const entries = allEntries.filter(entry => !isFuturePost(entry.date)).slice(0, 20) const updated = entries[0]?.date ? new Date(entries[0].date).toISOString() : new Date(0).toISOString() @@ -41,10 +96,12 @@ export default defineEventHandler(async (event) => { .filter(Boolean) .map(name => `${escapeXml(name)}`) .join('\n ') + const bodyHtml = renderBodyToHtml(entry.body).replace(/]]>/g, ']]>') return ` ${absoluteUrl} ${escapeXml(entry.title)} ${escapeXml(entry.subtitle || entry.description || '')} + ${new Date(entry.date).toISOString()} ${authorTags} diff --git a/nuxt/server/routes/changelog/index.xml.ts b/nuxt/server/routes/changelog/index.xml.ts index f15ee42df5..e6129b3510 100644 --- a/nuxt/server/routes/changelog/index.xml.ts +++ b/nuxt/server/routes/changelog/index.xml.ts @@ -19,9 +19,64 @@ function escapeXml(value: string): string { .replace(/>/g, '>') } +// entry.body is a minimark tree: { value: MinimarkNode[] } where a node is +// either a text string or an element array [tag, props, ...children]. +type MinimarkNode = string | [string, Record | null, ...MinimarkNode[]] + +const VOID_TAGS = new Set(['img', 'br', 'hr']) + +function absoluteUrl(url: string): string { + if (/^https?:\/\//.test(url)) return url + return `https://flowfuse.com${url.startsWith('/') ? '' : '/'}${url}` +} + +function toKebabCase(key: string): string { + return key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`) +} + +function attrsToHtml(props: Record | null): string { + if (!props) return '' + const attrs: string[] = [] + for (const [rawKey, value] of Object.entries(props)) { + if (value == null || value === false) continue + const key = rawKey === 'className' ? 'class' : toKebabCase(rawKey) + if (key === 'class') { + attrs.push(`class="${escapeXml(String(Array.isArray(value) ? value.join(' ') : value))}"`) + continue + } + if ((key === 'href' || key === 'src') && typeof value === 'string') { + attrs.push(`${key}="${escapeXml(absoluteUrl(value))}"`) + continue + } + if (value === true) { + attrs.push(key) + continue + } + attrs.push(`${key}="${escapeXml(String(value))}"`) + } + return attrs.length ? ` ${attrs.join(' ')}` : '' +} + +function minimarkToHtml(node: MinimarkNode): string { + if (typeof node === 'string') return escapeXml(node) + const [tag, props, ...children] = node + // Custom Vue components have no meaningful standalone markup, so the + // feed omits them entirely. + if (tag.includes('-')) return '' + const innerHtml = children.map(minimarkToHtml).join('') + if (VOID_TAGS.has(tag)) return `<${tag}${attrsToHtml(props)}/>` + return `<${tag}${attrsToHtml(props)}>${innerHtml}` +} + +function renderBodyToHtml(body: { value?: MinimarkNode[] } | undefined): string { + return (body?.value || []).map(minimarkToHtml).join('') +} + export default defineEventHandler(async (event) => { + // Full entry bodies are heavy - cap the feed to the most recent entries rather + // than shipping the entire multi-megabyte changelog archive on every request. const [entries, teamPeople, guestPeople] = await Promise.all([ - queryCollection(event, 'changelog').order('date', 'DESC').all(), + queryCollection(event, 'changelog').order('date', 'DESC').limit(20).all(), loadPeople('team'), loadPeople('guests'), ]) @@ -36,10 +91,12 @@ export default defineEventHandler(async (event) => { .filter(Boolean) .map(name => `${escapeXml(name)}`) .join('\n ') + const bodyHtml = renderBodyToHtml(entry.body).replace(/]]>/g, ']]>') return ` ${absoluteUrl} ${escapeXml(entry.title)} ${escapeXml(entry.subtitle || entry.description || '')} + ${new Date(entry.date).toISOString()} ${authorTags} From 9826e1bccedde22ace082f94be1816d4632d97ce Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Mon, 17 Aug 2026 12:10:34 +0200 Subject: [PATCH 2/5] Improve blog RSS summary fallback and render inline CTA images --- nuxt/server/routes/blog/index.xml.ts | 29 ++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/nuxt/server/routes/blog/index.xml.ts b/nuxt/server/routes/blog/index.xml.ts index 6e520ce1f3..956bd5190a 100644 --- a/nuxt/server/routes/blog/index.xml.ts +++ b/nuxt/server/routes/blog/index.xml.ts @@ -1,4 +1,14 @@ import { queryCollection } from '@nuxt/content/server' +import site from '../../../../src/_data/site.json' + +// Mirrors nuxt/components/content/CtaImage.vue's DESTINATIONS map - kept in +// sync manually since the feed can't import a .vue component's script setup. +const CTA_IMAGE_DESTINATIONS: Record = { + 'sign-up': `${site.appURL}/account/create`, + demo: '/book-demo/', + contact: '/contact-us/', + pricing: '/pricing', +} async function loadPeople(mount: string): Promise> { const storage = useStorage(`assets:${mount}`) @@ -64,7 +74,17 @@ function attrsToHtml(props: Record | null): string { function minimarkToHtml(node: MinimarkNode): string { if (typeof node === 'string') return escapeXml(node) const [tag, props, ...children] = node - // Custom Vue components (e.g. cta-image, feature-tier-badges) have no + // ::cta-image{...} carries its own src/alt/cta, so it renders as a real + // (linked to its destination) instead of being dropped like other + // custom components. + if (tag === 'cta-image' && props) { + const src = typeof props.src === 'string' ? props.src : '' + const alt = typeof props.alt === 'string' ? props.alt : '' + const href = CTA_IMAGE_DESTINATIONS[props.cta as string] + const img = `${escapeXml(alt)}` + return href ? `${img}` : img + } + // Other custom Vue components (e.g. feature-tier-badges) have no // meaningful standalone markup, so the feed omits them entirely. if (tag.includes('-')) return '' const innerHtml = children.map(minimarkToHtml).join('') @@ -76,6 +96,11 @@ function renderBodyToHtml(body: { value?: MinimarkNode[] } | undefined): string return (body?.value || []).map(minimarkToHtml).join('') } +function buildSummary(entry: { tldr?: string | string[], description?: string, meta?: { description?: string }, subtitle?: string }): string { + const tldr = Array.isArray(entry.tldr) ? entry.tldr.join(' ') : entry.tldr + return tldr || entry.description || entry.meta?.description || entry.subtitle || '' +} + export default defineEventHandler(async (event) => { const [allEntries, teamPeople, guestPeople] = await Promise.all([ queryCollection(event, 'blog').order('date', 'DESC').all(), @@ -100,7 +125,7 @@ export default defineEventHandler(async (event) => { return ` ${absoluteUrl} ${escapeXml(entry.title)} - ${escapeXml(entry.subtitle || entry.description || '')} + ${escapeXml(buildSummary(entry))} ${new Date(entry.date).toISOString()} From fefac9f6668e494f6371ff182982132c6d2bab33 Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Mon, 17 Aug 2026 12:33:37 +0200 Subject: [PATCH 3/5] Render release plan badges and changelog links in RSS feeds --- nuxt/server/routes/blog/index.xml.ts | 48 +++++++++++++++++++++-- nuxt/server/routes/changelog/index.xml.ts | 20 +++++++--- 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/nuxt/server/routes/blog/index.xml.ts b/nuxt/server/routes/blog/index.xml.ts index 956bd5190a..ab138b31a8 100644 --- a/nuxt/server/routes/blog/index.xml.ts +++ b/nuxt/server/routes/blog/index.xml.ts @@ -1,5 +1,9 @@ import { queryCollection } from '@nuxt/content/server' import site from '../../../../src/_data/site.json' +// @ts-ignore untyped module +import { planBadges } from '../../../lib/feature-catalog.mjs' +// @ts-ignore untyped module +import { resolveReleaseFeatures, injectReleaseFeatures } from '../../../lib/release-features.mjs' // Mirrors nuxt/components/content/CtaImage.vue's DESTINATIONS map - kept in // sync manually since the feed can't import a .vue component's script setup. @@ -84,8 +88,31 @@ function minimarkToHtml(node: MinimarkNode): string { const img = `${escapeXml(alt)}` return href ? `${img}` : img } - // Other custom Vue components (e.g. feature-tier-badges) have no - // meaningful standalone markup, so the feed omits them entirely. + // Injected into release-blog bodies by injectReleaseFeatures (see below) - each + // plan names its own product page, mirroring FeatureTierBadges.vue. + if (tag === 'feature-tier-badges' && props) { + const plans = typeof props.plans === 'string' ? props.plans.split(',').map(p => p.trim()).filter(Boolean) : [] + const badges = planBadges(plans) as Array<{ plan: string, href: string }> + if (!badges.length) return '' + const links = badges.map(badge => `${escapeXml(badge.plan)}`).join(', ') + return `

Available in: ${links}

` + } + // Also injected by injectReleaseFeatures, mirroring FeatureReleaseLinks.vue. + if (tag === 'feature-release-links' && props) { + const changelog = Array.isArray(props.changelog) ? props.changelog as Array<{ url: string, label: string }> : [] + const docs = props.docs as { href: string, label: string } | null | undefined + const parts: string[] = [] + if (changelog.length) { + const links = changelog.map(entry => `${escapeXml(entry.label)}`).join(' | ') + parts.push(`

Changelog: ${links}

`) + } + if (docs) { + parts.push(`

Docs: ${escapeXml(docs.label)}

`) + } + return parts.join('') + } + // Other custom Vue components have no meaningful standalone markup, so the + // feed omits them entirely. if (tag.includes('-')) return '' const innerHtml = children.map(minimarkToHtml).join('') if (VOID_TAGS.has(tag)) return `<${tag}${attrsToHtml(props)}/>` @@ -102,16 +129,29 @@ function buildSummary(entry: { tldr?: string | string[], description?: string, m } export default defineEventHandler(async (event) => { - const [allEntries, teamPeople, guestPeople] = await Promise.all([ + const [allEntries, teamPeople, guestPeople, catalog, changelogPosts] = await Promise.all([ queryCollection(event, 'blog').order('date', 'DESC').all(), loadPeople('team'), loadPeople('guests'), + queryCollection(event, 'featureCatalog').first(), + queryCollection(event, 'changelog').select('path', 'title').all(), ]) const people = { ...teamPeople, ...guestPeople } // Full post bodies are heavy - cap the feed to the most recent posts rather // than shipping the entire multi-megabyte blog archive on every request. const entries = allEntries.filter(entry => !isFuturePost(entry.date)).slice(0, 20) + // Mirrors useReleaseFeaturePage: splices plan-availability badges and changelog/docs + // links into a release blog's body, resolved from its `features:` frontmatter. + const changelogTitles: Record = Object.fromEntries( + changelogPosts.map(post => [`${post.path.replace(/\/+$/, '')}/`, post.title]), + ) + function withReleaseFeatures(entry: typeof entries[number]) { + if (!entry.release || !entry.features?.length || !entry.body?.value) return entry.body + const resolved = resolveReleaseFeatures(entry.features, catalog, entry.release, changelogTitles) + return { ...entry.body, value: injectReleaseFeatures(entry.body.value, resolved) } + } + const updated = entries[0]?.date ? new Date(entries[0].date).toISOString() : new Date(0).toISOString() const items = entries.map((entry) => { @@ -121,7 +161,7 @@ export default defineEventHandler(async (event) => { .filter(Boolean) .map(name => `${escapeXml(name)}`) .join('\n ') - const bodyHtml = renderBodyToHtml(entry.body).replace(/]]>/g, ']]>') + const bodyHtml = renderBodyToHtml(withReleaseFeatures(entry)).replace(/]]>/g, ']]>') return ` ${absoluteUrl} ${escapeXml(entry.title)} diff --git a/nuxt/server/routes/changelog/index.xml.ts b/nuxt/server/routes/changelog/index.xml.ts index e6129b3510..0fb7c98e35 100644 --- a/nuxt/server/routes/changelog/index.xml.ts +++ b/nuxt/server/routes/changelog/index.xml.ts @@ -1,4 +1,6 @@ import { queryCollection } from '@nuxt/content/server' +// @ts-ignore untyped module +import { findFeatureByChangelog, featurePlanLabels, planBadges } from '../../../lib/feature-catalog.mjs' async function loadPeople(mount: string): Promise> { const storage = useStorage(`assets:${mount}`) @@ -75,30 +77,38 @@ function renderBodyToHtml(body: { value?: MinimarkNode[] } | undefined): string export default defineEventHandler(async (event) => { // Full entry bodies are heavy - cap the feed to the most recent entries rather // than shipping the entire multi-megabyte changelog archive on every request. - const [entries, teamPeople, guestPeople] = await Promise.all([ + const [entries, teamPeople, guestPeople, catalog] = await Promise.all([ queryCollection(event, 'changelog').order('date', 'DESC').limit(20).all(), loadPeople('team'), loadPeople('guests'), + queryCollection(event, 'featureCatalog').first(), ]) const people = { ...teamPeople, ...guestPeople } const updated = entries[0]?.date ? new Date(entries[0].date).toISOString() : new Date(0).toISOString() const items = entries.map((entry) => { - const absoluteUrl = `https://flowfuse.com${entry.path}/` + const entryUrl = `https://flowfuse.com${entry.path}/` const authorTags = (entry.authors || []) .map(username => people[username]?.name) .filter(Boolean) .map(name => `${escapeXml(name)}`) .join('\n ') - const bodyHtml = renderBodyToHtml(entry.body).replace(/]]>/g, ']]>') + // Mirrors the changelog page: FeatureTierBadges is looked up by this entry's own + // path against the feature catalog, not embedded in the markdown body. + const feature = findFeatureByChangelog(catalog, entry.path) + const badges = planBadges(featurePlanLabels(feature)) as Array<{ plan: string, href: string }> + const badgesHtml = badges.length + ? `

Available in: ${badges.map(badge => `${escapeXml(badge.plan)}`).join(', ')}

` + : '' + const bodyHtml = badgesHtml + renderBodyToHtml(entry.body).replace(/]]>/g, ']]>') return ` - ${absoluteUrl} + ${entryUrl} ${escapeXml(entry.title)} ${escapeXml(entry.subtitle || entry.description || '')} ${new Date(entry.date).toISOString()} - + ${authorTags} ` }).join('\n') From 3995b4eb094512f495048d23ba4b70752c1cbdcb Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Mon, 17 Aug 2026 12:37:56 +0200 Subject: [PATCH 4/5] Include the end-of-article CTA in blog RSS content --- nuxt/server/routes/blog/index.xml.ts | 38 +++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/nuxt/server/routes/blog/index.xml.ts b/nuxt/server/routes/blog/index.xml.ts index ab138b31a8..f1933ee83b 100644 --- a/nuxt/server/routes/blog/index.xml.ts +++ b/nuxt/server/routes/blog/index.xml.ts @@ -14,6 +14,42 @@ const CTA_IMAGE_DESTINATIONS: Record = { pricing: '/pricing', } +// Mirrors nuxt/components/BlogPostCta.vue's CTA_VARIANTS and fixed Cta* +// button labels (see CtaSignUp/CtaBookDemo/CtaContactUs) - every post ends +// with this block, defaulting to 'sign-up' when frontmatter `cta` is unset +// or names an unrecognised type. +const END_CTA_VARIANTS: Record = { + 'sign-up': { + title: 'Start building with your own industrial data', + description: 'Connect your systems, automate workflows, and see what’s possible in your environment.', + label: 'Try it out', + }, + demo: { + title: 'See how FlowFuse works in real environments', + description: 'Walk through real use cases and see how teams connect systems, automate workflows, and deploy at scale.', + label: 'Book a Demo', + }, + contact: { + title: 'Discuss your use case with our team', + description: 'See how FlowFuse can support your architecture, integrations, and deployment needs.', + label: 'Contact Us', + }, + pricing: { + title: 'Explore plans that fit your deployment', + description: 'Compare options based on your scale, infrastructure, and security requirements.', + label: 'View Pricing', + }, +} + +function buildEndCta(entry: { cta?: { type?: string, title?: string, description?: string } | null }): string { + const type = entry.cta?.type && END_CTA_VARIANTS[entry.cta.type] ? entry.cta.type : 'sign-up' + const variant = END_CTA_VARIANTS[type] + const title = entry.cta?.title || variant.title + const description = entry.cta?.description || variant.description + const href = CTA_IMAGE_DESTINATIONS[type] + return `

${escapeXml(title)}

${escapeXml(description)}

${escapeXml(variant.label)}

` +} + async function loadPeople(mount: string): Promise> { const storage = useStorage(`assets:${mount}`) const people: Record = {} @@ -161,7 +197,7 @@ export default defineEventHandler(async (event) => { .filter(Boolean) .map(name => `${escapeXml(name)}`) .join('\n ') - const bodyHtml = renderBodyToHtml(withReleaseFeatures(entry)).replace(/]]>/g, ']]>') + const bodyHtml = (renderBodyToHtml(withReleaseFeatures(entry)) + buildEndCta(entry)).replace(/]]>/g, ']]>') return ` ${absoluteUrl} ${escapeXml(entry.title)} From de929ca91ecec47b9cf60f79fd6a499b353686ae Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Mon, 17 Aug 2026 12:56:30 +0200 Subject: [PATCH 5/5] Fix absoluteUrl for non-http schemes and guard against missing catalog --- nuxt/server/routes/blog/index.xml.ts | 7 +++++-- nuxt/server/routes/changelog/index.xml.ts | 7 +++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/nuxt/server/routes/blog/index.xml.ts b/nuxt/server/routes/blog/index.xml.ts index f1933ee83b..2458a16ffc 100644 --- a/nuxt/server/routes/blog/index.xml.ts +++ b/nuxt/server/routes/blog/index.xml.ts @@ -80,7 +80,10 @@ type MinimarkNode = string | [string, Record | null, ...Minimar const VOID_TAGS = new Set(['img', 'br', 'hr']) function absoluteUrl(url: string): string { - if (/^https?:\/\//.test(url)) return url + // Any URI with a scheme (https:, mailto:, tel:, ...) or an in-page anchor + // is already a complete reference - only a bare site-relative path needs + // the origin prepended. + if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('#')) return url return `https://flowfuse.com${url.startsWith('/') ? '' : '/'}${url}` } @@ -183,7 +186,7 @@ export default defineEventHandler(async (event) => { changelogPosts.map(post => [`${post.path.replace(/\/+$/, '')}/`, post.title]), ) function withReleaseFeatures(entry: typeof entries[number]) { - if (!entry.release || !entry.features?.length || !entry.body?.value) return entry.body + if (!catalog || !entry.release || !entry.features?.length || !entry.body?.value) return entry.body const resolved = resolveReleaseFeatures(entry.features, catalog, entry.release, changelogTitles) return { ...entry.body, value: injectReleaseFeatures(entry.body.value, resolved) } } diff --git a/nuxt/server/routes/changelog/index.xml.ts b/nuxt/server/routes/changelog/index.xml.ts index 0fb7c98e35..7a29372138 100644 --- a/nuxt/server/routes/changelog/index.xml.ts +++ b/nuxt/server/routes/changelog/index.xml.ts @@ -28,7 +28,10 @@ type MinimarkNode = string | [string, Record | null, ...Minimar const VOID_TAGS = new Set(['img', 'br', 'hr']) function absoluteUrl(url: string): string { - if (/^https?:\/\//.test(url)) return url + // Any URI with a scheme (https:, mailto:, tel:, ...) or an in-page anchor + // is already a complete reference - only a bare site-relative path needs + // the origin prepended. + if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith('#')) return url return `https://flowfuse.com${url.startsWith('/') ? '' : '/'}${url}` } @@ -96,7 +99,7 @@ export default defineEventHandler(async (event) => { .join('\n ') // Mirrors the changelog page: FeatureTierBadges is looked up by this entry's own // path against the feature catalog, not embedded in the markdown body. - const feature = findFeatureByChangelog(catalog, entry.path) + const feature = catalog ? findFeatureByChangelog(catalog, entry.path) : null const badges = planBadges(featurePlanLabels(feature)) as Array<{ plan: string, href: string }> const badgesHtml = badges.length ? `

Available in: ${badges.map(badge => `${escapeXml(badge.plan)}`).join(', ')}

`