Skip to content

Commit a3ed9be

Browse files
committedFeb 26, 2025·
fix(addons): stabilize useSeoMeta tree shaking
1 parent 2e36c2a commit a3ed9be

File tree

4 files changed

+235
-47
lines changed

4 files changed

+235
-47
lines changed
 

‎examples/vite-ssr-react-ts/src/App.tsx

+7-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import './App.css'
22
import { useState, useEffect } from 'react'
33
import reactLogo from './assets/react.svg'
4-
import { useHead, useScript } from '@unhead/react'
4+
import { useHead, useScript, useSeoMeta } from '@unhead/react'
55
import { useSchemaOrg, defineWebPage, defineWebSite } from '@unhead/schema-org/react'
66

77
function PageHead() {
@@ -33,6 +33,12 @@ function App() {
3333
}
3434
})
3535

36+
const name = "World"
37+
useSeoMeta({
38+
title: `Hello - ${name}`,
39+
description: `Welcome to ${name}`,
40+
})
41+
3642
useSchemaOrg([
3743
defineWebSite({
3844
url: 'https://example.com',
+2-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { defineConfig } from 'vite'
22
import react from '@vitejs/plugin-react'
3+
import Unhead from "@unhead/addons/vite";
34

45
// https://vite.dev/config/
56
export default defineConfig({
6-
plugins: [react()],
7+
plugins: [react(), Unhead()],
78
})

‎packages/addons/src/unplugin/UseSeoMetaTransform.ts

+67-21
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createUnplugin } from 'unplugin'
1818

1919
export interface UseSeoMetaTransformOptions extends BaseTransformerTypes {
2020
imports?: boolean
21+
importSpecifiers?: string[]
2122
}
2223

2324
/**
@@ -37,6 +38,14 @@ export interface UseSeoMetaTransformOptions extends BaseTransformerTypes {
3738
*/
3839
export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, false>((options: UseSeoMetaTransformOptions = {}) => {
3940
options.imports = options.imports || true
41+
42+
function isValidPackage(s: string) {
43+
if (s === 'unhead' || s.startsWith('@unhead')) {
44+
return true
45+
}
46+
return [...(options.importSpecifiers || [])].includes(s)
47+
}
48+
4049
return {
4150
name: 'unhead:use-seo-meta-transform',
4251
enforce: 'post',
@@ -72,8 +81,7 @@ export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, fa
7281
return
7382

7483
// // useSeoMeta may be auto-imported or may not be
75-
const packages = ['unhead', '@unhead/vue', '@unhead/react']
76-
const statements = findStaticImports(code).filter(i => packages.includes(i.specifier))
84+
const statements = findStaticImports(code).filter(i => isValidPackage(i.specifier))
7785
const importNames: Record<string, string> = {}
7886
for (const i of statements.flatMap(i => parseStaticImport(i))) {
7987
if (i.namedImports) {
@@ -87,25 +95,37 @@ export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, fa
8795

8896
const ast = this.parse(code)
8997
const s = new MagicString(code)
90-
const extraImports = new Set()
98+
let replacementPayload: ((f: boolean) => [number, number, string]) | undefined
99+
let replaceCount = 0
100+
let totalCount = 0
91101
walk(ast as Node, {
92102
enter(_node) {
93-
if (options.imports && _node.type === 'ImportDeclaration' && packages.includes(_node.source.value as string)) {
103+
if (options.imports && _node.type === 'ImportDeclaration' && isValidPackage(_node.source.value as string)) {
94104
const node = _node as unknown as ImportDeclaration
95-
// make sure we are using seo meta
96-
if (
97-
// @ts-expect-error untyped
98-
!node.specifiers.some(s => s.type === 'ImportSpecifier' && ['useSeoMeta', 'useServerSeoMeta'].includes(s.imported?.name))
105+
const hasSeoMeta = node.specifiers.some(s =>
106+
s.type === 'ImportSpecifier'
107+
&& ['useSeoMeta', 'useServerSeoMeta'].includes((s.imported as any).name),
99108
)
100-
return
101109

102-
const imports = Object.values(importNames)
103-
// add useHead and useServerHead if they are not already imported
104-
if (!imports.includes('useHead'))
105-
extraImports.add(`import { useHead } from '${node.source.value}'`)
110+
if (!hasSeoMeta) {
111+
return
112+
}
106113

107-
if (!imports.includes('useServerHead') && imports.includes('useServerSeoMeta'))
108-
extraImports.add(`import { useServerHead } from '${node.source.value}'`)
114+
// Count how many specifiers we're removing
115+
const toImport = new Set()
116+
node.specifiers.forEach((spec) => {
117+
if (spec.type === 'ImportSpecifier'
118+
&& ['useSeoMeta', 'useServerSeoMeta'].includes((spec.imported as any).name)) {
119+
toImport.add((spec.imported as any).name.includes('Server') ? 'useServerHead' : 'useHead')
120+
}
121+
else {
122+
toImport.add((spec.imported as any).name)
123+
}
124+
})
125+
if (toImport.size) {
126+
// need to modify current node imports
127+
replacementPayload = (useSeoMeta = false) => [node.specifiers[0].start, node.specifiers[node.specifiers.length - 1].end, [...toImport, useSeoMeta ? 'useSeoMeta' : false].filter(Boolean).join(', ')]
128+
}
109129
}
110130
else if (
111131
_node.type === 'CallExpression'
@@ -115,6 +135,7 @@ export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, fa
115135
useServerSeoMeta: 'useServerSeoMeta',
116136
...importNames,
117137
}).includes(_node.callee.name)) {
138+
replaceCount++
118139
const node = _node as SimpleCallExpression
119140

120141
const calleeName = importNames[(node.callee as any).name] || (node.callee as any).name
@@ -150,6 +171,7 @@ export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, fa
150171
output.push('});')
151172
output.push('useServerHead({')
152173
}
174+
153175
if (meta.length)
154176
output.push(' meta: [')
155177

@@ -177,8 +199,28 @@ export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, fa
177199
}
178200
let value = code.substring(property.value.start as number, property.value.end as number)
179201
if (property.value.type === 'ArrayExpression') {
180-
// @todo add support for og:image arrays
181-
output = false
202+
if (output === false)
203+
return
204+
205+
const elements = property.value.elements
206+
if (!elements.length)
207+
return
208+
209+
// For each array element
210+
const metaTags = elements.map((element) => {
211+
// If not an object, handle as a simple value
212+
if (element.type !== 'ObjectExpression')
213+
return ` { ${key}: '${keyValue}', ${valueKey}: ${code.substring(element.start, element.end)} },`
214+
215+
// Transform object properties to meta tags
216+
return element.properties.map((p: any) => {
217+
const propKey = p.key.name
218+
const propValue = code.substring(p.value.start, p.value.end)
219+
return ` { ${key}: '${keyValue}:${propKey}', ${valueKey}: ${propValue} },`
220+
}).join('\n')
221+
})
222+
223+
output.push(metaTags.join('\n'))
182224
return
183225
}
184226
// value may be an object, in which case we need to stringify it, this may be a problem if the object is reactive
@@ -218,14 +260,18 @@ export const UseSeoMetaTransform = createUnplugin<UseSeoMetaTransformOptions, fa
218260
s.overwrite(node.start, node.end, output.join('\n'))
219261
}
220262
}
263+
else if (_node.type === 'Identifier'
264+
&& ['useSeoMeta', 'useServerSeoMeta'].includes(_node.name)) {
265+
totalCount++
266+
}
221267
},
222268
})
223269

224270
if (s.hasChanged()) {
225-
// only if we swapped out useSeoMeta
226-
const prependImports = [...extraImports]
227-
if (prependImports.length)
228-
s.prepend(`${prependImports.join('\n')}\n`)
271+
if (replacementPayload) {
272+
// only if we swapped out useSeoMeta
273+
s.overwrite(...replacementPayload(replaceCount + 3 === totalCount))
274+
}
229275

230276
return {
231277
code: s.toString(),

‎packages/addons/test/useSeoMetaTransform.test.ts

+159-24
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import { parse } from 'acorn-loose'
22
import { describe, expect, it } from 'vitest'
33
import { UseSeoMetaTransform } from '../src/unplugin/UseSeoMetaTransform'
44

5-
async function transform(code: string | string[], id = 'some-id.js') {
6-
const plugin = UseSeoMetaTransform.vite() as any
5+
async function transform(code: string | string[], id = 'some-id.js', opts: any = {}) {
6+
const plugin = UseSeoMetaTransform.vite(opts) as any
77
const res = await plugin.transform.call(
88
{ parse: (code: string) => parse(code, { ecmaVersion: 2022, sourceType: 'module', allowImportExportEverywhere: true, allowAwaitOutsideFunction: true }) },
99
Array.isArray(code) ? code.join('\n') : code,
@@ -46,9 +46,8 @@ describe('useSeoMetaTransform', () => {
4646
'useSeoMeta({ title: \'Hello 2\', description: \'World 2\' })',
4747
])
4848
expect(code).toMatchInlineSnapshot(`
49-
"import { useHead } from 'unhead'
50-
import { something } from 'other-module'
51-
import { useSeoMeta } from 'unhead'
49+
"import { something } from 'other-module'
50+
import { useHead } from 'unhead'
5251
useHead({
5352
title: 'Hello',
5453
meta: [
@@ -73,7 +72,6 @@ describe('useSeoMetaTransform', () => {
7372
])
7473
expect(code).toMatchInlineSnapshot(`
7574
"import { useHead } from 'unhead'
76-
import { useSeoMeta } from 'unhead'
7775
import { ref } from 'vue'
7876
const someValue = { value: 'test' }
7977
useHead({
@@ -101,7 +99,6 @@ describe('useSeoMetaTransform', () => {
10199
])
102100
expect(code).toMatchInlineSnapshot(`
103101
"import { useHead } from '@unhead/vue'
104-
import { useSeoMeta } from '@unhead/vue'
105102
useHead({
106103
meta: [
107104
{ charset: 'utf-8' },
@@ -126,7 +123,6 @@ describe('useSeoMetaTransform', () => {
126123
])
127124
expect(code).toMatchInlineSnapshot(`
128125
"import { useHead } from 'unhead'
129-
import { useSeoMeta } from 'unhead'
130126
useHead({
131127
meta: [
132128
{ charset: 'utf-8' },
@@ -147,9 +143,31 @@ describe('useSeoMetaTransform', () => {
147143
alt: 'My amazing image',
148144
},
149145
],
146+
twitterImage: [
147+
{
148+
url: 'https://example.com/image.png',
149+
width: 800,
150+
height: 600,
151+
alt: 'My amazing image',
152+
},
153+
],
150154
})`,
151155
])
152-
expect(code).toMatchInlineSnapshot('undefined')
156+
expect(code).toMatchInlineSnapshot(`
157+
"import { useHead } from 'unhead'
158+
useHead({
159+
meta: [
160+
{ property: 'og:image:url', content: 'https://example.com/image.png' },
161+
{ property: 'og:image:width', content: 800 },
162+
{ property: 'og:image:height', content: 600 },
163+
{ property: 'og:image:alt', content: 'My amazing image' },
164+
{ name: 'twitter:image:url', content: 'https://example.com/image.png' },
165+
{ name: 'twitter:image:width', content: 800 },
166+
{ name: 'twitter:image:height', content: 600 },
167+
{ name: 'twitter:image:alt', content: 'My amazing image' },
168+
]
169+
})"
170+
`)
153171
})
154172

155173
it('respects how users import library', async () => {
@@ -160,7 +178,6 @@ describe('useSeoMetaTransform', () => {
160178
expect(code).toBeDefined()
161179
expect(code).toMatchInlineSnapshot(`
162180
"import { useHead } from 'unhead'
163-
import { useSeoMeta as usm } from 'unhead'
164181
useHead({
165182
title: 'Hello',
166183
meta: [
@@ -179,7 +196,6 @@ describe('useSeoMetaTransform', () => {
179196
expect(code).toBeDefined()
180197
expect(code).toMatchInlineSnapshot(`
181198
"import { useHead } from 'unhead'
182-
import { useSeoMeta as usm, useHead } from 'unhead'
183199
useHead({ title: 'test', })
184200
useHead({
185201
meta: [
@@ -197,7 +213,6 @@ describe('useSeoMetaTransform', () => {
197213
expect(code).toBeDefined()
198214
expect(code).toMatchInlineSnapshot(`
199215
"import { useHead } from 'unhead'
200-
import { useSeoMeta } from 'unhead'
201216
useHead({
202217
meta: [
203218
{ name: 'description', content: 'World' },
@@ -213,9 +228,7 @@ describe('useSeoMetaTransform', () => {
213228
])
214229
expect(code).toBeDefined()
215230
expect(code).toMatchInlineSnapshot(`
216-
"import { useHead } from 'unhead'
217-
import { useServerHead } from 'unhead'
218-
import { useServerSeoMeta } from 'unhead'
231+
"import { useServerHead } from 'unhead'
219232
useServerHead({
220233
meta: [
221234
{ name: 'description', content: 'World' },
@@ -231,9 +244,7 @@ describe('useSeoMetaTransform', () => {
231244
])
232245
expect(code).toBeDefined()
233246
expect(code).toMatchInlineSnapshot(`
234-
"import { useHead } from 'unhead'
235-
import { useServerHead } from 'unhead'
236-
import { useServerSeoMeta, useServerHead, useHead, SomethingRandom } from 'unhead'
247+
"import { useServerHead, useHead, SomethingRandom } from 'unhead'
237248
useHead({
238249
title: 'Hello',
239250
});
@@ -541,10 +552,9 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
541552

542553
expect(code).toBeDefined()
543554
expect(code).toMatchInlineSnapshot(`
544-
"import { useHead } from '@unhead/vue'
545-
555+
"
546556
import { defineComponent as _defineComponent } from "vue";
547-
import { useHead, useSeoMeta } from "@unhead/vue";
557+
import { useHead } from "@unhead/vue";
548558
549559
const _sfc_main = /* @__PURE__ */ _defineComponent({
550560
__name: "app",
@@ -580,10 +590,9 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
580590

581591
expect(code).toBeDefined()
582592
expect(code).toMatchInlineSnapshot(`
583-
"import { useHead } from '@unhead/vue'
584-
593+
"
585594
import { defineComponent as _defineComponent } from "vue";
586-
import { useSeoMeta as SEOMETA } from "@unhead/vue";
595+
import { useHead } from "@unhead/vue";
587596
588597
const _sfc_main = /* @__PURE__ */ _defineComponent({
589598
__name: "app",
@@ -597,4 +606,130 @@ const _sfc_main = /* @__PURE__ */ _defineComponent({
597606
});"
598607
`)
599608
})
609+
610+
it('handles empty meta objects', async () => {
611+
const code = await transform([
612+
'import { useSeoMeta } from \'unhead\'',
613+
'useSeoMeta({})',
614+
])
615+
expect(code).toBeDefined()
616+
expect(code).toMatchInlineSnapshot(`
617+
"import { useHead } from 'unhead'
618+
useHead({
619+
})"
620+
`)
621+
})
622+
623+
it('handles complex meta properties', async () => {
624+
const code = await transform([
625+
'import { useSeoMeta } from \'unhead\'',
626+
'useSeoMeta({',
627+
' ogTitle: "My Page",',
628+
' ogDescription: "My Description",',
629+
' ogImage: "https://example.com/image.jpg",',
630+
' twitterCard: "summary_large_image"',
631+
'})',
632+
])
633+
expect(code).toBeDefined()
634+
expect(code).toMatchInlineSnapshot(`
635+
"import { useHead } from 'unhead'
636+
useHead({
637+
meta: [
638+
{ property: 'og:title', content: "My Page" },
639+
{ property: 'og:description', content: "My Description" },
640+
{ property: 'og:image', content: "https://example.com/image.jpg" },
641+
{ name: 'twitter:card', content: "summary_large_image" },
642+
]
643+
})"
644+
`)
645+
})
646+
647+
it('handles template literals', async () => {
648+
const code = await transform([
649+
'import { useSeoMeta } from \'unhead\'',
650+
'const name = "World"',
651+
'useSeoMeta({',
652+
' title: `Hello ${name}`,',
653+
' description: `Welcome to ${name}`',
654+
'})'
655+
+ 'console.log(useSeoMeta)',
656+
])
657+
expect(code).toBeDefined()
658+
expect(code).toMatchInlineSnapshot(`
659+
"import { useHead, useSeoMeta } from 'unhead'
660+
const name = "World"
661+
useHead({
662+
title: \`Hello \${name}\`,
663+
meta: [
664+
{ name: 'description', content: \`Welcome to \${name}\` },
665+
]
666+
})console.log(useSeoMeta)"
667+
`)
668+
})
669+
670+
it('handles multiple imports and transformations', async () => {
671+
const code = await transform([
672+
'import { useSeoMeta, useServerSeoMeta } from \'unhead\'',
673+
'useSeoMeta({ title: "Client" })',
674+
'useServerSeoMeta({ description: "Server" })',
675+
])
676+
expect(code).toBeDefined()
677+
expect(code).toMatchInlineSnapshot(`
678+
"import { useHead, useServerHead } from 'unhead'
679+
useHead({
680+
title: "Client",
681+
})
682+
useServerHead({
683+
meta: [
684+
{ name: 'description', content: "Server" },
685+
]
686+
})"
687+
`)
688+
})
689+
690+
it('handles conditional meta values', async () => {
691+
const code = await transform([
692+
'import { useSeoMeta } from \'unhead\'',
693+
'const condition = true',
694+
'useSeoMeta({',
695+
' title: condition ? "True Title" : "False Title",',
696+
' description: condition && "Conditional Description"',
697+
'})',
698+
])
699+
expect(code).toBeDefined()
700+
expect(code).toMatchInlineSnapshot(`
701+
"import { useHead } from 'unhead'
702+
const condition = true
703+
useHead({
704+
title: condition ? "True Title" : "False Title",
705+
meta: [
706+
{ name: 'description', content: condition && "Conditional Description" },
707+
]
708+
})"
709+
`)
710+
})
711+
712+
it('handles #import', async () => {
713+
const code = await transform([
714+
'import { useSeoMeta } from \'#imports\'',
715+
'const condition = true',
716+
'useSeoMeta({',
717+
' title: condition ? "True Title" : "False Title",',
718+
' description: condition && "Conditional Description"',
719+
'})',
720+
], 'some-id.js', {
721+
importSpecifiers: ['#imports'],
722+
})
723+
expect(code).toBeDefined()
724+
expect(code).toMatchInlineSnapshot(`
725+
"import { useHead } from '#imports'
726+
const condition = true
727+
useHead({
728+
title: condition ? "True Title" : "False Title",
729+
meta: [
730+
{ name: 'description', content: condition && "Conditional Description" },
731+
]
732+
})"
733+
`)
734+
})
600735
})

0 commit comments

Comments
 (0)
Please sign in to comment.