diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 72ee624036..fe49d61152 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -127,7 +127,7 @@ Tag options are defined in `nuxt/data/blogTags.json`. Future-dated posts are exc
- `cta` is required on every instance, one of `sign-up` | `demo` | `contact` | `pricing` — no fallback to the post's frontmatter `cta.type` (that one only drives `BlogPostCta` at the end of the article). An invalid value throws a descriptive error at render time instead of a bare `Cannot read properties of undefined`.
- `position` isn't a prop — it's hardcoded to `'inline-image'`, since every instance shares the same placement semantics.
-- Fires the same `blog-cta` event as `BlogPostCta` (not a dedicated `cta-sign-up`/`cta-book-demo`/etc. event) — `cta_type` alone distinguishes the destination, so both the end-of-article CTA and inline image CTAs land in one PostHog series, filterable by `cta_type`/`position`.
+- Renders through `CtaLink` (see **Inline links** under Call-to-Action components), so its href and destination event (`cta-book-demo`, etc., with `variant: 'image'`) come from `CTA_DESTINATIONS`. It also fires `blog-cta` with `cta_type`, the same as `BlogPostCta` does alongside its button's event. Image CTAs therefore land in both the destination's `cta-*` series and the single blog series, filterable by `cta_type`/`position`.
- The article title can't be passed as a prop from markdown (MDC only forwards literal `{...}` attributes), so `nuxt/pages/blog/[...slug].vue` does `provide('blogPostTitle', pageTitle)` and the component `inject()`s it — the only reason this indirection exists here and not on `BlogPostCta`.
---
@@ -329,7 +329,7 @@ There are exactly five CTA destinations, each with its own component with **fixe
All five are thin wrappers around `nuxt/components/cta/CtaButton.vue`, which does the actual styling/tracking and isn't meant to be used directly. If a page needs different wording, that's a sign a sixth destination-specific component is needed — not a prop that lets callers override copy on these five.
-**Props** (all optional except `variant`/`position`): `variant` (`primary` | `primary-outlined` | `highlight` | `highlight-outlined` | `ghost` | `nav-text`), `position` (free string, sent to PostHog — describes *where on the page* the button sits, e.g. `hero`, `footer`, `pricing-card` — never which page; PostHog already captures the page URL on every event, so encoding the page into `position` too would just duplicate that and make it look like a placement dimension when it isn't one), `plan` (e.g. `edge`/`hub`/`fleet`, sent to PostHog), `color` (`primary`|`highlight`|`white`, only for `variant="ghost"`, which has no background of its own), `icon` (Nuxt Icon name for a trailing icon — not a `→`/`←` character baked into `label` itself, which every fixed component's copy avoids), `uppercase`, `padded` (only for `variant="nav-text"` — whether it has the header-`
` link padding or is true zero-padding inline text), `target` (passed straight through to the underlying link; none of the five fixed destinations need it, it exists for `CtaCustom` linking off-site), `preview` (renders identically but doesn't navigate or call `capture()` — used by the handbook's live example gallery so clicking a doc example can't send a real event or leave the page).
+**Props** (all optional except `variant`/`position`): `variant` (`primary` | `primary-outlined` | `highlight` | `highlight-outlined` | `ghost` | `nav-text`), `position` (free string, sent to PostHog — describes *where on the page* the button sits, e.g. `hero`, `footer`, `pricing-card` — never which page; PostHog already captures the page URL on every event, so encoding the page into `position` too would just duplicate that and make it look like a placement dimension when it isn't one), `plan` (e.g. `edge`/`hub`/`fleet`, sent to PostHog), `color` (`primary`|`highlight`|`white`, only for `variant="ghost"`, which has no background of its own), `icon` (Nuxt Icon name for a trailing icon — not a `→`/`←` character baked into `label` itself, which every fixed component's copy avoids), `uppercase`, `padded` (only for `variant="nav-text"` — whether it has the header-`` link padding or is true zero-padding inline text), `target` (passed straight through to the underlying link; none of the five fixed destinations need it, it exists for `CtaCustom` linking off-site), `preview` (renders identically but doesn't navigate or call `capture()` — used by the handbook's live example gallery so clicking a doc example can't send a real event or leave the page). The five fixed components also take `query` (URL parameters appended to the fixed href, e.g. `{ subject: 'Certified Nodes' }` or utm properties; an array value repeats the parameter): the path, label and event stay fixed, and there's no way to change the path or add a hash.
### CtaCustom — one-off destinations
@@ -337,17 +337,32 @@ All five are thin wrappers around `nuxt/components/cta/CtaButton.vue`, which doe
This exists because a raw `event` prop would let two different `CtaCustom` instances pointed at the same URL drift onto two different PostHog events with nothing to catch it — exactly the fragmentation this whole system exists to prevent, just one level down from the five reserved destinations. Keying off a shared registry entry instead makes "this destination has exactly one event" structural: there's one place it's decided, not N call sites each typing a string by hand.
-- **Fixed-URL destinations** (e.g. `hubspotMeeting`, `communityForum`, `homepage`) store both `href` and `event` in the registry; `CtaCustom` reads the href from there and rejects a caller-supplied `href` that conflicts with it. The registry file also self-checks on load for a second key reusing an href already claimed by another key — checked against both other entries in this same file and the five reserved destinations in `cta-destinations.ts` — so that mistake fails immediately at build/dev-start rather than only when (and if) some page actually renders the offending ``.
+- **Fixed-URL destinations** (e.g. `hubspotMeeting`, `communityForum`, `homepage`) store both `href` and `event` in the registry; `CtaCustom` reads the href from there and rejects a caller-supplied `href` that conflicts with it. The registry file also self-checks on load for a second key reusing an href already claimed by another key — checked against both other entries in this same file and the five reserved destinations in `cta-destinations.ts` — and `nuxt/lib/cta-destinations.test.mjs` loads the registry, so that mistake fails `npm test` rather than only when (and if) some page actually renders the offending ``.
- **Dynamic-URL destinations** (e.g. `latestWebinar`, whose target is whichever webinar is currently most-recently-dated; `agentSetupClientOpen`, whose target varies per AI client) have no `href` in the registry — only the event is pinned there, and the caller still supplies `href` each time. This is a real, accepted limit: nothing can statically verify that two different call sites' *dynamic* hrefs never coincidentally collide, since the actual value only exists at render time and can change over time (this session's design discussion concluded that's fine — a coincidental runtime overlap between two conceptually different destinations, like "always show the newest webinar" vs. "a blog post links to this specific one," isn't the same kind of drift as two call sites hand-typing the same static URL with different event names).
- `destinationKey` is required and its TS type is `keyof typeof CUSTOM_CTA_DESTINATIONS` — there's no way to point `CtaCustom` at a URL without first adding an entry to that file, by design.
-- Like the five fixed components, `CtaCustom` throws a descriptive error (same convention as `CtaImage.vue`'s invalid-`cta` check) if pointed at one of the five *reserved* destinations' hrefs — use the matching fixed component instead.
+- Like the five fixed components, `CtaCustom` throws a descriptive error (same convention as `CtaImage.vue`'s invalid-`cta` check) if pointed at one of the five *reserved* destinations' hrefs — use the matching fixed component instead. Both checks match reserved destinations with `ctaDestinationKey()` (see **Inline links**), so a query string or absolute `https://flowfuse.com/...` URL counts as the same destination, while a hash (a section of the page, like `deviceAgentInstall`'s) counts as its own.
+- A render-time throw doesn't fail the build: Nitro's `prerender.failOnError` is off (the `failOnError: true` in `nuxt.config.ts` is the link checker's), so the failed route is logged and the build stays green. That's why these guards are also checked in `npm test`, the required PR check.
-`nav-text` is deliberately not called `text` — it's the plain, no-underline treatment used for "Free Trial" (main nav) and "Sign In" (utility bar) specifically, not a general-purpose inline link. A future `text` variant (styled like a normal paragraph link — the site's blue-700, underline-on-hover convention) is reserved for that.
+`nav-text` is deliberately not called `text` — it's the plain, no-underline treatment used for "Free Trial" (main nav) and "Sign In" (utility bar) specifically, not a general-purpose inline link. Inline links are `CtaLink` (see **Inline links** below), not a `CtaButton` variant.
There's no `size` prop — every real-button variant's padding/font-size is hardcoded to match `.ff-btn` exactly (see the Computed-tab note in the gotchas below), so a size knob would only ever have affected icon dimensions. It was removed once confirmed nothing used a non-default value.
Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composables/useCapture.ts`, which wraps the global `window.capture()` from `nuxt/server/assets/analytics/body.html` (a Nitro server asset injected by `nuxt/server/plugins/analytics.ts` in production builds only, so `capture()` is absent in dev and deploy previews; it no-ops without analytics consent). Event names: `cta-sign-up`, `cta-sign-in`, `cta-contact-us`, `cta-book-demo`, `cta-pricing`.
+### Inline links
+
+A link inside a sentence keeps free-form text (it has to fit the prose), so it isn't one of the five buttons. It is `nuxt/components/CtaLink.vue`: `destination` is a `CTA_DESTINATIONS` key, and the href and event come from there. Text goes in the slot. It fires `capture(event, { position, variant })` (`variant` is `text`, or `image` when `CtaImage` wraps an image in it) and renders a class-less link, so it takes the page's default link CSS unless the caller passes a class. `query` adds URL parameters the same way the buttons' `query` does.
+
+```vue
+book a demo
+```
+
+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 `` 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:` (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: ` 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.
+
+`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.
+
### Gotchas already solved here (don't re-discover them)
- **Vue auto-defaults unspecified `boolean` props to `false`, not `undefined`.** Any prop typed as `boolean` in a type-only `defineProps<{...}>()` needs `withDefaults(defineProps<...>(), { theProp: undefined })` if the code distinguishes "not passed" from "explicitly false" (e.g. via `??`) — otherwise the `??` fallback never triggers, since `false ?? x` is `false`.
diff --git a/nuxt/components/CtaBookDemo.vue b/nuxt/components/CtaBookDemo.vue
index 160cd523d3..3eb90f7ea0 100644
--- a/nuxt/components/CtaBookDemo.vue
+++ b/nuxt/components/CtaBookDemo.vue
@@ -2,7 +2,7 @@
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
-import { CTA_DESTINATIONS } from '../lib/cta-destinations'
+import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'
withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
@@ -13,6 +13,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
+ query?: CtaQuery
}>(), { uppercase: undefined })
const DEST = CTA_DESTINATIONS.bookDemo
@@ -22,5 +23,5 @@ const LABEL = DEST.label
-
+
diff --git a/nuxt/components/CtaContactUs.vue b/nuxt/components/CtaContactUs.vue
index 76a9a8d732..6be0038f5a 100644
--- a/nuxt/components/CtaContactUs.vue
+++ b/nuxt/components/CtaContactUs.vue
@@ -2,7 +2,7 @@
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
-import { CTA_DESTINATIONS } from '../lib/cta-destinations'
+import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'
withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
@@ -13,6 +13,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
+ query?: CtaQuery
}>(), { uppercase: undefined })
const DEST = CTA_DESTINATIONS.contactUs
@@ -22,5 +23,5 @@ const LABEL = DEST.label
-
+
diff --git a/nuxt/components/CtaCustom.vue b/nuxt/components/CtaCustom.vue
index 504a160f75..5558860b02 100644
--- a/nuxt/components/CtaCustom.vue
+++ b/nuxt/components/CtaCustom.vue
@@ -29,7 +29,7 @@
// descriptive error" convention as CtaImage.vue's invalid-cta check (see
// CLAUDE.md).
import CtaButton from './cta/CtaButton.vue'
-import { CTA_DESTINATIONS, normalizeHref } from '../lib/cta-destinations'
+import { CTA_DESTINATIONS, ctaDestinationKey } from '../lib/cta-destinations'
import { CUSTOM_CTA_DESTINATIONS } from '../lib/custom-cta-destinations'
const props = withDefaults(defineProps<{
@@ -93,7 +93,10 @@ const HREF = computed(() => {
const EVENT = computed(() => destination.value.event)
-const collision = computed(() => Object.values(CTA_DESTINATIONS).find(dest => normalizeHref(dest.href) === normalizeHref(HREF.value)))
+const collision = computed(() => {
+ const key = ctaDestinationKey(HREF.value)
+ return key && CTA_DESTINATIONS[key]
+})
if (collision.value) {
throw new Error(`CtaCustom cannot point at "${HREF.value}" - use <${collision.value.component}> instead, so PostHog keeps grouping this destination under one event name.`)
}
diff --git a/nuxt/components/CtaLink.vue b/nuxt/components/CtaLink.vue
new file mode 100644
index 0000000000..438e01a5de
--- /dev/null
+++ b/nuxt/components/CtaLink.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/nuxt/components/CtaPricing.vue b/nuxt/components/CtaPricing.vue
index 2a353675a1..09c9336fd9 100644
--- a/nuxt/components/CtaPricing.vue
+++ b/nuxt/components/CtaPricing.vue
@@ -2,7 +2,7 @@
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
-import { CTA_DESTINATIONS } from '../lib/cta-destinations'
+import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'
withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
@@ -13,6 +13,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
+ query?: CtaQuery
}>(), { uppercase: undefined })
const DEST = CTA_DESTINATIONS.pricing
@@ -22,5 +23,5 @@ const LABEL = DEST.label
-
+
diff --git a/nuxt/components/CtaSignIn.vue b/nuxt/components/CtaSignIn.vue
index 213de93837..4038f36190 100644
--- a/nuxt/components/CtaSignIn.vue
+++ b/nuxt/components/CtaSignIn.vue
@@ -3,7 +3,7 @@
// where it lives on the page (position) vary per insertion.
// First component to give the Sign In link any tracking at all.
import CtaButton from './cta/CtaButton.vue'
-import { CTA_DESTINATIONS } from '../lib/cta-destinations'
+import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'
withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
@@ -14,6 +14,7 @@ withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
+ query?: CtaQuery
}>(), { uppercase: undefined })
const DEST = CTA_DESTINATIONS.signIn
@@ -23,5 +24,5 @@ const LABEL = DEST.label
-
+
diff --git a/nuxt/components/CtaSignUp.vue b/nuxt/components/CtaSignUp.vue
index fa72b0b838..231a0d1628 100644
--- a/nuxt/components/CtaSignUp.vue
+++ b/nuxt/components/CtaSignUp.vue
@@ -2,7 +2,7 @@
// Fixed destination, copy, and tracked event - only the look (variant) and
// where it lives on the page (position) vary per insertion.
import CtaButton from './cta/CtaButton.vue'
-import { CTA_DESTINATIONS } from '../lib/cta-destinations'
+import { CTA_DESTINATIONS, type CtaQuery } from '../lib/cta-destinations'
const props = withDefaults(defineProps<{
variant: 'primary' | 'primary-outlined' | 'highlight' | 'highlight-outlined' | 'nav-text' | 'ghost'
@@ -13,6 +13,7 @@ const props = withDefaults(defineProps<{
padded?: boolean
preview?: boolean
icon?: string
+ query?: CtaQuery
}>(), { uppercase: undefined })
const DEST = CTA_DESTINATIONS.signUp
@@ -26,5 +27,5 @@ const LABEL = computed(() => NAV_POSITIONS.has(props.position) ? DEST.navLabel :
-
+
diff --git a/nuxt/components/content/CtaImage.vue b/nuxt/components/content/CtaImage.vue
index 86e731bdca..57ab4ae0c5 100644
--- a/nuxt/components/content/CtaImage.vue
+++ b/nuxt/components/content/CtaImage.vue
@@ -2,7 +2,7 @@
// Inline image-as-CTA for blog markdown: ::cta-image{...}
// `cta` is separate from the frontmatter `cta` (only used by BlogPostCta).
import { useCapture } from '../../composables/useCapture'
-import site from '../../data/site.json'
+import type { CTA_DESTINATIONS } from '../../lib/cta-destinations'
const props = defineProps<{
src: string
@@ -25,11 +25,11 @@ const POSITION = 'inline-image'
// Same event as BlogPostCta - cta_type distinguishes the destination, same as there.
const EVENT = 'blog-cta'
-const DESTINATIONS: Record = {
- 'sign-up': { href: `${site.appURL}/account/create`, external: false },
- demo: { href: '/book-demo/', external: true },
- contact: { href: '/contact-us/', external: true },
- pricing: { href: '/pricing', external: true },
+const DESTINATIONS: Record = {
+ 'sign-up': 'signUp',
+ demo: 'bookDemo',
+ contact: 'contactUs',
+ pricing: 'pricing',
}
const destination = computed(() => {
@@ -52,7 +52,7 @@ function onClick () {
-
+
-
+
diff --git a/nuxt/components/content/ProseA.vue b/nuxt/components/content/ProseA.vue
new file mode 100644
index 0000000000..5acd63c5ea
--- /dev/null
+++ b/nuxt/components/content/ProseA.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/nuxt/components/cta/CtaButton.vue b/nuxt/components/cta/CtaButton.vue
index 48bd48b9b2..8c4d7e342a 100644
--- a/nuxt/components/cta/CtaButton.vue
+++ b/nuxt/components/cta/CtaButton.vue
@@ -4,10 +4,12 @@
// directly outside that folder — event name, href, and copy are fixed per
// destination on purpose, so this component only ever handles *how it looks*.
import { useCapture } from '~/composables/useCapture'
+import { withCtaQuery, type CtaQuery } from '~/lib/cta-destinations'
const props = withDefaults(defineProps<{
event: string
href: string
+ query?: CtaQuery
// Whether `href` points at a route Nuxt actually serves. NuxtLink/UButton's
// `to` otherwise treats any same-origin-looking path as an internal Vue
// Router route, so a path Nuxt has no page for 404s on click instead of
@@ -145,7 +147,7 @@ function onClick () {
try it for free`.
+
### Choosing a style
Every component takes the same `variant` prop, which controls the look. Click "Show code" under any example to see (and copy) the exact syntax:
@@ -206,6 +208,7 @@ The dark card above is just to make the white text visible in this doc, use `col
| `plan` | No | Which pricing plan the button belongs to, if relevant (e.g. `edge`, `hub`, `fleet`) — also shows up in analytics |
| `color` | No | Only for `variant="ghost"`: `primary`, `highlight`, or `white` — which text color to use, since a ghost button has no background to imply one |
| `icon` | No | An icon name to show after the button text, e.g. `i-lucide-arrow-right` |
+| `query` | No | URL parameters added to the link, e.g. `:query='{"subject": "Certified Nodes"}'` or utm properties. The destination, button text and tracking stay the same |
### One-off links (`CtaCustom`)
diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs
new file mode 100644
index 0000000000..c2b87e45c9
--- /dev/null
+++ b/nuxt/lib/cta-destinations.test.mjs
@@ -0,0 +1,77 @@
+import { test } from 'node:test'
+import assert from 'node:assert/strict'
+import { readdirSync, readFileSync, statSync } from 'node:fs'
+import { join, relative } from 'node:path'
+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 nuxtDir = fileURLToPath(new URL('..', import.meta.url))
+
+test('matches every way of writing a destination URL', () => {
+ const cases = {
+ 'https://app.flowfuse.com/account/create': 'signUp',
+ 'https://app.flowfuse.com/account/create/?code=RELEASE11': 'signUp',
+ 'https://app.flowfuse.com': 'signIn',
+ 'https://app.flowfuse.com/': 'signIn',
+ '/contact-us': 'contactUs',
+ '/contact-us/?subject=Certified%20Nodes': 'contactUs',
+ 'https://flowfuse.com/contact-us/': 'contactUs',
+ 'http://flowfuse.com/pricing/': 'pricing',
+ 'http://app.flowfuse.com': 'signIn',
+ 'HTTPS://flowfuse.com/pricing/': 'pricing',
+ '/book-demo/?utm_source=blog': 'bookDemo',
+ '/pricing': 'pricing',
+ }
+ for (const [href, key] of Object.entries(cases)) assert.equal(ctaDestinationKey(href), key, href)
+})
+
+test('does not match other URLs', () => {
+ for (const href of ['/pricing/#comparison', '/pricing/request-quote/', 'https://app.flowfuse.com/team/x/', 'pricing/', './pricing.md', '#pricing', '/', 'https://www.mongodb.com/pricing', 'https://example.com/contact-us/', undefined]) {
+ assert.equal(ctaDestinationKey(href), undefined, String(href))
+ }
+})
+
+test('adds a query to a destination without touching the path', () => {
+ assert.equal(withCtaQuery('/contact-us/', { subject: 'FlowFuse Expert for Self-Hosted' }), '/contact-us/?subject=FlowFuse%20Expert%20for%20Self-Hosted')
+ assert.equal(withCtaQuery('https://app.flowfuse.com/account/create', { code: 'RELEASE11', utm_source: 'blog' }), 'https://app.flowfuse.com/account/create?code=RELEASE11&utm_source=blog')
+ assert.equal(withCtaQuery('/pricing/', {}), '/pricing/')
+ assert.equal(withCtaQuery('/pricing/'), '/pricing/')
+})
+
+test('reads the query back out of a link, so rebuilding it gives the same URL', () => {
+ const href = '/contact-us/?subject=FlowFuse%20Expert%20Application%20Building'
+ assert.deepEqual(ctaQuery(href), { subject: 'FlowFuse Expert Application Building' })
+ assert.equal(withCtaQuery('/contact-us/', ctaQuery(href)), href)
+})
+
+test('keeps every value of a repeated query parameter', () => {
+ const href = '/contact-us/?tag=one&tag=two&tag=three'
+ assert.deepEqual(ctaQuery(href), { tag: ['one', 'two', 'three'] })
+ assert.equal(withCtaQuery('/contact-us/', ctaQuery(href)), href)
+})
+
+test('a parameter named like a built-in object property round-trips unchanged', () => {
+ const href = '/pricing/?toString=a&constructor=b&__proto__=c'
+ assert.equal(withCtaQuery('/pricing/', ctaQuery(href)), href)
+})
+
+test('custom CTA registry has no duplicate URLs and none of the five reserved destinations', async () => {
+ await jiti.import('./custom-cta-destinations.ts')
+})
+
+const vueFiles = dir => readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name)
+ if (['.nuxt', '.output', 'node_modules', 'public'].includes(name)) return []
+ return statSync(path).isDirectory() ? vueFiles(path) : path.endsWith('.vue') ? [path] : []
+})
+
+test('no .vue file hard-codes a link to a CTA destination', () => {
+ const offenders = vueFiles(nuxtDir).flatMap(file => readFileSync(file, 'utf8').split('\n').flatMap((line, i) =>
+ [...line.matchAll(/\b(?:href|to)\s*[:=]\s*(["'])(.*?)\1/g)]
+ .filter(([, , href]) => ctaDestinationKey(href))
+ .map(([, , href]) => `${relative(nuxtDir, file)}:${i + 1} ${href}`),
+ ))
+ assert.deepEqual(offenders, [], 'Link to a CTA destination with or the matching Cta* button, so the click is tracked under that destination\'s event.')
+})
diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts
index 8a6ff03600..ea0c0199df 100644
--- a/nuxt/lib/cta-destinations.ts
+++ b/nuxt/lib/cta-destinations.ts
@@ -1,8 +1,6 @@
import site from '../data/site.json'
-// Shared by CtaCustom.vue and custom-cta-destinations.ts, both of which
-// compare hrefs for equality - a trailing slash shouldn't make two otherwise
-// identical URLs count as different destinations.
+// A trailing slash shouldn't make two otherwise identical URLs count as different destinations.
export const normalizeHref = (href: string) => href.replace(/\/+$/, '')
// Single source of truth for the five reserved CTA destinations' event name,
@@ -47,3 +45,36 @@ export const CTA_DESTINATIONS = {
label: 'View Pricing',
},
} as const
+
+const DESTINATION_KEY_BY_HREF = new Map(
+ (Object.keys(CTA_DESTINATIONS) as (keyof typeof CTA_DESTINATIONS)[])
+ .map(key => [normalizeHref(new URL(CTA_DESTINATIONS[key].href, site.baseURL).href), key]),
+)
+
+export function ctaDestinationKey (href?: string) {
+ if (!href?.match(/^(https?:)?\//i)) return undefined
+ try {
+ const url = new URL(href, site.baseURL)
+ if (url.hash) return undefined
+ return DESTINATION_KEY_BY_HREF.get(normalizeHref(url.origin.replace(/^http:/, 'https:') + url.pathname))
+ } catch {
+ return undefined
+ }
+}
+
+export type CtaQuery = Record
+
+export function withCtaQuery (href: string, query?: CtaQuery) {
+ const search = Object.entries(query ?? {})
+ .flatMap(([name, values]) => [values].flat().map(value => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`))
+ .join('&')
+ return search ? `${href}?${search}` : href
+}
+
+export function ctaQuery (href: string): CtaQuery {
+ const query: CtaQuery = Object.create(null)
+ for (const [name, value] of new URL(href, site.baseURL).searchParams) {
+ query[name] = name in query ? [query[name]].flat().concat(value) : value
+ }
+ return { ...query }
+}
diff --git a/nuxt/lib/custom-cta-destinations.ts b/nuxt/lib/custom-cta-destinations.ts
index 5c01bfabcc..a715b21744 100644
--- a/nuxt/lib/custom-cta-destinations.ts
+++ b/nuxt/lib/custom-cta-destinations.ts
@@ -1,4 +1,4 @@
-import { CTA_DESTINATIONS, normalizeHref } from './cta-destinations'
+import { CTA_DESTINATIONS, ctaDestinationKey, normalizeHref } from './cta-destinations'
// Registry for one-off CTA destinations that aren't one of the five reserved
// ones (see cta-destinations.ts) but still deserve one fixed PostHog event -
@@ -73,32 +73,22 @@ export const CUSTOM_CTA_DESTINATIONS = {
},
} as const
-// Self-check, run once when this module loads (so at build/dev-start time,
-// before any page renders and regardless of whether anything uses the new
-// entry yet): CtaCustom's own checks stop a *caller* from mismatching an
-// href and a key, but nothing stopped a second entry from being added HERE
-// with the same href as an existing one - reuse the existing key instead of
-// adding a new one for a URL already registered.
-//
-// Also checked against the five RESERVED destinations (cta-destinations.ts):
-// without this, adding a custom entry whose href duplicates e.g. bookDemo's
-// would load fine and only fail later, if and when some page actually
-// rendered a with that key - CtaCustom's own render-time guard
-// would still catch it then, but only for a key that's actually used
-// somewhere, not the instant the bad entry is registered.
-//
-// Uses the same normalizeHref CtaCustom.vue uses for its own
-// reserved-destination check - without it, "/book-demo/" and "/book-demo"
-// would count as different URLs and slip past this check.
-const hrefOwners = new Map(
- Object.values(CTA_DESTINATIONS).map(dest => [normalizeHref(dest.href), dest.component]),
-)
+// Self-check, run when this module loads: a second entry must not reuse a URL another
+// entry already registered, and no entry may point at one of the five reserved
+// destinations (cta-destinations.ts), matched with ctaDestinationKey - the same rule
+// CtaCustom, CtaLink and ProseA use, so a query string or absolute flowfuse.com URL
+// doesn't slip past. A hash is a section of the page and counts as its own destination. nuxt/lib/cta-destinations.test.mjs loads this module, so a bad
+// entry fails `npm test`.
+const hrefOwners = new Map()
for (const [key, dest] of Object.entries(CUSTOM_CTA_DESTINATIONS)) {
if (!('href' in dest) || !dest.href) continue
- const normalizedHref = normalizeHref(dest.href)
- const owner = hrefOwners.get(normalizedHref)
+ const reserved = ctaDestinationKey(dest.href)
+ if (reserved) {
+ throw new Error(`custom-cta-destinations.ts: "${key}" points at "${dest.href}", one of the five reserved CTA destinations - use <${CTA_DESTINATIONS[reserved].component}> or instead.`)
+ }
+ const owner = hrefOwners.get(normalizeHref(dest.href))
if (owner) {
throw new Error(`custom-cta-destinations.ts: "${dest.href}" is registered under both "${owner}" and "${key}" - reuse "${owner}" instead of adding a second key for the same URL.`)
}
- hrefOwners.set(normalizedHref, key)
+ hrefOwners.set(normalizeHref(dest.href), key)
}
diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue
index 2a01f1ca81..3b6eb3eee7 100644
--- a/nuxt/pages/index.vue
+++ b/nuxt/pages/index.vue
@@ -13,7 +13,6 @@
// - site.messaging.heroTagLine and .subtitle came from src/_data/site.json, which Nuxt
// still imports for its own config. They are literals here, so the page reads as the
// page.
-const capture = useCapture()
const METRICS = [
{ number: '50%', text: 'Reduction in scrap rate with real-time operational monitoring' },
@@ -351,16 +350,10 @@ useSchemaOrg([
Speed without governance creates new debt. FlowFuse provides the production layer where AI-assisted work becomes visible, versioned, secure, and reusable, across the enterprise.
-
-
+
Book a demo
-
+
diff --git a/nuxt/pages/integrations/opcua.vue b/nuxt/pages/integrations/opcua.vue
index 69d759eeb8..ac2634fef1 100644
--- a/nuxt/pages/integrations/opcua.vue
+++ b/nuxt/pages/integrations/opcua.vue
@@ -355,7 +355,7 @@ function toggleFaq (i: number) {
FlowFuse connects to OPC UA through a FlowFuse Certified Node built on node-opcua and maintained by Sterfive, the team behind that open-source stack. Certified Nodes are vetted for quality, security, and ongoing support, unlike community packages, which can go unmaintained without warning.
- One node handles both directions: connect to third-party OPC UA servers as a client, or host your own server on self-hosted FlowFuse (not available on FlowFuse Cloud). Both sides share a single certificate store, so a trust decision made for one applies to the other. The node ships through the FlowFuse Edge Certified Nodes catalogue, contact us to enable it for your instance.
+ One node handles both directions: connect to third-party OPC UA servers as a client, or host your own server on self-hosted FlowFuse (not available on FlowFuse Cloud). Both sides share a single certificate store, so a trust decision made for one applies to the other. The node ships through the FlowFuse Edge Certified Nodes catalogue, contact us to enable it for your instance.
diff --git a/nuxt/pages/resources/roi-calculator.vue b/nuxt/pages/resources/roi-calculator.vue
index ad143de296..5702a6ecb8 100644
--- a/nuxt/pages/resources/roi-calculator.vue
+++ b/nuxt/pages/resources/roi-calculator.vue
@@ -11,8 +11,6 @@ function capture (eventName?: string, props?: Record
) {
}
}
-const DEMO_URL = '/book-demo/'
-
const evidence = [
{ stat: '~20% of the work-week', claim: 'is lost searching for internal information.', source: 'McKinsey Global Institute — The Social Economy (2012)', url: 'https://www.mckinsey.com/industries/technology-media-and-telecommunications/our-insights/the-social-economy' },
{ stat: '17.3 hrs / week', claim: 'of developer time goes to maintenance & technical debt — ~42% of the week.', source: 'Stripe — The Developer Coefficient (2018)', url: 'https://stripe.com/files/reports/the-developer-coefficient.pdf' },
@@ -154,7 +152,7 @@ useSchemaOrg([
1 · Waste elimination. Engineers lose ~20% of the week finding information (McKinsey). We recover the share you set (default 30%): engineers × salary × 0.20 × recovery.
2 · Speed to deploy. Building from scratch and hand-deploying is labor you can reclaim with reuse and pipelines: (apps × hrs/app × reuse% + deploys × hrs/deploy × pipeline%) × hourly rate. Reuse gains are grounded in Lim (40–57%); deployment automation in DORA.
3 · Fault tolerance. Slow recovery means idle machines and engineers: incidents × downtime hrs × cost/hr × avoided%. The $125k/hr industry average (ABB) is the ceiling; we default far lower.
- Net & payback. A representative FlowFuse package cost is subtracted from gross savings to show net savings, an ROI multiple, and a payback window. It’s an estimate for comparison — see FlowFuse pricing for what each product includes, or book a demo for an exact quote.
+ Net & payback. A representative FlowFuse package cost is subtracted from gross savings to show net savings, an ROI multiple, and a payback window. It’s an estimate for comparison — see FlowFuse pricing for what each product includes, or book a demo for an exact quote.
diff --git a/nuxt/utils/remark-site-links.ts b/nuxt/utils/remark-site-links.ts
index 5202f778d8..0d8038c5f8 100644
--- a/nuxt/utils/remark-site-links.ts
+++ b/nuxt/utils/remark-site-links.ts
@@ -4,7 +4,8 @@ import type { VFile } from 'vfile'
import { useResolveHref } from '../composables/useResolveHref'
import { CTA_DESTINATIONS } from '../lib/cta-destinations'
-// [text](site:appURL) resolves against site.json, [text](cta:signUp) against CTA_DESTINATIONS.
+// [text](site:appURL) resolves against site.json, [text](cta:signUp) against CTA_DESTINATIONS,
+// with an optional query string: [text](cta:contactUs?subject=Certified%20Nodes).
export default function remarkSiteLinks() {
const resolveHref = useResolveHref()
@@ -12,7 +13,11 @@ export default function remarkSiteLinks() {
visit(tree, 'link', (node) => {
let href: unknown = node.url
if (node.url.startsWith('site:')) href = resolveHref(node.url)
- else if (node.url.startsWith('cta:')) href = CTA_DESTINATIONS[node.url.slice('cta:'.length) as keyof typeof CTA_DESTINATIONS]?.href
+ else if (node.url.startsWith('cta:')) {
+ const [key, search] = node.url.slice('cta:'.length).split(/\?(.*)/s)
+ const destination = CTA_DESTINATIONS[key as keyof typeof CTA_DESTINATIONS]?.href
+ href = destination && search ? `${destination}?${search}` : destination
+ }
else return
if (typeof href !== 'string') {