head-metadata 0.0.12 → 0.1.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/MIGRATION.md ADDED
@@ -0,0 +1,124 @@
1
+ # Migration Guide
2
+
3
+ ## 0.0.x to 0.1.0
4
+
5
+ `head-metadata` now uses extractor config keys as output field names. Collection
6
+ extractors preserve every result in document order instead of writing values into
7
+ a record, and extractor callbacks can return typed values of any shape.
8
+
9
+ ### Move Element Names Into Extractors
10
+
11
+ Previously, the config key selected the HTML element. The extractor's `key` or
12
+ `parent` selected the output field:
13
+
14
+ ```ts
15
+ // old
16
+ const metadata = extractHeadMetadata(html, {
17
+ title: titleExtractor,
18
+ meta: metaExtractor,
19
+ link: linkExtractor
20
+ });
21
+ ```
22
+
23
+ Extractors now declare their element with `tag`. Config keys name the output fields:
24
+
25
+ ```ts
26
+ // new
27
+ const metadata = extractHeadMetadata(html, {
28
+ pageTitle: titleExtractor,
29
+ meta: metaExtractor,
30
+ links: linkExtractor
31
+ });
32
+ ```
33
+
34
+ This allows multiple extractors to read the same element while producing different
35
+ fields.
36
+
37
+ ### Replace Collection Records With Arrays
38
+
39
+ Collection callbacks previously returned `{ key, value }`. Later values replaced
40
+ earlier values with the same key:
41
+
42
+ ```ts
43
+ // old
44
+ const imageExtractor = {
45
+ type: 'collection',
46
+ parent: 'images',
47
+ callback: (node) => ({ key: 'og:image', value: '/image.png' })
48
+ };
49
+ ```
50
+
51
+ Collection callbacks now return the item to append. The config key selects the
52
+ result array:
53
+
54
+ ```ts
55
+ // new
56
+ import type { TCollectionExtractor } from 'head-metadata';
57
+
58
+ const imageExtractor = {
59
+ tag: 'meta',
60
+ type: 'collection',
61
+ callback: () => ({ property: 'og:image', content: '/image.png' })
62
+ } satisfies TCollectionExtractor<{ property: string; content: string }>;
63
+
64
+ const metadata = extractHeadMetadata(html, { images: imageExtractor });
65
+ // metadata.images: Array<{ property: string; content: string }>
66
+ ```
67
+
68
+ Collections are always present, even when no element produces a value. Repeated
69
+ metadata is retained in document order.
70
+
71
+ ### Update Single Extractors
72
+
73
+ Move the selected element from the config key into `tag` and remove `key`:
74
+
75
+ ```ts
76
+ // old
77
+ const descriptionExtractor = {
78
+ type: 'single',
79
+ key: 'description',
80
+ callback: extractDescription
81
+ };
82
+
83
+ const metadata = extractHeadMetadata(html, { meta: descriptionExtractor });
84
+ ```
85
+
86
+ ```ts
87
+ // new
88
+ import type { TSingleExtractor } from 'head-metadata';
89
+
90
+ const descriptionExtractor = {
91
+ tag: 'meta',
92
+ type: 'single',
93
+ callback: extractDescription
94
+ } satisfies TSingleExtractor<string>;
95
+
96
+ const metadata = extractHeadMetadata(html, { description: descriptionExtractor });
97
+ ```
98
+
99
+ Single extractors keep the first non-null result. Their output field is optional
100
+ because no matching element may produce a value.
101
+
102
+ ### Update Built-In Extractor Results
103
+
104
+ `metaExtractor` and `linkExtractor` now return structured arrays:
105
+
106
+ | Old result | New result |
107
+ | -------------------------------- | ----------------------------------------------------------------- |
108
+ | `metadata.meta.description` | Search `metadata.meta` for `{ name: 'description' }` |
109
+ | `metadata.meta['og:image']` | Filter `metadata.meta` for `{ property: 'og:image' }` |
110
+ | `metadata.link.icon` | Filter `metadata.links` for entries whose `rel` includes `'icon'` |
111
+ | Repeated keys kept only the last | Repeated entries are all retained in document order |
112
+
113
+ Use any config key you prefer. This guide uses `meta` and `links` to match the
114
+ built-in result shapes.
115
+
116
+ The new `baseExtractor` reads the first non-null `<base href>` value.
117
+
118
+ ### Account for Decoded Character References
119
+
120
+ Extractor callbacks now receive decoded attribute values and ordinary text. For
121
+ example, `Cats &amp; Dogs` becomes `Cats & Dogs`. Script, style, and CDATA content
122
+ remain literal.
123
+
124
+ Tokenizer and extractor errors continue to propagate to the caller.
package/README.md CHANGED
@@ -10,70 +10,257 @@
10
10
  <img src="https://img.shields.io/bundlephobia/minzip/head-metadata.svg?label=minzipped%20size&style=flat&colorA=293140&colorB=FDE200" alt="NPM bundle minzipped size"/>
11
11
  </a>
12
12
  <a href="https://www.npmjs.com/package/head-metadata">
13
- <img src="https://img.shields.io/npm/dt/featuer-state.svg?label=downloads&style=flat&colorA=293140&colorB=FDE200" alt="NPM total downloads"/>
13
+ <img src="https://img.shields.io/npm/dt/head-metadata.svg?label=downloads&style=flat&colorA=293140&colorB=FDE200" alt="NPM total downloads"/>
14
14
  </a>
15
15
  <a href="https://discord.gg/w4xE3bSjhQ">
16
16
  <img src="https://img.shields.io/discord/795291052897992724.svg?label=&logo=discord&logoColor=000000&color=293140&labelColor=FDE200" alt="Join Discord"/>
17
17
  </a>
18
18
  </p>
19
19
 
20
- > Status: Experimental
20
+ `head-metadata` extracts typed values from the first `<head>` element in an HTML
21
+ document. Choose an output field and extractor for each value you need. Single
22
+ extractors keep one value, while collection extractors preserve repeated values in
23
+ document order.
21
24
 
22
- `head-metadata` is a typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document.
25
+ - Extract titles, base URLs, meta tags, and links without building a full-page DOM
26
+ - Preserve repeated Open Graph images, icons, alternates, and other metadata
27
+ - Infer result fields and value types from the extractor config
28
+ - Decode character references in attributes and ordinary text
29
+ - Add custom extractors for project-specific head elements
23
30
 
24
- ## 📖 Usage
31
+ ```ts
32
+ import { extractHeadMetadata, linkExtractor, metaExtractor, titleExtractor } from 'head-metadata';
33
+
34
+ const metadata = extractHeadMetadata(
35
+ `<head>
36
+ <title>Example</title>
37
+ <meta property="og:image" content="/first.png">
38
+ <meta property="og:image" content="/second.png">
39
+ <link rel="icon" sizes="48x48" href="/favicon.png">
40
+ </head>`,
41
+ {
42
+ title: titleExtractor,
43
+ meta: metaExtractor,
44
+ links: linkExtractor
45
+ }
46
+ );
47
+
48
+ metadata.title; // 'Example' | undefined
49
+ metadata.meta; // both og:image entries, in document order
50
+ metadata.links; // [{ rel: ['icon'], sizes: ['48x48'], href: '/favicon.png' }]
51
+ ```
52
+
53
+ Migrating from `0.0.x`? See [MIGRATION.md](./MIGRATION.md).
54
+
55
+ ## Install
56
+
57
+ ```bash
58
+ npm install head-metadata
59
+ ```
25
60
 
26
- ### Extract Metadata from `<head>`
61
+ ## Usage
62
+
63
+ Pass the HTML string and an extractor config to `extractHeadMetadata()`:
27
64
 
28
65
  ```ts
29
- import { extractHeadMetadata, linkExtractor, metaExtractor, titleExtractor } from 'head-metadata';
66
+ import {
67
+ baseExtractor,
68
+ extractHeadMetadata,
69
+ linkExtractor,
70
+ metaExtractor,
71
+ titleExtractor
72
+ } from 'head-metadata';
73
+
74
+ const metadata = extractHeadMetadata(html, {
75
+ title: titleExtractor,
76
+ baseHref: baseExtractor,
77
+ meta: metaExtractor,
78
+ links: linkExtractor
79
+ });
80
+ ```
81
+
82
+ Config keys name the output fields. Each extractor's `tag` selects the HTML element
83
+ it receives. Multiple extractors can select the same tag and produce different
84
+ fields.
85
+
86
+ The extractor kind determines whether an output field is optional or always present:
87
+
88
+ | Extractor kind | Result |
89
+ | -------------- | ---------------------------------------------------------------------- |
90
+ | `single` | First non-null result. The field is omitted when nothing produces one. |
91
+ | `collection` | All non-null results in document order. An empty array when unmatched. |
92
+
93
+ ## API
30
94
 
31
- const html = `
32
- <html>
33
- <head>
34
- <title>Example</title>
35
- <meta name="description" content="An example page" />
36
- <link rel="canonical" href="https://example.com" />
37
- </head>
38
- </html>
39
- `;
95
+ ### `extractHeadMetadata(html, extractors)`
40
96
 
97
+ Extracts configured values from the first `<head>` element.
98
+
99
+ ```ts
41
100
  const metadata = extractHeadMetadata(html, {
42
- meta: metaExtractor,
43
- title: titleExtractor,
44
- link: linkExtractor
101
+ pageTitle: titleExtractor,
102
+ meta: metaExtractor
45
103
  });
46
104
 
47
- console.log(metadata);
48
- /*
49
- {
50
- title: 'Example',
51
- meta: {
52
- description: 'An example page'
53
- },
54
- link: {
55
- canonical: 'https://example.com'
56
- }
57
- }
58
- */
105
+ metadata.pageTitle; // string | undefined
106
+ metadata.meta; // TMetaMetadata[]
107
+ ```
108
+
109
+ The function stops parsing after the first selected head. Tokenizer errors and
110
+ extractor callback errors propagate to the caller.
111
+
112
+ Extractor callbacks receive a `TXmlNode`:
113
+
114
+ | Field | Description |
115
+ | ------------ | ------------------------------------------- |
116
+ | `local` | Lowercase local element name |
117
+ | `prefix` | Lowercase namespace prefix, when present |
118
+ | `attributes` | Attributes with decoded values |
119
+ | `content` | Child nodes and non-whitespace text content |
120
+
121
+ Character references are decoded in ordinary text and attribute values. Script,
122
+ style, and CDATA content remain literal. URLs are not resolved or otherwise changed.
123
+
124
+ ## Built-In Extractors
125
+
126
+ ### `titleExtractor`
127
+
128
+ Selects `<title>` and returns its trimmed text. Configure it as a single field:
129
+
130
+ ```ts
131
+ const metadata = extractHeadMetadata(html, {
132
+ title: titleExtractor
133
+ });
134
+ ```
135
+
136
+ The first title that produces text wins. `metadata.title` is `undefined` when no
137
+ title produces text.
138
+
139
+ ### `baseExtractor`
140
+
141
+ Selects `<base>` and returns its `href` without resolving it:
142
+
143
+ ```ts
144
+ const metadata = extractHeadMetadata(html, {
145
+ baseHref: baseExtractor
146
+ });
147
+ ```
148
+
149
+ The first base element with an `href` wins.
150
+
151
+ ### `metaExtractor`
152
+
153
+ Selects `<meta>` elements and returns `TMetaMetadata[]`. Each entry preserves the
154
+ supported attributes that are present:
155
+
156
+ ```ts
157
+ const metadata = extractHeadMetadata(
158
+ `<head>
159
+ <meta name="description" content="An example page">
160
+ <meta property="og:image" content="/first.png">
161
+ <meta property="og:image" content="/second.png">
162
+ </head>`,
163
+ { meta: metaExtractor }
164
+ );
165
+
166
+ metadata.meta;
167
+ // [
168
+ // { name: 'description', content: 'An example page' },
169
+ // { property: 'og:image', content: '/first.png' },
170
+ // { property: 'og:image', content: '/second.png' }
171
+ // ]
59
172
  ```
60
173
 
61
- ### Create Custom Extractors
174
+ Supported fields are `charset`, `name`, `property`, `httpEquiv`, and `content`.
175
+ An element without any supported attribute is skipped.
176
+
177
+ ### `linkExtractor`
178
+
179
+ Selects `<link>` elements that contain both `rel` and `href`, and returns
180
+ `TLinkMetadata[]`:
181
+
182
+ ```ts
183
+ const metadata = extractHeadMetadata(
184
+ `<head>
185
+ <link rel="icon" type="image/png" sizes="32x32 48x48" href="/favicon.png">
186
+ </head>`,
187
+ { links: linkExtractor }
188
+ );
189
+
190
+ metadata.links;
191
+ // [{
192
+ // rel: ['icon'],
193
+ // href: '/favicon.png',
194
+ // type: 'image/png',
195
+ // sizes: ['32x32', '48x48']
196
+ // }]
197
+ ```
198
+
199
+ `rel` and `sizes` are split on ASCII whitespace. Relation tokens are lowercase.
200
+ The extractor also preserves `media` and `hreflang` when present.
201
+
202
+ ## Custom Extractors
203
+
204
+ Use `TSingleExtractor<GValue>` for the first matching value and
205
+ `TCollectionExtractor<GValue>` for every matching value. Return `null` to skip an
206
+ element.
207
+
208
+ ```ts
209
+ import { extractHeadMetadata, type TSingleExtractor } from 'head-metadata';
210
+
211
+ const descriptionExtractor = {
212
+ tag: 'meta',
213
+ type: 'single',
214
+ callback: (node) => {
215
+ const name = node.attributes.find((attribute) => attribute.local === 'name')?.value;
216
+ const content = node.attributes.find((attribute) => attribute.local === 'content')?.value;
217
+ return name === 'description' ? (content ?? null) : null;
218
+ }
219
+ } satisfies TSingleExtractor<string>;
220
+
221
+ const metadata = extractHeadMetadata(html, {
222
+ description: descriptionExtractor
223
+ });
224
+
225
+ metadata.description; // string | undefined
226
+ ```
62
227
 
63
- You can write your own extractors to handle any `<head>` child element:
228
+ Set `tag` to the lowercase HTML tag name. Config keys remain independent from tag
229
+ names, so several extractors can read the same element:
64
230
 
65
231
  ```ts
66
- export const customLinkExtractor = {
67
- type: 'collection' as const,
68
- parent: 'link' as const,
69
- callback: (node) => {
70
- const rel = node.attributes.find((a) => a.local === 'rel');
71
- const href = node.attributes.find((a) => a.local === 'href');
72
- if (rel != null && href != null) {
73
- return { key: rel.value, value: href.value };
74
- }
75
-
76
- return null;
77
- }
78
- } satisfies TCollectionExtractor;
232
+ const metadata = extractHeadMetadata(html, {
233
+ meta: metaExtractor,
234
+ description: descriptionExtractor
235
+ });
79
236
  ```
237
+
238
+ ## Scope
239
+
240
+ `head-metadata` parses HTML you already have. The calling application remains
241
+ responsible for:
242
+
243
+ - Fetching pages and following redirects
244
+ - Limiting response size and request duration
245
+ - Resolving relative URLs against the page URL and `<base href>`
246
+ - Selecting preferred Open Graph, Twitter, and fallback values
247
+ - Validating or downloading referenced resources
248
+
249
+ ## FAQ
250
+
251
+ ### Why use extractors instead of returning every head element?
252
+
253
+ Extractors keep parsing separate from application policy. They also keep the result
254
+ small and typed: callers choose the elements and output fields they need.
255
+
256
+ ### Are repeated metadata values preserved?
257
+
258
+ Yes. Collection extractors append every non-null result in document order. This
259
+ allows callers to choose among repeated Open Graph images, icons, and alternate
260
+ links.
261
+
262
+ ### Does it parse the entire document?
263
+
264
+ No. It processes only the first `<head>` selected by `xml-tokenizer`, then advances
265
+ the tokenizer to the end of its input. The HTML string is still supplied by the
266
+ caller; network streaming and response limits are outside this package.
@@ -1 +1 @@
1
- "use strict";var o=require("xml-tokenizer");function u(i,n){const r=Object.keys(n).reduce((e,s)=>(e[s]={},e),{}),t=[];return o.select(i,[[{axis:"self-or-descendant",local:"head"}]],(e,s)=>{switch(e.type){case"SelectionEnd":{s.goToEnd();break}case"ElementStart":{if(e.local in n){const l={local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,attributes:[],content:[]},a=t[t.length-1];a!=null&&a.content.push(l),t.push(l)}break}case"ElementEnd":{if(e.end.type==="Close"||e.end.type==="Empty"){const l=t[t.length-1];if(l==null)break;const a=n[l.local];if(a!=null)switch(a.type){case"collection":{const c=a.callback(l);c!=null&&(r[a.parent][c.key]=c.value);break}case"single":{const c=a.callback(l);c!=null&&(r[a.key]=c);break}}t.pop()}break}case"Attribute":{const l=t[t.length-1];l!=null&&l.attributes.push({local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,value:e.value});break}case"Text":case"Cdata":{const l=t[t.length-1];l!=null&&e.text.trim().length>0&&l.content.push(e.text);break}}},o.htmlConfig),r}exports.extractHeadMetadata=u;
1
+ "use strict";var u=require("entities"),p=require("xml-tokenizer");function h(f,d){var r;const o=new Map,s=new Map;for(const[e,l]of Object.entries(d)){const t=(r=s.get(l.tag))!=null?r:[];t.push([e,l]),s.set(l.tag,t),l.type==="collection"&&o.set(e,[])}const n=[];p.select(f,[[{axis:"self-or-descendant",local:"head"}]],(e,l)=>{switch(e.type){case"SelectionEnd":{l.goToEnd();break}case"ElementStart":{const t=n[n.length-1];if(t==null&&!s.has(e.local)){n.push(null);break}const a={local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,attributes:[],content:[]};t==null||t.content.push(a),n.push(a);break}case"ElementEnd":{if(e.end.type==="Open")break;const t=n.pop();if(t==null)break;n[n.length-1]==null&&i(t);break}case"Attribute":{const t=n[n.length-1];t==null||t.attributes.push({local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,value:u.decodeHTMLAttribute(e.value)});break}case"Text":case"Cdata":{const t=n[n.length-1];if(t==null)break;const a=t.local==="script"||t.local==="style",c=e.type==="Cdata"||a?e.text:u.decodeHTML(e.text);c.trim().length>0&&t.content.push(c);break}}},p.htmlConfig);function i(e){var l;for(const[t,a]of(l=s.get(e.local))!=null?l:[]){if(a.type==="single"&&o.has(t))continue;const c=a.callback(e);c!=null&&(a.type==="collection"?o.get(t).push(c):o.set(t,c))}for(const t of e.content)typeof t!="string"&&i(t)}return Object.fromEntries(o)}exports.extractHeadMetadata=h;
@@ -0,0 +1 @@
1
+ "use strict";const r={type:"single",tag:"base",callback:l=>{var t,a;return(a=(t=l.attributes.find(({local:e})=>e==="href"))==null?void 0:t.value)!=null?a:null}};exports.baseExtractor=r;
@@ -1 +1 @@
1
- "use strict";const r={type:"collection",parent:"link",callback:t=>{const e=t.attributes.find(l=>l.local==="rel"),n=t.attributes.find(l=>l.local==="href");return e!=null&&n!=null?{key:e.value,value:n.value}:null}};exports.linkExtractor=r;
1
+ "use strict";var b=Object.defineProperty,c=Object.getOwnPropertySymbols,p=Object.prototype.hasOwnProperty,v=Object.prototype.propertyIsEnumerable,f=(r,e,t)=>e in r?b(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t,a=(r,e)=>{for(var t in e||(e={}))p.call(e,t)&&f(r,t,e[t]);if(c)for(var t of c(e))v.call(e,t)&&f(r,t,e[t]);return r};const y={type:"collection",tag:"link",callback:r=>{const e=r.attributes.find(l=>l.local==="rel"),t=r.attributes.find(l=>l.local==="href");if(e==null||t==null)return null;const n=r.attributes.find(l=>l.local==="type"),u=r.attributes.find(l=>l.local==="sizes"),i=r.attributes.find(l=>l.local==="media"),o=r.attributes.find(l=>l.local==="hreflang");return a(a(a(a({rel:s(e.value,!0),href:t.value},n!=null?{type:n.value}:{}),u!=null?{sizes:s(u.value)}:{}),i!=null?{media:i.value}:{}),o!=null?{hreflang:o.value}:{})}};function s(r,e=!1){return r.split(/[\t\n\f\r ]+/).filter(t=>t.length>0).map(t=>e?t.toLowerCase():t)}exports.linkExtractor=y;
@@ -1 +1 @@
1
- "use strict";const n={type:"collection",parent:"meta",callback:e=>{const a=e.attributes.find(t=>t.local==="charset");if(a!=null)return{key:"charset",value:a.value};const l=e.attributes.find(t=>t.local==="name"||t.local==="property"),c=e.attributes.find(t=>t.local==="content");return l!=null&&c!=null?{key:l.value,value:c.value}:null}};exports.metaExtractor=n;
1
+ "use strict";const l={type:"collection",tag:"meta",callback:a=>{const t={};for(const{local:e,value:c}of a.attributes)switch(e){case"charset":case"name":case"property":case"content":t[e]!=null||(t[e]=c);break;case"http-equiv":t.httpEquiv!=null||(t.httpEquiv=c);break}return Object.keys(t).length>0?t:null}};exports.metaExtractor=l;
@@ -1 +1 @@
1
- "use strict";const r={type:"single",key:"title",callback:e=>{const t=e.content[0];return typeof t=="string"?t.trim():null}};exports.titleExtractor=r;
1
+ "use strict";const r={type:"single",tag:"title",callback:e=>{const t=e.content[0];return typeof t=="string"?t.trim():null}};exports.titleExtractor=r;
package/dist/cjs/index.js CHANGED
@@ -1 +1 @@
1
- "use strict";var t=require("./extract-head-metadata.js"),r=require("./extractors/link-extractor.js"),a=require("./extractors/meta-extractor.js"),e=require("./extractors/title-extractor.js");exports.extractHeadMetadata=t.extractHeadMetadata,exports.linkExtractor=r.linkExtractor,exports.metaExtractor=a.metaExtractor,exports.titleExtractor=e.titleExtractor;
1
+ "use strict";var t=require("./extract-head-metadata.js"),r=require("./extractors/base-extractor.js"),a=require("./extractors/link-extractor.js"),e=require("./extractors/meta-extractor.js"),c=require("./extractors/title-extractor.js");exports.extractHeadMetadata=t.extractHeadMetadata,exports.baseExtractor=r.baseExtractor,exports.linkExtractor=a.linkExtractor,exports.metaExtractor=e.metaExtractor,exports.titleExtractor=c.titleExtractor;
@@ -1 +1 @@
1
- import{select as i,htmlConfig as p}from"xml-tokenizer";function u(r,c){const o=Object.keys(c).reduce((e,s)=>(e[s]={},e),{}),t=[];return i(r,[[{axis:"self-or-descendant",local:"head"}]],(e,s)=>{switch(e.type){case"SelectionEnd":{s.goToEnd();break}case"ElementStart":{if(e.local in c){const l={local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,attributes:[],content:[]},a=t[t.length-1];a!=null&&a.content.push(l),t.push(l)}break}case"ElementEnd":{if(e.end.type==="Close"||e.end.type==="Empty"){const l=t[t.length-1];if(l==null)break;const a=c[l.local];if(a!=null)switch(a.type){case"collection":{const n=a.callback(l);n!=null&&(o[a.parent][n.key]=n.value);break}case"single":{const n=a.callback(l);n!=null&&(o[a.key]=n);break}}t.pop()}break}case"Attribute":{const l=t[t.length-1];l!=null&&l.attributes.push({local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,value:e.value});break}case"Text":case"Cdata":{const l=t[t.length-1];l!=null&&e.text.trim().length>0&&l.content.push(e.text);break}}},p),o}export{u as extractHeadMetadata};
1
+ import{decodeHTML as f,decodeHTMLAttribute as h}from"entities";import{select as d,htmlConfig as b}from"xml-tokenizer";function g(p,u){var r;const a=new Map,s=new Map;for(const[e,l]of Object.entries(u)){const t=(r=s.get(l.tag))!=null?r:[];t.push([e,l]),s.set(l.tag,t),l.type==="collection"&&a.set(e,[])}const n=[];d(p,[[{axis:"self-or-descendant",local:"head"}]],(e,l)=>{switch(e.type){case"SelectionEnd":{l.goToEnd();break}case"ElementStart":{const t=n[n.length-1];if(t==null&&!s.has(e.local)){n.push(null);break}const o={local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,attributes:[],content:[]};t==null||t.content.push(o),n.push(o);break}case"ElementEnd":{if(e.end.type==="Open")break;const t=n.pop();if(t==null)break;n[n.length-1]==null&&i(t);break}case"Attribute":{const t=n[n.length-1];t==null||t.attributes.push({local:e.local,prefix:e.prefix.length>0?e.prefix:void 0,value:h(e.value)});break}case"Text":case"Cdata":{const t=n[n.length-1];if(t==null)break;const o=t.local==="script"||t.local==="style",c=e.type==="Cdata"||o?e.text:f(e.text);c.trim().length>0&&t.content.push(c);break}}},b);function i(e){var l;for(const[t,o]of(l=s.get(e.local))!=null?l:[]){if(o.type==="single"&&a.has(t))continue;const c=o.callback(e);c!=null&&(o.type==="collection"?a.get(t).push(c):a.set(t,c))}for(const t of e.content)typeof t!="string"&&i(t)}return Object.fromEntries(a)}export{g as extractHeadMetadata};
@@ -0,0 +1 @@
1
+ const r={type:"single",tag:"base",callback:a=>{var l,t;return(t=(l=a.attributes.find(({local:e})=>e==="href"))==null?void 0:l.value)!=null?t:null}};export{r as baseExtractor};
@@ -1 +1 @@
1
- const a={type:"collection",parent:"link",callback:t=>{const e=t.attributes.find(l=>l.local==="rel"),n=t.attributes.find(l=>l.local==="href");return e!=null&&n!=null?{key:e.value,value:n.value}:null}};export{a as linkExtractor};
1
+ var b=Object.defineProperty,c=Object.getOwnPropertySymbols,p=Object.prototype.hasOwnProperty,v=Object.prototype.propertyIsEnumerable,f=(e,r,t)=>r in e?b(e,r,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[r]=t,a=(e,r)=>{for(var t in r||(r={}))p.call(r,t)&&f(e,t,r[t]);if(c)for(var t of c(r))v.call(r,t)&&f(e,t,r[t]);return e};const y={type:"collection",tag:"link",callback:e=>{const r=e.attributes.find(l=>l.local==="rel"),t=e.attributes.find(l=>l.local==="href");if(r==null||t==null)return null;const n=e.attributes.find(l=>l.local==="type"),u=e.attributes.find(l=>l.local==="sizes"),i=e.attributes.find(l=>l.local==="media"),o=e.attributes.find(l=>l.local==="hreflang");return a(a(a(a({rel:s(r.value,!0),href:t.value},n!=null?{type:n.value}:{}),u!=null?{sizes:s(u.value)}:{}),i!=null?{media:i.value}:{}),o!=null?{hreflang:o.value}:{})}};function s(e,r=!1){return e.split(/[\t\n\f\r ]+/).filter(t=>t.length>0).map(t=>r?t.toLowerCase():t)}export{y as linkExtractor};
@@ -1 +1 @@
1
- const c={type:"collection",parent:"meta",callback:e=>{const l=e.attributes.find(t=>t.local==="charset");if(l!=null)return{key:"charset",value:l.value};const a=e.attributes.find(t=>t.local==="name"||t.local==="property"),n=e.attributes.find(t=>t.local==="content");return a!=null&&n!=null?{key:a.value,value:n.value}:null}};export{c as metaExtractor};
1
+ const l={type:"collection",tag:"meta",callback:a=>{const t={};for(const{local:e,value:c}of a.attributes)switch(e){case"charset":case"name":case"property":case"content":t[e]!=null||(t[e]=c);break;case"http-equiv":t.httpEquiv!=null||(t.httpEquiv=c);break}return Object.keys(t).length>0?t:null}};export{l as metaExtractor};
@@ -1 +1 @@
1
- const n={type:"single",key:"title",callback:e=>{const t=e.content[0];return typeof t=="string"?t.trim():null}};export{n as titleExtractor};
1
+ const n={type:"single",tag:"title",callback:e=>{const t=e.content[0];return typeof t=="string"?t.trim():null}};export{n as titleExtractor};
package/dist/esm/index.js CHANGED
@@ -1 +1 @@
1
- import{extractHeadMetadata as o}from"./extract-head-metadata.js";import{linkExtractor as e}from"./extractors/link-extractor.js";import{metaExtractor as m}from"./extractors/meta-extractor.js";import{titleExtractor as f}from"./extractors/title-extractor.js";export{o as extractHeadMetadata,e as linkExtractor,m as metaExtractor,f as titleExtractor};
1
+ import{extractHeadMetadata as o}from"./extract-head-metadata.js";import{baseExtractor as e}from"./extractors/base-extractor.js";import{linkExtractor as m}from"./extractors/link-extractor.js";import{metaExtractor as f}from"./extractors/meta-extractor.js";import{titleExtractor as E}from"./extractors/title-extractor.js";export{e as baseExtractor,o as extractHeadMetadata,m as linkExtractor,f as metaExtractor,E as titleExtractor};
@@ -1,7 +1,19 @@
1
- import { TExtractCollectionKeys, TExtractors, TExtractSingleKeys } from './types';
1
+ import { type TCollectionExtractor, type TExtractor, type TExtractors } from './types';
2
+ /**
3
+ * Extracts configured metadata from the first `<head>` element.
4
+ *
5
+ * Config keys name output fields, while each extractor's `tag` selects an HTML
6
+ * element. Collections contain every non-null result in document order. Singles
7
+ * keep the first non-null result and are omitted when no value is found.
8
+ *
9
+ * Attribute values and ordinary text are decoded before callbacks run. Script,
10
+ * style, and CDATA content remain literal. Tokenizer and callback errors propagate.
11
+ */
2
12
  export declare function extractHeadMetadata<GExtractors extends TExtractors>(html: string, extractors: GExtractors): TExtractMetadata<GExtractors>;
3
13
  export type TExtractMetadata<GExtractors extends TExtractors> = {
4
- [K in TExtractSingleKeys<GExtractors>]: string;
14
+ [K in keyof GExtractors as GExtractors[K]['type'] extends 'collection' ? never : K]?: TExtractorResult<GExtractors[K]>;
5
15
  } & {
6
- [K in TExtractCollectionKeys<GExtractors>]: Record<string, string>;
16
+ [K in keyof GExtractors as GExtractors[K]['type'] extends 'collection' ? K : never]: TExtractorResult<GExtractors[K]>;
7
17
  };
18
+ type TExtractorResult<GExtractor extends TExtractor> = GExtractor extends TCollectionExtractor ? NonNullable<ReturnType<GExtractor['callback']>>[] : NonNullable<ReturnType<GExtractor['callback']>>;
19
+ export {};
@@ -0,0 +1,6 @@
1
+ /** Extracts the unresolved `href` from a `<base>` element. */
2
+ export declare const baseExtractor: {
3
+ type: "single";
4
+ tag: string;
5
+ callback: (node: import("xml-tokenizer").TXmlNode) => string | null;
6
+ };
@@ -1,3 +1,4 @@
1
+ export * from './base-extractor';
1
2
  export * from './link-extractor';
2
3
  export * from './meta-extractor';
3
4
  export * from './title-extractor';
@@ -1,8 +1,21 @@
1
+ /** Extracts link relationships and resource details from each `<link>` element. */
1
2
  export declare const linkExtractor: {
2
3
  type: "collection";
3
- parent: "link";
4
- callback: (node: import("..").TXmlNode) => {
5
- key: string;
6
- value: string;
7
- } | null;
4
+ tag: string;
5
+ callback: (node: import("xml-tokenizer").TXmlNode) => TLinkMetadata | null;
8
6
  };
7
+ /** Link relationship and resource attributes extracted from a `<link>` element. */
8
+ export interface TLinkMetadata {
9
+ /** Lowercase relationship tokens from `rel`. */
10
+ rel: string[];
11
+ /** Unresolved resource URL from `href`. */
12
+ href: string;
13
+ /** Resource media type. */
14
+ type?: string;
15
+ /** Resource size tokens from `sizes`. */
16
+ sizes?: string[];
17
+ /** Media query that controls when the link applies. */
18
+ media?: string;
19
+ /** Language of the linked resource. */
20
+ hreflang?: string;
21
+ }
@@ -1,8 +1,19 @@
1
+ /** Extracts supported attributes from each `<meta>` element. */
1
2
  export declare const metaExtractor: {
2
3
  type: "collection";
3
- parent: "meta";
4
- callback: (node: import("..").TXmlNode) => {
5
- key: string;
6
- value: string;
7
- } | null;
4
+ tag: string;
5
+ callback: (node: import("xml-tokenizer").TXmlNode) => TMetaMetadata | null;
8
6
  };
7
+ /** Supported attributes extracted from a `<meta>` element. */
8
+ export interface TMetaMetadata {
9
+ /** Declared document character encoding. */
10
+ charset?: string;
11
+ /** Metadata name, such as `description` or `twitter:card`. */
12
+ name?: string;
13
+ /** Metadata property, such as `og:title` or `og:image`. */
14
+ property?: string;
15
+ /** HTTP header name supplied through `http-equiv`. */
16
+ httpEquiv?: string;
17
+ /** Metadata value. */
18
+ content?: string;
19
+ }
@@ -1,5 +1,6 @@
1
+ /** Extracts trimmed text from a `<title>` element. */
1
2
  export declare const titleExtractor: {
2
3
  type: "single";
3
- key: "title";
4
- callback: (node: import("..").TXmlNode) => string | null;
4
+ tag: string;
5
+ callback: (node: import("xml-tokenizer").TXmlNode) => string | null;
5
6
  };
@@ -1,33 +1,20 @@
1
- export interface TXmlNode {
2
- local: string;
3
- prefix?: string;
4
- attributes: {
5
- local: string;
6
- prefix?: string;
7
- value: string;
8
- }[];
9
- content: (TXmlNode | string)[];
10
- }
11
- export type TCollectionExtractor = {
1
+ import { type TXmlNode } from 'xml-tokenizer';
2
+ export type { TXmlNode } from 'xml-tokenizer';
3
+ /** Extractor that collects every non-null callback result in document order. */
4
+ export interface TCollectionExtractor<GValue = unknown> {
12
5
  type: 'collection';
13
- parent: string;
14
- callback: (node: TXmlNode) => {
15
- key: string;
16
- value: string;
17
- } | null;
18
- };
19
- export type TSingleExtractor = {
6
+ /** Selects an HTML element by its lowercase tag name. */
7
+ tag: string;
8
+ /** Returns the value to append, or `null` to skip this element. */
9
+ callback: (node: TXmlNode) => GValue | null;
10
+ }
11
+ /** Extractor that keeps the first non-null callback result and skips later matches. */
12
+ export interface TSingleExtractor<GValue = unknown> {
20
13
  type: 'single';
21
- key: string;
22
- callback: (node: TXmlNode) => string | null;
23
- };
14
+ /** Selects an HTML element by its lowercase tag name. */
15
+ tag: string;
16
+ /** Returns the field value, or `null` to continue searching. */
17
+ callback: (node: TXmlNode) => GValue | null;
18
+ }
24
19
  export type TExtractor = TCollectionExtractor | TSingleExtractor;
25
- export type TExtractors = {
26
- [K: string]: TExtractor;
27
- };
28
- export type TExtractCollectionKeys<GExtractors extends TExtractors> = {
29
- [K in keyof GExtractors]: GExtractors[K] extends TCollectionExtractor ? GExtractors[K]['parent'] : never;
30
- }[keyof GExtractors];
31
- export type TExtractSingleKeys<GExtractors extends TExtractors> = {
32
- [K in keyof GExtractors]: GExtractors[K] extends TSingleExtractor ? GExtractors[K]['key'] : never;
33
- }[keyof GExtractors];
20
+ export type TExtractors = Record<string, TExtractor>;
package/package.json CHANGED
@@ -1,9 +1,23 @@
1
1
  {
2
2
  "name": "head-metadata",
3
- "version": "0.0.12",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
- "description": "Typesafe and straightforward utility for extracting structured metadata (like `<meta>`, `<title>`, and `<link>`) from the `<head>` of an HTML document",
6
- "keywords": [],
5
+ "description": "Typed HTML head metadata extraction with configurable single and collection extractors",
6
+ "keywords": [
7
+ "html",
8
+ "metadata",
9
+ "head-metadata",
10
+ "head",
11
+ "meta-tags",
12
+ "open-graph",
13
+ "twitter-card",
14
+ "seo",
15
+ "title",
16
+ "link-tags",
17
+ "typescript",
18
+ "extractor",
19
+ "xml-tokenizer"
20
+ ],
7
21
  "homepage": "https://builder.group/?utm_source=package-json",
8
22
  "bugs": {
9
23
  "url": "https://github.com/builder-group/community/issues"
@@ -20,14 +34,16 @@
20
34
  "types": "./dist/types/index.d.ts",
21
35
  "files": [
22
36
  "dist",
23
- "README.md"
37
+ "README.md",
38
+ "MIGRATION.md"
24
39
  ],
25
40
  "dependencies": {
26
- "xml-tokenizer": "0.0.45"
41
+ "entities": "^8.1.0",
42
+ "xml-tokenizer": "0.0.47"
27
43
  },
28
44
  "devDependencies": {
29
- "@types/node": "^25.5.2",
30
- "rollup-presets": "0.0.27"
45
+ "@types/node": "^26.5.1",
46
+ "rollup-presets": "0.0.30"
31
47
  },
32
48
  "size-limit": [
33
49
  {
@@ -39,8 +55,8 @@
39
55
  "build:prod": "export NODE_ENV=production && pnpm build",
40
56
  "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules",
41
57
  "install:clean": "pnpm run clean && pnpm install",
42
- "lint": "eslint . --fix",
43
- "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public",
58
+ "lint": "eslint .",
59
+ "publish:patch": "pnpm build:prod && pnpm version patch --no-git-tag-version && pnpm publish --no-git-checks --access=public",
44
60
  "size": "size-limit --why",
45
61
  "start:dev": "tsc -w",
46
62
  "test": "vitest run",