head-metadata 0.0.13 → 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
@@ -17,37 +17,41 @@
17
17
  </a>
18
18
  </p>
19
19
 
20
- `head-metadata` extracts structured metadata from the `<head>` of an HTML document. It tokenizes the first head element with `xml-tokenizer`, ships extractors for `title`, `meta`, and `link`, and lets you add focused extractors for project-specific tags.
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
- - Read page title, meta tags, Open Graph tags, charset, and canonical links into typed output
23
- - Stop after the first `<head>` so full-page HTML does not need a DOM parse
24
- - Combine built-in extractors with custom `single` and `collection` extractors
25
- - Keep the extraction shape explicit: output keys come from the extractor config
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
26
30
 
27
31
  ```ts
28
32
  import { extractHeadMetadata, linkExtractor, metaExtractor, titleExtractor } from 'head-metadata';
29
33
 
30
- const html = `
31
- <head>
34
+ const metadata = extractHeadMetadata(
35
+ `<head>
32
36
  <title>Example</title>
33
- <meta name="description" content="An example page" />
34
- <meta property="og:title" content="Example OG title" />
35
- <link rel="canonical" href="https://example.com" />
36
- </head>
37
- `;
38
-
39
- const metadata = extractHeadMetadata(html, {
40
- title: titleExtractor,
41
- meta: metaExtractor,
42
- link: linkExtractor
43
- });
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
+ );
44
47
 
45
- console.log(metadata.title);
46
- console.log(metadata.meta.description);
47
- console.log(metadata.meta['og:title']);
48
- console.log(metadata.link.canonical);
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' }]
49
51
  ```
50
52
 
53
+ Migrating from `0.0.x`? See [MIGRATION.md](./MIGRATION.md).
54
+
51
55
  ## Install
52
56
 
53
57
  ```bash
@@ -56,173 +60,207 @@ npm install head-metadata
56
60
 
57
61
  ## Usage
58
62
 
59
- Pass HTML and the extractors you want to run:
63
+ Pass the HTML string and an extractor config to `extractHeadMetadata()`:
60
64
 
61
65
  ```ts
62
- import { extractHeadMetadata, linkExtractor, metaExtractor, titleExtractor } from 'head-metadata';
63
-
64
- const html = `
65
- <html>
66
- <head>
67
- <title>Example</title>
68
- <meta name="description" content="An example page" />
69
- <meta property="og:title" content="Example OG title" />
70
- <link rel="canonical" href="https://example.com" />
71
- </head>
72
- <body>Hello</body>
73
- </html>
74
- `;
66
+ import {
67
+ baseExtractor,
68
+ extractHeadMetadata,
69
+ linkExtractor,
70
+ metaExtractor,
71
+ titleExtractor
72
+ } from 'head-metadata';
75
73
 
76
74
  const metadata = extractHeadMetadata(html, {
77
75
  title: titleExtractor,
76
+ baseHref: baseExtractor,
78
77
  meta: metaExtractor,
79
- link: linkExtractor
78
+ links: linkExtractor
80
79
  });
81
-
82
- metadata.title; // Example
83
- metadata.meta.description; // An example page
84
- metadata.meta['og:title']; // Example OG title
85
- metadata.link.canonical; // https://example.com
86
80
  ```
87
81
 
88
- The output shape follows the extractor config. Config object keys choose the element names each extractor receives. `key` and `parent` choose metadata fields. Collection extractors write records under `parent`, while single extractors set a string under `key` when the callback returns a value.
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.
89
85
 
90
- ## Built-in Extractors
86
+ The extractor kind determines whether an output field is optional or always present:
91
87
 
92
- ### `titleExtractor`
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
93
94
 
94
- Reads text content from `<title>` and returns it as `metadata.title`.
95
+ ### `extractHeadMetadata(html, extractors)`
96
+
97
+ Extracts configured values from the first `<head>` element.
95
98
 
96
99
  ```ts
97
100
  const metadata = extractHeadMetadata(html, {
98
- title: titleExtractor
101
+ pageTitle: titleExtractor,
102
+ meta: metaExtractor
99
103
  });
100
- ```
101
-
102
- ### `metaExtractor`
103
-
104
- Reads `<meta>` tags into `metadata.meta`.
105
104
 
106
- ```html
107
- <meta charset="utf-8" />
108
- <meta name="description" content="An example page" />
109
- <meta property="og:title" content="Example OG title" />
105
+ metadata.pageTitle; // string | undefined
106
+ metadata.meta; // TMetaMetadata[]
110
107
  ```
111
108
 
112
- The extractor uses `charset`, `name`, or `property` as the record key.
109
+ The function stops parsing after the first selected head. Tokenizer errors and
110
+ extractor callback errors propagate to the caller.
113
111
 
114
- ### `linkExtractor`
112
+ Extractor callbacks receive a `TXmlNode`:
115
113
 
116
- Reads `<link>` tags into `metadata.link`.
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 |
117
120
 
118
- ```html
119
- <link rel="canonical" href="https://example.com" />
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.
121
123
 
122
- The extractor uses `rel` as the record key and `href` as the value.
124
+ ## Built-In Extractors
123
125
 
124
- ## Custom Extractors
126
+ ### `titleExtractor`
125
127
 
126
- Use a `single` extractor for one output value:
128
+ Selects `<title>` and returns its trimmed text. Configure it as a single field:
127
129
 
128
130
  ```ts
129
- import type { TSingleExtractor } from 'head-metadata';
131
+ const metadata = extractHeadMetadata(html, {
132
+ title: titleExtractor
133
+ });
134
+ ```
130
135
 
131
- const viewportExtractor = {
132
- type: 'single',
133
- key: 'viewport',
134
- callback: (node) => {
135
- const name = node.attributes.find((attr) => attr.local === 'name');
136
- const content = node.attributes.find((attr) => attr.local === 'content');
136
+ The first title that produces text wins. `metadata.title` is `undefined` when no
137
+ title produces text.
137
138
 
138
- return name?.value === 'viewport' && content != null ? content.value : null;
139
- }
140
- } satisfies TSingleExtractor;
141
- ```
139
+ ### `baseExtractor`
140
+
141
+ Selects `<base>` and returns its `href` without resolving it:
142
142
 
143
143
  ```ts
144
144
  const metadata = extractHeadMetadata(html, {
145
- meta: viewportExtractor
145
+ baseHref: baseExtractor
146
146
  });
147
-
148
- metadata.viewport;
149
147
  ```
150
148
 
151
- In this example, `meta` means the extractor receives `<meta>` elements. `key: 'viewport'` controls the output field.
149
+ The first base element with an `href` wins.
152
150
 
153
- Use a `collection` extractor when many tags should contribute to one record:
151
+ ### `metaExtractor`
154
152
 
155
- ```ts
156
- import type { TCollectionExtractor } from 'head-metadata';
153
+ Selects `<meta>` elements and returns `TMetaMetadata[]`. Each entry preserves the
154
+ supported attributes that are present:
157
155
 
158
- const iconExtractor = {
159
- type: 'collection',
160
- parent: 'link',
161
- callback: (node) => {
162
- const rel = node.attributes.find((attr) => attr.local === 'rel');
163
- const href = node.attributes.find((attr) => attr.local === 'href');
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
+ // ]
172
+ ```
164
173
 
165
- if (rel?.value.includes('icon') === true && href != null) {
166
- return { key: rel.value, value: href.value };
167
- }
174
+ Supported fields are `charset`, `name`, `property`, `httpEquiv`, and `content`.
175
+ An element without any supported attribute is skipped.
168
176
 
169
- return null;
170
- }
171
- } satisfies TCollectionExtractor;
172
- ```
177
+ ### `linkExtractor`
173
178
 
174
- Install custom extractors under the tag name they should receive:
179
+ Selects `<link>` elements that contain both `rel` and `href`, and returns
180
+ `TLinkMetadata[]`:
175
181
 
176
182
  ```ts
177
- const metadata = extractHeadMetadata(html, {
178
- link: iconExtractor
179
- });
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
+ // }]
180
197
  ```
181
198
 
182
- ## API
199
+ `rel` and `sizes` are split on ASCII whitespace. Relation tokens are lowercase.
200
+ The extractor also preserves `media` and `hreflang` when present.
183
201
 
184
- ### `extractHeadMetadata(html, extractors)`
202
+ ## Custom Extractors
185
203
 
186
- Tokenizes the first `<head>` element and returns metadata collected by the provided extractors.
204
+ Use `TSingleExtractor<GValue>` for the first matching value and
205
+ `TCollectionExtractor<GValue>` for every matching value. Return `null` to skip an
206
+ element.
187
207
 
188
208
  ```ts
189
- const metadata = extractHeadMetadata(html, {
190
- title: titleExtractor,
191
- meta: metaExtractor
192
- });
193
- ```
209
+ import { extractHeadMetadata, type TSingleExtractor } from 'head-metadata';
194
210
 
195
- Extractor callbacks receive a `TXmlNode` with this shape:
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>;
196
220
 
197
- | Field | Description |
198
- | ------------ | ------------------------------------------------ |
199
- | `local` | Local element name |
200
- | `prefix` | Namespace prefix when present |
201
- | `attributes` | Parsed attributes with local name, prefix, value |
202
- | `content` | Child nodes and text content |
221
+ const metadata = extractHeadMetadata(html, {
222
+ description: descriptionExtractor
223
+ });
203
224
 
204
- ## FAQ
225
+ metadata.description; // string | undefined
226
+ ```
205
227
 
206
- ### Is this a full metadata crawler?
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:
207
230
 
208
- No. `head-metadata` extracts metadata from HTML you already have. Fetching pages, following redirects, resolving relative URLs, and crawling links stay in your application code.
231
+ ```ts
232
+ const metadata = extractHeadMetadata(html, {
233
+ meta: metaExtractor,
234
+ description: descriptionExtractor
235
+ });
236
+ ```
209
237
 
210
- ### Why does it use extractors instead of returning every head tag?
238
+ ## Scope
211
239
 
212
- Extractors keep the output shape explicit and typed. You choose which tags matter, how keys are derived, and which tags should be ignored.
240
+ `head-metadata` parses HTML you already have. The calling application remains
241
+ responsible for:
213
242
 
214
- ### Can I extract Open Graph and Twitter metadata?
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
215
248
 
216
- Yes. `metaExtractor` stores both `name` and `property` attributes as keys, so tags such as `og:title` and `twitter:card` are included in `metadata.meta`.
249
+ ## FAQ
217
250
 
218
- ### What happens with multiple `<head>` elements?
251
+ ### Why use extractors instead of returning every head element?
219
252
 
220
- Only the first matched `<head>` is read. Later `<head>` elements are ignored.
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.
221
255
 
222
- ### What happens when two tags produce the same key?
256
+ ### Are repeated metadata values preserved?
223
257
 
224
- Collection output is a record. The later value replaces the earlier value for the same key.
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.
225
261
 
226
- ### Does it validate SEO metadata?
262
+ ### Does it parse the entire document?
227
263
 
228
- No. It extracts selected values only. It does not validate SEO rules, normalize metadata, or resolve relative URLs.
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,8 +1,8 @@
1
1
  {
2
2
  "name": "head-metadata",
3
- "version": "0.0.13",
3
+ "version": "0.1.0",
4
4
  "private": false,
5
- "description": "Typed HTML head metadata extraction for title, meta, link, and custom extractors",
5
+ "description": "Typed HTML head metadata extraction with configurable single and collection extractors",
6
6
  "keywords": [
7
7
  "html",
8
8
  "metadata",
@@ -34,14 +34,16 @@
34
34
  "types": "./dist/types/index.d.ts",
35
35
  "files": [
36
36
  "dist",
37
- "README.md"
37
+ "README.md",
38
+ "MIGRATION.md"
38
39
  ],
39
40
  "dependencies": {
40
- "xml-tokenizer": "0.0.45"
41
+ "entities": "^8.1.0",
42
+ "xml-tokenizer": "0.0.47"
41
43
  },
42
44
  "devDependencies": {
43
- "@types/node": "^25.9.1",
44
- "rollup-presets": "0.0.27"
45
+ "@types/node": "^26.5.1",
46
+ "rollup-presets": "0.0.30"
45
47
  },
46
48
  "size-limit": [
47
49
  {
@@ -54,7 +56,7 @@
54
56
  "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules",
55
57
  "install:clean": "pnpm run clean && pnpm install",
56
58
  "lint": "eslint .",
57
- "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public",
59
+ "publish:patch": "pnpm build:prod && pnpm version patch --no-git-tag-version && pnpm publish --no-git-checks --access=public",
58
60
  "size": "size-limit --why",
59
61
  "start:dev": "tsc -w",
60
62
  "test": "vitest run",