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
167 changes: 164 additions & 3 deletions nuxt/server/routes/blog/index.xml.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,54 @@
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.
const CTA_IMAGE_DESTINATIONS: Record<string, string> = {
'sign-up': `${site.appURL}/account/create`,
demo: '/book-demo/',
contact: '/contact-us/',
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<string, { title: string, description: string, label: string }> = {
'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 `<p><strong>${escapeXml(title)}</strong></p><p>${escapeXml(description)}</p><p><a href="${escapeXml(absoluteUrl(href))}">${escapeXml(variant.label)}</a></p>`
}

async function loadPeople(mount: string): Promise<Record<string, { name: string }>> {
const storage = useStorage(`assets:${mount}`)
Expand All @@ -23,14 +73,123 @@ 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<string, unknown> | null, ...MinimarkNode[]]

const VOID_TAGS = new Set(['img', 'br', 'hr'])

function absoluteUrl(url: string): string {
// 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}`
}

function toKebabCase(key: string): string {
return key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
}

function attrsToHtml(props: Record<string, unknown> | 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
// ::cta-image{...} carries its own src/alt/cta, so it renders as a real
// <img> (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 = `<img src="${escapeXml(absoluteUrl(src))}" alt="${escapeXml(alt)}"/>`
return href ? `<a href="${escapeXml(absoluteUrl(href))}">${img}</a>` : img
}
// 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 => `<a href="${escapeXml(absoluteUrl(badge.href))}">${escapeXml(badge.plan)}</a>`).join(', ')
return `<p>Available in: ${links}</p>`
}
// 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 => `<a href="${escapeXml(absoluteUrl(entry.url))}">${escapeXml(entry.label)}</a>`).join(' | ')
parts.push(`<p>Changelog: ${links}</p>`)
}
if (docs) {
parts.push(`<p>Docs: <a href="${escapeXml(absoluteUrl(docs.href))}">${escapeXml(docs.label)}</a></p>`)
}
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)}/>`
return `<${tag}${attrsToHtml(props)}>${innerHtml}</${tag}>`
}

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([
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 }
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)

// Mirrors useReleaseFeaturePage: splices plan-availability badges and changelog/docs
// links into a release blog's body, resolved from its `features:` frontmatter.
const changelogTitles: Record<string, string> = Object.fromEntries(
changelogPosts.map(post => [`${post.path.replace(/\/+$/, '')}/`, post.title]),
)
function withReleaseFeatures(entry: typeof entries[number]) {
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) }
}

const updated = entries[0]?.date ? new Date(entries[0].date).toISOString() : new Date(0).toISOString()

Expand All @@ -41,10 +200,12 @@ export default defineEventHandler(async (event) => {
.filter(Boolean)
.map(name => `<author><name>${escapeXml(name)}</name></author>`)
.join('\n ')
const bodyHtml = (renderBodyToHtml(withReleaseFeatures(entry)) + buildEndCta(entry)).replace(/]]>/g, ']]&gt;')
return ` <entry>
<id>${absoluteUrl}</id>
<title>${escapeXml(entry.title)}</title>
<summary>${escapeXml(entry.subtitle || entry.description || '')}</summary>
<summary>${escapeXml(buildSummary(entry))}</summary>
<content type="html"><![CDATA[${bodyHtml}]]></content>
<updated>${new Date(entry.date).toISOString()}</updated>
<link href="${absoluteUrl}"/>
${authorTags}
Expand Down
80 changes: 75 additions & 5 deletions nuxt/server/routes/changelog/index.xml.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, { name: string }>> {
const storage = useStorage(`assets:${mount}`)
Expand All @@ -19,29 +21,97 @@ function escapeXml(value: string): string {
.replace(/>/g, '&gt;')
}

// 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<string, unknown> | null, ...MinimarkNode[]]

const VOID_TAGS = new Set(['img', 'br', 'hr'])

function absoluteUrl(url: string): string {
// 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}`
}

function toKebabCase(key: string): string {
return key.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`)
}

function attrsToHtml(props: Record<string, unknown> | 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}</${tag}>`
}

function renderBodyToHtml(body: { value?: MinimarkNode[] } | undefined): string {
return (body?.value || []).map(minimarkToHtml).join('')
}

export default defineEventHandler(async (event) => {
const [entries, teamPeople, guestPeople] = await Promise.all([
queryCollection(event, 'changelog').order('date', 'DESC').all(),
// 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, 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 => `<author><name>${escapeXml(name)}</name></author>`)
.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 = catalog ? findFeatureByChangelog(catalog, entry.path) : null
const badges = planBadges(featurePlanLabels(feature)) as Array<{ plan: string, href: string }>
const badgesHtml = badges.length
? `<p>Available in: ${badges.map(badge => `<a href="${escapeXml(absoluteUrl(badge.href))}">${escapeXml(badge.plan)}</a>`).join(', ')}</p>`
: ''
const bodyHtml = badgesHtml + renderBodyToHtml(entry.body).replace(/]]>/g, ']]&gt;')
return ` <entry>
<id>${absoluteUrl}</id>
<id>${entryUrl}</id>
<title>${escapeXml(entry.title)}</title>
<summary>${escapeXml(entry.subtitle || entry.description || '')}</summary>
<content type="html"><![CDATA[${bodyHtml}]]></content>
<updated>${new Date(entry.date).toISOString()}</updated>
<link href="${absoluteUrl}"/>
<link href="${entryUrl}"/>
${authorTags}
</entry>`
}).join('\n')
Expand Down