@forsvn/metaprev 0.5.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/CHANGELOG.md +83 -0
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/metaprev.mjs +25 -0
- package/bin/metaprev.ts +353 -0
- package/package.json +57 -0
- package/skills/metaprev/SKILL.md +156 -0
- package/src/fetch.ts +215 -0
- package/src/format.ts +5 -0
- package/src/host.ts +93 -0
- package/src/inputs.ts +94 -0
- package/src/parse.ts +184 -0
- package/src/render.ts +980 -0
- package/src/repair.ts +171 -0
- package/src/types.ts +60 -0
- package/src/validate.ts +206 -0
package/src/parse.ts
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import type { MetaTags } from './types.ts'
|
|
2
|
+
|
|
3
|
+
const META_RE = /<meta\s+([^>]+?)\/?>/gi
|
|
4
|
+
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i
|
|
5
|
+
const LINK_RE = /<link\s+([^>]+?)\/?>/gi
|
|
6
|
+
|
|
7
|
+
const ATTR_RE = /(\w[\w:-]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/g
|
|
8
|
+
|
|
9
|
+
function attrs(raw: string): Record<string, string> {
|
|
10
|
+
const out: Record<string, string> = {}
|
|
11
|
+
let m: RegExpExecArray | null
|
|
12
|
+
ATTR_RE.lastIndex = 0
|
|
13
|
+
while ((m = ATTR_RE.exec(raw)) !== null) {
|
|
14
|
+
const key = m[1]?.toLowerCase()
|
|
15
|
+
const value = m[2] ?? m[3] ?? m[4] ?? ''
|
|
16
|
+
if (key) out[key] = decodeEntities(value).trim()
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Curated named entities common in titles/descriptions. HTML defines ~2100 named
|
|
22
|
+
// refs; shipping the full table isn't worth the bytes for a meta-tag parser, so this
|
|
23
|
+
// covers punctuation and symbols that actually show up in og:* content. Numeric
|
|
24
|
+
// refs (decimal + hex) are handled generally below.
|
|
25
|
+
const NAMED_ENTITIES: Record<string, string> = {
|
|
26
|
+
amp: '&', lt: '<', gt: '>', quot: '"', apos: "'",
|
|
27
|
+
nbsp: ' ', ensp: ' ', emsp: ' ', thinsp: ' ',
|
|
28
|
+
copy: '©', reg: '®', trade: '™',
|
|
29
|
+
hellip: '…', mdash: '—', ndash: '–', minus: '−',
|
|
30
|
+
lsquo: '‘', rsquo: '’', sbquo: '‚',
|
|
31
|
+
ldquo: '“', rdquo: '”', bdquo: '„',
|
|
32
|
+
laquo: '«', raquo: '»', lsaquo: '‹', rsaquo: '›',
|
|
33
|
+
bull: '•', middot: '·', dagger: '†', Dagger: '‡',
|
|
34
|
+
deg: '°', plusmn: '±', times: '×', divide: '÷',
|
|
35
|
+
euro: '€', pound: '£', cent: '¢', yen: '¥',
|
|
36
|
+
sect: '§', para: '¶', permil: '‰',
|
|
37
|
+
frac12: '½', frac14: '¼', frac34: '¾',
|
|
38
|
+
hearts: '♥', star: '★',
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const ENTITY_RE = /&(#x[0-9a-f]+|#\d+|[a-z][a-z0-9]*);/gi
|
|
42
|
+
|
|
43
|
+
function decodeEntities(s: string): string {
|
|
44
|
+
if (!s || s.indexOf('&') === -1) return s
|
|
45
|
+
return s.replace(ENTITY_RE, (match, body: string) => {
|
|
46
|
+
if (body[0] === '#') {
|
|
47
|
+
const cp = body[1] === 'x' || body[1] === 'X'
|
|
48
|
+
? parseInt(body.slice(2), 16)
|
|
49
|
+
: parseInt(body.slice(1), 10)
|
|
50
|
+
// Skip NUL, out-of-range, and invalid code points; leave the raw text intact.
|
|
51
|
+
if (!Number.isFinite(cp) || cp < 1 || cp > 0x10ffff) return match
|
|
52
|
+
try {
|
|
53
|
+
return String.fromCodePoint(cp)
|
|
54
|
+
} catch {
|
|
55
|
+
return match
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Named refs are case-sensitive in HTML; fall back to the lowercase form so
|
|
59
|
+
// `&` / `&Amp;` still resolve for the common entities. Use hasOwn so names
|
|
60
|
+
// that collide with Object.prototype members (constructor, toString, valueOf, …)
|
|
61
|
+
// don't resolve up the prototype chain and corrupt the text.
|
|
62
|
+
if (Object.hasOwn(NAMED_ENTITIES, body)) return NAMED_ENTITIES[body]!
|
|
63
|
+
const lower = body.toLowerCase()
|
|
64
|
+
if (Object.hasOwn(NAMED_ENTITIES, lower)) return NAMED_ENTITIES[lower]!
|
|
65
|
+
return match
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseMeta(html: string): MetaTags {
|
|
70
|
+
const head = extractHead(html)
|
|
71
|
+
const tags: MetaTags = {}
|
|
72
|
+
// Open Graph arrays attach structured properties to the root image they follow.
|
|
73
|
+
// metaprev previews the first image, so later images' dimensions/alt must never
|
|
74
|
+
// overwrite the selected image's evidence.
|
|
75
|
+
let selectedOgImageIsActive = false
|
|
76
|
+
let pendingStructured: Record<string, string> = {}
|
|
77
|
+
|
|
78
|
+
const titleMatch = TITLE_RE.exec(head)
|
|
79
|
+
if (titleMatch?.[1]) tags.title = decodeEntities(titleMatch[1].trim())
|
|
80
|
+
|
|
81
|
+
const selectImage = (content: string): void => {
|
|
82
|
+
if (!tags.ogImage) {
|
|
83
|
+
tags.ogImage = content
|
|
84
|
+
selectedOgImageIsActive = true
|
|
85
|
+
// Structured props declared before the first root tag belong to it; attach them now.
|
|
86
|
+
for (const [key, value] of Object.entries(pendingStructured)) assign(tags, key, value)
|
|
87
|
+
pendingStructured = {}
|
|
88
|
+
} else {
|
|
89
|
+
// A second root tag starts a new group the preview does not use.
|
|
90
|
+
selectedOgImageIsActive = false
|
|
91
|
+
pendingStructured = {}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let m: RegExpExecArray | null
|
|
96
|
+
META_RE.lastIndex = 0
|
|
97
|
+
while ((m = META_RE.exec(head)) !== null) {
|
|
98
|
+
const a = attrs(m[1] ?? '')
|
|
99
|
+
const key = (a['property'] ?? a['name'] ?? '').toLowerCase()
|
|
100
|
+
const content = a['content']
|
|
101
|
+
if (!key || content === undefined) continue
|
|
102
|
+
if (key === 'og:image') {
|
|
103
|
+
selectImage(content)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (key === 'og:image:url' || key === 'og:image:secure_url') {
|
|
107
|
+
selectImage(content)
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
if (key === 'og:image:width' || key === 'og:image:height' || key === 'og:image:alt') {
|
|
111
|
+
if (selectedOgImageIsActive) assign(tags, key, content)
|
|
112
|
+
else if (!tags.ogImage) pendingStructured[key] ??= content
|
|
113
|
+
continue
|
|
114
|
+
}
|
|
115
|
+
assign(tags, key, content)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
LINK_RE.lastIndex = 0
|
|
119
|
+
while ((m = LINK_RE.exec(head)) !== null) {
|
|
120
|
+
const a = attrs(m[1] ?? '')
|
|
121
|
+
const rels = (a['rel'] ?? '').toLowerCase().split(/\s+/)
|
|
122
|
+
if (rels.includes('canonical') && a['href']) {
|
|
123
|
+
tags.canonical ??= a['href']
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return tags
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function extractHead(html: string): string {
|
|
131
|
+
const match = /<head[^>]*>([\s\S]*?)<\/head>/i.exec(html)
|
|
132
|
+
return match?.[1] ?? html
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function assign(tags: MetaTags, key: string, value: string): void {
|
|
136
|
+
switch (key) {
|
|
137
|
+
case 'description':
|
|
138
|
+
tags.description ??= value
|
|
139
|
+
break
|
|
140
|
+
case 'og:site_name':
|
|
141
|
+
tags.ogSiteName = value
|
|
142
|
+
break
|
|
143
|
+
case 'og:type':
|
|
144
|
+
tags.ogType = value
|
|
145
|
+
break
|
|
146
|
+
case 'og:title':
|
|
147
|
+
tags.ogTitle = value
|
|
148
|
+
break
|
|
149
|
+
case 'og:description':
|
|
150
|
+
tags.ogDescription = value
|
|
151
|
+
break
|
|
152
|
+
case 'og:url':
|
|
153
|
+
tags.ogUrl = value
|
|
154
|
+
break
|
|
155
|
+
case 'og:image:width':
|
|
156
|
+
tags.ogImageWidth = value
|
|
157
|
+
break
|
|
158
|
+
case 'og:image:height':
|
|
159
|
+
tags.ogImageHeight = value
|
|
160
|
+
break
|
|
161
|
+
case 'og:image:alt':
|
|
162
|
+
tags.ogImageAlt = value
|
|
163
|
+
break
|
|
164
|
+
case 'twitter:card':
|
|
165
|
+
tags.twitterCard = value
|
|
166
|
+
break
|
|
167
|
+
case 'twitter:site':
|
|
168
|
+
tags.twitterSite = value
|
|
169
|
+
break
|
|
170
|
+
case 'twitter:title':
|
|
171
|
+
tags.twitterTitle = value
|
|
172
|
+
break
|
|
173
|
+
case 'twitter:description':
|
|
174
|
+
tags.twitterDescription = value
|
|
175
|
+
break
|
|
176
|
+
case 'twitter:image':
|
|
177
|
+
case 'twitter:image:src':
|
|
178
|
+
tags.twitterImage ??= value
|
|
179
|
+
break
|
|
180
|
+
case 'twitter:image:alt':
|
|
181
|
+
tags.twitterImageAlt = value
|
|
182
|
+
break
|
|
183
|
+
}
|
|
184
|
+
}
|