@open-mercato/web-research 0.7.1-develop.7194.1.ab4fc81f82

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.
Files changed (121) hide show
  1. package/.turbo/turbo-build.log +2 -0
  2. package/AGENTS.md +67 -0
  3. package/build.mjs +10 -0
  4. package/dist/contract/adapter.js +13 -0
  5. package/dist/contract/adapter.js.map +7 -0
  6. package/dist/contract/errors.js +28 -0
  7. package/dist/contract/errors.js.map +7 -0
  8. package/dist/contract/http.js +7 -0
  9. package/dist/contract/http.js.map +7 -0
  10. package/dist/contract/index.js +9 -0
  11. package/dist/contract/index.js.map +7 -0
  12. package/dist/contract/outcomes.js +58 -0
  13. package/dist/contract/outcomes.js.map +7 -0
  14. package/dist/contract/policy.js +91 -0
  15. package/dist/contract/policy.js.map +7 -0
  16. package/dist/contract/results.js +13 -0
  17. package/dist/contract/results.js.map +7 -0
  18. package/dist/contract/steps.js +1 -0
  19. package/dist/contract/steps.js.map +7 -0
  20. package/dist/contract/version.js +11 -0
  21. package/dist/contract/version.js.map +7 -0
  22. package/dist/engine/cache.js +29 -0
  23. package/dist/engine/cache.js.map +7 -0
  24. package/dist/engine/deadline.js +54 -0
  25. package/dist/engine/deadline.js.map +7 -0
  26. package/dist/engine/engine.js +481 -0
  27. package/dist/engine/engine.js.map +7 -0
  28. package/dist/engine/index.js +20 -0
  29. package/dist/engine/index.js.map +7 -0
  30. package/dist/engine/runAdapter.js +57 -0
  31. package/dist/engine/runAdapter.js.map +7 -0
  32. package/dist/engine/types.js +1 -0
  33. package/dist/engine/types.js.map +7 -0
  34. package/dist/extract/classify.js +51 -0
  35. package/dist/extract/classify.js.map +7 -0
  36. package/dist/extract/content.js +104 -0
  37. package/dist/extract/content.js.map +7 -0
  38. package/dist/extract/entities.js +95 -0
  39. package/dist/extract/entities.js.map +7 -0
  40. package/dist/extract/index.js +24 -0
  41. package/dist/extract/index.js.map +7 -0
  42. package/dist/extract/text.js +114 -0
  43. package/dist/extract/text.js.map +7 -0
  44. package/dist/extract/tokenizer.js +141 -0
  45. package/dist/extract/tokenizer.js.map +7 -0
  46. package/dist/fusion/canonicalize.js +88 -0
  47. package/dist/fusion/canonicalize.js.map +7 -0
  48. package/dist/fusion/fuse.js +88 -0
  49. package/dist/fusion/fuse.js.map +7 -0
  50. package/dist/fusion/index.js +11 -0
  51. package/dist/fusion/index.js.map +7 -0
  52. package/dist/index.js +21 -0
  53. package/dist/index.js.map +7 -0
  54. package/dist/loader/describe.js +92 -0
  55. package/dist/loader/describe.js.map +7 -0
  56. package/dist/loader/index.js +31 -0
  57. package/dist/loader/index.js.map +7 -0
  58. package/dist/loader/manifest.js +32 -0
  59. package/dist/loader/manifest.js.map +7 -0
  60. package/dist/loader/registry.js +98 -0
  61. package/dist/loader/registry.js.map +7 -0
  62. package/dist/net/client.js +298 -0
  63. package/dist/net/client.js.map +7 -0
  64. package/dist/net/ssrf.js +165 -0
  65. package/dist/net/ssrf.js.map +7 -0
  66. package/dist/net/url.js +9 -0
  67. package/dist/net/url.js.map +7 -0
  68. package/dist/testing/contract.js +118 -0
  69. package/dist/testing/contract.js.map +7 -0
  70. package/dist/testing/fakeAdapter.js +44 -0
  71. package/dist/testing/fakeAdapter.js.map +7 -0
  72. package/dist/testing/index.js +15 -0
  73. package/dist/testing/index.js.map +7 -0
  74. package/jest.config.cjs +24 -0
  75. package/package.json +65 -0
  76. package/src/contract/adapter.ts +100 -0
  77. package/src/contract/errors.ts +48 -0
  78. package/src/contract/http.ts +46 -0
  79. package/src/contract/index.ts +8 -0
  80. package/src/contract/outcomes.ts +91 -0
  81. package/src/contract/policy.ts +157 -0
  82. package/src/contract/results.ts +79 -0
  83. package/src/contract/steps.ts +45 -0
  84. package/src/contract/version.ts +25 -0
  85. package/src/engine/__tests__/engine.test.ts +597 -0
  86. package/src/engine/cache.ts +46 -0
  87. package/src/engine/deadline.ts +65 -0
  88. package/src/engine/engine.ts +633 -0
  89. package/src/engine/index.ts +22 -0
  90. package/src/engine/runAdapter.ts +89 -0
  91. package/src/engine/types.ts +137 -0
  92. package/src/extract/__tests__/content.test.ts +81 -0
  93. package/src/extract/__tests__/text.test.ts +61 -0
  94. package/src/extract/__tests__/tokenizer.test.ts +85 -0
  95. package/src/extract/classify.ts +81 -0
  96. package/src/extract/content.ts +136 -0
  97. package/src/extract/entities.ts +98 -0
  98. package/src/extract/index.ts +11 -0
  99. package/src/extract/text.ts +124 -0
  100. package/src/extract/tokenizer.ts +197 -0
  101. package/src/fusion/__tests__/fusion.test.ts +198 -0
  102. package/src/fusion/canonicalize.ts +119 -0
  103. package/src/fusion/fuse.ts +145 -0
  104. package/src/fusion/index.ts +8 -0
  105. package/src/index.ts +15 -0
  106. package/src/loader/__tests__/describe.test.ts +119 -0
  107. package/src/loader/__tests__/registry.test.ts +134 -0
  108. package/src/loader/describe.ts +145 -0
  109. package/src/loader/index.ts +25 -0
  110. package/src/loader/manifest.ts +44 -0
  111. package/src/loader/registry.ts +156 -0
  112. package/src/net/__tests__/client.test.ts +273 -0
  113. package/src/net/__tests__/ssrf.test.ts +165 -0
  114. package/src/net/client.ts +434 -0
  115. package/src/net/ssrf.ts +235 -0
  116. package/src/net/url.ts +13 -0
  117. package/src/testing/contract.ts +172 -0
  118. package/src/testing/fakeAdapter.ts +69 -0
  119. package/src/testing/index.ts +9 -0
  120. package/tsconfig.json +10 -0
  121. package/watch.mjs +7 -0
@@ -0,0 +1,136 @@
1
+ import { BLOCK_ELEMENTS, NON_CONTENT_ELEMENTS, normalizeWhitespace } from './text'
2
+ import { tokenizeHtml } from './tokenizer'
3
+
4
+ const CANDIDATE_ELEMENTS = new Set(['article', 'main', 'section', 'div', 'td', 'body'])
5
+ const SEMANTIC_BONUS = new Set(['article', 'main'])
6
+
7
+ const NEGATIVE_HINT =
8
+ /(^|[\s_-])(nav|navigation|menu|sidebar|side-bar|footer|masthead|comment|share|social|advert|promo|banner|cookie|consent|related|breadcrumb|pagination|widget|newsletter|subscribe)([\s_-]|$)/i
9
+ const POSITIVE_HINT = /(^|[\s_-])(article|content|post|story|entry|body-?text|main)([\s_-]|$)/i
10
+
11
+ /** Below this a candidate is treated as chrome rather than an article. */
12
+ const MIN_CONTENT_CHARS = 200
13
+
14
+ type BestRange = {
15
+ readonly startPart: number
16
+ readonly endPart: number
17
+ readonly score: number
18
+ }
19
+
20
+ type Frame = {
21
+ readonly name: string
22
+ readonly startPart: number
23
+ readonly hintScore: number
24
+ textLength: number
25
+ linkTextLength: number
26
+ paragraphs: number
27
+ }
28
+
29
+ function hintScoreOf(attributes: ReadonlyMap<string, string>): number {
30
+ const descriptor = `${attributes.get('class') ?? ''} ${attributes.get('id') ?? ''} ${attributes.get('role') ?? ''}`
31
+ if (descriptor.trim().length === 0) return 0
32
+ let score = 0
33
+ if (NEGATIVE_HINT.test(descriptor)) score -= 600
34
+ if (POSITIVE_HINT.test(descriptor)) score += 300
35
+ return score
36
+ }
37
+
38
+ function scoreOf(frame: Frame): number {
39
+ const semantic = SEMANTIC_BONUS.has(frame.name) ? 400 : 0
40
+ return frame.textLength - 2 * frame.linkTextLength + 25 * frame.paragraphs + frame.hintScore + semantic
41
+ }
42
+
43
+ /**
44
+ * Picks the subtree that reads like the article, using text-to-link density plus
45
+ * paragraph count and class/id hints. Counters are accumulated during the single
46
+ * tokenizer pass and only the winning range is materialized, so the scan stays
47
+ * linear in time and the output allocates once.
48
+ */
49
+ export function extractMainContent(html: string): string {
50
+ const parts: string[] = []
51
+ const stack: Frame[] = []
52
+ const best: { current: BestRange | null } = { current: null }
53
+ let linkDepth = 0
54
+ let skipName: string | null = null
55
+ let skipDepth = 0
56
+
57
+ const closeFrame = (): void => {
58
+ const frame = stack.pop()
59
+ if (!frame) return
60
+ const score = scoreOf(frame)
61
+ if (frame.textLength >= MIN_CONTENT_CHARS && (best.current === null || score > best.current.score)) {
62
+ best.current = { startPart: frame.startPart, endPart: parts.length, score }
63
+ }
64
+ const parent = stack[stack.length - 1]
65
+ if (parent) {
66
+ parent.textLength += frame.textLength
67
+ parent.linkTextLength += frame.linkTextLength
68
+ parent.paragraphs += frame.paragraphs
69
+ }
70
+ }
71
+
72
+ for (const token of tokenizeHtml(html)) {
73
+ if (token.kind === 'open') {
74
+ if (skipName !== null) {
75
+ if (token.name === skipName) skipDepth += 1
76
+ continue
77
+ }
78
+ if (NON_CONTENT_ELEMENTS.has(token.name)) {
79
+ skipName = token.name
80
+ skipDepth = 1
81
+ continue
82
+ }
83
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
84
+ if (token.name === 'a') linkDepth += 1
85
+ if (token.name === 'p') {
86
+ const frame = stack[stack.length - 1]
87
+ if (frame) frame.paragraphs += 1
88
+ }
89
+ if (CANDIDATE_ELEMENTS.has(token.name) && !token.selfClosing) {
90
+ stack.push({
91
+ name: token.name,
92
+ startPart: parts.length,
93
+ hintScore: hintScoreOf(token.attributes),
94
+ textLength: 0,
95
+ linkTextLength: 0,
96
+ paragraphs: 0,
97
+ })
98
+ }
99
+ continue
100
+ }
101
+
102
+ if (token.kind === 'close') {
103
+ if (skipName !== null) {
104
+ if (token.name === skipName) {
105
+ skipDepth -= 1
106
+ if (skipDepth <= 0) skipName = null
107
+ }
108
+ continue
109
+ }
110
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
111
+ if (token.name === 'a' && linkDepth > 0) linkDepth -= 1
112
+ if (CANDIDATE_ELEMENTS.has(token.name)) {
113
+ // Unbalanced markup is common; only unwind to the nearest matching frame.
114
+ const index = stack.findLastIndex((frame) => frame.name === token.name)
115
+ if (index !== -1) {
116
+ while (stack.length > index) closeFrame()
117
+ }
118
+ }
119
+ continue
120
+ }
121
+
122
+ if (skipName !== null) continue
123
+ parts.push(token.value)
124
+ const frame = stack[stack.length - 1]
125
+ if (!frame) continue
126
+ const length = token.value.trim().length
127
+ frame.textLength += length
128
+ if (linkDepth > 0) frame.linkTextLength += length
129
+ }
130
+
131
+ while (stack.length > 0) closeFrame()
132
+
133
+ const winner = best.current
134
+ const selected = winner === null ? parts : parts.slice(winner.startPart, winner.endPart)
135
+ return normalizeWhitespace(selected.join(''))
136
+ }
@@ -0,0 +1,98 @@
1
+ const NAMED: Readonly<Record<string, string>> = {
2
+ amp: '&',
3
+ lt: '<',
4
+ gt: '>',
5
+ quot: '"',
6
+ apos: "'",
7
+ nbsp: ' ',
8
+ ensp: ' ',
9
+ emsp: ' ',
10
+ thinsp: ' ',
11
+ shy: '',
12
+ hellip: '…',
13
+ mdash: '—',
14
+ ndash: '–',
15
+ lsquo: '‘',
16
+ rsquo: '’',
17
+ ldquo: '“',
18
+ rdquo: '”',
19
+ laquo: '«',
20
+ raquo: '»',
21
+ bull: '•',
22
+ middot: '·',
23
+ copy: '©',
24
+ reg: '®',
25
+ trade: '™',
26
+ deg: '°',
27
+ euro: '€',
28
+ pound: '£',
29
+ yen: '¥',
30
+ cent: '¢',
31
+ sect: '§',
32
+ para: '¶',
33
+ dagger: '†',
34
+ permil: '‰',
35
+ prime: '′',
36
+ Prime: '″',
37
+ times: '×',
38
+ divide: '÷',
39
+ minus: '−',
40
+ plusmn: '±',
41
+ frac12: '½',
42
+ frac14: '¼',
43
+ frac34: '¾',
44
+ larr: '←',
45
+ rarr: '→',
46
+ harr: '↔',
47
+ darr: '↓',
48
+ uarr: '↑',
49
+ }
50
+
51
+ function codePointToString(codePoint: number): string {
52
+ if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff) return ''
53
+ // Surrogate halves are not valid standalone scalar values.
54
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) return '�'
55
+ return String.fromCodePoint(codePoint)
56
+ }
57
+
58
+ /**
59
+ * Decodes HTML character references in a single forward pass. Unterminated or
60
+ * unknown references are emitted verbatim, matching how browsers treat them.
61
+ */
62
+ export function decodeEntities(input: string): string {
63
+ if (!input.includes('&')) return input
64
+ let output = ''
65
+ let position = 0
66
+ while (position < input.length) {
67
+ const start = input.indexOf('&', position)
68
+ if (start === -1) {
69
+ output += input.slice(position)
70
+ break
71
+ }
72
+ output += input.slice(position, start)
73
+ const end = input.indexOf(';', start + 1)
74
+ if (end === -1 || end - start > 32) {
75
+ output += '&'
76
+ position = start + 1
77
+ continue
78
+ }
79
+ const reference = input.slice(start + 1, end)
80
+ if (reference.startsWith('#')) {
81
+ const isHex = reference[1] === 'x' || reference[1] === 'X'
82
+ const digits = isHex ? reference.slice(2) : reference.slice(1)
83
+ const valid = isHex ? /^[0-9a-fA-F]+$/.test(digits) : /^[0-9]+$/.test(digits)
84
+ if (valid) {
85
+ output += codePointToString(Number.parseInt(digits, isHex ? 16 : 10))
86
+ position = end + 1
87
+ continue
88
+ }
89
+ } else if (Object.hasOwn(NAMED, reference)) {
90
+ output += NAMED[reference]
91
+ position = end + 1
92
+ continue
93
+ }
94
+ output += '&'
95
+ position = start + 1
96
+ }
97
+ return output
98
+ }
@@ -0,0 +1,11 @@
1
+ export { decodeEntities } from './entities'
2
+ export { classifyPage, type ClassifyInput, type PageClassification } from './classify'
3
+ export { extractMainContent } from './content'
4
+ export {
5
+ BLOCK_ELEMENTS,
6
+ NON_CONTENT_ELEMENTS,
7
+ extractTitle,
8
+ htmlToText,
9
+ normalizeWhitespace,
10
+ } from './text'
11
+ export { MAX_HTML_INPUT, tokenizeHtml, type HtmlToken } from './tokenizer'
@@ -0,0 +1,124 @@
1
+ import { tokenizeHtml } from './tokenizer'
2
+
3
+ /** Elements whose text content is never part of the readable document. */
4
+ export const NON_CONTENT_ELEMENTS = new Set([
5
+ 'script',
6
+ 'style',
7
+ 'noscript',
8
+ 'template',
9
+ 'svg',
10
+ 'math',
11
+ 'title',
12
+ 'iframe',
13
+ 'object',
14
+ ])
15
+
16
+ export const BLOCK_ELEMENTS = new Set([
17
+ 'address',
18
+ 'article',
19
+ 'aside',
20
+ 'blockquote',
21
+ 'br',
22
+ 'dd',
23
+ 'div',
24
+ 'dl',
25
+ 'dt',
26
+ 'figcaption',
27
+ 'figure',
28
+ 'footer',
29
+ 'form',
30
+ 'h1',
31
+ 'h2',
32
+ 'h3',
33
+ 'h4',
34
+ 'h5',
35
+ 'h6',
36
+ 'header',
37
+ 'hr',
38
+ 'li',
39
+ 'main',
40
+ 'nav',
41
+ 'ol',
42
+ 'p',
43
+ 'pre',
44
+ 'section',
45
+ 'table',
46
+ 'tbody',
47
+ 'td',
48
+ 'tfoot',
49
+ 'th',
50
+ 'thead',
51
+ 'tr',
52
+ 'ul',
53
+ ])
54
+
55
+ /** Non-breaking and typographic spaces that must fold into an ordinary space. */
56
+ const UNICODE_SPACES = new RegExp('[\\u00A0\\u1680\\u2000-\\u200A\\u202F\\u205F\\u3000]', 'g')
57
+
58
+ export function normalizeWhitespace(text: string): string {
59
+ return text
60
+ .replace(UNICODE_SPACES, ' ')
61
+ .replace(/[ \t\f\r\v]+/g, ' ')
62
+ // The collapse above leaves at most one space on either side of a newline,
63
+ // so ` ?` is equivalent to ` *` here — and, unlike ` *`, does not backtrack
64
+ // quadratically over long runs of spaces (CodeQL polynomial ReDoS).
65
+ .replace(/ ?\n ?/g, '\n')
66
+ .replace(/\n{3,}/g, '\n\n')
67
+ .trim()
68
+ }
69
+
70
+ export function htmlToText(html: string): string {
71
+ const parts: string[] = []
72
+ let skipName: string | null = null
73
+ let skipDepth = 0
74
+
75
+ for (const token of tokenizeHtml(html)) {
76
+ if (token.kind === 'open') {
77
+ if (skipName !== null) {
78
+ if (token.name === skipName) skipDepth += 1
79
+ continue
80
+ }
81
+ if (NON_CONTENT_ELEMENTS.has(token.name)) {
82
+ skipName = token.name
83
+ skipDepth = 1
84
+ continue
85
+ }
86
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
87
+ continue
88
+ }
89
+ if (token.kind === 'close') {
90
+ if (skipName !== null) {
91
+ if (token.name === skipName) {
92
+ skipDepth -= 1
93
+ if (skipDepth <= 0) skipName = null
94
+ }
95
+ continue
96
+ }
97
+ if (BLOCK_ELEMENTS.has(token.name)) parts.push('\n')
98
+ continue
99
+ }
100
+ if (skipName !== null) continue
101
+ parts.push(token.value)
102
+ }
103
+
104
+ return normalizeWhitespace(parts.join(''))
105
+ }
106
+
107
+ export function extractTitle(html: string): string | null {
108
+ let inTitle = false
109
+ let title = ''
110
+ for (const token of tokenizeHtml(html)) {
111
+ if (token.kind === 'open') {
112
+ if (token.name === 'title') inTitle = true
113
+ else if (token.name === 'body') break
114
+ continue
115
+ }
116
+ if (token.kind === 'close') {
117
+ if (token.name === 'title') break
118
+ continue
119
+ }
120
+ if (inTitle) title += token.value
121
+ }
122
+ const normalized = title.replace(/\s+/g, ' ').trim()
123
+ return normalized.length > 0 ? normalized : null
124
+ }
@@ -0,0 +1,197 @@
1
+ import { decodeEntities } from './entities'
2
+
3
+ export type HtmlToken =
4
+ | { readonly kind: 'text'; readonly value: string }
5
+ | {
6
+ readonly kind: 'open'
7
+ readonly name: string
8
+ readonly attributes: ReadonlyMap<string, string>
9
+ readonly selfClosing: boolean
10
+ }
11
+ | { readonly kind: 'close'; readonly name: string }
12
+
13
+ /**
14
+ * Bounds memory for a pathological document. The tokenizer itself is linear and
15
+ * cannot stall the event loop, so this is an allocation guard rather than the
16
+ * ReDoS mitigation a regex-based extractor would need it to be.
17
+ */
18
+ export const MAX_HTML_INPUT = 8 * 1024 * 1024
19
+
20
+ const VOID_ELEMENTS = new Set([
21
+ 'area',
22
+ 'base',
23
+ 'br',
24
+ 'col',
25
+ 'embed',
26
+ 'hr',
27
+ 'img',
28
+ 'input',
29
+ 'link',
30
+ 'meta',
31
+ 'param',
32
+ 'source',
33
+ 'track',
34
+ 'wbr',
35
+ ])
36
+
37
+ const RAW_TEXT_ELEMENTS = new Set(['script', 'style'])
38
+
39
+ function isNameStart(character: string): boolean {
40
+ return (character >= 'a' && character <= 'z') || (character >= 'A' && character <= 'Z')
41
+ }
42
+
43
+ function isWhitespace(character: string): boolean {
44
+ return character === ' ' || character === '\t' || character === '\n' || character === '\r' || character === '\f'
45
+ }
46
+
47
+ type ParsedTag = {
48
+ readonly name: string
49
+ readonly attributes: Map<string, string>
50
+ readonly selfClosing: boolean
51
+ readonly end: number
52
+ }
53
+
54
+ /**
55
+ * Reads one start tag beginning at the character after `<`. Quote-aware so an
56
+ * attribute value containing `>` does not truncate the tag.
57
+ */
58
+ function readStartTag(html: string, start: number): ParsedTag | null {
59
+ let position = start
60
+ while (position < html.length && !isWhitespace(html[position]) && html[position] !== '>' && html[position] !== '/') {
61
+ position += 1
62
+ }
63
+ const name = html.slice(start, position).toLowerCase()
64
+ if (name.length === 0) return null
65
+
66
+ const attributes = new Map<string, string>()
67
+ let selfClosing = false
68
+
69
+ while (position < html.length) {
70
+ while (position < html.length && isWhitespace(html[position])) position += 1
71
+ if (position >= html.length) break
72
+ if (html[position] === '>') {
73
+ position += 1
74
+ break
75
+ }
76
+ if (html[position] === '/') {
77
+ selfClosing = true
78
+ position += 1
79
+ continue
80
+ }
81
+
82
+ const nameStart = position
83
+ while (
84
+ position < html.length &&
85
+ !isWhitespace(html[position]) &&
86
+ html[position] !== '=' &&
87
+ html[position] !== '>' &&
88
+ html[position] !== '/'
89
+ ) {
90
+ position += 1
91
+ }
92
+ const attributeName = html.slice(nameStart, position).toLowerCase()
93
+ if (attributeName.length === 0) {
94
+ position += 1
95
+ continue
96
+ }
97
+
98
+ while (position < html.length && isWhitespace(html[position])) position += 1
99
+ if (html[position] !== '=') {
100
+ attributes.set(attributeName, '')
101
+ continue
102
+ }
103
+ position += 1
104
+ while (position < html.length && isWhitespace(html[position])) position += 1
105
+
106
+ const quote = html[position]
107
+ if (quote === '"' || quote === "'") {
108
+ const valueStart = position + 1
109
+ const valueEnd = html.indexOf(quote, valueStart)
110
+ if (valueEnd === -1) {
111
+ attributes.set(attributeName, decodeEntities(html.slice(valueStart)))
112
+ position = html.length
113
+ break
114
+ }
115
+ attributes.set(attributeName, decodeEntities(html.slice(valueStart, valueEnd)))
116
+ position = valueEnd + 1
117
+ continue
118
+ }
119
+
120
+ const valueStart = position
121
+ while (position < html.length && !isWhitespace(html[position]) && html[position] !== '>') position += 1
122
+ attributes.set(attributeName, decodeEntities(html.slice(valueStart, position)))
123
+ }
124
+
125
+ return { name, attributes, selfClosing: selfClosing || VOID_ELEMENTS.has(name), end: position }
126
+ }
127
+
128
+ /**
129
+ * Single forward scan over HTML emitting structural tokens. Every advance is
130
+ * driven by `indexOf` or a bounded character walk, so there is no backtracking
131
+ * and runtime is linear in the input length.
132
+ */
133
+ export function* tokenizeHtml(rawHtml: string): Generator<HtmlToken> {
134
+ const html = rawHtml.length > MAX_HTML_INPUT ? rawHtml.slice(0, MAX_HTML_INPUT) : rawHtml
135
+ // Lowercased once: searching for a raw-text close tag inside the loop would
136
+ // otherwise copy the whole document per <script>/<style>.
137
+ let lowerHtml: string | null = null
138
+ let position = 0
139
+
140
+ while (position < html.length) {
141
+ const angle = html.indexOf('<', position)
142
+ if (angle === -1) {
143
+ const text = html.slice(position)
144
+ if (text.length > 0) yield { kind: 'text', value: decodeEntities(text) }
145
+ return
146
+ }
147
+ if (angle > position) {
148
+ yield { kind: 'text', value: decodeEntities(html.slice(position, angle)) }
149
+ }
150
+
151
+ const next = html[angle + 1]
152
+
153
+ if (next === '!') {
154
+ if (html.startsWith('<!--', angle)) {
155
+ const close = html.indexOf('-->', angle + 4)
156
+ position = close === -1 ? html.length : close + 3
157
+ continue
158
+ }
159
+ const close = html.indexOf('>', angle + 2)
160
+ position = close === -1 ? html.length : close + 1
161
+ continue
162
+ }
163
+
164
+ if (next === '/') {
165
+ const close = html.indexOf('>', angle + 2)
166
+ const end = close === -1 ? html.length : close
167
+ const name = html.slice(angle + 2, end).trim().toLowerCase()
168
+ if (name.length > 0) yield { kind: 'close', name }
169
+ position = close === -1 ? html.length : close + 1
170
+ continue
171
+ }
172
+
173
+ if (next === undefined || !isNameStart(next)) {
174
+ yield { kind: 'text', value: '<' }
175
+ position = angle + 1
176
+ continue
177
+ }
178
+
179
+ const tag = readStartTag(html, angle + 1)
180
+ if (tag === null) {
181
+ yield { kind: 'text', value: '<' }
182
+ position = angle + 1
183
+ continue
184
+ }
185
+ yield { kind: 'open', name: tag.name, attributes: tag.attributes, selfClosing: tag.selfClosing }
186
+ position = tag.end
187
+
188
+ if (RAW_TEXT_ELEMENTS.has(tag.name) && !tag.selfClosing) {
189
+ lowerHtml ??= html.toLowerCase()
190
+ const close = lowerHtml.indexOf(`</${tag.name}`, position)
191
+ position = close === -1 ? html.length : close
192
+ continue
193
+ }
194
+
195
+ if (tag.selfClosing) yield { kind: 'close', name: tag.name }
196
+ }
197
+ }