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..6c6d4c4ef6 --- /dev/null +++ b/nuxt/components/StoryTile.vue @@ -0,0 +1,34 @@ + + + diff --git a/nuxt/content.config.ts b/nuxt/content.config.ts index 6cba515b45..3a302b77d9 100644 --- a/nuxt/content.config.ts +++ b/nuxt/content.config.ts @@ -161,6 +161,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/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, diff --git a/nuxt/pages/customer-stories/[slug].vue b/nuxt/pages/customer-stories/[slug].vue new file mode 100644 index 0000000000..a462b6f66e --- /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/public/images/stories/product-icons/ff-dashboard.svg b/nuxt/public/images/stories/product-icons/ff-dashboard.svg new file mode 100644 index 0000000000..0a21920f8b --- /dev/null +++ b/nuxt/public/images/stories/product-icons/ff-dashboard.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/nuxt/public/images/stories/product-icons/ff-device-agent.svg b/nuxt/public/images/stories/product-icons/ff-device-agent.svg new file mode 100644 index 0000000000..6f987829be --- /dev/null +++ b/nuxt/public/images/stories/product-icons/ff-device-agent.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/nuxt/public/images/stories/product-icons/ff-icon.svg b/nuxt/public/images/stories/product-icons/ff-icon.svg new file mode 100644 index 0000000000..65c7be9bc3 --- /dev/null +++ b/nuxt/public/images/stories/product-icons/ff-icon.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/nuxt/public/images/stories/product-icons/ff-project-nodes.svg b/nuxt/public/images/stories/product-icons/ff-project-nodes.svg new file mode 100644 index 0000000000..0415636ef3 --- /dev/null +++ b/nuxt/public/images/stories/product-icons/ff-project-nodes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/nuxt/public/images/stories/product-icons/node-red.svg b/nuxt/public/images/stories/product-icons/node-red.svg new file mode 100644 index 0000000000..59f3b9ab0d --- /dev/null +++ b/nuxt/public/images/stories/product-icons/node-red.svg @@ -0,0 +1,9 @@ + + + + + + + + + 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/_includes/stories-block.njk b/src/_includes/stories-block.njk index c428d2bebd..51f62a61ef 100644 --- a/src/_includes/stories-block.njk +++ b/src/_includes/stories-block.njk @@ -2,6 +2,6 @@ {% from "stories/customer-story.njk" import storyTile %} {%- for item in collections.stories | excludeCurrent(page.url) | shuffle | limit(3) -%} - {{ storyTile(title=item.data.title, url=item.url, brand=item.data.story.brand, logo=item.data.logo, image=item.data.image) }} + {{ storyTile(title=item.data.title, url="/customer-stories/" ~ item.fileSlug ~ "/", brand=item.data.story.brand, logo=item.data.logo, image=item.data.image) }} {%- endfor -%} \ No newline at end of file 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.” diff --git a/src/landing/tulip.njk b/src/landing/tulip.njk index d640b8ebae..73883197a2 100644 --- a/src/landing/tulip.njk +++ b/src/landing/tulip.njk @@ -181,7 +181,7 @@ hubspot: