Repository: emmetio/emmet Branch: master Commit: 9f0944960ec3 Files: 105 Total size: 343.5 KB Directory structure: gitextract_u8cxec12/ ├── .editorconfig ├── .github/ │ ├── FUNDING.yml │ └── workflows/ │ └── node.js.yml ├── .gitignore ├── .npmignore ├── .npmrc ├── LICENSE ├── README.md ├── lerna.json ├── package.json ├── packages/ │ ├── abbreviation/ │ │ ├── .npmignore │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── convert.ts │ │ │ ├── index.ts │ │ │ ├── parser/ │ │ │ │ ├── TokenScanner.ts │ │ │ │ └── index.ts │ │ │ ├── stringify.ts │ │ │ ├── tokenizer/ │ │ │ │ ├── index.ts │ │ │ │ ├── tokens.ts │ │ │ │ └── utils.ts │ │ │ └── types.ts │ │ ├── test/ │ │ │ ├── assets/ │ │ │ │ ├── stringify-node.ts │ │ │ │ └── stringify.ts │ │ │ ├── convert.ts │ │ │ ├── parser.ts │ │ │ └── tokenizer.ts │ │ └── tsconfig.json │ ├── css-abbreviation/ │ │ ├── .npmignore │ │ ├── LICENSE │ │ ├── README.md │ │ ├── package.json │ │ ├── rollup.config.js │ │ ├── src/ │ │ │ ├── index.ts │ │ │ ├── parser/ │ │ │ │ ├── TokenScanner.ts │ │ │ │ └── index.ts │ │ │ └── tokenizer/ │ │ │ ├── index.ts │ │ │ ├── tokens.ts │ │ │ └── utils.ts │ │ ├── test/ │ │ │ ├── assets/ │ │ │ │ └── stringify.ts │ │ │ ├── parser.ts │ │ │ └── tokenizer.ts │ │ └── tsconfig.json │ └── scanner/ │ ├── .gitignore │ ├── .npmignore │ ├── LICENSE │ ├── package.json │ ├── rollup.config.js │ ├── src/ │ │ ├── scanner.ts │ │ └── utils.ts │ ├── test/ │ │ ├── stream-reader.ts │ │ └── utils.ts │ └── tsconfig.json ├── rollup.config.js ├── src/ │ ├── config.ts │ ├── extract-abbreviation/ │ │ ├── brackets.ts │ │ ├── index.ts │ │ ├── is-html.ts │ │ ├── quotes.ts │ │ └── reader.ts │ ├── index.ts │ ├── markup/ │ │ ├── addon/ │ │ │ ├── bem.ts │ │ │ ├── label.ts │ │ │ └── xsl.ts │ │ ├── attributes.ts │ │ ├── format/ │ │ │ ├── comment.ts │ │ │ ├── haml.ts │ │ │ ├── html.ts │ │ │ ├── indent-format.ts │ │ │ ├── pug.ts │ │ │ ├── slim.ts │ │ │ ├── template.ts │ │ │ ├── utils.ts │ │ │ └── walk.ts │ │ ├── implicit-tag.ts │ │ ├── index.ts │ │ ├── lorem/ │ │ │ ├── index.ts │ │ │ ├── latin.json │ │ │ ├── russian.json │ │ │ └── spanish.json │ │ ├── snippets.ts │ │ └── utils.ts │ ├── output-stream.ts │ ├── snippets/ │ │ ├── css.json │ │ ├── html.json │ │ ├── pug.json │ │ ├── variables.json │ │ └── xsl.json │ └── stylesheet/ │ ├── color.ts │ ├── format.ts │ ├── index.ts │ ├── score.ts │ └── snippets.ts ├── test/ │ ├── assets/ │ │ └── stringify.ts │ ├── expand.ts │ ├── extract-abbreviation.ts │ ├── format.ts │ ├── lorem.ts │ ├── markup.ts │ ├── output.ts │ ├── snippets.ts │ └── stylesheet.ts └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] indent_style = space indent_size = 4 end_of_line = lf charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true [*.{json,yml}] indent_style = space indent_size = 2 [snippets/*.json] indent_style = tab indent_size = 4 ================================================ FILE: .github/FUNDING.yml ================================================ # These are supported funding model platforms github: emmetio patreon: # Replace with a single Patreon username open_collective: emmet ko_fi: # Replace with a single Ko-fi username tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry liberapay: # Replace with a single Liberapay username issuehunt: # Replace with a single IssueHunt username otechie: # Replace with a single Otechie username custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] ================================================ FILE: .github/workflows/node.js.yml ================================================ # This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs name: Node.js CI on: push: branches: [ "master", "ci" ] pull_request: branches: [ "master" ] jobs: build: runs-on: ubuntu-latest strategy: matrix: node-version: [20.x, 22.x, 24.x] # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} - run: npm ci - run: npm run build:full - run: npm run test:all ================================================ FILE: .gitignore ================================================ # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (https://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # TypeScript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history .rpt2_cache dist/ # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # next.js build output .next .DS_Store /.vscode ================================================ FILE: .npmignore ================================================ npm-debug.log* node_modules jspm_packages .npm /.* /*.* /test /src /packages ================================================ FILE: .npmrc ================================================ save-exact=true ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 Sergey Chikuyonok Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Emmet — the essential toolkit for web-developers Emmet is a web-developer’s toolkit for boosting HTML & CSS code writing. 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: ``` ul#nav>li.item$*4>a{Item $} ``` ...can be expanded into: ```html ``` ## Features * **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. * **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. * **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)`. * **Available for most popular syntaxes**: use single abbreviation to produce code for most popular syntaxes like HAML, Pug, JSX, SCSS, SASS etc. [Read more about Emmet features](https://docs.emmet.io) This repo contains only core module for parsing and expanding Emmet abbreviations. Editor plugins are available as [separate repos](https://github.com/emmetio). 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). ### Installation You can install Emmet as a regular npm module: ```bash npm i emmet ``` ## Usage To expand abbreviation, pass it to default function of `emmet` module: ```js import expand from 'emmet'; console.log(expand('p>a')); //

``` 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: ```js import expand from 'emmet'; console.log(expand('p10', { type: 'stylesheet' })); // padding: 10px; ``` 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. Alternatively, Emmet supports *syntaxes* with predefined snippets and options: ```js import expand from 'emmet'; console.log(expand('p10', { syntax: 'css' })); // padding: 10px; console.log(expand('p10', { syntax: 'stylus' })); // padding 10px ``` 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`): ```js import expand from 'emmet'; console.log(expand('p10', { syntax: 'my-custom-syntax', type: 'stylesheet', options: { 'stylesheet.between': '__', 'stylesheet.after': '', } })); // padding__10px ``` 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. ## Extracting abbreviations from text 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: ```js import expand, { extract } from 'emmet'; const source = 'Hello world ul.tabs>li'; const data = extract(source, 22); // { abbreviation: 'ul.tabs>li' } console.log(expand(data.abbreviation)); // ``` 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. 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): ``` ul>li[title="Foo|"] ``` 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: ```js import { extract } from 'emmet'; const source = 'a div[title] b'; const loc = 11; // right after "title" word // `lookAhead` is enabled by default console.log(extract(source, loc)); // { abbreviation: 'div[title]', start: 2, end: 12 } console.log(extract(source, loc, { lookAhead: false })); // { abbreviation: 'title', start: 6, end: 11 } ``` 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: ```js import { extract } from 'emmet'; const source = 'a{b}'; const loc = 3; // right after "b" console.log(extract(source, loc)); // { abbreviation: 'a{b}', start: 0, end: 4 } // Stylesheet abbreviations does not have `{text}` syntax console.log(extract(source, loc, { type: 'stylesheet' })); // { abbreviation: 'b', start: 2, end: 3 } ``` ### Extract abbreviation with custom prefix 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.: ```js var div // `div` is a valid abbreviation, Emmet may transform it to `
` ``` A possible solution for this problem it to use _prefix_ for abbreviation: abbreviation can be successfully extracted only if its preceded with given prefix. ```js import { extract } from 'emmet'; const source1 = '() => div'; const source2 = '() => ", "license": "MIT", "bugs": { "url": "https://github.com/emmetio/emmet/issues" }, "homepage": "https://github.com/emmetio/emmet#readme", "devDependencies": { "@emmetio/abbreviation": "workspace:", "@emmetio/css-abbreviation": "workspace:", "@rollup/plugin-node-resolve": "16.0.3", "@rollup/plugin-typescript": "12.2.0", "@types/node": "22.10.1", "lerna": "9.0.0", "rimraf": "6.0.1", "rollup": "4.52.5", "tsx": "4.20.6", "typescript": "5.9.3" }, "workspaces": [ "./packages/scanner", "./packages/abbreviation", "./packages/css-abbreviation" ] } ================================================ FILE: packages/abbreviation/.npmignore ================================================ npm-debug.log* node_modules jspm_packages .npm /.* /*.* /test /src ================================================ FILE: packages/abbreviation/LICENSE ================================================ MIT License Copyright (c) 2020 Sergey Chikuyonok Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/abbreviation/README.md ================================================ # Emmet markup abbreviation parser Parses given Emmet *markup* abbreviation into AST. Parsing is performed in two steps: first it tokenizes given abbreviation (useful for syntax highlighting in editors) and then tokens are analyzed and converted into AST nodes as plain, JSON-serializable objects. Note that AST tree in most cases cannot be used directly for output: for example, AST node produced from `.foo.bar` element misses element name and contains two `class` attributes with `foo` and `bar` values (not a single `class` with `foo bar` value). ## Usage You can install it via npm: ```bash npm install @emmetio/abbreviation ``` Then add it into your project: ```js import parse from '@emmetio/abbreviation'; const tree = parse('div#foo>span.bar*3'); /* { type: 'Abbreviation', children: [{ type: 'AbbreviationNode', name: 'div', attributes: [...], children: [...] }] } */ ``` The returned tree contains `AbbreviationNode` items: a node with name, attributes and/or text content. E.g. an element that can be represented somehow. Repeated and grouped nodes like `a>(b+c)*3` are automatically converted and duplicated as distinct `AbbreviationNode` with distinct `.repeat` property which identifies node in repeating sequence. ## Abbreviation syntax Emmet abbreviation element has the following basic parts: ``` name.class#id[attributes?, ...]{text value}*repeater/ ``` * `name` — element name, like `div`, `span` etc. Stored as `node.name` property. * `[attributes]` — list of attributes. Each attribute is stored as [`AbbreviationAttribute`](/src/types.ts) instance and can be accessed by `node.getAttribute(name)`. Each attribute can be written in different formats: * `attr` — attribute with empty value. * `attr=value` — attribute with value. The `value` may contain any character except space or `]`. * `attr="value"` or `attr='value'` — attribute with value in quotes. Quotes are automatically removed. Expression values like `attr={value}` are supported and can be identified by `valueType: "expression"` property. * `attr.` — boolean attribute, e.g. attribute without value, like `required` in ``. * `!attr` – implicit attribute, will be outputted if its value is not empty. Used as a placeholder to preserve attribute order in output. * `./non/attr/value` — value for default attribute. In other words, anything that doesn’t match a attribute name characters. Can be a single- or double-quotted as well. Default attribute is stored with `null` as name and should be used later, for example, to resolve predefined attributes. * `.class` — shorthand for `class` attribute. Note that an element can have multiple classes, like `.class1.class2.class3`. * `#id` — shorthand for `id` attribute. * `{text}` — node’s text content * `*N` — element repeater, tells parser to create `N` copies of given node. * `/` — optional self-closing operator. Marks element with `node.selfClosing = true`. ### Operators Each element of abbreviation must be separated with any of these operators: ``` elem1+elem2>elem3 ``` * `+` — sibling operator, adds next element as a next sibling of current element in tree. * `>` — child operator, adds next element as a child of current element. * `^` — climb-up operator, adds next element as a child of current element’s parent node. Multiple climb-up operators are allowed, each operator moves one level up by tree. ### Groups A set of elements could be grouped using `()`, mostly for repeating and for easier elements nesting: ``` a>(b>c+d)*4+(e+f) ``` Groups can be optionally concatenated with `+` operator. ================================================ FILE: packages/abbreviation/package.json ================================================ { "name": "@emmetio/abbreviation", "version": "2.3.3", "description": "Emmet standalone abbreviation parser", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", "exports": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "scripts": { "test": "tsx --test ./test/*.ts", "build": "rollup -c", "watch": "rollup -wc", "clean": "rm -rf ./dist", "prepublishOnly": "npm test && npm run clean && npm run build" }, "keywords": [ "emmet", "abbreviation" ], "author": "Sergey Chikuyonok ", "license": "MIT", "dependencies": { "@emmetio/scanner": "^1.0.4" }, "directories": { "test": "test" }, "repository": { "type": "git", "url": "git+https://github.com/emmetio/emmet.git" }, "bugs": { "url": "https://github.com/emmetio/emmet/issues" }, "homepage": "https://github.com/emmetio/emmet#readme" } ================================================ FILE: packages/abbreviation/rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; export default { input: './src/index.ts', external: ['@emmetio/scanner'], plugins: [typescript()], output: [{ format: 'cjs', sourcemap: true, exports: 'named', file: './dist/index.cjs' }, { format: 'es', sourcemap: true, file: './dist/index.js' }] }; ================================================ FILE: packages/abbreviation/src/convert.ts ================================================ import { isQuote, isBracket } from './parser'; import type { TokenGroup, TokenStatement, TokenElement, TokenAttribute } from './parser'; import type { Abbreviation, ParserOptions, AbbreviationNode, ConvertState, Value, AbbreviationAttribute, AttributeType } from './types'; import type { Repeater, ValueToken, Quote, Field } from './tokenizer'; import stringify from './stringify'; const urlRegex = /^((https?:|ftp:|file:)?\/\/|(www|ftp)\.)[^ ]*$/; const emailRegex = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,5}$/; /** * Converts given token-based abbreviation into simplified and unrolled node-based * abbreviation */ export default function convert(abbr: TokenGroup, options: ParserOptions = {}): Abbreviation { let textInserted = false; let cleanText: string | string[] | undefined; if (options.text) { if (Array.isArray(options.text)) { cleanText = options.text.filter(s => s.trim()); } else { cleanText = options.text; } } const result: Abbreviation = { type: 'Abbreviation', children: convertGroup(abbr, { inserted: false, repeaters: [], text: options.text, cleanText, repeatGuard: options.maxRepeat || Number.POSITIVE_INFINITY, getText(pos) { textInserted = true; let value: string; if (Array.isArray(options.text)) { if (pos !== undefined && pos >= 0 && pos < cleanText!.length) { return cleanText![pos]; } value = pos !== undefined ? options.text[pos] : options.text.join('\n'); } else { value = options.text ?? ''; } return value; }, getVariable(name) { const varValue = options.variables && options.variables[name]; return varValue != null ? varValue : name; } }) }; if (options.text != null && !textInserted) { // Text given but no implicitly repeated elements: insert it into // deepest child const deepest = deepestNode(last(result.children)); if (deepest) { const text = Array.isArray(options.text) ? options.text.join('\n') : options.text; insertText(deepest, text); if (deepest.name === 'a' && options.href) { // Automatically update value of `` element if inserting URL or email insertHref(deepest, text); } } } return result; } /** * Converts given statement to abbreviation nodes */ function convertStatement(node: TokenStatement, state: ConvertState): AbbreviationNode[] { let result: AbbreviationNode[] = []; if (node.repeat) { // Node is repeated: we should create copies of given node // and supply context token with actual repeater state const original = node.repeat; const repeat = { ...original } as Repeater; repeat.count = repeat.implicit && Array.isArray(state.text) ? state.cleanText!.length : (repeat.count || 1); let items: AbbreviationNode[]; state.repeaters.push(repeat); for (let i = 0; i < repeat.count; i++) { repeat.value = i; node.repeat = repeat; items = isGroup(node) ? convertGroup(node, state) : convertElement(node, state); if (repeat.implicit && !state.inserted) { // It’s an implicit repeater but no repeater placeholders found inside, // we should insert text into deepest node const target = last(items); const deepest = target && deepestNode(target); if (deepest) { insertText(deepest, state.getText(repeat.value)); } } result = result.concat(items); // We should output at least one repeated item even if it’s reached // repeat limit if (--state.repeatGuard <= 0) { break; } } state.repeaters.pop(); node.repeat = original; if (repeat.implicit) { state.inserted = true; } } else { result = result.concat(isGroup(node) ? convertGroup(node, state) : convertElement(node, state)); } return result; } function convertElement(node: TokenElement, state: ConvertState): AbbreviationNode[] { let children: AbbreviationNode[] = []; const elem = { type: 'AbbreviationNode', name: node.name && stringifyName(node.name, state), value: node.value && stringifyValue(node.value, state), attributes: void 0, children, repeat: node.repeat && { ...node.repeat }, selfClosing: node.selfClose, } as AbbreviationNode; let result: AbbreviationNode[] = [elem]; for (const child of node.elements) { children = children.concat(convertStatement(child, state)); } if (node.attributes) { elem.attributes = []; for (const attr of node.attributes) { elem.attributes.push(convertAttribute(attr, state)); } } // In case if current node is a text-only snippet without fields, we should // put all children as siblings if (!elem.name && !elem.attributes && elem.value && !elem.value.some(isField)) { // XXX it’s unclear that `children` is not bound to `elem` // due to concat operation result = result.concat(children); } else { elem.children = children; } return result; } function convertGroup(node: TokenGroup, state: ConvertState): AbbreviationNode[] { let result: AbbreviationNode[] = []; for (const child of node.elements) { result = result.concat(convertStatement(child, state)); } if (node.repeat) { result = attachRepeater(result, node.repeat); } return result; } function convertAttribute(node: TokenAttribute, state: ConvertState): AbbreviationAttribute { let implied = false; let isBoolean = false; let valueType: AttributeType = node.expression ? 'expression' : 'raw'; let value: Value[] | undefined; const name = node.name && stringifyName(node.name, state); if (name && name[0] === '!') { implied = true; } if (name && name[name.length - 1] === '.') { isBoolean = true; } if (node.value) { const tokens = node.value.slice(); if (isQuote(tokens[0])) { // It’s a quoted value: remove quotes from output but mark attribute // value as quoted const quote = tokens.shift() as Quote; if (tokens.length && last(tokens).type === quote.type) { tokens.pop(); } valueType = quote.single ? 'singleQuote' : 'doubleQuote'; } else if (isBracket(tokens[0], 'expression', true)) { // Value is expression: remove brackets but mark value type valueType = 'expression'; tokens.shift(); if (isBracket(last(tokens), 'expression', false)) { tokens.pop(); } } value = stringifyValue(tokens, state); } return { name: isBoolean || implied ? name!.slice(implied ? 1 : 0, isBoolean ? -1 : void 0) : name, value, boolean: isBoolean, implied, valueType, multiple: node.multiple }; } /** * Converts given token list to string */ function stringifyName(tokens: ValueToken[], state: ConvertState): string { let str = ''; for (let i = 0; i < tokens.length; i++) { str += stringify(tokens[i], state); } return str; } /** * Converts given token list to value list */ function stringifyValue(tokens: ValueToken[], state: ConvertState): Value[] { const result: Value[] = []; let str = ''; for (let i = 0, token: ValueToken; i < tokens.length; i++) { token = tokens[i]; if (isField(token)) { // We should keep original fields in output since some editors has their // own syntax for field or doesn’t support fields at all so we should // capture actual field location in output stream if (str) { result.push(str); str = ''; } result.push(token); } else { str += stringify(token, state); } } if (str) { result.push(str); } return result; } export function isGroup(node: any): node is TokenGroup { return node.type === 'TokenGroup'; } function isField(token: any): token is Field { return typeof token === 'object' && token.type === 'Field' && token.index != null; } function last(arr: T[]): T { return arr[arr.length - 1]; } function deepestNode(node: AbbreviationNode): AbbreviationNode { return node.children.length ? deepestNode(last(node.children)) : node; } function insertText(node: AbbreviationNode, text: string) { if (node.value) { const lastToken = last(node.value); if (typeof lastToken === 'string') { node.value[node.value.length - 1] += text; } else { node.value.push(text); } } else { node.value = [text]; } } function insertHref(node: AbbreviationNode, text: string) { let href = ''; if (urlRegex.test(text)) { href = text; if (!/\w+:/.test(href) && !href.startsWith('//')) { href = `http://${href}`; } } else if (emailRegex.test(text)) { href = `mailto:${text}`; } const hrefAttribute = node.attributes?.find(attr => attr.name === 'href'); if (!hrefAttribute) { if (!node.attributes) { node.attributes = []; } node.attributes.push({ name: 'href', value: [href], valueType: 'doubleQuote' }); } else if (!hrefAttribute.value) { hrefAttribute.value = [href]; } } function attachRepeater(items: AbbreviationNode[], repeater: Repeater): AbbreviationNode[] { for (const item of items) { if (!item.repeat) { item.repeat = { ...repeater }; } } return items; } ================================================ FILE: packages/abbreviation/src/index.ts ================================================ import { ScannerError } from '@emmetio/scanner'; import parse, { type TokenGroup } from './parser'; import tokenize, { getToken, type AllTokens } from './tokenizer'; import convert from './convert'; import type { ParserOptions } from './types'; export { parse, tokenize, getToken, convert }; export * from './tokenizer/tokens'; export * from './types'; export type MarkupAbbreviation = TokenGroup; /** * Parses given abbreviation into node tree */ export default function parseAbbreviation(abbr: string | AllTokens[], options?: ParserOptions) { try { const tokens = typeof abbr === 'string' ? tokenize(abbr) : abbr; return convert(parse(tokens, options), options); } catch (err) { if (err instanceof ScannerError && typeof abbr === 'string') { err.message += `\n${abbr}\n${'-'.repeat(err.pos)}^`; } throw err; } } ================================================ FILE: packages/abbreviation/src/parser/TokenScanner.ts ================================================ import type { AllTokens } from '../tokenizer'; export interface TokenScanner { tokens: AllTokens[]; start: number; pos: number; size: number; } type TestFn = (token?: AllTokens) => boolean; export default function tokenScanner(tokens: AllTokens[]): TokenScanner { return { tokens, start: 0, pos: 0, size: tokens.length }; } export function peek(scanner: TokenScanner): AllTokens | undefined { return scanner.tokens[scanner.pos]; } export function next(scanner: TokenScanner): AllTokens | undefined { return scanner.tokens[scanner.pos++]; } export function slice(scanner: TokenScanner, from = scanner.start, to = scanner.pos): AllTokens[] { return scanner.tokens.slice(from, to); } export function readable(scanner: TokenScanner): boolean { return scanner.pos < scanner.size; } export function consume(scanner: TokenScanner, test: TestFn): boolean { const token = peek(scanner); if (token && test(token)) { scanner.pos++; return true; } return false; } export function error(scanner: TokenScanner, message: string, token = peek(scanner)) { if (token && token.start != null) { message += ` at ${token.start}`; } const err = new Error(message); err['pos'] = token && token.start; return err; } export function consumeWhile(scanner: TokenScanner, test: TestFn): boolean { const start = scanner.pos; while (consume(scanner, test)) { /* */ } return scanner.pos !== start; } ================================================ FILE: packages/abbreviation/src/parser/index.ts ================================================ import type { NameToken, ValueToken, Repeater, AllTokens, BracketType, Bracket, Operator, OperatorType, Quote, WhiteSpace, Literal } from '../tokenizer'; import tokenScanner, { type TokenScanner, peek, consume, readable, next, error, slice } from './TokenScanner'; import type { ParserOptions } from '../types'; export type TokenStatement = TokenElement | TokenGroup; export interface TokenAttribute { name?: ValueToken[]; value?: ValueToken[]; expression?: boolean; /** * Indicates that current attribute was repeated multiple times in a row. * Used to alter output of multiple shorthand attributes like `..` (double class) */ multiple?: boolean; } export interface TokenElement { type: 'TokenElement'; name?: NameToken[]; attributes?: TokenAttribute[]; value?: ValueToken[]; repeat?: Repeater; selfClose: boolean; elements: TokenStatement[]; } export interface TokenGroup { type: 'TokenGroup'; elements: TokenStatement[]; repeat?: Repeater; } export default function abbreviation(abbr: AllTokens[], options: ParserOptions = {}): TokenGroup { const scanner = tokenScanner(abbr); const result = statements(scanner, options); if (readable(scanner)) { throw error(scanner, 'Unexpected character'); } return result; } function statements(scanner: TokenScanner, options: ParserOptions): TokenGroup { const result: TokenGroup = { type: 'TokenGroup', elements: [] }; let ctx: TokenStatement = result; let node: TokenStatement | undefined; const stack: TokenStatement[] = []; while (readable(scanner)) { if (node = element(scanner, options) || group(scanner, options)) { ctx.elements.push(node); if (consume(scanner, isChildOperator)) { stack.push(ctx); ctx = node; } else if (consume(scanner, isSiblingOperator)) { continue; } else if (consume(scanner, isClimbOperator)) { do { if (stack.length) { ctx = stack.pop()!; } } while (consume(scanner, isClimbOperator)); } } else { break; } } return result; } /** * Consumes group from given scanner */ function group(scanner: TokenScanner, options: ParserOptions): TokenGroup | undefined { if (consume(scanner, isGroupStart)) { const result = statements(scanner, options); const token = next(scanner); if (isBracket(token, 'group', false)) { result.repeat = repeater(scanner); } return result; } } /** * Consumes single element from given scanner */ function element(scanner: TokenScanner, options: ParserOptions): TokenElement | undefined { let attr: TokenAttribute | TokenAttribute[] | undefined; const elem: TokenElement = { type: 'TokenElement', name: void 0, attributes: void 0, value: void 0, repeat: void 0, selfClose: false, elements: [] }; if (elementName(scanner, options)) { elem.name = slice(scanner) as NameToken[]; } while (readable(scanner)) { scanner.start = scanner.pos; if (!elem.repeat && !isEmpty(elem) && consume(scanner, isRepeater)) { elem.repeat = scanner.tokens[scanner.pos - 1] as Repeater; } else if (!elem.value && text(scanner)) { elem.value = getText(scanner); } else if (attr = shortAttribute(scanner, 'id', options) || shortAttribute(scanner, 'class', options) || attributeSet(scanner)) { if (!elem.attributes) { elem.attributes = Array.isArray(attr) ? attr.slice() : [attr]; } else { elem.attributes = elem.attributes.concat(attr); } } else { if (!isEmpty(elem) && consume(scanner, isCloseOperator)) { elem.selfClose = true; if (!elem.repeat && consume(scanner, isRepeater)) { elem.repeat = scanner.tokens[scanner.pos - 1] as Repeater; } } break; } } return !isEmpty(elem) ? elem : void 0; } /** * Consumes attribute set from given scanner */ function attributeSet(scanner: TokenScanner): TokenAttribute[] | undefined { if (consume(scanner, isAttributeSetStart)) { const attributes: TokenAttribute[] = []; let attr: TokenAttribute | undefined; while (readable(scanner)) { if (attr = attribute(scanner)) { attributes.push(attr); } else if (consume(scanner, isAttributeSetEnd)) { break; } else if (!consume(scanner, isWhiteSpace)) { throw error(scanner, `Unexpected "${peek(scanner)!.type}" token`); } } return attributes; } } /** * Consumes attribute shorthand (class or id) from given scanner */ function shortAttribute(scanner: TokenScanner, type: 'class' | 'id', options: ParserOptions): TokenAttribute | undefined { if (isOperator(peek(scanner), type)) { scanner.pos++; // Consume multiple operators let count = 1; while (isOperator(peek(scanner), type)) { scanner.pos++; count++; } const attr: TokenAttribute = { name: [createLiteral(type)] }; if (count > 1) { attr.multiple = true; } // Consume expression after shorthand start for React-like components if (options.jsx && text(scanner)) { attr.value = getText(scanner); attr.expression = true; } else if (quoted(scanner)) { attr.value = slice(scanner, scanner.start + 1, scanner.pos - 1) as ValueToken[]; } else { attr.value = literal(scanner) ? slice(scanner) as ValueToken[] : void 0; } return attr; } } /** * Consumes single attribute from given scanner */ function attribute(scanner: TokenScanner): TokenAttribute | undefined { if (quoted(scanner)) { // Consumed quoted value: it’s a value for default attribute return { value: slice(scanner) as ValueToken[] }; } if (literal(scanner, true)) { const name = slice(scanner) as NameToken[]; let value: ValueToken[] | undefined; if (consume(scanner, isEquals)) { if (quoted(scanner) || literal(scanner, true)) { value = slice(scanner) as ValueToken[]; } } return { name, value }; } } function repeater(scanner: TokenScanner): Repeater | undefined { return isRepeater(peek(scanner)) ? scanner.tokens[scanner.pos++] as Repeater : void 0; } /** * Consumes quoted value from given scanner, if possible */ function quoted(scanner: TokenScanner): boolean { const start = scanner.pos; const quote = peek(scanner); if (isQuote(quote)) { scanner.pos++; while (readable(scanner)) { if (isQuote(next(scanner), quote.single)) { scanner.start = start; return true; } } throw error(scanner, 'Unclosed quote', quote); } return false; } /** * Consumes literal (unquoted value) from given scanner */ function literal(scanner: TokenScanner, allowBrackets?: boolean): boolean { const start = scanner.pos; const brackets: { [type in BracketType]: number } = { attribute: 0, expression: 0, group: 0 }; while (readable(scanner)) { const token = peek(scanner); if (brackets.expression) { // If we’re inside expression, we should consume all content in it if (isBracket(token, 'expression')) { brackets[token.context] += token.open ? 1 : -1; } } else if (isQuote(token) || isOperator(token) || isWhiteSpace(token) || isRepeater(token)) { break; } else if (isBracket(token)) { if (!allowBrackets) { break; } if (token.open) { brackets[token.context]++; } else if (!brackets[token.context]) { // Stop if found unmatched closing brace: it must be handled // by parent consumer break; } else { brackets[token.context]--; } } scanner.pos++; } if (start !== scanner.pos) { scanner.start = start; return true; } return false; } /** * Consumes element name from given scanner */ function elementName(scanner: TokenScanner, options: ParserOptions): boolean { const start = scanner.pos; if (options.jsx && consume(scanner, isCapitalizedLiteral)) { // Check for edge case: consume immediate capitalized class names // for React-like components, e.g. `Foo.Bar.Baz` while (readable(scanner)) { const { pos } = scanner; if (!consume(scanner, isClassNameOperator) || !consume(scanner, isCapitalizedLiteral)) { scanner.pos = pos; break; } } } while (readable(scanner) && consume(scanner, isElementName)) { // empty } if (scanner.pos !== start) { scanner.start = start; return true; } return false; } /** * Consumes text value from given scanner */ function text(scanner: TokenScanner): boolean { const start = scanner.pos; if (consume(scanner, isTextStart)) { let brackets = 0; while (readable(scanner)) { const token = next(scanner); if (isBracket(token, 'expression')) { if (token.open) { brackets++; } else if (!brackets) { break; } else { brackets--; } } } scanner.start = start; return true; } return false; } function getText(scanner: TokenScanner): ValueToken[] { let from = scanner.start; let to = scanner.pos; if (isBracket(scanner.tokens[from], 'expression', true)) { from++; } if (isBracket(scanner.tokens[to - 1], 'expression', false)) { to--; } return slice(scanner, from, to) as ValueToken[]; } export function isBracket(token: AllTokens | undefined, context?: BracketType, isOpen?: boolean): token is Bracket { return Boolean(token && token.type === 'Bracket' && (!context || token.context === context) && (isOpen == null || token.open === isOpen)); } export function isOperator(token: AllTokens | undefined, type?: OperatorType): token is Operator { return Boolean(token && token.type === 'Operator' && (!type || token.operator === type)); } export function isQuote(token: AllTokens | undefined, isSingle?: boolean): token is Quote { return Boolean(token && token.type === 'Quote' && (isSingle == null || token.single === isSingle)); } function isWhiteSpace(token?: AllTokens): token is WhiteSpace { return Boolean(token && token.type === 'WhiteSpace'); } function isEquals(token: AllTokens) { return isOperator(token, 'equal'); } function isRepeater(token?: AllTokens): token is Repeater { return Boolean(token && token.type === 'Repeater'); } function isLiteral(token: AllTokens): token is Literal { return token.type === 'Literal'; } function isCapitalizedLiteral(token: AllTokens) { if (isLiteral(token)) { const ch = token.value.charCodeAt(0); return ch >= 65 && ch <= 90; } return false; } function isElementName(token: AllTokens): boolean { return token.type === 'Literal' || token.type === 'RepeaterNumber' || token.type === 'RepeaterPlaceholder'; } function isClassNameOperator(token: AllTokens) { return isOperator(token, 'class'); } function isAttributeSetStart(token?: AllTokens) { return isBracket(token, 'attribute', true); } function isAttributeSetEnd(token?: AllTokens) { return isBracket(token, 'attribute', false); } function isTextStart(token: AllTokens) { return isBracket(token, 'expression', true); } function isGroupStart(token: AllTokens) { return isBracket(token, 'group', true); } function createLiteral(value: string): Literal { return { type: 'Literal', value }; } function isEmpty(elem: TokenElement): boolean { return !elem.name && !elem.value && !elem.attributes; } function isChildOperator(token: AllTokens) { return isOperator(token, 'child'); } function isSiblingOperator(token: AllTokens) { return isOperator(token, 'sibling'); } function isClimbOperator(token: AllTokens) { return isOperator(token, 'climb'); } function isCloseOperator(token: AllTokens) { return isOperator(token, 'close'); } ================================================ FILE: packages/abbreviation/src/stringify.ts ================================================ import type { Token, Literal, Bracket, Field, RepeaterPlaceholder, Repeater, RepeaterNumber, ValueToken, Quote, Operator, OperatorType, WhiteSpace } from './tokenizer/tokens'; import type { ConvertState } from './types'; type TokenVisitor = (token: Token, state: ConvertState) => string; const operators: { [key in OperatorType]: string } = { child: '>', class: '.', climb: '^', id: '#', equal: '=', close: '/', sibling: '+' }; const tokenVisitor: { [name: string]: TokenVisitor } = { Literal(token: Literal): string { return token.value; }, Quote(token: Quote) { return token.single ? '\'' : '"'; }, Bracket(token: Bracket): string { if (token.context === 'attribute') { return token.open ? '[' : ']'; } else if (token.context === 'expression') { return token.open ? '{' : '}'; } else { return token.open ? '(' : '}'; } }, Operator(token: Operator) { return operators[token.operator]; }, Field(token: Field, state) { if (token.index != null) { // It’s a field: by default, return TextMate-compatible field return token.name ? `\${${token.index}:${token.name}}` : `\${${token.index}`; } else if (token.name) { // It’s a variable return state.getVariable(token.name); } return ''; }, RepeaterPlaceholder(token: RepeaterPlaceholder, state) { // Find closest implicit repeater let repeater: Repeater | undefined; for (let i = state.repeaters.length - 1; i >= 0; i--) { if (state.repeaters[i]!.implicit) { repeater = state.repeaters[i]!; break; } } state.inserted = true; return state.getText(repeater && repeater.value); }, RepeaterNumber(token: RepeaterNumber, state) { let value = 1; const lastIx = state.repeaters.length - 1; // const repeaterIx = Math.max(0, state.repeaters.length - 1 - token.parent); const repeater = state.repeaters[lastIx]; if (repeater) { value = token.reverse ? token.base + repeater.count - repeater.value! - 1 : token.base + repeater.value!; if (token.parent) { const parentIx = Math.max(0, lastIx - token.parent); if (parentIx !== lastIx) { const parentRepeater = state.repeaters[parentIx]; value += repeater.count * parentRepeater.value; } } } let result = String(value); while (result.length < token.size) { result = '0' + result; } return result; }, WhiteSpace(token: WhiteSpace) { return token.value; } }; /** * Converts given value token to string */ export default function stringify(token: ValueToken, state: ConvertState): string { if (!tokenVisitor[token.type]) { throw new Error(`Unknown token ${token.type}`); } return tokenVisitor[token.type](token, state); } ================================================ FILE: packages/abbreviation/src/tokenizer/index.ts ================================================ import Scanner, { isSpace, isQuote, isNumber, isAlpha, isAlphaNumericWord, isUmlaut } from '@emmetio/scanner'; import type { Literal, WhiteSpace, Quote, Bracket, BracketType, OperatorType, Operator, RepeaterPlaceholder, Repeater, Field, RepeaterNumber, AllTokens } from './tokens'; import { Chars, escaped } from './utils'; export * from './tokens'; type Context = { [ctx in BracketType]: number } & { quote: number }; export default function tokenize(source: string): AllTokens[] { const scanner = new Scanner(source); const result: AllTokens[] = []; const ctx: Context = { group: 0, attribute: 0, expression: 0, quote: 0 }; let ch = 0; let token: AllTokens | undefined; while (!scanner.eof()) { ch = scanner.peek(); token = getToken(scanner, ctx); if (token) { result.push(token); if (token.type === 'Quote') { ctx.quote = ch === ctx.quote ? 0 : ch; } else if (token.type === 'Bracket') { ctx[token.context] += token.open ? 1 : -1; } } else { throw scanner.error('Unexpected character'); } } return result; } /** * Returns next token from given scanner, if possible */ export function getToken(scanner: Scanner, ctx: Context): AllTokens | undefined { return field(scanner, ctx) || repeaterPlaceholder(scanner) || repeaterNumber(scanner) || repeater(scanner) || whiteSpace(scanner) || literal(scanner, ctx) || operator(scanner) || quote(scanner) || bracket(scanner); } /** * Consumes literal from given scanner */ function literal(scanner: Scanner, ctx: Context): Literal | undefined { const start = scanner.pos; const expressionStart = ctx.expression; let value = ''; while (!scanner.eof()) { // Consume escaped sequence no matter of context if (escaped(scanner)) { value += scanner.current(); continue; } const ch = scanner.peek(); if (ch === Chars.Slash && !ctx.quote && !ctx.expression && !ctx.attribute) { // Special case for `/` character between numbers in class names const prev = scanner.string.charCodeAt(scanner.pos - 1); const next = scanner.string.charCodeAt(scanner.pos + 1); if (isNumber(prev) && isNumber(next)) { value += scanner.string[scanner.pos++]; continue; } } if (ch === ctx.quote || ch === Chars.Dollar || isAllowedOperator(ch, ctx)) { // 1. Found matching quote // 2. The `$` character has special meaning in every context // 3. Depending on context, some characters should be treated as operators break; } if (expressionStart) { // Consume nested expressions, e.g. span{{foo}} if (ch === Chars.CurlyBracketOpen) { ctx.expression++; } else if (ch === Chars.CurlyBracketClose) { if (ctx.expression > expressionStart) { ctx.expression--; } else { break; } } } else if (!ctx.quote) { // Consuming element name if (!ctx.attribute && !isElementName(ch)) { break; } if (isAllowedSpace(ch, ctx) || isAllowedRepeater(ch, ctx) || isQuote(ch) || bracketType(ch)) { // Stop for characters not allowed in unquoted literal break; } } value += scanner.string[scanner.pos++]; } if (start !== scanner.pos) { scanner.start = start; return { type: 'Literal', value, start, end: scanner.pos }; } } /** * Consumes white space characters as string literal from given scanner */ function whiteSpace(scanner: Scanner): WhiteSpace | undefined { const start = scanner.pos; if (scanner.eatWhile(isSpace)) { return { type: 'WhiteSpace', start, end: scanner.pos, value: scanner.substring(start, scanner.pos) }; } } /** * Consumes quote from given scanner */ function quote(scanner: Scanner): Quote | undefined { const ch = scanner.peek(); if (isQuote(ch)) { return { type: 'Quote', single: ch === Chars.SingleQuote, start: scanner.pos++, end: scanner.pos }; } } /** * Consumes bracket from given scanner */ function bracket(scanner: Scanner): Bracket | undefined { const ch = scanner.peek(); const context = bracketType(ch); if (context) { return { type: 'Bracket', open: isOpenBracket(ch), context, start: scanner.pos++, end: scanner.pos }; } } /** * Consumes operator from given scanner */ function operator(scanner: Scanner): Operator | undefined { const op = operatorType(scanner.peek()); if (op) { return { type: 'Operator', operator: op, start: scanner.pos++, end: scanner.pos }; } } /** * Consumes node repeat token from current stream position and returns its * parsed value */ function repeater(scanner: Scanner): Repeater | undefined { const start = scanner.pos; if (scanner.eat(Chars.Asterisk)) { scanner.start = scanner.pos; let count = 1; let implicit = false; if (scanner.eatWhile(isNumber)) { count = Number(scanner.current()); } else { implicit = true; } return { type: 'Repeater', count, value: 0, implicit, start, end: scanner.pos }; } } /** * Consumes repeater placeholder `$#` from given scanner */ function repeaterPlaceholder(scanner: Scanner): RepeaterPlaceholder | undefined { const start = scanner.pos; if (scanner.eat(Chars.Dollar) && scanner.eat(Chars.Hash)) { return { type: 'RepeaterPlaceholder', value: void 0, start, end: scanner.pos }; } scanner.pos = start; } /** * Consumes numbering token like `$` from given scanner state */ function repeaterNumber(scanner: Scanner): RepeaterNumber | undefined { const start = scanner.pos; if (scanner.eatWhile(Chars.Dollar)) { const size = scanner.pos - start; let reverse = false; let base = 1; let parent = 0; if (scanner.eat(Chars.At)) { // Consume numbering modifiers while (scanner.eat(Chars.Climb)) { parent++; } reverse = scanner.eat(Chars.Dash); scanner.start = scanner.pos; if (scanner.eatWhile(isNumber)) { base = Number(scanner.current()); } } scanner.start = start; return { type: 'RepeaterNumber', size, reverse, base, parent, start, end: scanner.pos }; } } function field(scanner: Scanner, ctx: Context): Field | undefined { const start = scanner.pos; // Fields are allowed inside expressions and attributes if ((ctx.expression || ctx.attribute) && scanner.eat(Chars.Dollar) && scanner.eat(Chars.CurlyBracketOpen)) { scanner.start = scanner.pos; let index: number | undefined; let name: string = ''; if (scanner.eatWhile(isNumber)) { // It’s a field index = Number(scanner.current()); name = scanner.eat(Chars.Colon) ? consumePlaceholder(scanner) : ''; } else if (isAlpha(scanner.peek())) { // It’s a variable name = consumePlaceholder(scanner); } if (scanner.eat(Chars.CurlyBracketClose)) { return { type: 'Field', index, name, start, end: scanner.pos }; } throw scanner.error('Expecting }'); } // If we reached here then there’s no valid field here, revert // back to starting position scanner.pos = start; } /** * Consumes a placeholder: value right after `:` in field. Could be empty */ function consumePlaceholder(stream: Scanner): string { const stack: number[] = []; stream.start = stream.pos; while (!stream.eof()) { if (stream.eat(Chars.CurlyBracketOpen)) { stack.push(stream.pos); } else if (stream.eat(Chars.CurlyBracketClose)) { if (!stack.length) { stream.pos--; break; } stack.pop(); } else { stream.pos++; } } if (stack.length) { stream.pos = stack.pop()!; throw stream.error(`Expecting }`); } return stream.current(); } /** * Check if given character code is an operator and it’s allowed in current context */ function isAllowedOperator(ch: number, ctx: Context): boolean { const op = operatorType(ch); if (!op || ctx.quote || ctx.expression) { // No operators inside quoted values or expressions return false; } // Inside attributes, only `equals` is allowed return !ctx.attribute || op === 'equal'; } /** * Check if given character is a space character and is allowed to be consumed * as a space token in current context */ function isAllowedSpace(ch: number, ctx: Context): boolean { return isSpace(ch) && !ctx.expression; } /** * Check if given character can be consumed as repeater in current context */ function isAllowedRepeater(ch: number, ctx: Context): boolean { return ch === Chars.Asterisk && !ctx.attribute && !ctx.expression; } /** * If given character is a bracket, returns it’s type */ function bracketType(ch: number): BracketType | undefined { if (ch === Chars.RoundBracketOpen || ch === Chars.RoundBracketClose) { return 'group'; } if (ch === Chars.SquareBracketOpen || ch === Chars.SquareBracketClose) { return 'attribute'; } if (ch === Chars.CurlyBracketOpen || ch === Chars.CurlyBracketClose) { return 'expression'; } } /** * If given character is an operator, returns it’s type */ function operatorType(ch: number): OperatorType | undefined { return (ch === Chars.Child && 'child') || (ch === Chars.Sibling && 'sibling') || (ch === Chars.Climb && 'climb') || (ch === Chars.Dot && 'class') || (ch === Chars.Hash && 'id') || (ch === Chars.Slash && 'close') || (ch === Chars.Equals && 'equal') || void 0; } /** * Check if given character is an open bracket */ function isOpenBracket(ch: number): boolean { return ch === Chars.CurlyBracketOpen || ch === Chars.SquareBracketOpen || ch === Chars.RoundBracketOpen; } /** * Check if given character is allowed in element name */ function isElementName(ch: number) { return isAlphaNumericWord(ch) || isUmlaut(ch) || ch === Chars.Dash || ch === Chars.Colon || ch === Chars.Excl; } ================================================ FILE: packages/abbreviation/src/tokenizer/tokens.ts ================================================ export type OperatorType = 'child' | 'sibling' | 'climb' | 'class' | 'id' | 'close' | 'equal'; export type BracketType = 'group' | 'attribute' | 'expression'; export type AllTokens = Bracket | Field | Literal | Operator | Quote | Repeater | RepeaterNumber | RepeaterPlaceholder | WhiteSpace; export type NameToken = Literal | RepeaterNumber; export type ValueToken = Literal | Quote | Bracket | Field | RepeaterPlaceholder | RepeaterNumber; export interface Token { type: string; /** Location of token start in source */ start?: number; /** Location of token end in source */ end?: number; } export interface Repeater extends Token { type: 'Repeater'; /** How many times context element should be repeated */ count: number; /** Position of context element in its repeating sequence */ value: number; /** Repeater is implicit, e.g. repeated by the amount of text lines selected by user */ implicit: boolean; } export interface RepeaterNumber extends Token { type: 'RepeaterNumber'; /** Size of repeater content, e.g. the amount consequent numbering characters */ size: number; /** Should output numbering in reverse order? */ reverse: boolean; /** Base value to start numbering from */ base: number; /** Parent offset from which numbering should be used */ parent: number; } export interface RepeaterPlaceholder extends Token { type: 'RepeaterPlaceholder'; /** Value to insert instead of placeholder */ value?: string; } export interface Field extends Token { type: 'Field'; index?: number; name: string; } export interface Operator extends Token { type: 'Operator'; operator: OperatorType; } export interface Bracket extends Token { type: 'Bracket'; open: boolean; context: BracketType; } export interface Quote extends Token { type: 'Quote'; single: boolean; } export interface Literal extends Token { type: 'Literal'; value: string; } export interface WhiteSpace extends Token { type: 'WhiteSpace'; value: string; } ================================================ FILE: packages/abbreviation/src/tokenizer/utils.ts ================================================ import type Scanner from '@emmetio/scanner'; export const enum Chars { /** `{` character */ CurlyBracketOpen = 123, /** `}` character */ CurlyBracketClose = 125, /** `\\` character */ Escape = 92, /** `=` character */ Equals = 61, /** `[` character */ SquareBracketOpen = 91, /** `]` character */ SquareBracketClose = 93, /** `*` character */ Asterisk = 42, /** `#` character */ Hash = 35, /** `$` character */ Dollar = 36, /** `-` character */ Dash = 45, /** `.` character */ Dot = 46, /** `/` character */ Slash = 47, /** `:` character */ Colon = 58, /** `!` character */ Excl = 33, /** `@` character */ At = 64, /** `_` character */ Underscore = 95, /** `(` character */ RoundBracketOpen = 40, /** `)` character */ RoundBracketClose = 41, /** `+` character */ Sibling = 43, /** `>` character */ Child = 62, /** `^` character */ Climb = 94, /** `'` character */ SingleQuote = 39, /** `""` character */ DoubleQuote = 34, } /** * If consumes escape character, sets current stream range to escaped value */ export function escaped(scanner: Scanner): boolean { if (scanner.eat(Chars.Escape)) { scanner.start = scanner.pos; if (!scanner.eof()) { scanner.pos++; } return true; } return false; } ================================================ FILE: packages/abbreviation/src/types.ts ================================================ import type { Field, Repeater } from './tokenizer'; export interface ParserOptions { /** Text strings to insert into implicitly repeated elements */ text?: string | string[]; /** Variable values for `${var}` tokens */ variables?: { [name: string]: string }; /** Max amount of repeated elements in abbreviation */ maxRepeat?: number; /** Enabled JSX parsing mode */ jsx?: boolean; /** Enable inserting text into href attribute of links */ href?: boolean; } export interface ConvertState { inserted: boolean; text?: string | string[]; cleanText?: string | string[]; repeatGuard: number; /** Context repeaters, e.g. all actual repeaters from parent */ repeaters: Repeater[]; getText(pos?: number): string; getVariable(name: string): string; } export type Value = string | Field; export type AttributeType = 'raw' | 'singleQuote' | 'doubleQuote' | 'expression'; export interface Abbreviation { type: 'Abbreviation'; children: AbbreviationNode[]; } export interface AbbreviationNode { type: 'AbbreviationNode'; name?: string; value?: Value[]; repeat?: Repeater; attributes?: AbbreviationAttribute[]; children: AbbreviationNode[]; /** Indicates current element is self-closing, e.g. should not contain closing pair */ selfClosing?: boolean; } export interface AbbreviationAttribute { name?: string; value?: Value[]; /** Indicates type of value stored in `.value` property */ valueType: AttributeType; /** Attribute is boolean (e.g.name equals value) */ boolean?: boolean; /** Attribute is implied (e.g.must be outputted only if contains non-null value) */ implied?: boolean; /** * Internal property that indicates that given attribute was specified * more than once as a shorthand. E.g. `..` is a multiple `class` attribute */ multiple?: boolean; } ================================================ FILE: packages/abbreviation/test/assets/stringify-node.ts ================================================ import type { Abbreviation, AbbreviationNode, Value, AbbreviationAttribute } from '../../src'; export default function stringify(abbr: Abbreviation): string { return abbr.children.map(elem).join(''); } function elem(node: AbbreviationNode): string { const name = node.name || '?'; const attributes = node.attributes ? node.attributes.map(attr => ' ' + attribute(attr)) : ''; const value = node.value ? stringifyValue(node.value) : ''; const repeat = node.repeat ? `*${node.repeat.count}@${node.repeat.value}` : ''; return node.selfClosing && !node.value && !node.children.length ? `<${name}${repeat}${attributes} />` : `<${name}${repeat}${attributes}>${value}${node.children.map(elem).join('')}`; } function attribute(attr: AbbreviationAttribute): string { const name = attr.name || '?'; const value = attr.value ? `"${stringifyValue(attr.value)}"` : null; return value != null ? `${name}=${value}` : name; } function stringifyValue(items: Value[]): string { return items.map(item => typeof item === 'string' ? item : (item.name ? `\${${item.index!}:${item.name}}` : `\${${item.index!}}`)).join(''); } ================================================ FILE: packages/abbreviation/test/assets/stringify.ts ================================================ import type { AllTokens, Repeater, RepeaterNumber, Field, OperatorType, Operator, Bracket, Quote, Literal } from '../../src/tokenizer'; import type { TokenElement, TokenAttribute, TokenGroup, TokenStatement } from '../../src/parser'; type TokenVisitor = (token: T) => string; interface TokenVisitorMap { [nodeType: string]: TokenVisitor; } const operatorMap: { [name in OperatorType]: string } = { id: '#', class: '.', equal: '=', child: '>', climb: '^', sibling: '+', close: '/' }; const tokenVisitors = { Repeater(token: Repeater) { return `*${token.implicit ? '' : token.count}`; }, RepeaterNumber(token: RepeaterNumber) { return '$'.repeat(token.size); }, RepeaterPlaceholder() { return '$#'; }, Field(node: Field) { const index = node.index != null ? String(node.index) : ''; const sep = index && node.name ? ':' : ''; return `\${${index}${sep}${node.name}}`; }, Operator(node: Operator) { return operatorMap[node.operator]; }, Bracket(node: Bracket) { if (node.context === 'attribute') { return node.open ? '[' : ']'; } if (node.context === 'expression') { return node.open ? '{' : '}'; } if (node.context === 'group') { return node.open ? '(' : ')'; } return '?'; }, Quote(node: Quote) { return node.single ? '\'' : '"'; }, Literal(node: Literal) { return node.value; }, WhiteSpace() { return ' '; } } as TokenVisitorMap; function statement(node: TokenElement | TokenGroup): string { if (node.type === 'TokenGroup') { return `(${content(node)})${node.repeat ? str(node.repeat) : ''}`; } return element(node); } function element(node: TokenElement): string { const name = node.name ? tokenList(node.name) : '?'; const repeat = node.repeat ? str(node.repeat) : ''; const attributes = node.attributes ? node.attributes.map(attribute).join(' ') : ''; if (node.selfClose && !node.elements.length) { return `<${name}${repeat}${attributes ? ' ' + attributes : ''} />`; } return `<${name}${repeat}${attributes ? ' ' + attributes : ''}>${tokenList(node.value)}${content(node)}`; } function attribute(attr: TokenAttribute): string { const name = tokenList(attr.name) || '?'; return attr.value ? `${name}=${tokenList(attr.value)}` : name; } function tokenList(tokens?: AllTokens[]): string { return tokens ? tokens.map(str).join('') : ''; } function str(token: AllTokens): string { if (token.type in tokenVisitors) { return tokenVisitors[token.type](token); } throw new Error(`Unknown token "${token.type}"`); } function content(node: TokenStatement): string { return node.elements.map(statement).join(''); } export default function stringify(abbr: TokenGroup): string { return content(abbr); } ================================================ FILE: packages/abbreviation/test/convert.ts ================================================ import { describe, it } from 'node:test'; import { equal } from 'node:assert'; import parser, { type ParserOptions } from '../src'; import stringify from './assets/stringify-node'; function parse(abbr: string, options?: ParserOptions) { return stringify(parser(abbr, options)); } describe('Convert token abbreviations', () => { it('basic', () => { equal(parse('input[value="text$"]*2'), ''); equal(parse('ul>li.item$*3'), '
'); equal(parse('ul>li.item$*', { text: ['foo$', 'bar$'] }), '
    foo$bar$
'); equal(parse('ul>li[class=$#]{item $}*', { text: ['foo$', 'bar$'] }), '
    item 1item 2
'); equal(parse('ul>li.item$*'), '
'); equal(parse('ul>li.item$*', { text: ['foo.bar', 'hello.world'] }), '
    foo.barhello.world
'); equal(parse('p{hi}', { text: ['hello'] }), '

hihello

'); equal(parse('p*{hi}', { text: ['1', '2'] }), 'hi1

hi2

'); equal(parse('div>p+p{hi}', { text: ['hello'] }), '

hihello

'); equal(parse('html[lang=${lang}]'), ''); equal(parse('html.one.two'), ''); equal(parse('html.one[two=three]'), ''); equal(parse('div{[}+a{}'), '
[
'); }); it('unroll', () => { equal(parse('a>(b>c)+d'), ''); equal(parse('(a>b)+(c>d)'), ''); equal(parse('a>((b>c)(d>e))f'), ''); equal(parse('a>((((b>c))))+d'), ''); equal(parse('a>(((b>c))*4)+d'), ''); equal(parse('(div>dl>(dt+dd)*2)'), '
'); equal(parse('a*2>b*3'), ''); equal(parse('a>(b+c)*2'), ''); equal(parse('a>(b+c)*2+(d+e)*2'), ''); // Should move `
` as sibling of `{foo}` equal(parse('p>{foo}>div'), '

foo

'); equal(parse('p>{foo ${0}}>div'), '

foo ${0}

'); }); it('limit unroll', () => { // Limit amount of repeated elements equal(parse('a*10', { maxRepeat: 5 }), ''); equal(parse('a*10'), ''); equal(parse('a*3>b*3', { maxRepeat: 5 }), ''); }); it('parent repeater', () => { equal(parse('a$*2>b$*3/'), ''); equal(parse('a$*2>b$@^*3/'), ''); }); it('href', () => { equal(parse('a', { href: true, text: 'https://www.google.it' }), 'https://www.google.it'); equal(parse('a', { href: true, text: 'www.google.it' }), 'www.google.it'); equal(parse('a', { href: true, text: 'google.it' }), 'google.it'); equal(parse('a', { href: true, text: 'test here' }), 'test here'); equal(parse('a', { href: true, text: 'test@domain.com' }), 'test@domain.com'); equal(parse('a', { href: true, text: 'test here test@domain.com' }), 'test here test@domain.com'); equal(parse('a', { href: true, text: 'test here www.domain.com' }), 'test here www.domain.com'); equal(parse('a[href=]', { href: true, text: 'https://www.google.it' }), 'https://www.google.it'); equal(parse('a[href=]', { href: true, text: 'www.google.it' }), 'www.google.it'); equal(parse('a[href=]', { href: true, text: 'google.it' }), 'google.it'); equal(parse('a[href=]', { href: true, text: 'test here' }), 'test here'); equal(parse('a[href=]', { href: true, text: 'test@domain.com' }), 'test@domain.com'); equal(parse('a[href=]', { href: true, text: 'test here test@domain.com' }), 'test here test@domain.com'); equal(parse('a[href=]', { href: true, text: 'test here www.domain.com' }), 'test here www.domain.com'); equal(parse('a[class=here]', { href: true, text: 'test@domain.com' }), 'test@domain.com'); equal(parse('a.here', { href: true, text: 'www.domain.com' }), 'www.domain.com'); equal(parse('a[class=here]', { href: true, text: 'test here test@domain.com' }), 'test here test@domain.com'); equal(parse('a.here', { href: true, text: 'test here www.domain.com' }), 'test here www.domain.com'); equal(parse('a[href="www.google.it"]', { href: false, text: 'test' }), 'test'); equal(parse('a[href="www.example.com"]', { href: true, text: 'www.google.it' }), 'www.google.it'); }); it('wrap basic', () => { equal(parse('p', { text: 'test' }), '

test

'); equal(parse('p', { text: ['test'] }), '

test

'); equal(parse('p', { text: ['test1', 'test2'] }), '

test1\ntest2

'); equal(parse('p', { text: ['test1', '', 'test2'] }), '

test1\n\ntest2

'); equal(parse('p*', { text: ['test1', 'test2'] }), 'test1

test2

'); equal(parse('p*', { text: ['test1', '', 'test2'] }), 'test1

test2

'); }) }); ================================================ FILE: packages/abbreviation/test/parser.ts ================================================ import { describe, it } from 'node:test'; import { strictEqual as equal, throws } from 'node:assert'; import parser from '../src/parser'; import tokenizer from '../src/tokenizer'; import stringify from './assets/stringify'; import type { ParserOptions } from '../src'; const parse = (abbr: string, options?: ParserOptions) => parser(tokenizer(abbr), options); const str = (abbr: string, options?: ParserOptions) => stringify(parse(abbr, options)); describe('Parser', () => { it('basic abbreviations', () => { equal(str('p'), '

'); equal(str('p{text}'), '

text

'); equal(str('h$'), ''); equal(str('.nav'), ''); equal(str('div.width1\\/2'), '
'); equal(str('#sample*3'), ''); // ulmauts, https://github.com/emmetio/emmet/issues/439 equal(str('DatenSätze^'), '') // https://github.com/emmetio/emmet/issues/562 equal(str('li[repeat.for="todo of todoList"]'), '
  • ', 'Dots in attribute names'); equal(str('a>b'), ''); equal(str('a+b'), ''); equal(str('a+b>c+d'), ''); equal(str('a>b>c+e'), ''); equal(str('a>b>c^d'), ''); equal(str('a>b>c^^^^d'), ''); equal(str('a:b>c'), ''); equal(str('ul.nav[title="foo"]'), ''); }); it('groups', () => { equal(str('a>(b>c)+d'), '()'); equal(str('(a>b)+(c>d)'), '()()'); equal(str('a>((b>c)(d>e))f'), '(()())'); equal(str('a>((((b>c))))+d'), '(((())))'); equal(str('a>(((b>c))*4)+d'), '((())*4)'); equal(str('(div>dl>(dt+dd)*2)'), '(
    (
    )*2
    )'); equal(str('a>()'), '()'); }); it('attributes', () => { equal(str('[].foo'), ''); equal(str('[a]'), ''); equal(str('[a b c [d]]'), ''); equal(str('[a=b]'), ''); equal(str('[a=b c= d=e]'), ''); equal(str('[a=b.c d=тест]'), ''); equal(str('[[a]=b (c)=d]'), ''); // Quoted attribute values equal(str('[a="b"]'), ''); equal(str('[a="b" c=\'d\' e=""]'), ''); equal(str('[[a]="b" (c)=\'d\']'), ''); // Mixed quoted equal(str('[a="foo\'bar" b=\'foo"bar\' c="foo\\\"bar"]'), ''); // Boolean & implied attributes equal(str('[a. b.]'), ''); equal(str('[!a !b.]'), ''); // Default values equal(str('["a.b"]'), ''); equal(str('[\'a.b\' "c=d" foo=bar "./test.html"]'), ''); // Expressions as values equal(str('[foo={1 + 2} bar={fn(1, "foo")}]'), ''); // Tabstops as unquoted values equal(str('[name=${1} value=${2:test}]'), ''); }); it('malformed attributes', () => { equal(str('[a'), ''); equal(str('[a={foo]'), ''); throws(() => str('[a="foo]'), /Unclosed quote/); throws(() => str('[a=b=c]'), /Unexpected "Operator" token/); }); it('elements', () => { equal(str('div'), '
    '); equal(str('div.foo'), '
    '); equal(str('div#foo'), '
    '); equal(str('div#foo.bar'), '
    '); equal(str('div.foo#bar'), '
    '); equal(str('div.foo.bar.baz'), '
    '); equal(str('.foo'), ''); equal(str('#foo'), ''); equal(str('.foo_bar'), ''); equal(str('#foo.bar'), ''); // Attribute shorthands equal(str('.'), ''); equal(str('#'), ''); equal(str('#.'), ''); equal(str('.#.'), ''); equal(str('.a..'), ''); // Elements with attributes equal(str('div[foo=bar]'), '
    '); equal(str('div.a[b=c]'), '
    '); equal(str('div.mr-\\[500\\][a=b]'), '
    '); equal(str('div[b=c].a'), '
    '); equal(str('div[a=b][c="d"]'), '
    '); equal(str('[b=c]'), ''); equal(str('.a\\[b-c\\]'), ''); equal(str('."a:[b-c]"'), ''); equal(str('."peer-[.is-dirty]:peer-required:block"'), ''); equal(str('."mr-50"."peer-[:nth-of-type(3)_&]:block"'), ''); equal(str('.a[b=c]'), ''); equal(str('[b=c].a#d'), ''); equal(str('[b=c]a'), '', 'Do not consume node name after attribute set'); // Element with text equal(str('div{foo}'), '
    foo
    '); equal(str('{foo}'), 'foo'); // Mixed equal(str('div.foo{bar}'), '
    bar
    '); equal(str('.foo{bar}#baz'), 'bar'); equal(str('.foo[b=c]{bar}'), 'bar'); // Repeated element equal(str('div.foo*3'), '
    '); equal(str('.foo*'), ''); equal(str('.a[b=c]*10'), ''); equal(str('.a*10[b=c]'), ''); equal(str('.a*10{text}'), 'text'); // Self-closing element equal(str('div/'), '
    '); equal(str('.foo/'), ''); equal(str('.foo[bar]/'), ''); equal(str('.foo/*3'), ''); equal(str('.foo*3/'), ''); throws(() => parse('/'), /Unexpected character/); }); it('JSX', () => { const opt = { jsx: true }; equal(str('foo.bar', opt), ''); equal(str('Foo.bar', opt), ''); equal(str('Foo.Bar', opt), ''); equal(str('Foo.', opt), ''); equal(str('Foo.Bar.baz', opt), ''); equal(str('Foo.Bar.Baz', opt), ''); equal(str('.{theme.class}', opt), ''); equal(str('#{id}', opt), ''); equal(str('Foo.{theme.class}', opt), ''); }); it('errors', () => { throws(() => parse('str?'), /Unexpected character at 4/); throws(() => parse('foo,bar'), /Unexpected character at 4/); equal(str('foo\\,bar'), ''); equal(str('foo\\'), ''); }); it('missing braces', () => { // Do not throw errors on missing closing braces equal(str('div[title="test"'), '
    '); equal(str('div(foo'), '
    ()'); equal(str('div{foo'), '
    foo
    '); }); }); ================================================ FILE: packages/abbreviation/test/tokenizer.ts ================================================ import { describe, it } from 'node:test'; import { deepStrictEqual } from 'node:assert'; import tokenize from '../src/tokenizer'; describe('Tokenizer', () => { it('basic abbreviations', () => { deepStrictEqual(tokenize('ul>li'), [ { type: 'Literal', value: 'ul', start: 0, end: 2 }, { type: 'Operator', operator: 'child', start: 2, end: 3 }, { type: 'Literal', value: 'li', start: 3, end: 5 } ]); deepStrictEqual(tokenize('ul[title="foo+bar\'str\'" (attr)=bar]{(some > text)}'), [ { type: 'Literal', value: 'ul', start: 0, end: 2 }, { type: 'Bracket', open: true, context: 'attribute', start: 2, end: 3 }, { type: 'Literal', value: 'title', start: 3, end: 8 }, { type: 'Operator', operator: 'equal', start: 8, end: 9 }, { type: 'Quote', single: false, start: 9, end: 10 }, { type: 'Literal', value: 'foo+bar\'str\'', start: 10, end: 22 }, { type: 'Quote', single: false, start: 22, end: 23 }, { type: 'WhiteSpace', start: 23, end: 24, value: ' ' }, { type: 'Bracket', open: true, context: 'group', start: 24, end: 25 }, { type: 'Literal', value: 'attr', start: 25, end: 29 }, { type: 'Bracket', open: false, context: 'group', start: 29, end: 30 }, { type: 'Operator', operator: 'equal', start: 30, end: 31 }, { type: 'Literal', value: 'bar', start: 31, end: 34 }, { type: 'Bracket', open: false, context: 'attribute', start: 34, end: 35 }, { type: 'Bracket', open: true, context: 'expression', start: 35, end: 36 }, { type: 'Literal', value: '(some > text)', start: 36, end: 49 }, { type: 'Bracket', open: false, context: 'expression', start: 49, end: 50 } ]); deepStrictEqual(tokenize('h${some${1:field placeholder}}'), [ { type: 'Literal', value: 'h', start: 0, end: 1 }, { type: 'RepeaterNumber', size: 1, parent: 0, reverse: false, base: 1, start: 1, end: 2 }, { type: 'Bracket', open: true, context: 'expression', start: 2, end: 3 }, { type: 'Literal', value: 'some', start: 3, end: 7 }, { type: 'Field', index: 1, name: 'field placeholder', start: 7, end: 29 }, { type: 'Bracket', open: false, context: 'expression', start: 29, end: 30 } ]); deepStrictEqual(tokenize('div{[}+a{}'), [ { type: 'Literal', value: 'div', start: 0, end: 3 }, { type: 'Bracket', open: true, context: 'expression', start: 3, end: 4 }, { type: 'Literal', value: '[', start: 4, end: 5 }, { type: 'Bracket', open: false, context: 'expression', start: 5, end: 6 }, { type: 'Operator', operator: 'sibling', start: 6, end: 7 }, { type: 'Literal', value: 'a', start: 7, end: 8 }, { type: 'Bracket', open: true, context: 'expression', start: 8, end: 9 }, { type: 'Bracket', open: false, context: 'expression', start: 9, end: 10 } ]); }); it('repeater', () => { deepStrictEqual(tokenize('#sample*3'), [ { type: 'Operator', operator: 'id', start: 0, end: 1 }, { type: 'Literal', value: 'sample', start: 1, end: 7 }, { type: 'Repeater', count: 3, value: 0, implicit: false, start: 7, end: 9 } ]); deepStrictEqual(tokenize('div[foo*3]'), [ { type: 'Literal', value: 'div', start: 0, end: 3 }, { type: 'Bracket', open: true, context: 'attribute', start: 3, end: 4 }, { type: 'Literal', value: 'foo*3', start: 4, end: 9 }, { type: 'Bracket', open: false, context: 'attribute', start: 9, end: 10 } ]); deepStrictEqual(tokenize('({a*2})*3'), [ { type: 'Bracket', open: true, context: 'group', start: 0, end: 1 }, { type: 'Bracket', open: true, context: 'expression', start: 1, end: 2 }, { type: 'Literal', value: 'a*2', start: 2, end: 5 }, { type: 'Bracket', open: false, context: 'expression', start: 5, end: 6 }, { type: 'Bracket', open: false, context: 'group', start: 6, end: 7 }, { type: 'Repeater', count: 3, value: 0, implicit: false, start: 7, end: 9 } ]); }); }); ================================================ FILE: packages/abbreviation/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "outDir": "./dist" }, "include": ["src/**/*.ts"] } ================================================ FILE: packages/css-abbreviation/.npmignore ================================================ npm-debug.log* node_modules jspm_packages .npm /.* /*.* /test /src ================================================ FILE: packages/css-abbreviation/LICENSE ================================================ MIT License Copyright (c) 2020 Sergey Chikuyonok Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/css-abbreviation/README.md ================================================ # Emmet stylesheet abbreviation parser Parses given Emmet *stylesheet* abbreviation into AST. Parsing is performed in two steps: first it tokenizes given abbreviation (useful for syntax highlighting in editors) and then tokens are analyzed and converted into AST nodes as plain, JSON-serializable objects. Unlike in [markup abbreviations](/packages/abbreviation), elements in stylesheet abbreviations cannot be nested and contain attributes, but allow embedded values in element names. ## Usage You can install it via npm: ```bash npm install @emmetio/css-abbreviation ``` Then add it into your project: ```js import parse from '@emmetio/css-abbreviation'; const props = parse('p10+poa'); /* [{ name: 'p', value: [{ type: 'CSSValue', value: [...] }], important: false }, { name: 'poa', value: [], important: false }] */ ``` The returned result is an array of `CSSProperty` items: a node with name and values. ## Abbreviation syntax Emmet stylesheet abbreviation element may start with name and followed by values, optionally chained with `-` delimiter. In most cases, actual CSS properties doesn’t have numbers in their names (or at least they are not used in abbreviation shortcuts) so a number right after alpha characters is considered as *embedded value*, as well as colors starting with `#` character: `p10`, `bg#fc0` etc. If implicit name/value boundary can’t be identified, you should use `-` as value separator: `m-a`, `p10-20` etc. ### Operators Since CSS properties can’t be nested, the only available operator is `+`. ================================================ FILE: packages/css-abbreviation/package.json ================================================ { "name": "@emmetio/css-abbreviation", "version": "2.1.8", "description": "Parses Emmet CSS abbreviation into AST tree", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/index.d.ts", "type": "module", "exports": { "import": "./dist/index.js", "require": "./dist/index.cjs" }, "scripts": { "test": "tsx --test ./test/*.ts", "build": "rollup -c", "watch": "rollup -wc", "clean": "rm -rf ./dist", "prepublishOnly": "npm test &&npm run clean && npm run build" }, "repository": { "type": "git", "url": "git+https://github.com/emmetio/emmet.git" }, "keywords": [], "author": "Sergey Chikuyonok ", "license": "MIT", "bugs": { "url": "https://github.com/emmetio/emmet/issues" }, "homepage": "https://github.com/emmetio/emmet#readme", "dependencies": { "@emmetio/scanner": "^1.0.4" }, "directories": { "test": "test" } } ================================================ FILE: packages/css-abbreviation/rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; /** @type {import('rollup').RollupOptions} */ export default { input: './src/index.ts', external: ['@emmetio/scanner'], plugins: [typescript()], output: [{ format: 'cjs', file: 'dist/index.cjs', sourcemap: true, exports: 'named', }, { format: 'es', file: 'dist/index.js', sourcemap: true, }] }; ================================================ FILE: packages/css-abbreviation/src/index.ts ================================================ import { ScannerError } from '@emmetio/scanner'; import tokenize, { getToken, type AllTokens } from './tokenizer'; import parser, { type CSSProperty, type ParseOptions } from './parser'; export { tokenize, getToken, parser }; export * from './tokenizer'; export type { CSSProperty, CSSValue, ParseOptions, FunctionCall, Value } from './parser'; export type CSSAbbreviation = CSSProperty[]; /** * Parses given abbreviation into property set */ export default function parse(abbr: string | AllTokens[], options?: ParseOptions): CSSAbbreviation { try { const tokens = typeof abbr === 'string' ? tokenize(abbr, options && options.value) : abbr; return parser(tokens, options); } catch (err) { if (err instanceof ScannerError && typeof abbr === 'string') { err.message += `\n${abbr}\n${'-'.repeat(err.pos)}^`; } throw err; } } ================================================ FILE: packages/css-abbreviation/src/parser/TokenScanner.ts ================================================ import type { AllTokens } from '../tokenizer/index.js'; export interface TokenScanner { tokens: AllTokens[]; start: number; pos: number; size: number; } type TestFn = (token?: AllTokens) => boolean; export default function tokenScanner(tokens: AllTokens[]): TokenScanner { return { tokens, start: 0, pos: 0, size: tokens.length }; } export function peek(scanner: TokenScanner): AllTokens | undefined { return scanner.tokens[scanner.pos]; } export function next(scanner: TokenScanner): AllTokens | undefined { return scanner.tokens[scanner.pos++]; } export function slice(scanner: TokenScanner, from = scanner.start, to = scanner.pos): AllTokens[] { return scanner.tokens.slice(from, to); } export function readable(scanner: TokenScanner): boolean { return scanner.pos < scanner.size; } export function consume(scanner: TokenScanner, test: TestFn): boolean { if (test(peek(scanner))) { scanner.pos++; return true; } return false; } export function error(scanner: TokenScanner, message: string, token = peek(scanner)) { if (token && token.start != null) { message += ` at ${token.start}`; } const err = new Error(message); err['pos'] = token && token.start; return err; } export function consumeWhile(scanner: TokenScanner, test: TestFn): boolean { const start = scanner.pos; while (consume(scanner, test)) { /* */ } return scanner.pos !== start; } ================================================ FILE: packages/css-abbreviation/src/parser/index.ts ================================================ import { OperatorType } from '../tokenizer/tokens.js'; import type { StringValue, NumberValue, ColorValue, Literal, AllTokens, Bracket, WhiteSpace, Operator, Field, CustomProperty } from '../tokenizer/tokens.js'; import tokenScanner, { type TokenScanner, readable, peek, consume, error } from './TokenScanner.js'; export type Value = StringValue | NumberValue | ColorValue | Literal | FunctionCall | Field | CustomProperty; export interface FunctionCall { type: 'FunctionCall'; name: string; arguments: CSSValue[]; } export interface CSSValue { type: 'CSSValue'; value: Value[]; } export interface CSSProperty { name?: string; value: CSSValue[]; important: boolean; /** Snippet matched with current property */ snippet?: any; } export interface ParseOptions { /** Consumes given abbreviation tokens as value */ value?: boolean; } export default function parser(tokens: AllTokens[], options: ParseOptions = {}): CSSProperty[] { const scanner = tokenScanner(tokens); const result: CSSProperty[] = []; let property: CSSProperty | undefined; while (readable(scanner)) { if (property = consumeProperty(scanner, options)) { result.push(property); } else if (!consume(scanner, isSiblingOperator)) { throw error(scanner, 'Unexpected token'); } } return result; } /** * Consumes single CSS property */ function consumeProperty(scanner: TokenScanner, options: ParseOptions): CSSProperty | undefined { let name: string | undefined; let important = false; let valueFragment: CSSValue | undefined; const value: CSSValue[] = []; const token = peek(scanner)!; const valueMode = !!options.value; if (!valueMode && isLiteral(token) && !isFunctionStart(scanner)) { scanner.pos++; name = token.value; // Consume any following value delimiter after property name consume(scanner, isValueDelimiter); } // Skip whitespace right after property name, if any if (valueMode) { consume(scanner, isWhiteSpace); } while (readable(scanner)) { if (consume(scanner, isImportant)) { important = true; } else if (valueFragment = consumeValue(scanner, valueMode)) { value.push(valueFragment); } else if (!consume(scanner, isFragmentDelimiter)) { break; } } if (name || value.length || important) { return { name, value, important }; } } /** * Consumes single value fragment, e.g. all value tokens before comma */ function consumeValue(scanner: TokenScanner, inArgument: boolean): CSSValue | undefined { const result: Value[] = []; let token: AllTokens | undefined; let args: CSSValue[] | undefined; while (readable(scanner)) { token = peek(scanner)!; if (isValue(token)) { scanner.pos++; if (isLiteral(token) && (args = consumeArguments(scanner))) { result.push({ type: 'FunctionCall', name: token.value, arguments: args } as FunctionCall); } else { result.push(token); } } else if (isValueDelimiter(token) || (inArgument && isWhiteSpace(token))) { scanner.pos++; } else { break; } } return result.length ? { type: 'CSSValue', value: result } : void 0; } function consumeArguments(scanner: TokenScanner): CSSValue[] | undefined { const start = scanner.pos; if (consume(scanner, isOpenBracket)) { const args: CSSValue[] = []; let value: CSSValue | undefined; while (readable(scanner) && !consume(scanner, isCloseBracket)) { if (value = consumeValue(scanner, true)) { args.push(value); } else if (!consume(scanner, isWhiteSpace) && !consume(scanner, isArgumentDelimiter)) { throw error(scanner, 'Unexpected token'); } } scanner.start = start; return args; } } function isLiteral(token: AllTokens): token is Literal { return token && token.type === 'Literal'; } function isBracket(token: AllTokens, open?: boolean): token is Bracket { return token && token.type === 'Bracket' && (open == null || token.open === open); } function isOpenBracket(token: AllTokens) { return isBracket(token, true); } function isCloseBracket(token: AllTokens) { return isBracket(token, false); } function isWhiteSpace(token: AllTokens): token is WhiteSpace { return token && token.type === 'WhiteSpace'; } function isOperator(token: AllTokens, operator?: OperatorType): token is Operator { return token && token.type === 'Operator' && (!operator || token.operator === operator); } function isSiblingOperator(token: AllTokens) { return isOperator(token, OperatorType.Sibling); } function isArgumentDelimiter(token: AllTokens) { return isOperator(token, OperatorType.ArgumentDelimiter); } function isFragmentDelimiter(token: AllTokens) { return isArgumentDelimiter(token); } function isImportant(token: AllTokens) { return isOperator(token, OperatorType.Important); } function isValue(token: AllTokens): token is StringValue | NumberValue | ColorValue | Literal { return token.type === 'StringValue' || token.type === 'ColorValue' || token.type === 'NumberValue' || token.type === 'Literal' || token.type === 'Field' || token.type === 'CustomProperty'; } function isValueDelimiter(token: AllTokens): boolean { return isOperator(token, OperatorType.PropertyDelimiter) || isOperator(token, OperatorType.ValueDelimiter); } function isFunctionStart(scanner: TokenScanner): boolean { const t1 = scanner.tokens[scanner.pos]; const t2 = scanner.tokens[scanner.pos + 1]; return t1 && t2 && isLiteral(t1) && t2.type === 'Bracket'; } ================================================ FILE: packages/css-abbreviation/src/tokenizer/index.ts ================================================ import { default as Scanner, isAlphaWord, isAlpha, isNumber, isAlphaNumericWord, isSpace, isQuote } from '@emmetio/scanner'; import { OperatorType } from './tokens'; import type { AllTokens, Literal, NumberValue, ColorValue, WhiteSpace, Operator, Bracket, StringValue, Field, CustomProperty } from './tokens'; import { Chars } from './utils'; export * from './tokens'; export default function tokenize(abbr: string, isValue?: boolean): AllTokens[] { let brackets = 0; let token: AllTokens | undefined; const scanner = new Scanner(abbr); const tokens: AllTokens[] = []; while (!scanner.eof()) { token = getToken(scanner, brackets === 0 && !isValue); if (!token) { throw scanner.error('Unexpected character'); } if (token.type === 'Bracket') { if (!brackets && token.open) { mergeTokens(scanner, tokens); } brackets += token.open ? 1 : -1; if (brackets < 0) { throw scanner.error('Unexpected bracket', token.start); } } tokens.push(token); // Forcibly consume next operator after unit-less numeric value or color: // next dash `-` must be used as value delimiter if (shouldConsumeDashAfter(token) && (token = operator(scanner))) { tokens.push(token); } } return tokens; } /** * Returns next token from given scanner, if possible */ export function getToken(scanner: Scanner, short?: boolean) { return field(scanner) || customProperty(scanner) || numberValue(scanner) || colorValue(scanner) || stringValue(scanner) || bracket(scanner) || operator(scanner) || whiteSpace(scanner) || literal(scanner, short); } function field(scanner: Scanner): Field | undefined { const start = scanner.pos; if (scanner.eat(Chars.Dollar) && scanner.eat(Chars.CurlyBracketOpen)) { scanner.start = scanner.pos; let index: number | undefined; let name: string = ''; if (scanner.eatWhile(isNumber)) { // It’s a field index = Number(scanner.current()); name = scanner.eat(Chars.Colon) ? consumePlaceholder(scanner) : ''; } else if (isAlpha(scanner.peek())) { // It’s a variable name = consumePlaceholder(scanner); } if (scanner.eat(Chars.CurlyBracketClose)) { return { type: 'Field', index, name, start, end: scanner.pos }; } throw scanner.error('Expecting }'); } // If we reached here then there’s no valid field here, revert // back to starting position scanner.pos = start; } /** * Consumes a placeholder: value right after `:` in field. Could be empty */ function consumePlaceholder(stream: Scanner): string { const stack: number[] = []; stream.start = stream.pos; while (!stream.eof()) { if (stream.eat(Chars.CurlyBracketOpen)) { stack.push(stream.pos); } else if (stream.eat(Chars.CurlyBracketClose)) { if (!stack.length) { stream.pos--; break; } stack.pop(); } else { stream.pos++; } } if (stack.length) { stream.pos = stack.pop()!; throw stream.error(`Expecting }`); } return stream.current(); } /** * Consumes literal from given scanner * @param short Use short notation for consuming value. * The difference between “short” and “full” notation is that first one uses * alpha characters only and used for extracting keywords from abbreviation, * while “full” notation also supports numbers and dashes */ function literal(scanner: Scanner, short?: boolean): Literal | undefined { const start = scanner.pos; if (scanner.eat(isIdentPrefix)) { // SCSS or LESS variable // NB a bit dirty hack: if abbreviation starts with identifier prefix, // consume alpha characters only to allow embedded variables scanner.eatWhile(start ? isKeyword : isLiteral); } else if (scanner.eat(isAlphaWord)) { scanner.eatWhile(short ? isLiteral : isKeyword); } else { // Allow dots only at the beginning of literal scanner.eat(Chars.Dot); scanner.eatWhile(isLiteral); } if (start !== scanner.pos) { scanner.start = start; return createLiteral(scanner, scanner.start = start); } } function createLiteral(scanner: Scanner, start = scanner.start, end = scanner.pos): Literal { return { type: 'Literal', value: scanner.substring(start, end), start, end }; } /** * Consumes numeric CSS value (number with optional unit) from current stream, * if possible */ function numberValue(scanner: Scanner): NumberValue | undefined { const start = scanner.pos; if (consumeNumber(scanner)) { scanner.start = start; const rawValue = scanner.current(); // eat unit, which can be a % or alpha word scanner.start = scanner.pos; scanner.eat(Chars.Percent) || scanner.eatWhile(isAlphaWord); return { type: 'NumberValue', value: Number(rawValue), rawValue, unit: scanner.current(), start, end: scanner.pos }; } } /** * Consumes quoted string value from given scanner */ function stringValue(scanner: Scanner): StringValue | undefined { const ch = scanner.peek(); const start = scanner.pos; let finished = false; if (isQuote(ch)) { scanner.pos++; while (!scanner.eof()) { // Do not throw error on malformed string if (scanner.eat(ch)) { finished = true; break; } else { scanner.pos++; } } scanner.start = start; return { type: 'StringValue', value: scanner.substring(start + 1, scanner.pos - (finished ? 1 : 0)), quote: ch === Chars.SingleQuote ? 'single' : 'double', start, end: scanner.pos }; } } /** * Consumes a color token from given string */ function colorValue(scanner: Scanner): ColorValue | Literal | undefined { // supported color variations: // #abc → #aabbccc // #0 → #000000 // #fff.5 → rgba(255, 255, 255, 0.5) // #t → transparent const start = scanner.pos; if (scanner.eat(Chars.Hash)) { const valueStart = scanner.pos; let color = ''; let alpha = ''; if (scanner.eatWhile(isHex)) { color = scanner.substring(valueStart, scanner.pos); alpha = colorAlpha(scanner); } else if (scanner.eat(Chars.Transparent)) { color = '0'; alpha = colorAlpha(scanner) || '0'; } else { alpha = colorAlpha(scanner); } if (color || alpha || scanner.eof()) { const { r, g, b, a } = parseColor(color, alpha); return { type: 'ColorValue', r, g, b, a, raw: scanner.substring(start + 1, scanner.pos), start, end: scanner.pos }; } else { // Consumed # but no actual value: invalid color value, treat it as literal return createLiteral(scanner, start); } } scanner.pos = start; } /** * Consumes alpha value of color: `.1` */ function colorAlpha(scanner: Scanner): string { const start = scanner.pos; if (scanner.eat(Chars.Dot)) { scanner.start = start; if (scanner.eatWhile(isNumber)) { return scanner.current(); } return '1'; } return ''; } /** * Consumes white space characters as string literal from given scanner */ function whiteSpace(scanner: Scanner): WhiteSpace | undefined { const start = scanner.pos; if (scanner.eatWhile(isSpace)) { return { type: 'WhiteSpace', start, end: scanner.pos }; } } /** * Consumes custom CSS property: --foo-bar */ function customProperty(scanner: Scanner): CustomProperty | undefined { const start = scanner.pos; if (scanner.eat(Chars.Dash) && scanner.eat(Chars.Dash)) { scanner.start = start; scanner.eatWhile(isKeyword); return { type: 'CustomProperty', value: scanner.current(), start, end: scanner.pos }; } scanner.pos = start; } /** * Consumes bracket from given scanner */ function bracket(scanner: Scanner): Bracket | undefined { const ch = scanner.peek(); if (isBracket(ch)) { return { type: 'Bracket', open: ch === Chars.RoundBracketOpen, start: scanner.pos++, end: scanner.pos }; } } /** * Consumes operator from given scanner */ function operator(scanner: Scanner): Operator | undefined { const op = operatorType(scanner.peek()); if (op) { return { type: 'Operator', operator: op, start: scanner.pos++, end: scanner.pos }; } } /** * Eats number value from given stream * @return Returns `true` if number was consumed */ function consumeNumber(stream: Scanner): boolean { const start = stream.pos; stream.eat(Chars.Dash); const afterNegative = stream.pos; const hasDecimal = stream.eatWhile(isNumber); const prevPos = stream.pos; if (stream.eat(Chars.Dot)) { // It’s perfectly valid to have numbers like `1.`, which enforces // value to float unit type const hasFloat = stream.eatWhile(isNumber); if (!hasDecimal && !hasFloat) { // Lone dot stream.pos = prevPos; } } // Edge case: consumed dash only: not a number, bail-out if (stream.pos === afterNegative) { stream.pos = start; } return stream.pos !== start; } function isIdentPrefix(code: number): boolean { return code === Chars.At || code === Chars.Dollar; } /** * If given character is an operator, returns it’s type */ function operatorType(ch: number): OperatorType | undefined { return (ch === Chars.Sibling && OperatorType.Sibling) || (ch === Chars.Excl && OperatorType.Important) || (ch === Chars.Comma && OperatorType.ArgumentDelimiter) || (ch === Chars.Colon && OperatorType.PropertyDelimiter) || (ch === Chars.Dash && OperatorType.ValueDelimiter) || void 0; } /** * Check if given code is a hex value (/0-9a-f/) */ function isHex(code: number): boolean { return isNumber(code) || isAlpha(code, 65, 70); // A-F } function isKeyword(code: number): boolean { return isAlphaNumericWord(code) || code === Chars.Dash; } function isBracket(code: number) { return code === Chars.RoundBracketOpen || code === Chars.RoundBracketClose; } function isLiteral(code: number) { return isAlphaWord(code) || code === Chars.Percent || code === Chars.Slash; } /** * Parses given color value from abbreviation into RGBA format */ function parseColor(value: string, alpha?: string): { r: number, g: number, b: number, a: number } { let r = '0'; let g = '0'; let b = '0'; let a = Number(alpha != null && alpha !== '' ? alpha : 1); if (value === 't') { a = 0; } else { switch (value.length) { case 0: break; case 1: r = g = b = value + value; break; case 2: r = g = b = value; break; case 3: r = value[0] + value[0]; g = value[1] + value[1]; b = value[2] + value[2]; break; default: value += value; r = value.slice(0, 2); g = value.slice(2, 4); b = value.slice(4, 6); } } return { r: parseInt(r, 16), g: parseInt(g, 16), b: parseInt(b, 16), a }; } /** * Check if scanner reader must consume dash after given token. * Used in cases where user must explicitly separate numeric values */ function shouldConsumeDashAfter(token: AllTokens): boolean { return token.type === 'ColorValue' || (token.type === 'NumberValue' && !token.unit); } /** * Merges last adjacent tokens into a single literal. * This function is used to overcome edge case when function name was parsed * as a list of separate tokens. For example, a `scale3d()` value will be * parsed as literal and number tokens (`scale` and `3d`) which is a perfectly * valid abbreviation but undesired result. This function will detect last adjacent * literal and number values and combine them into single literal */ function mergeTokens(scanner: Scanner, tokens: AllTokens[]) { let start = 0; let end = 0; while (tokens.length) { const token = last(tokens)!; if (token.type === 'Literal' || token.type === 'NumberValue') { start = token.start!; if (!end) { end = token.end!; } tokens.pop(); } else { break; } } if (start !== end) { tokens.push(createLiteral(scanner, start, end)); } } function last(arr: T[]): T | undefined { return arr[arr.length - 1]; } ================================================ FILE: packages/css-abbreviation/src/tokenizer/tokens.ts ================================================ export type AllTokens = Bracket | Literal | Operator | WhiteSpace | ColorValue | NumberValue | StringValue | CustomProperty | Field; export const enum OperatorType { Sibling = '+', Important = '!', ArgumentDelimiter = ',', ValueDelimiter = '-', PropertyDelimiter = ':' } export interface Token { type: string; /** Location of token start in source */ start?: number; /** Location of token end in source */ end?: number; } export interface Operator extends Token { type: 'Operator'; operator: OperatorType; } export interface Bracket extends Token { type: 'Bracket'; open: boolean; } export interface Literal extends Token { type: 'Literal'; value: string; } export interface CustomProperty extends Token { type: 'CustomProperty'; value: string; } export interface NumberValue extends Token { type: 'NumberValue'; value: number; unit: string; rawValue: string; } export interface ColorValue extends Token { type: 'ColorValue'; r: number; g: number; b: number; a: number; raw: string; } export interface StringValue extends Token { type: 'StringValue'; value: string; quote: 'single' | 'double'; } export interface WhiteSpace extends Token { type: 'WhiteSpace'; } export interface Field extends Token { type: 'Field'; index?: number; name: string; } ================================================ FILE: packages/css-abbreviation/src/tokenizer/utils.ts ================================================ export const enum Chars { /** `#` character */ Hash = 35, /** `$` character */ Dollar = 36, /** `-` character */ Dash = 45, /** `.` character */ Dot = 46, /** `:` character */ Colon = 58, /** `,` character */ Comma = 44, /** `!` character */ Excl = 33, /** `@` character */ At = 64, /** `%` character */ Percent = 37, /** `_` character */ Underscore = 95, /** `(` character */ RoundBracketOpen = 40, /** `)` character */ RoundBracketClose = 41, /** `{` character */ CurlyBracketOpen = 123, /** `}` character */ CurlyBracketClose = 125, /** `+` character */ Sibling = 43, /** `'` character */ SingleQuote = 39, /** `"` character */ DoubleQuote = 34, /** `t` character */ Transparent = 116, /** `/` character */ Slash = 47, } ================================================ FILE: packages/css-abbreviation/test/assets/stringify.ts ================================================ import type { CSSProperty, CSSValue, Value } from '../../src/parser'; export default function stringify(prop: CSSProperty): string { return `${prop.name || '?'}: ${prop.value.map(stringifyValue).join(', ')}${prop.important ? ' !important' : ''};`; } function stringifyValue(value: CSSValue): string { return value.value.map(stringifyToken).join(' '); } function stringifyToken(token: Value): string { if (token.type === 'ColorValue') { const { r, g, b, a } = token; if (!r && !g && !b && !a) { return 'transparent'; } if (a === 1) { return `#${toHex(r)}${toHex(g)}${toHex(b)}`; } return `rgba(${r}, ${g}, ${b}, ${a})`; } else if (token.type === 'NumberValue') { return `${token.value}${token.unit}`; } else if (token.type === 'StringValue') { return `"${token.value}"`; } else if (token.type === 'Literal') { return token.value; } else if (token.type === 'FunctionCall') { return `${token.name}(${token.arguments.map(stringifyValue).join(', ')})`; } throw new Error('Unexpected token'); } function toHex(num: number): string { return pad(num.toString(16), 2); } function pad(value: string, len: number): string { while (value.length < len) { value = '0' + value; } return value; } ================================================ FILE: packages/css-abbreviation/test/parser.ts ================================================ import { describe, it } from 'node:test'; import { strictEqual as equal, throws } from 'node:assert'; import parser, { ParseOptions, FunctionCall } from '../src/parser'; import tokenizer from '../src/tokenizer'; import stringify from './assets/stringify'; const parse = (abbr: string, opt?: ParseOptions) => parser(tokenizer(abbr), opt); const expand = (abbr: string) => parse(abbr).map(stringify).join(''); describe('CSS Abbreviation parser', () => { it('basic', () => { equal(expand('p10!'), 'p: 10 !important;'); equal(expand('p-10-20'), 'p: -10 20;'); equal(expand('p-10%-20--30'), 'p: -10% -20 -30;'); equal(expand('p.5'), 'p: 0.5;'); equal(expand('p-.5'), 'p: -0.5;'); equal(expand('p.1.2.3'), 'p: 0.1 0.2 0.3;'); equal(expand('p.1-.2.3'), 'p: 0.1 0.2 0.3;'); equal(expand('10'), '?: 10;'); equal(expand('.1'), '?: 0.1;'); equal(expand('lh1.'), 'lh: 1;'); }); it('color', () => { equal(expand('c#'), 'c: #000000;'); equal(expand('c#1'), 'c: #111111;'); equal(expand('c#f'), 'c: #ffffff;'); equal(expand('c#a#b#c'), 'c: #aaaaaa #bbbbbb #cccccc;'); equal(expand('c#af'), 'c: #afafaf;'); equal(expand('c#fc0'), 'c: #ffcc00;'); equal(expand('c#11.5'), 'c: rgba(17, 17, 17, 0.5);'); equal(expand('c#.99'), 'c: rgba(0, 0, 0, 0.99);'); equal(expand('c#t'), 'c: transparent;'); }); it('keywords', () => { equal(expand('m:a'), 'm: a;'); equal(expand('m-a'), 'm: a;'); equal(expand('m-abc'), 'm: abc;'); equal(expand('m-a0'), 'm: a 0;'); equal(expand('m-a0-a'), 'm: a 0 a;'); }); it('functions', () => { equal(expand('bg-lg(top, "red, black",rgb(0, 0, 0) 10%)'), 'bg: lg(top, "red, black", rgb(0, 0, 0) 10%);'); equal(expand('lg(top, "red, black",rgb(0, 0, 0) 10%)'), '?: lg(top, "red, black", rgb(0, 0, 0) 10%);'); }); it('mixed', () => { equal(expand('bd1-s#fc0'), 'bd: 1 s #ffcc00;'); equal(expand('bd#fc0-1'), 'bd: #ffcc00 1;'); equal(expand('p0+m0'), 'p: 0;m: 0;'); equal(expand('p0!+m0!'), 'p: 0 !important;m: 0 !important;'); }); it('embedded variables', () => { equal(expand('foo$bar'), 'foo: $bar;'); equal(expand('foo$bar-2'), 'foo: $bar-2;'); equal(expand('foo$bar@bam'), 'foo: $bar @bam;'); }); it('parse value', () => { const opt: ParseOptions = { value: true }; let prop = parse('${1:foo} ${2:bar}, baz', opt)[0]; equal(prop.name, undefined); equal(prop.value.length, 2); equal(prop.value[0].value.length, 2); equal(prop.value[0].value[0].type, 'Field'); prop = parse('scale3d(${1:x}, ${2:y}, ${3:z})', opt)[0]; const fn = prop.value[0].value[0] as FunctionCall; equal(prop.value.length, 1); equal(prop.value[0].value.length, 1); equal(fn.type, 'FunctionCall'); equal(fn.name, 'scale3d'); prop = parse('repeat(2,auto) / repeat(auto-fit, minmax(250px, 1fr))', opt)[0]; equal(prop.value.length, 1); }); it('errors', () => { throws(() => expand('p10 '), /Unexpected token/); }); }); ================================================ FILE: packages/css-abbreviation/test/tokenizer.ts ================================================ import { describe, it } from 'node:test'; import { deepStrictEqual as deepEqual } from 'node:assert'; import tokenize from '../src/tokenizer'; describe('Tokenizer', () => { it('numeric values', () => { deepEqual(tokenize('p10'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 10, rawValue: '10', unit: '', start: 1, end: 3 } ]); deepEqual(tokenize('p-10'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: '', start: 1, end: 4 } ]); deepEqual(tokenize('p-10-'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: '', start: 1, end: 4 }, { type: 'Operator', operator: '-', start: 4, end: 5 } ]); deepEqual(tokenize('p-10-20'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: '', start: 1, end: 4 }, { type: 'Operator', operator: '-', start: 4, end: 5 }, { type: 'NumberValue', value: 20, rawValue: '20', unit: '', start: 5, end: 7 } ]); deepEqual(tokenize('p-10--20'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: '', start: 1, end: 4 }, { type: 'Operator', operator: '-', start: 4, end: 5 }, { type: 'NumberValue', value: -20, rawValue: '-20', unit: '', start: 5, end: 8 } ]); deepEqual(tokenize('p-10-20--30'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: '', start: 1, end: 4 }, { type: 'Operator', operator: '-', start: 4, end: 5 }, { type: 'NumberValue', value: 20, rawValue: '20', unit: '', start: 5, end: 7 }, { type: 'Operator', operator: '-', start: 7, end: 8 }, { type: 'NumberValue', value: -30, rawValue: '-30', unit: '', start: 8, end: 11 } ]); deepEqual(tokenize('p-10p-20--30'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: 'p', start: 1, end: 5 }, { type: 'NumberValue', value: -20, rawValue: '-20', unit: '', start: 5, end: 8 }, { type: 'Operator', operator: '-', start: 8, end: 9 }, { type: 'NumberValue', value: -30, rawValue: '-30', unit: '', start: 9, end: 12 } ]); deepEqual(tokenize('p-10%-20--30'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -10, rawValue: '-10', unit: '%', start: 1, end: 5 }, { type: 'NumberValue', value: -20, rawValue: '-20', unit: '', start: 5, end: 8 }, { type: 'Operator', operator: '-', start: 8, end: 9 }, { type: 'NumberValue', value: -30, rawValue: '-30', unit: '', start: 9, end: 12 } ]); }); it('float values', () => { deepEqual(tokenize('p.5'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 0.5, rawValue: '.5', unit: '', start: 1, end: 3 } ]); deepEqual(tokenize('p-.5'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: -0.5, rawValue: '-.5', unit: '', start: 1, end: 4 } ]); deepEqual(tokenize('p.1.2.3'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 0.1, rawValue: '.1', unit: '', start: 1, end: 3 }, { type: 'NumberValue', value: 0.2, rawValue: '.2', unit: '', start: 3, end: 5 }, { type: 'NumberValue', value: 0.3, rawValue: '.3', unit: '', start: 5, end: 7 } ]); deepEqual(tokenize('p.1-.2.3'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 0.1, rawValue: '.1', unit: '', start: 1, end: 3 }, { type: 'Operator', operator: '-', start: 3, end: 4 }, { type: 'NumberValue', value: 0.2, rawValue: '.2', unit: '', start: 4, end: 6 }, { type: 'NumberValue', value: 0.3, rawValue: '.3', unit: '', start: 6, end: 8 } ]); deepEqual(tokenize('p.1--.2.3'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 0.1, rawValue: '.1', unit: '', start: 1, end: 3 }, { type: 'Operator', operator: '-', start: 3, end: 4 }, { type: 'NumberValue', value: -0.2, rawValue: '-.2', unit: '', start: 4, end: 7 }, { type: 'NumberValue', value: 0.3, rawValue: '.3', unit: '', start: 7, end: 9 } ]); deepEqual(tokenize('10'), [ { type: 'NumberValue', value: 10, rawValue: '10', unit: '', start: 0, end: 2 }, ]); deepEqual(tokenize('.1'), [ { type: 'NumberValue', value: 0.1, rawValue: '.1', unit: '', start: 0, end: 2 }, ]); // NB: now dot should be a part of literal // throws(() => tokenize('.foo'), /Unexpected character at 1/); }); it('color values', () => { deepEqual(tokenize('c#'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 0, g: 0, b: 0, a: 1, raw: '', start: 1, end: 2 } ]); deepEqual(tokenize('c#1'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 17, g: 17, b: 17, a: 1, raw: '1', start: 1, end: 3 } ]); deepEqual(tokenize('c#.'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 0, g: 0, b: 0, a: 1, raw: '.', start: 1, end: 3 } ]); deepEqual(tokenize('c#f'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 255, g: 255, b: 255, a: 1, raw: 'f', start: 1, end: 3 } ]); deepEqual(tokenize('c#a#b#c'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 170, g: 170, b: 170, a: 1, raw: 'a', start: 1, end: 3 }, { type: 'ColorValue', r: 187, g: 187, b: 187, a: 1, raw: 'b', start: 3, end: 5 }, { type: 'ColorValue', r: 204, g: 204, b: 204, a: 1, raw: 'c', start: 5, end: 7 } ]); deepEqual(tokenize('c#af'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 175, g: 175, b: 175, a: 1, raw: 'af', start: 1, end: 4 } ]); deepEqual(tokenize('c#fc0'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 255, g: 204, b: 0, a: 1, raw: 'fc0', start: 1, end: 5 } ]); deepEqual(tokenize('c#11.5'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 17, g: 17, b: 17, a: 0.5, raw: '11.5', start: 1, end: 6 } ]); deepEqual(tokenize('c#.99'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 0, g: 0, b: 0, a: 0.99, raw: '.99', start: 1, end: 5 } ]); deepEqual(tokenize('c#t'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'ColorValue', r: 0, g: 0, b: 0, a: 0, raw: 't', start: 1, end: 3 } ]); deepEqual(tokenize('c#${fff}'), [ { type: 'Literal', value: 'c', start: 0, end: 1 }, { type: 'Literal', value: '#', start: 1, end: 2 }, { type: 'Field', index: undefined, name: 'fff', start: 2, end: 8 } ]); }); it('keywords', () => { deepEqual(tokenize('m:a'), [ { type: 'Literal', value: 'm', start: 0, end: 1 }, { type: 'Operator', operator: ':', start: 1, end: 2 }, { type: 'Literal', value: 'a', start: 2, end: 3 } ]); deepEqual(tokenize('m-a'), [ { type: 'Literal', value: 'm', start: 0, end: 1 }, { type: 'Operator', operator: '-', start: 1, end: 2 }, { type: 'Literal', value: 'a', start: 2, end: 3 } ]); deepEqual(tokenize('m-abc'), [ { type: 'Literal', value: 'm', start: 0, end: 1 }, { type: 'Operator', operator: '-', start: 1, end: 2 }, { type: 'Literal', value: 'abc', start: 2, end: 5 } ]); deepEqual(tokenize('m-a0'), [ { type: 'Literal', value: 'm', start: 0, end: 1 }, { type: 'Operator', operator: '-', start: 1, end: 2 }, { type: 'Literal', value: 'a', start: 2, end: 3 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 3, end: 4 } ]); deepEqual(tokenize('m-a0-a'), [ { type: 'Literal', value: 'm', start: 0, end: 1 }, { type: 'Operator', operator: '-', start: 1, end: 2 }, { type: 'Literal', value: 'a', start: 2, end: 3 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 3, end: 4 }, { type: 'Operator', operator: '-', start: 4, end: 5 }, { type: 'Literal', value: 'a', start: 5, end: 6 } ]); }); it('arguments', () => { deepEqual(tokenize('lg(top, "red, black", rgb(0, 0, 0) 10%)'), [ { type: 'Literal', value: 'lg', start: 0, end: 2 }, { type: 'Bracket', open: true, start: 2, end: 3 }, { type: 'Literal', value: 'top', start: 3, end: 6 }, { type: 'Operator', operator: ',', start: 6, end: 7 }, { type: 'WhiteSpace', start: 7, end: 8 }, { type: 'StringValue', value: 'red, black', quote: 'double', start: 8, end: 20 }, { type: 'Operator', operator: ',', start: 20, end: 21 }, { type: 'WhiteSpace', start: 21, end: 22 }, { type: 'Literal', value: 'rgb', start: 22, end: 25 }, { type: 'Bracket', open: true, start: 25, end: 26 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 26, end: 27 }, { type: 'Operator', operator: ',', start: 27, end: 28 }, { type: 'WhiteSpace', start: 28, end: 29 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 29, end: 30 }, { type: 'Operator', operator: ',', start: 30, end: 31 }, { type: 'WhiteSpace', start: 31, end: 32 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 32, end: 33 }, { type: 'Bracket', open: false, start: 33, end: 34 }, { type: 'WhiteSpace', start: 34, end: 35 }, { type: 'NumberValue', value: 10, rawValue: '10', unit: '%', start: 35, end: 38 }, { type: 'Bracket', open: false, start: 38, end: 39 } ]); }); it('important', () => { deepEqual(tokenize('!'), [ { type: 'Operator', operator: '!', start: 0, end: 1 } ]); deepEqual(tokenize('p!'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'Operator', operator: '!', start: 1, end: 2 } ]); deepEqual(tokenize('p10!'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 10, rawValue: '10', unit: '', start: 1, end: 3 }, { type: 'Operator', operator: '!', start: 3, end: 4 } ]); }); it('mixed', () => { deepEqual(tokenize('bd1-s#fc0'), [ { type: 'Literal', value: 'bd', start: 0, end: 2 }, { type: 'NumberValue', value: 1, rawValue: '1', unit: '', start: 2, end: 3 }, { type: 'Operator', operator: '-', start: 3, end: 4 }, { type: 'Literal', value: 's', start: 4, end: 5 }, { type: 'ColorValue', r: 255, g: 204, b: 0, a: 1, raw: 'fc0', start: 5, end: 9 } ]); deepEqual(tokenize('bd#fc0-1'), [ { type: 'Literal', value: 'bd', start: 0, end: 2 }, { type: 'ColorValue', r: 255, g: 204, b: 0, a: 1, raw: 'fc0', start: 2, end: 6 }, { type: 'Operator', operator: '-', start: 6, end: 7 }, { type: 'NumberValue', value: 1, rawValue: '1', unit: '', start: 7, end: 8 } ]); deepEqual(tokenize('p0+m0'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 1, end: 2 }, { type: 'Operator', operator: '+', start: 2, end: 3 }, { type: 'Literal', value: 'm', start: 3, end: 4 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 4, end: 5 } ]); deepEqual(tokenize('p0!+m0!'), [ { type: 'Literal', value: 'p', start: 0, end: 1 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 1, end: 2 }, { type: 'Operator', operator: '!', start: 2, end: 3 }, { type: 'Operator', operator: '+', start: 3, end: 4 }, { type: 'Literal', value: 'm', start: 4, end: 5 }, { type: 'NumberValue', value: 0, rawValue: '0', unit: '', start: 5, end: 6 }, { type: 'Operator', operator: '!', start: 6, end: 7 } ]); deepEqual(tokenize('${2:0}%'), [ { type: 'Field', index: 2, name: '0', start: 0, end: 6 }, { type: 'Literal', value: '%', start: 6, end: 7 } ]); deepEqual(tokenize('.${1:5}'), [ { type: 'Literal', value: '.', start: 0, end: 1 }, { type: 'Field', index: 1, name: '5', start: 1, end: 7 }, ]); }); it('embedded variables', () => { deepEqual(tokenize('foo$bar'), [ { type: 'Literal', value: 'foo', start: 0, end: 3 }, { type: 'Literal', value: '$bar', start: 3, end: 7 } ]); deepEqual(tokenize('foo$bar-2'), [ { type: 'Literal', value: 'foo', start: 0, end: 3 }, { type: 'Literal', value: '$bar-2', start: 3, end: 9 } ]); deepEqual(tokenize('foo$bar@bam'), [ { type: 'Literal', value: 'foo', start: 0, end: 3 }, { type: 'Literal', value: '$bar', start: 3, end: 7 }, { type: 'Literal', value: '@bam', start: 7, end: 11 } ]); deepEqual(tokenize('@k10'), [ { type: 'Literal', value: '@k', start: 0, end: 2 }, { type: 'NumberValue', value: 10, rawValue: '10', unit: '', start: 2, end: 4 } ]); }); }); ================================================ FILE: packages/css-abbreviation/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "outDir": "./dist" }, "include": ["src"] } ================================================ FILE: packages/scanner/.gitignore ================================================ /scanner.js /scanner.es.js /scanner.cjs /*.d.ts /*.map ================================================ FILE: packages/scanner/.npmignore ================================================ npm-debug.log* node_modules jspm_packages .npm /.* /*.* !/scanner.js !/scanner.cjs !/*.d.ts !/*.map /test /src ================================================ FILE: packages/scanner/LICENSE ================================================ MIT License Copyright (c) 2020 Sergey Chikuyonok Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packages/scanner/package.json ================================================ { "name": "@emmetio/scanner", "version": "1.0.4", "description": "Scans given text character-by-character", "main": "./dist/scanner.cjs", "module": "./dist/scanner.js", "types": "./dist/scanner.d.ts", "type": "module", "exports": { "import": "./dist/scanner.js", "require": "./dist/scanner.cjs" }, "scripts": { "test": "tsx --test ./test/*.ts", "build": "rollup -c", "watch": "rollup -wc", "clean": "rm ./scanner.* ./*.d.ts", "prepublishOnly": "npm test && npm run clean && npm run build" }, "repository": { "type": "git", "url": "git+https://github.com/emmetio/stream-reader.git" }, "keywords": [ "emmet", "stream", "scanner" ], "author": "Sergey Chikuyonok ", "license": "MIT", "bugs": { "url": "https://github.com/emmetio/emmet/issues" }, "homepage": "https://github.com/emmetio/emmet#readme" } ================================================ FILE: packages/scanner/rollup.config.js ================================================ import typescript from '@rollup/plugin-typescript'; export default { input: './src/scanner.ts', plugins: [typescript()], output: [{ format: 'cjs', exports: 'named', sourcemap: true, file: './dist/scanner.cjs' }, { format: 'es', sourcemap: true, file: './dist/scanner.js' }] }; ================================================ FILE: packages/scanner/src/scanner.ts ================================================ export * from './utils'; type MatchFn = (ch: number) => boolean; /** * A streaming, character code-based string reader */ export default class Scanner { /** Current string */ string: string; /** Current scanner position */ pos: number; /** Lower range limit where string reader is available */ start: number; /** Upper range limit where string reader is available */ end: number; constructor(str: string, start?: number, end?: number) { if (end == null && typeof str === 'string') { end = str.length; } this.string = str; this.pos = this.start = start || 0; this.end = end || 0; } /** * Returns true only if the stream is at the end of the file. */ eof(): boolean { return this.pos >= this.end; } /** * Creates a new stream instance which is limited to given `start` and `end` * range. E.g. its `eof()` method will look at `end` property, not actual * stream end */ limit(start?: number, end?: number): Scanner { return new Scanner(this.string, start, end); } /** * Returns the next character code in the stream without advancing it. * Will return NaN at the end of the file. */ peek(): number { return this.string.charCodeAt(this.pos); } /** * Returns the next character in the stream and advances it. * Also returns undefined when no more characters are available. */ next(): number | undefined { if (this.pos < this.string.length) { return this.string.charCodeAt(this.pos++); } } /** * `match` can be a character code or a function that takes a character code * and returns a boolean. If the next character in the stream 'matches' * the given argument, it is consumed and returned. * Otherwise, `false` is returned. */ eat(match: number | MatchFn): boolean { const ch = this.peek(); const ok = typeof match === 'function' ? match(ch) : ch === match; if (ok) { this.next(); } return ok; } /** * Repeatedly calls eat with the given argument, until it * fails. Returns true if any characters were eaten. */ eatWhile(match: number | MatchFn): boolean { const start = this.pos; while (!this.eof() && this.eat(match)) { /* */ } return this.pos !== start; } /** * Backs up the stream n characters. Backing it up further than the * start of the current token will cause things to break, so be careful. */ backUp(n: number) { this.pos -= (n || 1); } /** * Get the string between the start of the current token and the * current stream position. */ current(): string { return this.substring(this.start, this.pos); } /** * Returns substring for given range */ substring(start: number, end?: number): string { return this.string.slice(start, end); } /** * Creates error object with current stream state */ error(message: string, pos = this.pos): ScannerError { return new ScannerError(`${message} at ${pos + 1}`, pos, this.string); } } export class ScannerError extends Error { pos: number; string: string; constructor(message: string, pos: number, str: string) { super(message); this.pos = pos; this.string = str; } } ================================================ FILE: packages/scanner/src/utils.ts ================================================ import type Scanner from './scanner'; interface QuotedOptions { /** A character code of quote-escape symbol */ escape?: number; /** Throw error if quotes string can’t be properly consumed */ throws?: boolean; } const defaultQuotedOptions: QuotedOptions = { escape: 92, // \ character throws: false }; /** * Check if given code is a number */ export function isNumber(code: number): boolean { return code > 47 && code < 58; } /** * Check if given character code is alpha code (letter through A to Z) */ export function isAlpha(code: number, from?: number, to?: number): boolean { from = from || 65; // A to = to || 90; // Z code &= ~32; // quick hack to convert any char code to uppercase char code return code >= from && code <= to; } /** * Check if given character code is alpha-numeric (letter through A to Z or number) */ export function isAlphaNumeric(code: number): boolean { return isNumber(code) || isAlpha(code); } export function isAlphaNumericWord(code: number): boolean { return isNumber(code) || isAlphaWord(code); } export function isAlphaWord(code: number): boolean { return code === 95 /* _ */ || isAlpha(code); } /** * Check for Umlauts i.e. ä, Ä, ö, Ö, ü and Ü */ export function isUmlaut(code: number): boolean { return code === 196 || code == 214 || code === 220 || code === 228 || code === 246 || code === 252; } /** * Check if given character code is a white-space character: a space character * or line breaks */ export function isWhiteSpace(code: number) { return code === 32 /* space */ || code === 9 /* tab */ || code === 160; /* non-breaking space */ } /** * Check if given character code is a space character */ export function isSpace(code: number): boolean { return isWhiteSpace(code) || code === 10 /* LF */ || code === 13; /* CR */ } /** * Consumes 'single' or "double"-quoted string from given string, if possible * @return `true` if quoted string was consumed. The contents of quoted string * will be available as `stream.current()` */ export function eatQuoted(stream: Scanner, options?: QuotedOptions): boolean { options = { ...defaultQuotedOptions, ...options }; const start = stream.pos; const quote = stream.peek(); if (stream.eat(isQuote)) { while (!stream.eof()) { switch (stream.next()) { case quote: stream.start = start; return true; case options.escape: stream.next(); break; } } // If we’re here then stream wasn’t properly consumed. // Revert stream and decide what to do stream.pos = start; if (options.throws) { throw stream.error('Unable to consume quoted string'); } } return false; } /** * Check if given character code is a quote character */ export function isQuote(code: number): boolean { return code === 39 /* ' */ || code === 34 /* " */; } /** * Eats paired characters substring, for example `(foo)` or `[bar]` * @param open Character code of pair opening * @param close Character code of pair closing * @return Returns `true` if character pair was successfully consumed, it’s * content will be available as `stream.current()` */ export function eatPair(stream: Scanner, open: number, close: number, options?: QuotedOptions): boolean { options = { ...defaultQuotedOptions, ...options }; const start = stream.pos; if (stream.eat(open)) { let stack = 1; let ch: number; while (!stream.eof()) { if (eatQuoted(stream, options)) { continue; } ch = stream.next()!; if (ch === open) { stack++; } else if (ch === close) { stack--; if (!stack) { stream.start = start; return true; } } else if (ch === options.escape) { stream.next(); } } // If we’re here then paired character can’t be consumed stream.pos = start; if (options.throws) { throw stream.error(`Unable to find matching pair for ${String.fromCharCode(open)}`); } } return false; } ================================================ FILE: packages/scanner/test/stream-reader.ts ================================================ import { describe, it } from 'node:test'; import { strictEqual as equal, ok } from 'node:assert'; import StreamReader from '../src/scanner'; describe('Stream Reader', () => { it('basic', () => { const data = 'hello'; const s = new StreamReader(data); equal(s.string, data); equal(s.start, 0); equal(s.pos, 0); equal(s.peek(), data.charCodeAt(0)); equal(s.start, 0); equal(s.pos, 0); equal(s.next(), data.charCodeAt(0)); equal(s.next(), data.charCodeAt(1)); equal(s.start, 0); equal(s.pos, 2); equal(s.next(), data.charCodeAt(2)); equal(s.start, 0); equal(s.pos, 3); equal(s.current(), data.slice(0, 3)); }); it('should limit reader range', () => { const outer = new StreamReader('foo bar baz'); const inner = outer.limit(4, 7); ok(outer !== inner); let outerValue = ''; let innerValue = ''; while (!outer.eof()) { outerValue += String.fromCharCode(outer.next()!); } while (!inner.eof()) { innerValue += String.fromCharCode(inner.next()!); } equal(outerValue, 'foo bar baz'); equal(innerValue, 'bar'); }); }); ================================================ FILE: packages/scanner/test/utils.ts ================================================ import { describe, it } from 'node:test'; import { strictEqual as equal, ok, throws } from 'node:assert'; import StreamReader from '../src/scanner'; import { eatPair, eatQuoted } from '../src/utils'; describe('Pairs', () => { const code = (ch: string) => ch.charCodeAt(0); it('eat', () => { const stream = new StreamReader('[foo] (bar (baz) bam)'); ok(eatPair(stream, code('['), code(']'))); equal(stream.start, 0); equal(stream.pos, 5); equal(stream.current(), '[foo]'); // No pair here ok(!eatPair(stream, code('('), code(')'), { throws: true })); stream.eatWhile(code(' ')); ok(eatPair(stream, code('('), code(')'), { throws: true })); equal(stream.start, 6); equal(stream.pos, 21); equal(stream.current(), '(bar (baz) bam)'); }); it('eat with quotes', () => { const stream = new StreamReader('[foo "bar]" ]'); ok(eatPair(stream, code('['), code(']'))); equal(stream.start, 0); equal(stream.pos, 13); equal(stream.current(), '[foo "bar]" ]'); }); it('handle invalid', () => { const stream = new StreamReader('[foo'); ok(!eatPair(stream, code('['), code(']'))); equal(stream.start, 0); equal(stream.pos, 0); throws(() => ok(!eatPair(stream, code('['), code(']'), { throws: true })), /Unable to find matching pair/); }); }); describe('Quoted', () => { it('eat quoted', () => { const data = '"foo" \'bar\''; const stream = new StreamReader(data); ok(eatQuoted(stream)); equal(stream.start, 0); equal(stream.pos, 5); equal(stream.current(), '"foo"'); // no double-quoted value ahead ok(!eatQuoted(stream, { throws: true })); // eat space ok(stream.eatWhile(' '.charCodeAt(0))); equal(stream.pos, 8); ok(eatQuoted(stream)); equal(stream.start, 8); equal(stream.pos, 13); equal(stream.current(), '\'bar\''); ok(stream.eof()); }); it('handle broken strings', () => { const stream = new StreamReader('"foo'); ok(!eatQuoted(stream)); equal(stream.pos, 0); throws(() => eatQuoted(stream, { throws: true }), /Unable to consume quoted string/); }); it('handle escapes', () => { const stream = new StreamReader('"foo\\"bar" baz'); ok(eatQuoted(stream)); equal(stream.start, 0); equal(stream.pos, 10); equal(stream.current(), '"foo\\"bar"'); }); }); ================================================ FILE: packages/scanner/tsconfig.json ================================================ { "extends": "../../tsconfig", "compilerOptions": { "outDir": "./dist" }, "include": ["src"] } ================================================ FILE: rollup.config.js ================================================ import { extname } from 'path'; import typescript from '@rollup/plugin-typescript'; import nodeResolve from '@rollup/plugin-node-resolve'; export default { input: './src/index.ts', plugins: [nodeResolve(), json(), typescript()], output: [{ file: './dist/emmet.js', format: 'es', sourcemap: true }, { file: './dist/emmet.cjs', format: 'cjs', exports: 'named', sourcemap: true }] }; function json() { return { transform(code, id) { if (extname(id) === '.json') { return { code: `export default ${code}`, map: null }; } } }; } ================================================ FILE: src/config.ts ================================================ import type { Abbreviation } from '@emmetio/abbreviation'; import markupSnippets from './snippets/html.json' with { type: 'json' }; import stylesheetSnippets from './snippets/css.json' with { type: 'json' }; import xslSnippets from './snippets/xsl.json' with { type: 'json' }; import pugSnippets from './snippets/pug.json' with { type: 'json' }; import variables from './snippets/variables.json' with { type: 'json' }; import type { CSSSnippet } from './stylesheet/snippets.js'; export type SyntaxType = 'markup' | 'stylesheet'; export type FieldOutput = (index: number, placeholder: string, offset: number, line: number, column: number) => string; export type TextOutput = (text: string, offset: number, line: number, column: number) => string; export type StringCase = '' | 'lower' | 'upper'; export interface SnippetsMap { [name: string]: string; } export interface AbbreviationContext { name: string; attributes?: { [name: string]: string | null }; } /** * Raw config which contains per-syntax options. `markup` and `syntax` keys are * reserved for global settings for all markup and stylesheet syntaxes */ export interface GlobalConfig { [syntax: string]: Partial; } export interface BaseConfig { /* Type of abbreviation context, default is `markup` */ type: SyntaxType; /** Options for abbreviation output */ options: Partial; /** Substitutions for variable names */ variables: SnippetsMap; /** Abbreviation name to snippets mapping */ snippets: SnippetsMap; } interface ResolvedConfig extends BaseConfig { /** Host syntax */ syntax: string; /** * Context of abbreviation. For markup abbreviation, it contains parent tag * name with attributes, for stylesheet abbreviation it contains property name * if abbreviation is expanded as value */ context?: AbbreviationContext; /** Text to wrap with abbreviation */ text?: string | string[]; /** Max amount of repeated elements (fool proof) */ maxRepeat?: number; /** * Object for storing internal cache data to be shared across Emmet methods * invocation. If provided, Emmet will store compute-intensive data in this * object and will re-use it during editor session. * Every time user settings are changed, you should empty cache by passing * new object. */ cache?: Cache; /** * A callback for internal warnings or errors (for example, when parsing invalid abbreviation) */ warn?: (message: string, err?: Error) => void } export type Config = ResolvedConfig & { options: Options }; export type UserConfig = Partial; export interface Cache { stylesheetSnippets?: CSSSnippet[]; markupSnippets?: { [name: string]: Abbreviation | null }; } export interface Options { ///////////////////// // Generic options // ///////////////////// /** A list of inline-level elements */ inlineElements: string[]; //////////////////// // Output options // //////////////////// /** A string for one level indent */ 'output.indent': string; /** * A string for base indent, e.g. context indentation which will be added * for every generated line */ 'output.baseIndent': string; /** A string to use as a new line */ 'output.newline': string; /** Tag case: lower, upper or '' (keep as-is) */ 'output.tagCase': StringCase; /** Attribute name case: lower, upper or '' (keep as-is) */ 'output.attributeCase': StringCase; /** Attribute value quotes: 'single' or 'double' */ 'output.attributeQuotes': 'single' | 'double'; /** Enable output formatting (indentation and line breaks) */ 'output.format': boolean; /** When enabled, automatically adds inner line breaks for leaf (e.g. without children) nodes */ 'output.formatLeafNode': boolean; /** A list of tag names that should not get inner indentation */ 'output.formatSkip': string[]; /** A list of tag names that should *always* get inner indentation. */ 'output.formatForce': string[]; /** * How many inline sibling elements should force line break for each tag. * Set to `0` to output all inline elements without formatting. * Set to `1` to output all inline elements with formatting (same as block-level). */ 'output.inlineBreak': number; /** * Produce compact notation of boolean attributes: attributes which doesn’t have value. * With this option enabled, outputs `
    ` instead of * `
    ` */ 'output.compactBoolean': boolean; /** A list of boolean attributes */ 'output.booleanAttributes': string[]; /** Reverses attribute merging directions when resolving snippets */ 'output.reverseAttributes': boolean; /** Style of self-closing tags: html (`
    `), xml (`
    `) or xhtml (`
    `) */ 'output.selfClosingStyle': 'html' | 'xml' | 'xhtml'; /** * A function that takes field index and optional placeholder and returns * a string field (tabstop) for host editor. For example, a TextMate-style * field is `$index` or `${index:placeholder}` * @param index Field index * @param placeholder Field placeholder (default value), if any * @param offset Current character offset from the beginning of generated content * @param line Current line of generated output * @param column Current column in line */ 'output.field': FieldOutput; /** * A function for processing text chunk passed to `OutputStream`. * May be used by editor for escaping characters, if necessary */ 'output.text': TextOutput; //////////////////// // Markup options // //////////////////// /** * Automatically update value of element's href attribute * if inserting URL or email */ 'markup.href': boolean; /** * Attribute name mapping. Can be used to change attribute names for output. * For example, `class` -> `className` in JSX. If a key ends with `*`, this * value will be used for multi-attributes: currentry, it’s a `class` and `id` * since `multiple` marker is added for shorthand attributes only. * Example: `{ "class*": "styleName" }` */ 'markup.attributes'?: Record; /** * Prefixes for attribute values. * If specified, a value is treated as prefix for object notation and * automatically converts attribute value into expression if `jsx` is enabled. * Same as in `markup.attributes` option, a `*` can be used. */ 'markup.valuePrefix'?: Record; //////////////////////////////// // Element commenting options // //////////////////////////////// /** * Enable/disable element commenting: generate comments before open and/or * after close tag */ 'comment.enabled': boolean; /** * Attributes that should trigger node commenting on specific node, * if commenting is enabled */ 'comment.trigger': string[]; /** * Template string for comment to be placed *before* opening tag */ 'comment.before': string; /** * Template string for comment to be placed *after* closing tag. * Example: `\n` */ 'comment.after': string; ///////////////// // BEM options // ///////////////// /** Enable/disable BEM addon */ 'bem.enabled': boolean; /** A string for separating elements in output class */ 'bem.element': string; /** A string for separating modifiers in output class */ 'bem.modifier': string; ///////////////// // JSX options // ///////////////// /** Enable/disable JSX addon */ 'jsx.enabled': boolean; //////////////////////// // Stylesheet options // //////////////////////// /** List of globally available keywords for properties */ 'stylesheet.keywords': string[]; /** * List of unitless properties, e.g. properties where numeric values without * explicit unit will be outputted as is, without default value */ 'stylesheet.unitless': string[]; /** Use short hex notation where possible, e.g. `#000` instead of `#000000` */ 'stylesheet.shortHex': boolean; /** A string between property name and value */ 'stylesheet.between': string; /** A string after property value */ 'stylesheet.after': string; /** A unit suffix to output by default after integer values, 'px' by default */ 'stylesheet.intUnit': string; /** A unit suffix to output by default after float values, 'em' by default */ 'stylesheet.floatUnit': string; /** * Aliases for custom units in abbreviation. For example, `r: 'rem'` will * output `10rem` for abbreviation `10r` */ 'stylesheet.unitAliases': SnippetsMap; /** Output abbreviation as JSON object properties (for CSS-in-JS syntaxes) */ 'stylesheet.json': boolean; /** Use double quotes for JSON values */ 'stylesheet.jsonDoubleQuotes': boolean; /** * A float number between 0 and 1 to pick fuzzy-matched abbreviations. * Lower value will pick more abbreviations (and less accurate) */ 'stylesheet.fuzzySearchMinScore': number; /** * Force strict abbreviation match. If Emmet is unable to match abbreviation * with existing snippets, it will convert it to CSS property (`false`) * or skip it (`true`). E.g. `foo-bar` will expand to `foo: bar` if this option * is disabled or empty string if enabled */ 'stylesheet.strictMatch': boolean; } /** * Default syntaxes for abbreviation types */ export const defaultSyntaxes: { [name in SyntaxType]: string } = { markup: 'html', stylesheet: 'css' }; /** * List of all known syntaxes */ export const syntaxes = { markup: ['html', 'xml', 'xsl', 'jsx', 'js', 'pug', 'slim', 'haml', 'vue', 'svelte'], stylesheet: ['css', 'sass', 'scss', 'less', 'sss', 'stylus'] }; export const defaultOptions: Options = { 'inlineElements': [ 'a', 'abbr', 'acronym', 'applet', 'b', 'basefont', 'bdo', 'big', 'br', 'button', 'cite', 'code', 'del', 'dfn', 'em', 'font', 'i', 'iframe', 'img', 'input', 'ins', 'kbd', 'label', 'map', 'object', 'q', 's', 'samp', 'select', 'small', 'span', 'strike', 'strong', 'sub', 'sup', 'textarea', 'tt', 'u', 'var' ], 'output.indent': '\t', 'output.baseIndent': '', 'output.newline': '\n', 'output.tagCase': '', 'output.attributeCase': '', 'output.attributeQuotes': 'double', 'output.format': true, 'output.formatLeafNode': false, 'output.formatSkip': ['html'], 'output.formatForce': ['body'], 'output.inlineBreak': 3, 'output.compactBoolean': false, 'output.booleanAttributes': [ 'contenteditable', 'seamless', 'async', 'autofocus', 'autoplay', 'checked', 'controls', 'defer', 'disabled', 'formnovalidate', 'hidden', 'ismap', 'loop', 'multiple', 'muted', 'novalidate', 'readonly', 'required', 'reversed', 'selected', 'typemustmatch' ], 'output.reverseAttributes': false, 'output.selfClosingStyle': 'html', 'output.field': (index, placeholder) => placeholder, 'output.text': text => text, 'markup.href': true, 'comment.enabled': false, 'comment.trigger': ['id', 'class'], 'comment.before': '', 'comment.after': '\n', 'bem.enabled': false, 'bem.element': '__', 'bem.modifier': '_', 'jsx.enabled': false, 'stylesheet.keywords': ['auto', 'inherit', 'unset', 'none'], 'stylesheet.unitless': ['z-index', 'line-height', 'opacity', 'font-weight', 'zoom', 'flex', 'flex-grow', 'flex-shrink'], 'stylesheet.shortHex': true, 'stylesheet.between': ': ', 'stylesheet.after': ';', 'stylesheet.intUnit': 'px', 'stylesheet.floatUnit': 'em', 'stylesheet.unitAliases': { e: 'em', p: '%', x: 'ex', r: 'rem' }, 'stylesheet.json': false, 'stylesheet.jsonDoubleQuotes': false, 'stylesheet.fuzzySearchMinScore': 0, 'stylesheet.strictMatch': false }; export const defaultConfig: Config = { type: 'markup', syntax: 'html', variables, snippets: {}, options: defaultOptions }; /** * Default per-syntax config */ export const syntaxConfig: GlobalConfig = { markup: { snippets: parseSnippets(markupSnippets), }, xhtml: { options: { 'output.selfClosingStyle': 'xhtml' } }, xml: { options: { 'output.selfClosingStyle': 'xml' } }, xsl: { snippets: parseSnippets(xslSnippets), options: { 'output.selfClosingStyle': 'xml' } }, jsx: { options: { 'jsx.enabled': true, 'markup.attributes': { 'class': 'className', 'class*': 'styleName', 'for': 'htmlFor' }, 'markup.valuePrefix': { 'class*': 'styles' } } }, vue: { options: { 'markup.attributes': { 'class*': ':class', } } }, svelte: { options: { 'jsx.enabled': true } }, pug: { snippets: parseSnippets(pugSnippets) }, stylesheet: { snippets: parseSnippets(stylesheetSnippets) }, sass: { options: { 'stylesheet.after': '' } }, stylus: { options: { 'stylesheet.between': ' ', 'stylesheet.after': '', } } }; /** * Parses raw snippets definitions with possibly multiple keys into a plan * snippet map */ export function parseSnippets(snippets: SnippetsMap): SnippetsMap { const result: SnippetsMap = {}; Object.keys(snippets).forEach(k => { for (const name of k.split('|')) { result[name] = snippets[k]; } }); return result; } export default function resolveConfig(config: UserConfig = {}, globals: GlobalConfig = {}): Config { const type: SyntaxType = config.type || 'markup'; const syntax: string = config.syntax || defaultSyntaxes[type]; return { ...defaultConfig, ...config, type, syntax, variables: mergedData(type, syntax, 'variables', config, globals), snippets: mergedData(type, syntax, 'snippets', config, globals), options: mergedData(type, syntax, 'options', config, globals) }; } function mergedData(type: SyntaxType, syntax: string, key: K, config: UserConfig, globals: GlobalConfig = {}): Config[K] { const typeDefaults = syntaxConfig[type]; const typeOverride = globals[type]; const syntaxDefaults = syntaxConfig[syntax]; const syntaxOverride = globals[syntax]; return { ...(defaultConfig[key] as object), ...(typeDefaults && typeDefaults[key] as object), ...(syntaxDefaults && syntaxDefaults[key] as object), ...(typeOverride && typeOverride[key] as object), ...(syntaxOverride && syntaxOverride[key] as object), ...(config[key] as object) } as Config[K]; } ================================================ FILE: src/extract-abbreviation/brackets.ts ================================================ export const enum Brackets { SquareL = 91, SquareR = 93, RoundL = 40, RoundR = 41, CurlyL = 123, CurlyR = 125, } export const bracePairs = { [Brackets.SquareL]: Brackets.SquareR, [Brackets.RoundL]: Brackets.RoundR, [Brackets.CurlyL]: Brackets.CurlyR, }; ================================================ FILE: src/extract-abbreviation/index.ts ================================================ import type { SyntaxType } from '../config'; import backwardScanner, { sol, peek, consume, type BackwardScanner } from './reader'; import isAtHTMLTag from './is-html'; import { isQuote } from './quotes'; import { Brackets, bracePairs } from './brackets'; export interface ExtractOptions { /** * Allow parser to look ahead of `pos` index for searching of missing * abbreviation parts. Most editors automatically inserts closing braces for * `[`, `{` and `(`, which will most likely be right after current caret position. * So in order to properly expand abbreviation, user must explicitly move * caret right after auto-inserted braces. With this option enabled, parser * will search for closing braces right after `pos`. Default is `true` */ lookAhead: boolean; /** * Type of context syntax of expanded abbreviation. * In 'stylesheet' syntax, brackets `[]` and `{}` are not supported thus * not extracted. */ type: SyntaxType; /** * A string that should precede abbreviation in order to make it successfully * extracted. If given, the abbreviation will be extracted from the nearest * `prefix` occurrence. */ prefix: string; } export interface ExtractedAbbreviation { /** Extracted abbreviation */ abbreviation: string; /** Location of abbreviation in input string */ location: number; /** Start location of matched abbreviation, including prefix */ start: number; /** End location of extracted abbreviation */ end: number; } const code = (ch: string) => ch.charCodeAt(0); const specialChars = '#.*:$-_!@%^+>/'.split('').map(code); const defaultOptions: ExtractOptions = { type: 'markup', lookAhead: true, prefix: '' }; /** * Extracts Emmet abbreviation from given string. * The goal of this module is to extract abbreviation from current editor’s line, * e.g. like this: `.foo[title=bar|]` -> `.foo[title=bar]`, where * `|` is a current caret position. * @param line A text line where abbreviation should be expanded * @param pos Caret position in line. If not given, uses end of line * @param options Extracting options */ export default function extractAbbreviation(line: string, pos: number = line.length, options: Partial = {}): ExtractedAbbreviation | undefined { // make sure `pos` is within line range const opt: ExtractOptions = { ...defaultOptions, ...options }; pos = Math.min(line.length, Math.max(0, pos == null ? line.length : pos)); if (opt.lookAhead) { pos = offsetPastAutoClosed(line, pos, opt); } let ch: number; const start = getStartOffset(line, pos, opt.prefix || ''); if (start === -1) { return void 0; } const scanner = backwardScanner(line, start); scanner.pos = pos; const stack: number[] = []; while (!sol(scanner)) { ch = peek(scanner); if (stack.includes(Brackets.CurlyR)) { if (ch === Brackets.CurlyR) { stack.push(ch); scanner.pos--; continue; } if (ch !== Brackets.CurlyL) { scanner.pos--; continue; } } if (isCloseBrace(ch, opt.type)) { stack.push(ch); } else if (isOpenBrace(ch, opt.type)) { if (stack.pop() !== bracePairs[ch]) { // unexpected brace break; } } else if (stack.includes(Brackets.SquareR) || stack.includes(Brackets.CurlyR)) { // respect all characters inside attribute sets or text nodes scanner.pos--; continue; } else if (isAtHTMLTag(scanner) || !isAbbreviation(ch)) { break; } scanner.pos--; } if (!stack.length && scanner.pos !== pos) { // Found something, remove some invalid symbols from the // beginning and return abbreviation const abbreviation = line.slice(scanner.pos, pos).replace(/^[*+>^]+/, ''); return { abbreviation, location: pos - abbreviation.length, start: options.prefix ? start - options.prefix.length : pos - abbreviation.length, end: pos }; } } /** * Returns new `line` index which is right after characters beyound `pos` that * editor will likely automatically close, e.g. }, ], and quotes */ function offsetPastAutoClosed(line: string, pos: number, options: ExtractOptions): number { // closing quote is allowed only as a next character if (isQuote(line.charCodeAt(pos))) { pos++; } // offset pointer until non-autoclosed character is found while (isCloseBrace(line.charCodeAt(pos), options.type)) { pos++; } return pos; } /** * Returns start offset (left limit) in `line` where we should stop looking for * abbreviation: it’s nearest to `pos` location of `prefix` token */ function getStartOffset(line: string, pos: number, prefix: string): number { if (!prefix) { return 0; } const scanner = backwardScanner(line); const compiledPrefix = prefix.split('').map(code); scanner.pos = pos; let result: number; while (!sol(scanner)) { if (consumePair(scanner, Brackets.SquareR, Brackets.SquareL) || consumePair(scanner, Brackets.CurlyR, Brackets.CurlyL)) { continue; } result = scanner.pos; if (consumeArray(scanner, compiledPrefix)) { return result; } scanner.pos--; } return -1; } /** * Consumes full character pair, if possible */ function consumePair(scanner: BackwardScanner, close: number, open: number): boolean { const start = scanner.pos; if (consume(scanner, close)) { while (!sol(scanner)) { if (consume(scanner, open)) { return true; } scanner.pos--; } } scanner.pos = start; return false; } /** * Consumes all character codes from given array, right-to-left, if possible */ function consumeArray(scanner: BackwardScanner, arr: number[]) { const start = scanner.pos; let consumed = false; for (let i = arr.length - 1; i >= 0 && !sol(scanner); i--) { if (!consume(scanner, arr[i])) { break; } consumed = i === 0; } if (!consumed) { scanner.pos = start; } return consumed; } function isAbbreviation(ch: number) { return (ch > 64 && ch < 91) // uppercase letter || (ch > 96 && ch < 123) // lowercase letter || (ch > 47 && ch < 58) // number || specialChars.includes(ch); // special character } function isOpenBrace(ch: number, syntax: SyntaxType) { return ch === Brackets.RoundL || (syntax === 'markup' && (ch === Brackets.SquareL || ch === Brackets.CurlyL)); } function isCloseBrace(ch: number, syntax: SyntaxType) { return ch === Brackets.RoundR || (syntax === 'markup' && (ch === Brackets.SquareR || ch === Brackets.CurlyR)); } ================================================ FILE: src/extract-abbreviation/is-html.ts ================================================ import { isQuote, consumeQuoted } from './quotes'; import { type BackwardScanner, consume, sol, consumeWhile, peek } from './reader'; import { Brackets, bracePairs } from './brackets'; const enum Chars { Tab = 9, Space = 32, /** `-` character */ Dash = 45, /** `/` character */ Slash = 47, /** `:` character */ Colon = 58, /** `=` character */ Equals = 61, /** `<` character */ AngleLeft = 60, /** `>` character */ AngleRight = 62, } /** * Check if given reader’s current position points at the end of HTML tag */ export default function isHtml(scanner: BackwardScanner): boolean { const start = scanner.pos; if (!consume(scanner, Chars.AngleRight)) { return false; } let ok = false; consume(scanner, Chars.Slash); // possibly self-closed element while (!sol(scanner)) { consumeWhile(scanner, isWhiteSpace); if (consumeIdent(scanner)) { // ate identifier: could be a tag name, boolean attribute or unquoted // attribute value if (consume(scanner, Chars.Slash)) { // either closing tag or invalid tag ok = consume(scanner, Chars.AngleLeft); break; } else if (consume(scanner, Chars.AngleLeft)) { // opening tag ok = true; break; } else if (consume(scanner, isWhiteSpace)) { // boolean attribute continue; } else if (consume(scanner, Chars.Equals)) { // simple unquoted value or invalid attribute if (consumeIdent(scanner)) { continue; } break; } else if (consumeAttributeWithUnquotedValue(scanner)) { // identifier was a part of unquoted value ok = true; break; } // invalid tag break; } if (consumeAttribute(scanner)) { continue; } break; } scanner.pos = start; return ok; } /** * Consumes HTML attribute from given string. * @return `true` if attribute was consumed. */ function consumeAttribute(scanner: BackwardScanner): boolean { return consumeAttributeWithQuotedValue(scanner) || consumeAttributeWithUnquotedValue(scanner); } function consumeAttributeWithQuotedValue(scanner: BackwardScanner): boolean { const start = scanner.pos; if (consumeQuoted(scanner) && consume(scanner, Chars.Equals) && consumeIdent(scanner)) { return true; } scanner.pos = start; return false; } function consumeAttributeWithUnquotedValue(scanner: BackwardScanner): boolean { const start = scanner.pos; const stack: Brackets[] = []; while (!sol(scanner)) { const ch = peek(scanner); if (isCloseBracket(ch)) { stack.push(ch); } else if (isOpenBracket(ch)) { if (stack.pop() !== bracePairs[ch]) { // Unexpected open bracket break; } } else if (!isUnquotedValue(ch)) { break; } scanner.pos--; } if (start !== scanner.pos && consume(scanner, Chars.Equals) && consumeIdent(scanner)) { return true; } scanner.pos = start; return false; } /** * Consumes HTML identifier from stream */ function consumeIdent(scanner: BackwardScanner): boolean { return consumeWhile(scanner, isIdent); } /** * Check if given character code belongs to HTML identifier */ function isIdent(ch: number): boolean { return ch === Chars.Colon || ch === Chars.Dash || isAlpha(ch) || isNumber(ch); } /** * Check if given character code is alpha code (letter though A to Z) */ function isAlpha(ch: number): boolean { ch &= ~32; // quick hack to convert any char code to uppercase char code return ch >= 65 && ch <= 90; // A-Z } /** * Check if given code is a number */ function isNumber(ch: number): boolean { return ch > 47 && ch < 58; } /** * Check if given code is a whitespace */ function isWhiteSpace(ch: number): boolean { return ch === Chars.Space || ch === Chars.Tab; } /** * Check if given code may belong to unquoted attribute value */ function isUnquotedValue(ch: number): boolean { return !isNaN(ch) && ch !== Chars.Equals && !isWhiteSpace(ch) && !isQuote(ch); } function isOpenBracket(ch: number): boolean { return ch === Brackets.CurlyL || ch === Brackets.RoundL || ch === Brackets.SquareL; } function isCloseBracket(ch: number): boolean { return ch === Brackets.CurlyR || ch === Brackets.RoundR || ch === Brackets.SquareR; } ================================================ FILE: src/extract-abbreviation/quotes.ts ================================================ import { type BackwardScanner, previous, sol, peek } from './reader.js'; const enum Chars { SingleQuote = 39, DoubleQuote = 34, Escape = 92 } /** * Check if given character code is a quote */ export function isQuote(c?: number) { return c === Chars.SingleQuote || c === Chars.DoubleQuote; } /** * Consumes quoted value, if possible * @return Returns `true` is value was consumed */ export function consumeQuoted(scanner: BackwardScanner): boolean { const start = scanner.pos; const quote = previous(scanner); if (isQuote(quote)) { while (!sol(scanner)) { if (previous(scanner) === quote && peek(scanner) !== Chars.Escape) { return true; } } } scanner.pos = start; return false; } ================================================ FILE: src/extract-abbreviation/reader.ts ================================================ type Match = ((code: number) => boolean) | number; export interface BackwardScanner { /** Text to scan */ text: string; /** Left bound till given text must be scanned */ start: number; /** Current scanner position */ pos: number; } /** * Creates structure for scanning given string in backward direction */ export default function backwardScanner(text: string, start = 0): BackwardScanner { return { text, start, pos: text.length }; } /** * Check if given scanner position is at start of scanned text */ export function sol(scanner: BackwardScanner) { return scanner.pos === scanner.start; } /** * “Peeks” character code an current scanner location without advancing it */ export function peek(scanner: BackwardScanner, offset = 0) { return scanner.text.charCodeAt(scanner.pos - 1 + offset); } /** * Returns current character code and moves character location one symbol back */ export function previous(scanner: BackwardScanner) { if (!sol(scanner)) { return scanner.text.charCodeAt(--scanner.pos); } } /** * Consumes current character code if it matches given `match` code or function */ export function consume(scanner: BackwardScanner, match: Match): boolean { if (sol(scanner)) { return false; } const ok = typeof match === 'function' ? match(peek(scanner)) : match === peek(scanner); if (ok) { scanner.pos--; } return !!ok; } export function consumeWhile(scanner: BackwardScanner, match: Match): boolean { const start = scanner.pos; while (consume(scanner, match)) { // empty } return scanner.pos < start; } ================================================ FILE: src/index.ts ================================================ import markupAbbreviation, { type Abbreviation } from '@emmetio/abbreviation'; import stylesheetAbbreviation, { type CSSAbbreviation } from '@emmetio/css-abbreviation'; import parseMarkup, { stringify as stringifyMarkup } from './markup'; import parseStylesheet, { stringify as stringifyStylesheet, convertSnippets as parseStylesheetSnippets, CSSAbbreviationScope } from './stylesheet'; import resolveConfig, { type UserConfig, type Config } from './config'; export default function expandAbbreviation(abbr: string, config?: UserConfig): string { const resolvedConfig = resolveConfig(config); return resolvedConfig.type === 'stylesheet' ? stylesheet(abbr, resolvedConfig) : markup(abbr, resolvedConfig); } /** * Expands given *markup* abbreviation (e.g. regular Emmet abbreviation that * produces structured output like HTML) and outputs it according to options * provided in config */ export function markup(abbr: string | Abbreviation, config: Config) { return stringifyMarkup(parseMarkup(abbr, config), config); } /** * Expands given *stylesheet* abbreviation (a special Emmet abbreviation designed for * stylesheet languages like CSS, SASS etc.) and outputs it according to options * provided in config */ export function stylesheet(abbr: string | CSSAbbreviation, config: Config) { return stringifyStylesheet(parseStylesheet(abbr, config), config); } export { markupAbbreviation, parseMarkup, stringifyMarkup, stylesheetAbbreviation, parseStylesheet, stringifyStylesheet, parseStylesheetSnippets, CSSAbbreviationScope }; export type { Abbreviation as MarkupAbbreviation, CSSAbbreviation as StylesheetAbbreviation, }; export { default as extract, type ExtractOptions, type ExtractedAbbreviation } from './extract-abbreviation'; export { default as resolveConfig } from './config'; export type { GlobalConfig, SyntaxType, Config, UserConfig, Options, AbbreviationContext} from './config'; ================================================ FILE: src/markup/addon/bem.ts ================================================ import type { AbbreviationNode, Value } from '@emmetio/abbreviation'; import type { Container } from '../utils'; import type { Config, AbbreviationContext } from '../../config'; interface BEMAbbreviationNode extends AbbreviationNode { _bem?: BEMData; } interface BEMAbbreviationContext extends AbbreviationContext { _bem?: BEMData; } interface BEMData { classNames: string[]; block?: string ; } const reElement = /^(-+)([a-z0-9]+[a-z0-9-]*)/i; const reModifier = /^(_+)([a-z0-9]+[a-z0-9-_]*)/i; const blockCandidates1 = (className: string) => /^[a-z]\-/i.test(className); const blockCandidates2 = (className: string) => /^[a-z]/i.test(className); export default function bem(node: AbbreviationNode, ancestors: Container[], config: Config) { expandClassNames(node); expandShortNotation(node, ancestors, config); } /** * Expands existing class names in BEM notation in given `node`. * For example, if node contains `b__el_mod` class name, this method ensures * that element contains `b__el` class as well */ function expandClassNames(node: BEMAbbreviationNode) { const data = getBEMData(node); const classNames: string[] = []; for (const cl of data.classNames) { // remove all modifiers and element prefixes from class name to get a base element name const ix = cl.indexOf('_'); if (ix > 0 && !cl.startsWith('-')) { classNames.push(cl.slice(0, ix)); classNames.push(cl.slice(ix)); } else { classNames.push(cl); } } if (classNames.length) { data.classNames = classNames.filter(uniqueClass); data.block = findBlockName(data.classNames); updateClass(node, data.classNames.join(' ')); } } /** * Expands short BEM notation, e.g. `-element` and `_modifier` */ function expandShortNotation(node: BEMAbbreviationNode, ancestors: Container[], config: Config) { const data = getBEMData(node); const classNames: string[] = []; const { options } = config; const path = ancestors.slice(1).concat(node) as BEMAbbreviationNode[]; for (let cl of data.classNames) { let prefix: string = ''; let m: RegExpMatchArray | null; const originalClass = cl; // parse element definition (could be only one) if (m = cl.match(reElement)) { prefix = getBlockName(path, m[1].length, config.context) + options['bem.element'] + m[2]; classNames.push(prefix); cl = cl.slice(m[0].length); } // parse modifiers definitions if (m = cl.match(reModifier)) { if (!prefix) { prefix = getBlockName(path, m[1].length); classNames.push(prefix); } classNames.push(`${prefix}${options['bem.modifier']}${m[2]}`); cl = cl.slice(m[0].length); } if (cl === originalClass) { // class name wasn’t modified: it’s not a BEM-specific class, // add it as-is into output classNames.push(originalClass); } } const arrClassNames = classNames.filter(uniqueClass); if (arrClassNames.length) { updateClass(node, arrClassNames.join(' ')); } } /** * Returns BEM data from given abbreviation node */ function getBEMData(node: BEMAbbreviationNode): BEMData { if (!node._bem) { let classValue = ''; if (node.attributes) { for (const attr of node.attributes) { if (attr.name === 'class' && attr.value) { classValue = stringifyValue(attr.value); break; } } } node._bem = parseBEM(classValue); } return node._bem; } function getBEMDataFromContext(context: BEMAbbreviationContext) { if (!context._bem) { context._bem = parseBEM(context.attributes && context.attributes.class || ''); } return context._bem; } /** * Parses BEM data from given class name */ function parseBEM(classValue?: string): BEMData { const classNames = classValue ? classValue.split(/\s+/) : []; return { classNames, block: findBlockName(classNames) }; } /** * Returns block name for given `node` by `prefix`, which tells the depth of * of parent node lookup */ function getBlockName(ancestors: BEMAbbreviationNode[], depth: number = 0, context?: BEMAbbreviationContext): string { const maxParentIx = 0; let parentIx = Math.max(ancestors.length - depth, maxParentIx); do { const parent = ancestors[parentIx]; if (parent) { const data = getBEMData(parent as BEMAbbreviationNode); if (data.block) { return data.block; } } } while (maxParentIx < parentIx--); if (context) { const data = getBEMDataFromContext(context); if (data.block) { return data.block; } } return ''; } function findBlockName(classNames: string[]): string | undefined { return find(classNames, blockCandidates1) || find(classNames, blockCandidates2) || void 0; } /** * Finds class name from given list which may be used as block name */ function find(classNames: string[], filter: (className: string) => boolean): string | void { for (const cl of classNames) { if (reElement.test(cl) || reModifier.test(cl)) { break; } if (filter(cl)) { return cl; } } } function updateClass(node: AbbreviationNode, value: string) { for (const attr of node.attributes!) { if (attr.name === 'class') { attr.value = [value]; break; } } } function stringifyValue(value: Value[]): string { let result = ''; for (const t of value) { result += typeof t === 'string' ? t : t.name; } return result; } function uniqueClass(item: T, ix: number, arr: T[]): boolean { return !!item && arr.indexOf(item) === ix; } ================================================ FILE: src/markup/addon/label.ts ================================================ import type { AbbreviationAttribute, AbbreviationNode } from '@emmetio/abbreviation'; import { find } from '../utils'; /** * Preprocessor of `'); equal(expand('a.test', reverse), ''); equal(expand('test', opt), ''); equal(expand('test[foo]', opt), ''); equal(expand('test[baz=a foo=1]', opt), ''); equal(expand('map'), ''); equal(expand('map[]'), ''); equal(expand('map[name="valid"]'), ''); equal(expand('map[href="invalid"]'), ''); equal(expand('data'), ''); equal(expand('data[value=5]'), ''); equal(expand('meter'), ''); equal(expand('meter[min=4 max=6]'), ''); equal(expand('time'), ''); equal(expand('time[datetime=2023-07-01]'), ''); // Apply attributes in reverse order equal(expand('test', reverse), ''); equal(expand('test[foo]', reverse), ''); equal(expand('test[baz=a foo=1]', reverse), ''); }); it('expressions', () => { equal(expand('span{{foo}}'), '{foo}'); equal(expand('span{foo}'), 'foo'); equal(expand('span[foo={bar}]'), ''); equal(expand('span[foo={{bar}}]'), ''); }); it('numbering', () => { equal(expand('ul>li.item$@-*5'), '
      \n\t
    • \n\t
    • \n\t
    • \n\t
    • \n\t
    • \n
    '); }); it('syntax', () => { equal(expand('ul>.item$*2', { syntax: 'html' }), '
      \n\t
    • \n\t
    • \n
    '); equal(expand('ul>.item$*2', { syntax: 'slim' }), 'ul\n\tli.item1 \n\tli.item2 '); equal(expand('xsl:variable[name=a select=b]>div', { syntax: 'xsl' }), '\n\t
    \n
    '); }); it('custom profile', () => { equal(expand('img'), ''); equal(expand('img', { options: { 'output.selfClosingStyle': 'xhtml' } }), ''); }); it('custom variables', () => { const variables = { charset: 'ru-RU' }; equal(expand('[charset=${charset}]{${charset}}'), '
    utf-8
    '); equal(expand('[charset=${charset}]{${charset}}', { variables }), '
    ru-RU
    '); }); it('custom snippets', () => { const snippets = { link: 'link[foo=bar href]/', foo: '.foo[bar=baz]', repeat: 'div>ul>li{Hello World}*3' }; equal(expand('foo', { snippets }), '
    '); // `link:css` depends on `link` snippet so changing it will result in // altered `link:css` result equal(expand('link:css'), ''); equal(expand('link:css', { snippets }), ''); // https://github.com/emmetio/emmet/issues/468 equal(expand('repeat', { snippets }), '
    \n\t
      \n\t\t
    • Hello World
    • \n\t\t
    • Hello World
    • \n\t\t
    • Hello World
    • \n\t
    \n
    '); // https://github.com/emmetio/emmet/issues/725 equal(expand('tarea'), ''); equal(expand('tarea:c'), '') equal(expand('tarea:r'), '') equal(expand('tarea:cr'), '') }); it('formatter options', () => { equal(expand('ul>.item$*2'), '
      \n\t
    • \n\t
    • \n
    '); equal(expand('ul>.item$*2', { options: { 'comment.enabled': true } }), '
      \n\t
    • \n\t\n\t
    • \n\t\n
    '); equal(expand('div>p'), '
    \n\t

    \n
    '); equal(expand('div>p', { options: { 'output.formatLeafNode': true } }), '
    \n\t

    \n\t\t\n\t

    \n
    '); }); it('JSX', () => { const config = { syntax: 'jsx' }; equal(expand('div#foo.bar', config), '
    '); equal(expand('label[for=a]', config), ''); equal(expand('Foo.Bar', config), ''); equal(expand('div.{theme.style}', config), '
    '); }); it('override attributes', () => { const config = { syntax: 'jsx' }; equal(expand('.bar', config), '
    '); equal(expand('..bar', config), '
    '); equal(expand('..foo-bar', config), '
    '); equal(expand('.foo', { syntax: 'vue' }), '
    '); equal(expand('..foo', { syntax: 'vue' }), '
    '); }); it('overrides attributes with custom config', () => { const attrConfig = { syntax: 'jsx', options: { 'markup.attributes': { 'class': 'className', 'class*': 'classStarName', } } }; equal(expand('.foo', attrConfig), '
    '); equal(expand('..foo', attrConfig), '
    '); const prefixConfig = { syntax: 'jsx', options: { 'markup.valuePrefix': { 'class*': 'class' } } }; equal(expand('..foo', prefixConfig), '
    '); }); it('wrap with abbreviation', () => { equal(expand('div>ul', { text: ['
    line1
    \n
    line2
    '] }), '
    \n\t
      \n\t\t
      line1
      \n\t\t
      line2
      \n\t
    \n
    '); equal(expand('p', { text: 'foo\nbar' }), '

    \n\tfoo\n\tbar\n

    '); equal(expand('p', { text: '
    foo
    ' }), '

    \n\t

    foo
    \n

    '); equal(expand('p', { text: 'foo' }), '

    foo

    '); equal(expand('p', { text: 'foofoo' }), '

    foofoo

    '); equal(expand('p', { text: 'foo
    foo
    ' }), '

    foo

    foo

    '); }); it('wrap with abbreviation href', () => { equal(expand('a', { text: ['www.google.it'] }), 'www.google.it'); equal(expand('a', { text: ['then www.google.it'] }), 'then www.google.it'); equal(expand('a', { text: ['www.google.it'], options: { 'markup.href': false } }), 'www.google.it'); equal(expand('map[name="https://example.com"]', { text: ['some text'] }), 'some text'); equal(expand('map[href="https://example.com"]', { text: ['some text'] }), 'some text'); equal(expand('map[name="https://example.com"]>b', { text: ['some text'] }), 'some text'); equal(expand('a[href="https://example.com"]>b', { text: ['some text false'], options: { 'markup.href': false } }), 'some text false'); equal(expand('a[href="https://example.com"]>b', { text: ['some text true'], options: { 'markup.href': true } }), 'some text true'); equal(expand('a[href="https://example.com"]>div', { text: ['

    some text false

    '], options: { 'markup.href': false } }), '\n\t
    \n\t\t

    some text false

    \n\t
    \n
    '); equal(expand('a[href="https://example.com"]>div', { text: ['

    some text true

    '], options: { 'markup.href': true } }), '\n\t
    \n\t\t

    some text true

    \n\t
    \n
    '); }); it('class names', () => { equal(expand('div.foo/'), '
    '); equal(expand('div.foo1/2'), '
    '); equal(expand('div.foo.1/2'), '
    '); }) // it.only('debug', () => { // equal(expand('link:css'), ''); // }); }); describe('Pug templates', () => { const config = resolveConfig({ syntax: 'pug' }); it('basic', () => { equal(expand('!', config), 'doctype html\nhtml(lang="en")\n\thead\n\t\tmeta(charset="utf-8")\n\t\tmeta(name="viewport", content="width=device-width, initial-scale=1.0")\n\t\ttitle Document\n\tbody '); }); }); }); ================================================ FILE: test/extract-abbreviation.ts ================================================ import { describe, it } from 'node:test'; import { deepStrictEqual, strictEqual, ok } from 'node:assert'; import extractAbbreviation, { type ExtractOptions, type ExtractedAbbreviation } from '../src/extract-abbreviation'; import isAtHTMLTag from '../src/extract-abbreviation/is-html'; import scanner from '../src/extract-abbreviation/reader'; import { consumeQuoted } from '../src/extract-abbreviation/quotes'; function extract(abbr: string, options?: Partial) { let caretPos: number | undefined = abbr.indexOf('|'); if (caretPos !== -1) { abbr = abbr.slice(0, caretPos) + abbr.slice(caretPos + 1); } else { caretPos = void 0; } return extractAbbreviation(abbr, caretPos, options); } function result(abbreviation: string, location: number, start = location): ExtractedAbbreviation { return { abbreviation, location, start: start != null ? start : location, end: location + abbreviation.length }; } describe('Extract abbreviation', () => { it('basic', () => { deepStrictEqual(extract('.bar'), result('.bar', 0)); deepStrictEqual(extract('.foo .bar'), result('.bar', 5)); deepStrictEqual(extract('.foo @bar'), result('@bar', 5)); deepStrictEqual(extract('.foo img/'), result('img/', 5)); deepStrictEqual(extract('текстdiv'), result('div', 5)); deepStrictEqual(extract('foo div[foo="текст" bar=текст2]'), result('div[foo="текст" bar=текст2]', 4)); // https://github.com/emmetio/emmet/issues/577 deepStrictEqual( extract('table>(tr.prefix-intro>td*1)+(tr.prefix-pro-con>th*1+td*3)+(tr.prefix-key-specs>th[colspan=2]*1+td[colspan=2]*3)+(tr.prefix-key-find-online>th[colspan=2]*1+td*2)'), result('table>(tr.prefix-intro>td*1)+(tr.prefix-pro-con>th*1+td*3)+(tr.prefix-key-specs>th[colspan=2]*1+td[colspan=2]*3)+(tr.prefix-key-find-online>th[colspan=2]*1+td*2)', 0)); }); it('abbreviation with operators', () => { deepStrictEqual(extract('a foo+bar.baz'), result('foo+bar.baz', 2)); deepStrictEqual(extract('a foo>bar+baz*3'), result('foo>bar+baz*3', 2)); }); it('abbreviation with attributes', () => { deepStrictEqual(extract('a foo[bar|]'), result('foo[bar]', 2)); deepStrictEqual(extract('a foo[bar="baz" a b]'), result('foo[bar="baz" a b]', 2)); deepStrictEqual(extract('foo bar[a|] baz'), result('bar[a]', 4)); }); it('tag test', () => { deepStrictEqual(extract('bar[a b="c"]>baz'), result('bar[a b="c"]>baz', 5)); deepStrictEqual(extract('foo>bar'), result('foo>bar', 0)); deepStrictEqual(extract('bar'), result('bar', 5)); deepStrictEqual(extract('bar[a="d" b="c"]>baz'), result('bar[a="d" b="c"]>baz', 5)); }); it('stylesheet abbreviation', () => { deepStrictEqual(extract('foo{bar|}'), result('foo{bar}', 0)); deepStrictEqual(extract('foo{bar|}', { type: 'stylesheet' }), result('bar', 4)); }); it('prefixed extract', () => { deepStrictEqual(extract('bar[a b="c"]>baz'), result('bar[a b="c"]>baz', 5)); deepStrictEqual(extract('bar[a b="c"]>baz', { prefix: '<' }), result('foo>bar[a b="c"]>baz', 1, 0)); deepStrictEqual(extract('bar[a b="<"]>baz', { prefix: '<' }), result('foo>bar[a b="<"]>baz', 1, 0)); deepStrictEqual(extract('bar{<}>baz', { prefix: '<' }), result('foo>bar{<}>baz', 1, 0)); // Multiple prefix characters deepStrictEqual(extract('foo>>>bar[a b="c"]>baz', { prefix: '>>>' }), result('bar[a b="c"]>baz', 6, 3)); // Absent prefix strictEqual(extract('bar[a b="c"]>baz', { prefix: '&&' }), void 0); }); it('brackets inside curly braces', () => { deepStrictEqual(extract('foo div{[}+a{}'), result('div{[}+a{}', 4)); deepStrictEqual(extract('div{}}'), undefined); deepStrictEqual(extract('div{{}'), result('{}', 4)); }); it('HTML test', () => { const html = (str: string) => isAtHTMLTag(scanner(str)); // simple tag ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); // tag with attributes ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('
    ')); ok(html('')); // invalid tags ok(!html('div>')); ok(!html('')); ok(!html('
    ')); ok(!html('
    ')); ok(!html('[a=b c=d]>')); ok(!html('div[a=b c=d]>')); }); it('consume quotes', () => { let s = scanner(' "foo"'); ok(consumeQuoted(s)); strictEqual(s.pos, 1); s = scanner('"foo"'); ok(consumeQuoted(s)); strictEqual(s.pos, 0); s = scanner('""'); ok(consumeQuoted(s)); strictEqual(s.pos, 0); s = scanner('"a\\\"b"'); ok(consumeQuoted(s)); strictEqual(s.pos, 0); // don’t eat anything s = scanner('foo'); ok(!consumeQuoted(s)); strictEqual(s.pos, 3); }); }); ================================================ FILE: test/format.ts ================================================ import { describe, it } from 'node:test'; import { equal } from 'node:assert'; import html from '../src/markup/format/html'; import haml from '../src/markup/format/haml'; import pug from '../src/markup/format/pug'; import slim from '../src/markup/format/slim'; import parse from '../src/markup/index'; import createConfig, { Options } from '../src/config'; describe('Format', () => { const defaultConfig = createConfig(); const field = createConfig({ options: { 'output.field': (index, placeholder) => placeholder ? `\${${index}:${placeholder}}` : `\${${index}}` } }); function createProfile(options: Partial) { const config = createConfig({ options }); return config; } describe('HTML', () => { const format = (abbr: string, config = defaultConfig) => html(parse(abbr, config), config); it('basic', () => { equal(format('div>p'), '
    \n\t

    \n
    '); equal(format('div>p*3'), '
    \n\t

    \n\t

    \n\t

    \n
    '); equal(format('div#a>p.b*2>span'), '
    \n\t

    \n\t

    \n
    '); equal(format('div>div>div'), '
    \n\t
    \n\t\t
    \n\t
    \n
    '); equal(format('table>tr*2>td{item}*2'), '\n\t\n\t\t\n\t\t\n\t\n\t\n\t\t\n\t\t\n\t\n
    itemitem
    itemitem
    '); }); it('inline elements', () => { const profile = createProfile({ 'output.inlineBreak': 3 }); const breakInline = createProfile({ 'output.inlineBreak': 1 }); const keepInline = createProfile({ 'output.inlineBreak': 0 }); const xhtml = createProfile({ 'output.selfClosingStyle': 'xhtml' }); equal(format('div>a>b*3', xhtml), ''); equal(format('p>i', profile), '

    '); equal(format('p>i*2', profile), '

    '); equal(format('p>i*2', breakInline), '

    \n\t\n\t\n

    '); equal(format('p>i*3', profile), '

    \n\t\n\t\n\t\n

    '); equal(format('p>i*3', keepInline), '

    '); equal(format('i*2', profile), ''); equal(format('i*3', profile), '\n\n'); equal(format('i{a}+i{b}', profile), 'ab'); equal(format('img[src]/+p', xhtml), '\n

    '); equal(format('div>img[src]/+p', xhtml), '
    \n\t\n\t

    \n
    '); equal(format('div>p+img[src]/', xhtml), '
    \n\t

    \n\t\n
    '); equal(format('div>p+img[src]/+p', xhtml), '
    \n\t

    \n\t\n\t

    \n
    '); equal(format('div>p+img[src]/*2+p', xhtml), '
    \n\t

    \n\t\n\t

    \n
    '); equal(format('div>p+img[src]/*3+p', xhtml), '
    \n\t

    \n\t\n\t\n\t\n\t

    \n
    '); }); it('generate fields', () => { equal(format('a[href]', field), '${2}'); equal(format('a[href]*2', field), '${2}${4}'); equal(format('{${0} ${1:foo} ${2:bar}}*2', field), '${1} ${2:foo} ${3:bar}\n${4} ${5:foo} ${6:bar}'); equal(format('{${0} ${1:foo} ${2:bar}}*2'), ' foo bar\n foo bar'); equal(format('ul>li*2', field), '
      \n\t
    • ${1}
    • \n\t
    • ${2}
    • \n
    '); equal(format('div>img[src]/', field), '
    ${2}
    '); }); // it.only('debug', () => { // equal(format('div>{foo}+{bar}+p'), '
    \n\tfoobar\n\t

    \n
    '); // }); it('mixed content', () => { equal(format('div{foo}'), '
    foo
    '); equal(format('div>{foo}'), '
    foo
    '); equal(format('div>{foo}+{bar}'), '
    \n\tfoo\n\tbar\n
    '); equal(format('div>{foo}+{bar}+p'), '
    \n\tfoo\n\tbar\n\t

    \n
    '); equal(format('div>{foo}+{bar}+p+{foo}+{bar}+p'), '
    \n\tfoo\n\tbar\n\t

    \n\tfoo\n\tbar\n\t

    \n
    '); equal(format('div>{foo}+p+{bar}'), '
    \n\tfoo\n\t

    \n\tbar\n
    '); equal(format('div>{foo}>p'), '
    \n\tfoo\n\t

    \n
    '); equal(format('div>{}'), '
    '); equal(format('div>{}+p'), '
    \n\t\n\t

    \n
    '); equal(format('div>p+{}'), '
    \n\t

    \n\t\n
    '); equal(format('div>{}>p'), '
    \n\t\n
    '); equal(format('div>{}*2>p'), '
    \n\t\n\t\n
    '); equal(format('div>{}>p*2'), '
    \n\t\n
    '); equal(format('div>{}*2>p*2'), '
    \n\t\n\t\n
    '); equal(format('div>{}>b'), '
    \n\t\n
    '); equal(format('div>{}>b*2'), '
    \n\t\n
    '); equal(format('div>{}>b*3'), '
    \n\t\n
    '); equal(format('div>{}', field), '
    '); equal(format('div>{}>b', field), '
    \n\t\n
    '); }); it('self-closing', () => { const xmlStyle = createProfile({ 'output.selfClosingStyle': 'xml' }); const htmlStyle = createProfile({ 'output.selfClosingStyle': 'html' }); const xhtmlStyle = createProfile({ 'output.selfClosingStyle': 'xhtml' }); equal(format('img[src]/', htmlStyle), ''); equal(format('img[src]/', xhtmlStyle), ''); equal(format('img[src]/', xmlStyle), ''); equal(format('div>img[src]/', xhtmlStyle), '
    '); }); it('boolean attributes', () => { const compact = createProfile({ 'output.compactBoolean': true }); const noCompact = createProfile({ 'output.compactBoolean': false }); equal(format('p[b.]', noCompact), '

    '); equal(format('p[b.]', compact), '

    '); equal(format('p[contenteditable]', compact), '

    '); equal(format('p[contenteditable]', noCompact), '

    '); equal(format('p[contenteditable=foo]', compact), '

    '); }); it('no formatting', () => { const profile = createProfile({ 'output.format': false }); equal(format('div>p', profile), '

    '); equal(format('div>{foo}+p+{bar}', profile), '
    foo

    bar
    '); equal(format('div>{foo}>p', profile), '
    foo

    '); equal(format('div>{}>p', profile), '
    '); }); it('format specific nodes', () => { equal(format('{}+html>(head>meta[charset=${charset}]/+title{${1:Document}})+body', field), '\n\n\n\t\n\t${2:Document}\n\n\n\t${3}\n\n'); }); it('comment', () => { const opt = createConfig({ options: { 'comment.enabled': true } }); equal(format('ul>li.item', opt), '
      \n\t
    • \n\t\n
    '); equal(format('div>ul>li.item#foo', opt), '
    \n\t
      \n\t\t
    • \n\t\t\n\t
    \n
    '); opt.options['comment.after'] = ' { [%ID] }'; equal(format('div>ul>li.item#foo', opt), '
    \n\t
      \n\t\t
    • { %foo }\n\t
    \n
    '); }); it('jsx', () => { const config = createConfig({ syntax: 'jsx', options: { 'markup.attributes': { 'class': 'className', 'class*': 'className', }, 'markup.valuePrefix': { 'class*': 'styles', }, 'output.field': (index) => `\${${index}}`, }, }); equal(format('.', config), '
    ${2}
    '); equal(format('..', config), '
    ${2}
    '); equal(format('div.', config), '
    ${2}
    '); equal(format('div..', config), '
    ${2}
    '); equal(format('div.a', config), '
    ${1}
    '); equal(format('div..a', config), '
    ${1}
    '); equal(format('div.a.b', config), '
    ${1}
    '); equal(format('div.a..b', config), '
    ${1}
    '); }); }); describe('HAML', () => { const format = (abbr: string, config = defaultConfig) => haml(parse(abbr, config), config); it('basic', () => { equal(format('div#header>ul.nav>li[title=test].nav-item*2'), '#header\n\t%ul.nav\n\t\t%li.nav-item(title="test") \n\t\t%li.nav-item(title="test") '); // https://github.com/emmetio/emmet/issues/446 equal(format('li>a'), '%li\n\t%a(href="") '); equal(format('div#foo[data-n1=v1 title=test data-n2=v2].bar'), '#foo.bar(data-n1="v1" title="test" data-n2="v2") '); let profile = createProfile({ 'output.compactBoolean': true }); equal(format('input[disabled. foo title=test]/', profile), '%input(type="text" disabled foo="" title="test")/'); profile = createProfile({ 'output.compactBoolean': false }); equal(format('input[disabled. foo title=test]/', profile), '%input(type="text" disabled=true foo="" title="test")/'); }); it('nodes with text', () => { equal(format('{Text 1}'), 'Text 1'); equal(format('span{Text 1}'), '%span Text 1'); equal(format('span{Text 1}>b{Text 2}'), '%span Text 1\n\t%b Text 2'); equal(format('span{Text 1\nText 2}>b{Text 3}'), '%span\n\tText 1 |\n\tText 2 |\n\t%b Text 3'); equal(format('div>span{Text 1\nText 2\nText 123}>b{Text 3}'), '%div\n\t%span\n\t\tText 1 |\n\t\tText 2 |\n\t\tText 123 |\n\t\t%b Text 3'); }); it('generate fields', () => { equal(format('a[href]', field), '%a(href="${1}") ${2}'); equal(format('a[href]*2', field), '%a(href="${1}") ${2}\n%a(href="${3}") ${4}'); equal(format('{${0} ${1:foo} ${2:bar}}*2', field), '${1} ${2:foo} ${3:bar}${4} ${5:foo} ${6:bar}'); equal(format('{${0} ${1:foo} ${2:bar}}*2'), ' foo bar foo bar'); equal(format('ul>li*2', field), '%ul\n\t%li ${1}\n\t%li ${2}'); equal(format('div>img[src]/', field), '%div\n\t%img(src="${1}" alt="${2}")/'); }); }); describe('Pug', () => { const format = (abbr: string, config = defaultConfig) => pug(parse(abbr, config), config); it('basic', () => { equal(format('div#header>ul.nav>li[title=test].nav-item*2'), '#header\n\tul.nav\n\t\tli.nav-item(title="test") \n\t\tli.nav-item(title="test") '); equal(format('div#foo[data-n1=v1 title=test data-n2=v2].bar'), '#foo.bar(data-n1="v1", title="test", data-n2="v2") '); equal(format('input[disabled. foo title=test]'), 'input(type="text", disabled, foo="", title="test")'); // Use closing slash for XML output format equal(format('input[disabled. foo title=test]', createProfile({ 'output.selfClosingStyle': 'xml' })), 'input(type="text", disabled, foo="", title="test")/'); }); it('nodes with text', () => { equal(format('{Text 1}'), 'Text 1'); equal(format('span{Text 1}'), 'span Text 1'); equal(format('span{Text 1}>b{Text 2}'), 'span Text 1\n\tb Text 2'); equal(format('span{Text 1\nText 2}>b{Text 3}'), 'span\n\t| Text 1\n\t| Text 2\n\tb Text 3'); equal(format('div>span{Text 1\nText 2}>b{Text 3}'), 'div\n\tspan\n\t\t| Text 1\n\t\t| Text 2\n\t\tb Text 3'); }); it('generate fields', () => { equal(format('a[href]', field), 'a(href="${1}") ${2}'); equal(format('a[href]*2', field), 'a(href="${1}") ${2}\na(href="${3}") ${4}'); equal(format('{${0} ${1:foo} ${2:bar}}*2', field), '${1} ${2:foo} ${3:bar}${4} ${5:foo} ${6:bar}'); equal(format('{${0} ${1:foo} ${2:bar}}*2'), ' foo bar foo bar'); equal(format('ul>li*2', field), 'ul\n\tli ${1}\n\tli ${2}'); equal(format('div>img[src]/', field), 'div\n\timg(src="${1}", alt="${2}")'); }); }); describe('Slim', () => { const format = (abbr: string, config = defaultConfig) => slim(parse(abbr, config), config); it('basic', () => { equal(format('div#header>ul.nav>li[title=test].nav-item*2'), '#header\n\tul.nav\n\t\tli.nav-item title="test" \n\t\tli.nav-item title="test" '); equal(format('div#foo[data-n1=v1 title=test data-n2=v2].bar'), '#foo.bar data-n1="v1" title="test" data-n2="v2" '); // const profile = createProfile({ inlineBreak: 0 }); // equal(format('ul>li>span{Text}', profile), 'ul\n\tli: span Text'); // equal(format('ul>li>span{Text}'), 'ul\n\tli\n\t\tspan Text'); // equal(format('ul>li>span{Text}*2', profile), 'ul\n\tli\n\t\tspan Text\n\t\tspan Text'); }); // it.skip('attribute wrappers', () => { // equal(format('input[disabled. foo title=test]'), 'input disabled=true foo="" title="test"'); // equal(format('input[disabled. foo title=test]', null, { attributeWrap: 'round' }), // 'input(disabled foo="" title="test")'); // }); it('nodes with text', () => { equal(format('{Text 1}'), 'Text 1'); equal(format('span{Text 1}'), 'span Text 1'); equal(format('span{Text 1}>b{Text 2}'), 'span Text 1\n\tb Text 2'); equal(format('span{Text 1\nText 2}>b{Text 3}'), 'span\n\t| Text 1\n\t| Text 2\n\tb Text 3'); equal(format('div>span{Text 1\nText 2}>b{Text 3}'), 'div\n\tspan\n\t\t| Text 1\n\t\t| Text 2\n\t\tb Text 3'); }); it('generate fields', () => { equal(format('a[href]', field), 'a href="${1}" ${2}'); equal(format('a[href]*2', field), 'a href="${1}" ${2}\na href="${3}" ${4}'); equal(format('{${0} ${1:foo} ${2:bar}}*2', field), '${1} ${2:foo} ${3:bar}${4} ${5:foo} ${6:bar}'); equal(format('{${0} ${1:foo} ${2:bar}}*2'), ' foo bar foo bar'); equal(format('ul>li*2', field), 'ul\n\tli ${1}\n\tli ${2}'); equal(format('div>img[src]/', field), 'div\n\timg src="${1}" alt="${2}"/'); }); }); }); ================================================ FILE: test/lorem.ts ================================================ import { describe, it } from 'node:test'; import { ok, strictEqual as equal } from 'node:assert'; import expand from '../src'; function wordCount(str: string): number { return str.split(' ').length; } function splitLines(str: string): string[] { return str.split(/\n/); } describe('Lorem Ipsum generator', () => { it('single', () => { let output = expand('lorem'); ok(/^Lorem,?\sipsum/.test(output)); ok(wordCount(output) > 20); output = expand('lorem5'); ok(/^Lorem,?\sipsum/.test(output)); equal(wordCount(output), 5); output = expand('lorem5-10'); ok(/^Lorem,?\sipsum/.test(output)); ok(wordCount(output) >= 5 && wordCount(output) <= 10); output = expand('loremru4'); ok(/^Далеко-далеко,?\sза,?\sсловесными/.test(output)); equal(wordCount(output), 4); output = expand('p>lorem'); ok(/^

    Lorem,?\sipsum/.test(output)); // https://github.com/emmetio/expand-abbreviation/issues/24 output = expand('(p)lorem2'); ok(/^

    <\/p>\nLorem,?\sipsum/.test(output)); output = expand('p(lorem10)'); ok(/^

    <\/p>\nLorem,?\sipsum/.test(output)); }); it('multiple', () => { let output = expand('lorem6*3'); let lines = splitLines(output); ok(/^Lorem,?\sipsum/.test(output)); equal(lines.length, 3); output = expand('lorem6*2'); lines = splitLines(output); ok(/^Lorem,?\sipsum/.test(output)); equal(lines.length, 2); output = expand('p*3>lorem'); lines = splitLines(output); ok(/^

    Lorem,?\sipsum/.test(lines[0]!)); ok(!/^

    Lorem,?\sipsum/.test(lines[1]!)); output = expand('ul>lorem5*3', { options: { 'output.indent': '' } }); lines = splitLines(output); equal(lines.length, 5); ok(/^

  • Lorem,?\sipsum/.test(lines[1]!)); ok(!/^
  • Lorem,?\sipsum/.test(lines[2]!)); }); }); ================================================ FILE: test/markup.ts ================================================ import { describe, it } from 'node:test'; import { strictEqual as equal } from 'node:assert'; import parse from '../src/markup'; import resolveConfig from '../src/config'; import stringify from './assets/stringify'; const defaultConfig = resolveConfig({ cache: {} }); function expand(abbr: string, config = defaultConfig): string { return stringify(parse(abbr, config)); } describe('Markup abbreviations', () => { it('implicit tags', () => { equal(expand('.'), '
    '); equal(expand('.foo>.bar'), '
    '); equal(expand('p.foo>.bar'), '

    '); equal(expand('ul>.item*2'), '
    '); equal(expand('table>.row>.cell'), '
    '); equal(expand('{test}'), 'test'); equal(expand('.{test}'), '
    test
    '); equal(expand('ul>.item$*2'), '
    '); }); it('XSL', () => { const config = resolveConfig({ syntax: 'xsl' }); equal(expand('xsl:variable[select]', config), ''); equal(expand('xsl:with-param[select]', config), ''); equal(expand('xsl:variable[select]>div', config), '
    '); equal(expand('xsl:with-param[select]{foo}', config), 'foo'); }); it('