Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions src/__tests__/attributeValues.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import assert from "node:assert/strict";
import test from "./util/runner.mjs";
import parser from "../../dist/index.js";
import postcss from "postcss";

const message = "Expected an attribute value.";
const operators = ["=", "~=", "|=", "^=", "$=", "*="];

for (const operator of operators) {
test(`missing attribute value after ${operator}`, (t) => {
const input = `[href${operator}]`;
t.throws(() => parser().processSync(input), { message });
t.throws(() => parser().astSync(input), { message });
});

test(`quoted empty attribute value after ${operator}`, (t) => {
for (const quote of ['"', "'"]) {
const input = `[href${operator}${quote}${quote} i]`;
const attribute = parser().astSync(input).first.first;
t.is(attribute.value, "");
t.is(attribute.operator, operator);
t.is(attribute.insensitive, true);
t.is(parser().processSync(input), input);
}
});

test(`unquoted attribute value after ${operator}`, (t) => {
const input = `[href${operator}example]`;
t.is(parser().astSync(input).first.first.value, "example");
t.is(parser().processSync(input), input);
});
}

for (const input of [
"[href= ]",
"[href=\n\t ]",
"[href=/**/]",
"[href= /* value */ ]",
"[ns|href=]",
"[*|href=]",
"[|href=]",
"[ ns | href = ]",
]) {
test(`missing attribute value in ${JSON.stringify(input)}`, (t) => {
t.throws(() => parser().processSync(input), { message });
t.throws(() => parser().processSync(input, { lossless: false }), { message });
});
}

test("missing attribute value rejects asynchronous processing", async () => {
await assert.rejects(parser().process("[href=]"), { message });
await assert.rejects(parser().ast("[href=]"), { message });
});

test("missing attribute value reports the closing bracket location", (t) => {
const rule = postcss.parse("a,\n [href=] {}").first;
const error = t.throws(() => parser().processSync(rule));
t.is(error.name, "CssSyntaxError");
t.is(error.reason, message);
t.is(error.line, 2);
t.is(error.column, 9);
});

test("presence and namespaced quoted-empty attributes remain valid", (t) => {
for (const input of [
"[href]",
"[ns|href]",
'[ns|href=""]',
"[*|href='']",
'[|href="" i]',
'[ ns | href = "" ]',
'[href=/**/""/* value */]',
]) {
t.is(parser().processSync(input), input);
}
t.is(parser().processSync('[ ns | href = "" ]', { lossless: false }), '[ns|href=""]');
});

const incompleteOperators = ["$", "*", "~", "|", "^"];

for (const operator of incompleteOperators) {
for (const input of [
`[href${operator}]`,
`[href${operator} ]`,
`[href${operator}/**/]`,
`[href /* name */ ${operator} /* operator */ ]`,
`[ns|href${operator}]`,
`[*|href${operator}]`,
`[|href${operator}]`,
]) {
test(`incomplete attribute operator in ${JSON.stringify(input)}`, (t) => {
for (const lossless of [true, false]) {
const error = t.throws(() => parser().processSync(input, { lossless }));
t.is(error.name, "Error");
t.regex(error.message, /^(Expected|Unexpected) /);
t.throws(() => parser().astSync(input, { lossless }), {
message: error.message,
});
}
});
}
}

test("incomplete attribute operators reject asynchronous processing", async () => {
for (const operator of incompleteOperators) {
const input = `[href${operator}]`;
await assert.rejects(parser().process(input), { name: "Error" });
await assert.rejects(parser().ast(input), { name: "Error" });
}
});

test("incomplete attribute operator reports the operator location", (t) => {
const rule = postcss.parse("a,\n [href$] {}").first;
const error = t.throws(() => parser().processSync(rule));
t.is(error.name, "CssSyntaxError");
t.regex(error.reason, /^(Expected|Unexpected) /);
t.is(error.line, 2);
t.is(error.column, 8);
});

test("escaped matcher characters remain part of attribute names", (t) => {
for (const character of incompleteOperators) {
const input = `[href\\${character}]`;
const attribute = parser().astSync(input).first.first;
t.is(attribute.attribute, `href${character}`);
t.is(attribute.operator, undefined);
t.is(parser().processSync(input), input);
t.is(parser().processSync(input, { lossless: false }), input);
}
});

test("interpolated attribute names and operator-like values remain valid", (t) => {
for (const input of [
"[#{$attr}]",
"[@{attr}]",
"[#{$attr}=#{$value}]",
"[lang=#{$locale}]",
"[name=foo$]",
'[name="$*~|^"]',
]) {
t.is(parser().processSync(input), input);
t.is(parser().processSync(input, { lossless: false }), input);
}
});

test("missing attribute names retain their syntax errors", (t) => {
for (const input of ["[]", "[ ]", "[ * ]", "[ns|*]", "[ns|* ]", "[ns|$]", "[ns|^]"]) {
const error = t.throws(() => parser().processSync(input), {
message: "Expected an attribute.",
});
t.is(error.name, "Error");
}
});
16 changes: 6 additions & 10 deletions src/__tests__/attributes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -677,17 +677,13 @@ testDeprecation("set Attribute#quoteMark", "[data-foo=bar]", (t, tree) => {

// A trailing `* $ ^ ~ |` immediately before the closing bracket used to look
// ahead past the end of the token stream and throw a raw TypeError. None of
// them is a valid operator without a following `=`, so the token is dropped,
// which matches how `[href=]` already drops an operator that has no value.
// Asserted with `ava` rather than `test` because the output is intentionally
// not a round-trip, and pinning it with `test` would assert the lossy form is
// correct.
// them is a valid operator without a following `=`, so report a syntax error
// rather than dropping the token and silently changing the selector.
for (const trailing of ["*", "$", "^", "~", "|"]) {
ava(`attribute name followed by a trailing ${trailing}`, (t) => {
const tree = parser().astSync(`[href${trailing}]`);
const attr = tree.nodes[0].nodes[0];
t.deepEqual(attr.attribute, "href");
t.deepEqual(attr.operator, undefined);
t.deepEqual(tree.toString(), "[href]");
const error = t.throws(() => parser().astSync(`[href${trailing}]`), {
message: "Expected an attribute operator.",
});
t.is(error.name, "Error");
});
}
16 changes: 16 additions & 0 deletions src/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,9 @@ export default class Parser {
}
break;
case tokens.asterisk:
if (node.attribute && !node.operator && !(next && next[TOKEN.TYPE] === tokens.equals)) {
return this.expected("attribute operator", token[TOKEN.START_POS]);
}
if (next && next[TOKEN.TYPE] === tokens.equals) {
node.operator = content;
lastAdded = "operator";
Expand Down Expand Up @@ -342,10 +345,20 @@ export default class Parser {
if (next && next[TOKEN.TYPE] === tokens.equals) {
node.operator = content;
lastAdded = "operator";
} else if (node.attribute && !node.operator) {
return this.expected("attribute operator", token[TOKEN.START_POS]);
}
spaceAfterMeaningfulToken = false;
break;
case tokens.combinator:
if (
node.attribute &&
!node.operator &&
(content === "~" || content === "|") &&
!(next && next[TOKEN.TYPE] === tokens.equals)
) {
return this.expected("attribute operator", token[TOKEN.START_POS]);
}
if (content === "~" && next && next[TOKEN.TYPE] === tokens.equals) {
node.operator = content;
lastAdded = "operator";
Expand Down Expand Up @@ -516,6 +529,9 @@ export default class Parser {
// needs to look.
return this.expected("attribute", startingToken[TOKEN.START_POS]);
}
if (node.operator && node.value === undefined) {
return this.expected("attribute value", this.currToken[TOKEN.START_POS]);
}
unescapeProp(node, "attribute");
unescapeProp(node, "namespace");
this.newNode(new Attribute(node));
Expand Down
Loading