From daea6726b71c6f1b7a34cffdffc295915aa0c539 Mon Sep 17 00:00:00 2001 From: ZJ van de Weg Date: Thu, 13 Aug 2026 14:35:52 -0700 Subject: [PATCH 1/6] Migrate customer stories listing to Nuxt Serve /customer-stories/** through Nuxt (StoryTile component + listing/detail pages) while keeping source markdown at src/customer-stories/ and giving 11ty's copy permalink: false so the few live 11ty pages that still read collections.stories keep working without 11ty writing output files. --- .eleventyignore | 7 + nuxt/components/StoryTile.vue | 33 ++++ nuxt/content.config.ts | 40 ++++ nuxt/pages/customer-stories/[slug].vue | 183 ++++++++++++++++++ nuxt/pages/customer-stories/index.vue | 40 ++++ nuxt/redirects.ts | 1 - .../api/__sitemap__/content-urls.get.ts | 5 + nuxt/server/middleware/legacy.ts | 2 +- src/customer-stories/customer-stories.json | 3 +- ...i-future-of-textile-powered-by-node-red.md | 8 +- 10 files changed, 315 insertions(+), 7 deletions(-) create mode 100644 nuxt/components/StoryTile.vue create mode 100644 nuxt/pages/customer-stories/[slug].vue create mode 100644 nuxt/pages/customer-stories/index.vue diff --git a/.eleventyignore b/.eleventyignore index 0223473529..f6ebfbdc16 100644 --- a/.eleventyignore +++ b/.eleventyignore @@ -2,3 +2,10 @@ # directly); 11ty must not also render them. src/changelog/**/*.md src/blog/**/*.md + +# Customer stories listing page is served by Nuxt (nuxt/pages/customer-stories/index.vue). +# The individual story markdown files are NOT ignored here - they keep `permalink: false` +# (src/customer-stories/customer-stories.json) instead, so `collections.stories` stays +# populated for the few live 11ty pages that still read it (src/landing/tulip.njk, +# src/node-red/index.njk, src/_includes/stories-block.njk) without 11ty writing output files. +src/customer-stories.njk diff --git a/nuxt/components/StoryTile.vue b/nuxt/components/StoryTile.vue new file mode 100644 index 0000000000..beb7cc6d08 --- /dev/null +++ b/nuxt/components/StoryTile.vue @@ -0,0 +1,33 @@ + + + diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index 261bf3a85c..dd57803b29 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -146,6 +146,46 @@ export default defineContentConfig({ }).optional(), }) }), + // Source files stay at src/customer-stories/ (11ty's historical location) rather than + // being copied into nuxt/content/ - keeps this migration a content-config-only change. + // The directory data file (src/customer-stories/customer-stories.json) sets + // `permalink: false` so 11ty keeps these in `collections.stories` (still read by a + // few live 11ty pages - src/landing/tulip.njk, src/node-red/index.njk, + // src/_includes/stories-block.njk) without also writing output files for them. + stories: defineCollection({ + type: 'page', + source: { + cwd: join(__dirname, '../src'), + include: 'customer-stories/**/*.md', + }, + schema: z.object({ + description: z.string().optional(), + image: z.string().optional(), + date: z.coerce.date(), + // Card-badge logo shown on the listing/related-stories tiles - distinct from + // story.logo below (the sidebar logo on the detail page). Most stories leave + // this unset even when story.logo is set; that's existing 11ty behaviour, not + // a migration bug. Nullable because most story files write the key with no + // value ("logo:"), which YAML parses as null rather than omitting the key. + logo: z.string().nullable().optional(), + usecase: z.array(z.string()).optional(), + subtitle: z.string().optional(), + hubspot: z.object({ + formId: z.string(), + }), + story: z.object({ + brand: z.string(), + // Nullable for the same blank-key-in-YAML reason as top-level `logo` above. + url: z.string().nullable().optional(), + logo: z.string().optional(), + quote: z.string().optional(), + challenge: z.string(), + solution: z.string(), + products: z.array(z.string()), + results: z.array(z.string()), + }), + }) + }), ebooks: defineCollection({ type: 'page', source: 'ebooks/*.md', diff --git a/nuxt/pages/customer-stories/[slug].vue b/nuxt/pages/customer-stories/[slug].vue new file mode 100644 index 0000000000..49c348e23d --- /dev/null +++ b/nuxt/pages/customer-stories/[slug].vue @@ -0,0 +1,183 @@ + + + diff --git a/nuxt/pages/customer-stories/index.vue b/nuxt/pages/customer-stories/index.vue new file mode 100644 index 0000000000..76fafddaf8 --- /dev/null +++ b/nuxt/pages/customer-stories/index.vue @@ -0,0 +1,40 @@ + + + diff --git a/nuxt/redirects.ts b/nuxt/redirects.ts index 209fa3f9c2..a560684b91 100644 --- a/nuxt/redirects.ts +++ b/nuxt/redirects.ts @@ -44,7 +44,6 @@ export const redirects: Record = { '/blueprints/manufacturing/andon-task/': { redirect: { to: '/blueprints/manufacturing/andon-system/', statusCode: 301 } }, '/landing/technology-migration-1/': { redirect: { to: '/vs/kepware/', statusCode: 301 } }, '/landing/technology-migration-2/': { redirect: { to: '/vs/kepware/', statusCode: 301 } }, - '/customer-stories/scaling-manufacturing-automation-with-flowfuse/': { redirect: { to: '/customer-stories/', statusCode: 301 } }, '/blog/2025/10/the-ai-orchestation-hype/': { redirect: { to: '/blog/2025/10/the-ai-orchestration-hype/', statusCode: 301 } }, '/node-red/core-nodes/mqtt/': { redirect: { to: '/node-red/core-nodes/mqtt-in/', statusCode: 301 } }, '/blueprints/manufacturing/manufacturing-support-request/': { redirect: { to: '/blueprints/manufacturing/andon-system/', statusCode: 301 } }, diff --git a/nuxt/server/api/__sitemap__/content-urls.get.ts b/nuxt/server/api/__sitemap__/content-urls.get.ts index 317bee115e..52c2527a2a 100644 --- a/nuxt/server/api/__sitemap__/content-urls.get.ts +++ b/nuxt/server/api/__sitemap__/content-urls.get.ts @@ -69,6 +69,11 @@ const CONTENT_SOURCES: ContentSource[] = [ lastmod: entry => stringField(entry, 'lastUpdated') ?? getGitLastmod(REPO_ROOT, `src/${entry.stem}.md`), images: entry => [stringField(entry, 'image')].filter((path): path is string => Boolean(path)), }, + { + collection: 'stories', + fileRoot: 'src', + images: entry => [stringField(entry, 'image')].filter((path): path is string => Boolean(path)), + }, { collection: 'ebooks', fileRoot: 'nuxt/content', diff --git a/nuxt/server/middleware/legacy.ts b/nuxt/server/middleware/legacy.ts index 37f799bcd7..8b57aa189e 100644 --- a/nuxt/server/middleware/legacy.ts +++ b/nuxt/server/middleware/legacy.ts @@ -10,7 +10,7 @@ const NUXT_ROUTES = new Set(['/terms', '/privacy-policy', '/integrations', '/res const NUXT_ROUTE_PREFIXES = ['/integrations/', '/raw/'] // Route prefixes handled by Nuxt (all paths starting with these are served by Nuxt). -const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs', '/changelog', '/application-guide', '/blog', '/product'] +const NUXT_PREFIXES = ['/handbook', '/ebooks', '/whitepaper', '/pricing', '/docs', '/changelog', '/application-guide', '/blog', '/product', '/customer-stories'] export default defineEventHandler(async (event) => { if (process.env.NODE_ENV !== 'development') return diff --git a/src/customer-stories/customer-stories.json b/src/customer-stories/customer-stories.json index a6d71bd873..d94c082cd9 100644 --- a/src/customer-stories/customer-stories.json +++ b/src/customer-stories/customer-stories.json @@ -4,5 +4,6 @@ ], "layout": "layouts/story.njk", "nav": "customer-stories", - "searchTitle": "Customer Stories" + "searchTitle": "Customer Stories", + "permalink": false } diff --git a/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md b/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md index a26dc7343b..81f8a7047b 100644 --- a/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md +++ b/src/customer-stories/stfi-future-of-textile-powered-by-node-red.md @@ -25,23 +25,23 @@ Sächsisches Textilforschungsinstitut e.V. ([STFI](https://www.stfi.de/en/)) in -!["The textile model factory for automated, networked and low-code-based digital production"](images/stories/stfi-future-textile.jpg "The textile model factory for automated, networked and low-code-based digital production") (ⒸSTFI/D. Hanus)_ +!["The textile model factory for automated, networked and low-code-based digital production"](./images/stories/stfi-future-textile.jpg "The textile model factory for automated, networked and low-code-based digital production") (ⒸSTFI/D. Hanus)_ The Model Factory consists of a number of different manufacturing applications, including for instance a product configurator, systems for job control, and [location based carrier management](https://youtu.be/yzK7vo6VpNU?si=YFhiaJTZpgmreU0c). Node-RED has been used for these applications to provide back end integration to different systems. For instance, the [Product Configurator](https://youtu.be/cgtHO1OVkV8?si=CmbDHoMzlkAS6Siu) uses Node-RED to manage the login information and to forward a product definition to the control system and/or the ERP system. The ability to forward the product definition to different systems makes the application flexible enough to support different types of applications. -![State-Machine and OPC-UA-Connection to the machines](images/stories/stfi-node-red-flow-opcua.png "State-Machine and OPC-UA-Connection to the machines"){data-zoomable} +![State-Machine and OPC-UA-Connection to the machines](./images/stories/stfi-node-red-flow-opcua.png "State-Machine and OPC-UA-Connection to the machines"){data-zoomable} _Figure 2: [Job Control](https://youtu.be/cgtHO1OVkV8?si=oTpLigbmfqlZ-8Bi&t=98) with State-Machine and OPC-UA-Connection to the machines like a [laser cutter](https://youtu.be/eUkZ8R1tNM4?si=uOwL-XGf0uKkXdVL) (ⒸSTFI)_ The [job control system](https://youtu.be/cgtHO1OVkV8?si=oTpLigbmfqlZ-8Bi&t=98) is a set of state machines built in Node-RED, see figure 2. Each state machine represents a different machine like a [mobile robot](https://youtu.be/Z_e6EcT2mQs?si=DfxQS0K16bcrpixi) and includes different communication nodes to the different pieces of equipment in the factory line. Node-RED was well suited for developing these state machines since state machines are well represented in flow based programming and also Node-RED provides support for the different communication protocols like OPC UA. Node-RED was also used to control the automated guided vehicle and the collaborative robot UR10e within the mobile robot, see figure 3. -!["Control the Automated Guided Vehicle and the Universal Robot via OPC UA and Real-Time Data Exchange"](images/stories/stfi-mobile-robot.png "Control the Automated Guided Vehicle and the Universal Robot via OPC UA and Real-Time Data Exchange"){data-zoomable} +!["Control the Automated Guided Vehicle and the Universal Robot via OPC UA and Real-Time Data Exchange"](./images/stories/stfi-mobile-robot.png "Control the Automated Guided Vehicle and the Universal Robot via OPC UA and Real-Time Data Exchange"){data-zoomable} _Figure 3: Within the [mobile Robot](https://youtu.be/Z_e6EcT2mQs?si=DfxQS0K16bcrpixi), control the Automated Guided Vehicle and the Universal Robot via OPC UA and Real-Time Data Exchange (ⒸSTFI)_ The Model Factory also consists of a number of different pieces of equipment. Unfortunately, not all the equipment was able to communicate with the standard protocol. Equipment like a laser cutter and a 3D printing machine are connected to the network with a proprietary protocol. The Model Factory team wanted to use OPC-UA as the standard protocol to allow for flexibility and future integration to other equipment. To overcome this problem, the research team used Node-RED to convert proprietary protocols to OPC-UA. Node-RED was deployed to the equipment desktop control machine so that it could convert the protocol inbound and outbound into OPC-UA. With Node-RED, it is also possible to create a mobile dashboard for machines with just a few clicks and watch it within a [Hololens](https://youtu.be/T5BNb0-2D7o?feature=shared). Furthermore, systems for [process declaration input](https://youtu.be/rNAgmsZoh7g?t=243) (such as setup, cleaning, repair) can be implemented to increase transparency on machine states in production. Furthermore, based on Node-RED you can develop [Augmented Reality Application](https://youtu.be/jElLfvJUpH0?feature=shared&t=303) and system for [worker assistance](https://youtu.be/jElLfvJUpH0?feature=shared&t=348) with the help of cordova. -!["Augmented Reality Application based on Node-RED and cordova"](images/stories/stfi-augmented-reality.jpg "Augmented Reality Application based on Node-RED and cordova") +!["Augmented Reality Application based on Node-RED and cordova"](./images/stories/stfi-augmented-reality.jpg "Augmented Reality Application based on Node-RED and cordova") _Figure 4: Augmented Reality Application based on Node-RED and cordova (ⒸSTFI)_ Andreas Boehm, industrial engineer and lead researcher for the Model Factory comments on why they use Node-RED, “Node-RED is very easy to use and quick to develop. The low-code interface makes it possible for a non-professional developer to quickly start building applications. However, the ability to drop into the function node to write JavaScript makes it very powerful for a professional developer. Node-RED allows you to focus on developing the important things you want to create and not worry about a lot of the lower level infrastructure you need to connect data from different sources.” From 6bdba412ac60759b5c375e168f7aefbce4c66744 Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Fri, 14 Aug 2026 17:32:03 +0200 Subject: [PATCH 2/6] Fix customer-stories pages not being prerendered by Nuxt --- nuxt/nuxt.config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nuxt/nuxt.config.ts b/nuxt/nuxt.config.ts index b9303aa725..9ec6ed4072 100644 --- a/nuxt/nuxt.config.ts +++ b/nuxt/nuxt.config.ts @@ -70,6 +70,17 @@ function collectProductRoutes (dir: string): string[] { return routes } +// Same idea as collectApplicationGuideRoutes above, for customer stories (flat +// src/customer-stories/ dir, see content.config.ts). +function collectStoryRoutes(dir: string): string[] { + const routes = ['/customer-stories/'] + for (const file of readdirSync(dir)) { + if (!file.endsWith('.md')) continue + routes.push(`/customer-stories/${basename(file, '.md')}/`) + } + return routes +} + // Same idea for blog posts. Each entry also carries its `tags` so the 13 tag-listing // pages (and their own pagination, 19 entries/page) can be sized correctly, and its // `authors` so the /blog/author/{slug}/ pages can be enumerated. @@ -361,6 +372,7 @@ export default defineNuxtConfig({ ...blogFiles.map(f => f.route), ...blogAuthorRoutes, ...collectHandbookRoutes(join(__dirname, 'content/handbook'), '/handbook'), + ...collectStoryRoutes(join(__dirname, '../src/customer-stories')), ] })(), crawlLinks: false, From da2bed20a4367bb4d864535731d9a0844703159a Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Fri, 14 Aug 2026 17:48:50 +0200 Subject: [PATCH 3/6] Fix cards style --- nuxt/components/StoryTile.vue | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/nuxt/components/StoryTile.vue b/nuxt/components/StoryTile.vue index beb7cc6d08..6c6d4c4ef6 100644 --- a/nuxt/components/StoryTile.vue +++ b/nuxt/components/StoryTile.vue @@ -12,19 +12,20 @@ withDefaults(defineProps<{