Skip to content
Open
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
21 changes: 21 additions & 0 deletions nuxt/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ function collectHandbookRoutes(dir: string, basePath: string): string[] {
return routes
}

// Collect blog listing routes for SSG prerendering (/blog/, /blog/2/, …)
function collectBlogRoutes(): string[] {
const blogDir = join(__dirname, '../src/blog')
let count = 0
function countMd(dir: string) {
for (const file of readdirSync(dir)) {
const full = join(dir, file)
if (statSync(full).isDirectory()) countMd(full)
else if (file.endsWith('.md')) count++
}
}
countMd(blogDir)
const pageCount = Math.ceil(count / 19)
return [
'/blog/',
...Array.from({ length: pageCount - 1 }, (_, i) => `/blog/${i + 2}/`),
]
}

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
devtools: { enabled: true },
Expand Down Expand Up @@ -113,6 +132,7 @@ export default defineNuxtConfig({
'/whitepaper/accelerating-industrial-innovation-with-low-code-platforms/',
'/resources/publications/',
...collectHandbookRoutes(join(__dirname, 'content/handbook'), '/handbook'),
...collectBlogRoutes(),
],
crawlLinks: false
}
Expand Down Expand Up @@ -162,6 +182,7 @@ export default defineNuxtConfig({
vite: {
optimizeDeps: {
include: [
'@unhead/schema-org/vue',
'@vue/devtools-core',
'@vue/devtools-kit',
],
Expand Down
140 changes: 140 additions & 0 deletions nuxt/pages/blog/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
<script setup lang="ts">
Comment thread
n-lark marked this conversation as resolved.
import type { BlogPost } from '~/server/api/blog.get'
import blogTagsData from '../../../src/_data/blogTags.json'

definePageMeta({ layout: 'default' })

const tagLabels = Object.fromEntries(blogTagsData.map(t => [t.value, t.label]))

const route = useRoute()
const currentPage = computed(() => {
const p = route.query.page
const n = p ? Number(p) : 1
return Number.isFinite(n) && n >= 1 ? n : 1
})
const currentTag = computed(() => route.query.tag ? String(route.query.tag) : '')

const { data } = await useAsyncData(
() => `blog-${currentPage.value}-${currentTag.value}`,
() => $fetch<{ posts: BlogPost[]; total: number; pageCount: number }>(`/api/blog?page=${currentPage.value}${currentTag.value ? `&tag=${currentTag.value}` : ''}`)
)

const posts = computed(() => data.value?.posts ?? [])
const pageCount = computed(() => data.value?.pageCount ?? 1)

useHead({
title: computed(() => currentPage.value > 1
? `Blog - Page ${currentPage.value} • FlowFuse`
: 'Blog • FlowFuse'
),
meta: [
{ name: 'description', content: 'The FlowFuse blog: articles, tutorials and updates on Node-RED, industrial IoT and the FlowFuse platform.' },
{ property: 'og:title', content: 'FlowFuse Blog' },
],
})

function formatDate(dateStr: string): string {
const d = new Date(dateStr)
const day = d.getUTCDate()
const month = d.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' })
const year = d.getUTCFullYear()
return `${day} ${month}, ${year}`
}

function truncate(text: string, words = 20): string {
const ws = text.split(/\s+/).filter(Boolean)
return ws.length <= words ? text : `${ws.slice(0, words).join(' ')}...`
}

function paginationTo(page: number) {
const q: Record<string, string | number> = {}
if (currentTag.value) q.tag = currentTag.value
if (page > 1) q.page = page
return Object.keys(q).length ? { query: q } : '/blog/'
}
</script>

<template>
<div class="ff-blog container m-auto text-left max-w-4xl pt-8 pb-24 w-full">
<div class="px-2">
<h1 class="mb-0">Blog<template v-if="currentTag"> — {{ tagLabels[currentTag] ?? currentTag }}</template></h1>
</div>

<ul class="flex flex-wrap">

<!-- Featured post (first on each page) -->
<li v-if="posts[0]" class="w-full mt-2 px-2 pb-4">
<NuxtLink :href="posts[0].url" class="w-full flex flex-col group hover:no-underline">
<div class="md:w-3/4 pr-2">
<time class="block text-xs text-gray-500">{{ formatDate(posts[0].date) }}</time>
<h2 class="mb-0 text-xl font-medium group-hover:underline">{{ posts[0].title }}</h2>
<div class="italic text-xs mb-3">
<span v-for="(author, i) in posts[0].authors" :key="author.id">{{ i > 0 ? ', ' : '' }}{{ author.name }}</span>
</div>
</div>
<div class="flex flex-col md:flex-row">
<div class="ff-blog-tile pr-2 md:w-1/3">
<img
:src="posts[0].image ?? '/images/og-blog.jpg'"
:alt="posts[0].title"
width="285"
class="w-full h-auto"
/>
</div>
<div class="flex flex-col justify-between md:w-2/3 md:px-2">
<div>{{ truncate(posts[0].description) }}</div>
<div class="group-hover:underline">read more...</div>
</div>
</div>
</NuxtLink>
</li>

<!-- Newsletter banner -->
<li v-if="posts.length > 0" class="w-full px-2 pt-2 pb-2 mb-2 flex flex-col border-t-2 border-b-2">
<a id="sign-up"></a>
<h3 class="mb-0 text-lg font-semibold">Sign up for our monthly email updates:</h3>
<ClientOnly>
<HubSpotForm
form-id="159c173d-dd95-49bd-922b-ff3ef243e90c"
cta="cta-blog-subscribe"
reference="blog"
/>
</ClientOnly>
</li>

<!-- Regular posts (3-column grid) -->
<li v-for="post in posts.slice(1)" :key="post.url" class="w-full md:w-1/3 my-2 px-2 pb-6 border-b">
<NuxtLink :href="post.url" class="w-full flex flex-col group hover:no-underline">
<div>
<time class="block text-xs mb-2 text-gray-500">{{ formatDate(post.date) }}</time>
<div class="ff-blog-tile">
<img
:src="post.image ?? '/images/og-blog.jpg'"
:alt="post.title"
width="285"
class="w-full h-auto"
/>
</div>
<h2 class="mt-1 mb-0 text-xl font-medium group-hover:underline">{{ post.title }}</h2>
</div>
<div class="text-sm prose prose-blue py-1">{{ truncate(post.description) }}</div>
<div class="italic text-xs mb-3">
<span v-for="(author, i) in post.authors" :key="author.id">{{ i > 0 ? ', ' : '' }}{{ author.name }}</span>
</div>
</NuxtLink>
</li>

</ul>

<div v-if="pageCount > 1" class="flex justify-center mt-4">
<UPagination
:page="currentPage"
:total="data?.total ?? 0"
:items-per-page="19"
:to="paginationTo"
active-color="secondary"
active-variant="subtle"
/>
</div>
</div>
</template>
132 changes: 132 additions & 0 deletions nuxt/server/api/blog.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { resolve, relative, basename } from 'node:path'

const PAGE_SIZE = 19

// Normalise YAML dates ("2022-03-17 1:00:00.0", "2026-04-24") to ISO strings.
function normalizeDate(s: string): string {
const m = String(s).trim().match(/^(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}):(\d{2}):(\d{2}))?/)
if (!m) return s
if (!m[2]) return `${m[1]}T00:00:00.000Z`
return `${m[1]}T${m[2].padStart(2, '0')}:${m[3]}:${m[4]}.000Z`
}

export interface BlogPost {
title: string
date: string
url: string
description: string
image: string | null
authors: Array<{ id: string; name: string }>
}

function parseFrontmatter(src: string): Record<string, unknown> {
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!m) return {}

const result: Record<string, unknown> = {}
const lines = m[1].split('\n')
let i = 0

while (i < lines.length) {
const line = lines[i]
if (!line.trim() || line.trim().startsWith('#')) { i++; continue }

if (!/^\s/.test(line)) {
const kv = line.match(/^([a-zA-Z][\w-]*):\s*(.*)$/)
if (kv) {
const key = kv[1]
const val = kv[2].trim()

if (!val) {
// Block sequence: collect indented "- item" lines
const items: string[] = []
while (i + 1 < lines.length && /^\s+-\s/.test(lines[i + 1])) {
i++
items.push(lines[i].trim().replace(/^-\s+/, '').replace(/^["']|["']$/g, ''))
}
result[key] = items
} else if (val.startsWith('[')) {
// Inline sequence: ["item1", "item2"]
result[key] = val.slice(1, -1)
.split(',')
.map(s => s.trim().replace(/^["']|["']$/g, ''))
.filter(Boolean)
} else if (val.startsWith('"') || val.startsWith("'")) {
result[key] = val.slice(1, -1)
} else {
result[key] = val
}
}
}
i++
}

return result
}

function loadPeople(srcDir: string): Record<string, string> {
const people: Record<string, string> = {}
for (const subdir of ['_data/team', '_data/guests']) {
try {
const fullDir = resolve(srcDir, subdir)
for (const file of readdirSync(fullDir)) {
if (!file.endsWith('.json')) continue
const data = JSON.parse(readFileSync(resolve(fullDir, file), 'utf-8'))
people[basename(file, '.json')] = data.name
}
} catch {
// directory may not exist
}
}
return people
}

function collectPosts(dir: string, base: string): Array<Record<string, unknown>> {
const posts: Array<Record<string, unknown>> = []
for (const entry of readdirSync(dir)) {
const full = resolve(dir, entry)
if (statSync(full).isDirectory()) {
posts.push(...collectPosts(full, base))
} else if (entry.endsWith('.md')) {
const content = readFileSync(full, 'utf-8')
const data = parseFrontmatter(content)
if (!data.title || !data.date) continue
const rel = relative(base, full).replace(/\\/g, '/')
data.url = `/blog/${rel.replace(/\.md$/, '/')}`
posts.push(data)
}
}
return posts
}

export default defineEventHandler((event) => {
const query = getQuery(event)
const page = Math.max(1, Number(query.page) || 1)
const tag = query.tag ? String(query.tag) : ''

const srcDir = resolve(process.cwd(), '../src')
const blogDir = resolve(srcDir, 'blog')
const people = loadPeople(srcDir)

const now = new Date()
const all = collectPosts(blogDir, blogDir)
.map(p => ({ ...p, _date: normalizeDate(String(p.date)) }))
.filter(p => process.env.NODE_ENV !== 'production' || new Date(p._date) <= now)
.filter(p => !tag || (Array.isArray(p.tags) && (p.tags as string[]).includes(tag)))
.sort((a, b) => new Date(b._date).getTime() - new Date(a._date).getTime())

const total = all.length
const pageCount = Math.ceil(total / PAGE_SIZE)

const posts: BlogPost[] = all.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE).map(p => ({
title: String(p.title),
date: p._date,
url: String(p.url),
description: String(p.description || ''),
image: p.image ? String(p.image).replace(/^(?!\/)/, '/') : null,
authors: ((p.authors as string[]) || []).map(id => ({ id, name: people[id] || id })),
}))

return { posts, total, pageCount }
})
4 changes: 4 additions & 0 deletions nuxt/server/middleware/legacy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ export default defineEventHandler(async (event) => {
// Let Nuxt handle migrated path prefixes
if (NUXT_PREFIXES.some(prefix => normalised === prefix || normalised.startsWith(prefix + '/'))) return

// Blog listing (/blog/, /blog/?page=N) is served by Nuxt.
// Tag pages (/blog/how-to/, /blog/ai/, etc.) and individual posts still proxy to 11ty.
if (normalised === '/blog') return

// Proxy everything else to the 11ty dev server
return proxyRequest(event, `http://localhost:8080${path}`)
})
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading