'));
112 | ok(!html('[a=b c=d]>'));
113 | ok(!html('div[a=b c=d]>'));
114 | });
115 |
116 | it('consume quotes', () => {
117 | let s = scanner(' "foo"');
118 | ok(consumeQuoted(s));
119 | strictEqual(s.pos, 1);
120 |
121 | s = scanner('"foo"');
122 | ok(consumeQuoted(s));
123 | strictEqual(s.pos, 0);
124 |
125 | s = scanner('""');
126 | ok(consumeQuoted(s));
127 | strictEqual(s.pos, 0);
128 |
129 | s = scanner('"a\\\"b"');
130 | ok(consumeQuoted(s));
131 | strictEqual(s.pos, 0);
132 |
133 | // don’t eat anything
134 | s = scanner('foo');
135 | ok(!consumeQuoted(s));
136 | strictEqual(s.pos, 3);
137 | });
138 | });
139 |
--------------------------------------------------------------------------------
/src/stylesheet/format.ts:
--------------------------------------------------------------------------------
1 | import type { CSSAbbreviation, CSSProperty, Value, CSSValue, NumberValue } from '@emmetio/css-abbreviation';
2 | import createOutputStream, { type OutputStream, push, pushString, pushField, pushNewline } from '../output-stream';
3 | import type { Config } from '../config';
4 | import color, { frac } from './color';
5 |
6 | export const CSSAbbreviationScope = {
7 | /** Include all possible snippets in match */
8 | Global: '@@global',
9 | /** Include raw snippets only (e.g. no properties) in abbreviation match */
10 | Section: '@@section',
11 | /** Include properties only in abbreviation match */
12 | Property: '@@property',
13 | /** Resolve abbreviation in context of CSS property value */
14 | Value: '@@value',
15 | } as const;
16 |
17 | export default function css(abbr: CSSAbbreviation, config: Config): string {
18 | const out = createOutputStream(config.options);
19 | const format = config.options['output.format'];
20 |
21 | if (config.context?.name === CSSAbbreviationScope.Section) {
22 | // For section context, filter out unmatched snippets
23 | abbr = abbr.filter(node => node.snippet);
24 | }
25 |
26 | for (let i = 0; i < abbr.length; i++) {
27 | if (format && i !== 0) {
28 | pushNewline(out, true);
29 | }
30 | property(abbr[i], out, config);
31 | }
32 |
33 | return out.value;
34 | }
35 |
36 | /**
37 | * Outputs given abbreviation node into output stream
38 | */
39 | function property(node: CSSProperty, out: OutputStream, config: Config) {
40 | const isJSON = config.options['stylesheet.json'];
41 | if (node.name) {
42 | // It’s a CSS property
43 | const name = isJSON ? toCamelCase(node.name) : node.name;
44 | pushString(out, name + config.options['stylesheet.between']);
45 |
46 | if (node.value.length) {
47 | propertyValue(node, out, config);
48 | } else {
49 | pushField(out, 0, '');
50 | }
51 |
52 | if (isJSON) {
53 | // For CSS-in-JS, always finalize property with comma
54 | // NB: seems like `important` is not available in CSS-in-JS syntaxes
55 | push(out, ',');
56 | } else {
57 | outputImportant(node, out, true);
58 | push(out, config.options['stylesheet.after']);
59 | }
60 | } else {
61 | // It’s a regular snippet, output plain tokens without any additional formatting
62 | for (const cssVal of node.value) {
63 | for (const v of cssVal.value) {
64 | outputToken(v, out, config);
65 | }
66 | }
67 | outputImportant(node, out, node.value.length > 0);
68 | }
69 | }
70 |
71 | function propertyValue(node: CSSProperty, out: OutputStream, config: Config) {
72 | const isJSON = config.options['stylesheet.json'];
73 | const num = isJSON ? getSingleNumeric(node) : null;
74 |
75 | if (num && (!num.unit || num.unit === 'px')) {
76 | // For CSS-in-JS, if property contains single numeric value, output it
77 | // as JS number
78 | push(out, String(num.value));
79 | } else {
80 | const quote = getQuote(config);
81 | isJSON && push(out, quote);
82 | for (let i = 0; i < node.value.length; i++) {
83 | if (i !== 0) {
84 | push(out, ', ');
85 | }
86 | outputValue(node.value[i], out, config);
87 | }
88 | isJSON && push(out, quote);
89 | }
90 | }
91 |
92 | function outputImportant(node: CSSProperty, out: OutputStream, separator?: boolean) {
93 | if (node.important) {
94 | if (separator) {
95 | push(out, ' ');
96 | }
97 | push(out, '!important');
98 | }
99 | }
100 |
101 | function outputValue(value: CSSValue, out: OutputStream, config: Config) {
102 | for (let i = 0, prevEnd = -1; i < value.value.length; i++) {
103 | const token = value.value[i];
104 | // Handle edge case: a field is written close to previous token like this: `foo${bar}`.
105 | // We should not add delimiter here
106 | if (i !== 0 && (token.type !== 'Field' || token.start !== prevEnd)) {
107 | push(out, ' ');
108 | }
109 |
110 | outputToken(token, out, config);
111 | prevEnd = token['end'];
112 | }
113 | }
114 |
115 | function outputToken(token: Value, out: OutputStream, config: Config) {
116 | if (token.type === 'ColorValue') {
117 | push(out, color(token, config.options['stylesheet.shortHex']));
118 | } else if (token.type === 'Literal' || token.type === 'CustomProperty') {
119 | pushString(out, token.value);
120 | } else if (token.type === 'NumberValue') {
121 | pushString(out, frac(token.value, 4) + token.unit);
122 | } else if (token.type === 'StringValue') {
123 | const quote = token.quote === 'double' ? '"' : '\'';
124 | pushString(out, quote + token.value + quote);
125 | } else if (token.type === 'Field') {
126 | pushField(out, token.index!, token.name);
127 | } else if (token.type === 'FunctionCall') {
128 | push(out, token.name + '(');
129 | for (let i = 0; i < token.arguments.length; i++) {
130 | if (i) {
131 | push(out, ', ');
132 | }
133 | outputValue(token.arguments[i], out, config);
134 | }
135 | push(out, ')');
136 | }
137 | }
138 |
139 | /**
140 | * If value of given property is a single numeric value, returns this token
141 | */
142 | function getSingleNumeric(node: CSSProperty): NumberValue | void {
143 | if (node.value.length === 1) {
144 | const cssVal = node.value[0]!;
145 | if (cssVal.value.length === 1 && cssVal.value[0]!.type === 'NumberValue') {
146 | return cssVal.value[0] as NumberValue;
147 | }
148 | }
149 | }
150 |
151 | /**
152 | * Converts kebab-case string to camelCase
153 | */
154 | function toCamelCase(str: string): string {
155 | return str.replace(/\-(\w)/g, (_, letter: string) => letter.toUpperCase());
156 | }
157 |
158 | function getQuote(config: Config): string {
159 | return config.options['stylesheet.jsonDoubleQuotes'] ? '"' : '\'';
160 | }
161 |
--------------------------------------------------------------------------------
/src/snippets/html.json:
--------------------------------------------------------------------------------
1 | {
2 | "a": "a[href]",
3 | "a:blank": "a[href='http://${0}' target='_blank' rel='noopener noreferrer']",
4 | "a:link": "a[href='http://${0}']",
5 | "a:mail": "a[href='mailto:${0}']",
6 | "a:tel": "a[href='tel:+${0}']",
7 | "abbr": "abbr[title]",
8 | "acr|acronym": "acronym[title]",
9 | "base": "base[href]/",
10 | "basefont": "basefont/",
11 | "br": "br/",
12 | "frame": "frame/",
13 | "hr": "hr/",
14 | "bdo": "bdo[dir]",
15 | "bdo:r": "bdo[dir=rtl]",
16 | "bdo:l": "bdo[dir=ltr]",
17 | "col": "col/",
18 | "link": "link[rel=stylesheet href]/",
19 | "link:css": "link[href='${1:style}.css']",
20 | "link:print": "link[href='${1:print}.css' media=print]",
21 | "link:favicon": "link[rel='icon' type=image/x-icon href='${1:favicon.ico}']",
22 | "link:mf|link:manifest": "link[rel='manifest' href='${1:manifest.json}']",
23 | "link:touch": "link[rel=apple-touch-icon href='${1:favicon.png}']",
24 | "link:rss": "link[rel=alternate type=application/rss+xml title=RSS href='${1:rss.xml}']",
25 | "link:atom": "link[rel=alternate type=application/atom+xml title=Atom href='${1:atom.xml}']",
26 | "link:im|link:import": "link[rel=import href='${1:component}.html']",
27 | "link:preload": "link[rel=preload href='${1}' as='${2}']/",
28 | "meta": "meta/",
29 | "meta:utf": "meta[http-equiv=Content-Type content='text/html;charset=UTF-8']",
30 | "meta:vp": "meta[name=viewport content='width=${1:device-width}, initial-scale=${2:1.0}']",
31 | "meta:compat": "meta[http-equiv=X-UA-Compatible content='${1:IE=7}']",
32 | "meta:edge": "meta:compat[content='${1:ie=edge}']",
33 | "meta:redirect": "meta[http-equiv=refresh content='0; url=${1:http://example.com}']",
34 | "meta:refresh": "meta[http-equiv=refresh content='${1:5}']",
35 | "meta:kw": "meta[name=keywords content]",
36 | "meta:desc": "meta[name=description content]",
37 | "style": "style",
38 | "script": "script",
39 | "script:src": "script[src]",
40 | "script:module": "script[type=module src]",
41 | "img": "img[src alt]/",
42 | "img:s|img:srcset": "img[srcset src alt]",
43 | "img:z|img:sizes": "img[sizes srcset src alt]",
44 | "picture": "picture",
45 | "src|source": "source/",
46 | "src:sc|source:src": "source[src type]",
47 | "src:s|source:srcset": "source[srcset]",
48 | "src:t|source:type": "source[srcset type='${1:image/}']",
49 | "src:z|source:sizes": "source[sizes srcset]",
50 | "src:m|source:media": "source[media='(${1:min-width: })' srcset]",
51 | "src:mt|source:media:type": "source:media[type='${2:image/}']",
52 | "src:mz|source:media:sizes": "source:media[sizes srcset]",
53 | "src:zt|source:sizes:type": "source[sizes srcset type='${1:image/}']",
54 | "iframe": "iframe[src frameborder=0]",
55 | "embed": "embed[src type]/",
56 | "object": "object[data type]",
57 | "param": "param[name value]/",
58 | "map": "map[name]",
59 | "area": "area[shape coords href alt]/",
60 | "area:d": "area[shape=default]",
61 | "area:c": "area[shape=circle]",
62 | "area:r": "area[shape=rect]",
63 | "area:p": "area[shape=poly]",
64 | "form": "form[action]",
65 | "form:get": "form[method=get]",
66 | "form:post": "form[method=post]",
67 | "label": "label[for]",
68 | "input": "input[type=${1:text}]/",
69 | "inp": "input[name=${1} id=${1}]",
70 | "input:h|input:hidden": "input[type=hidden name]",
71 | "input:t|input:text": "inp[type=text]",
72 | "input:search": "inp[type=search]",
73 | "input:email": "inp[type=email]",
74 | "input:url": "inp[type=url]",
75 | "input:p|input:password": "inp[type=password]",
76 | "input:datetime": "inp[type=datetime]",
77 | "input:date": "inp[type=date]",
78 | "input:datetime-local": "inp[type=datetime-local]",
79 | "input:month": "inp[type=month]",
80 | "input:week": "inp[type=week]",
81 | "input:time": "inp[type=time]",
82 | "input:tel": "inp[type=tel]",
83 | "input:number": "inp[type=number]",
84 | "input:color": "inp[type=color]",
85 | "input:c|input:checkbox": "inp[type=checkbox]",
86 | "input:r|input:radio": "inp[type=radio]",
87 | "input:range": "inp[type=range]",
88 | "input:f|input:file": "inp[type=file]",
89 | "input:s|input:submit": "input[type=submit value]",
90 | "input:i|input:image": "input[type=image src alt]",
91 | "input:b|input:btn|input:button": "input[type=button value]",
92 | "input:reset": "input:button[type=reset]",
93 | "isindex": "isindex/",
94 | "select": "select[name=${1} id=${1}]",
95 | "select:d|select:disabled": "select[disabled.]",
96 | "opt|option": "option[value]",
97 | "textarea": "textarea[name=${1} id=${1}]",
98 | "tarea:c|textarea:cols":"textarea[name=${1} id=${1} cols=${2:30}]",
99 | "tarea:r|textarea:rows":"textarea[name=${1} id=${1} rows=${3:10}]",
100 | "tarea:cr|textarea:cols:rows":"textarea[name=${1} id=${1} cols=${2:30} rows=${3:10}]",
101 | "marquee": "marquee[behavior direction]",
102 | "menu:c|menu:context": "menu[type=context]",
103 | "menu:t|menu:toolbar": "menu[type=toolbar]",
104 | "video": "video[src]",
105 | "audio": "audio[src]",
106 | "html:xml": "html[xmlns=http://www.w3.org/1999/xhtml]",
107 | "keygen": "keygen/",
108 | "command": "command/",
109 | "btn:s|button:s|button:submit" : "button[type=submit]",
110 | "btn:r|button:r|button:reset" : "button[type=reset]",
111 | "btn:b|button:b|button:button" : "button[type=button]",
112 | "btn:d|button:d|button:disabled" : "button[disabled.]",
113 | "fst:d|fset:d|fieldset:d|fieldset:disabled" : "fieldset[disabled.]",
114 |
115 | "bq": "blockquote",
116 | "fig": "figure",
117 | "figc": "figcaption",
118 | "pic": "picture",
119 | "ifr": "iframe",
120 | "emb": "embed",
121 | "obj": "object",
122 | "cap": "caption",
123 | "colg": "colgroup",
124 | "fst": "fieldset",
125 | "btn": "button",
126 | "optg": "optgroup",
127 | "tarea": "textarea",
128 | "leg": "legend",
129 | "sect": "section",
130 | "art": "article",
131 | "hdr": "header",
132 | "ftr": "footer",
133 | "adr": "address",
134 | "dlg": "dialog",
135 | "str": "strong",
136 | "prog": "progress",
137 | "mn": "main",
138 | "tem": "template",
139 | "fset": "fieldset",
140 | "datal": "datalist",
141 | "kg": "keygen",
142 | "out": "output",
143 | "det": "details",
144 | "sum": "summary",
145 | "cmd": "command",
146 | "data": "data[value]",
147 | "meter": "meter[value]",
148 | "time": "time[datetime]",
149 |
150 | "ri:d|ri:dpr": "img:s",
151 | "ri:v|ri:viewport": "img:z",
152 | "ri:a|ri:art": "pic>src:m+img",
153 | "ri:t|ri:type": "pic>src:t+img",
154 |
155 | "!!!": "{}",
156 | "doc": "html[lang=${lang}]>(head>meta[charset=${charset}]+meta:vp+title{${1:Document}})+body",
157 | "!|html:5": "!!!+doc",
158 |
159 | "c": "{}",
160 | "cc:ie": "{}",
161 | "cc:noie": "{${0}}"
162 | }
163 |
--------------------------------------------------------------------------------
/packages/abbreviation/test/convert.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'node:test';
2 | import { equal } from 'node:assert';
3 | import parser, { type ParserOptions } from '../src';
4 | import stringify from './assets/stringify-node';
5 |
6 | function parse(abbr: string, options?: ParserOptions) {
7 | return stringify(parser(abbr, options));
8 | }
9 |
10 | describe('Convert token abbreviations', () => {
11 | it('basic', () => {
12 | equal(parse('input[value="text$"]*2'), '
');
13 |
14 | equal(parse('ul>li.item$*3'), '
');
15 | equal(parse('ul>li.item$*', { text: ['foo$', 'bar$'] }), '
');
16 | equal(parse('ul>li[class=$#]{item $}*', { text: ['foo$', 'bar$'] }), '
');
17 | equal(parse('ul>li.item$*'), '
');
18 | equal(parse('ul>li.item$*', { text: ['foo.bar', 'hello.world'] }), '
');
19 |
20 | equal(parse('p{hi}', { text: ['hello'] }), '
hihello
');
21 | equal(parse('p*{hi}', { text: ['1', '2'] }), '
hi1
hi2
');
22 | equal(parse('div>p+p{hi}', { text: ['hello'] }), '
');
23 |
24 | equal(parse('html[lang=${lang}]'), '');
25 | equal(parse('html.one.two'), '');
26 | equal(parse('html.one[two=three]'), '');
27 | equal(parse('div{[}+a{}'), '
[
');
28 | });
29 |
30 | it('unroll', () => {
31 | equal(parse('a>(b>c)+d'), '
');
32 | equal(parse('(a>b)+(c>d)'), '
');
33 | equal(parse('a>((b>c)(d>e))f'), '
');
34 | equal(parse('a>((((b>c))))+d'), '
');
35 | equal(parse('a>(((b>c))*4)+d'), '
');
36 | equal(parse('(div>dl>(dt+dd)*2)'), '
');
37 |
38 | equal(parse('a*2>b*3'), '
');
39 | equal(parse('a>(b+c)*2'), '
');
40 | equal(parse('a>(b+c)*2+(d+e)*2'), '
');
41 |
42 | // Should move `
` as sibling of `{foo}`
43 | equal(parse('p>{foo}>div'), '
>foo?>
');
44 | equal(parse('p>{foo ${0}}>div'), '
>foo ${0}
?>');
45 | });
46 |
47 | it('limit unroll', () => {
48 | // Limit amount of repeated elements
49 | equal(parse('a*10', { maxRepeat: 5 }), '
');
50 | equal(parse('a*10'), '
');
51 | equal(parse('a*3>b*3', { maxRepeat: 5 }), '
');
52 | });
53 |
54 | it('parent repeater', () => {
55 | equal(parse('a$*2>b$*3/'), '
');
56 | equal(parse('a$*2>b$@^*3/'), '
');
57 | });
58 |
59 | it('href', () => {
60 | equal(parse('a', { href: true, text: 'https://www.google.it' }), '
https://www.google.it');
61 | equal(parse('a', { href: true, text: 'www.google.it' }), '
www.google.it');
62 | equal(parse('a', { href: true, text: 'google.it' }), '
google.it');
63 | equal(parse('a', { href: true, text: 'test here' }), '
test here');
64 | equal(parse('a', { href: true, text: 'test@domain.com' }), '
test@domain.com');
65 | equal(parse('a', { href: true, text: 'test here test@domain.com' }), '
test here test@domain.com');
66 | equal(parse('a', { href: true, text: 'test here www.domain.com' }), '
test here www.domain.com');
67 |
68 | equal(parse('a[href=]', { href: true, text: 'https://www.google.it' }), '
https://www.google.it');
69 | equal(parse('a[href=]', { href: true, text: 'www.google.it' }), '
www.google.it');
70 | equal(parse('a[href=]', { href: true, text: 'google.it' }), '
google.it');
71 | equal(parse('a[href=]', { href: true, text: 'test here' }), '
test here');
72 | equal(parse('a[href=]', { href: true, text: 'test@domain.com' }), '
test@domain.com');
73 | equal(parse('a[href=]', { href: true, text: 'test here test@domain.com' }), '
test here test@domain.com');
74 | equal(parse('a[href=]', { href: true, text: 'test here www.domain.com' }), '
test here www.domain.com');
75 | equal(parse('a[class=here]', { href: true, text: 'test@domain.com' }), '
test@domain.com');
76 | equal(parse('a.here', { href: true, text: 'www.domain.com' }), '
www.domain.com');
77 | equal(parse('a[class=here]', { href: true, text: 'test here test@domain.com' }), '
test here test@domain.com');
78 | equal(parse('a.here', { href: true, text: 'test here www.domain.com' }), '
test here www.domain.com');
79 |
80 | equal(parse('a[href="www.google.it"]', { href: false, text: 'test' }), '
test');
81 | equal(parse('a[href="www.example.com"]', { href: true, text: 'www.google.it' }), '
www.google.it');
82 | });
83 |
84 | it('wrap basic', () => {
85 | equal(parse('p', { text: 'test' }), '
test
');
86 | equal(parse('p', { text: ['test'] }), '
test
');
87 | equal(parse('p', { text: ['test1', 'test2'] }), '
test1\ntest2
');
88 | equal(parse('p', { text: ['test1', '', 'test2'] }), '
test1\n\ntest2
');
89 | equal(parse('p*', { text: ['test1', 'test2'] }), '
test1
test2
');
90 | equal(parse('p*', { text: ['test1', '', 'test2'] }), '
test1
test2
');
91 | })
92 | });
93 |
--------------------------------------------------------------------------------
/packages/css-abbreviation/src/parser/index.ts:
--------------------------------------------------------------------------------
1 | import { OperatorType } from '../tokenizer/tokens.js';
2 | import type { StringValue, NumberValue, ColorValue, Literal, AllTokens, Bracket, WhiteSpace, Operator, Field, CustomProperty } from '../tokenizer/tokens.js';
3 | import tokenScanner, { type TokenScanner, readable, peek, consume, error } from './TokenScanner.js';
4 |
5 | export type Value = StringValue | NumberValue | ColorValue | Literal | FunctionCall | Field | CustomProperty;
6 |
7 | export interface FunctionCall {
8 | type: 'FunctionCall';
9 | name: string;
10 | arguments: CSSValue[];
11 | }
12 |
13 | export interface CSSValue {
14 | type: 'CSSValue';
15 | value: Value[];
16 | }
17 |
18 | export interface CSSProperty {
19 | name?: string;
20 | value: CSSValue[];
21 | important: boolean;
22 | /** Snippet matched with current property */
23 | snippet?: any;
24 | }
25 |
26 | export interface ParseOptions {
27 | /** Consumes given abbreviation tokens as value */
28 | value?: boolean;
29 | }
30 |
31 | export default function parser(tokens: AllTokens[], options: ParseOptions = {}): CSSProperty[] {
32 | const scanner = tokenScanner(tokens);
33 | const result: CSSProperty[] = [];
34 | let property: CSSProperty | undefined;
35 |
36 | while (readable(scanner)) {
37 | if (property = consumeProperty(scanner, options)) {
38 | result.push(property);
39 | } else if (!consume(scanner, isSiblingOperator)) {
40 | throw error(scanner, 'Unexpected token');
41 | }
42 | }
43 |
44 | return result;
45 | }
46 |
47 | /**
48 | * Consumes single CSS property
49 | */
50 | function consumeProperty(scanner: TokenScanner, options: ParseOptions): CSSProperty | undefined {
51 | let name: string | undefined;
52 | let important = false;
53 | let valueFragment: CSSValue | undefined;
54 | const value: CSSValue[] = [];
55 | const token = peek(scanner)!;
56 | const valueMode = !!options.value;
57 |
58 | if (!valueMode && isLiteral(token) && !isFunctionStart(scanner)) {
59 | scanner.pos++;
60 | name = token.value;
61 | // Consume any following value delimiter after property name
62 | consume(scanner, isValueDelimiter);
63 | }
64 |
65 | // Skip whitespace right after property name, if any
66 | if (valueMode) {
67 | consume(scanner, isWhiteSpace);
68 | }
69 |
70 | while (readable(scanner)) {
71 | if (consume(scanner, isImportant)) {
72 | important = true;
73 | } else if (valueFragment = consumeValue(scanner, valueMode)) {
74 | value.push(valueFragment);
75 | } else if (!consume(scanner, isFragmentDelimiter)) {
76 | break;
77 | }
78 | }
79 |
80 | if (name || value.length || important) {
81 | return { name, value, important };
82 | }
83 | }
84 |
85 | /**
86 | * Consumes single value fragment, e.g. all value tokens before comma
87 | */
88 | function consumeValue(scanner: TokenScanner, inArgument: boolean): CSSValue | undefined {
89 | const result: Value[] = [];
90 | let token: AllTokens | undefined;
91 | let args: CSSValue[] | undefined;
92 |
93 | while (readable(scanner)) {
94 | token = peek(scanner)!;
95 | if (isValue(token)) {
96 | scanner.pos++;
97 |
98 | if (isLiteral(token) && (args = consumeArguments(scanner))) {
99 | result.push({
100 | type: 'FunctionCall',
101 | name: token.value,
102 | arguments: args
103 | } as FunctionCall);
104 | } else {
105 | result.push(token);
106 | }
107 | } else if (isValueDelimiter(token) || (inArgument && isWhiteSpace(token))) {
108 | scanner.pos++;
109 | } else {
110 | break;
111 | }
112 | }
113 |
114 | return result.length
115 | ? { type: 'CSSValue', value: result }
116 | : void 0;
117 | }
118 |
119 | function consumeArguments(scanner: TokenScanner): CSSValue[] | undefined {
120 | const start = scanner.pos;
121 | if (consume(scanner, isOpenBracket)) {
122 | const args: CSSValue[] = [];
123 | let value: CSSValue | undefined;
124 |
125 | while (readable(scanner) && !consume(scanner, isCloseBracket)) {
126 | if (value = consumeValue(scanner, true)) {
127 | args.push(value);
128 | } else if (!consume(scanner, isWhiteSpace) && !consume(scanner, isArgumentDelimiter)) {
129 | throw error(scanner, 'Unexpected token');
130 | }
131 | }
132 |
133 | scanner.start = start;
134 | return args;
135 | }
136 | }
137 |
138 | function isLiteral(token: AllTokens): token is Literal {
139 | return token && token.type === 'Literal';
140 | }
141 |
142 | function isBracket(token: AllTokens, open?: boolean): token is Bracket {
143 | return token && token.type === 'Bracket' && (open == null || token.open === open);
144 | }
145 |
146 | function isOpenBracket(token: AllTokens) {
147 | return isBracket(token, true);
148 | }
149 |
150 | function isCloseBracket(token: AllTokens) {
151 | return isBracket(token, false);
152 | }
153 |
154 | function isWhiteSpace(token: AllTokens): token is WhiteSpace {
155 | return token && token.type === 'WhiteSpace';
156 | }
157 |
158 | function isOperator(token: AllTokens, operator?: OperatorType): token is Operator {
159 | return token && token.type === 'Operator' && (!operator || token.operator === operator);
160 | }
161 |
162 | function isSiblingOperator(token: AllTokens) {
163 | return isOperator(token, OperatorType.Sibling);
164 | }
165 |
166 | function isArgumentDelimiter(token: AllTokens) {
167 | return isOperator(token, OperatorType.ArgumentDelimiter);
168 | }
169 |
170 | function isFragmentDelimiter(token: AllTokens) {
171 | return isArgumentDelimiter(token);
172 | }
173 |
174 | function isImportant(token: AllTokens) {
175 | return isOperator(token, OperatorType.Important);
176 | }
177 |
178 | function isValue(token: AllTokens): token is StringValue | NumberValue | ColorValue | Literal {
179 | return token.type === 'StringValue'
180 | || token.type === 'ColorValue'
181 | || token.type === 'NumberValue'
182 | || token.type === 'Literal'
183 | || token.type === 'Field'
184 | || token.type === 'CustomProperty';
185 | }
186 |
187 | function isValueDelimiter(token: AllTokens): boolean {
188 | return isOperator(token, OperatorType.PropertyDelimiter)
189 | || isOperator(token, OperatorType.ValueDelimiter);
190 | }
191 |
192 | function isFunctionStart(scanner: TokenScanner): boolean {
193 | const t1 = scanner.tokens[scanner.pos];
194 | const t2 = scanner.tokens[scanner.pos + 1];
195 | return t1 && t2 && isLiteral(t1) && t2.type === 'Bracket';
196 | }
197 |
--------------------------------------------------------------------------------
/src/markup/addon/bem.ts:
--------------------------------------------------------------------------------
1 | import type { AbbreviationNode, Value } from '@emmetio/abbreviation';
2 | import type { Container } from '../utils';
3 | import type { Config, AbbreviationContext } from '../../config';
4 |
5 | interface BEMAbbreviationNode extends AbbreviationNode {
6 | _bem?: BEMData;
7 | }
8 |
9 | interface BEMAbbreviationContext extends AbbreviationContext {
10 | _bem?: BEMData;
11 | }
12 |
13 | interface BEMData {
14 | classNames: string[];
15 | block?: string ;
16 | }
17 |
18 | const reElement = /^(-+)([a-z0-9]+[a-z0-9-]*)/i;
19 | const reModifier = /^(_+)([a-z0-9]+[a-z0-9-_]*)/i;
20 | const blockCandidates1 = (className: string) => /^[a-z]\-/i.test(className);
21 | const blockCandidates2 = (className: string) => /^[a-z]/i.test(className);
22 |
23 | export default function bem(node: AbbreviationNode, ancestors: Container[], config: Config) {
24 | expandClassNames(node);
25 | expandShortNotation(node, ancestors, config);
26 | }
27 |
28 | /**
29 | * Expands existing class names in BEM notation in given `node`.
30 | * For example, if node contains `b__el_mod` class name, this method ensures
31 | * that element contains `b__el` class as well
32 | */
33 | function expandClassNames(node: BEMAbbreviationNode) {
34 | const data = getBEMData(node);
35 |
36 | const classNames: string[] = [];
37 | for (const cl of data.classNames) {
38 | // remove all modifiers and element prefixes from class name to get a base element name
39 | const ix = cl.indexOf('_');
40 | if (ix > 0 && !cl.startsWith('-')) {
41 | classNames.push(cl.slice(0, ix));
42 | classNames.push(cl.slice(ix));
43 | } else {
44 | classNames.push(cl);
45 | }
46 | }
47 |
48 | if (classNames.length) {
49 | data.classNames = classNames.filter(uniqueClass);
50 | data.block = findBlockName(data.classNames);
51 | updateClass(node, data.classNames.join(' '));
52 | }
53 | }
54 |
55 | /**
56 | * Expands short BEM notation, e.g. `-element` and `_modifier`
57 | */
58 | function expandShortNotation(node: BEMAbbreviationNode, ancestors: Container[], config: Config) {
59 | const data = getBEMData(node);
60 | const classNames: string[] = [];
61 | const { options } = config;
62 | const path = ancestors.slice(1).concat(node) as BEMAbbreviationNode[];
63 |
64 | for (let cl of data.classNames) {
65 | let prefix: string = '';
66 | let m: RegExpMatchArray | null;
67 | const originalClass = cl;
68 |
69 | // parse element definition (could be only one)
70 | if (m = cl.match(reElement)) {
71 | prefix = getBlockName(path, m[1].length, config.context) + options['bem.element'] + m[2];
72 | classNames.push(prefix);
73 | cl = cl.slice(m[0].length);
74 | }
75 |
76 | // parse modifiers definitions
77 | if (m = cl.match(reModifier)) {
78 | if (!prefix) {
79 | prefix = getBlockName(path, m[1].length);
80 | classNames.push(prefix);
81 | }
82 |
83 | classNames.push(`${prefix}${options['bem.modifier']}${m[2]}`);
84 | cl = cl.slice(m[0].length);
85 | }
86 |
87 | if (cl === originalClass) {
88 | // class name wasn’t modified: it’s not a BEM-specific class,
89 | // add it as-is into output
90 | classNames.push(originalClass);
91 | }
92 | }
93 |
94 | const arrClassNames = classNames.filter(uniqueClass);
95 | if (arrClassNames.length) {
96 | updateClass(node, arrClassNames.join(' '));
97 | }
98 | }
99 |
100 | /**
101 | * Returns BEM data from given abbreviation node
102 | */
103 | function getBEMData(node: BEMAbbreviationNode): BEMData {
104 | if (!node._bem) {
105 | let classValue = '';
106 | if (node.attributes) {
107 | for (const attr of node.attributes) {
108 | if (attr.name === 'class' && attr.value) {
109 | classValue = stringifyValue(attr.value);
110 | break;
111 | }
112 | }
113 | }
114 |
115 | node._bem = parseBEM(classValue);
116 | }
117 |
118 | return node._bem;
119 | }
120 |
121 | function getBEMDataFromContext(context: BEMAbbreviationContext) {
122 | if (!context._bem) {
123 | context._bem = parseBEM(context.attributes && context.attributes.class || '');
124 | }
125 |
126 | return context._bem;
127 | }
128 |
129 | /**
130 | * Parses BEM data from given class name
131 | */
132 | function parseBEM(classValue?: string): BEMData {
133 | const classNames = classValue ? classValue.split(/\s+/) : [];
134 | return {
135 | classNames,
136 | block: findBlockName(classNames)
137 | };
138 | }
139 |
140 | /**
141 | * Returns block name for given `node` by `prefix`, which tells the depth of
142 | * of parent node lookup
143 | */
144 | function getBlockName(ancestors: BEMAbbreviationNode[], depth: number = 0, context?: BEMAbbreviationContext): string {
145 | const maxParentIx = 0;
146 | let parentIx = Math.max(ancestors.length - depth, maxParentIx);
147 | do {
148 | const parent = ancestors[parentIx];
149 | if (parent) {
150 | const data = getBEMData(parent as BEMAbbreviationNode);
151 | if (data.block) {
152 | return data.block;
153 | }
154 | }
155 | } while (maxParentIx < parentIx--);
156 |
157 | if (context) {
158 | const data = getBEMDataFromContext(context);
159 | if (data.block) {
160 | return data.block;
161 | }
162 | }
163 |
164 | return '';
165 | }
166 |
167 | function findBlockName(classNames: string[]): string | undefined {
168 | return find(classNames, blockCandidates1)
169 | || find(classNames, blockCandidates2)
170 | || void 0;
171 | }
172 |
173 | /**
174 | * Finds class name from given list which may be used as block name
175 | */
176 | function find(classNames: string[], filter: (className: string) => boolean): string | void {
177 | for (const cl of classNames) {
178 | if (reElement.test(cl) || reModifier.test(cl)) {
179 | break;
180 | }
181 |
182 | if (filter(cl)) {
183 | return cl;
184 | }
185 | }
186 | }
187 |
188 | function updateClass(node: AbbreviationNode, value: string) {
189 | for (const attr of node.attributes!) {
190 | if (attr.name === 'class') {
191 | attr.value = [value];
192 | break;
193 | }
194 | }
195 | }
196 |
197 | function stringifyValue(value: Value[]): string {
198 | let result = '';
199 |
200 | for (const t of value) {
201 | result += typeof t === 'string' ? t : t.name;
202 | }
203 |
204 | return result;
205 | }
206 |
207 | function uniqueClass
(item: T, ix: number, arr: T[]): boolean {
208 | return !!item && arr.indexOf(item) === ix;
209 | }
210 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Emmet — the essential toolkit for web-developers
2 |
3 | Emmet is a web-developer’s toolkit for boosting HTML & CSS code writing.
4 |
5 | With Emmet, you can type expressions (_abbreviations_) similar to CSS selectors and convert them into code fragment with a single keystroke. For example, this abbreviation:
6 |
7 | ```
8 | ul#nav>li.item$*4>a{Item $}
9 | ```
10 |
11 | ...can be expanded into:
12 |
13 | ```html
14 |
20 | ```
21 |
22 | ## Features
23 |
24 | * **Familiar syntax**: as a web-developer, you already know how to use Emmet. Abbreviation syntax is similar to CSS Selectors with shortcuts for id, class, custom attributes, element nesting and so on.
25 | * **Dynamic snippets**: unlike default editor snippets, Emmet abbreviations are dynamic and parsed as-you-type. No need to predefine them for each project, just type `MyComponent>custom-element` to convert any word into a tag.
26 | * **CSS properties shortcuts**: Emmet provides special syntax for CSS properties with embedded values. For example, `bd1-s#f.5` will be expanded to `border: 1px solid rgba(255, 255, 255, 0.5)`.
27 | * **Available for most popular syntaxes**: use single abbreviation to produce code for most popular syntaxes like HAML, Pug, JSX, SCSS, SASS etc.
28 |
29 | [Read more about Emmet features](https://docs.emmet.io)
30 |
31 | This repo contains only core module for parsing and expanding Emmet abbreviations. Editor plugins are available as [separate repos](https://github.com/emmetio).
32 |
33 | This is a *monorepo*: top-level project contains all the code required for converting abbreviation into code fragment while [`./packages`](/packages) folder contains modules for parsing abbreviations into AST and can be used independently (for example, as lexer for syntax highlighting).
34 |
35 | ### Installation
36 |
37 | You can install Emmet as a regular npm module:
38 |
39 | ```bash
40 | npm i emmet
41 | ```
42 |
43 | ## Usage
44 |
45 | To expand abbreviation, pass it to default function of `emmet` module:
46 |
47 | ```js
48 | import expand from 'emmet';
49 |
50 | console.log(expand('p>a')); //
51 | ```
52 |
53 | By default, Emmet expands *markup* abbreviation, e.g. abbreviation used for producing nested elements with attributes (like HTML, XML, HAML etc.). If you want to expand *stylesheet* abbreviation, you should pass it as a `type` property of second argument:
54 |
55 | ```js
56 | import expand from 'emmet';
57 |
58 | console.log(expand('p10', { type: 'stylesheet' })); // padding: 10px;
59 | ```
60 |
61 | A stylesheet abbreviation has slightly different syntax compared to markup one: it doesn’t support nesting and attributes but allows embedded values in element name.
62 |
63 | Alternatively, Emmet supports *syntaxes* with predefined snippets and options:
64 |
65 | ```js
66 | import expand from 'emmet';
67 |
68 | console.log(expand('p10', { syntax: 'css' })); // padding: 10px;
69 | console.log(expand('p10', { syntax: 'stylus' })); // padding 10px
70 | ```
71 |
72 | Predefined syntaxes already have `type` attribute which describes whether given abbreviation is markup or stylesheet, but if you want to use it with your custom syntax name, you should provide `type` config option as well (default is `markup`):
73 |
74 | ```js
75 | import expand from 'emmet';
76 |
77 | console.log(expand('p10', {
78 | syntax: 'my-custom-syntax',
79 | type: 'stylesheet',
80 | options: {
81 | 'stylesheet.between': '__',
82 | 'stylesheet.after': '',
83 | }
84 | })); // padding__10px
85 | ```
86 |
87 | You can pass `options` property as well to shape-up final output or enable/disable various features. See [`src/config.ts`](src/config.ts) for more info and available options.
88 |
89 | ## Extracting abbreviations from text
90 |
91 | A common workflow with Emmet is to type abbreviation somewhere in source code and then expand it with editor action. To support such workflow, abbreviations must be properly _extracted_ from source code:
92 |
93 | ```js
94 | import expand, { extract } from 'emmet';
95 |
96 | const source = 'Hello world ul.tabs>li';
97 | const data = extract(source, 22); // { abbreviation: 'ul.tabs>li' }
98 |
99 | console.log(expand(data.abbreviation)); //
100 | ```
101 |
102 | The `extract` function accepts source code (most likely, current line) and character location in source from which abbreviation search should be started. The abbreviation is searched in backward direction: the location pointer is moved backward until it finds abbreviation bound. Returned result is an object with `abbreviation` property and `start` and `end` properties which describe location of extracted abbreviation in given source.
103 |
104 | Most current editors automatically insert closing quote or bracket for `(`, `[` and `{` characters so when user types abbreviation that uses attributes or text, it will end with the following state (`|` is caret location):
105 |
106 | ```
107 | ul>li[title="Foo|"]
108 | ```
109 |
110 | E.g. caret location is not at the end of abbreviation and must be moved a few characters ahead. The `extract` function is able to handle such cases with `lookAhead` option (enabled by default). This this option enabled, `extract` method automatically detects auto-inserted characters and adjusts location, which will be available as `end` property of the returned result:
111 |
112 | ```js
113 | import { extract } from 'emmet';
114 |
115 | const source = 'a div[title] b';
116 | const loc = 11; // right after "title" word
117 |
118 | // `lookAhead` is enabled by default
119 | console.log(extract(source, loc)); // { abbreviation: 'div[title]', start: 2, end: 12 }
120 | console.log(extract(source, loc, { lookAhead: false })); // { abbreviation: 'title', start: 6, end: 11 }
121 | ```
122 |
123 | By default, `extract` tries to detect _markup_ abbreviations (see above). _stylesheet_ abbreviations has slightly different syntax so in order to extract abbreviations for stylesheet syntaxes like CSS, you should pass `type: 'stylesheet'` option:
124 |
125 | ```js
126 | import { extract } from 'emmet';
127 |
128 | const source = 'a{b}';
129 | const loc = 3; // right after "b"
130 |
131 | console.log(extract(source, loc)); // { abbreviation: 'a{b}', start: 0, end: 4 }
132 |
133 |
134 | // Stylesheet abbreviations does not have `{text}` syntax
135 | console.log(extract(source, loc, { type: 'stylesheet' })); // { abbreviation: 'b', start: 2, end: 3 }
136 | ```
137 |
138 | ### Extract abbreviation with custom prefix
139 |
140 | Lots of developers uses React (or similar) library for writing UI code which mixes JS and XML (JSX) in the same source code. Since _any_ Latin word can be used as Emmet abbreviation, writing JSX code with Emmet becomes pain since it will interfere with native editor snippets and distract user with false positive abbreviation matches for variable names, methods etc.:
141 |
142 | ```js
143 | var div // `div` is a valid abbreviation, Emmet may transform it to ``
144 | ```
145 |
146 | A possible solution for this problem it to use _prefix_ for abbreviation: abbreviation can be successfully extracted only if its preceded with given prefix.
147 |
148 | ```js
149 | import { extract } from 'emmet';
150 |
151 | const source1 = '() => div';
152 | const source2 = '() => ch.charCodeAt(0);
48 | const specialChars = '#.*:$-_!@%^+>/'.split('').map(code);
49 |
50 | const defaultOptions: ExtractOptions = {
51 | type: 'markup',
52 | lookAhead: true,
53 | prefix: ''
54 | };
55 |
56 | /**
57 | * Extracts Emmet abbreviation from given string.
58 | * The goal of this module is to extract abbreviation from current editor’s line,
59 | * e.g. like this: `
.foo[title=bar|]` -> `.foo[title=bar]`, where
60 | * `|` is a current caret position.
61 | * @param line A text line where abbreviation should be expanded
62 | * @param pos Caret position in line. If not given, uses end of line
63 | * @param options Extracting options
64 | */
65 | export default function extractAbbreviation(line: string, pos: number = line.length, options: Partial
= {}): ExtractedAbbreviation | undefined {
66 | // make sure `pos` is within line range
67 | const opt: ExtractOptions = { ...defaultOptions, ...options };
68 | pos = Math.min(line.length, Math.max(0, pos == null ? line.length : pos));
69 |
70 | if (opt.lookAhead) {
71 | pos = offsetPastAutoClosed(line, pos, opt);
72 | }
73 |
74 | let ch: number;
75 | const start = getStartOffset(line, pos, opt.prefix || '');
76 | if (start === -1) {
77 | return void 0;
78 | }
79 |
80 | const scanner = backwardScanner(line, start);
81 | scanner.pos = pos;
82 | const stack: number[] = [];
83 |
84 | while (!sol(scanner)) {
85 | ch = peek(scanner);
86 |
87 | if (stack.includes(Brackets.CurlyR)) {
88 | if (ch === Brackets.CurlyR) {
89 | stack.push(ch);
90 | scanner.pos--;
91 | continue;
92 | }
93 |
94 | if (ch !== Brackets.CurlyL) {
95 | scanner.pos--;
96 | continue;
97 | }
98 | }
99 |
100 | if (isCloseBrace(ch, opt.type)) {
101 | stack.push(ch);
102 | } else if (isOpenBrace(ch, opt.type)) {
103 | if (stack.pop() !== bracePairs[ch]) {
104 | // unexpected brace
105 | break;
106 | }
107 | } else if (stack.includes(Brackets.SquareR) || stack.includes(Brackets.CurlyR)) {
108 | // respect all characters inside attribute sets or text nodes
109 | scanner.pos--;
110 | continue;
111 | } else if (isAtHTMLTag(scanner) || !isAbbreviation(ch)) {
112 | break;
113 | }
114 |
115 | scanner.pos--;
116 | }
117 |
118 | if (!stack.length && scanner.pos !== pos) {
119 | // Found something, remove some invalid symbols from the
120 | // beginning and return abbreviation
121 | const abbreviation = line.slice(scanner.pos, pos).replace(/^[*+>^]+/, '');
122 | return {
123 | abbreviation,
124 | location: pos - abbreviation.length,
125 | start: options.prefix
126 | ? start - options.prefix.length
127 | : pos - abbreviation.length,
128 | end: pos
129 | };
130 | }
131 | }
132 |
133 | /**
134 | * Returns new `line` index which is right after characters beyound `pos` that
135 | * editor will likely automatically close, e.g. }, ], and quotes
136 | */
137 | function offsetPastAutoClosed(line: string, pos: number, options: ExtractOptions): number {
138 | // closing quote is allowed only as a next character
139 | if (isQuote(line.charCodeAt(pos))) {
140 | pos++;
141 | }
142 |
143 | // offset pointer until non-autoclosed character is found
144 | while (isCloseBrace(line.charCodeAt(pos), options.type)) {
145 | pos++;
146 | }
147 |
148 | return pos;
149 | }
150 |
151 | /**
152 | * Returns start offset (left limit) in `line` where we should stop looking for
153 | * abbreviation: it’s nearest to `pos` location of `prefix` token
154 | */
155 | function getStartOffset(line: string, pos: number, prefix: string): number {
156 | if (!prefix) {
157 | return 0;
158 | }
159 |
160 | const scanner = backwardScanner(line);
161 | const compiledPrefix = prefix.split('').map(code);
162 | scanner.pos = pos;
163 | let result: number;
164 |
165 | while (!sol(scanner)) {
166 | if (consumePair(scanner, Brackets.SquareR, Brackets.SquareL) || consumePair(scanner, Brackets.CurlyR, Brackets.CurlyL)) {
167 | continue;
168 | }
169 |
170 | result = scanner.pos;
171 | if (consumeArray(scanner, compiledPrefix)) {
172 | return result;
173 | }
174 |
175 | scanner.pos--;
176 | }
177 |
178 | return -1;
179 | }
180 |
181 | /**
182 | * Consumes full character pair, if possible
183 | */
184 | function consumePair(scanner: BackwardScanner, close: number, open: number): boolean {
185 | const start = scanner.pos;
186 | if (consume(scanner, close)) {
187 | while (!sol(scanner)) {
188 | if (consume(scanner, open)) {
189 | return true;
190 | }
191 |
192 | scanner.pos--;
193 | }
194 | }
195 |
196 | scanner.pos = start;
197 | return false;
198 | }
199 |
200 | /**
201 | * Consumes all character codes from given array, right-to-left, if possible
202 | */
203 | function consumeArray(scanner: BackwardScanner, arr: number[]) {
204 | const start = scanner.pos;
205 | let consumed = false;
206 |
207 | for (let i = arr.length - 1; i >= 0 && !sol(scanner); i--) {
208 | if (!consume(scanner, arr[i])) {
209 | break;
210 | }
211 |
212 | consumed = i === 0;
213 | }
214 |
215 | if (!consumed) {
216 | scanner.pos = start;
217 | }
218 |
219 | return consumed;
220 | }
221 |
222 | function isAbbreviation(ch: number) {
223 | return (ch > 64 && ch < 91) // uppercase letter
224 | || (ch > 96 && ch < 123) // lowercase letter
225 | || (ch > 47 && ch < 58) // number
226 | || specialChars.includes(ch); // special character
227 | }
228 |
229 | function isOpenBrace(ch: number, syntax: SyntaxType) {
230 | return ch === Brackets.RoundL || (syntax === 'markup' && (ch === Brackets.SquareL || ch === Brackets.CurlyL));
231 | }
232 |
233 | function isCloseBrace(ch: number, syntax: SyntaxType) {
234 | return ch === Brackets.RoundR || (syntax === 'markup' && (ch === Brackets.SquareR || ch === Brackets.CurlyR));
235 | }
236 |
--------------------------------------------------------------------------------
/packages/abbreviation/test/parser.ts:
--------------------------------------------------------------------------------
1 | import { describe, it } from 'node:test';
2 | import { strictEqual as equal, throws } from 'node:assert';
3 | import parser from '../src/parser';
4 | import tokenizer from '../src/tokenizer';
5 | import stringify from './assets/stringify';
6 | import type { ParserOptions } from '../src';
7 |
8 | const parse = (abbr: string, options?: ParserOptions) => parser(tokenizer(abbr), options);
9 | const str = (abbr: string, options?: ParserOptions) => stringify(parse(abbr, options));
10 |
11 | describe('Parser', () => {
12 | it('basic abbreviations', () => {
13 | equal(str('p'), '');
14 | equal(str('p{text}'), 'text
');
15 | equal(str('h$'), '');
16 | equal(str('.nav'), ' class=nav>?>');
17 | equal(str('div.width1\\/2'), '');
18 | equal(str('#sample*3'), '*3 id=sample>?>');
19 |
20 | // ulmauts, https://github.com/emmetio/emmet/issues/439
21 | equal(str('DatenSätze^'), '')
22 |
23 | // https://github.com/emmetio/emmet/issues/562
24 | equal(str('li[repeat.for="todo of todoList"]'), '', 'Dots in attribute names');
25 |
26 | equal(str('a>b'), '');
27 | equal(str('a+b'), '');
28 | equal(str('a+b>c+d'), '');
29 | equal(str('a>b>c+e'), '');
30 | equal(str('a>b>c^d'), '');
31 | equal(str('a>b>c^^^^d'), '');
32 | equal(str('a:b>c'), '');
33 |
34 | equal(str('ul.nav[title="foo"]'), '');
35 | });
36 |
37 | it('groups', () => {
38 | equal(str('a>(b>c)+d'), '()');
39 | equal(str('(a>b)+(c>d)'), '()()');
40 | equal(str('a>((b>c)(d>e))f'), '(()())');
41 | equal(str('a>((((b>c))))+d'), '(((())))');
42 | equal(str('a>(((b>c))*4)+d'), '((())*4)');
43 | equal(str('(div>dl>(dt+dd)*2)'), '()');
44 | equal(str('a>()'), '()');
45 | });
46 |
47 | it('attributes', () => {
48 | equal(str('[].foo'), ' class=foo>?>');
49 | equal(str('[a]'), ' a>?>');
50 | equal(str('[a b c [d]]'), ' a b c [d]>?>');
51 | equal(str('[a=b]'), ' a=b>?>');
52 | equal(str('[a=b c= d=e]'), ' a=b c d=e>?>');
53 | equal(str('[a=b.c d=тест]'), ' a=b.c d=тест>?>');
54 | equal(str('[[a]=b (c)=d]'), ' [a]=b (c)=d>?>');
55 |
56 | // Quoted attribute values
57 | equal(str('[a="b"]'), ' a="b">?>');
58 | equal(str('[a="b" c=\'d\' e=""]'), ' a="b" c=\'d\' e="">?>');
59 | equal(str('[[a]="b" (c)=\'d\']'), ' [a]="b" (c)=\'d\'>?>');
60 |
61 | // Mixed quoted
62 | equal(str('[a="foo\'bar" b=\'foo"bar\' c="foo\\\"bar"]'), ' a="foo\'bar" b=\'foo"bar\' c="foo"bar">?>');
63 |
64 | // Boolean & implied attributes
65 | equal(str('[a. b.]'), ' a. b.>?>');
66 | equal(str('[!a !b.]'), ' !a !b.>?>');
67 |
68 | // Default values
69 | equal(str('["a.b"]'), ' ?="a.b">?>');
70 | equal(str('[\'a.b\' "c=d" foo=bar "./test.html"]'), ' ?=\'a.b\' ?="c=d" foo=bar ?="./test.html">?>');
71 |
72 | // Expressions as values
73 | equal(str('[foo={1 + 2} bar={fn(1, "foo")}]'), ' foo={1 + 2} bar={fn(1, "foo")}>?>');
74 |
75 | // Tabstops as unquoted values
76 | equal(str('[name=${1} value=${2:test}]'), ' name=${1} value=${2:test}>?>');
77 | });
78 |
79 | it('malformed attributes', () => {
80 | equal(str('[a'), ' a>?>');
81 | equal(str('[a={foo]'), ' a={foo]>?>');
82 | throws(() => str('[a="foo]'), /Unclosed quote/);
83 | throws(() => str('[a=b=c]'), /Unexpected "Operator" token/);
84 | });
85 |
86 | it('elements', () => {
87 | equal(str('div'), '');
88 | equal(str('div.foo'), '');
89 | equal(str('div#foo'), '');
90 | equal(str('div#foo.bar'), '');
91 | equal(str('div.foo#bar'), '');
92 | equal(str('div.foo.bar.baz'), '');
93 | equal(str('.foo'), ' class=foo>?>');
94 | equal(str('#foo'), ' id=foo>?>');
95 | equal(str('.foo_bar'), ' class=foo_bar>?>');
96 | equal(str('#foo.bar'), ' id=foo class=bar>?>');
97 |
98 | // Attribute shorthands
99 | equal(str('.'), ' class>?>');
100 | equal(str('#'), ' id>?>');
101 | equal(str('#.'), ' id class>?>');
102 | equal(str('.#.'), ' class id class>?>');
103 | equal(str('.a..'), ' class=a class>?>');
104 |
105 | // Elements with attributes
106 | equal(str('div[foo=bar]'), '');
107 | equal(str('div.a[b=c]'), '');
108 | equal(str('div.mr-\\[500\\][a=b]'), '');
109 | equal(str('div[b=c].a'), '');
110 | equal(str('div[a=b][c="d"]'), '');
111 | equal(str('[b=c]'), ' b=c>?>');
112 | equal(str('.a\\[b-c\\]'), ' class=a[b-c]>?>');
113 | equal(str('."a:[b-c]"'), ' class=a:[b-c]>?>');
114 | equal(str('."peer-[.is-dirty]:peer-required:block"'), ' class=peer-[.is-dirty]:peer-required:block>?>');
115 | equal(str('."mr-50"."peer-[:nth-of-type(3)_&]:block"'), ' class=mr-50 class=peer-[:nth-of-type(3)_&]:block>?>');
116 | equal(str('.a[b=c]'), ' class=a b=c>?>');
117 | equal(str('[b=c].a#d'), ' b=c class=a id=d>?>');
118 | equal(str('[b=c]a'), ' b=c>?>', 'Do not consume node name after attribute set');
119 |
120 | // Element with text
121 | equal(str('div{foo}'), 'foo
');
122 | equal(str('{foo}'), '>foo?>');
123 |
124 | // Mixed
125 | equal(str('div.foo{bar}'), 'bar
');
126 | equal(str('.foo{bar}#baz'), ' class=foo id=baz>bar?>');
127 | equal(str('.foo[b=c]{bar}'), ' class=foo b=c>bar?>');
128 |
129 | // Repeated element
130 | equal(str('div.foo*3'), '');
131 | equal(str('.foo*'), '* class=foo>?>');
132 | equal(str('.a[b=c]*10'), '*10 class=a b=c>?>');
133 | equal(str('.a*10[b=c]'), '*10 class=a b=c>?>');
134 | equal(str('.a*10{text}'), '*10 class=a>text?>');
135 |
136 | // Self-closing element
137 | equal(str('div/'), '');
138 | equal(str('.foo/'), ' class=foo />');
139 | equal(str('.foo[bar]/'), ' class=foo bar />');
140 | equal(str('.foo/*3'), '*3 class=foo />');
141 | equal(str('.foo*3/'), '*3 class=foo />');
142 |
143 | throws(() => parse('/'), /Unexpected character/);
144 | });
145 |
146 | it('JSX', () => {
147 | const opt = { jsx: true };
148 | equal(str('foo.bar', opt), '');
149 | equal(str('Foo.bar', opt), '');
150 | equal(str('Foo.Bar', opt), '');
151 | equal(str('Foo.', opt), '');
152 | equal(str('Foo.Bar.baz', opt), '');
153 | equal(str('Foo.Bar.Baz', opt), '');
154 |
155 | equal(str('.{theme.class}', opt), ' class=theme.class>?>');
156 | equal(str('#{id}', opt), ' id=id>?>');
157 | equal(str('Foo.{theme.class}', opt), '');
158 | });
159 |
160 | it('errors', () => {
161 | throws(() => parse('str?'), /Unexpected character at 4/);
162 | throws(() => parse('foo,bar'), /Unexpected character at 4/);
163 | equal(str('foo\\,bar'), '');
164 | equal(str('foo\\'), '');
165 | });
166 |
167 | it('missing braces', () => {
168 | // Do not throw errors on missing closing braces
169 | equal(str('div[title="test"'), '');
170 | equal(str('div(foo'), '()');
171 | equal(str('div{foo'), 'foo
');
172 | });
173 | });
174 |
--------------------------------------------------------------------------------