astro-helmet 1.0.5 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -4
- package/index.ts +1 -1
- package/package.json +11 -5
- package/src/Helmet.astro +29 -1
- package/src/main.ts +114 -16
package/README.md
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- Include default charset and viewport meta tags.
|
|
11
11
|
- Order head items with default or specified priority.
|
|
12
12
|
- Meta tag deduplication.
|
|
13
|
+
- JSON-LD structured data helper with automatic `@context`, serialization, and escaping.
|
|
13
14
|
- Flexible API for adding head items and tag attributes.
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
@@ -113,12 +114,65 @@ const headItems: HeadItems = {
|
|
|
113
114
|
</Layout>
|
|
114
115
|
```
|
|
115
116
|
|
|
117
|
+
### JSON-LD / Structured Data
|
|
118
|
+
|
|
119
|
+
Use the `jsonLd` property to add [JSON-LD structured data](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data) to your pages. `@context` is set to `https://schema.org` automatically, `JSON.stringify()` is handled internally, and `</script>` sequences in values are escaped.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
const headItems: HeadItems = {
|
|
123
|
+
title: 'My Article',
|
|
124
|
+
jsonLd: {
|
|
125
|
+
'@type': 'Article',
|
|
126
|
+
headline: 'My Article',
|
|
127
|
+
author: { '@type': 'Person', name: 'Ryan' },
|
|
128
|
+
datePublished: '2025-12-01'
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Multiple JSON-LD blocks are supported — pass an array:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
const headItems: HeadItems = {
|
|
137
|
+
title: 'My Article',
|
|
138
|
+
jsonLd: [
|
|
139
|
+
{ '@type': 'Article', headline: 'My Article' },
|
|
140
|
+
{
|
|
141
|
+
'@type': 'BreadcrumbList',
|
|
142
|
+
itemListElement: [
|
|
143
|
+
{ '@type': 'ListItem', position: 1, name: 'Home', item: 'https://example.com/' },
|
|
144
|
+
{ '@type': 'ListItem', position: 2, name: 'My Article' }
|
|
145
|
+
]
|
|
146
|
+
}
|
|
147
|
+
]
|
|
148
|
+
}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
JSON-LD composes naturally with layout + page merging. Each source contributes blocks that render as separate `<script type="application/ld+json">` tags:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// Layout
|
|
155
|
+
const layoutHead: HeadItems = {
|
|
156
|
+
jsonLd: { '@type': 'WebSite', name: 'My Blog', url: 'https://example.com' }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Page
|
|
160
|
+
const pageHead: HeadItems = {
|
|
161
|
+
title: 'My Article',
|
|
162
|
+
jsonLd: { '@type': 'Article', headline: 'My Article' }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// <Helmet headItems={[layoutHead, pageHead]} />
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
JSON-LD script tags are rendered with priority `105`, placing them after regular meta tags and before noscript elements.
|
|
169
|
+
|
|
116
170
|
### Deduplication
|
|
117
171
|
|
|
118
172
|
When provided with an array of `headItems`, `astro-helmet` will merge the items together.
|
|
119
173
|
|
|
120
|
-
`headItems.meta` are deduplicated by `name`, `property` and `
|
|
121
|
-
Meta items later in the array replace earlier items.
|
|
174
|
+
`headItems.meta` are deduplicated by `name`, `property`, `http-equiv` and `charset`.
|
|
175
|
+
Meta items later in the array replace earlier items. Meta tags without any of these keys (e.g. `itemprop`-only tags) are never deduplicated.
|
|
122
176
|
|
|
123
177
|
`title` and `base` items are also deduplicated, with the last item in the array taking precedence.
|
|
124
178
|
|
|
@@ -182,6 +236,7 @@ By default, items are ordered as follows:
|
|
|
182
236
|
| 80 | `<link rel="prefetch" />` |
|
|
183
237
|
| 90 | remaining `<link>` |
|
|
184
238
|
| 100 | remaining `<meta>` |
|
|
239
|
+
| 105 | `<script type="application/ld+json">` |
|
|
185
240
|
| 110 | anything else |
|
|
186
241
|
|
|
187
242
|
This is the default implementation of `applyPriority()`:
|
|
@@ -215,11 +270,12 @@ function applyPriority(tag: Tag): Required<Tag> {
|
|
|
215
270
|
break
|
|
216
271
|
|
|
217
272
|
case 'style':
|
|
218
|
-
priority = tag.innerHTML
|
|
273
|
+
priority = tag.innerHTML?.includes('@import') ? 30 : 51
|
|
219
274
|
break
|
|
220
275
|
|
|
221
276
|
case 'script':
|
|
222
|
-
if (tag.
|
|
277
|
+
if (tag.type === 'application/ld+json') priority = 105
|
|
278
|
+
else if (tag.async) priority = 20
|
|
223
279
|
else if (tag.defer) priority = 70
|
|
224
280
|
else priority = 40
|
|
225
281
|
break
|
package/index.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "astro-helmet",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "A document head manager for astro.",
|
|
6
6
|
"author": "Ryan Voitiskis <ryanvoitiskis@pm.me> (https://ryanvoitiskis.com/)",
|
|
@@ -25,23 +25,29 @@
|
|
|
25
25
|
"scripts": {
|
|
26
26
|
"test": "vitest",
|
|
27
27
|
"coverage": "vitest run --coverage",
|
|
28
|
+
"typecheck": "astro check",
|
|
29
|
+
"lint": "eslint .",
|
|
28
30
|
"release": "semantic-release"
|
|
29
31
|
},
|
|
30
32
|
"devDependencies": {
|
|
33
|
+
"@astrojs/check": "^0.9.8",
|
|
34
|
+
"@eslint/js": "^9.39.2",
|
|
31
35
|
"@semantic-release/changelog": "^6.0.3",
|
|
32
36
|
"@semantic-release/git": "^10.0.1",
|
|
33
37
|
"@semantic-release/github": "^11.0.1",
|
|
34
38
|
"@semantic-release/npm": "^12.0.1",
|
|
35
|
-
"@vitest/coverage-v8": "^
|
|
36
|
-
"astro": "^
|
|
39
|
+
"@vitest/coverage-v8": "^4.1.2",
|
|
40
|
+
"astro": "^6.1.3",
|
|
41
|
+
"eslint": "^9.39.2",
|
|
37
42
|
"prettier": "^3.4.2",
|
|
38
43
|
"prettier-plugin-astro": "^0.14.1",
|
|
39
44
|
"semantic-release": "^24.2.0",
|
|
40
45
|
"typescript": "^5.7.2",
|
|
41
|
-
"
|
|
46
|
+
"typescript-eslint": "^8.55.0",
|
|
47
|
+
"vitest": "^4.1.2"
|
|
42
48
|
},
|
|
43
49
|
"peerDependencies": {
|
|
44
|
-
"astro": "^4.0.0 || ^5.0.0"
|
|
50
|
+
"astro": "^4.0.0 || ^5.0.0 || ^6.0.0"
|
|
45
51
|
},
|
|
46
52
|
"repository": {
|
|
47
53
|
"type": "git",
|
package/src/Helmet.astro
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
import { type HeadItems, type Tag, renderHead } from './main'
|
|
2
|
+
import { type HeadItems, type Tag, renderHead, getInlineContent, getExternalResources } from './main'
|
|
3
3
|
|
|
4
4
|
interface Props {
|
|
5
5
|
headItems: HeadItems | HeadItems[]
|
|
@@ -12,6 +12,34 @@ interface Props {
|
|
|
12
12
|
const { headItems, options = {} } = Astro.props
|
|
13
13
|
const { applyPriority, omitHeadTags = false } = options
|
|
14
14
|
const head = renderHead(headItems, applyPriority)
|
|
15
|
+
|
|
16
|
+
// Register CSP hashes and resources when running on Astro 6+ with CSP enabled.
|
|
17
|
+
// Cast avoids typecheck failures for downstream consumers on Astro 4/5 where
|
|
18
|
+
// `csp` isn't declared on `AstroGlobal`.
|
|
19
|
+
const csp = (Astro as unknown as {
|
|
20
|
+
csp?: {
|
|
21
|
+
insertScriptHash(hash: string): void
|
|
22
|
+
insertStyleHash(hash: string): void
|
|
23
|
+
insertScriptResource(url: string): void
|
|
24
|
+
insertStyleResource(url: string): void
|
|
25
|
+
}
|
|
26
|
+
}).csp
|
|
27
|
+
if (csp) {
|
|
28
|
+
for (const { type, content } of getInlineContent(headItems)) {
|
|
29
|
+
const hashBuffer = await crypto.subtle.digest(
|
|
30
|
+
'SHA-256',
|
|
31
|
+
new TextEncoder().encode(content)
|
|
32
|
+
)
|
|
33
|
+
const hash = btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
|
|
34
|
+
if (type === 'script') csp.insertScriptHash(`sha256-${hash}`)
|
|
35
|
+
else csp.insertStyleHash(`sha256-${hash}`)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (const { type, url } of getExternalResources(headItems)) {
|
|
39
|
+
if (type === 'script') csp.insertScriptResource(url)
|
|
40
|
+
else csp.insertStyleResource(url)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
15
43
|
---
|
|
16
44
|
|
|
17
45
|
{
|
package/src/main.ts
CHANGED
|
@@ -14,6 +14,7 @@ type TagName =
|
|
|
14
14
|
| 'noscript'
|
|
15
15
|
|
|
16
16
|
type BaseItem = {
|
|
17
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
18
|
[key: string]: any
|
|
18
19
|
priority?: number
|
|
19
20
|
}
|
|
@@ -27,6 +28,14 @@ export type Tag = (BaseItem | ContentItem) & {
|
|
|
27
28
|
priority?: number
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
export type JsonLdItem = {
|
|
32
|
+
'@type': string
|
|
33
|
+
/** Automatically injected — do not provide. */
|
|
34
|
+
'@context'?: never
|
|
35
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
36
|
+
[key: string]: any
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export type HeadItems = {
|
|
31
40
|
title?: string
|
|
32
41
|
base?: BaseItem[]
|
|
@@ -35,6 +44,18 @@ export type HeadItems = {
|
|
|
35
44
|
style?: ContentItem[]
|
|
36
45
|
script?: ContentItem[]
|
|
37
46
|
noscript?: ContentItem[]
|
|
47
|
+
jsonLd?: JsonLdItem | JsonLdItem[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type NormalisedHeadItems = {
|
|
51
|
+
title: string
|
|
52
|
+
base: BaseItem[]
|
|
53
|
+
meta: BaseItem[]
|
|
54
|
+
link: BaseItem[]
|
|
55
|
+
style: ContentItem[]
|
|
56
|
+
script: ContentItem[]
|
|
57
|
+
noscript: ContentItem[]
|
|
58
|
+
jsonLd: JsonLdItem[]
|
|
38
59
|
}
|
|
39
60
|
|
|
40
61
|
export function renderHead(
|
|
@@ -50,13 +71,23 @@ export function renderHead(
|
|
|
50
71
|
items.meta = deduplicateMetaItems(items.meta)
|
|
51
72
|
items.base = items.base.slice(-1)
|
|
52
73
|
|
|
53
|
-
const { title, ...rest } = items
|
|
74
|
+
const { title: _title, jsonLd: _jsonLd, ...rest } = items
|
|
54
75
|
const tags: Tag[] = Object.entries(rest).flatMap(([tagName, tagItems]) =>
|
|
55
76
|
tagItems.map((item) => ({ ...item, tagName }) as Tag)
|
|
56
77
|
)
|
|
57
78
|
tags.push({ tagName: 'title', innerHTML: items.title })
|
|
58
79
|
tags.push(...getDefaultTags(tags))
|
|
59
80
|
|
|
81
|
+
for (const item of items.jsonLd) {
|
|
82
|
+
tags.push({
|
|
83
|
+
tagName: 'script',
|
|
84
|
+
type: 'application/ld+json',
|
|
85
|
+
innerHTML: escapeJsonLd(
|
|
86
|
+
JSON.stringify({ ...item, '@context': 'https://schema.org' })
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
60
91
|
const prioritisedTags = tags.map((tag) =>
|
|
61
92
|
applyPriority ? applyPriority(tag) : applyPriorityDefault(tag)
|
|
62
93
|
)
|
|
@@ -66,7 +97,7 @@ export function renderHead(
|
|
|
66
97
|
return orderedTags.map((i) => renderHeadTag(i)).join('\n')
|
|
67
98
|
}
|
|
68
99
|
|
|
69
|
-
function normaliseHeadItems(items: HeadItems):
|
|
100
|
+
function normaliseHeadItems(items: HeadItems): NormalisedHeadItems {
|
|
70
101
|
return {
|
|
71
102
|
title: items.title || '',
|
|
72
103
|
base: items.base || [],
|
|
@@ -74,11 +105,16 @@ function normaliseHeadItems(items: HeadItems): Required<HeadItems> {
|
|
|
74
105
|
link: items.link || [],
|
|
75
106
|
style: items.style || [],
|
|
76
107
|
script: items.script || [],
|
|
77
|
-
noscript: items.noscript || []
|
|
108
|
+
noscript: items.noscript || [],
|
|
109
|
+
jsonLd: Array.isArray(items.jsonLd)
|
|
110
|
+
? items.jsonLd
|
|
111
|
+
: items.jsonLd
|
|
112
|
+
? [items.jsonLd]
|
|
113
|
+
: []
|
|
78
114
|
}
|
|
79
115
|
}
|
|
80
116
|
|
|
81
|
-
function mergeHeadItems(items:
|
|
117
|
+
function mergeHeadItems(items: NormalisedHeadItems[]): NormalisedHeadItems {
|
|
82
118
|
return items.reduce((merged, item) => {
|
|
83
119
|
merged.title = item.title || merged.title
|
|
84
120
|
merged.base.push(...item.base)
|
|
@@ -87,8 +123,9 @@ function mergeHeadItems(items: Required<HeadItems>[]): Required<HeadItems> {
|
|
|
87
123
|
merged.style.push(...item.style)
|
|
88
124
|
merged.script.push(...item.script)
|
|
89
125
|
merged.noscript.push(...item.noscript)
|
|
126
|
+
merged.jsonLd.push(...item.jsonLd)
|
|
90
127
|
return merged
|
|
91
|
-
})
|
|
128
|
+
}, normaliseHeadItems({}))
|
|
92
129
|
}
|
|
93
130
|
|
|
94
131
|
function getDefaultTags(tags: Tag[]): Tag[] {
|
|
@@ -130,11 +167,12 @@ function applyPriorityDefault(tag: Tag): Required<Tag> {
|
|
|
130
167
|
break
|
|
131
168
|
|
|
132
169
|
case 'style':
|
|
133
|
-
priority = tag.innerHTML
|
|
170
|
+
priority = tag.innerHTML?.includes('@import') ? 30 : 51
|
|
134
171
|
break
|
|
135
172
|
|
|
136
173
|
case 'script':
|
|
137
|
-
if (tag.
|
|
174
|
+
if (tag.type === 'application/ld+json') priority = 105
|
|
175
|
+
else if (tag.async) priority = 20
|
|
138
176
|
else if (tag.defer) priority = 70
|
|
139
177
|
else priority = 40
|
|
140
178
|
break
|
|
@@ -145,20 +183,80 @@ function applyPriorityDefault(tag: Tag): Required<Tag> {
|
|
|
145
183
|
return { ...tag, priority }
|
|
146
184
|
}
|
|
147
185
|
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
186
|
+
export function getInlineContent(
|
|
187
|
+
headItems: HeadItems | HeadItems[]
|
|
188
|
+
): { type: 'script' | 'style'; content: string }[] {
|
|
189
|
+
const items = Array.isArray(headItems)
|
|
190
|
+
? mergeHeadItems(headItems.map((i) => normaliseHeadItems(i)))
|
|
191
|
+
: normaliseHeadItems(headItems)
|
|
192
|
+
|
|
193
|
+
const result: { type: 'script' | 'style'; content: string }[] = []
|
|
194
|
+
|
|
195
|
+
for (const style of items.style) {
|
|
196
|
+
if (style.innerHTML) result.push({ type: 'style', content: style.innerHTML })
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
for (const script of items.script) {
|
|
200
|
+
if (script.innerHTML)
|
|
201
|
+
result.push({ type: 'script', content: script.innerHTML })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const item of items.jsonLd) {
|
|
205
|
+
result.push({
|
|
206
|
+
type: 'script',
|
|
207
|
+
content: escapeJsonLd(
|
|
208
|
+
JSON.stringify({ ...item, '@context': 'https://schema.org' })
|
|
209
|
+
)
|
|
210
|
+
})
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return result
|
|
151
214
|
}
|
|
152
215
|
|
|
153
|
-
function
|
|
216
|
+
export function getExternalResources(
|
|
217
|
+
headItems: HeadItems | HeadItems[]
|
|
218
|
+
): { type: 'script' | 'style'; url: string }[] {
|
|
219
|
+
const items = Array.isArray(headItems)
|
|
220
|
+
? mergeHeadItems(headItems.map((i) => normaliseHeadItems(i)))
|
|
221
|
+
: normaliseHeadItems(headItems)
|
|
222
|
+
|
|
223
|
+
const result: { type: 'script' | 'style'; url: string }[] = []
|
|
224
|
+
|
|
225
|
+
for (const script of items.script) {
|
|
226
|
+
if (script.src) result.push({ type: 'script', url: script.src })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const link of items.link) {
|
|
230
|
+
if (link.rel === 'stylesheet' && link.href)
|
|
231
|
+
result.push({ type: 'style', url: link.href })
|
|
232
|
+
else if (link.rel === 'preload' && link.href) {
|
|
233
|
+
if (link.as === 'script') result.push({ type: 'script', url: link.href })
|
|
234
|
+
else if (link.as === 'style')
|
|
235
|
+
result.push({ type: 'style', url: link.href })
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return result
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function escapeJsonLd(json: string): string {
|
|
243
|
+
return json.replace(/<\//g, '<\\/')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function deduplicateMetaItems(metaItems: BaseItem[]): BaseItem[] {
|
|
247
|
+
const keyless: BaseItem[] = []
|
|
154
248
|
const metaMap = new Map<string, BaseItem[]>()
|
|
155
249
|
|
|
156
|
-
|
|
157
|
-
const key = meta.property || meta.name || meta['http-equiv']
|
|
250
|
+
for (const meta of metaItems) {
|
|
251
|
+
const key = meta.property || meta.name || meta['http-equiv'] || (meta.charset ? 'charset' : null)
|
|
158
252
|
if (key) metaMap.set(key, (metaMap.get(key) || []).concat(meta))
|
|
159
|
-
|
|
253
|
+
else keyless.push(meta)
|
|
254
|
+
}
|
|
160
255
|
|
|
161
|
-
return
|
|
256
|
+
return [
|
|
257
|
+
...keyless,
|
|
258
|
+
...Array.from(metaMap.values()).flatMap(deduplicateByMedia)
|
|
259
|
+
]
|
|
162
260
|
}
|
|
163
261
|
|
|
164
262
|
function deduplicateByMedia(items: BaseItem[]): BaseItem[] {
|
|
@@ -176,7 +274,7 @@ function deduplicateByMedia(items: BaseItem[]): BaseItem[] {
|
|
|
176
274
|
function renderHeadTag(item: BaseItem | ContentItem): string {
|
|
177
275
|
const attrs = renderAttrs(item)
|
|
178
276
|
return ['meta', 'link', 'base'].includes(item.tagName)
|
|
179
|
-
? `<${item.tagName}
|
|
277
|
+
? `<${item.tagName}${attrs ? ' ' + attrs : ''}>`
|
|
180
278
|
: `<${item.tagName}${attrs && ' '}${attrs}>${item.innerHTML || ''}</${
|
|
181
279
|
item.tagName
|
|
182
280
|
}>`
|