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 +124 -0
- package/README.md +166 -128
- package/dist/cjs/extract-head-metadata.js +1 -1
- package/dist/cjs/extractors/base-extractor.js +1 -0
- package/dist/cjs/extractors/link-extractor.js +1 -1
- package/dist/cjs/extractors/meta-extractor.js +1 -1
- package/dist/cjs/extractors/title-extractor.js +1 -1
- package/dist/cjs/index.js +1 -1
- package/dist/esm/extract-head-metadata.js +1 -1
- package/dist/esm/extractors/base-extractor.js +1 -0
- package/dist/esm/extractors/link-extractor.js +1 -1
- package/dist/esm/extractors/meta-extractor.js +1 -1
- package/dist/esm/extractors/title-extractor.js +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/types/extract-head-metadata.d.ts +15 -3
- package/dist/types/extractors/base-extractor.d.ts +6 -0
- package/dist/types/extractors/index.d.ts +1 -0
- package/dist/types/extractors/link-extractor.d.ts +18 -5
- package/dist/types/extractors/meta-extractor.d.ts +16 -5
- package/dist/types/extractors/title-extractor.d.ts +3 -2
- package/dist/types/types.d.ts +17 -30
- package/package.json +9 -7
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 & 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
|
|
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
|
-
-
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
-
|
|
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
|
|
31
|
-
|
|
34
|
+
const metadata = extractHeadMetadata(
|
|
35
|
+
`<head>
|
|
32
36
|
<title>Example</title>
|
|
33
|
-
<meta
|
|
34
|
-
<meta property="og:
|
|
35
|
-
<link rel="
|
|
36
|
-
</head
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
63
|
+
Pass the HTML string and an extractor config to `extractHeadMetadata()`:
|
|
60
64
|
|
|
61
65
|
```ts
|
|
62
|
-
import {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
86
|
+
The extractor kind determines whether an output field is optional or always present:
|
|
91
87
|
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
101
|
+
pageTitle: titleExtractor,
|
|
102
|
+
meta: metaExtractor
|
|
99
103
|
});
|
|
100
|
-
```
|
|
101
|
-
|
|
102
|
-
### `metaExtractor`
|
|
103
|
-
|
|
104
|
-
Reads `<meta>` tags into `metadata.meta`.
|
|
105
104
|
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
109
|
+
The function stops parsing after the first selected head. Tokenizer errors and
|
|
110
|
+
extractor callback errors propagate to the caller.
|
|
113
111
|
|
|
114
|
-
|
|
112
|
+
Extractor callbacks receive a `TXmlNode`:
|
|
115
113
|
|
|
116
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
124
|
+
## Built-In Extractors
|
|
123
125
|
|
|
124
|
-
|
|
126
|
+
### `titleExtractor`
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
Selects `<title>` and returns its trimmed text. Configure it as a single field:
|
|
127
129
|
|
|
128
130
|
```ts
|
|
129
|
-
|
|
131
|
+
const metadata = extractHeadMetadata(html, {
|
|
132
|
+
title: titleExtractor
|
|
133
|
+
});
|
|
134
|
+
```
|
|
130
135
|
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
145
|
+
baseHref: baseExtractor
|
|
146
146
|
});
|
|
147
|
-
|
|
148
|
-
metadata.viewport;
|
|
149
147
|
```
|
|
150
148
|
|
|
151
|
-
|
|
149
|
+
The first base element with an `href` wins.
|
|
152
150
|
|
|
153
|
-
|
|
151
|
+
### `metaExtractor`
|
|
154
152
|
|
|
155
|
-
|
|
156
|
-
|
|
153
|
+
Selects `<meta>` elements and returns `TMetaMetadata[]`. Each entry preserves the
|
|
154
|
+
supported attributes that are present:
|
|
157
155
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
174
|
+
Supported fields are `charset`, `name`, `property`, `httpEquiv`, and `content`.
|
|
175
|
+
An element without any supported attribute is skipped.
|
|
168
176
|
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
} satisfies TCollectionExtractor;
|
|
172
|
-
```
|
|
177
|
+
### `linkExtractor`
|
|
173
178
|
|
|
174
|
-
|
|
179
|
+
Selects `<link>` elements that contain both `rel` and `href`, and returns
|
|
180
|
+
`TLinkMetadata[]`:
|
|
175
181
|
|
|
176
182
|
```ts
|
|
177
|
-
const metadata = extractHeadMetadata(
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
202
|
+
## Custom Extractors
|
|
185
203
|
|
|
186
|
-
|
|
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
|
-
|
|
190
|
-
title: titleExtractor,
|
|
191
|
-
meta: metaExtractor
|
|
192
|
-
});
|
|
193
|
-
```
|
|
209
|
+
import { extractHeadMetadata, type TSingleExtractor } from 'head-metadata';
|
|
194
210
|
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
225
|
+
metadata.description; // string | undefined
|
|
226
|
+
```
|
|
205
227
|
|
|
206
|
-
|
|
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
|
-
|
|
231
|
+
```ts
|
|
232
|
+
const metadata = extractHeadMetadata(html, {
|
|
233
|
+
meta: metaExtractor,
|
|
234
|
+
description: descriptionExtractor
|
|
235
|
+
});
|
|
236
|
+
```
|
|
209
237
|
|
|
210
|
-
|
|
238
|
+
## Scope
|
|
211
239
|
|
|
212
|
-
|
|
240
|
+
`head-metadata` parses HTML you already have. The calling application remains
|
|
241
|
+
responsible for:
|
|
213
242
|
|
|
214
|
-
|
|
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
|
-
|
|
249
|
+
## FAQ
|
|
217
250
|
|
|
218
|
-
###
|
|
251
|
+
### Why use extractors instead of returning every head element?
|
|
219
252
|
|
|
220
|
-
|
|
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
|
-
###
|
|
256
|
+
### Are repeated metadata values preserved?
|
|
223
257
|
|
|
224
|
-
|
|
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
|
|
262
|
+
### Does it parse the entire document?
|
|
227
263
|
|
|
228
|
-
No. It
|
|
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
|
|
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";
|
|
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
|
|
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",
|
|
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/
|
|
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
|
|
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
|
-
|
|
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
|
|
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",
|
|
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{
|
|
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 {
|
|
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
|
|
14
|
+
[K in keyof GExtractors as GExtractors[K]['type'] extends 'collection' ? never : K]?: TExtractorResult<GExtractors[K]>;
|
|
5
15
|
} & {
|
|
6
|
-
[K in
|
|
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 {};
|
|
@@ -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
|
-
|
|
4
|
-
callback: (node: import("
|
|
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
|
-
|
|
4
|
-
callback: (node: import("
|
|
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
|
-
|
|
4
|
-
callback: (node: import("
|
|
4
|
+
tag: string;
|
|
5
|
+
callback: (node: import("xml-tokenizer").TXmlNode) => string | null;
|
|
5
6
|
};
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,33 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
export
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "Typed HTML head metadata extraction
|
|
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
|
-
"
|
|
41
|
+
"entities": "^8.1.0",
|
|
42
|
+
"xml-tokenizer": "0.0.47"
|
|
41
43
|
},
|
|
42
44
|
"devDependencies": {
|
|
43
|
-
"@types/node": "^
|
|
44
|
-
"rollup-presets": "0.0.
|
|
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",
|