• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

mojolicious / dom.js / 25563801595

08 May 2026 03:21PM UTC coverage: 96.501% (-0.7%) from 97.231%
25563801595

push

github

kraih
Backported bug fixes from Perl version

524 of 560 branches covered (93.57%)

Branch coverage included in aggregate %.

193 of 209 new or added lines in 2 files covered. (92.34%)

1 existing line in 1 file now uncovered.

2041 of 2098 relevant lines covered (97.28%)

512.62 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

97.64
/src/parser.ts
1
import type {Parent} from './types.js';
1✔
2
import {
1✔
3
  BLOCK,
1✔
4
  CLOSE,
1✔
5
  EMPTY,
1✔
6
  END,
1✔
7
  NAME_START_CHAR,
1✔
8
  NAME_CHAR,
1✔
9
  NO_MORE_CONTENT,
1✔
10
  PHRASING,
1✔
11
  RAW,
1✔
12
  RCDATA,
1✔
13
  SCOPE
1✔
14
} from './constants.js';
1✔
15
import {CDATANode} from './nodes/cdata.js';
1✔
16
import {CommentNode} from './nodes/comment.js';
1✔
17
import {DoctypeNode} from './nodes/doctype.js';
1✔
18
import {ElementNode} from './nodes/element.js';
1✔
19
import {FragmentNode} from './nodes/fragment.js';
1✔
20
import {PINode} from './nodes/pi.js';
1✔
21
import {TextNode} from './nodes/text.js';
1✔
22
import {SafeString, escapeRegExp, stickyMatch, xmlUnescape} from '@mojojs/util';
1✔
23

1✔
24
const NAME_RE = new RegExp(`[${NAME_START_CHAR}][${NAME_CHAR}]*`, 'u');
1✔
25

1✔
26
const TEXT_RE = new RegExp(`([^<]+)`, 'ys');
1✔
27
const DOCTYPE_RE = new RegExp(
1✔
28
  `<!DOCTYPE\\s*(\\w+(?:(?:\\s+\\w+)?(?:\\s+(?:"[^"]*"|'[^']*'))+)?(?:\\s+\\[.+?\\])?\\s*)>`,
1✔
29
  'ysi'
1✔
30
);
1✔
31
const COMMENT_RE = new RegExp(`<!--(.*?)(?:--!?|(?<=<!--)-?(?=>))>`, 'ys');
1✔
32
const CDATA_RE = new RegExp(`<!\\[CDATA\\[(.*?)\\]\\]>`, 'ysi');
1✔
33
const PI_RE = new RegExp(`<\\?(.*?)\\?>`, 'ys');
1✔
34
const TAG_ATTR_RE = new RegExp(
1✔
35
  `\\s*(${NAME_RE.source})(?:\\s*=\\s*(?:(?:"([^"]*)")|(?:'([^']*)')|([^>\\s]*)))?`,
1✔
36
  'ysu'
1✔
37
);
1✔
38
const TAG_END_RE = new RegExp(`\\s*(/)?\\s*>`, 'ys');
1✔
39
const TAG_START_RE = new RegExp(`<(?:(\\/)\\s*)?(${NAME_RE.source})`, 'ysu');
1✔
40
const RUNAWAY_RE = new RegExp(`<`, 'y');
1✔
41

1✔
42
const SCRIPT_NO_LT_RE = new RegExp(`[^<]*`, 'ys');
1✔
43
const SCRIPT_NO_LT_OR_DASH_RE = new RegExp(`[^<\\-]*`, 'ys');
1✔
44
const SCRIPT_CLOSE_RE = new RegExp(`</script(?:\\s+|\\s*>)`, 'ysi');
1✔
45
const SCRIPT_COMMENT_START_RE = new RegExp(`<!--`, 'ys');
1✔
46
const SCRIPT_COMMENT_END_RE = new RegExp(`-->`, 'ys');
1✔
47
const SCRIPT_OPEN_RE = new RegExp(`<script(?=[\\s/>])`, 'ysi');
1✔
48

1✔
49
function scriptContent(sticky: {offset: number; value: string}): {content: string; foundEnd: boolean} {
15✔
50
  const start = sticky.offset;
15✔
51
  const length = sticky.value.length;
15✔
52
  let state: 0 | 1 | 2 = 0;
15✔
53

15✔
54
  while (length > sticky.offset) {
15✔
55
    stickyMatch(sticky, state === 0 ? SCRIPT_NO_LT_RE : SCRIPT_NO_LT_OR_DASH_RE);
36✔
56
    const pos = sticky.offset;
36✔
57
    if (pos >= length) break;
36✔
58

35✔
59
    // "</script>" (or end of nested script)
35✔
60
    if (stickyMatch(sticky, SCRIPT_CLOSE_RE) !== null) {
36✔
61
      if (state !== 2) return {content: sticky.value.slice(start, pos), foundEnd: true};
16✔
62
      state = 1;
2✔
63
      continue;
2✔
64
    }
2✔
65

19✔
66
    // "<!--" (only outside HTML comment)
19✔
67
    if (state === 0) {
36✔
68
      if (stickyMatch(sticky, SCRIPT_COMMENT_START_RE) !== null) state = 1;
14✔
69
      else sticky.offset = pos + 1;
10✔
70
      continue;
14✔
71
    }
14✔
72

5✔
73
    // "-->" (only inside HTML comment)
5✔
74
    if (stickyMatch(sticky, SCRIPT_COMMENT_END_RE) !== null) {
36✔
75
      state = 0;
2✔
76
      continue;
2✔
77
    }
2✔
78

3✔
79
    // "<script>" (only inside HTML comment)
3✔
80
    if (state === 1 && stickyMatch(sticky, SCRIPT_OPEN_RE) !== null) {
36✔
81
      state = 2;
3✔
82
      continue;
3✔
83
    }
3✔
NEW
84

×
NEW
85
    sticky.offset = pos + 1;
×
NEW
86
  }
×
87

1✔
88
  return {content: sticky.value.slice(start), foundEnd: false};
1✔
89
}
1✔
90

1✔
91
export class Parser {
1✔
92
  parse(text: string, xml: boolean): FragmentNode {
1✔
93
    return this.parseFragment(text, xml);
111✔
94
  }
111✔
95

1✔
96
  parseFragment(text: string, xml: boolean): FragmentNode {
1✔
97
    const doc = new FragmentNode();
138✔
98
    let current: Parent = doc;
138✔
99

138✔
100
    const sticky = {offset: 0, value: text};
138✔
101
    const textLength = text.length;
138✔
102
    while (textLength > sticky.offset) {
138✔
103
      // Text
2,111✔
104
      const textMatch = stickyMatch(sticky, TEXT_RE);
2,111✔
105
      if (textMatch !== null) {
2,111✔
106
        current.insertText(xmlUnescape(textMatch[1]));
982✔
107
        continue;
982✔
108
      }
982✔
109

1,129✔
110
      // DOCTYPE
1,129✔
111
      const doctypeMatch = stickyMatch(sticky, DOCTYPE_RE);
1,129✔
112
      if (doctypeMatch !== null) {
2,111✔
113
        current.appendChild(new DoctypeNode(doctypeMatch[1], '', ''));
13✔
114
        continue;
13✔
115
      }
13✔
116

1,116✔
117
      // Comment
1,116✔
118
      const commentMatch = stickyMatch(sticky, COMMENT_RE);
1,116✔
119
      if (commentMatch !== null) {
2,111✔
120
        current.appendChild(new CommentNode(commentMatch[1]));
12✔
121
        continue;
12✔
122
      }
12✔
123

1,104✔
124
      // CDATA
1,104✔
125
      const cdataMatch = stickyMatch(sticky, CDATA_RE);
1,104✔
126
      if (cdataMatch !== null) {
2,111✔
127
        current.appendChild(new CDATANode(cdataMatch[1]));
4✔
128
        continue;
4✔
129
      }
4✔
130

1,100✔
131
      // Processing instruction
1,100✔
132
      const piMatch = stickyMatch(sticky, PI_RE);
1,100✔
133
      if (piMatch !== null) {
2,111✔
134
        current.appendChild(new PINode(piMatch[1]));
7✔
135
        continue;
7✔
136
      }
7✔
137

1,093✔
138
      // Tag
1,093✔
139
      const before = sticky.offset;
1,093✔
140
      const startMatch = stickyMatch(sticky, TAG_START_RE);
1,093✔
141
      if (startMatch !== null) {
2,111✔
142
        let tag = xml === true ? startMatch[2] : startMatch[2].toLowerCase();
1,066✔
143

1,066✔
144
        // Attributes
1,066✔
145
        const attrs: Record<string, string> = {};
1,066✔
146
        while (textLength > sticky.offset) {
1,066✔
147
          const attrMatch = stickyMatch(sticky, TAG_ATTR_RE);
1,295✔
148
          if (attrMatch === null) break;
1,295✔
149
          const name = xml === true ? attrMatch[1] : attrMatch[1].toLowerCase();
1,295✔
150
          attrs[name] = xmlUnescape(attrMatch[2] ?? attrMatch[3] ?? attrMatch[4] ?? '');
1,295✔
151
        }
1,295✔
152

1,066✔
153
        let close = false;
1,066✔
154
        const endMatch = stickyMatch(sticky, TAG_END_RE);
1,066✔
155
        if (endMatch !== null) {
1,066✔
156
          if (endMatch[1] !== undefined) close = true;
1,061✔
157

1,061✔
158
          // Start
1,061✔
159
          if (startMatch[1] === undefined) {
1,061✔
160
            // "image" is an alias for "img"
634✔
161
            if (xml === false && tag === 'image') tag = 'img';
634✔
162
            current = this._start(current, xml, tag, attrs);
634✔
163

634✔
164
            // Element without end tag (self-closing)
634✔
165
            const isSelfClosing =
634✔
166
              (xml !== true && EMPTY.has(tag) === true) ||
634✔
167
              ((xml === true || BLOCK.has(tag) === false) && close === true);
634✔
168
            if (isSelfClosing === true) current = this._end(current, xml, tag);
634✔
169

634✔
170
            // Raw text elements
634✔
171
            if (xml === true || (RAW.has(tag) === false && RCDATA.has(tag) === false)) continue;
634✔
172

35✔
173
            // "script" (allows nested tags inside HTML comments)
35✔
174
            if (tag === 'script') {
634✔
175
              const {content, foundEnd} = scriptContent(sticky);
15✔
176
              if (content.length > 0) current.appendChild(new TextNode(new SafeString(content)));
15✔
177
              if (foundEnd === true) current = this._end(current, xml, tag);
15✔
178
              continue;
15✔
179
            }
15✔
180

20✔
181
            const rawMatch = stickyMatch(sticky, new RegExp(`(.*?)</${escapeRegExp(tag)}(?:\\s+|\\s*>)`, 'ysi'));
20✔
182
            if (rawMatch === null) continue;
20!
183
            const text = RCDATA.has(tag) === true ? xmlUnescape(rawMatch[1]) : rawMatch[1];
634✔
184
            current.appendChild(new TextNode(new SafeString(text)));
634✔
185
            current = this._end(current, xml, tag);
634✔
186
          }
634✔
187

427✔
188
          // End
427✔
189
          else {
427✔
190
            // No more content
427✔
191
            if (xml !== true && NO_MORE_CONTENT[tag] !== undefined) {
427✔
192
              for (const noMoreContent of NO_MORE_CONTENT[tag]) {
7✔
193
                current = this._end(current, xml, noMoreContent);
14✔
194
              }
14✔
195
            }
7✔
196

427✔
197
            current = this._end(current, xml, tag);
427✔
198
          }
427✔
199

447✔
200
          continue;
447✔
201
        }
447✔
202

5✔
203
        // No full tag (reset offset)
5✔
204
        else {
5✔
205
          sticky.offset = before;
5✔
206
        }
5✔
207
      }
1,066✔
208

32✔
209
      // Runaway "<"
32✔
210
      const runawayMatch = stickyMatch(sticky, RUNAWAY_RE);
32✔
211
      if (runawayMatch !== null) {
32✔
212
        current.insertText('<');
32✔
213
        continue;
32✔
214
      }
32✔
215

×
216
      break;
×
217
    }
×
218

138✔
219
    return doc;
138✔
220
  }
138✔
221

1✔
222
  _end(current: Parent, xml: boolean, tag: string): Parent {
1✔
223
    let node: Parent | null = current;
792✔
224
    while (node !== null) {
792✔
225
      const parent: Parent | null = node.parentNode;
6,020✔
226
      if (parent === null) break;
6,020✔
227

5,804✔
228
      if (node.nodeType === '#element') {
5,804✔
229
        const tagName = node.tagName;
5,804✔
230

5,804✔
231
        // Don’t traverse a container tag
5,804✔
232
        if (SCOPE.has(tagName) === true && tagName !== tag) break;
5,804✔
233

5,801✔
234
        // Right tag
5,801✔
235
        if (tagName === tag) {
5,804✔
236
          // "template"
567✔
237
          if (tag === 'template') {
567✔
238
            const fragment = new FragmentNode();
1✔
239
            node.content = fragment;
1✔
240
            for (const child of node.childNodes) {
1✔
241
              child.detach();
1✔
242
              fragment.appendChild(child);
1✔
243
            }
1✔
244
          }
1✔
245

567✔
246
          return parent;
567✔
247
        }
567✔
248

5,234✔
249
        // Phrasing content can only cross phrasing content
5,234✔
250
        if (xml === false && PHRASING.has(tag) === true && PHRASING.has(tagName) === false) break;
5,804✔
251
      }
5,804✔
252

5,228✔
253
      node = parent;
5,228✔
254
    }
5,228✔
255

225✔
256
    return current;
225✔
257
  }
225✔
258

1✔
259
  _start(current: Parent, xml: boolean, tag: string, attrs: Record<string, string>): Parent {
1✔
260
    // Autoclose optional HTML elements
634✔
261
    if (xml === false && current.nodeType === '#element') {
634✔
262
      if (END[tag] !== undefined) {
455✔
263
        current = this._end(current, xml, END[tag]);
212✔
264
      }
212✔
265

243✔
266
      // Close allowed parent elements in scope
243✔
267
      else if (CLOSE[tag] !== undefined) {
243✔
268
        const {allowed, scope} = CLOSE[tag];
113✔
269

113✔
270
        let parent: ElementNode = current;
113✔
271
        while (scope.has(parent.tagName) === false) {
113✔
272
          if (allowed.has(parent.tagName) === true) current = this._end(current, xml, parent.tagName);
120✔
273
          if (parent.parentNode === null || parent.parentNode.nodeType !== '#element') break;
120✔
274
          parent = parent.parentNode;
114✔
275
        }
114✔
276
      }
113✔
277
    }
455✔
278

634✔
279
    // New tag
634✔
280
    const node = new ElementNode(tag, '', attrs);
634✔
281
    current.appendChild(node);
634✔
282
    return node;
634✔
283
  }
634✔
284
}
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc