Skip to content

Commit 4cdaacf

Browse files
authoredMay 28, 2021
fix(parse): Hand-roll attribute parsing (#503)
Attribute values are slightly more permissive now. Ensures attribute parsing will always be linear.
1 parent 8c5eda2 commit 4cdaacf

File tree

4 files changed

+159
-62
lines changed

4 files changed

+159
-62
lines changed
 

‎src/__fixtures__/tests.ts

+17
Original file line numberDiff line numberDiff line change
@@ -801,6 +801,23 @@ export const tests: [
801801
"XML mode",
802802
{ xmlMode: true },
803803
],
804+
[
805+
"#myID",
806+
[
807+
[
808+
{
809+
action: "equals",
810+
name: "id",
811+
type: "attribute",
812+
namespace: null,
813+
value: "myID",
814+
ignoreCase: null,
815+
},
816+
],
817+
],
818+
"IDs in XML mode",
819+
{ xmlMode: true },
820+
],
804821
[
805822
"fOo[baR]",
806823
[

‎src/parse.spec.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,15 @@ describe("parse own tests", () => {
1010
}
1111
});
1212

13-
describe("Collected selectors", () => {
14-
test("(qwery, sizzle, nwmatcher)", () => {
15-
const out = JSON.parse(
16-
readFileSync(`${__dirname}/__fixtures__/out.json`, "utf8")
17-
);
18-
for (const s of Object.keys(out)) {
13+
describe("Collected selectors (qwery, sizzle, nwmatcher)", () => {
14+
const out = JSON.parse(
15+
readFileSync(`${__dirname}/__fixtures__/out.json`, "utf8")
16+
);
17+
for (const s of Object.keys(out)) {
18+
test(s, () => {
1919
expect(parse(s)).toStrictEqual(out[s]);
20-
}
21-
});
20+
});
21+
}
2222
});
2323

2424
const broken = [
@@ -32,13 +32,17 @@ const broken = [
3232
",a",
3333
"a,",
3434
"[id=012345678901234567890123456789",
35-
"input[name=foo.baz]",
35+
"input[name=foo b]",
36+
"input[name!foo]",
37+
"input[name|]",
38+
"input[name=']",
3639
"input[name=foo[baz]]",
3740
':has("p")',
3841
":has(p",
3942
":foo(p()",
4043
"#",
4144
"##foo",
45+
"/*",
4246
];
4347

4448
describe("Broken selectors", () => {

‎src/parse.ts

+127-52
Original file line numberDiff line numberDiff line change
@@ -81,29 +81,24 @@ export type TraversalType =
8181

8282
const reName = /^[^\\#]?(?:\\(?:[\da-f]{1,6}\s?|.)|[\w\-\u00b0-\uFFFF])+/;
8383
const reEscape = /\\([\da-f]{1,6}\s?|(\s)|.)/gi;
84-
// Modified version of https://github.com/jquery/sizzle/blob/master/src/sizzle.js#L87
85-
const reAttr =
86-
/^\s*(?:(\*|[-\w]*)\|)?((?:\\.|[\w\u00b0-\uFFFF-])+)\s*(?:(\S?)=\s*(?:(['"])((?:[^\\]|\\[^])*?)\4|(#?(?:\\.|[\w\u00b0-\uFFFF-])*)|)|)\s*([iIsS])?\s*\]/;
87-
88-
const actionTypes: { [key: string]: AttributeAction } = {
89-
undefined: "exists",
90-
"": "equals",
91-
"~": "element",
92-
"^": "start",
93-
$: "end",
94-
"*": "any",
95-
"!": "not",
96-
"|": "hyphen",
97-
};
9884

99-
const Traversals: { [key: string]: TraversalType } = {
85+
const actionTypes = new Map<string, AttributeAction>([
86+
["~", "element"],
87+
["^", "start"],
88+
["$", "end"],
89+
["*", "any"],
90+
["!", "not"],
91+
["|", "hyphen"],
92+
]);
93+
94+
const Traversals: Record<string, TraversalType> = {
10095
">": "child",
10196
"<": "parent",
10297
"~": "sibling",
10398
"+": "adjacent",
10499
};
105100

106-
const attribSelectors: { [key: string]: [string, AttributeAction] } = {
101+
const attribSelectors: Record<string, [string, AttributeAction]> = {
107102
"#": ["id", "equals"],
108103
".": ["class", "element"],
109104
};
@@ -302,10 +297,7 @@ function parseSelector(
302297
tokens = [];
303298
sawWS = false;
304299
stripWhitespace(1);
305-
} else if (
306-
firstChar === "/" &&
307-
selector.charAt(selectorIndex + 1) === "*"
308-
) {
300+
} else if (selector.startsWith("/*", selectorIndex)) {
309301
const endIndex = selector.indexOf("*/", selectorIndex + 2);
310302

311303
if (endIndex < 0) {
@@ -332,51 +324,134 @@ function parseSelector(
332324
ignoreCase: options.xmlMode ? null : false,
333325
});
334326
} else if (firstChar === "[") {
335-
const attributeMatch = selector
336-
.slice(selectorIndex + 1)
337-
.match(reAttr);
338-
339-
if (!attributeMatch) {
340-
throw new Error(
341-
`Malformed attribute selector: ${selector.slice(
342-
selectorIndex
343-
)}`
344-
);
327+
stripWhitespace(1);
328+
329+
// Determine attribute name and namespace
330+
331+
let name;
332+
let namespace: string | null = null;
333+
334+
if (selector.charAt(selectorIndex) === "|") {
335+
namespace = "";
336+
selectorIndex += 1;
337+
}
338+
339+
if (selector.startsWith("*|", selectorIndex)) {
340+
namespace = "*";
341+
selectorIndex += 2;
345342
}
346343

347-
const [
348-
completeSelector,
349-
namespace = null,
350-
baseName,
351-
actionType,
352-
,
353-
quotedValue = "",
354-
value = quotedValue,
355-
forceIgnore,
356-
] = attributeMatch;
344+
name = getName(0);
357345

358-
selectorIndex += completeSelector.length + 1;
359-
let name = unescapeCSS(baseName);
346+
if (
347+
namespace === null &&
348+
selector.charAt(selectorIndex) === "|" &&
349+
selector.charAt(selectorIndex + 1) !== "="
350+
) {
351+
namespace = name;
352+
name = getName(1);
353+
}
360354

361355
if (options.lowerCaseAttributeNames ?? !options.xmlMode) {
362356
name = name.toLowerCase();
363357
}
364358

365-
const ignoreCase =
359+
stripWhitespace(0);
360+
361+
// Determine comparison operation
362+
363+
let action: AttributeAction = "exists";
364+
const possibleAction = actionTypes.get(
365+
selector.charAt(selectorIndex)
366+
);
367+
368+
if (possibleAction) {
369+
action = possibleAction;
370+
371+
if (selector.charAt(selectorIndex + 1) !== "=") {
372+
throw new Error("Expected `=`");
373+
}
374+
375+
stripWhitespace(2);
376+
} else if (selector.charAt(selectorIndex) === "=") {
377+
action = "equals";
378+
stripWhitespace(1);
379+
}
380+
381+
// Determine value
382+
383+
let value = "";
384+
let ignoreCase: boolean | null = null;
385+
386+
if (action !== "exists") {
387+
if (quotes.has(selector.charAt(selectorIndex))) {
388+
const quote = selector.charAt(selectorIndex);
389+
let sectionEnd = selectorIndex + 1;
390+
while (
391+
sectionEnd < selector.length &&
392+
(selector.charAt(sectionEnd) !== quote ||
393+
isEscaped(sectionEnd))
394+
) {
395+
sectionEnd += 1;
396+
}
397+
398+
if (selector.charAt(sectionEnd) !== quote) {
399+
throw new Error("Attribute value didn't end");
400+
}
401+
402+
value = unescapeCSS(
403+
selector.slice(selectorIndex + 1, sectionEnd)
404+
);
405+
selectorIndex = sectionEnd + 1;
406+
} else {
407+
const valueStart = selectorIndex;
408+
409+
while (
410+
selectorIndex < selector.length &&
411+
((!isWhitespace(selector.charAt(selectorIndex)) &&
412+
selector.charAt(selectorIndex) !== "]") ||
413+
isEscaped(selectorIndex))
414+
) {
415+
selectorIndex += 1;
416+
}
417+
418+
value = unescapeCSS(
419+
selector.slice(valueStart, selectorIndex)
420+
);
421+
}
422+
423+
stripWhitespace(0);
424+
425+
// See if we have a force ignore flag
426+
427+
const forceIgnore = selector.charAt(selectorIndex);
366428
// If the forceIgnore flag is set (either `i` or `s`), use that value
367-
forceIgnore
368-
? forceIgnore.toLowerCase() === "i"
369-
: // If `xmlMode` is set, there are no rules; return `null`.
370-
options.xmlMode
371-
? null
372-
: // Otherwise, use the `caseInsensitiveAttributes` list.
373-
caseInsensitiveAttributes.has(name);
429+
if (forceIgnore === "s" || forceIgnore === "S") {
430+
ignoreCase = false;
431+
stripWhitespace(1);
432+
} else if (forceIgnore === "i" || forceIgnore === "I") {
433+
ignoreCase = true;
434+
stripWhitespace(1);
435+
}
436+
}
437+
438+
// If `xmlMode` is set, there are no rules; otherwise, use the `caseInsensitiveAttributes` list.
439+
if (!options.xmlMode) {
440+
// TODO: Skip this for `exists`, as there is no value to compare to.
441+
ignoreCase ??= caseInsensitiveAttributes.has(name);
442+
}
443+
444+
if (selector.charAt(selectorIndex) !== "]") {
445+
throw new Error("Attribute selector didn't terminate");
446+
}
447+
448+
selectorIndex += 1;
374449

375450
const attributeSelector: AttributeSelector = {
376451
type: "attribute",
377452
name,
378-
action: actionTypes[actionType],
379-
value: unescapeCSS(value),
453+
action,
454+
value,
380455
namespace,
381456
ignoreCase,
382457
};

‎src/stringify.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Selector } from "./parse";
22

3-
const actionTypes: { [key: string]: string } = {
3+
const actionTypes: Record<string, string> = {
44
equals: "",
55
element: "~",
66
start: "^",
@@ -21,6 +21,7 @@ const charsToEscape = new Set([
2121
"\\",
2222
"(",
2323
")",
24+
"'",
2425
]);
2526

2627
/**

0 commit comments

Comments
 (0)
Please sign in to comment.