Skip to content

Commit

Permalink
feat: sort critical tags and sort opt-in renderPriority (#89)
Browse files Browse the repository at this point in the history
  • Loading branch information
harlan-zw committed Sep 18, 2022
1 parent 6f919c7 commit 839f781
Show file tree
Hide file tree
Showing 13 changed files with 527 additions and 58 deletions.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,41 @@ const title = ref("Website Title")
useHead({ title })
```

#### Render Priority

> :warning: Experimental feature
> Only available when rendering SSR.
To set the render priority of a tag you can use the `renderPriority` attribute:

```ts
useHead({
script: [
{
src: "/not-important-script.js",
},
],
})

useHead({
script: [
// will render first
{
src: "/very-important-script.js",
renderPriority: 1 // default is 10, so will be first
},
],
})
```

The following special tags have default priorities:

- -2 <meta charset ...>
- -1 <base>
- 0 <meta http-equiv="content-security-policy" ...>

All other tags have a default priority of 10: <meta>, <script>, <link>, <style>, etc

### `<Head>`

Besides `useHead`, you can also manipulate head tags using the `<Head>` component:
Expand Down
7 changes: 6 additions & 1 deletion example/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export const createApp = () => {
useHead({
title,
base: { href: "/" },
style: [{ children: `body {background: red}` }],
style: [{ children: `body {background: salmon}` }],
htmlAttrs: {
lang: "en",
},
Expand All @@ -61,6 +61,11 @@ export const createApp = () => {
content: "zh",
key: "zh",
},
{
name: "custom-priority",
content: "of 1",
renderPriority: 1
},
],
script: [
{
Expand Down
2 changes: 1 addition & 1 deletion src/create-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export const createElement = (
} else {
let value = attrs[key]

if (key === "key" || value === false) {
if (key === "renderPriority" || key === "key" || value === false) {
continue
}

Expand Down
154 changes: 112 additions & 42 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,28 @@ import {
import { createElement } from "./create-element"
import { stringifyAttrs } from "./stringify-attrs"
import { isEqualNode } from "./utils"
import type { HeadObjectPlain, HeadObject, TagKeys } from "./types"
import type {
HeadObjectPlain,
HeadObject,
TagKeys,
HasRenderPriority,
} from "./types"
import { HandlesDuplicates, RendersInnerContent, RendersToBody } from "./types"

export * from './types'
export * from "./types"

type MaybeRef<T> = T | Ref<T>

export type HeadAttrs = { [k: string]: any }

export type HeadTag = {
tag: TagKeys
props: {
body?: boolean
[k: string]: any
}
props: HandlesDuplicates &
HasRenderPriority &
RendersToBody &
RendersInnerContent & {
[k: string]: any
}
}

export type HeadClient = {
Expand All @@ -59,22 +67,38 @@ export interface HTMLResult {
readonly bodyTags: string
}

const getTagKey = (
props: Record<string, any>,
): { name: string; value: any } | void => {
const names = ["key", "id", "name", "property"]
for (const n of names) {
const value =
// Probably an HTML Element
typeof props.getAttribute === "function"
? props.hasAttribute(n)
? props.getAttribute(n)
: undefined
: props[n]
const getTagDeduper = <T extends HeadTag>(tag: T) => {
// only meta, base and script tags will be deduped
if (!["meta", "base", "script", "link"].includes(tag.tag)) {
return false
}
const { props, tag: tagName } = tag
// must only be a single base so we always dedupe
if (tagName === "base") {
return true
}
// support only a single canonical
if (tagName === "link" && props.rel === "canonical") {
return { propValue: "canonical" }
}
// must only be a single charset
if (props.charset) {
return { propKey: "charset" }
}
const name = ["key", "id", "name", "property", "http-equiv"]
for (const n of name) {
let value = undefined
// Probably an HTML Element
if (typeof props.getAttribute === "function" && props.hasAttribute(n)) {
value = props.getAttribute(n)
} else {
value = props[n]
}
if (value !== undefined) {
return { name: n, value: value }
return { propValue: n }
}
}
return false
}

/**
Expand Down Expand Up @@ -276,7 +300,6 @@ export const createHead = (initHeadObject?: MaybeRef<HeadObjectPlain>) => {
app.config.globalProperties.$head = head
app.provide(PROVIDE_KEY, head)
},

/**
* Get deduped tags
*/
Expand All @@ -291,30 +314,45 @@ export const createHead = (initHeadObject?: MaybeRef<HeadObjectPlain>) => {
allHeadObjs.forEach((objs) => {
const tags = headObjToTags(unref(objs))
tags.forEach((tag) => {
if (
tag.tag === "meta" ||
tag.tag === "base" ||
tag.tag === "script"
) {
// Remove tags with the same key
const key = getTagKey(tag.props)
if (key) {
let index = -1

for (let i = 0; i < deduped.length; i++) {
const prev = deduped[i]
const prevValue = prev.props[key.name]
const nextValue = tag.props[key.name]
if (prev.tag === tag.tag && prevValue === nextValue) {
index = i
break
}
// Remove tags with the same key
const dedupe = getTagDeduper(tag)
if (dedupe) {
let index = -1

for (let i = 0; i < deduped.length; i++) {
const prev = deduped[i]
// only if the tags match
if (prev.tag !== tag.tag) {
continue
}
// dedupe based on tag, for example <base>
if (dedupe === true) {
index = i
}
// dedupe based on property key value, for example <meta name="description">
else if (
dedupe.propValue &&
unref(prev.props[dedupe.propValue]) ===
unref(tag.props[dedupe.propValue])
) {
index = i
}
// dedupe based on property keys, for example <meta charset="utf-8">
else if (
dedupe.propKey &&
prev.props[dedupe.propKey] &&
tag.props[dedupe.propKey]
) {
index = i
}

if (index !== -1) {
deduped.splice(index, 1)
break
}
}

if (index !== -1) {
deduped.splice(index, 1)
}
}

if (titleTemplate && tag.tag === "title") {
Expand Down Expand Up @@ -346,7 +384,8 @@ export const createHead = (initHeadObject?: MaybeRef<HeadObjectPlain>) => {

const actualTags: Record<string, HeadTag[]> = {}

for (const tag of head.headTags) {
// head sorting here is not guaranteed to be honoured
for (const tag of head.headTags.sort(sortTags)) {
if (tag.tag === "title") {
title = tag.props.children
continue
Expand Down Expand Up @@ -407,6 +446,9 @@ const tagToString = (tag: HeadTag) => {
// avoid rendering body attr
delete tag.props.body
}
if (tag.props.renderPriority) {
delete tag.props.renderPriority
}
let attrs = stringifyAttrs(tag.props)
if (SELF_CLOSING_TAGS.includes(tag.tag)) {
return `<${tag.tag}${attrs}${
Expand All @@ -419,13 +461,41 @@ const tagToString = (tag: HeadTag) => {
}>${tag.props.children || ""}</${tag.tag}>`
}

const sortTags = (aTag: HeadTag, bTag: HeadTag) => {
const tagWeight = (tag: HeadTag) => {
if (tag.props.renderPriority) {
return tag.props.renderPriority
}
switch (tag.tag) {
// This element must come before other elements with attribute values of URLs
case "base":
return -1
case "meta":
// charset must come early in case there's non-utf8 characters in the HTML document
if (tag.props.charset) {
return -2
}
// CSP needs to be as it effects the loading of assets
if (tag.props["http-equiv"] === "content-security-policy") {
return 0
}
return 10
default:
// arbitrary safe number that can go up and down without conflicting
return 10
}
}
return tagWeight(aTag) - tagWeight(bTag)
}

export const renderHeadToString = (head: HeadClient): HTMLResult => {
const tags: string[] = []
let titleTag = ""
let htmlAttrs: HeadAttrs = {}
let bodyAttrs: HeadAttrs = {}
let bodyTags: string[] = []
for (const tag of head.headTags) {

for (const tag of head.headTags.sort(sortTags)) {
if (tag.tag === "title") {
titleTag = tagToString(tag)
} else if (tag.tag === "htmlAttrs") {
Expand Down
58 changes: 49 additions & 9 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,55 @@ export interface RendersInnerContent {
children?: string
}

export interface HeadAugmentations {
base: HandlesDuplicates & { body?: never; children?: never }
link: RendersToBody & { key?: never; children?: never }
meta: HandlesDuplicates & { children?: never; body?: never }
style: RendersToBody & RendersInnerContent & { key?: never }
script: RendersToBody & RendersInnerContent & HandlesDuplicates
noscript: RendersToBody & RendersInnerContent & { key?: never }
htmlAttrs: { key?: never; children?: never; body?: never }
bodyAttrs: { key?: never; children?: never; body?: never }
export interface HasRenderPriority {
/**
* The priority for rendering the tag, without this all tags are rendered as they are registered
* (besides some special tags).
*
* The following special tags have default priorities:
* * -2 <meta charset ...>
* * -1 <base>
* * 0 <meta http-equiv="content-security-policy" ...>
*
* All other tags have a default priority of 10: <meta>, <script>, <link>, <style>, etc
*
* @warn Experimental feature. Only available when rendering SSR
*/
renderPriority?: number
}

interface HeadAugmentations {
base: {
key?: never
renderPriority?: never
body?: never
children?: never
}
link: HasRenderPriority & RendersToBody & { key?: never; children?: never }
meta: HasRenderPriority &
HandlesDuplicates & { children?: never; body?: never }
style: HasRenderPriority &
RendersToBody &
RendersInnerContent & { key?: never }
script: HasRenderPriority &
RendersToBody &
RendersInnerContent &
HandlesDuplicates
noscript: HasRenderPriority &
RendersToBody &
RendersInnerContent & { key?: never }
htmlAttrs: {
renderPriority?: never
key?: never
children?: never
body?: never
}
bodyAttrs: {
renderPriority?: never
key?: never
children?: never
body?: never
}
}

export type HeadObjectPlain = PlainHead<HeadAugmentations>
Expand Down

0 comments on commit 839f781

Please sign in to comment.