Skip to content

Commit e6384c3

Browse files
committed
feat: class array / object support
1 parent 4777d95 commit e6384c3

File tree

6 files changed

+245
-8
lines changed

6 files changed

+245
-8
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ Universal document <head> tag manager. Tiny, adaptable and full featured.
3434
- 🤝 Built for everyone: Vue, React (soon), Svelte (soon), etc.
3535
- 🚀 Optimised, tiny SSR and DOM bundles
3636
- 🖥️ `useServerHead` for 0kb runtime head management
37-
- 🍣 Intuitive tag deduping, sorting and title templates
37+
- 🍣 Intuitive deduping, sorting, title templates, class merging and more
3838
- 🪝 Extensible hook / plugin based API
3939

4040
## Install

packages/schema/src/schema.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,28 @@ export interface SchemaAugmentations extends MergeHead {
1919
noscript: TagUserProperties
2020
}
2121

22-
type MaybeArray<T> = T | T[]
22+
export type MaybeArray<T> = T | T[]
23+
24+
export type BaseBodyAttr = _BodyAttributes
25+
export type BaseHtmlAttr = _HtmlAttributes
26+
27+
interface BodyAttr extends Omit<BaseBodyAttr, 'class'> {
28+
/**
29+
* The class global attribute is a space-separated list of the case-sensitive classes of the element.
30+
*
31+
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class
32+
*/
33+
class?: MaybeArray<string> | Record<string, boolean>
34+
}
35+
36+
interface HtmlAttr extends Omit<_HtmlAttributes, 'class'> {
37+
/**
38+
* The class global attribute is a space-separated list of the case-sensitive classes of the element.
39+
*
40+
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class
41+
*/
42+
class?: MaybeArray<string> | Record<string, boolean>
43+
}
2344

2445
interface BaseMeta extends Omit<_Meta, 'content'> {
2546
/**
@@ -34,6 +55,8 @@ interface BaseMeta extends Omit<_Meta, 'content'> {
3455

3556
export type EntryAugmentation = undefined | Record<string, any>
3657

58+
export { DataKeys, DefinedValueOrEmptyObject }
59+
3760
export type Title = string
3861
export type TitleTemplate = string | null | ((title?: string) => string)
3962
export type Base<E extends EntryAugmentation = {}> = Partial<Merge<SchemaAugmentations['base'], _Base>> & DefinedValueOrEmptyObject<E>
@@ -42,8 +65,8 @@ export type Meta<E extends EntryAugmentation = {}> = BaseMeta & DataKeys & Schem
4265
export type Style<E extends EntryAugmentation = {}> = _Style & DataKeys & SchemaAugmentations['style'] & DefinedValueOrEmptyObject<E>
4366
export type Script<E extends EntryAugmentation = {}> = _Script & DataKeys & SchemaAugmentations['script'] & DefinedValueOrEmptyObject<E>
4467
export type Noscript<E extends EntryAugmentation = {}> = _Noscript & DataKeys & SchemaAugmentations['noscript'] & DefinedValueOrEmptyObject<E>
45-
export type HtmlAttributes<E extends EntryAugmentation = {}> = _HtmlAttributes & DataKeys & SchemaAugmentations['htmlAttrs'] & DefinedValueOrEmptyObject<E>
46-
export type BodyAttributes<E extends EntryAugmentation = {}> = _BodyAttributes & DataKeys & SchemaAugmentations['bodyAttrs'] & DefinedValueOrEmptyObject<E>
68+
export type HtmlAttributes<E extends EntryAugmentation = {}> = HtmlAttr & DataKeys & SchemaAugmentations['htmlAttrs'] & DefinedValueOrEmptyObject<E>
69+
export type BodyAttributes<E extends EntryAugmentation = {}> = BodyAttr & DataKeys & SchemaAugmentations['bodyAttrs'] & DefinedValueOrEmptyObject<E>
4770

4871
export interface Head<E extends MergeHead = SchemaAugmentations> {
4972
/**

packages/unhead/src/plugin/dedupePlugin.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ export const dedupePlugin = defineHeadPlugin({
2323
let dedupeKey = tag._d || tag._p || i
2424
const dupedTag = deduping[dedupeKey]
2525
if (dupedTag) {
26-
if (tag?.tagDuplicateStrategy === 'merge') {
26+
let strategy = tag?.tagDuplicateStrategy
27+
if (!strategy && (tag.tag === 'htmlAttrs' || tag.tag === 'bodyAttrs'))
28+
strategy = 'merge'
29+
30+
if (strategy === 'merge') {
2731
const oldProps = dupedTag.props
2832
// apply oldProps to current props
2933
;['class', 'style'].forEach((key) => {

packages/vue/src/types/schema.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,26 @@
11
import type { MaybeComputedRef, MaybeRef } from '@vueuse/shared'
2-
import type { EntryAugmentation, MergeHead, Base as _Base, BodyAttributes as _BodyAttributes, HtmlAttributes as _HtmlAttributes, Link as _Link, Meta as _Meta, Noscript as _Noscript, Script as _Script, Style as _Style, Title as _Title, TitleTemplate as _TitleTemplate } from '@unhead/schema'
2+
import type { BaseBodyAttr, BaseHtmlAttr, EntryAugmentation, MaybeArray, MergeHead, SchemaAugmentations, Base as _Base, Link as _Link, Meta as _Meta, Noscript as _Noscript, Script as _Script, Style as _Style, Title as _Title, TitleTemplate as _TitleTemplate } from '@unhead/schema'
3+
import type { DataKeys, DefinedValueOrEmptyObject } from '@zhead/schema'
34
import type { MaybeComputedRefEntries } from './util'
45

6+
interface HtmlAttr extends Omit<BaseHtmlAttr, 'class'> {
7+
/**
8+
* The class global attribute is a space-separated list of the case-sensitive classes of the element.
9+
*
10+
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class
11+
*/
12+
class?: MaybeArray<MaybeComputedRef<string>> | Record<string, MaybeComputedRef<boolean>>
13+
}
14+
15+
interface BodyAttr extends Omit<BaseBodyAttr, 'class'> {
16+
/**
17+
* The class global attribute is a space-separated list of the case-sensitive classes of the element.
18+
*
19+
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/class
20+
*/
21+
class?: MaybeArray<MaybeComputedRef<string>> | Record<string, MaybeComputedRef<boolean>>
22+
}
23+
524
export type Title = MaybeComputedRef<_Title>
625
export type TitleTemplate = MaybeRef<_TitleTemplate> | ((title?: string) => _TitleTemplate)
726
export type Base<E extends EntryAugmentation = {}> = MaybeComputedRef<MaybeComputedRefEntries<_Base<E>>>
@@ -10,8 +29,8 @@ export type Meta<E extends EntryAugmentation = {}> = MaybeComputedRefEntries<_Me
1029
export type Style<E extends EntryAugmentation = {}> = MaybeComputedRefEntries<_Style<E>>
1130
export type Script<E extends EntryAugmentation = {}> = MaybeComputedRefEntries<_Script<E>>
1231
export type Noscript<E extends EntryAugmentation = {}> = MaybeComputedRefEntries<_Noscript<E>>
13-
export type HtmlAttributes<E extends EntryAugmentation = {}> = MaybeComputedRef<MaybeComputedRefEntries<_HtmlAttributes<E>>>
14-
export type BodyAttributes<E extends EntryAugmentation = {}> = MaybeComputedRef<MaybeComputedRefEntries<_BodyAttributes<E>>>
32+
export type HtmlAttributes<E extends EntryAugmentation = {}> = MaybeComputedRef<MaybeComputedRefEntries<HtmlAttr & DataKeys & SchemaAugmentations['htmlAttrs'] & DefinedValueOrEmptyObject<E>>>
33+
export type BodyAttributes<E extends EntryAugmentation = {}> = MaybeComputedRef<MaybeComputedRefEntries<BodyAttr & DataKeys & SchemaAugmentations['bodyAttrs'] & DefinedValueOrEmptyObject<E>>>
1534

1635
export interface ReactiveHead<E extends MergeHead = MergeHead> {
1736
/**

test/unhead/resolveTags.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,4 +210,86 @@ describe('resolveTags', () => {
210210
]
211211
`)
212212
})
213+
214+
it('class array merge support', async () => {
215+
const head = createHead()
216+
217+
head.push({
218+
htmlAttrs: {
219+
class: ['foo', 'bar'],
220+
},
221+
bodyAttrs: {
222+
class: ['foo2', 'bar2'],
223+
},
224+
})
225+
226+
head.push({
227+
htmlAttrs: {
228+
class: ['something-new'],
229+
},
230+
bodyAttrs: {
231+
class: 'something-new2',
232+
},
233+
})
234+
235+
const tags = await head.resolveTags()
236+
expect(tags).toMatchInlineSnapshot(`
237+
[
238+
{
239+
"_d": "htmlAttrs",
240+
"_e": 0,
241+
"_p": 0,
242+
"props": {
243+
"class": "foo bar something-new",
244+
},
245+
"tag": "htmlAttrs",
246+
},
247+
{
248+
"_d": "bodyAttrs",
249+
"_e": 0,
250+
"_p": 1,
251+
"props": {
252+
"class": "foo2 bar2 something-new2",
253+
},
254+
"tag": "bodyAttrs",
255+
},
256+
]
257+
`)
258+
})
259+
260+
it('class object merge support', async () => {
261+
const head = createHead()
262+
263+
head.push({
264+
htmlAttrs: {
265+
class: {
266+
foo: true,
267+
bar: false,
268+
},
269+
},
270+
})
271+
272+
head.push({
273+
htmlAttrs: {
274+
class: {
275+
bar: true,
276+
},
277+
},
278+
})
279+
280+
const tags = await head.resolveTags()
281+
expect(tags).toMatchInlineSnapshot(`
282+
[
283+
{
284+
"_d": "htmlAttrs",
285+
"_e": 0,
286+
"_p": 0,
287+
"props": {
288+
"class": "foo bar",
289+
},
290+
"tag": "htmlAttrs",
291+
},
292+
]
293+
`)
294+
})
213295
})

test/vue/resolveTags.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it } from 'vitest'
2+
import type { Ref } from 'vue'
3+
import { ref } from 'vue'
4+
import { createHead, useHead } from '../../packages/vue/src'
5+
6+
describe('resolveTags', () => {
7+
it('basic resolve tags', async () => {
8+
const head = createHead()
9+
10+
useHead({
11+
htmlAttrs: { class: 'first-class' },
12+
})
13+
14+
useHead({
15+
htmlAttrs: { class: 'second-class' },
16+
})
17+
18+
const tags = await head.resolveTags()
19+
expect(tags).toMatchInlineSnapshot(`
20+
[
21+
{
22+
"_d": "htmlAttrs",
23+
"_e": 0,
24+
"_p": 0,
25+
"props": {
26+
"class": "first-class second-class",
27+
},
28+
"tag": "htmlAttrs",
29+
},
30+
]
31+
`)
32+
})
33+
34+
it('conditional classes', async () => {
35+
const head = createHead()
36+
37+
const theme: Ref<'dark' | 'light'> = ref('dark')
38+
39+
useHead({
40+
htmlAttrs: {
41+
class: {
42+
'layout-theme-dark': () => theme.value === 'dark',
43+
'layout-theme-light': () => theme.value === 'light',
44+
},
45+
},
46+
bodyAttrs: {
47+
class: ['test', () => `theme-${theme.value}`],
48+
},
49+
})
50+
51+
const page: Ref<{ name: string }> = ref({ name: 'home' })
52+
useHead({
53+
htmlAttrs: {
54+
class: () => page.value.name,
55+
},
56+
})
57+
58+
const tags = await head.resolveTags()
59+
expect(tags).toMatchInlineSnapshot(`
60+
[
61+
{
62+
"_d": "htmlAttrs",
63+
"_e": 0,
64+
"_p": 0,
65+
"props": {
66+
"class": "layout-theme-dark home",
67+
},
68+
"tag": "htmlAttrs",
69+
},
70+
{
71+
"_d": "bodyAttrs",
72+
"_e": 0,
73+
"_p": 1,
74+
"props": {
75+
"class": "test theme-dark",
76+
},
77+
"tag": "bodyAttrs",
78+
},
79+
]
80+
`)
81+
})
82+
it('basic resolve tags', async () => {
83+
const head = createHead()
84+
85+
useHead({
86+
htmlAttrs: { class: 'first-class' },
87+
})
88+
89+
useHead({
90+
htmlAttrs: { class: 'second-class', tagDuplicateStrategy: 'replace' },
91+
})
92+
93+
const tags = await head.resolveTags()
94+
expect(tags).toMatchInlineSnapshot(`
95+
[
96+
{
97+
"_d": "htmlAttrs",
98+
"_e": 1,
99+
"_p": 0,
100+
"props": {
101+
"class": "second-class",
102+
},
103+
"tag": "htmlAttrs",
104+
"tagDuplicateStrategy": "replace",
105+
},
106+
]
107+
`)
108+
})
109+
})

0 commit comments

Comments
 (0)