Skip to content

Commit b1fd5ce

Browse files
authoredFeb 14, 2025··
feat(core): canonical plugin (#492)
* feat(core): canonical plugin * doc: title * chore: broken tests
1 parent e4b1ff6 commit b1fd5ce

File tree

4 files changed

+236
-0
lines changed

4 files changed

+236
-0
lines changed
 

Diff for: ‎docs/3.recipes/canonical.md

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
---
2+
title: "Canonical Plugin"
3+
description: Fix relative URLs in your meta tags automatically
4+
---
5+
6+
## Introduction
7+
8+
[Google](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls) requires your canonical URLs to use absolute paths. [Facebook](https://developers.facebook.com/docs/sharing/webmasters/getting-started) and [Twitter](https://developer.twitter.com/en/docs/twitter-for-websites/cards/overview/markup) will ignore images without a full URL. This plugin fixes both problems automatically
9+
when you provide a relative path.
10+
11+
The plugin converts relative paths to absolute URLs in these tags:
12+
- `og:image` and `twitter:image` meta tags
13+
- `og:url` meta tag
14+
- `rel="canonical"` link tag
15+
16+
For example:
17+
```html
18+
<!-- Before -->
19+
<meta property="og:image" content="/images/hero.jpg">
20+
21+
<!-- After -->
22+
<meta property="og:image" content="https://mysite.com/images/hero.jpg">
23+
```
24+
25+
## Setup
26+
27+
You should install the plugin in both your server & client entries.
28+
29+
```ts
30+
import { CanonicalPlugin } from 'unhead/plugins'
31+
32+
const head = createHead({
33+
plugins: [
34+
CanonicalPlugin({
35+
canonicalHost: 'https://mysite.com'
36+
})
37+
]
38+
})
39+
```
40+
41+
## Configuration
42+
43+
```ts
44+
interface CanonicalPluginOptions {
45+
// Your site's domain (required)
46+
canonicalHost?: string
47+
// Optional: Custom function to transform URLs
48+
customResolver?: (path: string) => string
49+
}
50+
```
51+
52+
### Fallback Behavior
53+
54+
If no `canonicalHost` is provided, the plugin will fallback to the window location origin if client side.
55+
56+
For SSR it will just leave the URLs as is.
57+
58+
### Custom URL Resolution
59+
60+
Use `customResolver` to handle the transformation yourself. This function should return a fully qualified URL.
61+
62+
```ts
63+
CanonicalPlugin({
64+
canonicalHost: 'https://mysite.com',
65+
customResolver: path => new URL(`/cdn${path}`, 'https://example.com').toString()
66+
})
67+
```
68+
69+
This would transform:
70+
```html
71+
<meta property="og:image" content="/hero.jpg">
72+
<!-- to -->
73+
<meta property="og:image" content="https://mysite.com/cdn/hero.jpg">
74+
```

Diff for: ‎packages/unhead/src/plugins/canonical.ts

+63
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import { defineHeadPlugin } from '../utils/defineHeadPlugin'
2+
3+
/**
4+
* CanonicalPlugin resolves paths in tags that require a canonical host to be set.
5+
*
6+
* - Resolves paths in meta tags like `og:image` and `twitter:image`.
7+
* - Resolves paths in the `og:url` meta tag.
8+
* - Resolves paths in the `link` tag with the `rel="canonical"` attribute.
9+
* @example
10+
* const plugin = CanonicalPlugin({
11+
* canonicalHost: 'https://example.com',
12+
* customResolver: (path) => `/custom${path}`,
13+
* });
14+
*
15+
* // This plugin will resolve URLs in meta tags like:
16+
* // <meta property="og:image" content="/image.jpg">
17+
* // to:
18+
* // <meta property="og:image" content="https://example.com/image.jpg">
19+
*/
20+
export function CanonicalPlugin(options: { canonicalHost?: string, customResolver?: (url: string) => string }) {
21+
return defineHeadPlugin((head) => {
22+
function resolvePath(path: string) {
23+
if (options?.customResolver) {
24+
return options.customResolver(path)
25+
}
26+
let host = options.canonicalHost || (!head.ssr ? (window.location.origin) : '')
27+
// handle https if not provided
28+
if (!host.startsWith('http') && !host.startsWith('//')) {
29+
host = `https://${host}`
30+
}
31+
// have error thrown if canonicalHost is not a valid URL
32+
host = new URL(host).origin
33+
if (path.startsWith('http') || path.startsWith('//'))
34+
return path
35+
try {
36+
return new URL(path, host).toString()
37+
}
38+
catch {
39+
return path
40+
}
41+
}
42+
return {
43+
key: 'canonical',
44+
hooks: {
45+
'tags:resolve': (ctx) => {
46+
for (const tag of ctx.tags) {
47+
if (tag.tag === 'meta') {
48+
if (tag.props.property?.startsWith('og:image') || tag.props.name?.startsWith('twitter:image')) {
49+
tag.props.content = resolvePath(tag.props.content)
50+
}
51+
else if (tag.props?.property === 'og:url') {
52+
tag.props.content = resolvePath(tag.props.content)
53+
}
54+
}
55+
else if (tag.tag === 'link' && tag.props.rel === 'canonical') {
56+
tag.props.href = resolvePath(tag.props.href)
57+
}
58+
}
59+
},
60+
},
61+
}
62+
})
63+
}

Diff for: ‎packages/unhead/src/plugins/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export { CanonicalPlugin } from './canonical'
12
export { DeprecationsPlugin } from './deprecations' // optional
23
export { FlatMetaPlugin } from './flatMeta' // optional
34
export { InferSeoMetaPlugin } from './inferSeoMetaPlugin' // optional

Diff for: ‎packages/unhead/test/unit/plugins/canonical.test.ts

+98
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { CanonicalPlugin } from '../../../src/plugins/canonical'
3+
4+
describe('canonicalPlugin', () => {
5+
it('should resolve og:image URLs correctly', () => {
6+
const plugin = CanonicalPlugin({ canonicalHost: 'https://example.com' })({ ssr: false })
7+
const ctx = {
8+
tags: [
9+
{ tag: 'meta', props: { property: 'og:image', content: '/image.jpg' } },
10+
],
11+
}
12+
13+
plugin.hooks['tags:resolve'](ctx)
14+
15+
expect(ctx.tags[0].props.content).toBe('https://example.com/image.jpg')
16+
})
17+
18+
it('should resolve twitter:image URLs correctly', () => {
19+
const plugin = CanonicalPlugin({ canonicalHost: 'https://example.com' })({ ssr: false })
20+
const ctx = {
21+
tags: [
22+
{ tag: 'meta', props: { name: 'twitter:image', content: '/image.jpg' } },
23+
],
24+
}
25+
26+
plugin.hooks['tags:resolve'](ctx)
27+
28+
expect(ctx.tags[0].props.content).toBe('https://example.com/image.jpg')
29+
})
30+
31+
it('should resolve og:url URLs correctly', () => {
32+
const plugin = CanonicalPlugin({ canonicalHost: 'https://example.com' })({ ssr: false })
33+
const ctx = {
34+
tags: [
35+
{ tag: 'meta', props: { property: 'og:url', content: '/page' } },
36+
],
37+
}
38+
39+
plugin.hooks['tags:resolve'](ctx)
40+
41+
expect(ctx.tags[0].props.content).toBe('https://example.com/page')
42+
})
43+
44+
it('should resolve canonical link URLs correctly', () => {
45+
const plugin = CanonicalPlugin({ canonicalHost: 'https://example.com' })({ ssr: false })
46+
const ctx = {
47+
tags: [
48+
{ tag: 'link', props: { rel: 'canonical', href: '/page' } },
49+
],
50+
}
51+
52+
plugin.hooks['tags:resolve'](ctx)
53+
54+
expect(ctx.tags[0].props.href).toBe('https://example.com/page')
55+
})
56+
57+
it('should use custom resolver if provided', () => {
58+
const plugin = CanonicalPlugin({
59+
canonicalHost: 'https://example.com',
60+
customResolver: path => `/custom${path}`,
61+
})({ ssr: false })
62+
const ctx = {
63+
tags: [
64+
{ tag: 'meta', props: { property: 'og:image', content: '/image.jpg' } },
65+
],
66+
}
67+
68+
plugin.hooks['tags:resolve'](ctx)
69+
70+
expect(ctx.tags[0].props.content).toBe('/custom/image.jpg')
71+
})
72+
73+
it('should handle already fully qualified URLs', () => {
74+
const plugin = CanonicalPlugin({ canonicalHost: 'https://example.com' })({ ssr: false })
75+
const ctx = {
76+
tags: [
77+
{ tag: 'meta', props: { property: 'og:image', content: 'https://other.com/image.jpg' } },
78+
],
79+
}
80+
81+
plugin.hooks['tags:resolve'](ctx)
82+
83+
expect(ctx.tags[0].props.content).toBe('https://other.com/image.jpg')
84+
})
85+
86+
it('should handle canonicalHost without protocol', () => {
87+
const plugin = CanonicalPlugin({ canonicalHost: 'example.com' })({ ssr: false })
88+
const ctx = {
89+
tags: [
90+
{ tag: 'meta', props: { property: 'og:image', content: '/image.jpg' } },
91+
],
92+
}
93+
94+
plugin.hooks['tags:resolve'](ctx)
95+
96+
expect(ctx.tags[0].props.content).toBe('https://example.com/image.jpg')
97+
})
98+
})

0 commit comments

Comments
 (0)
Please sign in to comment.