Skip to content

Commit 06b4a5c

Browse files
authored
fix: better type narrowing escape hatches for custom rel/type (#735)
* feat(unhead): add defineLink and defineScript helpers for custom rel/type The `satisfies GenericLink` / `satisfies GenericScript` pattern the v3 docstrings and migration guide recommended did not actually type-check against useHead, because `GenericLink` / `GenericScript` are intentionally excluded from the `Link` / `Script` unions to prevent silent absorption of known `rel` / `type` values. Introduce `defineLink` and `defineScript` helpers that keep known-value strictness (e.g. `rel: 'preload'` still requires `as`, `type: 'module'` still requires `src` or inline content) while accepting custom values via `GenericLink` / `GenericScript`. Update docstrings, migration guide, release notes, and the useHead API reference accordingly. * feat(unhead): add standard link rels (me, webmention, privacy-policy, etc.) Several standard `<link>` rel keywords were missing from `KnownLinkRel` and the `Link` union, making them look like "custom" rels that needed `defineLink`. Add dedicated interfaces and union members for: `me`, `webmention`, `privacy-policy`, `terms-of-service`, `expect`, `compression-dictionary`, and `alternate stylesheet`. `alternate stylesheet` requires `title` per spec; `expect` carries an optional `blocking: 'render'`. Update `defineLink` examples and docs to use genuinely non-standard rels (`openid2.provider`, `EditURI`) instead of rels that are now directly supported. * test: update exports snapshot for defineLink/defineScript * fix(unhead): require textContent or innerHTML on data scripts DataScriptTextContent used optional textContent/innerHTML in both union branches, allowing empty data scripts like `{ type: 'application/ld+json' }` to type-check with no content at all. Make each branch require its respective content field so JsonLdScript, SpeculationRulesScript, and ApplicationJsonScript enforce non-empty payloads at the type level. Add type tests covering empty-payload rejection for ld+json, speculation rules, application/json, and importmap. Rename two script test titles from `rel="..."` to `type="..."` per review feedback.
1 parent e680860 commit 06b4a5c

10 files changed

Lines changed: 428 additions & 34 deletions

File tree

β€Ždocs/6.migration-guide/1.v3.mdβ€Ž

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ The SSR hooks (`ssr:beforeRender`, `ssr:render`, `ssr:rendered`) are now synchro
406406

407407
🚦 Impact Level: **Medium**
408408

409-
The `Link` and `Script` types are now strict discriminated unions. Known `rel` and `type` values enforce per-tag required properties at the type level. `GenericLink` and `GenericScript` are still exported for custom values.
409+
The `Link` and `Script` types are now strict discriminated unions. Known `rel` and `type` values enforce per-tag required properties at the type level. Use the new `defineLink` and `defineScript` helpers to declare custom values without losing strictness on known ones.
410410

411411
### Link Tags
412412

@@ -423,28 +423,28 @@ useHead({
423423
})
424424
```
425425

426-
For custom `rel` values not in the known set, use `satisfies GenericLink`:
426+
For non-standard `rel` values not covered by `KnownLinkRel` (e.g. OpenID endpoints, RSD links), use `defineLink`:
427427

428428
```ts
429-
import type { GenericLink } from 'unhead/types'
429+
import { defineLink, useHead } from 'unhead'
430430

431431
useHead({
432432
link: [
433-
{ rel: 'me', href: 'https://mastodon.social/@me' } satisfies GenericLink,
433+
defineLink({ rel: 'openid2.provider', href: 'https://example.com/openid' }),
434434
]
435435
})
436436
```
437437

438438
### Script Tags
439439

440-
Inline scripts must have `textContent` or `innerHTML` and cannot include `src`, `async`, or `defer`. For custom `type` values, use `satisfies GenericScript`:
440+
Inline scripts must have `textContent` or `innerHTML` and cannot include `src`, `async`, or `defer`. For custom `type` values, use `defineScript`:
441441

442442
```ts
443-
import type { GenericScript } from 'unhead/types'
443+
import { defineScript, useHead } from 'unhead'
444444

445445
useHead({
446446
script: [
447-
{ type: 'text/plain', textContent: '...' } satisfies GenericScript,
447+
defineScript({ type: 'text/plain', textContent: '...' }),
448448
]
449449
})
450450
```
@@ -460,14 +460,14 @@ Meta `content` is now required on name, property, and http-equiv meta tags. Use
460460

461461
### String Variables
462462

463-
When `rel` or `type` comes from a variable typed as `string`, TypeScript cannot narrow the union. Use `as const` or `satisfies`:
463+
When `rel` or `type` comes from a variable typed as `string`, TypeScript cannot narrow the union. Wrap it with `defineLink` / `defineScript` or use `as const` for literals:
464464

465465
```ts
466-
import type { GenericLink } from 'unhead/types'
466+
import { defineLink, useHead } from 'unhead'
467467

468468
const rel = getRelFromConfig() // string, not a literal
469469
useHead({
470-
link: [{ rel, href: '/path' } satisfies GenericLink]
470+
link: [defineLink({ rel, href: '/path' })]
471471
})
472472

473473
// or use as const for literals

β€Ždocs/7.releases/1.v3.mdβ€Ž

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,4 +213,4 @@ For the full migration guide, see [Migrate to v3](/docs/migration-guide/v3).
213213
- `TemplateParamsPlugin` and `AliasSortingPlugin` are no longer included by default
214214
- `init`, `dom:renderTag`, `dom:rendered` hooks removed; `dom:beforeRender` is now synchronous
215215
- `@unhead/addons` renamed to `@unhead/bundler`; framework Vite plugins now use a named `Unhead` export instead of a default export
216-
- `Link` / `Script` unions are strict: custom `rel` / `type` values need `satisfies GenericLink` / `GenericScript`, and meta `content` is now required (use `content: null` to remove)
216+
- `Link` / `Script` unions are strict: use the new `defineLink` / `defineScript` helpers for custom `rel` / `type` values, and meta `content` is now required (use `content: null` to remove)

β€Ždocs/head/7.api/composables/0.use-head.mdβ€Ž

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -362,28 +362,29 @@ declare module '@unhead/schema' {
362362

363363
### Type Narrowing
364364

365-
`Link` and `Script` use discriminated unions keyed on `rel` and `type`. Known values enforce per-tag required properties at the type level. For custom or non-standard values, use `satisfies` with the generic fallback types:
365+
`Link` and `Script` use discriminated unions keyed on `rel` and `type`. Known values enforce per-tag required properties at the type level. For non-standard values not covered by the built-in union, use the `defineLink` and `defineScript` helpers, which preserve strict narrowing on known values and fall through to `GenericLink` / `GenericScript` for anything else:
366366

367367
::code-group
368368

369-
```ts [Custom Link rel]
370-
import type { GenericLink } from '@unhead/dynamic-import/types'
369+
```ts [Non-standard Link rel]
370+
import { defineLink, useHead } from '@unhead/dynamic-import'
371371
372372
useHead({
373373
link: [
374374
{ rel: 'canonical', href: 'https://example.com' }, // known rel, works directly
375-
{ rel: 'webmention', href: 'https://...' } satisfies GenericLink, // custom rel
375+
{ rel: 'me', href: 'https://mastodon.social/@me' }, // known rel, works directly
376+
defineLink({ rel: 'openid2.provider', href: 'https://example.com/openid' }), // non-standard rel
376377
]
377378
})
378379
```
379380

380381
```ts [Custom Script type]
381-
import type { GenericScript } from '@unhead/dynamic-import/types'
382+
import { defineScript, useHead } from '@unhead/dynamic-import'
382383
383384
useHead({
384385
script: [
385386
{ src: 'https://example.com/app.js' }, // external script, works directly
386-
{ type: 'text/plain', textContent: '...' } satisfies GenericScript, // custom type
387+
defineScript({ type: 'text/plain', textContent: '...' }), // custom type
387388
]
388389
})
389390
```
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import type { InferLink, InferScript, Link, Script } from './types'
2+
3+
/**
4+
* Typed helper for declaring a `<link>` element inside {@link useHead}.
5+
*
6+
* Known `rel` values stay strict: `rel: 'preload'` still requires `as`,
7+
* preload fonts still require `crossorigin`, `rel: 'mask-icon'` still requires
8+
* `color`, etc. Non-standard `rel` values not covered by `KnownLinkRel` (e.g.
9+
* OpenID endpoints, custom protocol discovery links) are accepted via
10+
* `GenericLink` without losing strictness on the rest of the union.
11+
*
12+
* Standard rels like `'me'`, `'webmention'`, `'privacy-policy'`, and
13+
* `'terms-of-service'` are already in the `Link` union, so they work with
14+
* `useHead` directly without this helper.
15+
*
16+
* @example
17+
* ```ts
18+
* import { defineLink, useHead } from 'unhead'
19+
*
20+
* useHead({
21+
* link: [
22+
* defineLink({ rel: 'openid2.provider', href: 'https://example.com/openid' }),
23+
* defineLink({ rel: 'EditURI', href: '/rsd.xml', type: 'application/rsd+xml' }),
24+
* ],
25+
* })
26+
* ```
27+
*/
28+
export function defineLink<const T extends { rel: string }>(link: T & InferLink<T>): Link {
29+
return link as unknown as Link
30+
}
31+
32+
/**
33+
* Typed helper for declaring a `<script>` element inside {@link useHead}.
34+
*
35+
* Known `type` values stay strict: `type: 'module'` still requires `src` or inline
36+
* content, `type: 'application/ld+json'` still requires `textContent`, etc. Custom
37+
* or non-standard `type` values (e.g. `'text/plain'`, `'text/html'`) are accepted
38+
* via {@link GenericScript} without losing strictness on the rest of the union.
39+
*
40+
* @example
41+
* ```ts
42+
* import { defineScript, useHead } from 'unhead'
43+
*
44+
* useHead({
45+
* script: [
46+
* defineScript({ type: 'text/plain', textContent: 'debug-token' }),
47+
* ],
48+
* })
49+
* ```
50+
*/
51+
export function defineScript<const T extends object>(script: T & InferScript<T>): Script {
52+
return script as unknown as Script
53+
}

β€Žpackages/unhead/src/index.tsβ€Ž

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export { useHead, useHeadSafe, useScript, useSeoMeta } from './composables'
2+
export { defineLink, defineScript } from './define'
23
export { createUnhead } from './unhead'

β€Žpackages/unhead/src/types/schema/head.tsβ€Ž

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,27 @@ import type {
99
AlternateLanguageLink,
1010
AlternateLink,
1111
AlternateMediaLink,
12+
AlternateStylesheetLink,
1213
AppleTouchIconLink,
1314
AuthorLink,
1415
BareAlternateLink,
1516
CanonicalLink,
17+
CompressionDictionaryLink,
1618
DnsPrefetchLink,
19+
ExpectLink,
1720
FaviconLink,
1821
GenericLink,
1922
HelpLink,
2023
IconLink,
24+
InferLink,
2125
KnownLinkRel,
2226
LicenseLink,
2327
Link,
2428
LinkBase,
2529
LinkHttpEvents,
2630
ManifestLink,
2731
MaskIconLink,
32+
MeLink,
2833
ModulepreloadLink,
2934
NextLink,
3035
PingbackLink,
@@ -39,9 +44,12 @@ import type {
3944
PreloadStyleLink,
4045
PrerenderLink,
4146
PrevLink,
47+
PrivacyPolicyLink,
4248
SearchLink,
4349
// Narrowed link types for re-export
4450
StylesheetLink,
51+
TermsOfServiceLink,
52+
WebmentionLink,
4553
} from './link'
4654
import type {
4755
CharsetMeta,
@@ -62,9 +70,11 @@ import type {
6270
GenericScript,
6371
ImportMapConfig,
6472
ImportMapScript,
73+
InferScript,
6574
InlineModuleScript,
6675
InlineScript,
6776
JsonLdScript,
77+
KnownScriptType,
6878
ModuleScript,
6979
Script,
7080
ScriptBase,
@@ -264,22 +274,27 @@ export type {
264274
AlternateLanguageLink,
265275
AlternateLink,
266276
AlternateMediaLink,
277+
AlternateStylesheetLink,
267278
AppleTouchIconLink,
268279
AuthorLink,
269280
BareAlternateLink,
270281
CanonicalLink,
282+
CompressionDictionaryLink,
271283
DnsPrefetchLink,
284+
ExpectLink,
272285
FaviconLink,
273286
GenericLink,
274287
HelpLink,
275288
IconLink,
289+
InferLink,
276290
KnownLinkRel,
277291
LicenseLink,
278292
Link,
279293
LinkBase,
280294
LinkHttpEvents,
281295
ManifestLink,
282296
MaskIconLink,
297+
MeLink,
283298
ModulepreloadLink,
284299
NextLink,
285300
PingbackLink,
@@ -294,8 +309,11 @@ export type {
294309
PreloadStyleLink,
295310
PrerenderLink,
296311
PrevLink,
312+
PrivacyPolicyLink,
297313
SearchLink,
298314
StylesheetLink,
315+
TermsOfServiceLink,
316+
WebmentionLink,
299317
}
300318

301319
// Script types (narrowed)
@@ -305,9 +323,11 @@ export type {
305323
GenericScript,
306324
ImportMapConfig,
307325
ImportMapScript,
326+
InferScript,
308327
InlineModuleScript,
309328
InlineScript,
310329
JsonLdScript,
330+
KnownScriptType,
311331
ModuleScript,
312332
Script,
313333
ScriptBase,

0 commit comments

Comments
 (0)