diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index 2c78dd4e4e..73d8068294 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -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 }, @@ -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 } @@ -162,6 +182,7 @@ export default defineNuxtConfig({ vite: { optimizeDeps: { include: [ + '@unhead/schema-org/vue', '@vue/devtools-core', '@vue/devtools-kit', ], diff --git a/nuxt/pages/blog/index.vue b/nuxt/pages/blog/index.vue new file mode 100644 index 0000000000..e998ae2cec --- /dev/null +++ b/nuxt/pages/blog/index.vue @@ -0,0 +1,140 @@ + + + diff --git a/nuxt/server/api/blog.get.ts b/nuxt/server/api/blog.get.ts new file mode 100644 index 0000000000..95024a1ca1 --- /dev/null +++ b/nuxt/server/api/blog.get.ts @@ -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 { + const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---/) + if (!m) return {} + + const result: Record = {} + 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 { + const people: Record = {} + 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> { + const posts: Array> = [] + 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 } +}) diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index 1838cc3c88..b432ebb1cb 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -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}`) }) diff --git a/package-lock.json b/package-lock.json index ebc638634d..eb755cd2b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6947,7 +6947,7 @@ }, "node_modules/@parcel/watcher-wasm/node_modules/napi-wasm": { "version": "1.1.0", - "dev": true, + "extraneous": true, "inBundle": true, "license": "MIT" }, diff --git a/src/_includes/blog/blog-posts.njk b/src/_includes/blog/blog-posts.njk deleted file mode 100644 index 98a2695164..0000000000 --- a/src/_includes/blog/blog-posts.njk +++ /dev/null @@ -1,66 +0,0 @@ -{% if loop.first %} -
  • - -
    - -

    {{ item.data.title }}

    -
    -
    - {% set comma = joiner() %} - {%- for author in item.data.authors %} - {{ comma() }} {{ team[author].name }} - {%- endfor %} -
    -
    -
    -
    -
    -
    - {% tileImage item, "./images/og-blog.jpg", "Image with logo and the slogan: Elevate Node-RED with Flowfuse", 285 %} -
    -
    -
    -
    - {% include "summary.njk" %} -
    -
    read more...
    -
    -
    -
    -
  • -
  • - -

    Sign up for our monthly email updates:

    - {% set formId = "159c173d-dd95-49bd-922b-ff3ef243e90c" %} - {% set cta = "cta-blog-subscribe" %} - {% set reference = "blog" %} - {% set targetId = "hs-form-newsletter-blog" %} - {% include "hubspot/hs-form.njk" %} -
  • -{% else %} -
  • - -
    - -
    -
    - {% tileImage item, "./images/og-blog.jpg", "Image with logo and the slogan: Elevate Node-RED with Flowfuse", 285 %} -
    -
    -

    {{ item.data.title }}

    -
    -
    - {% set summary = item.data.excerpt or item.data.description or item.data.meta.description %} - {% if summary %}{{ summary | md | striptags | truncate(20) }}{% endif %} -
    -
    -
    - {% set comma = joiner() %} - {%- for author in item.data.authors %} - {{ comma() }} {{ team[author].name }} - {%- endfor %} -
    -
    -
    -
  • -{% endif %} diff --git a/src/_includes/blog/template.njk b/src/_includes/blog/template.njk deleted file mode 100644 index fec42155f1..0000000000 --- a/src/_includes/blog/template.njk +++ /dev/null @@ -1,24 +0,0 @@ -
    -
    -

    Blog

    -
    -
    - -
    -
    - {%- for tag in blogTags -%} - {% if tag.value !== "posts" %} - - {% else %} - - {% endif %} - {% endfor %} -
    -
      - {%- asyncEach item in posts -%} - {% include "blog/blog-posts.njk" %} - {%- endeach -%} -
    - {% include "blog/pagination.njk" %} -
    -{% include "layouts/common-js.njk" %} diff --git a/src/blog.njk b/src/blog.njk deleted file mode 100644 index c899170b7e..0000000000 --- a/src/blog.njk +++ /dev/null @@ -1,15 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.9 -pagination: - data: collections.posts - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Blog -searchTitle: Blog -nav: blog ---- - -{% include "blog/template.njk" %} diff --git a/src/blog/2023/07/how-to-build-a-opc-client-dashboard-in-node-red.md b/src/blog/2023/07/how-to-build-a-opc-client-dashboard-in-node-red.md index fd878af754..67f9957aa2 100644 --- a/src/blog/2023/07/how-to-build-a-opc-client-dashboard-in-node-red.md +++ b/src/blog/2023/07/how-to-build-a-opc-client-dashboard-in-node-red.md @@ -84,7 +84,7 @@ Several custom nodes are required in order to properly deploy this flow. For mo As this is not a production application, no security will be utilized, and it is assumed that the OPC UA Server is running on the same network as the Node-RED OPC Client. -Is it also assumed that the end user of this article has familiarization with dashboards. There are many dashboard basic guides available on our FlowFuse website, For more infomation go to [Node-RED Dashboard 2.0 guides](/blog/dashboard/). +Is it also assumed that the end user of this article has familiarization with dashboards. There are many dashboard basic guides available on our FlowFuse website, For more infomation go to [Node-RED Dashboard 2.0 guides](/blog/?tag=dashboard). ## Install and Deploy the Prosys OPC UA Simulation Server diff --git a/src/blog/2023/09/modernize-your-legacy-industrial-data.md b/src/blog/2023/09/modernize-your-legacy-industrial-data.md index aaed127cfb..f857be2f17 100644 --- a/src/blog/2023/09/modernize-your-legacy-industrial-data.md +++ b/src/blog/2023/09/modernize-your-legacy-industrial-data.md @@ -92,6 +92,6 @@ That’s where FlowFuse steps in to make your life easier. FlowFuse is designed ### Learn More We will be publishing follow-up blog posts with more details, best practices and examples on how to use Node-RED to make sense of your industrial data. In the meantime, you can learn more about these tools by visiting the following links: -* [Node-RED blog posts](/blog/node-red/) +* [Node-RED blog posts](/blog/?tag=node-red) * [Node-RED videos](https://www.youtube.com/playlist?list=PLpcyqc7kNgp09XeRx_cae1fEIOloPqM1C) * [Buffer Parser Node](https://flows.nodered.org/node/node-red-contrib-buffer-parser) diff --git a/src/blog/2024/03/dashboard-getting-started.md b/src/blog/2024/03/dashboard-getting-started.md index 6bd4e8630c..79eefda053 100644 --- a/src/blog/2024/03/dashboard-getting-started.md +++ b/src/blog/2024/03/dashboard-getting-started.md @@ -153,7 +153,7 @@ With all of this together, we have the following functional Dashboard: Whilst this is just a simple introduction of Node-RED Dashboard 2.0, we do have many other articles and documentation that can help you get started with more advanced features. -- [FlowFuse Dashboard Articles](/blog/dashboard/) - Collection of examples and guides written by FlowFuse. +- [FlowFuse Dashboard Articles](/blog/?tag=dashboard) - Collection of examples and guides written by FlowFuse. - [Node-RED Dashboard 2.0 Documentation](https://dashboard.flowfuse.com) - Detailed information for each of the nodes available in Dashboard 2.0, as well as useful guides on building custom nodes and widgets of your own. - [Node-RED Forums - Dashboard 2.0](https://discourse.nodered.org/tag/dashboard-2) - The Node-RED forums is a great place to ask questions, share your projects and get help from the community. - [Beginner Guide to a Professional Node-RED](/ebooks/beginner-guide-to-a-professional-nodered/) - A free guide to an enterprise-ready Node-RED. Learn all about Node-RED history, securing your flows and dashboard data visualization. diff --git a/src/blog/2024/05/node-red-dashboard-2-layout-navigation-styling.md b/src/blog/2024/05/node-red-dashboard-2-layout-navigation-styling.md index ae1c9478d8..0d582ce900 100644 --- a/src/blog/2024/05/node-red-dashboard-2-layout-navigation-styling.md +++ b/src/blog/2024/05/node-red-dashboard-2-layout-navigation-styling.md @@ -239,7 +239,7 @@ To define your own CSS: To delve deeper into Node-RED Dashboard 2.0, we recommend exploring the following resources: -- [FlowFuse Dashboard Articles](/blog/dashboard/) - Collection of examples and guides written by FlowFuse. +- [FlowFuse Dashboard Articles](/blog/?tag=dashboard) - Collection of examples and guides written by FlowFuse. - [Node-RED Dashboard 2.0 Documentation](https://dashboard.flowfuse.com/) - Detailed information for each of the nodes available in Dashboard 2.0, as well as useful guides on building custom nodes and widgets of your own. - [Node-RED Forums - Dashboard 2.0](https://discourse.nodered.org/tag/dashboard-2) - The Node-RED forums are a great place to ask questions, share your projects and get help from the community. - [Beginner Guide to a Professional Node-RED](/ebooks/beginner-guide-to-a-professional-nodered/) - A free guide to an enterprise-ready Node-RED. Learn all about Node-RED history, securing your flows, and dashboard data visualization. diff --git a/src/blog/ai.njk b/src/blog/ai.njk deleted file mode 100644 index 9da4808131..0000000000 --- a/src/blog/ai.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.aiBlog - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: AI - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/dashboard.njk b/src/blog/dashboard.njk deleted file mode 100644 index 31013507f5..0000000000 --- a/src/blog/dashboard.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.dashboard - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Dashboard - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/flowfuse.njk b/src/blog/flowfuse.njk deleted file mode 100644 index 6c85a7bfec..0000000000 --- a/src/blog/flowfuse.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.flowfuse - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/how-to.njk b/src/blog/how-to.njk deleted file mode 100644 index 3c109bc820..0000000000 --- a/src/blog/how-to.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.how-to - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: How-To - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/modbus.njk b/src/blog/modbus.njk deleted file mode 100644 index 16dafed86e..0000000000 --- a/src/blog/modbus.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.modbus - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Modbus - Blog ---- - -{% include "blog/template.njk" %} diff --git a/src/blog/mqtt.njk b/src/blog/mqtt.njk deleted file mode 100644 index 77062a8871..0000000000 --- a/src/blog/mqtt.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.mqtt - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: MQTT - Blog ---- - -{% include "blog/template.njk" %} diff --git a/src/blog/news.njk b/src/blog/news.njk deleted file mode 100644 index 770a8002c8..0000000000 --- a/src/blog/news.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.news - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: News - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/node-red.njk b/src/blog/node-red.njk deleted file mode 100644 index 3418cb8197..0000000000 --- a/src/blog/node-red.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.node-red - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Node-RED - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/opcua.njk b/src/blog/opcua.njk deleted file mode 100644 index 0f730cbaca..0000000000 --- a/src/blog/opcua.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.opcua - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: OPC UA - Blog ---- - -{% include "blog/template.njk" %} diff --git a/src/blog/plc.njk b/src/blog/plc.njk deleted file mode 100644 index c324a2b600..0000000000 --- a/src/blog/plc.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.plc - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: PLC - Blog ---- - -{% include "blog/template.njk" %} diff --git a/src/blog/releases.njk b/src/blog/releases.njk deleted file mode 100644 index 7da3378df4..0000000000 --- a/src/blog/releases.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.releases - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Releases - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/tips.njk b/src/blog/tips.njk deleted file mode 100644 index a08955cc7c..0000000000 --- a/src/blog/tips.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.tips - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Quick Tips - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/blog/uns.njk b/src/blog/uns.njk deleted file mode 100644 index 675ddb3590..0000000000 --- a/src/blog/uns.njk +++ /dev/null @@ -1,13 +0,0 @@ ---- -layout: nohero -sitemapPriority: 0.8 -pagination: - data: collections.unified-namespace - size: 19 # creates a nice matrix (1 header, 18 left: 3 on 6 rows) - alias: posts - reverse: true -meta: - title: Unified Namespace (UNS) - Blog ---- - -{% include "blog/template.njk" %} \ No newline at end of file diff --git a/src/landing/plc.njk b/src/landing/plc.njk index bf534cbc54..5fdcb7446e 100644 --- a/src/landing/plc.njk +++ b/src/landing/plc.njk @@ -397,7 +397,7 @@ resources: {% endfor %} diff --git a/src/platform/dashboard.njk b/src/platform/dashboard.njk index f420c0e688..5dbab6d657 100644 --- a/src/platform/dashboard.njk +++ b/src/platform/dashboard.njk @@ -126,7 +126,7 @@ meta: {% endfor %} diff --git a/src/redirects.njk b/src/redirects.njk index c44dc67b0d..10efa81d66 100644 --- a/src/redirects.njk +++ b/src/redirects.njk @@ -70,7 +70,19 @@ eleventyExcludeFromCollections: true /blog/2024/01/barcode-scanner-into-nodered/ /node-red/peripheral/barcodescanner/ 301 /blog/2024/03/using_webcam_with_node-red/ /node-red/peripheral/webcam/ 301 /blog/2023/05/visualize-production-data-via-modbus-in-node-red/ /node-red/protocol/modbus/ 301 -/blog/flowforge/ /blog/flowfuse/ 301 +/blog/how-to/ /blog/?tag=how-to 301 +/blog/node-red/ /blog/?tag=node-red 301 +/blog/ai/ /blog/?tag=ai 301 +/blog/uns/ /blog/?tag=uns 301 +/blog/dashboard/ /blog/?tag=dashboard 301 +/blog/flowfuse/ /blog/?tag=flowfuse 301 +/blog/releases/ /blog/?tag=releases 301 +/blog/news/ /blog/?tag=news 301 +/blog/plc/ /blog/?tag=plc 301 +/blog/mqtt/ /blog/?tag=mqtt 301 +/blog/opcua/ /blog/?tag=opcua 301 +/blog/modbus/ /blog/?tag=modbus 301 +/blog/flowforge/ /blog/?tag=flowfuse 301 /node-red/core-nodes/httpin/ /node-red/core-nodes/http-in/ 301 /node-red/core-nodes/httprequest/ /node-red/core-nodes/http-request/ 301 /node-red/core-nodes/file/ /node-red/core-nodes/write-file/ 301