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 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 `http-equiv`.
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.includes('@import') ? 30 : 51
273
+ priority = tag.innerHTML?.includes('@import') ? 30 : 51
219
274
  break
220
275
 
221
276
  case 'script':
222
- if (tag.async) priority = 20
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
@@ -2,4 +2,4 @@ import Helmet from './src/Helmet.astro'
2
2
 
3
3
  export default Helmet
4
4
 
5
- export type { HeadItems, Tag } from './src/main'
5
+ export type { HeadItems, JsonLdItem, Tag } from './src/main'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-helmet",
3
- "version": "1.0.5",
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": "^2.1.8",
36
- "astro": "^5.0.3",
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
- "vitest": "^2.1.8"
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): Required<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: Required<HeadItems>[]): Required<HeadItems> {
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.includes('@import') ? 30 : 51
170
+ priority = tag.innerHTML?.includes('@import') ? 30 : 51
134
171
  break
135
172
 
136
173
  case 'script':
137
- if (tag.async) priority = 20
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 deduplicateMetaItems(metaItems: BaseItem[]): BaseItem[] {
149
- const metaMap = groupMetaItemsByKey(metaItems)
150
- return Array.from(metaMap.values()).flatMap(deduplicateByMedia)
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 groupMetaItemsByKey(metaItems: BaseItem[]): Map<string, BaseItem[]> {
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
- metaItems.forEach((meta) => {
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 metaMap
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} ${attrs}>`
277
+ ? `<${item.tagName}${attrs ? ' ' + attrs : ''}>`
180
278
  : `<${item.tagName}${attrs && ' '}${attrs}>${item.innerHTML || ''}</${
181
279
  item.tagName
182
280
  }>`