diff --git a/docs/0.typescript/head/guides/0.get-started/1.migration.md b/docs/0.typescript/head/guides/0.get-started/1.migration.md deleted file mode 100644 index bf2b45a96..000000000 --- a/docs/0.typescript/head/guides/0.get-started/1.migration.md +++ /dev/null @@ -1,355 +0,0 @@ ---- -title: Upgrade Guide -description: Learn how to migrate between Unhead versions for TypeScript users. -navigation: - title: Upgrade Guide ---- - -## Migrate to v3 (from v2) - -Unhead v3 removes all deprecated APIs and focuses on performance improvements. This guide covers the breaking changes. - -### Legacy Property Names - -🚦 Impact Level: High - -The `DeprecationsPlugin` that automatically converted legacy property names has been removed. You must update your head entries to use the current property names. - -**`children` → `innerHTML`** - -```diff -useHead({ - script: [{ -- children: 'console.log("hello")', -+ innerHTML: 'console.log("hello")', - }] -}) -``` - -**`hid` / `vmid` → `key`** - -```diff -useHead({ - meta: [{ -- hid: 'description', -+ key: 'description', - name: 'description', - content: 'My description' - }] -}) -``` - -**`body: true` → `tagPosition: 'bodyClose'`** - -```diff -useHead({ - script: [{ - src: '/script.js', -- body: true, -+ tagPosition: 'bodyClose', - }] -}) -``` - -### Schema.org Plugin - -🚦 Impact Level: High - -The `PluginSchemaOrg` and `SchemaOrgUnheadPlugin` exports have been removed. Use `UnheadSchemaOrg` instead. - -```diff -- import { PluginSchemaOrg } from '@unhead/schema-org' -+ import { UnheadSchemaOrg } from '@unhead/schema-org' - -const head = createHead({ - plugins: [ -- PluginSchemaOrg() -+ UnheadSchemaOrg() - ] -}) -``` - -### Server Composables Removed - -🚦 Impact Level: Medium-High - -The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables have been removed. Use the standard composables instead. - -```diff -- import { useServerHead, useServerSeoMeta } from 'unhead' -+ import { useHead, useSeoMeta } from 'unhead' - -- useServerHead({ title: 'My Page' }) -+ useHead({ title: 'My Page' }) - -- useServerSeoMeta({ description: 'My description' }) -+ useSeoMeta({ description: 'My description' }) -``` - -If you need server-only head management, use conditional logic: - -```ts -if (import.meta.server) { - useHead({ title: 'Server Only' }) -} -``` - -### Core API Changes - -🚦 Impact Level: Medium - -**`createHeadCore` → `createUnhead`** - -```diff -- import { createHeadCore } from 'unhead' -+ import { createUnhead } from 'unhead' - -- const head = createHeadCore() -+ const head = createUnhead() -``` - -**`headEntries()` → `entries` Map** - -```diff -- const entries = head.headEntries() -+ const entries = [...head.entries.values()] -``` - -**`mode` Option Removed** - -The `mode` option on head entries has been removed. - -```diff -head.push({ - title: 'My Page', -- }, { mode: 'server' }) -+ }) -``` - -### Hooks - -🚦 Impact Level: Low - -The following hooks have been removed: - -- `init` - No longer needed -- `dom:renderTag` - DOM rendering is now synchronous -- `dom:rendered` - Use the `onRendered` option on `useHead()` instead - -The `dom:beforeRender` hook is now synchronous and `renderDOMHead` no longer returns a Promise: - -```diff -- await renderDOMHead(head, { document }) -+ renderDOMHead(head, { document }) -``` - -The SSR hooks (`ssr:beforeRender`, `ssr:render`, `ssr:rendered`) are now synchronous and `renderSSRHead` no longer returns a Promise: - -```diff -- const head = await renderSSRHead(head) -+ const head = renderSSRHead(head) -``` - -### Type Changes - -🚦 Impact Level: Low - -| Removed Type | Replacement | -|-------------|-------------| -| `Head` | `HeadTag` or specific tag types | -| `ResolvedHead` | `ResolvedHeadTag` | -| `MetaFlatInput` | `MetaFlat` | -| `RuntimeMode` | Removed (no replacement needed) | - -```diff -- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead' -+ import type { HeadTag, MetaFlat } from 'unhead' -``` - -### Other Removed APIs - -- `resolveScriptKey` - Internal utility, no longer exported -- `DeprecationsPlugin` - Update property names directly instead -- `extractUnheadInputFromHtml` - Use `parseHtmlForUnheadExtraction` from `unhead/parser` - ---- - -## Migrate to v2 (from v1) - -With the release of Unhead v2, we now have first-class support for other frameworks. This section covers the v1 to v2 migration. - -### Client / Server Subpath Exports - -🚦 Impact Level: Critical - -**⚠️ Breaking Changes:** - -- `createServerHead()`{lang="ts"} and `createHead()`{lang="ts"} exports from `unhead` are removed - -The path where you import `createHead` from has been updated to be a subpath export. - -**Client bundle:** - -```diff --import { createServerHead } from 'unhead' -+import { createHead } from 'unhead/client' - -// avoids bundling server plugins -createHead() -``` - -**Server bundle:** - -```diff --import { createServerHead } from 'unhead' -+import { createHead } from 'unhead/server' - -// avoids bundling server plugins --createServerHead() -+createHead() -``` - -### Removed Implicit Context - -🚦 Impact Level: Critical - -**⚠️ Breaking Changes:** - -- `getActiveHead()`{lang="ts"}, `activeHead`{lang="ts"} exports are removed - -The implicit context implementation kept a global instance of Unhead available so that you could use the `useHead()`{lang="ts"} composables anywhere in your application. - -In v2, the core composables no longer have access to the Unhead instance. Instead, you must pass the Unhead instance to the composables. - -::note -Passing the instance is only relevant if you're importing from `unhead`. In JavaScript frameworks we tie the context to the framework itself so you don't need to worry about this. -:: - -```ts [TypeScript v2] -import { useHead } from 'unhead' - -// example of getting the instance -const unheadInstance = useMyApp().unhead -useHead(unheadInstance, { - title: 'Looks good' -}) -``` - -### Removed `vmid`, `hid`, `children`, `body` - -🚦 Impact Level: High - -For legacy support with Vue Meta we allowed end users to provide deprecated properties: `vmid`, `hid`, `children` and `body`. - -You must update these properties to the appropriate replacement or remove them. See the [v3 migration section](#legacy-property-names) for the replacements. - -### Opt-in Template Params & Tag Alias Sorting - -🚦 Impact Level: High - -To reduce the bundle size and improve performance, we've moved the template params and tag alias sorting to optional plugins. - -```ts -import { AliasSortingPlugin, TemplateParamsPlugin } from 'unhead/plugins' - -createHead({ - plugins: [TemplateParamsPlugin, AliasSortingPlugin] -}) -``` - -### Promise Input Support - -🚦 Impact Level: Medium - -If you have promises as input they will no longer be resolved, either await the promise before passing it along or register the optional promises plugin. - -```ts -import { PromisePlugin } from 'unhead/plugins' - -const unhead = createHead({ - plugins: [PromisePlugin] -}) -``` - -### Updated `useScript()`{lang="ts"} - -🚦 Impact Level: High - -**⚠️ Breaking Changes:** - -- Script instance is no longer augmented as a proxy and promise -- `script.proxy`{lang="ts"} is rewritten for simpler, more stable behavior -- `stub()`{lang="ts"} and runtime hook `script:instance-fn` are removed - -**Replacing promise usage** - -```diff -const script = useScript() - --script.then(() => console.log('loaded') -+script.onLoaded(() => console.log('loaded')) -``` - -**Replacing proxy usage** - -```diff -const script = useScript('..', { - use() { return { foo: [] } } -}) - --script.foo.push('bar') -+script.proxy.foo.push('bar') -``` - -### Tag Sorting Updated - -🚦 Impact Level: Low - -[Capo.js](https://rviscomi.github.io/capo.js/) sorting is now the default. You can opt-out: - -```ts -createHead({ - disableCapoSorting: true, -}) -``` - -### Default SSR Tags - -🚦 Impact Level: Low - -When SSR Unhead will now insert important default tags for you: - -- `` -- `` -- `` - -```ts -import { createHead } from 'unhead/server' - -// disable when creating the head instance -const head = createHead({ - disableDefaults: true, -}) -``` - -### CJS Exports Removed - -🚦 Impact Level: Low - -CommonJS exports have been removed in favor of ESM only. - -```diff --const { createHead } = require('unhead/client') -+import { createHead } from 'unhead/client' -``` - -### Deprecated `@unhead/schema` - -🚦 Impact Level: Low - -The `@unhead/schema` package is deprecated. Import from `unhead/types` or `unhead` instead. - -```diff --import { HeadTag } from '@unhead/schema' -+import { HeadTag } from 'unhead/types' -``` diff --git a/docs/content/6.migration-guide/1.v3.md b/docs/content/6.migration-guide/1.v3.md new file mode 100644 index 000000000..0195366cc --- /dev/null +++ b/docs/content/6.migration-guide/1.v3.md @@ -0,0 +1,353 @@ +--- +title: "Migrate to v3" +description: "Migrate from Unhead v2 to v3. Covers all breaking changes, removed APIs, and their replacements." +navigation: + title: "v3" +--- + +Unhead v3 removes all deprecated APIs and focuses on performance improvements. This guide covers the breaking changes. + +## Automated Migration Checks + +Add `ValidatePlugin` during your upgrade to automatically detect v2 patterns and get actionable warnings: + +```ts +import { ValidatePlugin } from 'unhead/plugins' + +const head = createHead({ + plugins: [ + ValidatePlugin() // Detects deprecated props, missing plugins, and more + ] +}) +``` + +The plugin will warn you about: +- **Missing `TemplateParamsPlugin`** : template params like `%siteName` are now opt-in and will appear literally without the plugin +- **Missing `AliasSortingPlugin`** : `before:`/`after:` tag priorities are now opt-in and will be silently ignored without the plugin +- **Deprecated property names** : `children`, `hid`, `vmid`, `body: true` are no longer auto-converted +- **Removed `mode` option** : `{ mode: 'server' }` on `head.push()` is silently ignored + +All rules use ESLint-style config and can be individually disabled: + +```ts +ValidatePlugin({ + rules: { + 'missing-template-params-plugin': 'off', + } +}) +``` + +Remove `ValidatePlugin` once your migration is complete, or keep it for ongoing validation. + +--- + +## Legacy Property Names + +🚦 Impact Level: **High** + +The `DeprecationsPlugin` that automatically converted legacy property names has been removed. You must update your head entries to use the current property names. + +### `children` → `innerHTML` + +```diff +useHead({ + script: [{ +- children: 'console.log("hello")', ++ innerHTML: 'console.log("hello")', + }] +}) +``` + +### `hid` / `vmid` → `key` + +```diff +useHead({ + meta: [{ +- hid: 'description', ++ key: 'description', + name: 'description', + content: 'My description' + }] +}) +``` + +```diff +useHead({ + meta: [{ +- vmid: 'og:title', ++ key: 'og:title', + property: 'og:title', + content: 'My Title' + }] +}) +``` + +### `body: true` → `tagPosition: 'bodyClose'` + +```diff +useHead({ + script: [{ + src: '/script.js', +- body: true, ++ tagPosition: 'bodyClose', + }] +}) +``` + +### Quick Reference + +| Old Property | New Property | +|-------------|--------------| +| `children` | `innerHTML` | +| `hid` | `key` | +| `vmid` | `key` | +| `body: true` | `tagPosition: 'bodyClose'` | + +--- + +## Schema.org Plugin + +🚦 Impact Level: **High** + +The `PluginSchemaOrg` and `SchemaOrgUnheadPlugin` exports have been removed. Use `UnheadSchemaOrg` instead. + +```diff +- import { PluginSchemaOrg } from '@unhead/schema-org' ++ import { UnheadSchemaOrg } from '@unhead/schema-org' + +const head = createHead({ + plugins: [ +- PluginSchemaOrg() ++ UnheadSchemaOrg() + ] +}) +``` + +For Vue users: + +```diff +- import { PluginSchemaOrg } from '@unhead/schema-org/vue' ++ import { UnheadSchemaOrg } from '@unhead/schema-org/vue' +``` + +### Schema.org Config Options + +The following config options have been removed: + +| Removed Option | Replacement | +|---------------|-------------| +| `canonicalHost` | `host` | +| `canonicalUrl` | `path` + `host` | +| `position` | Use `tagPosition` on individual schema entries | +| `defaultLanguage` | Use `inLanguage` on schema nodes | +| `defaultCurrency` | Use `priceCurrency` on schema nodes | + +```diff +UnheadSchemaOrg({ +- canonicalHost: 'https://example.com', +- canonicalUrl: 'https://example.com/page', ++ host: 'https://example.com', ++ path: '/page', +}) +``` + +--- + +## Server Composables Removed + +🚦 Impact Level: **Medium-High** + +The `useServerHead`, `useServerHeadSafe`, and `useServerSeoMeta` composables have been removed. Use the standard composables instead. + +```diff +- import { useServerHead, useServerSeoMeta } from 'unhead' ++ import { useHead, useSeoMeta } from 'unhead' + +- useServerHead({ title: 'My Page' }) ++ useHead({ title: 'My Page' }) + +- useServerSeoMeta({ description: 'My description' }) ++ useSeoMeta({ description: 'My description' }) +``` + +If you need server-only head management, use conditional logic: + +```ts +if (import.meta.server) { + useHead({ title: 'Server Only' }) +} +``` + +--- + +## Core API Changes + +🚦 Impact Level: **Medium** + +### `createHeadCore` → `createUnhead` + +```diff +- import { createHeadCore } from 'unhead' ++ import { createUnhead } from 'unhead' + +- const head = createHeadCore() ++ const head = createUnhead() +``` + +### `headEntries()` → `entries` Map + +```diff +- const entries = head.headEntries() ++ const entries = [...head.entries.values()] +``` + +### `mode` Option Removed + +The `mode` option on head entries has been removed. Runtime mode detection is no longer supported. + +```diff +head.push({ + title: 'My Page', +- }, { mode: 'server' }) ++ }) +``` + +Use the appropriate `createHead` function instead: + +```ts +// Client-side +import { createHead } from 'unhead/client' + +// Server-side +import { createHead } from 'unhead/server' +``` + +--- + +## Vue Legacy Exports + +🚦 Impact Level: **Medium** + +### `/legacy` Export Path Deprecated + +The `@unhead/vue/legacy` import still works but emits a runtime deprecation warning. Update to the explicit client or server import: + +```diff +- import { createHead } from '@unhead/vue/legacy' ++ import { createHead } from '@unhead/vue/client' +// or for SSR ++ import { createHead } from '@unhead/vue/server' +``` + +### `createHeadCore` Removed + +```diff +- import { createHeadCore } from '@unhead/vue' ++ import { createHead } from '@unhead/vue/server' +// or for client ++ import { createHead } from '@unhead/vue/client' +``` + +--- + +## Server Utilities + +🚦 Impact Level: **Low** + +### `extractUnheadInputFromHtml` → `parseHtmlForUnheadExtraction` + +The function has been moved from `unhead/server` to `unhead/parser`. + +```diff +- import { extractUnheadInputFromHtml } from 'unhead/server' ++ import { parseHtmlForUnheadExtraction } from 'unhead/parser' + +- const { input } = extractUnheadInputFromHtml(html) ++ const { input } = parseHtmlForUnheadExtraction(html) +``` + +--- + +## Hooks + +🚦 Impact Level: **Low** + +The following hooks have been removed: + +- `init` : No longer needed +- `dom:renderTag` : DOM rendering is now synchronous +- `dom:rendered` : Use the `onRendered` option on `useHead()` instead + +The `dom:beforeRender` hook is now synchronous and `renderDOMHead` no longer returns a Promise: + +```diff +- await renderDOMHead(head, { document }) ++ renderDOMHead(head, { document }) +``` + +The SSR hooks (`ssr:beforeRender`, `ssr:render`, `ssr:rendered`) are now synchronous and `renderSSRHead` no longer returns a Promise: + +```diff +- const head = await renderSSRHead(head) ++ const head = renderSSRHead(head) +``` + +--- + +## Type Changes + +🚦 Impact Level: **Low** + +| Removed Type | Replacement | +|-------------|-------------| +| `Head` | `HeadTag` or specific tag types | +| `ResolvedHead` | `ResolvedHeadTag` | +| `MergeHead` | Use generics directly | +| `MetaFlatInput` | `MetaFlat` | +| `ResolvedMetaFlat` | `MetaFlat` | +| `RuntimeMode` | Removed (no replacement needed) | + +```diff +- import type { Head, MetaFlatInput, RuntimeMode } from 'unhead' ++ import type { HeadTag, MetaFlat } from 'unhead' +``` + +--- + +## Other Removed APIs + +- `resolveScriptKey` : Internal utility, no longer exported +- `DeprecationsPlugin` : Update property names directly instead +- `resolveUnrefHeadInput` (Vue) : Reactive resolution now happens automatically +- `setHeadInjectionHandler` (Vue) : Head injection is handled automatically + +--- + +## Quick Reference: Import Changes + +```diff +// Legacy properties - update property names directly, no plugin needed +- import { DeprecationsPlugin } from 'unhead/plugins' + +// Schema.org +- import { PluginSchemaOrg, SchemaOrgUnheadPlugin } from '@unhead/schema-org' ++ import { UnheadSchemaOrg } from '@unhead/schema-org' + +// Server composables +- import { useServerHead, useServerHeadSafe, useServerSeoMeta } from 'unhead' ++ import { useHead, useHeadSafe, useSeoMeta } from 'unhead' + +// Core +- import { createHeadCore } from 'unhead' ++ import { createUnhead } from 'unhead' + +// Server utilities +- import { extractUnheadInputFromHtml } from 'unhead/server' ++ import { parseHtmlForUnheadExtraction } from 'unhead/parser' + +// Vue +- import { createHeadCore, resolveUnrefHeadInput, setHeadInjectionHandler } from '@unhead/vue' +- import { ... } from '@unhead/vue/legacy' ++ import { createHead } from '@unhead/vue/client' ++ import { createHead } from '@unhead/vue/server' +``` diff --git a/docs/content/6.migration-guide/2.v2.md b/docs/content/6.migration-guide/2.v2.md new file mode 100644 index 000000000..07d5eb3ad --- /dev/null +++ b/docs/content/6.migration-guide/2.v2.md @@ -0,0 +1,202 @@ +--- +title: "Migrate to v2" +description: "Migrate from Unhead v1 to v2. Covers subpath exports, removed implicit context, opt-in plugins, and more." +navigation: + title: "v2" +--- + +With the release of Unhead v2, we now have first-class support for other frameworks. This guide covers the v1 to v2 migration. + +## Client / Server Subpath Exports + +🚦 Impact Level: **Critical** + +**⚠️ Breaking Changes:** + +- `createServerHead()`{lang="ts"} and `createHead()`{lang="ts"} exports from `unhead` are removed + +The path where you import `createHead` from has been updated to be a subpath export. + +**Client bundle:** + +```diff +-import { createServerHead } from 'unhead' ++import { createHead } from 'unhead/client' + +// avoids bundling server plugins +createHead() +``` + +**Server bundle:** + +```diff +-import { createServerHead } from 'unhead' ++import { createHead } from 'unhead/server' + +// avoids bundling server plugins +-createServerHead() ++createHead() +``` + +--- + +## Removed Implicit Context + +🚦 Impact Level: **Critical** + +**⚠️ Breaking Changes:** + +- `getActiveHead()`{lang="ts"}, `activeHead`{lang="ts"} exports are removed + +The implicit context implementation kept a global instance of Unhead available so that you could use the `useHead()`{lang="ts"} composables anywhere in your application. + +In v2, the core composables no longer have access to the Unhead instance. Instead, you must pass the Unhead instance to the composables. + +::note +Passing the instance is only relevant if you're importing from `unhead`. In JavaScript frameworks we tie the context to the framework itself so you don't need to worry about this. +:: + +```ts [TypeScript v2] +import { useHead } from 'unhead' + +// example of getting the instance +const unheadInstance = useMyApp().unhead +useHead(unheadInstance, { + title: 'Looks good' +}) +``` + +--- + +## Removed `vmid`, `hid`, `children`, `body` + +🚦 Impact Level: **High** + +For legacy support with Vue Meta we allowed end users to provide deprecated properties: `vmid`, `hid`, `children` and `body`. + +You must update these properties to the appropriate replacement or remove them. See the [v3 migration guide](/docs/content/migration-guide/v3#legacy-property-names) for the replacements. + +--- + +## Opt-in Template Params & Tag Alias Sorting + +🚦 Impact Level: **High** + +To reduce the bundle size and improve performance, we've moved the template params and tag alias sorting to optional plugins. + +```ts +import { AliasSortingPlugin, TemplateParamsPlugin } from 'unhead/plugins' + +createHead({ + plugins: [TemplateParamsPlugin, AliasSortingPlugin] +}) +``` + +--- + +## Promise Input Support + +🚦 Impact Level: **Medium** + +If you have promises as input they will no longer be resolved, either await the promise before passing it along or register the optional promises plugin. + +```ts +import { PromisePlugin } from 'unhead/plugins' + +const unhead = createHead({ + plugins: [PromisePlugin] +}) +``` + +--- + +## Updated `useScript()`{lang="ts"} + +🚦 Impact Level: **High** + +**⚠️ Breaking Changes:** + +- Script instance is no longer augmented as a proxy and promise +- `script.proxy`{lang="ts"} is rewritten for simpler, more stable behavior +- `stub()`{lang="ts"} and runtime hook `script:instance-fn` are removed + +**Replacing promise usage** + +```diff +const script = useScript() + +-script.then(() => console.log('loaded') ++script.onLoaded(() => console.log('loaded')) +``` + +**Replacing proxy usage** + +```diff +const script = useScript('..', { + use() { return { foo: [] } } +}) + +-script.foo.push('bar') ++script.proxy.foo.push('bar') +``` + +--- + +## Tag Sorting Updated + +🚦 Impact Level: **Low** + +[Capo.js](https://rviscomi.github.io/capo.js/) sorting is now the default. You can opt-out: + +```ts +createHead({ + disableCapoSorting: true, +}) +``` + +--- + +## Default SSR Tags + +🚦 Impact Level: **Low** + +When SSR Unhead will now insert important default tags for you: + +- `` +- `` +- `` + +```ts +import { createHead } from 'unhead/server' + +// disable when creating the head instance +const head = createHead({ + disableDefaults: true, +}) +``` + +--- + +## CJS Exports Removed + +🚦 Impact Level: **Low** + +CommonJS exports have been removed in favor of ESM only. + +```diff +-const { createHead } = require('unhead/client') ++import { createHead } from 'unhead/client' +``` + +--- + +## Deprecated `@unhead/schema` + +🚦 Impact Level: **Low** + +The `@unhead/schema` package is deprecated. Import from `unhead/types` or `unhead` instead. + +```diff +-import { HeadTag } from '@unhead/schema' ++import { HeadTag } from 'unhead/types' +``` diff --git a/docs/head/1.guides/releases/v3.md b/docs/content/7.releases/1.v3.md similarity index 84% rename from docs/head/1.guides/releases/v3.md rename to docs/content/7.releases/1.v3.md index 86b3a1b5c..9edd6f8b3 100644 --- a/docs/head/1.guides/releases/v3.md +++ b/docs/content/7.releases/1.v3.md @@ -1,15 +1,15 @@ --- title: "Unhead v3" -description: "Unhead v3 release notes - streaming SSR, synchronous rendering, and significant performance improvements." +description: "Unhead v3 release notes: streaming SSR, synchronous rendering, and significant performance improvements." navigation: title: "v3" --- Unhead v3 rebuilds the rendering engine from the ground up. The motivation: **streaming SSR**. Frameworks like Nuxt, SolidStart, and SvelteKit stream HTML to the browser as data loads, but head tags were still stuck in a request/response model, resolved once and never updated. To fix this properly, we had to make rendering synchronous, pluggable, and side-effect free. The result is a faster, smaller, and more capable head manager. -## 📣 Highlights +## Highlights -### 🌊 Streaming SSR +### Streaming SSR Head tags now update dynamically as suspense boundaries resolve during streaming. As each chunk streams to the browser, new `Codestin Search App + + + +``` + +Seen as: ``{lang="html"}, ``{lang="html"}, ``{lang="html"} or more granular tags such as ``{lang="html"}, `<Meta>`{lang="html"}, `<Link>`{lang="html"}, etc. + +### Ecosystem + +Traditionally frameworks like React, [Vue](https://vuejs.org) and Angular have left it up to their respective ecosystems to solve this problem. Vue had [Vue Meta](https://vue-meta.nuxtjs.org/) and [VueUse Head](https://github.com/vueuse/head) and +React had and continues to have [React Helmet](https://github.com/nfl/react-helmet). + +We see some frameworks shifting towards providing simple support as part of their core, such as in [React v19](https://react.dev/blog/2024/12/05/react-19) +and in [Angular v14](https://angular.io/guide/releases#angular-v14-0-0). + +These solutions tend to be reliant on the component pattern, which is limited in its capabilities. + +## A quick recap on Unhead + +Unhead started out humbly 5 years ago as [`@vueuse/head`](https://github.com/vueuse/head). Since then it's joined +[UnJS](https://unjs.io/) as a high-quality, single-purpose package that works anywhere and is downloaded over ~100k times a day. + +:UnheadDownloads{class="my-10 lg:-mx-20 lg:w-[125%] rounded overflow-hidden"} + +Built from bullet-proof primitives such as `useHead()`{lang="ts"} and `useSeoMeta()`{lang="ts"}, Unhead is building out the ecosystem +of head management which is being realized through the v2 release. + +## v2 Release + +For the full changelog of changes please reference the [v2 roadmap](https://github.com/unjs/unhead/issues/395) GitHub Issue. + +### New Framework Supported + +Unhead started as a Vue-focused solution, but with v2, we've expanded to support all major frontend frameworks with +deep reactivity integrations. + +:FrameworkSelectorMinimal{ignore-redirect} + +```ts +import { useHead } from '@unhead/dynamic-import' + +useHead({ + title: '👋 @FRAMEWORK_NAME@' +}) +``` + +- **Vue** : `@unhead/vue`: [Installation](/docs/vue/head/guides/get-started/installation) & [Reactivity Guide](/docs/vue/head/guides/core-concepts/reactivity-and-context) +- **React** : `@unhead/react`: [Installation](/docs/react/head/guides/get-started/installation) & [Reactivity Guide](/docs/react/head/guides/core-concepts/reactivity) +- **Svelte** : `@unhead/svelte`: [Installation](/docs/svelte/head/guides/get-started/installation) & [Reactivity Guide](/docs/svelte/head/guides/core-concepts/reactivity) +- **Solid.js** : `@unhead/solid-js`: [Installation](/docs/solid-js/head/guides/get-started/installation) & [Reactivity Guide](/docs/solid-js/head/guides/core-concepts/reactivity) +- **Angular** : `@unhead/angular`: [Installation](/docs/angular/head/guides/get-started/installation) & [Reactivity Guide](/docs/angular/head/guides/core-concepts/reactivity) + +Each framework integration provides the same powerful features with idiomatic patterns for that ecosystem. + +### ⚡ Runtime Performance Improvements + +Every tenth of a millisecond counts performance. In this release, much of the core has been rewritten to improve performance and tree-shakability. + +**Rewritten Core**: With the [core being rewritten](https://github.com/unjs/unhead/pull/488), optimizations were done +to improve the fast-path times. + +- ✅ SSR **+51% Faster** 0.34ms ⇢ 0.20ms ([Benchmark: Medium Site](https://github.com/unjs/unhead/blob/main/bench/ssr-harlanzw-com-e2e.bench.ts)) + +**Faster INP**: Tag resolves are now [cached between DOM renders](https://github.com/unjs/unhead/pull/504). For large sites, this _can_ lead to lower INP when switching between pages. + +- ✅ CSR Page Switching **+64% Faster** 0.43ms ⇢ 0.22ms ([Benchmark: 10 Page Changes x useHead()](https://github.com/unjs/unhead/blob/main/packages/unhead/test/bench/ssr-perf.bench.ts)) + +**Leaner Core**: Through aggressive code optimizations and moving some features to opt-in, we see an improvement in the bundle size. + +- ✅ Client: **21% smaller** - 13.6 kB (gz 5.3 kB) ⇢ 10.7 kB (gz 4.5 kB) ([Minimal: useHead() + createUnhead()](https://github.com/nuxt/nuxt/pull/31169/files#diff-04a7585c5d6ddd5cf80321c69bc6e07cd85e9e86ae35fa5f9c036651f137c312R26) ) +- ✅ Server: **22% smaller** - 10.3 kB (gz 4.1 kB) ⇢ 8 kB (gz 3.4 kB) ([Minimal: useHead() + createUnhead()](https://github.com/nuxt/nuxt/pull/31169/files#diff-04a7585c5d6ddd5cf80321c69bc6e07cd85e9e86ae35fa5f9c036651f137c312R26)) + +Benchmarks do not reflect real-world performance. + +:UnheadTwoGraphs + +### Capo.js Sorting + +Unhead v2 now applies capo.js sorting to all tags by default which provides optimizations to improve the +performance of your site for end users. This sorting follows best practices for resource loading order in the document head. + +:CapoExample + +### 📦 Bundle Improvements + +**Single dependency** Unhead now relies on a single dependency, [hookable](https://github.com/unjs/hookable), used for pluggability. + +- ✅ **5 fewer** dependencies + +**ESM & dropped workspace packages** Only ESM is now published, workspace packages are deprecated for subpath exports. + +- ✅ **14kb reduction** in `node_modules` + +## 🔄 Upgrading to v2 + +The migration path is straightforward; Unhead provides a `legacy` subpath build to ease the transition. + +See the [Migrate to v2](/docs/content/migration-guide/v2) guide. + +## 🔮 The Future + +Now that Unhead has a battle-tested core and supports all major frameworks, we can start to build out the ecosystem of head management. Here's what's on our roadmap: + +### Short Term: Improved Tag Validation + +For several tags, the spec requires certain attributes when you use another attribute. For example, the `<link>`{lang="html"} tag +only requires an `as` attribute when you use `rel="preload"`{lang="html"}. + +The type system does not enforce this to avoid potential type juggling; however, it can lead to mistakes. + +To catch broken tags we have several options: +- Improve the type system for optionally required attributes +- Implement [ESLint](https://eslint.org) rules to catch anything the types haven't +- Use a runtime plugin in development to validate resolved tags + +### Medium Term: Third-Party Scripts + +While Unhead already has a lower-level way to work with third-party scripts `useScript()`{lang="ts"}, we can make them easier and more secure +to use by introducing higher-level composables, such as `useGoogleAnalytics()`{lang="ts"}, `useFacebookPixel()`{lang="ts"}, etc. + +These would hook into framework lifecycles to ensure optimal performance and security while providing improved developer experience. + +```ts +const { proxy } = useGoogleAnalytics({ + id: 'UA-123456789', +}) + +proxy.gtag('page_view', { + page_path: '/my-page', +}) +``` + +### Exploratory: OG Image + +Turn HTML templates into OG images with a simple composable. + +```ts +useOgImage('<div class="bg-red-500">my image :)</div>') +``` + +## 🙏 Thank You + +This release wouldn't be possible without our amazing community. Special thanks to all contributors who helped with code, testing, and documentation. diff --git a/packages/unhead/src/plugins/validate.ts b/packages/unhead/src/plugins/validate.ts index 28961811a..6221307dd 100644 --- a/packages/unhead/src/plugins/validate.ts +++ b/packages/unhead/src/plugins/validate.ts @@ -5,13 +5,19 @@ export type RuleSeverity = 'warn' | 'info' | 'off' export type ValidationRuleId = | 'canonical-og-url-mismatch' + | 'deprecated-option-mode' + | 'deprecated-prop-body' + | 'deprecated-prop-children' + | 'deprecated-prop-hid-vmid' | 'empty-meta-content' | 'empty-title' | 'html-in-title' | 'inline-script-size' | 'inline-style-size' | 'meta-beyond-1mb' + | 'missing-alias-sorting-plugin' | 'missing-description' + | 'missing-template-params-plugin' | 'missing-title' | 'non-absolute-canonical' | 'non-absolute-og-url' @@ -284,6 +290,9 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) { return defineHeadPlugin((head: Unhead) => { const _push = head.push.bind(head) head.push = (input, opts) => { + if ((opts as any)?.mode && resolveSeverity(ruleConfig['deprecated-option-mode'] as RuleSeverity | [RuleSeverity, unknown] | undefined, 'warn') !== 'off') { + console.warn(`[unhead] "mode: '${(opts as any).mode}'" option was removed in v3. Use the appropriate createHead import (unhead/client or unhead/server) instead.`) + } const source = captureSource(root) const active = _push(input, opts) if (source) @@ -545,6 +554,35 @@ export function ValidatePlugin(options: ValidatePluginOptions = {}) { report('prefetch-preload-conflict', `"${tag.props.href}" has both preload and prefetch — use preload for current page resources, prefetch for future navigation.`, 'warn', tag) } + // === v2 → v3 Migration Checks === + + // Missing TemplateParamsPlugin (silent breakage — %params appear literally) + if (!head.plugins.has('template-params')) { + if (tags.some((t: HeadTag) => t.tag === 'templateParams')) + report('missing-template-params-plugin', `templateParams are set but TemplateParamsPlugin is not registered. In v3, this plugin is opt-in. Add it to createHead({ plugins: [TemplateParamsPlugin] }).`, 'warn') + } + + // Missing AliasSortingPlugin (silent breakage — before:/after: priorities ignored) + if (!head.plugins.has('aliasSorting')) { + for (const tag of tags) { + const p = typeof tag.tagPriority === 'string' ? tag.tagPriority : '' + if (p.startsWith('before:') || p.startsWith('after:')) { + report('missing-alias-sorting-plugin', `Tag priority alias "${p}" requires AliasSortingPlugin. In v3, this plugin is opt-in. Add it to createHead({ plugins: [AliasSortingPlugin] }).`, 'warn', tag) + break + } + } + } + + // Deprecated v2 property names (no longer auto-converted) + for (const tag of tags) { + if ('children' in tag.props) + report('deprecated-prop-children', `"children" was removed in v3. Use "innerHTML" instead.`, 'warn', tag) + if ('hid' in tag.props || 'vmid' in tag.props) + report('deprecated-prop-hid-vmid', `"${('hid' in tag.props) ? 'hid' : 'vmid'}" was removed in v3. Use "key" instead.`, 'warn', tag) + if (tag.props.body === true) + report('deprecated-prop-body', `"body: true" was removed in v3. Use "tagPosition: 'bodyClose'" instead.`, 'warn', tag) + } + // Meta tags rendered after the crawler byte limit (default 1MB) // Social crawlers (Facebook, Twitter) only parse the first ~1MB of HTML. // Large inline styles can push SEO/OG meta tags past this limit. diff --git a/packages/unhead/src/types/hooks.ts b/packages/unhead/src/types/hooks.ts index ce142fad2..ff4b913f9 100644 --- a/packages/unhead/src/types/hooks.ts +++ b/packages/unhead/src/types/hooks.ts @@ -47,7 +47,9 @@ export interface CoreHeadHooks { export interface DOMHeadHooks { 'dom:beforeRender': (ctx: DomBeforeRenderCtx) => SyncHookResult + /** @deprecated Not called internally. Will be removed in v4. */ 'dom:renderTag': (ctx: DomRenderTagContext, document: Document, track: (id: string, scope: string, fn: () => void) => void) => HookResult + /** @deprecated Will be removed in v4. DOM rendering is synchronous; run post-render logic after calling `renderDOMHead()` directly. */ 'dom:rendered': (ctx: { renders: DomRenderTagContext[] }) => HookResult } diff --git a/packages/unhead/test/unit/plugins/validate.test.ts b/packages/unhead/test/unit/plugins/validate.test.ts index 1acbd8e84..c2471b94f 100644 --- a/packages/unhead/test/unit/plugins/validate.test.ts +++ b/packages/unhead/test/unit/plugins/validate.test.ts @@ -1,7 +1,7 @@ import type { HeadValidationRule, ValidatePluginOptions } from '../../../src/plugins' import { renderSSRHead } from '@unhead/ssr' import { describe, expect, it, vi } from 'vitest' -import { ValidatePlugin } from '../../../src/plugins' +import { AliasSortingPlugin, TemplateParamsPlugin, ValidatePlugin } from '../../../src/plugins' import { createHead } from '../../../src/server' function createValidationHead(opts?: Pick<ValidatePluginOptions, 'rules'>) { @@ -869,4 +869,133 @@ describe('validatePlugin', () => { expect(rules.find(r => r.id === 'meta-beyond-1mb')).toBeFalsy() }) }) + + describe('v2 migration', () => { + it('warns when templateParams used without TemplateParamsPlugin', () => { + const { head, rules } = createValidationHead() + head.push({ + templateParams: { site: { name: 'My Site' }, separator: '|' }, + title: 'Hello', + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'missing-template-params-plugin')).toBeTruthy() + }) + + it('does not warn about templateParams when TemplateParamsPlugin is registered', () => { + const results: HeadValidationRule[] = [] + const head = createHead({ + disableDefaults: true, + plugins: [ + TemplateParamsPlugin, + ValidatePlugin({ onReport: r => results.push(...r) }), + ], + }) + head.push({ + templateParams: { site: { name: 'My Site' }, separator: '|' }, + title: 'Hello', + } as any) + renderSSRHead(head) + expect(results.find(r => r.id === 'missing-template-params-plugin')).toBeFalsy() + }) + + it('warns when before:/after: tagPriority used without AliasSortingPlugin', () => { + const { head, rules } = createValidationHead() + head.push({ + script: [{ src: '/a.js', tagPriority: 'before:script:key:b' }], + }) + renderSSRHead(head) + expect(rules.find(r => r.id === 'missing-alias-sorting-plugin')).toBeTruthy() + }) + + it('does not warn about alias sorting when AliasSortingPlugin is registered', () => { + const results: HeadValidationRule[] = [] + const head = createHead({ + disableDefaults: true, + plugins: [ + AliasSortingPlugin, + ValidatePlugin({ onReport: r => results.push(...r) }), + ], + }) + head.push({ + script: [{ src: '/a.js', tagPriority: 'before:script:key:b' }], + }) + renderSSRHead(head) + expect(results.find(r => r.id === 'missing-alias-sorting-plugin')).toBeFalsy() + }) + + it('warns on deprecated "children" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + script: [{ children: 'console.log("hello")' }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-children')).toBeTruthy() + }) + + it('warns on deprecated "hid" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + meta: [{ hid: 'description', name: 'description', content: 'test' }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-hid-vmid')).toBeTruthy() + }) + + it('warns on deprecated "vmid" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + meta: [{ vmid: 'description', name: 'description', content: 'test' }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-hid-vmid')).toBeTruthy() + }) + + it('warns on deprecated "body" prop', () => { + const { head, rules } = createValidationHead() + head.push({ + script: [{ src: '/script.js', body: true }], + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'deprecated-prop-body')).toBeTruthy() + }) + + it('respects rule severity config for migration rules', () => { + const { head, rules } = createValidationHead({ + rules: { 'missing-template-params-plugin': 'off' }, + }) + head.push({ + templateParams: { site: { name: 'My Site' } }, + title: 'Hello', + } as any) + renderSSRHead(head) + expect(rules.find(r => r.id === 'missing-template-params-plugin')).toBeFalsy() + }) + + it('warns on deprecated "mode" option in head.push()', () => { + const { head } = createValidationHead() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + head.push({ title: 'Test' }, { mode: 'server' } as any) + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('"mode: \'server\'" option was removed in v3'), + ) + warnSpy.mockRestore() + }) + + it('does not warn on "mode" option when rule is off', () => { + const results: HeadValidationRule[] = [] + const head = createHead({ + disableDefaults: true, + plugins: [ + ValidatePlugin({ + onReport: r => results.push(...r), + rules: { 'deprecated-option-mode': 'off' }, + }), + ], + }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + head.push({ title: 'Test' }, { mode: 'server' } as any) + expect(warnSpy).not.toHaveBeenCalled() + warnSpy.mockRestore() + }) + }) }) diff --git a/packages/vue/build.config.ts b/packages/vue/build.config.ts index 5b9698977..cf987c3a9 100644 --- a/packages/vue/build.config.ts +++ b/packages/vue/build.config.ts @@ -16,6 +16,7 @@ export default defineBuildConfig({ { input: 'src/utils', name: 'utils' }, { input: 'src/stream/vite', name: 'stream/vite' }, { input: 'src/stream/iife', name: 'stream/iife' }, + { input: 'src/legacy', name: 'legacy' }, ], hooks: { 'rollup:options': (_, options) => { diff --git a/packages/vue/package.json b/packages/vue/package.json index a2ee51e21..8bde2b19e 100644 --- a/packages/vue/package.json +++ b/packages/vue/package.json @@ -68,6 +68,10 @@ "./stream/iife": { "types": "./dist/stream/iife.d.ts", "default": "./dist/stream/iife.mjs" + }, + "./legacy": { + "types": "./dist/legacy.d.ts", + "default": "./dist/legacy.mjs" } }, "main": "dist/index.mjs", @@ -107,6 +111,9 @@ ], "stream/iife": [ "dist/stream/iife" + ], + "legacy": [ + "dist/legacy" ] } }, diff --git a/packages/vue/src/legacy.ts b/packages/vue/src/legacy.ts new file mode 100644 index 000000000..de1590f60 --- /dev/null +++ b/packages/vue/src/legacy.ts @@ -0,0 +1,2 @@ +console.warn('[unhead] `@unhead/vue/legacy` is deprecated. Import from `@unhead/vue/client` or `@unhead/vue/server` instead.') +export * from './client' diff --git a/test/exports/vue.yaml b/test/exports/vue.yaml index c47db7273..6f124635a 100644 --- a/test/exports/vue.yaml +++ b/test/exports/vue.yaml @@ -14,6 +14,10 @@ VueHeadMixin: object ./components: Head: object +./legacy: + createHead: function + renderDOMHead: function + VueHeadMixin: object ./plugins: AliasSortingPlugin: object CanonicalPlugin: function