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 +124 -0
- package/README.md +232 -45
- 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 +25 -9
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
|
@@ -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/
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
61
|
+
## Usage
|
|
62
|
+
|
|
63
|
+
Pass the HTML string and an extractor config to `extractHeadMetadata()`:
|
|
27
64
|
|
|
28
65
|
```ts
|
|
29
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
link: linkExtractor
|
|
101
|
+
pageTitle: titleExtractor,
|
|
102
|
+
meta: metaExtractor
|
|
45
103
|
});
|
|
46
104
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
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,9 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "head-metadata",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"private": false,
|
|
5
|
-
"description": "
|
|
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
|
-
"
|
|
41
|
+
"entities": "^8.1.0",
|
|
42
|
+
"xml-tokenizer": "0.0.47"
|
|
27
43
|
},
|
|
28
44
|
"devDependencies": {
|
|
29
|
-
"@types/node": "^
|
|
30
|
-
"rollup-presets": "0.0.
|
|
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 .
|
|
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",
|