diff --git a/docs/content/1.usage/2.guides/2.sorting.md b/docs/content/1.usage/2.guides/2.sorting.md index 496fbb24..4c84be3b 100644 --- a/docs/content/1.usage/2.guides/2.sorting.md +++ b/docs/content/1.usage/2.guides/2.sorting.md @@ -3,13 +3,15 @@ title: Tag Sorting description: How tags are sorted and how to configure them. --- -Once tags are [deduped](/usage/guides/handling-duplicates), they will be sorted. Sorting the tags is important -to ensure critical tags are rendered first, as well as allowing you to have tags in a specific order that you need them in. +## Introduction -For example, if you need to preload an asset, you'll need this to come before the asset itself. Which is a bit of a challenge -when the tags are nested. +Sorting the tags is important to ensure critical tags are rendered first, as well as allowing you to have tags in a specific order that you need them in. -## Sorting Logic +## Tag Sorting Logic + +Sorting is first done using the [Capo.js](https://rviscomi.github.io/capo.js/) weights, making sure tags are rendered in +a specific way to avoid [Critical Request Chains](https://web.dev/critical-request-chains/) issues as well +as rendering bugs. Sorting is done in multiple steps: - order critical tags first diff --git a/docs/content/3.plugins/plugins/capo.md b/docs/content/3.plugins/plugins/capo.md deleted file mode 100644 index 6a29211e..00000000 --- a/docs/content/3.plugins/plugins/capo.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -title: "Capo.js" -description: "Sort your tags in a more performant way by using the Capo.js plugin." ---- - -[Capo.js](https://rviscomi.github.io/capo.js/) is an awesome project trying to bring some structure to the head, making sure tags are rendered in -a specific way to get the best performance. - -The plugin is experimental and may be moved in to the core with more testing. - -## Installation - -```ts -import { CapoPlugin } from 'unhead' - -const head = createHead({ - plugins: [ - CapoPlugin() - ] -}) - -// or - -head.use(CapoPlugin()) -``` - -It's recommended that you use the `CapoPlugin` only on the server side. Using on the client side is not an issue, but it's not necessary. - -### Options - -- `track` - `boolean` - Whether to add a `data-capo` to the `` so that performance can be monitored after adding the plugin. Defaults to `false`. - -```ts -import { CapoPlugin } from 'unhead' - -const head = createHead({ - plugins: [ - CapoPlugin({ track: true }) - ] -}) -``` - -### Testing Feedback - -If you are using Capo.js, please let me know how it goes by commenting on [this discussion](https://github.com/nuxt/nuxt/discussions/22632). diff --git a/packages/schema/src/head.ts b/packages/schema/src/head.ts index 01121362..5daf3fc6 100644 --- a/packages/schema/src/head.ts +++ b/packages/schema/src/head.ts @@ -81,6 +81,12 @@ export interface CreateHeadOptions { document?: Document plugins?: HeadPluginInput[] hooks?: NestedHooks + /** + * Disable the Capo.js tag sorting algorithm. + * + * This is added to make the v1 -> v2 migration easier allowing users to opt-out of the new sorting algorithm. + */ + disableCapoSorting?: boolean } export interface HeadEntryOptions extends TagPosition, TagPriority, ProcessesTemplateParams, ResolvesDuplicates { diff --git a/packages/shared/src/sort.ts b/packages/shared/src/sort.ts index 65c5f3cd..85f8e526 100644 --- a/packages/shared/src/sort.ts +++ b/packages/shared/src/sort.ts @@ -1,4 +1,4 @@ -import type { HeadTag } from '@unhead/schema' +import type { HeadTag, Unhead } from '@unhead/schema' export const TAG_WEIGHTS = { // tags @@ -13,10 +13,18 @@ export const TAG_ALIASES = { low: 20, } as const -export function tagWeight(tag: T) { +export const SortModifiers = [{ prefix: 'before:', offset: -1 }, { prefix: 'after:', offset: 1 }] + +const importRe = /@import/ +const isTruthy = (val?: string | boolean) => val === '' || val === true + +export function tagWeight(head: Unhead, tag: T) { const priority = tag.tagPriority if (typeof priority === 'number') return priority + const isScript = tag.tag === 'script' + const isLink = tag.tag === 'link' + const isStyle = tag.tag === 'style' let weight = 100 if (tag.tag === 'meta') { // CSP needs to be as it effects the loading of assets @@ -28,7 +36,7 @@ export function tagWeight(tag: T) { else if (tag.props.name === 'viewport') weight = -15 } - else if (tag.tag === 'link' && tag.props.rel === 'preconnect') { + else if (isLink && tag.props.rel === 'preconnect') { // preconnects should almost always come first weight = 20 } @@ -39,6 +47,38 @@ export function tagWeight(tag: T) { // @ts-expect-e+rror untyped return weight + TAG_ALIASES[priority as keyof typeof TAG_ALIASES] } + if (tag.tagPosition && tag.tagPosition !== 'head') { + return weight + } + if (!head.ssr || head.resolvedOptions.disableCapoSorting) { + return weight + } + if (isScript && isTruthy(tag.props.async)) { + // ASYNC_SCRIPT + weight = 30 + // SYNC_SCRIPT + } + else if (isStyle && tag.innerHTML && importRe.test(tag.innerHTML)) { + // IMPORTED_STYLES + weight = 40 + } + else if (isScript && tag.props.src && !isTruthy(tag.props.defer) && !isTruthy(tag.props.async) && tag.props.type !== 'module' && !tag.props.type?.endsWith('json')) { + weight = 50 + } + else if ((isLink && tag.props.rel === 'stylesheet') || tag.tag === 'style') { + // SYNC_STYLES + weight = 60 + } + else if (isLink && (tag.props.rel === 'preload' || tag.props.rel === 'modulepreload')) { + // PRELOAD + weight = 70 + } + else if (isScript && isTruthy(tag.props.defer) && tag.props.src && !isTruthy(tag.props.async)) { + // DEFER_SCRIPT + weight = 80 + } + else if (isLink && (tag.props.rel === 'prefetch' || tag.props.rel === 'dns-prefetch' || tag.props.rel === 'prerender')) { + weight = 90 + } return weight } -export const SortModifiers = [{ prefix: 'before:', offset: -1 }, { prefix: 'after:', offset: 1 }] diff --git a/packages/unhead/export-size-report.json b/packages/unhead/export-size-report.json index 6f6045e2..2914415f 100644 --- a/packages/unhead/export-size-report.json +++ b/packages/unhead/export-size-report.json @@ -41,13 +41,6 @@ "minzipped": 2151, "bundled": 11435 }, - { - "name": "CapoPlugin", - "path": "dist/index.mjs", - "minified": 5113, - "minzipped": 1891, - "bundled": 10303 - }, { "name": "useServerSeoMeta", "path": "dist/index.mjs", diff --git a/packages/unhead/src/index.ts b/packages/unhead/src/index.ts index 38085b5f..d95576f3 100644 --- a/packages/unhead/src/index.ts +++ b/packages/unhead/src/index.ts @@ -19,4 +19,3 @@ export * from './composables/useServerHead' export * from './composables/useServerHeadSafe' export * from './composables/useServerSeoMeta' export * from './context' -export * from './optionalPlugins/capoPlugin' diff --git a/packages/unhead/src/optionalPlugins/capoPlugin.ts b/packages/unhead/src/optionalPlugins/capoPlugin.ts deleted file mode 100644 index b6c6bf5d..00000000 --- a/packages/unhead/src/optionalPlugins/capoPlugin.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { defineHeadPlugin, tagWeight } from '@unhead/shared' - -const importRe = /@import/ - -/* @__NO_SIDE_EFFECTS__ */ export function CapoPlugin(options: { track?: boolean }) { - return defineHeadPlugin({ - hooks: { - 'tags:beforeResolve': ({ tags }) => { - // handle 9 and down in capo - for (const tag of tags) { - if (tag.tagPosition && tag.tagPosition !== 'head') - continue - tag.tagPriority = tag.tagPriority || tagWeight(tag) - // skip if already prioritised - if (tag.tagPriority !== 100) - continue - - const isTruthy = (val?: string | boolean) => val === '' || val === true - - const isScript = tag.tag === 'script' - const isLink = tag.tag === 'link' - if (isScript && isTruthy(tag.props.async)) { - // ASYNC_SCRIPT - tag.tagPriority = 30 - // SYNC_SCRIPT - } - else if (tag.tag === 'style' && tag.innerHTML && importRe.test(tag.innerHTML)) { - // IMPORTED_STYLES - tag.tagPriority = 40 - } - else if (isScript && tag.props.src && !isTruthy(tag.props.defer) && !isTruthy(tag.props.async) && tag.props.type !== 'module' && !tag.props.type?.endsWith('json')) { - tag.tagPriority = 50 - } - else if ((isLink && tag.props.rel === 'stylesheet') || tag.tag === 'style') { - // SYNC_STYLES - tag.tagPriority = 60 - } - else if (isLink && (tag.props.rel === 'preload' || tag.props.rel === 'modulepreload')) { - // PRELOAD - tag.tagPriority = 70 - } - else if (isScript && isTruthy(tag.props.defer) && tag.props.src && !isTruthy(tag.props.async)) { - // DEFER_SCRIPT - tag.tagPriority = 80 - } - else if (isLink && (tag.props.rel === 'prefetch' || tag.props.rel === 'dns-prefetch' || tag.props.rel === 'prerender')) { - tag.tagPriority = 90 - } - } - options?.track && tags.push({ - tag: 'htmlAttrs', - props: { - 'data-capo': '', - }, - }) - }, - }, - }) -} diff --git a/packages/unhead/src/plugins/dedupe.ts b/packages/unhead/src/plugins/dedupe.ts index 304fea98..ceae9463 100644 --- a/packages/unhead/src/plugins/dedupe.ts +++ b/packages/unhead/src/plugins/dedupe.ts @@ -3,7 +3,7 @@ import { defineHeadPlugin, HasElementTags, hashTag, tagDedupeKey, tagWeight } fr const UsesMergeStrategy = new Set(['templateParams', 'htmlAttrs', 'bodyAttrs']) -export default defineHeadPlugin({ +export default defineHeadPlugin(head => ({ hooks: { 'tag:normalise': ({ tag }) => { // support for third-party dedupe keys @@ -74,7 +74,7 @@ export default defineHeadPlugin({ dupedTag._duped.push(tag) continue } - else if (tagWeight(tag) > tagWeight(dupedTag)) { + else if ((!tag.key || !dupedTag.key) && tagWeight(head, tag) > tagWeight(head, dupedTag)) { // check tag weights continue } @@ -110,4 +110,4 @@ export default defineHeadPlugin({ .filter(t => !(t.tag === 'meta' && (t.props.name || t.props.property) && !t.props.content)) }, }, -}) +})) diff --git a/packages/unhead/src/plugins/sort.ts b/packages/unhead/src/plugins/sort.ts index a377997c..93d457aa 100644 --- a/packages/unhead/src/plugins/sort.ts +++ b/packages/unhead/src/plugins/sort.ts @@ -1,6 +1,7 @@ import { defineHeadPlugin, SortModifiers, tagWeight } from '@unhead/shared' -export default defineHeadPlugin({ + +export default defineHeadPlugin((head => ({ hooks: { 'tags:resolve': (ctx) => { // 2a. Sort based on priority @@ -17,18 +18,20 @@ export default defineHeadPlugin({ const key = (tag.tagPriority as string).substring(prefix.length) - const position = ctx.tags.find(tag => tag._d === key)?._p - - if (position !== undefined) { - tag._p = position + offset + const linkedTag = ctx.tags.find(tag => tag._d === key) + if (linkedTag) { + if (typeof linkedTag?.tagPriority === 'number') { + tag.tagPriority = linkedTag.tagPriority + } + tag._p = linkedTag._p! + offset break } } } ctx.tags.sort((a, b) => { - const aWeight = tagWeight(a) - const bWeight = tagWeight(b) + const aWeight = tagWeight(head, a) + const bWeight = tagWeight(head, b) // 2c. sort based on critical tags if (aWeight < bWeight) { @@ -43,4 +46,4 @@ export default defineHeadPlugin({ }) }, }, -}) +}))) diff --git a/packages/vue/export-size-report.json b/packages/vue/export-size-report.json index 3c9e1bcd..ee1f785c 100644 --- a/packages/vue/export-size-report.json +++ b/packages/vue/export-size-report.json @@ -112,13 +112,6 @@ "minzipped": 356, "bundled": 1618 }, - { - "name": "CapoPlugin", - "path": "dist/index.mjs", - "minified": 179, - "minzipped": 132, - "bundled": 460 - }, { "name": "createHeadCore", "path": "dist/index.mjs", diff --git a/packages/vue/src/index.ts b/packages/vue/src/index.ts index af513fa2..d1e395b9 100644 --- a/packages/vue/src/index.ts +++ b/packages/vue/src/index.ts @@ -1,4 +1,4 @@ -import { CapoPlugin, createHeadCore, unheadCtx } from 'unhead' +import { createHeadCore, unheadCtx } from 'unhead' import { createHead, createServerHead } from './createHead' import { resolveUnrefHeadInput } from './utils' @@ -10,11 +10,6 @@ export { unheadCtx, } -// extra plugins -export { - CapoPlugin, -} - // utils export { resolveUnrefHeadInput, diff --git a/packages/vue/test/e2e/basic.test.ts b/packages/vue/test/e2e/basic.test.ts index aa54c86f..f1780f95 100644 --- a/packages/vue/test/e2e/basic.test.ts +++ b/packages/vue/test/e2e/basic.test.ts @@ -90,13 +90,13 @@ describe('vue e2e', () => { "headTags": " Home + - ", "htmlAttrs": " lang="en"", @@ -131,13 +131,13 @@ describe('vue e2e', () => { Home + - diff --git a/packages/vue/test/ssr/examples.test.ts b/packages/vue/test/ssr/examples.test.ts index d9cde547..c145fec1 100644 --- a/packages/vue/test/ssr/examples.test.ts +++ b/packages/vue/test/ssr/examples.test.ts @@ -64,15 +64,13 @@ describe('vue ssr examples', () => { }) }) - expect(headResult.headTags).toMatchInlineSnapshot( - ` + expect(headResult.headTags).toMatchInlineSnapshot(` "hello + - " - `, - ) + `) expect(headResult.htmlAttrs).toEqual(' lang="zh"') }) diff --git a/packages/vue/test/ssr/optionsApi.test.ts b/packages/vue/test/ssr/optionsApi.test.ts index 8b1215c5..362a6e7f 100644 --- a/packages/vue/test/ssr/optionsApi.test.ts +++ b/packages/vue/test/ssr/optionsApi.test.ts @@ -24,15 +24,13 @@ describe('vue ssr options api', () => { ], }) - expect(headResult.headTags).toMatchInlineSnapshot( - ` + expect(headResult.headTags).toMatchInlineSnapshot(` "hello + - - " - `, - ) + " + `) expect(headResult.htmlAttrs).toEqual(' lang="zh"') }) }) diff --git a/test/bench/ssr-harlanzw-com-e2e.bench.ts b/test/bench/ssr-harlanzw-com-e2e.bench.ts index 7adb1b1b..996208ee 100644 --- a/test/bench/ssr-harlanzw-com-e2e.bench.ts +++ b/test/bench/ssr-harlanzw-com-e2e.bench.ts @@ -2,7 +2,7 @@ import type { Head } from '@unhead/schema' import { InferSeoMetaPlugin } from '@unhead/addons' import { definePerson, defineWebPage, defineWebSite, UnheadSchemaOrg, useSchemaOrg } from '@unhead/schema-org' import { renderSSRHead } from '@unhead/ssr' -import { CapoPlugin, createServerHead, useHead, useSeoMeta, useServerHead } from 'unhead' +import { createServerHead, useHead, useSeoMeta, useServerHead } from 'unhead' import { bench, describe } from 'vitest' describe('ssr e2e bench', () => { @@ -10,11 +10,7 @@ describe('ssr e2e bench', () => { // we're going to replicate the logic needed to render the tags for a harlanzw.com page // 1. Add nuxt.config meta tags - const head = createServerHead({ - plugins: [ - CapoPlugin({ track: false }), - ], - }) + const head = createServerHead() // nuxt.config app.head head.push({ title: 'Harlan Wilton', diff --git a/test/unhead/e2e/e2e.test.ts b/test/unhead/e2e/e2e.test.ts index 549bea70..c12e5cc7 100644 --- a/test/unhead/e2e/e2e.test.ts +++ b/test/unhead/e2e/e2e.test.ts @@ -80,11 +80,11 @@ describe('unhead e2e', () => { "headTags": " Home + - ", @@ -125,11 +125,11 @@ describe('unhead e2e', () => { Home + - diff --git a/test/unhead/plugins/capo.test.ts b/test/unhead/plugins/capo.test.ts index 41368c5f..003d64aa 100644 --- a/test/unhead/plugins/capo.test.ts +++ b/test/unhead/plugins/capo.test.ts @@ -1,27 +1,19 @@ -import { CapoPlugin } from 'unhead' import { describe, it } from 'vitest' import { createHeadWithContext } from '../../util' describe('capo', () => { it('basic', async () => { - const head = createHeadWithContext({ - plugins: [ - CapoPlugin({ - track: true, - }), - ], - }) - // add each type of capo tag in a random order + const head = createHeadWithContext() head.push({ - script: { + script: [{ defer: true, src: 'defer-script.js', - }, + }], }) head.push({ - script: { + script: [{ src: 'sync-script.js', - }, + }], }) head.push({ style: [ @@ -29,22 +21,22 @@ describe('capo', () => { ], }) head.push({ - link: { + link: [{ rel: 'modulepreload', href: 'modulepreload.js', - }, + }], }) head.push({ - script: { + script: [{ src: 'async-script.js', async: true, - }, + }], }) head.push({ - link: { + link: [{ rel: 'preload', href: 'preload.js', - }, + }], }) head.push({ style: [ @@ -52,54 +44,54 @@ describe('capo', () => { ], }) head.push({ - link: { + link: [{ rel: 'stylesheet', href: 'sync-styles.css', - }, + }], }) head.push({ title: 'title', }) // preconnect head.push({ - link: { + link: [{ rel: 'preconnect', href: 'https://example.com', - }, + }], }) // dns-prefetch head.push({ - link: { + link: [{ rel: 'dns-prefetch', href: 'https://example.com', - }, + }], }) // prefetch head.push({ - link: { + link: [{ rel: 'prefetch', href: 'https://example.com', - }, + }], }) // prerender head.push({ - link: { + link: [{ rel: 'prerender', href: 'https://example.com', - }, + }], }) // meta head.push({ - meta: { + meta: [{ name: 'description', content: 'description', - }, + }], }) head.push({ - meta: { + meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1.0', - }, + }], }) const resolvedTags = await head.resolveTags() diff --git a/test/unhead/resolveTags.test.ts b/test/unhead/resolveTags.test.ts index e9420d1b..83d8753b 100644 --- a/test/unhead/resolveTags.test.ts +++ b/test/unhead/resolveTags.test.ts @@ -57,25 +57,6 @@ describe('resolveTags', () => { }, "tag": "meta", }, - { - "_d": "htmlAttrs", - "_e": 0, - "_p": 0, - "props": { - "dir": "ltr", - "lang": "en", - }, - "tag": "htmlAttrs", - }, - { - "_d": "bodyAttrs", - "_e": 0, - "_p": 1, - "props": { - "class": "dark", - }, - "tag": "bodyAttrs", - }, { "_e": 0, "_p": 2, @@ -84,29 +65,6 @@ describe('resolveTags', () => { }, "tag": "script", }, - { - "_e": 0, - "_p": 4, - "props": { - "href": "https://cdn.example.com/favicon.ico", - "rel": "icon", - "type": "image/x-icon", - }, - "tag": "link", - }, - ] - `) - expect(tags).toMatchInlineSnapshot(` - [ - { - "_d": "charset", - "_e": 0, - "_p": 3, - "props": { - "charset": "utf-8", - }, - "tag": "meta", - }, { "_d": "htmlAttrs", "_e": 0, @@ -126,14 +84,6 @@ describe('resolveTags', () => { }, "tag": "bodyAttrs", }, - { - "_e": 0, - "_p": 2, - "props": { - "src": "https://cdn.example.com/script.js", - }, - "tag": "script", - }, { "_e": 0, "_p": 4, @@ -145,7 +95,7 @@ describe('resolveTags', () => { "tag": "link", }, ] - `, 'old') + `) }) it('basic /w removal', async () => { @@ -206,6 +156,14 @@ describe('resolveTags', () => { }, "tag": "meta", }, + { + "_e": 0, + "_p": 2, + "props": { + "src": "https://cdn.example.com/script2.js", + }, + "tag": "script", + }, { "_d": "htmlAttrs", "_e": 0, @@ -225,14 +183,6 @@ describe('resolveTags', () => { }, "tag": "bodyAttrs", }, - { - "_e": 0, - "_p": 2, - "props": { - "src": "https://cdn.example.com/script2.js", - }, - "tag": "script", - }, { "_e": 0, "_p": 4,