From bbe1cf8e85ad57b8eb03745ae21e981ba22a9755 Mon Sep 17 00:00:00 2001 From: Yndira-E Date: Thu, 24 Sep 2026 17:55:21 +0200 Subject: [PATCH 01/11] Track inline markdown links to CTA destinations Every markdown link renders through a ProseA wrapper that fires the destination's cta-* event with position inline-link when its href is one of the five CTA destinations. Link text stays free-form. --- .claude/CLAUDE.md | 6 ++++++ nuxt/components/content/ProseA.vue | 23 ++++++++++++++++++++++ nuxt/content/handbook/marketing/website.md | 2 ++ nuxt/lib/cta-destinations.ts | 14 +++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 nuxt/components/content/ProseA.vue diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 0c880342c7..01d56d6514 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -347,6 +347,12 @@ There's no `size` prop — every real-button variant's padding/font-size is hard Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composables/useCapture.ts`, which wraps the global `window.capture()` from `src/_includes/analytics/body.html` (shared with 11ty, no-ops without analytics consent). Event names: `cta-sign-up`, `cta-sign-in`, `cta-contact-us`, `cta-book-demo`, `cta-pricing`. +### Inline links in markdown + +Inline links keep free-form text (it has to fit the sentence), so they aren't `Cta*` buttons. Instead, every markdown link on a Nuxt content page renders through `nuxt/components/content/ProseA.vue` (a wrapper around Nuxt UI's own `ProseA`, so styling is unchanged), and when its href is one of the five destinations it fires that destination's event with `{ position: 'inline-link' }`. Matching is `ctaDestinationForHref()` in `cta-destinations.ts`: trailing slash, query string and hash are ignored, and `https://flowfuse.com/...` counts the same as a relative path. 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 with no markup change. `cta:` (resolved by `nuxt/utils/remark-site-links.ts`) is still the preferred way to write the href. + +This uses 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. `CtaImage`'s `blog-cta` is unaffected. Links to `CUSTOM_CTA_DESTINATIONS` hrefs are not matched. + ### 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/content/ProseA.vue b/nuxt/components/content/ProseA.vue new file mode 100644 index 0000000000..73462938aa --- /dev/null +++ b/nuxt/components/content/ProseA.vue @@ -0,0 +1,23 @@ +<template> + <UiProseA :href="href" :target="target" @click="onClick"> + <slot /> + </UiProseA> +</template> + +<script setup lang="ts"> +import UiProseA from '@nuxt/ui/components/prose/A.vue' +import { ctaDestinationForHref } from '../../lib/cta-destinations' +import { useCapture } from '../../composables/useCapture' + +const props = defineProps<{ + href?: string + target?: string +}>() + +const capture = useCapture() +const destination = computed(() => ctaDestinationForHref(props.href)) + +function onClick () { + if (destination.value) capture(destination.value.event, { position: 'inline-link' }) +} +</script> diff --git a/nuxt/content/handbook/marketing/website.md b/nuxt/content/handbook/marketing/website.md index 46177bda39..3228088c62 100644 --- a/nuxt/content/handbook/marketing/website.md +++ b/nuxt/content/handbook/marketing/website.md @@ -144,6 +144,8 @@ Contact Us vs. Book a Demo: these two CTAs carry different intent signals and sh If a page needs different wording than what's listed above, that's a sign the destination needs a sixth CTA, not a new prop on these five or custom inline code. +**Inline links are tracked too.** A link inside a sentence can use whatever wording fits the text: "sign up for a free trial", "contact us", and so on. If it points at one of the five destinations above, it reports the same analytics event as the matching button, with `inline-link` as its position, on every Nuxt page. To avoid typing the URL, write the destination's name instead: `[try it for free](cta:signUp)`. The names are `signUp`, `signIn`, `contactUs`, `bookDemo` and `pricing`. + **These components only exist on Nuxt-rendered pages** (`nuxt/pages/`, `nuxt/content/`), part of the site is still served by Eleventy and doesn't have access to them yet. On an Eleventy page, a CTA is still a hand-written `<a class="ff-btn ...">` link. ### Choosing a style diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index 81156c986a..ac7d28815b 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -47,3 +47,17 @@ export const CTA_DESTINATIONS = { label: 'View Pricing', }, } as const + +const DESTINATION_BY_HREF = new Map( + Object.values(CTA_DESTINATIONS).map(dest => [normalizeHref(new URL(dest.href, site.baseURL).href), dest]), +) + +export function ctaDestinationForHref (href?: string) { + if (!href?.match(/^(https?:)?\//)) return undefined + try { + const url = new URL(href, site.baseURL) + return DESTINATION_BY_HREF.get(normalizeHref(url.origin + url.pathname)) + } catch { + return undefined + } +} From e4d77601d89f041fbafcfe74a84e19b7f6fa3eb5 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 18:23:38 +0200 Subject: [PATCH 02/11] Add CtaLink for inline CTA links in .vue pages ProseA renders CtaLink for markdown links to a CTA destination, so both share one component. Migrates the inline links on the homepage, OPC UA and ROI calculator pages. --- .claude/CLAUDE.md | 14 ++++++--- nuxt/components/CtaLink.vue | 35 ++++++++++++++++++++++ nuxt/components/content/ProseA.vue | 15 ++++------ nuxt/content/handbook/marketing/website.md | 2 +- nuxt/lib/cta-destinations.ts | 9 +++--- nuxt/pages/index.vue | 11 ++----- nuxt/pages/integrations/opcua.vue | 2 +- nuxt/pages/resources/roi-calculator.vue | 4 +-- 8 files changed, 61 insertions(+), 31 deletions(-) create mode 100644 nuxt/components/CtaLink.vue diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 01d56d6514..b89f84d5eb 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -341,17 +341,23 @@ This exists because a raw `event` prop would let two different `CtaCustom` insta - `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. -`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 `src/_includes/analytics/body.html` (shared with 11ty, no-ops without analytics consent). Event names: `cta-sign-up`, `cta-sign-in`, `cta-contact-us`, `cta-book-demo`, `cta-pricing`. -### Inline links in markdown +### Inline links -Inline links keep free-form text (it has to fit the sentence), so they aren't `Cta*` buttons. Instead, every markdown link on a Nuxt content page renders through `nuxt/components/content/ProseA.vue` (a wrapper around Nuxt UI's own `ProseA`, so styling is unchanged), and when its href is one of the five destinations it fires that destination's event with `{ position: 'inline-link' }`. Matching is `ctaDestinationForHref()` in `cta-destinations.ts`: trailing slash, query string and hash are ignored, and `https://flowfuse.com/...` counts the same as a relative path. 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 with no markup change. `cta:<key>` (resolved by `nuxt/utils/remark-site-links.ts`) is still the preferred way to write the href. +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: 'text' })` and renders a class-less link, so it takes the page's default link CSS unless the caller passes a class. An optional `href` keeps a query string or hash (`?code=...`, `?subject=...`), and it throws if that href points at a different destination. -This uses 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. `CtaImage`'s `blog-cta` is unaffected. Links to `CUSTOM_CTA_DESTINATIONS` hrefs are not matched. +```vue +<CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink> +``` + +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`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash, the query string and the hash, and `https://flowfuse.com/...` counts the same as a relative path. 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. + +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. `CtaImage`'s `blog-cta` is unaffected. 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. ### Gotchas already solved here (don't re-discover them) diff --git a/nuxt/components/CtaLink.vue b/nuxt/components/CtaLink.vue new file mode 100644 index 0000000000..ec1478104d --- /dev/null +++ b/nuxt/components/CtaLink.vue @@ -0,0 +1,35 @@ +<script setup lang="ts"> +import UiProseA from '@nuxt/ui/components/prose/A.vue' +import { CTA_DESTINATIONS, ctaDestinationKey } from '../lib/cta-destinations' +import { useCapture } from '../composables/useCapture' + +const props = defineProps<{ + destination: keyof typeof CTA_DESTINATIONS + position: string + href?: string + target?: string + prose?: boolean +}>() + +const dest = computed(() => { + const match = CTA_DESTINATIONS[props.destination] + if (!match) throw new Error(`CtaLink: invalid destination "${props.destination}" - must be one of: ${Object.keys(CTA_DESTINATIONS).join(', ')}`) + if (props.href && ctaDestinationKey(props.href) !== props.destination) throw new Error(`CtaLink: href "${props.href}" doesn't point at ${props.destination} (${match.href})`) + return match +}) +const linkHref = computed(() => props.href || dest.value.href) +const capture = useCapture() + +function onClick () { + capture(dest.value.event, { position: props.position, variant: 'text' }) +} +</script> + +<template> + <UiProseA v-if="prose" :href="linkHref" :target="target" @click="onClick"> + <slot /> + </UiProseA> + <ULink v-else :href="linkHref" :target="target" raw @click="onClick"> + <slot /> + </ULink> +</template> diff --git a/nuxt/components/content/ProseA.vue b/nuxt/components/content/ProseA.vue index 73462938aa..4850a7724a 100644 --- a/nuxt/components/content/ProseA.vue +++ b/nuxt/components/content/ProseA.vue @@ -1,23 +1,20 @@ <template> - <UiProseA :href="href" :target="target" @click="onClick"> + <CtaLink v-if="destination" :destination="destination" :href="href" :target="target" position="inline-link" prose> + <slot /> + </CtaLink> + <UiProseA v-else :href="href" :target="target"> <slot /> </UiProseA> </template> <script setup lang="ts"> import UiProseA from '@nuxt/ui/components/prose/A.vue' -import { ctaDestinationForHref } from '../../lib/cta-destinations' -import { useCapture } from '../../composables/useCapture' +import { ctaDestinationKey } from '../../lib/cta-destinations' const props = defineProps<{ href?: string target?: string }>() -const capture = useCapture() -const destination = computed(() => ctaDestinationForHref(props.href)) - -function onClick () { - if (destination.value) capture(destination.value.event, { position: 'inline-link' }) -} +const destination = computed(() => ctaDestinationKey(props.href)) </script> diff --git a/nuxt/content/handbook/marketing/website.md b/nuxt/content/handbook/marketing/website.md index 3228088c62..981ae579a0 100644 --- a/nuxt/content/handbook/marketing/website.md +++ b/nuxt/content/handbook/marketing/website.md @@ -144,7 +144,7 @@ Contact Us vs. Book a Demo: these two CTAs carry different intent signals and sh If a page needs different wording than what's listed above, that's a sign the destination needs a sixth CTA, not a new prop on these five or custom inline code. -**Inline links are tracked too.** A link inside a sentence can use whatever wording fits the text: "sign up for a free trial", "contact us", and so on. If it points at one of the five destinations above, it reports the same analytics event as the matching button, with `inline-link` as its position, on every Nuxt page. To avoid typing the URL, write the destination's name instead: `[try it for free](cta:signUp)`. The names are `signUp`, `signIn`, `contactUs`, `bookDemo` and `pricing`. +**Inline links are tracked too.** A link inside a sentence can use whatever wording fits the text: "sign up for a free trial", "contact us", and so on. If it points at one of the five destinations above, it reports the same analytics event as the matching button, with `inline-link` as its position, on every Nuxt page. To avoid typing the URL, write the destination's name instead: `[try it for free](cta:signUp)`. The names are `signUp`, `signIn`, `contactUs`, `bookDemo` and `pricing`. In a `.vue` page, the same link is `<CtaLink destination="signUp" position="...">try it for free</CtaLink>`. **These components only exist on Nuxt-rendered pages** (`nuxt/pages/`, `nuxt/content/`), part of the site is still served by Eleventy and doesn't have access to them yet. On an Eleventy page, a CTA is still a hand-written `<a class="ff-btn ...">` link. diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index ac7d28815b..56df7b0890 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -48,15 +48,16 @@ export const CTA_DESTINATIONS = { }, } as const -const DESTINATION_BY_HREF = new Map( - Object.values(CTA_DESTINATIONS).map(dest => [normalizeHref(new URL(dest.href, site.baseURL).href), dest]), +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 ctaDestinationForHref (href?: string) { +export function ctaDestinationKey (href?: string) { if (!href?.match(/^(https?:)?\//)) return undefined try { const url = new URL(href, site.baseURL) - return DESTINATION_BY_HREF.get(normalizeHref(url.origin + url.pathname)) + return DESTINATION_KEY_BY_HREF.get(normalizeHref(url.origin + url.pathname)) } catch { return undefined } diff --git a/nuxt/pages/index.vue b/nuxt/pages/index.vue index db7096de78..c84d825834 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([ </div> <p class="text-gray-700 font-light m-0">Speed without governance creates new debt. FlowFuse provides the production layer where AI-assisted work becomes visible, versioned, secure, and reusable, across the enterprise.</p> </div> - <!-- No plain-text-link variant exists in the shared Cta* system (CLAUDE.md - reserves `text` for that), and ghost is too heavy (bold/uppercase/button - padding) for this inline sentence-style link. Hand-written until that - variant exists, but firing the same event and props the real - CtaBookDemo would, tagged variant="text" so it is identifiable in - PostHog as this one-off style. --> - <NuxtLink to="/book-demo/" class="group hover:no-underline flex items-center justify-end gap-1.5 text-indigo-600 mt-6" @click="capture('cta-book-demo', { position: 'ai', variant: 'text' })"> + <CtaLink destination="bookDemo" position="ai" class="group hover:no-underline flex items-center justify-end gap-1.5 text-indigo-600 mt-6"> <span class="group-hover:underline">Book a demo</span> <span class="w-5 h-5 shrink-0 flex items-center [&>svg]:w-full [&>svg]:h-full"><UIcon name="i-heroicons-arrow-long-right-solid" /></span> - </NuxtLink> + </CtaLink> </div> </div> </div> diff --git a/nuxt/pages/integrations/opcua.vue b/nuxt/pages/integrations/opcua.vue index 357332e0dc..0261a594d8 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 <strong>FlowFuse Certified Node</strong> built on <strong>node-opcua</strong> and maintained by <strong>Sterfive</strong>, 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. </p> <p> - 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, <a href="/contact-us/" class="text-indigo-600 hover:underline">contact us</a> 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, <CtaLink destination="contactUs" position="mid-page" class="text-indigo-600 hover:underline">contact us</CtaLink> to enable it for your instance. </p> </div> <div class="mt-8"> 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<string, unknown>) { } } -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([ <p><b>1 · Waste elimination.</b> Engineers lose ~20% of the week <a class="text-indigo-600 hover:underline" href="/use-cases/uns/">finding information</a> (McKinsey). We recover the share you set (default 30%): <code>engineers × salary × 0.20 × recovery</code>.</p> <p><b>2 · Speed to deploy.</b> Building from scratch and hand-deploying is labor you can reclaim with <a class="text-indigo-600 hover:underline" href="/blueprints/">reuse</a> and <a class="text-indigo-600 hover:underline" href="/blog/2024/10/how-to-build-automate-devops-pipelines-node-red-deployments/">pipelines</a>: <code>(apps × hrs/app × reuse% + deploys × hrs/deploy × pipeline%) × hourly rate</code>. Reuse gains are grounded in Lim (40–57%); deployment automation in DORA.</p> <p><b>3 · Fault tolerance.</b> <a class="text-indigo-600 hover:underline" href="/blog/2025/12/mttf-vs-mtbf-vs-mttr/">Slow recovery</a> means idle machines and engineers: <code>incidents × downtime hrs × cost/hr × avoided%</code>. The $125k/hr industry average (ABB) is the ceiling; we default far lower.</p> - <p><b>Net & payback.</b> 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 <a href="/pricing/">FlowFuse pricing</a> for what each product includes, or <a :href="DEMO_URL">book a demo</a> for an exact quote.</p> + <p><b>Net & payback.</b> 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 <CtaLink destination="pricing" position="methodology">FlowFuse pricing</CtaLink> for what each product includes, or <CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink> for an exact quote.</p> </div> </div> </div> From ca094cd8488206f1861907c815e6e69b14092324 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 18:59:08 +0200 Subject: [PATCH 03/11] Render CtaImage through CtaLink Image CTAs now take their href from CTA_DESTINATIONS and fire the destination's cta-* event (variant image) alongside blog-cta. --- .claude/CLAUDE.md | 6 +++--- nuxt/components/CtaLink.vue | 7 ++++--- nuxt/components/content/CtaImage.vue | 16 ++++++++-------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b89f84d5eb..e79d9af75f 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -139,7 +139,7 @@ Tag options are defined in `src/_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`. --- @@ -349,7 +349,7 @@ Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composa ### 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: 'text' })` and renders a class-less link, so it takes the page's default link CSS unless the caller passes a class. An optional `href` keeps a query string or hash (`?code=...`, `?subject=...`), and it throws if that href points at a different destination. +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. An optional `href` keeps a query string or hash (`?code=...`, `?subject=...`), and it throws if that href points at a different destination. ```vue <CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink> @@ -357,7 +357,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`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash, the query string and the hash, and `https://flowfuse.com/...` counts the same as a relative path. 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. -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. `CtaImage`'s `blog-cta` is unaffected. 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. FAQ answers (`nuxt/lib/faq-answer.mjs`, rendered with `v-html`) can't hold a component, so their links aren't tracked. ### Gotchas already solved here (don't re-discover them) diff --git a/nuxt/components/CtaLink.vue b/nuxt/components/CtaLink.vue index ec1478104d..7e2a2fd3fb 100644 --- a/nuxt/components/CtaLink.vue +++ b/nuxt/components/CtaLink.vue @@ -3,13 +3,14 @@ import UiProseA from '@nuxt/ui/components/prose/A.vue' import { CTA_DESTINATIONS, ctaDestinationKey } from '../lib/cta-destinations' import { useCapture } from '../composables/useCapture' -const props = defineProps<{ +const props = withDefaults(defineProps<{ destination: keyof typeof CTA_DESTINATIONS position: string + variant?: 'text' | 'image' href?: string target?: string prose?: boolean -}>() +}>(), { variant: 'text' }) const dest = computed(() => { const match = CTA_DESTINATIONS[props.destination] @@ -21,7 +22,7 @@ const linkHref = computed(() => props.href || dest.value.href) const capture = useCapture() function onClick () { - capture(dest.value.event, { position: props.position, variant: 'text' }) + capture(dest.value.event, { position: props.position, variant: props.variant }) } </script> diff --git a/nuxt/components/content/CtaImage.vue b/nuxt/components/content/CtaImage.vue index 38cd998443..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 '../../../src/_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<string, { href: string, external: boolean }> = { - '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<string, keyof typeof CTA_DESTINATIONS> = { + 'sign-up': 'signUp', + demo: 'bookDemo', + contact: 'contactUs', + pricing: 'pricing', } const destination = computed(() => { @@ -52,7 +52,7 @@ function onClick () { </script> <template> - <a class="mb-4 block" :href="destination.href" @click="onClick"> + <CtaLink :destination="destination" :position="POSITION" variant="image" class="mb-4 block" @click="onClick"> <NuxtImg :src="src" :alt="alt" /> - </a> + </CtaLink> </template> From b95944585d9724b38e73afcd2410eec7c96bf322 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 20:43:32 +0200 Subject: [PATCH 04/11] Catch every URL variant of a CTA destination in the CtaCustom guards CtaCustom and the custom destination registry now match reserved destinations with ctaDestinationKey, so a query string, hash or absolute URL no longer slips past. A new test loads the registry and fails npm test when a .vue file hard-codes a link to one of the five destinations, since a render-time throw doesn't fail the build. --- .claude/CLAUDE.md | 7 ++-- nuxt/components/CtaCustom.vue | 7 ++-- nuxt/lib/cta-destinations.test.mjs | 50 +++++++++++++++++++++++++++++ nuxt/lib/cta-destinations.ts | 4 +-- nuxt/lib/custom-cta-destinations.ts | 38 ++++++++-------------- 5 files changed, 75 insertions(+), 31 deletions(-) create mode 100644 nuxt/lib/cta-destinations.test.mjs diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index e79d9af75f..b9e7e72625 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -336,10 +336,11 @@ 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 `<CtaCustom>`. +- **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 `<CtaCustom>`. - **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, hash or absolute `https://flowfuse.com/...` URL counts as the same destination. +- 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. Inline links are `CtaLink` (see **Inline links** below), not a `CtaButton` variant. @@ -359,6 +360,8 @@ Markdown needs no markup for this: `nuxt/components/content/ProseA.vue` renders 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. +`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/CtaCustom.vue b/nuxt/components/CtaCustom.vue index e48678af6a..0defd6b3de 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/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs new file mode 100644 index 0000000000..51f35ba8a5 --- /dev/null +++ b/nuxt/lib/cta-destinations.test.mjs @@ -0,0 +1,50 @@ +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 } = 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', + '/book-demo/#calendar': '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/request-quote/', 'https://app.flowfuse.com/team/x/', 'pricing/', './pricing.md', '#pricing', '/', 'https://www.mongodb.com/pricing', undefined]) { + assert.equal(ctaDestinationKey(href), undefined, String(href)) + } +}) + +test('no custom CTA destination points at one 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 <CtaLink destination="..."> 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 56df7b0890..0de9eb1485 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -1,8 +1,6 @@ import site from '../../src/_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, diff --git a/nuxt/lib/custom-cta-destinations.ts b/nuxt/lib/custom-cta-destinations.ts index 5c01bfabcc..a835144ab2 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 <CtaCustom> 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<string, string>( - 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, hash or absolute flowfuse.com +// URL doesn't slip past. nuxt/lib/cta-destinations.test.mjs loads this module, so a bad +// entry fails `npm test`. +const hrefOwners = new Map<string, string>() 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 <CtaLink destination="${reserved}"> 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) } From a79eea49ffff951bc5a62e2043a8c2304b278d46 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 20:46:33 +0200 Subject: [PATCH 05/11] Count a hash link as its own destination A hash points at a section of the page, so /pricing/#comparison is no longer treated as the reserved pricing destination. Query strings still are. --- .claude/CLAUDE.md | 6 +++--- nuxt/lib/cta-destinations.test.mjs | 4 ++-- nuxt/lib/cta-destinations.ts | 1 + nuxt/lib/custom-cta-destinations.ts | 4 ++-- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b9e7e72625..c37ff1d33e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -339,7 +339,7 @@ This exists because a raw `event` prop would let two different `CtaCustom` insta - **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 `<CtaCustom>`. - **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. Both checks match reserved destinations with `ctaDestinationKey()` (see **Inline links**), so a query string, hash or absolute `https://flowfuse.com/...` URL counts as the same destination. +- 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. Inline links are `CtaLink` (see **Inline links** below), not a `CtaButton` variant. @@ -350,13 +350,13 @@ Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composa ### 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. An optional `href` keeps a query string or hash (`?code=...`, `?subject=...`), and it throws if that href points at a different destination. +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. An optional `href` keeps a query string (`?code=...`, `?subject=...`), and it throws if that href points at a different destination. ```vue <CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink> ``` -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`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash, the query string and the hash, and `https://flowfuse.com/...` counts the same as a relative path. 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. +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`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash and the query string, and `https://flowfuse.com/...` counts the same as a relative path. 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. 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. diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs index 51f35ba8a5..822b0354d0 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -18,14 +18,14 @@ test('matches every way of writing a destination URL', () => { '/contact-us': 'contactUs', '/contact-us/?subject=Certified%20Nodes': 'contactUs', 'https://flowfuse.com/contact-us/': 'contactUs', - '/book-demo/#calendar': 'bookDemo', + '/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/request-quote/', 'https://app.flowfuse.com/team/x/', 'pricing/', './pricing.md', '#pricing', '/', 'https://www.mongodb.com/pricing', undefined]) { + for (const href of ['/pricing/#comparison', '/pricing/request-quote/', 'https://app.flowfuse.com/team/x/', 'pricing/', './pricing.md', '#pricing', '/', 'https://www.mongodb.com/pricing', undefined]) { assert.equal(ctaDestinationKey(href), undefined, String(href)) } }) diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index 0de9eb1485..d4552d6f7c 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -55,6 +55,7 @@ export function ctaDestinationKey (href?: string) { if (!href?.match(/^(https?:)?\//)) return undefined try { const url = new URL(href, site.baseURL) + if (url.hash) return undefined return DESTINATION_KEY_BY_HREF.get(normalizeHref(url.origin + url.pathname)) } catch { return undefined diff --git a/nuxt/lib/custom-cta-destinations.ts b/nuxt/lib/custom-cta-destinations.ts index a835144ab2..a715b21744 100644 --- a/nuxt/lib/custom-cta-destinations.ts +++ b/nuxt/lib/custom-cta-destinations.ts @@ -76,8 +76,8 @@ export const CUSTOM_CTA_DESTINATIONS = { // 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, hash or absolute flowfuse.com -// URL doesn't slip past. nuxt/lib/cta-destinations.test.mjs loads this module, so a bad +// 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<string, string>() for (const [key, dest] of Object.entries(CUSTOM_CTA_DESTINATIONS)) { From f8241871c76992574868107a37b09388eb60f903 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 20:52:38 +0200 Subject: [PATCH 06/11] Let CTA buttons and links add URL parameters to their destination The five buttons and CtaLink take a query prop (e.g. subject or utm parameters) appended to the fixed href, so a link that needs one no longer has to be hand-written. The path, label and event stay fixed. cta:<key> in markdown accepts a query string too. --- .claude/CLAUDE.md | 6 +++--- nuxt/components/CtaBookDemo.vue | 5 +++-- nuxt/components/CtaContactUs.vue | 5 +++-- nuxt/components/CtaLink.vue | 7 +++---- nuxt/components/CtaPricing.vue | 5 +++-- nuxt/components/CtaSignIn.vue | 5 +++-- nuxt/components/CtaSignUp.vue | 5 +++-- nuxt/components/content/ProseA.vue | 5 +++-- nuxt/components/cta/CtaButton.vue | 4 +++- nuxt/content/handbook/marketing/website.md | 3 ++- nuxt/lib/cta-destinations.test.mjs | 15 ++++++++++++++- nuxt/lib/cta-destinations.ts | 11 +++++++++++ nuxt/utils/remark-site-links.ts | 9 +++++++-- 13 files changed, 61 insertions(+), 24 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c37ff1d33e..2c5620c4e3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -328,7 +328,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-`<ul>` 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-`<ul>` 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): the path, label and event stay fixed, and there's no way to change the path or add a hash. ### CtaCustom — one-off destinations @@ -350,13 +350,13 @@ Click tracking: `capture(event, { position, variant, plan? })` via `nuxt/composa ### 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. An optional `href` keeps a query string (`?code=...`, `?subject=...`), and it throws if that href points at a different destination. +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 <CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink> ``` -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`. `prose` keeps Nuxt UI's prose link styling. Matching ignores the trailing slash and the query string, and `https://flowfuse.com/...` counts the same as a relative path. 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. +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, and `https://flowfuse.com/...` counts the same as a relative path. 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. 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 </script> <template> - <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" /> + <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" :query="query" /> </template> 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 </script> <template> - <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" /> + <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" :query="query" /> </template> diff --git a/nuxt/components/CtaLink.vue b/nuxt/components/CtaLink.vue index 7e2a2fd3fb..438e01a5de 100644 --- a/nuxt/components/CtaLink.vue +++ b/nuxt/components/CtaLink.vue @@ -1,13 +1,13 @@ <script setup lang="ts"> import UiProseA from '@nuxt/ui/components/prose/A.vue' -import { CTA_DESTINATIONS, ctaDestinationKey } from '../lib/cta-destinations' +import { CTA_DESTINATIONS, withCtaQuery, type CtaQuery } from '../lib/cta-destinations' import { useCapture } from '../composables/useCapture' const props = withDefaults(defineProps<{ destination: keyof typeof CTA_DESTINATIONS position: string variant?: 'text' | 'image' - href?: string + query?: CtaQuery target?: string prose?: boolean }>(), { variant: 'text' }) @@ -15,10 +15,9 @@ const props = withDefaults(defineProps<{ const dest = computed(() => { const match = CTA_DESTINATIONS[props.destination] if (!match) throw new Error(`CtaLink: invalid destination "${props.destination}" - must be one of: ${Object.keys(CTA_DESTINATIONS).join(', ')}`) - if (props.href && ctaDestinationKey(props.href) !== props.destination) throw new Error(`CtaLink: href "${props.href}" doesn't point at ${props.destination} (${match.href})`) return match }) -const linkHref = computed(() => props.href || dest.value.href) +const linkHref = computed(() => withCtaQuery(dest.value.href, props.query)) const capture = useCapture() function onClick () { 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 </script> <template> - <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" /> + <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" :query="query" /> </template> 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 </script> <template> - <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" /> + <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" :query="query" /> </template> 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 : </script> <template> - <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" /> + <CtaButton :event="EVENT" :href="HREF" :external="false" :label="LABEL" :variant="variant" :position="position" :plan="plan" :icon="icon" :uppercase="uppercase" :padded="padded" :color="color" :preview="preview" :query="query" /> </template> diff --git a/nuxt/components/content/ProseA.vue b/nuxt/components/content/ProseA.vue index 4850a7724a..5acd63c5ea 100644 --- a/nuxt/components/content/ProseA.vue +++ b/nuxt/components/content/ProseA.vue @@ -1,5 +1,5 @@ <template> - <CtaLink v-if="destination" :destination="destination" :href="href" :target="target" position="inline-link" prose> + <CtaLink v-if="destination" :destination="destination" :query="query" :target="target" position="inline-link" prose> <slot /> </CtaLink> <UiProseA v-else :href="href" :target="target"> @@ -9,7 +9,7 @@ <script setup lang="ts"> import UiProseA from '@nuxt/ui/components/prose/A.vue' -import { ctaDestinationKey } from '../../lib/cta-destinations' +import { ctaDestinationKey, ctaQuery } from '../../lib/cta-destinations' const props = defineProps<{ href?: string @@ -17,4 +17,5 @@ const props = defineProps<{ }>() const destination = computed(() => ctaDestinationKey(props.href)) +const query = computed(() => destination.value ? ctaQuery(props.href!) : undefined) </script> diff --git a/nuxt/components/cta/CtaButton.vue b/nuxt/components/cta/CtaButton.vue index 9ae9743555..076b0965e3 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 destination still on 11ty (e.g. /contact-us/, @@ -145,7 +147,7 @@ function onClick () { <template> <UButton - :to="preview ? undefined : href" + :to="preview ? undefined : withCtaQuery(href, query)" :external="external" :target="target" :color="uiVariant.color" diff --git a/nuxt/content/handbook/marketing/website.md b/nuxt/content/handbook/marketing/website.md index 981ae579a0..1800d73a55 100644 --- a/nuxt/content/handbook/marketing/website.md +++ b/nuxt/content/handbook/marketing/website.md @@ -144,7 +144,7 @@ Contact Us vs. Book a Demo: these two CTAs carry different intent signals and sh If a page needs different wording than what's listed above, that's a sign the destination needs a sixth CTA, not a new prop on these five or custom inline code. -**Inline links are tracked too.** A link inside a sentence can use whatever wording fits the text: "sign up for a free trial", "contact us", and so on. If it points at one of the five destinations above, it reports the same analytics event as the matching button, with `inline-link` as its position, on every Nuxt page. To avoid typing the URL, write the destination's name instead: `[try it for free](cta:signUp)`. The names are `signUp`, `signIn`, `contactUs`, `bookDemo` and `pricing`. In a `.vue` page, the same link is `<CtaLink destination="signUp" position="...">try it for free</CtaLink>`. +**Inline links are tracked too.** A link inside a sentence can use whatever wording fits the text: "sign up for a free trial", "contact us", and so on. If it points at one of the five destinations above, it reports the same analytics event as the matching button, with `inline-link` as its position, on every Nuxt page. To avoid typing the URL, write the destination's name instead: `[try it for free](cta:signUp)`, with parameters if the link needs them: `[contact us](cta:contactUs?subject=Certified%20Nodes)`. The names are `signUp`, `signIn`, `contactUs`, `bookDemo` and `pricing`. In a `.vue` page, the same link is `<CtaLink destination="signUp" position="...">try it for free</CtaLink>`. **These components only exist on Nuxt-rendered pages** (`nuxt/pages/`, `nuxt/content/`), part of the site is still served by Eleventy and doesn't have access to them yet. On an Eleventy page, a CTA is still a hand-written `<a class="ff-btn ...">` link. @@ -233,6 +233,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 index 822b0354d0..bbd15a87cc 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -6,7 +6,7 @@ import { fileURLToPath } from 'node:url' import { createJiti } from 'jiti' const jiti = createJiti(import.meta.url) -const { ctaDestinationKey } = await jiti.import('./cta-destinations.ts') +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', () => { @@ -30,6 +30,19 @@ test('does not match other URLs', () => { } }) +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('no custom CTA destination points at one of the five reserved destinations', async () => { await jiti.import('./custom-cta-destinations.ts') }) diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index d4552d6f7c..5155c4c160 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -61,3 +61,14 @@ export function ctaDestinationKey (href?: string) { return undefined } } + +export type CtaQuery = Record<string, string> + +export function withCtaQuery (href: string, query?: CtaQuery) { + const search = Object.entries(query ?? {}).map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&') + return search ? `${href}?${search}` : href +} + +export function ctaQuery (href: string): CtaQuery { + return Object.fromEntries(new URL(href, site.baseURL).searchParams) +} 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') { From 4cf4d89d851bf2c7dd9e95c656e9a756753648d0 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 20:53:50 +0200 Subject: [PATCH 07/11] Name the registry test for both checks it runs --- nuxt/lib/cta-destinations.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs index bbd15a87cc..fb38595b1c 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -43,7 +43,7 @@ test('reads the query back out of a link, so rebuilding it gives the same URL', assert.equal(withCtaQuery('/contact-us/', ctaQuery(href)), href) }) -test('no custom CTA destination points at one of the five reserved destinations', async () => { +test('custom CTA registry has no duplicate URLs and none of the five reserved destinations', async () => { await jiti.import('./custom-cta-destinations.ts') }) From 8372f475a683164af932b7fe5546a0915cd8d7f9 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 21:26:08 +0200 Subject: [PATCH 08/11] Keep repeated query parameters on CTA links ctaQuery collapsed a repeated parameter (?tag=one&tag=two) to its last value. A query value can now be an array, which withCtaQuery writes back as the repeated parameter. --- .claude/CLAUDE.md | 2 +- nuxt/lib/cta-destinations.test.mjs | 6 ++++++ nuxt/lib/cta-destinations.ts | 12 +++++++++--- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2c5620c4e3..90245ca0b3 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -328,7 +328,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-`<ul>` 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): the path, label and event stay fixed, and there's no way to change the path or add a hash. +**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-`<ul>` 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 diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs index fb38595b1c..dd3575697a 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -43,6 +43,12 @@ test('reads the query back out of a link, so rebuilding it gives the same URL', 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('custom CTA registry has no duplicate URLs and none of the five reserved destinations', async () => { await jiti.import('./custom-cta-destinations.ts') }) diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index 5155c4c160..d5b7ac8e3e 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -62,13 +62,19 @@ export function ctaDestinationKey (href?: string) { } } -export type CtaQuery = Record<string, string> +export type CtaQuery = Record<string, string | string[]> export function withCtaQuery (href: string, query?: CtaQuery) { - const search = Object.entries(query ?? {}).map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&') + 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 { - return Object.fromEntries(new URL(href, site.baseURL).searchParams) + const query: CtaQuery = {} + for (const [name, value] of new URL(href, site.baseURL).searchParams) { + query[name] = name in query ? [query[name]].flat().concat(value) : value + } + return query } From 40b3f8478ecd47fcdac0d595224d1eda61f46047 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 21:29:27 +0200 Subject: [PATCH 09/11] Match http:// links to CTA destinations too A destination link written with http:// was treated as an ordinary link. The comparison now upgrades the protocol but keeps the host, so another site's /pricing/ still doesn't match. --- .claude/CLAUDE.md | 2 +- nuxt/lib/cta-destinations.test.mjs | 4 +++- nuxt/lib/cta-destinations.ts | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 90245ca0b3..895a9d90cf 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -356,7 +356,7 @@ A link inside a sentence keeps free-form text (it has to fit the prose), so it i <CtaLink destination="bookDemo" position="methodology">book a demo</CtaLink> ``` -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, and `https://flowfuse.com/...` counts the same as a relative path. 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)`. +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. diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs index dd3575697a..b9cfd84c2a 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -18,6 +18,8 @@ test('matches every way of writing a destination URL', () => { '/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', '/book-demo/?utm_source=blog': 'bookDemo', '/pricing': 'pricing', } @@ -25,7 +27,7 @@ test('matches every way of writing a destination URL', () => { }) 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', undefined]) { + 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)) } }) diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index d5b7ac8e3e..001f555d72 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -56,7 +56,7 @@ export function ctaDestinationKey (href?: string) { try { const url = new URL(href, site.baseURL) if (url.hash) return undefined - return DESTINATION_KEY_BY_HREF.get(normalizeHref(url.origin + url.pathname)) + return DESTINATION_KEY_BY_HREF.get(normalizeHref(url.origin.replace(/^http:/, 'https:') + url.pathname)) } catch { return undefined } From 6a1d9661f52feda2e7340846d8822ea40402486c Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Thu, 24 Sep 2026 21:41:02 +0200 Subject: [PATCH 10/11] Handle uppercase schemes and built-in property names in CTA links HTTPS://... links now match their destination, and a query parameter named toString, constructor or __proto__ no longer picks up an inherited value. --- nuxt/lib/cta-destinations.test.mjs | 6 ++++++ nuxt/lib/cta-destinations.ts | 6 +++--- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/nuxt/lib/cta-destinations.test.mjs b/nuxt/lib/cta-destinations.test.mjs index b9cfd84c2a..c2b87e45c9 100644 --- a/nuxt/lib/cta-destinations.test.mjs +++ b/nuxt/lib/cta-destinations.test.mjs @@ -20,6 +20,7 @@ test('matches every way of writing a destination URL', () => { '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', } @@ -51,6 +52,11 @@ test('keeps every value of a repeated query parameter', () => { 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') }) diff --git a/nuxt/lib/cta-destinations.ts b/nuxt/lib/cta-destinations.ts index 001f555d72..b58adfd8d7 100644 --- a/nuxt/lib/cta-destinations.ts +++ b/nuxt/lib/cta-destinations.ts @@ -52,7 +52,7 @@ const DESTINATION_KEY_BY_HREF = new Map( ) export function ctaDestinationKey (href?: string) { - if (!href?.match(/^(https?:)?\//)) return undefined + if (!href?.match(/^(https?:)?\//i)) return undefined try { const url = new URL(href, site.baseURL) if (url.hash) return undefined @@ -72,9 +72,9 @@ export function withCtaQuery (href: string, query?: CtaQuery) { } export function ctaQuery (href: string): CtaQuery { - const query: 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 + return { ...query } } From 512d1dfec219eab241dc6b2749dd4030bccbdb19 Mon Sep 17 00:00:00 2001 From: Yndira-E <yndira@flowfuse.com> Date: Fri, 25 Sep 2026 13:03:35 +0200 Subject: [PATCH 11/11] Retrigger CI