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
6 changes: 5 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ A link inside a sentence keeps free-form text (it has to fit the prose), so it i

Markdown needs no markup for this: `nuxt/components/content/ProseA.vue` renders every markdown link, and when `ctaDestinationKey()` (in `cta-destinations.ts`) matches the href, it renders `<CtaLink position="inline-link" prose>` instead of Nuxt UI's `ProseA`, passing the link's query string through as `query`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash and the query string, `https://flowfuse.com/...` counts the same as a relative path, and `http://` the same as `https://`. A hash points at a section of the page, so it doesn't match. So `[FlowFuse Cloud](site:appURL)`, `[try it for free](cta:signUp)` and a hand-typed `https://app.flowfuse.com/account/create?code=...` are all tracked. `cta:<key>` (resolved by `nuxt/utils/remark-site-links.ts`) is still the preferred way to write the href, and takes a query string too: `[contact us](cta:contactUs?subject=Certified%20Nodes)`.

These are the destination events, not `blog-cta`: inline links also appear on changelog, webinar, handbook and docs pages, where a `Blog: <title>` reference would be wrong, and PostHog already records the page URL. Hrefs from `CUSTOM_CTA_DESTINATIONS` aren't matched. FAQ answers (`nuxt/lib/faq-answer.mjs`, rendered with `v-html`) can't hold a component, so their links aren't tracked.
These are the destination events, not `blog-cta`: inline links also appear on changelog, webinar, handbook and docs pages, where a `Blog: <title>` reference would be wrong, and PostHog already records the page URL. Hrefs from `CUSTOM_CTA_DESTINATIONS` aren't matched. FAQs follow the same rule (see **FAQs**).

`nuxt/lib/cta-destinations.test.mjs` fails `npm test` if a `.vue` file hard-codes an `href`/`to` to one of the five destinations, so a link can't skip `CtaLink` or the matching button. It only sees literal values; a dynamic `:href` still relies on the caller.

Expand Down Expand Up @@ -395,6 +395,10 @@ These are the destination events, not `blog-cta`: inline links also appear on ch

Keep that art out of `nuxt/assets/icons/`. That directory is the `ff` Nuxt Icon collection (`<UIcon name="i-ff-pin" />`), which parses every file in it at build time and fails the build on a bare `<path>` fragment, which several of the nav icons are.

## FAQs

Every FAQ on the site renders through `nuxt/components/Faq.vue`, including `/pricing/` (answers in `nuxt/content/faq/pricing.yml`). Answers are plain text with a small markdown subset (`[label](url)`, `**bold**`, `*italic*`, blank-line paragraphs, `-`/`1.` lists), never HTML. `nuxt/lib/faq-answer.mjs` splits an answer into paragraphs and lists, `nuxt/lib/inline-markdown.mjs` parses the text inside them into nodes (not HTML strings), and `nuxt/components/InlineMarkdown.vue` renders those nodes. Vue escapes the text, so literal placeholders like `<ip>` stay literal. A link to a CTA destination renders through `CtaLink` (see **Inline links**), tracked with the `position` passed to `InlineMarkdown` (`faq` in `Faq`). Page copy outside FAQs that uses the inline part of the subset renders through the same component, e.g. `<InlineMarkdown :text="item.description" position="applications" />`. Don't render these strings with `v-html`. FAQ structured data (`defineQuestion`) takes `faqAnswerText(answer)` from the same file, the answer as plain text, not the raw markdown.

## Naming conventions

- All slugs: **kebab-case**
Expand Down
20 changes: 9 additions & 11 deletions nuxt/components/BlogFaq.vue → nuxt/components/Faq.vue
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
<script setup lang="ts">
// The answer renderer moved to nuxt/lib/faq-answer.mjs so it can be unit tested and so
// the escaping rules live in one place; it also gained *italic*, **bold** and blank-line
// paragraphs, which the 11ty pages expressed with raw <i> and <p> tags under `| safe`.
import { isListBlock, renderFaqAnswer } from '../lib/faq-answer.mjs'
import { parseFaqAnswer } from '../lib/faq-answer.mjs'

withDefaults(defineProps<{
const props = withDefaults(defineProps<{
faq: Array<{ question: string, answer: string }>
/**
* How much room the block takes above the first question.
Expand All @@ -19,6 +16,8 @@ withDefaults(defineProps<{
variant: 'post',
})

const answers = computed(() => props.faq.map(item => parseFaqAnswer(item.answer)))

const openIndex = ref<number | null>(null)
function toggle(i: number) {
openIndex.value = openIndex.value === i ? null : i
Expand Down Expand Up @@ -46,12 +45,11 @@ function toggle(i: number) {
</button>
</h3>
<div v-show="openIndex === i" class="px-6 mt-6">
<template v-for="(block, b) in renderFaqAnswer(item.answer)" :key="b">
<!-- A <ul>/<ol> cannot sit inside a <p>, so a list block renders bare.
eslint-disable-next-line vue/no-v-html -->
<div v-if="isListBlock(block)" class="ff-faq-list" v-html="block" />
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-else v-html="block" />
<template v-for="(block, b) in answers[i]" :key="b">
<p v-if="block.type === 'p'"><InlineMarkdown :nodes="block.children" position="faq" /></p>
<component :is="block.type" v-else>
<li v-for="(li, l) in block.items" :key="l"><InlineMarkdown :nodes="li" position="faq" /></li>
</component>
</template>
</div>
</div>
Expand Down
28 changes: 28 additions & 0 deletions nuxt/components/InlineMarkdown.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { parseInline } from '../lib/inline-markdown.mjs'
import { ctaLink } from '../lib/cta-destinations'

type InlineNode =
| { type: 'text', value: string }
| { type: 'strong' | 'em', children: InlineNode[] }
| { type: 'link', href: string, children: InlineNode[] }

const props = defineProps<{
text?: string
nodes?: InlineNode[]
position: string
}>()

const list = computed<InlineNode[]>(() => props.nodes ?? parseInline(props.text ?? ''))
const ctas = computed(() => list.value.map(node => node.type === 'link' ? ctaLink(node.href) : undefined))
</script>

<template>
<template v-for="(node, i) in list" :key="i">
<template v-if="node.type === 'text'">{{ node.value }}</template>
<strong v-else-if="node.type === 'strong'"><InlineMarkdown :nodes="node.children" :position="position" /></strong>
<em v-else-if="node.type === 'em'"><InlineMarkdown :nodes="node.children" :position="position" /></em>
<CtaLink v-else-if="ctas[i]" :destination="ctas[i]!.destination" :query="ctas[i]!.query" :position="position"><InlineMarkdown :nodes="node.children" :position="position" /></CtaLink>
<a v-else :href="node.href"><InlineMarkdown :nodes="node.children" :position="position" /></a>
</template>
</template>
7 changes: 3 additions & 4 deletions nuxt/components/content/ProseA.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<template>
<CtaLink v-if="destination" :destination="destination" :query="query" :target="target" position="inline-link" prose>
<CtaLink v-if="cta" :destination="cta.destination" :query="cta.query" :target="target" position="inline-link" prose>
<slot />
</CtaLink>
<UiProseA v-else :href="href" :target="target">
Expand All @@ -9,13 +9,12 @@

<script setup lang="ts">
import UiProseA from '@nuxt/ui/components/prose/A.vue'
import { ctaDestinationKey, ctaQuery } from '../../lib/cta-destinations'
import { ctaLink } from '../../lib/cta-destinations'

const props = defineProps<{
href?: string
target?: string
}>()

const destination = computed(() => ctaDestinationKey(props.href))
const query = computed(() => destination.value ? ctaQuery(props.href!) : undefined)
const cta = computed(() => ctaLink(props.href))
</script>
9 changes: 3 additions & 6 deletions nuxt/components/industry-page/Applications.vue
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
<script setup lang="ts">
// The zigzag "Applications for X Manufacturing" band from automotive.vue.
// Supports the same `[label](url)` inline-link subset as BlogFaq's answers.
import { inline } from '../../lib/faq-answer.mjs'
// Supports the same `[label](url)` inline-link subset as Faq's answers.

type Item = {
title: string
Expand Down Expand Up @@ -61,16 +60,14 @@ const VARIANTS: Record<string, { gradient: string, border: string, mobileGradien
</div>
</div>
<!-- A blank line in the source splits the description into separate paragraphs,
same convention as BlogFaq's answers (faq-answer.mjs's own blank-line rule) -
same convention as Faq's answers (faq-answer.mjs's own blank-line rule) -
for a trailing aside that shouldn't run into the main copy. -->
<!-- eslint-disable-next-line vue/no-v-html -->
<p
v-for="(paragraph, i) in item.description.split(/\n\n+/)"
:key="i"
class="text-gray-500 m-0"
:class="{ 'mt-3': i > 0 }"
v-html="inline(paragraph)"
/>
><InlineMarkdown :text="paragraph" position="applications" /></p>
<a :href="item.linkHref" class="flex items-center gap-1.5 text-blue-600 hover:underline max-md:justify-center">
{{ item.linkText }}
<UIcon name="i-heroicons-arrow-long-right" class="w-6 h-6 shrink-0" />
Expand Down
6 changes: 4 additions & 2 deletions nuxt/components/industry-page/Template.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// The shared page body for every industry page built on the automotive.vue template.
// A thin pages/industries/<slug>.vue queries its own `industries` collection entry and
// renders it through this one component, so the band markup lives in exactly one place.
import { faqAnswerText } from '../../lib/faq-answer.mjs'

const props = defineProps<{
page: {
slug: string
Expand Down Expand Up @@ -44,7 +46,7 @@ useSeoMeta({

useSchemaOrg([
defineWebPage({ '@type': 'FAQPage' }),
...props.page.faqs.map(item => defineQuestion(item)),
...props.page.faqs.map(item => defineQuestion({ question: item.question, answer: faqAnswerText(item.answer) })),
])
</script>

Expand Down Expand Up @@ -79,7 +81,7 @@ useSchemaOrg([
<div class="w-full px-6 pt-20 pb-10">
<div class="max-w-screen-lg mx-auto">
<h2 class="mb-1 text-center md:text-left">Frequently Asked <span class="text-indigo-600">Questions</span></h2>
<BlogFaq :faq="page.faqs" />
<Faq :faq="page.faqs" />
</div>
</div>

Expand Down
10 changes: 5 additions & 5 deletions nuxt/content/faq/pricing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ page: pricing
title: 'Frequently Asked <span class="text-indigo-600">Questions</span>'
items:
- question: "What's the difference between Edge and Hub?"
answer: "Edge is a product designed specifically for OT teams and the unique challenges they manage. With Edge, you can connect PLCs, machines, and systems, applying a standardised methodology for deploying automation at scale. This tier comes with everything in the FlowFuse core platform, but also includes support for Device Fleet Updates and Device Group Management. Additionally, this tier includes the OT Certified Nodes collection and deployable OT Application Blueprints.<br><br>Hub is a product better suited for IT teams moving data between their systems and orchestrating multi-plant installations. For this reason, it includes everything in the FlowFuse core platform, as well as some particularly useful features for this use case. It includes Git Integration, High Availability, BAA for HIPAA, and Protected Instances. It also includes the IT Certified Nodes collection and deployable IT Application Blueprints.<br><br>For customers requiring something between these two featuresets, please <a href='/contact-us'>Contact Us</a> so we can help build a tailored solution for your environment!"
answer: "Edge is a product designed specifically for OT teams and the unique challenges they manage. With Edge, you can connect PLCs, machines, and systems, applying a standardised methodology for deploying automation at scale. This tier comes with everything in the FlowFuse core platform, but also includes support for Device Fleet Updates and Device Group Management. Additionally, this tier includes the OT Certified Nodes collection and deployable OT Application Blueprints.\n\nHub is a product better suited for IT teams moving data between their systems and orchestrating multi-plant installations. For this reason, it includes everything in the FlowFuse core platform, as well as some particularly useful features for this use case. It includes Git Integration, High Availability, BAA for HIPAA, and Protected Instances. It also includes the IT Certified Nodes collection and deployable IT Application Blueprints.\n\nFor customers requiring something between these two featuresets, please [Contact Us](/contact-us/) so we can help build a tailored solution for your environment!"
- question: "Can I self-host FlowFuse, or is it cloud only?"
answer: "Both are available. Edge, Hub, and Fleet can run on FlowFuse Cloud, fully managed by us, or self-hosted on your own infrastructure - on-premise, in your private cloud, or in a fully air-gapped network. Self-hosted installs are supported on <a href='/docs/install/docker/'>Docker</a> and <a href='/docs/install/kubernetes/'>Kubernetes</a>; see the <a href='/docs/install/introduction/'>installation documentation</a> to get started, or <a href='/contact-us/'>Contact Us</a> to discuss a self-hosted deployment for your environment."
answer: "Both are available. Edge, Hub, and Fleet can run on FlowFuse Cloud, fully managed by us, or self-hosted on your own infrastructure - on-premise, in your private cloud, or in a fully air-gapped network. Self-hosted installs are supported on [Docker](/docs/install/docker/) and [Kubernetes](/docs/install/kubernetes/); see the [installation documentation](/docs/install/introduction/) to get started, or [Contact Us](/contact-us/) to discuss a self-hosted deployment for your environment."
- question: "How does customer support work for FlowFuse?"
answer: "FlowFuse provides active support for Certified Nodes and the FlowFuse platform per the <a href='/handbook/sales/customer-success/#sla'>SLA</a>. Customers with these features can get support by <a href='/support'>filing a ticket</a> or via help information in the <a href='/support'>FlowFuse Support Portal</a>. For general issues related to Node-RED or third party modules, raise issues in the <a href='https://community.flowfuse.com/'>community forum</a>."
answer: "FlowFuse provides active support for Certified Nodes and the FlowFuse platform per the [SLA](/handbook/sales/customer-success/#sla). Customers with these features can get support by [filing a ticket](/support/) or via help information in the [FlowFuse Support Portal](/support/). For general issues related to Node-RED or third party modules, raise issues in the [community forum](https://community.flowfuse.com/)."
- question: "How does FlowFuse secure my data?"
answer: "FlowFuse employs a layered approach to securing customer data. For a fuller understanding of the mechanisms behind this approach, please read our <a href='/platform/security/'>security statement</a> as well as our <a href='/docs/contribute/architecture/'>architecture documentation</a>.<br><br>FlowFuse secures user access through username/password, SAML-based SSO, or LDAP, with support for multi-factor authentication and automatic session expiry after 14 days. Access is governed by role-based controls within Teams - Owner, Member, Viewer, and Dashboard roles - while platform-wide admin access is tightly restricted (on FlowFuse Cloud, limited to vetted employees on a need-to-have basis). All administrative and team-level actions are captured in audit logs. On FlowFuse Cloud, data is encrypted at rest with AES-256 (PostgreSQL and AWS EFS), stored in the EU (Ireland), and protected in transit with TLS 1.2 or higher terminated at the edge for both HTTP and MQTT traffic.<br><br>Organizationally, FlowFuse holds SOC 2 Type 1 and Type 2 certifications (audited by Advantage Partners) and maintains formal information security, acceptable use, and data management policies alongside regular security awareness training. The platform is also built to be GxP validation-ready: features like role-based access control, audit logging, instance versioning and snapshots, infrastructure-as-code, and SSO/LDAP integration align with the traceability and change-control expectations of regulated environments such as FDA 21 CFR Part 11 and EU GMP Annex 11, enabling customers to perform their own validation and supplier qualification."
answer: "FlowFuse employs a layered approach to securing customer data. For a fuller understanding of the mechanisms behind this approach, please read our [security statement](/platform/security/) as well as our [architecture documentation](/docs/contribute/architecture/).\n\nFlowFuse secures user access through username/password, SAML-based SSO, or LDAP, with support for multi-factor authentication and automatic session expiry after 14 days. Access is governed by role-based controls within Teams - Owner, Member, Viewer, and Dashboard roles - while platform-wide admin access is tightly restricted (on FlowFuse Cloud, limited to vetted employees on a need-to-have basis). All administrative and team-level actions are captured in audit logs. On FlowFuse Cloud, data is encrypted at rest with AES-256 (PostgreSQL and AWS EFS), stored in the EU (Ireland), and protected in transit with TLS 1.2 or higher terminated at the edge for both HTTP and MQTT traffic.\n\nOrganizationally, FlowFuse holds SOC 2 Type 1 and Type 2 certifications (audited by Advantage Partners) and maintains formal information security, acceptable use, and data management policies alongside regular security awareness training. The platform is also built to be GxP validation-ready: features like role-based access control, audit logging, instance versioning and snapshots, infrastructure-as-code, and SSO/LDAP integration align with the traceability and change-control expectations of regulated environments such as FDA 21 CFR Part 11 and EU GMP Annex 11, enabling customers to perform their own validation and supplier qualification."
- question: "Does FlowFuse offer annual discounts for FlowFuse Cloud?"
answer: "Yes, Edge customers receive a free month when paying for a year of FlowFuse in advance. Simply choose yearly billing upon signup, or select it from the Billing area in the app."
- question: "Are flat-fee or site-wide licenses available?"
answer: "It is not possible at this time to purchase an unlimited-use license, but contact us to discuss pricing for your unique use case."
- question: "Do you offer discounts for large-scale deployments?"
answer: "Yes, we provide customized volume discounts for use cases requiring hundreds or thousands of Node-RED instances. Please <a href='/contact-us'>Contact Us</a> to discuss your specific requirements."
answer: "Yes, we provide customized volume discounts for use cases requiring hundreds or thousands of Node-RED instances. Please [Contact Us](/contact-us/) to discuss your specific requirements."
8 changes: 7 additions & 1 deletion nuxt/lib/cta-destinations.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url'
import { createJiti } from 'jiti'

const jiti = createJiti(import.meta.url)
const { ctaDestinationKey, ctaQuery, withCtaQuery } = await jiti.import('./cta-destinations.ts')
const { ctaDestinationKey, ctaLink, ctaQuery, withCtaQuery } = await jiti.import('./cta-destinations.ts')
const nuxtDir = fileURLToPath(new URL('..', import.meta.url))

test('matches every way of writing a destination URL', () => {
Expand Down Expand Up @@ -57,6 +57,12 @@ test('a parameter named like a built-in object property round-trips unchanged',
assert.equal(withCtaQuery('/pricing/', ctaQuery(href)), href)
})

test('ctaLink gives the destination and query of a destination link, and nothing for any other', () => {
assert.deepEqual(ctaLink('/contact-us/?subject=Certified%20Nodes'), { destination: 'contactUs', query: { subject: 'Certified Nodes' } })
assert.deepEqual(ctaLink('/pricing'), { destination: 'pricing', query: {} })
for (const href of ['/docs/', '/pricing/#comparison', 'https://community.flowfuse.com/', undefined]) assert.equal(ctaLink(href), undefined, String(href))
})

test('custom CTA registry has no duplicate URLs and none of the five reserved destinations', async () => {
await jiti.import('./custom-cta-destinations.ts')
})
Expand Down
Loading
Loading