diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index fe49d61152..3769d27852 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 `` 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`. --- diff --git a/nuxt/components/content/CtaImage.vue b/nuxt/components/content/CtaImage.vue index 57ab4ae0c5..cdb2547eaf 100644 --- a/nuxt/components/content/CtaImage.vue +++ b/nuxt/components/content/CtaImage.vue @@ -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. * @@ -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 = { - '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 | 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 }), }) } diff --git a/nuxt/content/handbook/marketing/content-strategy/blog.md b/nuxt/content/handbook/marketing/content-strategy/blog.md index 0db238395e..f27744ffa0 100644 --- a/nuxt/content/handbook/marketing/content-strategy/blog.md +++ b/nuxt/content/handbook/marketing/content-strategy/blog.md @@ -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. diff --git a/nuxt/lib/cta-image.test.mjs b/nuxt/lib/cta-image.test.mjs new file mode 100644 index 0000000000..b390f23d4c --- /dev/null +++ b/nuxt/lib/cta-image.test.mjs @@ -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, []) +}) diff --git a/nuxt/lib/cta-image.ts b/nuxt/lib/cta-image.ts new file mode 100644 index 0000000000..380d51c4b1 --- /dev/null +++ b/nuxt/lib/cta-image.ts @@ -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 = { + '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)[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 +} 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 }