@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/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;` / `&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
+ }