Skip to content

Commit

Permalink
fix: simplified tag prop normalisation
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Sep 10, 2023
1 parent 0a2cff5 commit 26c9f9e
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 114 deletions.
97 changes: 71 additions & 26 deletions docs/content/1.usage/2.guides/6.template-params.md
Expand Up @@ -3,11 +3,79 @@ title: Template Params
description: Use template params to simplify your code.
---

Template params act as a way of providing runtime data to your `title`, `titleTemplate`, or `meta.content` tags.
Template parameters allow you to write runtime templates within your tags in a SSR-friendly and easy to use way.

They accept an object where the values must be strings or nested objects (or computed strings / objects for Vue).
They are always prefixed with a `%` symbol and are case-sensitive. By default, they will work with the following tags:
- `title`
- `titleTemplate`
- `<meta content>`
- `<link href>`

### Example
```ts
useHead({
title: 'Hello %name',
templateParams: { name: 'World' }
})
// title: Hello World
```

## Tag Opt-In

For tags using `innerHTML` or `textContent` you can opt-in to template param processing by passing the
`processTemplateParams: true` option.

```ts
useHead({
templateParams: { name: 'My App' },
script: [
{
innerHTML: { name: '%name' },
type: 'application/json',
processTemplateParams: true
}
]
})
// <script type="application/json>{ "name": "My App" }</script>
```

## Tag Opt-Out

For tags that process template params by default, you can opt out of the processing by using `processTemplateParams: false`.

```ts
useHead({
title: 'Hello %name',
templateParams: { name: 'World' },
}, {
processTemplateParams: false,
})
// Title: Hello %name
```

## Separator

The `separator` is a special param for templating. When the template is substituted, the separator will be trimmed from the start and end of the string, in the case where some params are empty.

```ts
useHead({
templateParams: {
site: {
name: 'My Site',
},
separator: '-',
subPage: null // empty
},
title: 'My Page',
titleTemplate: '%s %separator %subPage% %separator %site.name',
})
```

```html
<title>My Page - My Site</title>
```


## Advanced Example

```ts
useHead({
Expand Down Expand Up @@ -47,26 +115,3 @@ The following HTML will be generated:
<meta property="og:url" content="https://example.com/my-page">
</head>
```

### Separator


The `separator` is a special param for templating. When the template is substituted, the separator will be trimmed from the start and end of the string, in the case where some params are empty.

```ts
useHead({
templateParams: {
site: {
name: 'My Site',
},
separator: '-',
subPage: null // empty
},
title: 'My Page',
titleTemplate: '%s %separator %subPage% %separator %site.name',
})
```

```html
<title>My Page - My Site</title>
```
2 changes: 1 addition & 1 deletion packages/shared/src/constants.ts
@@ -1,5 +1,5 @@
export const SelfClosingTags = ['meta', 'link', 'base']
export const TagsWithInnerContent = ['title', 'script', 'style', 'noscript']
export const TagsWithInnerContent = ['title', 'titleTemplate', 'script', 'style', 'noscript']
export const HasElementTags = [
'base',
'meta',
Expand Down
141 changes: 57 additions & 84 deletions packages/shared/src/normalise.ts
@@ -1,89 +1,60 @@
import type { Head, HeadEntry, HeadTag, TagPriority } from '@unhead/schema'
import type { Head, HeadEntry, HeadTag } from '@unhead/schema'
import { TagConfigKeys, TagsWithInnerContent, ValidHeadTags, asArray } from '.'

export async function normaliseTag<T extends HeadTag>(tagName: T['tag'], input: HeadTag['props'] | string, e: HeadEntry<T>): Promise<T | T[] | false> {
const tag = { tag: tagName, props: {} } as T
if (input instanceof Promise)
input = await input

if (tagName === 'templateParams') {
// input can be a function or an object, we need to clone it
const tag = {
tag: tagName,
props: await normaliseProps<T>(
// explicitly check for an object
typeof input === 'object' && typeof input !== 'function' && !(input instanceof Promise)
? { ...input }
: { [['script', 'noscript', 'style'].includes(tagName) ? 'innerHTML' : 'textContent']: input },
['templateParams', 'titleTemplate'].includes(tagName),
),
} as T
// merge options from the entry
TagConfigKeys.forEach((k) => {
// @ts-expect-error untyped
tag.props = input
return tag
}
if (['title', 'titleTemplate'].includes(tagName)) {
// title and titleTemplate can be a string or an object with the priority
if (input && typeof input === 'object') {
tag.textContent = input.textContent
if (input.tagPriority)
tag.tagPriority = input.tagPriority as TagPriority['tagPriority']
}
else {
tag.textContent = input as string
const val = typeof tag.props[k] !== 'undefined' ? tag.props[k] : e[k]
if (typeof val !== 'undefined') {
// strip innerHTML and textContent for tags which don't support it
if (!['innerHTML', 'textContent'].includes(k) || TagsWithInnerContent.includes(tag.tag)) {
// @ts-expect-error untyped
tag[k] = val
}
delete tag.props[k]
}
return tag
}
// allow shorthands
if (typeof input === 'string') {
// unsupported shorthand
if (!['script', 'noscript', 'style'].includes(tagName))
return false

// if string starts with "/", "http://" or "https://" then assume it's a src
if (tagName === 'script' && (/^(https?:)?\/\//.test(input) || input.startsWith('/')))
tag.props.src = input

else
tag.innerHTML = input

return tag
}

})
// Deprecated prop support
if (input.body) {
if (tag.props.body) {
// inserting dangerous javascript potentially
input.tagPosition = 'bodyClose'
tag.tagPosition = 'bodyClose'
// clean up
delete input.body
delete tag.props.body
}
// `children` is deprecated but still supported
if (input.children) {
if (tag.props.children) {
// inserting dangerous javascript potentially
input.innerHTML = input.children
tag.innerHTML = tag.props.children
// clean up
delete input.children
delete tag.props.children
}
tag.props = await normaliseProps<T>({ ...input })

Object.keys(tag.props)
.filter(k => TagConfigKeys.includes(k))
.forEach((k) => {
// strip innerHTML and textContent for tags which don't support it
if (!['innerHTML', 'textContent'].includes(k) || TagsWithInnerContent.includes(tag.tag)) {
// @ts-expect-error untyped
tag[k] = tag.props[k]
}
delete tag.props[k]
})

// merge options from the entry
TagConfigKeys.forEach((k) => {
// @ts-expect-error untyped
if (!tag[k] && e[k]) {
// @ts-expect-error untyped
tag[k] = e[k]
}
})

// stringify js objects
if (tag.tag === 'script' && typeof tag.innerHTML === 'object')
// shorthand for objects
if (tag.tag === 'script' && typeof tag.innerHTML === 'object') {
tag.innerHTML = JSON.stringify(tag.innerHTML)

tag.props.type = tag.props.type || 'application/json'
}
else
// shorthand script: [ 'https://example.com/script.js' ]
if (tag.tag === 'script' && tag.innerHTML && (/^(https?:)?\/\//.test(tag.innerHTML) || tag.innerHTML.startsWith('/'))) {
tag.props.src = tag.innerHTML
delete tag.innerHTML
}
// allow meta to be resolved into multiple tags if an array is provided on content
if (tag.props.content && Array.isArray(tag.props.content))
return tag.props.content.map(v => ({ ...tag, props: { ...tag.props, content: v } } as T))

return tag
return Array.isArray(tag.props.content)
? tag.props.content.map(v => ({ ...tag, props: { ...tag.props, content: v } } as T))
: tag
}

export function normaliseClassProp(v: Required<Required<Head>['htmlAttrs']['class']>) {
Expand All @@ -99,7 +70,7 @@ export function normaliseClassProp(v: Required<Required<Head>['htmlAttrs']['clas
.join(' ')
}

export async function normaliseProps<T extends HeadTag>(props: T['props']): Promise<T['props']> {
export async function normaliseProps<T extends HeadTag>(props: T['props'], virtual?: boolean): Promise<T['props']> {
// handle boolean props, see https://html.spec.whatwg.org/#boolean-attributes
for (const k of Object.keys(props)) {
// class has special handling
Expand All @@ -113,19 +84,21 @@ export async function normaliseProps<T extends HeadTag>(props: T['props']): Prom
if (props[k] instanceof Promise)
// @ts-expect-error untyped
props[k] = await props[k]
const v = String(props[k])
// data keys get special treatment, we opt for more verbose syntax
const isDataKey = k.startsWith('data-')
if (v === 'true' || v === '') {
// @ts-expect-error untyped
props[k] = isDataKey ? 'true' : true
}
else if (!props[k]) {
if (isDataKey && v === 'false')
if (!virtual && !TagConfigKeys.includes(k)) {
const v = String(props[k])
// data keys get special treatment, we opt for more verbose syntax
const isDataKey = k.startsWith('data-')
if (v === 'true' || v === '') {
// @ts-expect-error untyped
props[k] = 'false'
else
delete props[k]
props[k] = isDataKey ? 'true' : true
}
else if (!props[k]) {
if (isDataKey && v === 'false')
// @ts-expect-error untyped
props[k] = 'false'
else
delete props[k]
}
}
}
return props
Expand Down
2 changes: 1 addition & 1 deletion test/unhead/dom/innerHTML.test.ts
Expand Up @@ -34,7 +34,7 @@ describe('dom innerHTML', () => {
expect(await useDelayedSerializedDom()).toMatchInlineSnapshot(`
"<!DOCTYPE html><html><head>
<script>{\\"test\\":\\"test\\",\\"something\\":{\\"else\\":123}}</script><script type=\\"speculationrules\\">{\\"prefetch\\":[{\\"source\\":\\"list\\",\\"urls\\":[\\"/test\\"],\\"requires\\":[\\"anonymous-client-ip-when-cross-origin\\"]}]}</script></head>
<script type=\\"application/json\\">{\\"test\\":\\"test\\",\\"something\\":{\\"else\\":123}}</script><script type=\\"speculationrules\\">{\\"prefetch\\":[{\\"source\\":\\"list\\",\\"urls\\":[\\"/test\\"],\\"requires\\":[\\"anonymous-client-ip-when-cross-origin\\"]}]}</script></head>
<body>
<div>
Expand Down
2 changes: 1 addition & 1 deletion test/unhead/ssr/innerHTML.test.ts
Expand Up @@ -23,7 +23,7 @@ describe('ssr innerHTML', () => {
"bodyAttrs": "",
"bodyTags": "",
"bodyTagsOpen": "",
"headTags": "<script>{\\"test\\":\\"test\\",\\"something\\":{\\"else\\":123}}</script>",
"headTags": "<script type=\\"application/json\\">{\\"test\\":\\"test\\",\\"something\\":{\\"else\\":123}}</script>",
"htmlAttrs": "",
}
`)
Expand Down
2 changes: 1 addition & 1 deletion test/unhead/ssr/ssr.test.ts
Expand Up @@ -63,7 +63,7 @@ describe('ssr', () => {
"bodyAttrs": "",
"bodyTags": "",
"bodyTagsOpen": "",
"headTags": "<title></title>",
"headTags": "<title foo=\\"bar\\"></title>",
"htmlAttrs": "",
}
`)
Expand Down

0 comments on commit 26c9f9e

Please sign in to comment.