Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,10 @@ 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 `<ContentRenderer>` 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 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`.

---
Expand Down
28 changes: 15 additions & 13 deletions nuxt/components/content/CtaImage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +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 type { CTA_DESTINATIONS } from '../../lib/cta-destinations'
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'
cta: 'sign-up' | 'demo' | 'contact' | 'pricing' | 'custom'
destinationKey?: keyof typeof CUSTOM_CTA_DESTINATIONS
/**
* What the event's `reference` should say, for a page that is not a blog post.
*
Expand All @@ -22,37 +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 DESTINATIONS: Record<string, keyof typeof CTA_DESTINATIONS> = {
'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.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<Ref<string> | 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,
...(custom.value && { destination_key: props.destinationKey }),
})
}
</script>

<template>
<CtaLink :destination="destination" :position="POSITION" variant="image" class="mb-4 block" @click="onClick">
<CtaLink v-if="destination" :destination="destination" :position="POSITION" :variant="VARIANT" class="mb-4 block" @click="onClick">
<NuxtImg :src="src" :alt="alt" />
</CtaLink>
<ULink v-else :href="custom?.href" external raw class="mb-4 block" @click="onClick">
<NuxtImg :src="src" :alt="alt" />
</ULink>
</template>
12 changes: 10 additions & 2 deletions nuxt/content/handbook/marketing/content-strategy/blog.md
Original file line number Diff line number Diff line change
Expand Up @@ -301,13 +301,21 @@ 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 a `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.
```mdc
::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.

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.

Expand Down
55 changes: 55 additions & 0 deletions nuxt/lib/cta-image.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
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, customCtaImageDestination } = await jiti.import('./cta-image.ts')
const nuxtDir = fileURLToPath(new URL('..', import.meta.url))

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 destination-key with a fixed href in the registry', () => {
for (const key of ['homepage', 'communityForum', 'deviceAgentInstall']) assert.equal(ctaImageError('custom', key), undefined, key)
})

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 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/)
})

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, 'destination-key'))
return error ? [`${relative(nuxtDir, file)}: ${error}`] : []
})
})
assert.deepEqual(offenders, [])
})
28 changes: 28 additions & 0 deletions nuxt/lib/cta-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { CTA_DESTINATIONS } from './cta-destinations'
import { CUSTOM_CTA_DESTINATIONS } from './custom-cta-destinations'

export const CTA_IMAGE_DESTINATIONS: Record<string, keyof typeof CTA_DESTINATIONS> = {
'sign-up': 'signUp',
demo: 'bookDemo',
contact: 'contactUs',
pricing: 'pricing',
}

const CTA_VALUES = [...Object.keys(CTA_IMAGE_DESTINATIONS), 'custom']

export function customCtaImageDestination (destinationKey?: string) {
const dest = destinationKey ? (CUSTOM_CTA_DESTINATIONS as Record<string, { href?: string, event: string }>)[destinationKey] : undefined
return dest?.href ? { href: dest.href, event: dest.event } : undefined
}

export function ctaImageError (cta: string, destinationKey?: string) {
if (cta === 'custom') {
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 (destinationKey) return `cta="${cta}" already sets the link - use cta="custom" to pass a destination-key`
return undefined
}
9 changes: 6 additions & 3 deletions nuxt/server/routes/blog/index.xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
'sign-up': `${site.appURL}/account/create`,
demo: '/book-demo/',
Expand Down Expand Up @@ -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 = `<img src="${escapeXml(absoluteUrl(src))}" alt="${escapeXml(alt)}"/>`
return href ? `<a href="${escapeXml(absoluteUrl(href))}">${img}</a>` : img
}
Expand Down
Loading