From 9df10cdc133d08d525236d5da7d24f98650ee0a3 Mon Sep 17 00:00:00 2001 From: ATKasem Date: Tue, 14 Jul 2026 09:26:48 -0500 Subject: [PATCH] Support the modern attr() type() syntax attr() is parsed through the generic function machinery, so the modern attr( ?, ? ) form reported false syntax errors: type() failed because the syntax tokens are not valid value terms. Recognize type() as an argument, scoped to inside attr() via a depth guard so a user-defined type() function elsewhere is unaffected. Legacy attr(), units, raw-string, and Sass/Less variables are unchanged. Fixes #451 --- src/parser/cssParser.ts | 72 +++++++++++++++++++++++++++++++++++++ src/parser/lessParser.ts | 11 ++++++ src/test/css/parser.test.ts | 21 +++++++++++ 3 files changed, 104 insertions(+) diff --git a/src/parser/cssParser.ts b/src/parser/cssParser.ts index 13fdabd5..049885a2 100644 --- a/src/parser/cssParser.ts +++ b/src/parser/cssParser.ts @@ -29,6 +29,9 @@ export class Parser { private lastErrorToken?: IToken; + // Tracks nesting inside `attr()`, where `type()` is a valid argument. + protected attrDepth: number = 0; + constructor(scnr: Scanner = new Scanner()) { this.scanner = scnr; this.token = { type: TokenType.EOF, offset: -1, len: 0, text: '' }; @@ -2049,6 +2052,7 @@ export class Parser { public _parseTermExpression(): nodes.Node | null { return this._parseURILiteral() || // url before function this._parseUnicodeRange() || + this._parseAttrType() || // type() inside attr(), before the generic function this._parseFunction() || // function before ident this._parseIdent() || this._parseStringLiteral() || @@ -2155,6 +2159,7 @@ export class Parser { parseArgument = this._parseIfBranch.bind(this); separator = TokenType.SemiColon; } + const isAttr = this.peekIdent("attr"); if (!node.setIdentifier(this._parseFunctionIdentifier())) { return null; @@ -2165,6 +2170,9 @@ export class Parser { return null; } + if (isAttr) { + this.attrDepth++; + } if (node.getArguments().addChild(parseArgument())) { while (this.accept(separator)) { if (this.peek(TokenType.ParenthesisR)) { @@ -2175,6 +2183,9 @@ export class Parser { } } } + if (isAttr) { + this.attrDepth--; + } if (!this.accept(TokenType.ParenthesisR)) { return this.finish(node, ParseError.RightParenthesisExpected); @@ -2211,6 +2222,67 @@ export class Parser { return null; } + public _parseAttrType(): nodes.Node | null { + // The modern `attr()` accepts a type as `type( )`, e.g. `attr(data-x type())`. + // This is only valid inside `attr()`, so it is guarded by attrDepth to avoid shadowing a + // user-defined `type()` function (e.g. a Sass `@function type()`) elsewhere. + // https://drafts.csswg.org/css-values-5/#attr-notation + if (this.attrDepth === 0 || !this.peekIdent('type')) { + return null; + } + + const pos = this.mark(); + const node = this.create(nodes.Function); + node.setIdentifier(this._parseFunctionIdentifier()); + + if (this.hasWhitespace() || !this.accept(TokenType.ParenthesisL)) { + this.restoreAtMark(pos); + return null; + } + + if (!node.getArguments().addChild(this._parseAttrTypeSyntax())) { + return this.finish(node, ParseError.ExpressionExpected, [], [TokenType.ParenthesisR]); + } + + if (!this.accept(TokenType.ParenthesisR)) { + return this.finish(node, ParseError.RightParenthesisExpected); + } + return this.finish(node); + } + + public _parseAttrTypeSyntax(): nodes.Node | null { + // = '*' | [ '|' ]* + const node = this.create(nodes.Node); + if (this.acceptDelim('*')) { + return this.finish(node); + } + if (!this._acceptAttrSyntaxComponent()) { + return null; + } + while (this.acceptDelim('|')) { + if (!this._acceptAttrSyntaxComponent()) { + return this.finish(node, ParseError.IdentifierExpected); + } + } + return this.finish(node); + } + + private _acceptAttrSyntaxComponent(): boolean { + // = ( '<' '>' | ) ? + if (this.acceptDelim('<')) { + if (!this.accept(TokenType.Ident) || !this.acceptDelim('>')) { + return false; + } + } else if (!this.accept(TokenType.Ident)) { + return false; + } + // optional multiplier: '+' or '#' + if (!this.acceptDelim('+')) { + this.acceptDelim('#'); + } + return true; + } + public _parseIfBranch() { // = : ? diff --git a/src/parser/lessParser.ts b/src/parser/lessParser.ts index e401cb0f..38c34342 100644 --- a/src/parser/lessParser.ts +++ b/src/parser/lessParser.ts @@ -769,6 +769,8 @@ export class LESSParser extends cssParser.Parser { const pos = this.mark(); const node = this.create(nodes.Function); + const isAttr = this.peekIdent("attr"); + if (!node.setIdentifier(this._parseFunctionIdentifier())) { return null; } @@ -778,16 +780,25 @@ export class LESSParser extends cssParser.Parser { return null; } + if (isAttr) { + this.attrDepth++; + } if (node.getArguments().addChild(this._parseMixinArgument())) { while (this.accept(TokenType.Comma) || this.accept(TokenType.SemiColon)) { if (this.peek(TokenType.ParenthesisR)) { break; } if (!node.getArguments().addChild(this._parseMixinArgument())) { + if (isAttr) { + this.attrDepth--; + } return this.finish(node, ParseError.ExpressionExpected); } } } + if (isAttr) { + this.attrDepth--; + } if (!this.accept(TokenType.ParenthesisR)) { return this.finish(node, ParseError.RightParenthesisExpected); diff --git a/src/test/css/parser.test.ts b/src/test/css/parser.test.ts index 0d6154e8..f51899a5 100644 --- a/src/test/css/parser.test.ts +++ b/src/test/css/parser.test.ts @@ -644,6 +644,27 @@ suite('CSS - Parser', () => { assertError('if(invalid: black;)', parser, parser._parseFunction.bind(parser), ParseError.IfConditionExpected); }); + test('attr', function () { + const parser = new Parser(); + // legacy forms keep working + assertFunction('attr(data-x)', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x, "fallback")', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x px)', parser, parser._parseFunction.bind(parser)); + // modern attr() type() syntax (https://drafts.csswg.org/css-values-5/#attr-notation) + assertFunction('attr(data-x type())', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x type(), 0px)', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x type(*))', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x type( | ))', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x type(#))', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x type(+))', parser, parser._parseFunction.bind(parser)); + assertFunction('attr(data-x raw-string)', parser, parser._parseFunction.bind(parser)); + // type() is only special inside attr(): elsewhere it is a normal function reference + assertFunction('type(foo)', parser, parser._parseFunction.bind(parser)); + // malformed type() still reports an error + assertError('attr(data-x type())', parser, parser._parseFunction.bind(parser), ParseError.ExpressionExpected); + assertError('attr(data-x type(