Skip to content

Commit 6d0e4b4

Browse files
authoredFeb 12, 2025··
perf: drop packrup dependency (#487)
* perf: drop `packrup` dependency * chore: broken build
1 parent 4028432 commit 6d0e4b4

File tree

2 files changed

+145
-117
lines changed

2 files changed

+145
-117
lines changed
 

‎bench/use-seo-meta-perf.bench.ts

+31
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { createHead, renderSSRHead } from 'unhead/server'
2+
import { bench, describe } from 'vitest'
3+
import { useSeoMeta } from '../packages/unhead/src'
4+
5+
describe('use seo meta', () => {
6+
bench('x50 ssr', async () => {
7+
const head = createHead()
8+
const page = {
9+
title: 'Home',
10+
description: 'Home page description',
11+
image: 'https://nuxtjs.org/meta_0.png',
12+
}
13+
for (const i in Array.from({ length: 1000 })) {
14+
useSeoMeta(head, {
15+
// de-dupe keys
16+
title: `${page.title}-${i} | Nuxt`,
17+
description: `${page.description} ${i}`,
18+
ogImage: `${page.image}?${i}`,
19+
ogImageAlt: `${page.image}?${i}`,
20+
ogSiteName: 'Nuxt',
21+
ogType: 'website',
22+
}, {
23+
head,
24+
})
25+
}
26+
await renderSSRHead(head)
27+
}, {
28+
iterations: 1000,
29+
time: 1000,
30+
})
31+
})

‎packages/unhead/src/utils/meta.ts

+114-117
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,64 @@
1-
import type { TransformValueOptions } from 'packrup'
2-
import type { BaseMeta, Head, MetaFlatInput } from 'unhead/types'
3-
import { packArray, unpackToArray, unpackToString } from 'packrup'
1+
import type { BaseMeta, Head, MetaFlatInput } from '../types'
2+
3+
export interface TransformValueOptions {
4+
entrySeparator?: string
5+
keyValueSeparator?: string
6+
resolve?: (ctx: { key: string, value: unknown }) => string | void
7+
}
8+
9+
function unpackToString<T extends Record<keyof T, unknown>>(value: T, options: TransformValueOptions): string {
10+
return Object.entries(value)
11+
.map(([key, value]) => {
12+
if (typeof value === 'object')
13+
value = unpackToString(value as Record<keyof T, any>, options)
14+
if (options.resolve) {
15+
const resolved = options.resolve({ key, value })
16+
if (typeof resolved !== 'undefined')
17+
return resolved
18+
}
19+
return `${fixKeyCase(key)}${options.keyValueSeparator || ''}${String(value)}`
20+
})
21+
.filter(Boolean)
22+
.join(options.entrySeparator || '')
23+
}
24+
25+
interface Context { key: string, value: any }
26+
type ResolveFn = (ctx: Context) => string
27+
28+
export interface UnpackArrayOptions {
29+
key: string | ResolveFn
30+
value: string | ResolveFn
31+
resolveKeyData?: ResolveFn
32+
resolveValueData?: ResolveFn
33+
}
34+
35+
export function unpackToArray(input: Record<string, any>, options: UnpackArrayOptions): Record<string, any>[] {
36+
const unpacked: any[] = []
37+
const kFn = options.resolveKeyData || ((ctx: Context) => ctx.key)
38+
const vFn = options.resolveValueData || ((ctx: Context) => ctx.value)
39+
40+
for (const [k, v] of Object.entries(input)) {
41+
unpacked.push(...(Array.isArray(v) ? v : [v]).flatMap((i) => {
42+
if (String(i) === 'false') {
43+
return false
44+
}
45+
const ctx = { key: k, value: i }
46+
const val = vFn(ctx)
47+
// handle nested objects
48+
if (typeof val === 'object')
49+
return unpackToArray(val!, options)
50+
51+
if (Array.isArray(val))
52+
return val
53+
54+
return {
55+
[typeof options.key === 'function' ? options.key(ctx) : options.key]: kFn(ctx),
56+
[typeof options.value === 'function' ? options.value(ctx) : options.value]: val,
57+
}
58+
}))
59+
}
60+
return unpacked.filter(Boolean)
61+
}
462

563
interface PackingDefinition {
664
metaKey?: keyof BaseMeta
@@ -11,52 +69,47 @@ interface PackingDefinition {
1169
const p = (p: string) => ({ keyValue: p, metaKey: 'property' }) as PackingDefinition
1270
const k = (p: string) => ({ keyValue: p }) as PackingDefinition
1371

14-
const MetaPackingSchema: Record<string, PackingDefinition> = {
15-
appleItunesApp: {
72+
// @ts-expect-error untyped
73+
export const MetaPackingSchema = new Map<string, PackingDefinition>([
74+
['appleItunesApp', {
1675
unpack: {
1776
entrySeparator: ', ',
1877
resolve({ key, value }) {
1978
return `${fixKeyCase(key)}=${value}`
2079
},
2180
},
22-
},
23-
articleExpirationTime: p('article:expiration_time'),
24-
articleModifiedTime: p('article:modified_time'),
25-
articlePublishedTime: p('article:published_time'),
26-
bookReleaseDate: p('book:release_date'),
27-
charset: {
28-
metaKey: 'charset',
29-
},
30-
contentSecurityPolicy: {
81+
}],
82+
['articleExpirationTime', p('article:expiration_time')],
83+
['articleModifiedTime', p('article:modified_time')],
84+
['articlePublishedTime', p('article:published_time')],
85+
['bookReleaseDate', p('book:release_date')],
86+
['charset', { metaKey: 'charset' }],
87+
['contentSecurityPolicy', {
3188
unpack: {
3289
entrySeparator: '; ',
3390
resolve({ key, value }) {
3491
return `${fixKeyCase(key)} ${value}`
3592
},
3693
},
3794
metaKey: 'http-equiv',
38-
},
39-
contentType: {
40-
metaKey: 'http-equiv',
41-
},
42-
defaultStyle: {
43-
metaKey: 'http-equiv',
44-
},
45-
fbAppId: p('fb:app_id'),
46-
msapplicationConfig: k('msapplication-Config'),
47-
msapplicationTileColor: k('msapplication-TileColor'),
48-
msapplicationTileImage: k('msapplication-TileImage'),
49-
ogAudioSecureUrl: p('og:audio:secure_url'),
50-
ogAudioUrl: p('og:audio'),
51-
ogImageSecureUrl: p('og:image:secure_url'),
52-
ogImageUrl: p('og:image'),
53-
ogSiteName: p('og:site_name'),
54-
ogVideoSecureUrl: p('og:video:secure_url'),
55-
ogVideoUrl: p('og:video'),
56-
profileFirstName: p('profile:first_name'),
57-
profileLastName: p('profile:last_name'),
58-
profileUsername: p('profile:username'),
59-
refresh: {
95+
}],
96+
['contentType', { metaKey: 'http-equiv' }],
97+
['defaultStyle', { metaKey: 'http-equiv' }],
98+
['fbAppId', p('fb:app_id')],
99+
['msapplicationConfig', k('msapplication-Config')],
100+
['msapplicationTileColor', k('msapplication-TileColor')],
101+
['msapplicationTileImage', k('msapplication-TileImage')],
102+
['ogAudioSecureUrl', p('og:audio:secure_url')],
103+
['ogAudioUrl', p('og:audio')],
104+
['ogImageSecureUrl', p('og:image:secure_url')],
105+
['ogImageUrl', p('og:image')],
106+
['ogSiteName', p('og:site_name')],
107+
['ogVideoSecureUrl', p('og:video:secure_url')],
108+
['ogVideoUrl', p('og:video')],
109+
['profileFirstName', p('profile:first_name')],
110+
['profileLastName', p('profile:last_name')],
111+
['profileUsername', p('profile:username')],
112+
['refresh', {
60113
metaKey: 'http-equiv',
61114
unpack: {
62115
entrySeparator: ';',
@@ -65,22 +118,22 @@ const MetaPackingSchema: Record<string, PackingDefinition> = {
65118
return `${value}`
66119
},
67120
},
68-
},
69-
robots: {
121+
}],
122+
['robots', {
70123
unpack: {
71124
entrySeparator: ', ',
72125
resolve({ key, value }) {
126+
if (!value) {
127+
return false
128+
}
73129
if (typeof value === 'boolean')
74130
return `${fixKeyCase(key)}`
75-
else
76-
return `${fixKeyCase(key)}:${value}`
131+
return `${fixKeyCase(key)}:${value}`
77132
},
78133
},
79-
},
80-
xUaCompatible: {
81-
metaKey: 'http-equiv',
82-
},
83-
} as const
134+
}],
135+
['xUaCompatible', { metaKey: 'http-equiv' }],
136+
])
84137

85138
const openGraphNamespaces = new Set([
86139
'og',
@@ -94,49 +147,32 @@ export function resolveMetaKeyType(key: string): keyof BaseMeta {
94147
const prefixIndex = fKey.indexOf(':')
95148
if (openGraphNamespaces.has(fKey.substring(0, prefixIndex)))
96149
return 'property'
97-
return MetaPackingSchema[key]?.metaKey || 'name'
150+
return MetaPackingSchema.get(key)?.metaKey || 'name'
98151
}
99152

100153
export function resolveMetaKeyValue(key: string): string {
101-
return MetaPackingSchema[key]?.keyValue || fixKeyCase(key)
154+
return MetaPackingSchema.get(key)?.keyValue || fixKeyCase(key)
102155
}
103156

157+
const UPPERCASE_PATTERN = /([A-Z])/g
158+
104159
function fixKeyCase(key: string) {
105-
const updated = key.replace(/([A-Z])/g, '-$1').toLowerCase()
160+
const updated = key.replace(UPPERCASE_PATTERN, '-$1').toLowerCase()
106161
const prefixIndex = updated.indexOf('-')
107162
const fKey = updated.substring(0, prefixIndex)
108163
if (fKey === 'twitter' || openGraphNamespaces.has(fKey))
109-
return key.replace(/([A-Z])/g, ':$1').toLowerCase()
164+
return key.replace(UPPERCASE_PATTERN, ':$1').toLowerCase()
110165
return updated
111166
}
112167

113-
function changeKeyCasingDeep(input: any): any {
114-
if (Array.isArray(input)) {
115-
return input.map(entry => changeKeyCasingDeep(entry))
116-
}
117-
if (typeof input !== 'object' || Array.isArray(input))
118-
return input
119-
120-
const output: Record<string, any> = {}
121-
for (const key in input) {
122-
if (!Object.prototype.hasOwnProperty.call(input, key)) {
123-
continue
124-
}
125-
output[fixKeyCase(key)] = changeKeyCasingDeep(input[key])
126-
}
127-
128-
return output
129-
}
130-
131168
export function resolvePackedMetaObjectValue(value: string, key: string): string {
132-
const definition = MetaPackingSchema[key]
133-
169+
const definition = MetaPackingSchema.get(key)
134170
// refresh is weird...
135171
if (key === 'refresh')
136172
// @ts-expect-error untyped
137173
return `${value.seconds};url=${value.url}`
138174
return unpackToString(
139-
changeKeyCasingDeep(value),
175+
value,
140176
{
141177
keyValueSeparator: '=',
142178
entrySeparator: ', ',
@@ -153,41 +189,29 @@ export function resolvePackedMetaObjectValue(value: string, key: string): string
153189

154190
const ObjectArrayEntries = new Set(['og:image', 'og:video', 'og:audio', 'twitter:image'])
155191

156-
function sanitize(input: Record<string, any>) {
157-
const out: Record<string, any> = {}
158-
for (const k in input) {
159-
if (!Object.prototype.hasOwnProperty.call(input, k)) {
160-
continue
161-
}
162-
const v = input[k]
163-
if (String(v) !== 'false' && k)
164-
out[k] = v
165-
}
166-
return out
167-
}
168-
169192
function handleObjectEntry(key: string, v: Record<string, any>) {
170193
// filter out falsy values
171-
const value: Record<string, any> = sanitize(v)
172194
const fKey = fixKeyCase(key)
173195
const attr = resolveMetaKeyType(fKey)
174196
if (ObjectArrayEntries.has(fKey as keyof MetaFlatInput)) {
175197
const input: MetaFlatInput = {}
176-
for (const k in value) {
177-
if (!Object.prototype.hasOwnProperty.call(value, k)) {
198+
for (const k in v) {
199+
if (!Object.prototype.hasOwnProperty.call(v, k)) {
178200
continue
179201
}
180202

181203
// we need to prefix the keys with og:
182-
// @ts-expect-error untyped
183-
input[`${key}${k === 'url' ? '' : `${k[0].toUpperCase()}${k.slice(1)}`}`] = value[k]
204+
if (String(v[k]) !== 'false') {
205+
// @ts-expect-error untyped
206+
input[`${key}${k === 'url' ? '' : `${k[0].toUpperCase()}${k.slice(1)}`}`] = v[k]
207+
}
184208
}
185209
return unpackMeta(input)
186210
// sort by property name
187211
// @ts-expect-error untyped
188212
.sort((a, b) => (a[attr]?.length || 0) - (b[attr]?.length || 0)) as BaseMeta[]
189213
}
190-
return [{ [attr]: fKey, ...value }] as BaseMeta[]
214+
return [{ [attr]: fKey, ...v }] as BaseMeta[]
191215
}
192216

193217
/**
@@ -211,11 +235,8 @@ export function unpackMeta<T extends MetaFlatInput>(input: T): Required<Head>['m
211235
extras.push(...handleObjectEntry(key, value))
212236
continue
213237
}
214-
primitives[key] = sanitize(value)
215-
}
216-
else {
217-
primitives[key] = value
218238
}
239+
primitives[key] = value
219240
continue
220241
}
221242
for (const v of value) {
@@ -250,27 +271,3 @@ export function unpackMeta<T extends MetaFlatInput>(input: T): Required<Head>['m
250271
return m
251272
}) as unknown as Required<Head>['meta']
252273
}
253-
254-
/**
255-
* Convert an array of meta entries to a flat object.
256-
* @param inputs
257-
*/
258-
export function packMeta<T extends Required<Head>['meta']>(inputs: T): MetaFlatInput {
259-
const mappedPackingSchema = Object.entries(MetaPackingSchema)
260-
.map(([key, value]) => [key, value.keyValue])
261-
262-
return packArray(inputs, {
263-
key: ['name', 'property', 'httpEquiv', 'http-equiv', 'charset'],
264-
value: ['content', 'charset'],
265-
resolveKey(k) {
266-
let key = (mappedPackingSchema.filter(sk => sk[1] === k)?.[0]?.[0] || k) as string
267-
// turn : into a capital letter
268-
// @ts-expect-error untyped
269-
const replacer = (_, letter) => letter?.toUpperCase()
270-
key = key
271-
.replace(/:([a-z])/g, replacer)
272-
.replace(/-([a-z])/g, replacer)
273-
return key as string
274-
},
275-
})
276-
}

0 commit comments

Comments
 (0)
Please sign in to comment.