From 6612551b3497c011fa8c56088c0cb6a335d144c6 Mon Sep 17 00:00:00 2001 From: sumitshinde-84 Date: Thu, 1 Oct 2026 14:54:30 +0530 Subject: [PATCH 1/5] CtaImage: support custom destinations via cta="custom" and href Reworked on top of #5860: reserved destinations are matched with ctaDestinationKey, and every ::cta-image in the content tree is checked in npm test instead of only at render time. --- .claude/CLAUDE.md | 3 +- nuxt/components/content/CtaImage.vue | 24 ++++----- .../marketing/content-strategy/blog.md | 10 +++- nuxt/lib/cta-image.test.mjs | 53 +++++++++++++++++++ nuxt/lib/cta-image.ts | 25 +++++++++ 5 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 nuxt/lib/cta-image.test.mjs create mode 100644 nuxt/lib/cta-image.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fe49d61152..107276285b 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -125,7 +125,8 @@ Tag options are defined in `nuxt/data/blogTags.json`. Future-dated posts are exc `nuxt/components/content/CtaImage.vue` — a clickable, tracked image usable inside a post's markdown body via `::cta-image{src="..." alt="..." cta="..."}`. Renders through `` on `nuxt/pages/blog/[...slug].vue`, same as any other MDC content component (unlike `nuxt/components/BlogPostCta.vue`, which is a normal template component the page passes `page.title` to directly). -- `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`. +- `cta` is required on every instance, one of `sign-up` | `demo` | `contact` | `pricing` | `custom` — 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`. +- `cta="custom"` requires an `href`; `href` on any other `cta` value throws. A custom `href` that `ctaDestinationKey()` resolves to a reserved destination throws too, pointing at the `cta` value to use instead, so those destinations' clicks don't split across `cta_type` values. A custom image renders a plain link, not `CtaLink`, and its click adds the `href` to the `blog-cta` payload, since `cta_type: custom` alone can't tell them apart. The rules live in `nuxt/lib/cta-image.ts`, and `nuxt/lib/cta-image.test.mjs` checks every `::cta-image` in the content tree with them, so a bad one fails `npm test` rather than only the page render. - `position` isn't a prop — it's hardcoded to `'inline-image'`, since every instance shares the same placement semantics. - 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`. diff --git a/nuxt/components/content/CtaImage.vue b/nuxt/components/content/CtaImage.vue index 57ab4ae0c5..0f0b86ceb3 100644 --- a/nuxt/components/content/CtaImage.vue +++ b/nuxt/components/content/CtaImage.vue @@ -2,12 +2,13 @@ // 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 type { CTA_DESTINATIONS } from '../../lib/cta-destinations' +import { CTA_IMAGE_DESTINATIONS, ctaImageError } from '../../lib/cta-image' const props = defineProps<{ src: string alt: string - cta: 'sign-up' | 'demo' | 'contact' | 'pricing' + cta: 'sign-up' | 'demo' | 'contact' | 'pricing' | 'custom' + href?: string /** * What the event's `reference` should say, for a page that is not a blog post. * @@ -25,17 +26,10 @@ const POSITION = 'inline-image' // Same event as BlogPostCta - cta_type distinguishes the destination, same as there. const EVENT = 'blog-cta' -const DESTINATIONS: Record = { - 'sign-up': 'signUp', - demo: 'bookDemo', - contact: 'contactUs', - pricing: 'pricing', -} - const destination = computed(() => { - const match = DESTINATIONS[props.cta] - if (!match) throw new Error(`CtaImage: invalid cta "${props.cta}" - must be one of: ${Object.keys(DESTINATIONS).join(', ')}`) - return match + const error = ctaImageError(props.cta, props.href) + if (error) throw new Error(`CtaImage: ${error}`) + return props.cta === 'custom' ? undefined : CTA_IMAGE_DESTINATIONS[props.cta] }) const capture = useCapture() @@ -47,12 +41,16 @@ function onClick () { reference: props.reference || `Blog: ${postTitle?.value || ''}`, position: POSITION, cta_type: props.cta, + ...(!destination.value && { href: props.href }), }) } diff --git a/nuxt/content/handbook/marketing/content-strategy/blog.md b/nuxt/content/handbook/marketing/content-strategy/blog.md index 0db238395e..04bf322840 100644 --- a/nuxt/content/handbook/marketing/content-strategy/blog.md +++ b/nuxt/content/handbook/marketing/content-strategy/blog.md @@ -301,11 +301,19 @@ Besides the end-of-article CTA above, you can drop a clickable, tracked image an ``` - `src` and `alt` are required, same as any other blog image. -- `cta` is required on every instance and also sets where the image links to — there's no separate URL to configure. Same four fixed destinations as `cta.type` above: +- `cta` is required on every instance and also sets where the image links to. The same four fixed destinations as `cta.type` above need no URL: - `demo` - links to `/book-demo` - `contact` - links to `/contact-us` - `pricing` - links to `/pricing` - `sign-up` - links to the hosted sign-up URL +- For any other destination, such as a blueprint, another relevant blog post, a docs page, or a product page, use `cta="custom"` with an `href`: + + ```mdc + ::cta-image{src="/blog/2025/12/images/my-image.png" alt="Browse the FlowFuse Blueprint Library" cta="custom" href="/blueprints/"} + :: + ``` + + `href` is only accepted with `cta="custom"`, and a custom `href` can't point at one of the four fixed destinations — use its `cta` value instead, so those clicks stay grouped together in PostHog. The same goes for the sign-in URL. A query string or the full `https://flowfuse.com/...` form still counts as the fixed destination. Breaking either rule fails the build's tests and names the file. Custom clicks are reported with `cta_type: custom` and the `href` they link to. There is no default. `cta` here is independent of the front matter `cta.type` above — a single article can have several `CtaImage` blocks, each pointing at a different destination. diff --git a/nuxt/lib/cta-image.test.mjs b/nuxt/lib/cta-image.test.mjs new file mode 100644 index 0000000000..6aede535e0 --- /dev/null +++ b/nuxt/lib/cta-image.test.mjs @@ -0,0 +1,53 @@ +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 { ctaImageError } = await jiti.import('./cta-image.ts') +const nuxtDir = fileURLToPath(new URL('..', import.meta.url)) + +test('accepts the four fixed destinations without an href', () => { + for (const cta of ['sign-up', 'demo', 'contact', 'pricing']) assert.equal(ctaImageError(cta), undefined, cta) +}) + +test('accepts a custom href that is not a reserved destination', () => { + for (const href of ['/blueprints/', '/blog/2026/09/layered-process-audit-checklist-template/', '/pricing/#comparison', 'https://example.com/contact-us/']) { + assert.equal(ctaImageError('custom', href), undefined, href) + } +}) + +test('rejects a custom href that is a reserved destination', () => { + assert.match(ctaImageError('custom', '/book-demo/?utm_source=blog'), /use cta="demo"/) + assert.match(ctaImageError('custom', 'https://flowfuse.com/pricing'), /use cta="pricing"/) + assert.match(ctaImageError('custom', 'https://app.flowfuse.com/account/create'), /use cta="sign-up"/) + assert.match(ctaImageError('custom', 'https://app.flowfuse.com'), /reserved "signIn"/) +}) + +test('rejects a missing href, an href on a fixed destination, and an unknown cta', () => { + assert.match(ctaImageError('custom'), /requires an href/) + assert.match(ctaImageError('demo', '/blueprints/'), /use cta="custom"/) + assert.match(ctaImageError('book-demo'), /invalid cta/) +}) + +const markdownFiles = dir => readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + return statSync(path).isDirectory() ? markdownFiles(path) : path.endsWith('.md') ? [path] : [] +}) + +const attribute = (attrs, name) => attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`))?.[1] + +test('every ::cta-image in content is valid', () => { + const files = ['content/blog', 'content/changelog', 'content/handbook', 'content/webinars', 'content/customer-stories', 'content-guides'] + .flatMap(dir => markdownFiles(join(nuxtDir, dir))) + const offenders = files.flatMap((file) => { + const body = readFileSync(file, 'utf8').replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1/gm, '') + return [...body.matchAll(/::cta-image\{([^}]*)\}/g)].flatMap(([, attrs]) => { + const error = ctaImageError(attribute(attrs, 'cta') ?? '', attribute(attrs, 'href')) + return error ? [`${relative(nuxtDir, file)}: ${error}`] : [] + }) + }) + assert.deepEqual(offenders, []) +}) diff --git a/nuxt/lib/cta-image.ts b/nuxt/lib/cta-image.ts new file mode 100644 index 0000000000..2050540765 --- /dev/null +++ b/nuxt/lib/cta-image.ts @@ -0,0 +1,25 @@ +import { ctaDestinationKey, type CTA_DESTINATIONS } from './cta-destinations' + +export const CTA_IMAGE_DESTINATIONS: Record = { + 'sign-up': 'signUp', + demo: 'bookDemo', + contact: 'contactUs', + pricing: 'pricing', +} + +const CTA_VALUES = [...Object.keys(CTA_IMAGE_DESTINATIONS), 'custom'] + +export function ctaImageError (cta: string, href?: string) { + if (cta === 'custom') { + if (!href) return 'cta="custom" requires an href' + const key = ctaDestinationKey(href) + if (!key) return undefined + const value = Object.keys(CTA_IMAGE_DESTINATIONS).find(name => CTA_IMAGE_DESTINATIONS[name] === key) + return value + ? `href "${href}" is the "${value}" destination - use cta="${value}" instead of cta="custom"` + : `href "${href}" is the reserved "${key}" destination, which an image CTA can't link to` + } + if (!CTA_IMAGE_DESTINATIONS[cta]) return `invalid cta "${cta}" - must be one of: ${CTA_VALUES.join(', ')}` + if (href) return `cta="${cta}" already sets the link - use cta="custom" to pass an href` + return undefined +} From b5c193cc0333a20771cbc7f25702ab26ab6e5203 Mon Sep 17 00:00:00 2001 From: "sumit shinde ( Roni )" <110285294+sumitshinde-84@users.noreply.github.com> Date: Thu, 1 Oct 2026 16:52:56 +0530 Subject: [PATCH 2/5] Update nuxt/content/handbook/marketing/content-strategy/blog.md Co-authored-by: Yndira Escobar <129537638+Yndira-E@users.noreply.github.com> --- nuxt/content/handbook/marketing/content-strategy/blog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuxt/content/handbook/marketing/content-strategy/blog.md b/nuxt/content/handbook/marketing/content-strategy/blog.md index 04bf322840..5bdead675e 100644 --- a/nuxt/content/handbook/marketing/content-strategy/blog.md +++ b/nuxt/content/handbook/marketing/content-strategy/blog.md @@ -301,7 +301,7 @@ Besides the end-of-article CTA above, you can drop a clickable, tracked image an ``` - `src` and `alt` are required, same as any other blog image. -- `cta` is required on every instance and also sets where the image links to. The same four fixed destinations as `cta.type` above need no URL: +- `cta` is required on every instance and also sets where the image links to. The same four fixed destinations as `cta.type` below need no URL: - `demo` - links to `/book-demo` - `contact` - links to `/contact-us` - `pricing` - links to `/pricing` From 918f68cfc5befa2ea122c5740eb685e95e247687 Mon Sep 17 00:00:00 2001 From: sumitshinde-84 Date: Thu, 1 Oct 2026 17:45:04 +0530 Subject: [PATCH 3/5] CtaImage: link custom images through CUSTOM_CTA_DESTINATIONS cta="custom" now takes a destination-key into the shared registry instead of a free-form href. The registry supplies the URL and the destination event, fired with position inline-image and variant image, alongside blog-cta with destination_key. Only entries with a fixed href are accepted. --- .claude/CLAUDE.md | 2 +- nuxt/components/content/CtaImage.vue | 16 ++++++---- .../marketing/content-strategy/blog.md | 6 ++-- nuxt/lib/cta-image.test.mjs | 32 ++++++++++--------- nuxt/lib/cta-image.ts | 23 +++++++------ 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 107276285b..5119cfac41 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -126,7 +126,7 @@ Tag options are defined in `nuxt/data/blogTags.json`. Future-dated posts are exc `nuxt/components/content/CtaImage.vue` — a clickable, tracked image usable inside a post's markdown body via `::cta-image{src="..." alt="..." cta="..."}`. Renders through `` on `nuxt/pages/blog/[...slug].vue`, same as any other MDC content component (unlike `nuxt/components/BlogPostCta.vue`, which is a normal template component the page passes `page.title` to directly). - `cta` is required on every instance, one of `sign-up` | `demo` | `contact` | `pricing` | `custom` — 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`. -- `cta="custom"` requires an `href`; `href` on any other `cta` value throws. A custom `href` that `ctaDestinationKey()` resolves to a reserved destination throws too, pointing at the `cta` value to use instead, so those destinations' clicks don't split across `cta_type` values. A custom image renders a plain link, not `CtaLink`, and its click adds the `href` to the `blog-cta` payload, since `cta_type: custom` alone can't tell them apart. The rules live in `nuxt/lib/cta-image.ts`, and `nuxt/lib/cta-image.test.mjs` checks every `::cta-image` in the content tree with them, so a bad one fails `npm test` rather than only the page render. +- `cta="custom"` requires a `destination-key` naming an entry in `nuxt/lib/custom-cta-destinations.ts` (the same registry `CtaCustom` uses), and a `destination-key` on any other `cta` value throws. The entry must have a fixed `href`, which the image links to; entries without one (dynamic URLs such as `latestWebinar`) aren't supported for images. There's no free-form `href`, so a reserved destination or a duplicate URL is caught by the registry's own self-check. A custom image renders a plain link, not `CtaLink`, and its click fires the entry's registered event with `{ position: 'inline-image', variant: 'image' }` plus `blog-cta` with `cta_type: custom` and `destination_key`. The rules live in `nuxt/lib/cta-image.ts`, and `nuxt/lib/cta-image.test.mjs` checks every `::cta-image` in the content tree with them, so a bad one fails `npm test` rather than only the page render. - `position` isn't a prop — it's hardcoded to `'inline-image'`, since every instance shares the same placement semantics. - 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`. diff --git a/nuxt/components/content/CtaImage.vue b/nuxt/components/content/CtaImage.vue index 0f0b86ceb3..a3da5ece13 100644 --- a/nuxt/components/content/CtaImage.vue +++ b/nuxt/components/content/CtaImage.vue @@ -2,13 +2,14 @@ // 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 { CTA_IMAGE_DESTINATIONS, ctaImageError } from '../../lib/cta-image' +import type { CUSTOM_CTA_DESTINATIONS } from '../../lib/custom-cta-destinations' +import { CTA_IMAGE_DESTINATIONS, ctaImageError, customCtaImageDestination } from '../../lib/cta-image' const props = defineProps<{ src: string alt: string cta: 'sign-up' | 'demo' | 'contact' | 'pricing' | 'custom' - href?: string + destinationKey?: keyof typeof CUSTOM_CTA_DESTINATIONS /** * What the event's `reference` should say, for a page that is not a blog post. * @@ -23,34 +24,37 @@ const props = defineProps<{ }>() const POSITION = 'inline-image' +const VARIANT = 'image' // Same event as BlogPostCta - cta_type distinguishes the destination, same as there. const EVENT = 'blog-cta' const destination = computed(() => { - const error = ctaImageError(props.cta, props.href) + const error = ctaImageError(props.cta, props.destinationKey) if (error) throw new Error(`CtaImage: ${error}`) return props.cta === 'custom' ? undefined : CTA_IMAGE_DESTINATIONS[props.cta] }) +const custom = computed(() => destination.value ? undefined : customCtaImageDestination(props.destinationKey)) const capture = useCapture() // Provided by nuxt/pages/blog/[...slug].vue - avoids repeating the title per instance. const postTitle = inject | undefined>('blogPostTitle', undefined) function onClick () { + if (custom.value) capture(custom.value.event, { position: POSITION, variant: VARIANT }) capture(EVENT, { reference: props.reference || `Blog: ${postTitle?.value || ''}`, position: POSITION, cta_type: props.cta, - ...(!destination.value && { href: props.href }), + ...(custom.value && { destination_key: props.destinationKey }), }) } diff --git a/nuxt/content/handbook/marketing/content-strategy/blog.md b/nuxt/content/handbook/marketing/content-strategy/blog.md index 5bdead675e..376abfa767 100644 --- a/nuxt/content/handbook/marketing/content-strategy/blog.md +++ b/nuxt/content/handbook/marketing/content-strategy/blog.md @@ -306,14 +306,14 @@ Besides the end-of-article CTA above, you can drop a clickable, tracked image an - `contact` - links to `/contact-us` - `pricing` - links to `/pricing` - `sign-up` - links to the hosted sign-up URL -- For any other destination, such as a blueprint, another relevant blog post, a docs page, or a product page, use `cta="custom"` with an `href`: +- For any other destination, such as a blueprint, another relevant blog post, a docs page, or a product page, use `cta="custom"` with a `destination-key`: ```mdc - ::cta-image{src="/blog/2025/12/images/my-image.png" alt="Browse the FlowFuse Blueprint Library" cta="custom" href="/blueprints/"} + ::cta-image{src="/blog/2025/12/images/my-image.png" alt="Browse the FlowFuse Blueprint Library" cta="custom" destination-key="blueprintLibrary"} :: ``` - `href` is only accepted with `cta="custom"`, and a custom `href` can't point at one of the four fixed destinations — use its `cta` value instead, so those clicks stay grouped together in PostHog. The same goes for the sign-in URL. A query string or the full `https://flowfuse.com/...` form still counts as the fixed destination. Breaking either rule fails the build's tests and names the file. Custom clicks are reported with `cta_type: custom` and the `href` they link to. + The `destination-key` names an entry in `nuxt/lib/custom-cta-destinations.ts`, which holds the link and the PostHog event for that destination. If the page you want to link to isn't there yet, add an entry with its `href` and an `event` first (for example `blueprintLibrary: { href: '/blueprints/', event: 'cta-blueprint-library' }`), or ask the web team to. That file rejects the four fixed destinations and any URL that already has an entry, so a destination is always tracked under one event. A wrong or missing `destination-key` fails the build's tests and names the file. Custom clicks are reported with that destination's own event, plus `blog-cta` with `cta_type: custom` and the `destination_key`. There is no default. `cta` here is independent of the front matter `cta.type` above — a single article can have several `CtaImage` blocks, each pointing at a different destination. diff --git a/nuxt/lib/cta-image.test.mjs b/nuxt/lib/cta-image.test.mjs index 6aede535e0..b390f23d4c 100644 --- a/nuxt/lib/cta-image.test.mjs +++ b/nuxt/lib/cta-image.test.mjs @@ -6,29 +6,31 @@ import { fileURLToPath } from 'node:url' import { createJiti } from 'jiti' const jiti = createJiti(import.meta.url) -const { ctaImageError } = await jiti.import('./cta-image.ts') +const { ctaImageError, customCtaImageDestination } = await jiti.import('./cta-image.ts') const nuxtDir = fileURLToPath(new URL('..', import.meta.url)) -test('accepts the four fixed destinations without an href', () => { +test('accepts the four fixed destinations without a destination-key', () => { for (const cta of ['sign-up', 'demo', 'contact', 'pricing']) assert.equal(ctaImageError(cta), undefined, cta) }) -test('accepts a custom href that is not a reserved destination', () => { - for (const href of ['/blueprints/', '/blog/2026/09/layered-process-audit-checklist-template/', '/pricing/#comparison', 'https://example.com/contact-us/']) { - assert.equal(ctaImageError('custom', href), undefined, href) - } +test('accepts a custom destination-key with a fixed href in the registry', () => { + for (const key of ['homepage', 'communityForum', 'deviceAgentInstall']) assert.equal(ctaImageError('custom', key), undefined, key) }) -test('rejects a custom href that is a reserved destination', () => { - assert.match(ctaImageError('custom', '/book-demo/?utm_source=blog'), /use cta="demo"/) - assert.match(ctaImageError('custom', 'https://flowfuse.com/pricing'), /use cta="pricing"/) - assert.match(ctaImageError('custom', 'https://app.flowfuse.com/account/create'), /use cta="sign-up"/) - assert.match(ctaImageError('custom', 'https://app.flowfuse.com'), /reserved "signIn"/) +test('the registry supplies the link and event for a custom destination', () => { + assert.deepEqual(customCtaImageDestination('homepage'), { href: '/', event: 'cta-homepage' }) + assert.equal(customCtaImageDestination('latestWebinar'), undefined) + assert.equal(customCtaImageDestination('notAKey'), undefined) }) -test('rejects a missing href, an href on a fixed destination, and an unknown cta', () => { - assert.match(ctaImageError('custom'), /requires an href/) - assert.match(ctaImageError('demo', '/blueprints/'), /use cta="custom"/) +test('rejects a custom destination-key that is missing, unregistered, or dynamic', () => { + assert.match(ctaImageError('custom'), /requires a destination-key/) + assert.match(ctaImageError('custom', 'notAKey'), /isn't in lib\/custom-cta-destinations.ts/) + assert.match(ctaImageError('custom', 'latestWebinar'), /no fixed href/) +}) + +test('rejects a destination-key on a fixed destination and an unknown cta', () => { + assert.match(ctaImageError('demo', 'homepage'), /use cta="custom"/) assert.match(ctaImageError('book-demo'), /invalid cta/) }) @@ -45,7 +47,7 @@ test('every ::cta-image in content is valid', () => { const offenders = files.flatMap((file) => { const body = readFileSync(file, 'utf8').replace(/^[ \t]*(`{3,}|~{3,})[^\n]*\n[\s\S]*?^[ \t]*\1/gm, '') return [...body.matchAll(/::cta-image\{([^}]*)\}/g)].flatMap(([, attrs]) => { - const error = ctaImageError(attribute(attrs, 'cta') ?? '', attribute(attrs, 'href')) + const error = ctaImageError(attribute(attrs, 'cta') ?? '', attribute(attrs, 'destination-key')) return error ? [`${relative(nuxtDir, file)}: ${error}`] : [] }) }) diff --git a/nuxt/lib/cta-image.ts b/nuxt/lib/cta-image.ts index 2050540765..380d51c4b1 100644 --- a/nuxt/lib/cta-image.ts +++ b/nuxt/lib/cta-image.ts @@ -1,4 +1,5 @@ -import { ctaDestinationKey, type CTA_DESTINATIONS } from './cta-destinations' +import type { CTA_DESTINATIONS } from './cta-destinations' +import { CUSTOM_CTA_DESTINATIONS } from './custom-cta-destinations' export const CTA_IMAGE_DESTINATIONS: Record = { 'sign-up': 'signUp', @@ -9,17 +10,19 @@ export const CTA_IMAGE_DESTINATIONS: Record)[destinationKey] : undefined + return dest?.href ? { href: dest.href, event: dest.event } : undefined +} + +export function ctaImageError (cta: string, destinationKey?: string) { if (cta === 'custom') { - if (!href) return 'cta="custom" requires an href' - const key = ctaDestinationKey(href) - if (!key) return undefined - const value = Object.keys(CTA_IMAGE_DESTINATIONS).find(name => CTA_IMAGE_DESTINATIONS[name] === key) - return value - ? `href "${href}" is the "${value}" destination - use cta="${value}" instead of cta="custom"` - : `href "${href}" is the reserved "${key}" destination, which an image CTA can't link to` + if (!destinationKey) return 'cta="custom" requires a destination-key from lib/custom-cta-destinations.ts' + if (!(destinationKey in CUSTOM_CTA_DESTINATIONS)) return `destination-key "${destinationKey}" isn't in lib/custom-cta-destinations.ts - add an entry there first` + if (!customCtaImageDestination(destinationKey)) return `destination-key "${destinationKey}" has no fixed href in lib/custom-cta-destinations.ts, and image CTAs don't support dynamic URLs` + return undefined } if (!CTA_IMAGE_DESTINATIONS[cta]) return `invalid cta "${cta}" - must be one of: ${CTA_VALUES.join(', ')}` - if (href) return `cta="${cta}" already sets the link - use cta="custom" to pass an href` + if (destinationKey) return `cta="${cta}" already sets the link - use cta="custom" to pass a destination-key` return undefined } From 4bcb3372d22220ba59645bfa34326c7588603855 Mon Sep 17 00:00:00 2001 From: sumitshinde-84 Date: Thu, 1 Oct 2026 21:26:55 +0530 Subject: [PATCH 4/5] CtaImage: address review feedback on custom destinations Link custom images with external, fix the handbook example and indent, scope the CtaLink note in CLAUDE.md, and resolve custom images in the RSS feed. --- .claude/CLAUDE.md | 4 ++-- nuxt/components/content/CtaImage.vue | 2 +- nuxt/content/handbook/marketing/content-strategy/blog.md | 4 ++-- nuxt/server/routes/blog/index.xml.ts | 9 ++++++--- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 5119cfac41..3769d27852 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -126,9 +126,9 @@ Tag options are defined in `nuxt/data/blogTags.json`. Future-dated posts are exc `nuxt/components/content/CtaImage.vue` — a clickable, tracked image usable inside a post's markdown body via `::cta-image{src="..." alt="..." cta="..."}`. Renders through `` on `nuxt/pages/blog/[...slug].vue`, same as any other MDC content component (unlike `nuxt/components/BlogPostCta.vue`, which is a normal template component the page passes `page.title` to directly). - `cta` is required on every instance, one of `sign-up` | `demo` | `contact` | `pricing` | `custom` — 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`. -- `cta="custom"` requires a `destination-key` naming an entry in `nuxt/lib/custom-cta-destinations.ts` (the same registry `CtaCustom` uses), and a `destination-key` on any other `cta` value throws. The entry must have a fixed `href`, which the image links to; entries without one (dynamic URLs such as `latestWebinar`) aren't supported for images. There's no free-form `href`, so a reserved destination or a duplicate URL is caught by the registry's own self-check. A custom image renders a plain link, not `CtaLink`, and its click fires the entry's registered event with `{ position: 'inline-image', variant: 'image' }` plus `blog-cta` with `cta_type: custom` and `destination_key`. The rules live in `nuxt/lib/cta-image.ts`, and `nuxt/lib/cta-image.test.mjs` checks every `::cta-image` in the content tree with them, so a bad one fails `npm test` rather than only the page render. +- `cta="custom"` requires a `destination-key` naming an entry in `nuxt/lib/custom-cta-destinations.ts` (the same registry `CtaCustom` uses), and a `destination-key` on any other `cta` value throws. The entry must have a fixed `href`, which the image links to; entries without one (dynamic URLs such as `latestWebinar`) aren't supported for images. There's no free-form `href`, so a reserved destination or a duplicate URL is caught by the registry's own self-check. A custom image renders a plain `external` link (a full page load, the same default as `CtaCustom`), not `CtaLink`, and its click fires the entry's registered event with `{ position: 'inline-image', variant: 'image' }` plus `blog-cta` with `cta_type: custom` and `destination_key`. The rules live in `nuxt/lib/cta-image.ts`, and `nuxt/lib/cta-image.test.mjs` checks every `::cta-image` in the content tree with them, so a bad one fails `npm test` rather than only the page render. - `position` isn't a prop — it's hardcoded to `'inline-image'`, since every instance shares the same placement semantics. -- 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 four fixed values render through `CtaLink` (see **Inline links** under Call-to-Action components), so their href and destination event (`cta-book-demo`, etc., with `variant: 'image'`) come from `CTA_DESTINATIONS`. Every image, fixed or custom, 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`. --- diff --git a/nuxt/components/content/CtaImage.vue b/nuxt/components/content/CtaImage.vue index a3da5ece13..cdb2547eaf 100644 --- a/nuxt/components/content/CtaImage.vue +++ b/nuxt/components/content/CtaImage.vue @@ -54,7 +54,7 @@ function onClick () { - + diff --git a/nuxt/content/handbook/marketing/content-strategy/blog.md b/nuxt/content/handbook/marketing/content-strategy/blog.md index 376abfa767..bfe7de7f5f 100644 --- a/nuxt/content/handbook/marketing/content-strategy/blog.md +++ b/nuxt/content/handbook/marketing/content-strategy/blog.md @@ -309,13 +309,13 @@ Besides the end-of-article CTA above, you can drop a clickable, tracked image an - For any other destination, such as a blueprint, another relevant blog post, a docs page, or a product page, use `cta="custom"` with a `destination-key`: ```mdc - ::cta-image{src="/blog/2025/12/images/my-image.png" alt="Browse the FlowFuse Blueprint Library" cta="custom" destination-key="blueprintLibrary"} + ::cta-image{src="/blog/2025/12/images/my-image.png" alt="Read the OPC UA node docs" cta="custom" destination-key="opcuaCertifiedNodeDocs"} :: ``` The `destination-key` names an entry in `nuxt/lib/custom-cta-destinations.ts`, which holds the link and the PostHog event for that destination. If the page you want to link to isn't there yet, add an entry with its `href` and an `event` first (for example `blueprintLibrary: { href: '/blueprints/', event: 'cta-blueprint-library' }`), or ask the web team to. That file rejects the four fixed destinations and any URL that already has an entry, so a destination is always tracked under one event. A wrong or missing `destination-key` fails the build's tests and names the file. Custom clicks are reported with that destination's own event, plus `blog-cta` with `cta_type: custom` and the `destination_key`. - There is no default. `cta` here is independent of the front matter `cta.type` above — a single article can have several `CtaImage` blocks, each pointing at a different destination. +There is no default. `cta` here is independent of the front matter `cta.type` above — a single article can have several `CtaImage` blocks, each pointing at a different destination. Tracking is automatic and fires the same `blog-cta` event as the end-of-article CTA, with the same `reference` (the article title) and a `cta_type` property set to the destination. What tells the two apart is `position`: every `CtaImage` click sends `position: "inline-image"`, while the end-of-article CTA doesn't send `position` at all — so in PostHog you can filter `blog-cta` events by `position = inline-image` to isolate inline image clicks specifically, or leave it unfiltered to see both together. diff --git a/nuxt/server/routes/blog/index.xml.ts b/nuxt/server/routes/blog/index.xml.ts index 9168f1dc93..4c0a199a5a 100644 --- a/nuxt/server/routes/blog/index.xml.ts +++ b/nuxt/server/routes/blog/index.xml.ts @@ -4,9 +4,10 @@ import site from '../../../data/site.json' import { planBadges } from '../../../lib/feature-catalog.mjs' // @ts-ignore untyped module import { resolveReleaseFeatures, injectReleaseFeatures } from '../../../lib/release-features.mjs' +import { customCtaImageDestination } from '../../../lib/cta-image' -// Mirrors nuxt/components/content/CtaImage.vue's DESTINATIONS map - kept in -// sync manually since the feed can't import a .vue component's script setup. +// Mirrors the hrefs behind nuxt/lib/cta-image.ts's CTA_IMAGE_DESTINATIONS - kept in +// sync manually. cta="custom" resolves through customCtaImageDestination instead. const CTA_IMAGE_DESTINATIONS: Record = { 'sign-up': `${site.appURL}/account/create`, demo: '/book-demo/', @@ -125,7 +126,9 @@ function minimarkToHtml(node: MinimarkNode): string { if (tag === 'cta-image' && props) { const src = typeof props.src === 'string' ? props.src : '' const alt = typeof props.alt === 'string' ? props.alt : '' - const href = CTA_IMAGE_DESTINATIONS[props.cta as string] + const href = props.cta === 'custom' + ? customCtaImageDestination(props['destination-key'] as string | undefined)?.href + : CTA_IMAGE_DESTINATIONS[props.cta as string] const img = `${escapeXml(alt)}` return href ? `${img}` : img } From 5c719e49d5055373260865e77643f25aaa924dc1 Mon Sep 17 00:00:00 2001 From: Yndira Escobar <129537638+Yndira-E@users.noreply.github.com> Date: Thu, 1 Oct 2026 18:06:43 +0200 Subject: [PATCH 5/5] Apply suggestion from @Yndira-E --- nuxt/content/handbook/marketing/content-strategy/blog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nuxt/content/handbook/marketing/content-strategy/blog.md b/nuxt/content/handbook/marketing/content-strategy/blog.md index bfe7de7f5f..f27744ffa0 100644 --- a/nuxt/content/handbook/marketing/content-strategy/blog.md +++ b/nuxt/content/handbook/marketing/content-strategy/blog.md @@ -301,7 +301,7 @@ Besides the end-of-article CTA above, you can drop a clickable, tracked image an ``` - `src` and `alt` are required, same as any other blog image. -- `cta` is required on every instance and also sets where the image links to. The same four fixed destinations as `cta.type` below need no URL: +- `cta` is required on every instance and also sets where the image links to. The same four fixed destinations as `cta.type` above need no URL: - `demo` - links to `/book-demo` - `contact` - links to `/contact-us` - `pricing` - links to `/pricing`