Skip to content

Commit

Permalink
fix(titleTemplate): allow titleTemplate to resolve the title (#137)
Browse files Browse the repository at this point in the history
* fix(titleTemplate): allow titleTemplate to resolve the title

* chore: add happy case

* chore: fix type issues

* chore: maybe fix type issue

* chore: maybe fix type issue

* chore: maybe fix type issue

* chore: maybe fix type issue
  • Loading branch information
harlan-zw committed Oct 12, 2022
1 parent df127eb commit dd78d17
Show file tree
Hide file tree
Showing 7 changed files with 178 additions and 48 deletions.
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@
},
"dependencies": {
"@vueuse/shared": "^9.3.0",
"@zhead/schema": "^0.9.0",
"@zhead/schema-vue": "^0.9.0"
"@zhead/schema": "^0.9.5",
"@zhead/schema-vue": "^0.9.5"
},
"devDependencies": {
"@antfu/eslint-config": "^0.27.0",
Expand Down
18 changes: 9 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export interface HeadEntry<T extends MergeHead = {}> {
id: number
}

export type TagKeys = keyof Omit<HeadObjectPlain, 'titleTemplate'>
export type TagKeys = keyof HeadObjectPlain

export interface HeadTag {
tag: TagKeys
Expand Down
72 changes: 50 additions & 22 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { unref } from 'vue'
import type { MergeHead } from '@zhead/schema'
import type {
HeadEntry,
HeadObjectPlain,
HeadTag,
HeadTagOptions,
ResolvedUseHeadInput,
Expand Down Expand Up @@ -41,7 +40,7 @@ export const sortTags = (aTag: HeadTag, bTag: HeadTag) => {
export const tagDedupeKey = <T extends HeadTag>(tag: T) => {
const { props, tag: tagName, options } = tag
// must only be a single base so we always dedupe
if (tagName === 'base' || tagName === 'title')
if (tagName === 'base' || tagName === 'title' || tagName === 'titleTemplate')
return tagName

// support only a single canonical
Expand Down Expand Up @@ -104,7 +103,6 @@ const resolveTag = (name: TagKeys, input: Record<string, any>, e: HeadEntry): He
props: {},
runtime: {
entryId: e.id,
position: 0,
},
// tag inherits options from useHead registration
options: {
Expand Down Expand Up @@ -143,12 +141,20 @@ const resolveTag = (name: TagKeys, input: Record<string, any>, e: HeadEntry): He

export const headInputToTags = (e: HeadEntry) => {
return Object.entries(e.input)
.filter(([k, v]) => typeof v !== 'undefined' && v !== null && k !== 'titleTemplate')
.filter(([, v]) => typeof v !== 'undefined')
.map(([key, value]) => {
return (Array.isArray(value) ? value : [value]).map((props) => {
switch (key) {
case 'title':
return resolveTag(key, { children: props }, e)
case 'titleTemplate':
// titleTemplate is a fake tag so we can dedupe it, this will be removed
return <HeadTag> {
tag: key,
children: props,
props: {},
runtime: { entryId: e.id },
options: e.options,
}
case 'base':
case 'meta':
case 'link':
Expand All @@ -169,11 +175,11 @@ export const headInputToTags = (e: HeadEntry) => {
}

const renderTitleTemplate = (
template: Required<HeadObjectPlain>['titleTemplate'],
template: string | ((title?: string) => string | null),
title?: string,
): string => {
): string | null => {
if (template == null)
return ''
return title || null
if (typeof template === 'function')
return template(title)

Expand All @@ -185,11 +191,6 @@ export const resolveHeadEntriesToTags = (entries: HeadEntry[]) => {

const resolvedEntries = resolveHeadEntries(entries)

const titleTemplate = resolvedEntries
.map(i => i.input.titleTemplate)
.reverse()
.find(i => i != null)

resolvedEntries.forEach((entry, entryIndex) => {
const tags = headInputToTags(entry)
tags.forEach((tag, tagIdx) => {
Expand All @@ -199,20 +200,47 @@ export const resolveHeadEntriesToTags = (entries: HeadEntry[]) => {
// ideally we'd use the total tag count but this is too hard to calculate with the current reactivity
tag.runtime.position = entryIndex * 10000 + tagIdx

// resolve titles
if (titleTemplate && tag.tag === 'title') {
tag.children = renderTitleTemplate(
titleTemplate,
tag.children,
)
}

// Remove tags with the same key
deduping[tagDedupeKey(tag)] = tag
})
})

return Object.values(deduping)
let resolvedTags = Object.values(deduping)
.sort((a, b) => a.runtime!.position! - b.runtime!.position!)
.sort(sortTags)

// resolve title
const titleTemplateIdx = resolvedTags.findIndex(i => i.tag === 'titleTemplate')
const titleIdx = resolvedTags.findIndex(i => i.tag === 'title')
if (titleIdx !== -1 && titleTemplateIdx !== -1) {
const newTitle = renderTitleTemplate(
resolvedTags[titleTemplateIdx].children!,
resolvedTags[titleIdx].children,
)
if (newTitle !== null) {
resolvedTags[titleIdx].children = newTitle || resolvedTags[titleIdx].children
}
else {
// remove the title
resolvedTags = resolvedTags.filter((_, i) => i !== titleIdx)
}
// remove the title template
resolvedTags = resolvedTags.filter((_, i) => i !== titleTemplateIdx)
}
// titleTemplate is set but title is not set, convert to a title
else if (titleTemplateIdx !== -1) {
const newTitle = renderTitleTemplate(
resolvedTags[titleTemplateIdx].children!,
)
if (newTitle !== null) {
resolvedTags[titleTemplateIdx].children = newTitle
resolvedTags[titleTemplateIdx].tag = 'title'
}
else {
// remove the title template
resolvedTags = resolvedTags.filter((_, i) => i !== titleTemplateIdx)
}
}

return resolvedTags
}
3 changes: 2 additions & 1 deletion tests/reactivity-types.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { computed, createSSRApp, ref } from 'vue'
import { renderToString } from '@vue/server-renderer'
import type { UseHeadInput } from '@vueuse/head'
import { createHead, renderHeadToString, useHead } from '../src'
import type { HeadObjectPlain } from '../src/types'
import { ssrRenderHeadToString } from './shared/utils'
Expand Down Expand Up @@ -154,7 +155,7 @@ describe('reactivity', () => {

test('computed getter entries', async () => {
const test = ref('test')
const input = {
const input: UseHeadInput = {
title: () => 'my title',
script: () => {
return [
Expand Down
96 changes: 96 additions & 0 deletions tests/title-template.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useHead } from '../src'
import { ssrRenderHeadToString } from './shared/utils'

describe('titleTemplate', () => {
test('string replace', async () => {
const titleTemplate = '%s - my template'
const headResult = await ssrRenderHeadToString(() => {
useHead({
titleTemplate,
title: 'test',
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<title>test - my template</title><meta name=\\"head:count\\" content=\\"0\\">"',
)
})
test('fn replace', async () => {
const titleTemplate = (title?: string) => `${title} - my template`
const headResult = await ssrRenderHeadToString(() => {
useHead({
titleTemplate,
title: 'test',
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<title>test - my template</title><meta name=\\"head:count\\" content=\\"0\\">"',
)
})
test('titleTemplate as title', async () => {
const titleTemplate = (title?: string) => title ? `${title} - Template` : 'Default Title'
const headResult = await ssrRenderHeadToString(() => {
useHead({
titleTemplate,
title: null,
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<title>Default Title</title><meta name=\\"head:count\\" content=\\"0\\">"',
)
})
test('reset title template', async () => {
const titleTemplate = (title?: string) => title ? `${title} - Template` : 'Default Title'
const headResult = await ssrRenderHeadToString(() => {
useHead({
titleTemplate,
})
useHead({
// resets the titleTemplate
titleTemplate: null,
title: 'page title',
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<title>page title</title><meta name=\\"head:count\\" content=\\"0\\">"',
)
})

test('nested title template', async () => {
const titleTemplate = (title?: string | null) => title ? `${title} - Template` : 'Default Title'
const headResult = await ssrRenderHeadToString(() => {
useHead({
titleTemplate,
})
useHead({
titleTemplate: null,
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<meta name=\\"head:count\\" content=\\"0\\">"',
)
})

test('null fn return', async () => {
const titleTemplate = (title?: string | null) => title === 'test' ? null : `${title} - Template`
const headResult = await ssrRenderHeadToString(() => {
useHead({
titleTemplate,
title: 'test',
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<meta name=\\"head:count\\" content=\\"0\\">"',
)
})

test('empty title', async () => {
const headResult = await ssrRenderHeadToString(() => {
useHead({
title: '',
})
})
expect(headResult.headTags).toMatchInlineSnapshot(
'"<title></title><meta name=\\"head:count\\" content=\\"0\\">"',
)
})
})
31 changes: 18 additions & 13 deletions tests/vue-ssr.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ describe('vue ssr', () => {
const app = createSSRApp({
async setup() {
const title = ref('initial title')
useHead({ title })
useHead({
title,
})
await new Promise(resolve => setTimeout(resolve, 200))
title.value = 'new title'
return () => <div>hi</div>
Expand Down Expand Up @@ -121,18 +123,21 @@ describe('vue ssr', () => {
})

test('script key', async () => {
const headResult = await ssrRenderHeadToString(() => useHead({
script: [
{
key: 'my-script',
innerHTML: 'console.log(\'A\')',
},
{
key: 'my-script',
innerHTML: 'console.log(\'B\')',
},
],
}))
const headResult = await ssrRenderHeadToString(() =>
useHead({
script: [
{
src: 'test',
key: 'my-script',
innerHTML: 'console.log(\'A\')',
},
{
key: 'my-script',
innerHTML: 'console.log(\'B\')',
},
],
}),
)

expect(headResult.headTags).toMatchInlineSnapshot(
'"<script>console.log(\'B\')</script><meta name=\\"head:count\\" content=\\"1\\">"',
Expand Down

0 comments on commit dd78d17

Please sign in to comment.