| 1 | 'use strict';
|
|---|
| 2 | const { stringify } = require('postcss-value-parser');
|
|---|
| 3 |
|
|---|
| 4 | /**
|
|---|
| 5 | * @param {string[]} list
|
|---|
| 6 | * @return {string[]}
|
|---|
| 7 | */
|
|---|
| 8 | function uniqueFontFamilies(list) {
|
|---|
| 9 | return list.filter((item, i) => {
|
|---|
| 10 | if (item.toLowerCase() === 'monospace') {
|
|---|
| 11 | return true;
|
|---|
| 12 | }
|
|---|
| 13 | return i === list.indexOf(item);
|
|---|
| 14 | });
|
|---|
| 15 | }
|
|---|
| 16 |
|
|---|
| 17 | const globalKeywords = ['inherit', 'initial', 'unset'];
|
|---|
| 18 | const genericFontFamilykeywords = new Set([
|
|---|
| 19 | 'sans-serif',
|
|---|
| 20 | 'serif',
|
|---|
| 21 | 'fantasy',
|
|---|
| 22 | 'cursive',
|
|---|
| 23 | 'monospace',
|
|---|
| 24 | 'system-ui',
|
|---|
| 25 | ]);
|
|---|
| 26 |
|
|---|
| 27 | /**
|
|---|
| 28 | * @param {string} value
|
|---|
| 29 | * @param {number} length
|
|---|
| 30 | * @return {string[]}
|
|---|
| 31 | */
|
|---|
| 32 | function makeArray(value, length) {
|
|---|
| 33 | let array = [];
|
|---|
| 34 | while (length--) {
|
|---|
| 35 | array[length] = value;
|
|---|
| 36 | }
|
|---|
| 37 | return array;
|
|---|
| 38 | }
|
|---|
| 39 |
|
|---|
| 40 | // eslint-disable-next-line no-useless-escape
|
|---|
| 41 | const regexSimpleEscapeCharacters = /[ !"#$%&'()*+,.\/;<=>?@\[\\\]^`{|}~]/;
|
|---|
| 42 |
|
|---|
| 43 | /**
|
|---|
| 44 | * @param {string} string
|
|---|
| 45 | * @param {boolean} escapeForString
|
|---|
| 46 | * @return {string}
|
|---|
| 47 | */
|
|---|
| 48 | function escape(string, escapeForString) {
|
|---|
| 49 | let counter = 0;
|
|---|
| 50 | let character;
|
|---|
| 51 | let charCode;
|
|---|
| 52 | let value;
|
|---|
| 53 | let output = '';
|
|---|
| 54 |
|
|---|
| 55 | while (counter < string.length) {
|
|---|
| 56 | character = string.charAt(counter++);
|
|---|
| 57 | charCode = character.charCodeAt(0);
|
|---|
| 58 |
|
|---|
| 59 | // \r is already tokenized away at this point
|
|---|
| 60 | // `:` can be escaped as `\:`, but that fails in IE < 8
|
|---|
| 61 | if (!escapeForString && /[\t\n\v\f:]/.test(character)) {
|
|---|
| 62 | value = '\\' + charCode.toString(16) + ' ';
|
|---|
| 63 | } else if (
|
|---|
| 64 | !escapeForString &&
|
|---|
| 65 | regexSimpleEscapeCharacters.test(character)
|
|---|
| 66 | ) {
|
|---|
| 67 | value = '\\' + character;
|
|---|
| 68 | } else {
|
|---|
| 69 | value = character;
|
|---|
| 70 | }
|
|---|
| 71 |
|
|---|
| 72 | output += value;
|
|---|
| 73 | }
|
|---|
| 74 |
|
|---|
| 75 | if (!escapeForString) {
|
|---|
| 76 | if (/^-[-\d]/.test(output)) {
|
|---|
| 77 | output = '\\-' + output.slice(1);
|
|---|
| 78 | }
|
|---|
| 79 |
|
|---|
| 80 | const firstChar = string.charAt(0);
|
|---|
| 81 |
|
|---|
| 82 | if (/\d/.test(firstChar)) {
|
|---|
| 83 | output = '\\3' + firstChar + ' ' + output.slice(1);
|
|---|
| 84 | }
|
|---|
| 85 | }
|
|---|
| 86 |
|
|---|
| 87 | return output;
|
|---|
| 88 | }
|
|---|
| 89 |
|
|---|
| 90 | const regexKeyword = new RegExp(
|
|---|
| 91 | [...genericFontFamilykeywords].concat(globalKeywords).join('|'),
|
|---|
| 92 | 'i'
|
|---|
| 93 | );
|
|---|
| 94 | const regexInvalidIdentifier = /^(-?\d|--)/;
|
|---|
| 95 | const regexSpaceAtStart = /^\x20/;
|
|---|
| 96 | const regexWhitespace = /[\t\n\f\r\x20]/g;
|
|---|
| 97 | const regexIdentifierCharacter = /^[a-zA-Z\d\xa0-\uffff_-]+$/;
|
|---|
| 98 | const regexConsecutiveSpaces = /(\\(?:[a-fA-F0-9]{1,6}\x20|\x20))?(\x20{2,})/g;
|
|---|
| 99 | const regexTrailingEscape = /\\[a-fA-F0-9]{0,6}\x20$/;
|
|---|
| 100 | const regexTrailingSpace = /\x20$/;
|
|---|
| 101 |
|
|---|
| 102 | /**
|
|---|
| 103 | * @param {string} string
|
|---|
| 104 | * @return {string}
|
|---|
| 105 | */
|
|---|
| 106 | function escapeIdentifierSequence(string) {
|
|---|
| 107 | let identifiers = string.split(regexWhitespace);
|
|---|
| 108 | let index = 0;
|
|---|
| 109 | /** @type {string[] | string} */
|
|---|
| 110 | let result = [];
|
|---|
| 111 | let escapeResult;
|
|---|
| 112 |
|
|---|
| 113 | while (index < identifiers.length) {
|
|---|
| 114 | let subString = identifiers[index++];
|
|---|
| 115 |
|
|---|
| 116 | if (subString === '') {
|
|---|
| 117 | result.push(subString);
|
|---|
| 118 | continue;
|
|---|
| 119 | }
|
|---|
| 120 |
|
|---|
| 121 | escapeResult = escape(subString, false);
|
|---|
| 122 |
|
|---|
| 123 | if (regexIdentifierCharacter.test(subString)) {
|
|---|
| 124 | // the font family name part consists of allowed characters exclusively
|
|---|
| 125 | if (regexInvalidIdentifier.test(subString)) {
|
|---|
| 126 | // the font family name part starts with two hyphens, a digit, or a
|
|---|
| 127 | // hyphen followed by a digit
|
|---|
| 128 | if (index === 1) {
|
|---|
| 129 | // if this is the first item
|
|---|
| 130 | result.push(escapeResult);
|
|---|
| 131 | } else {
|
|---|
| 132 | // if it’s not the first item, we can simply escape the space
|
|---|
| 133 | // between the two identifiers to merge them into a single
|
|---|
| 134 | // identifier rather than escaping the start characters of the
|
|---|
| 135 | // second identifier
|
|---|
| 136 | result[index - 2] += '\\';
|
|---|
| 137 | result.push(escape(subString, true));
|
|---|
| 138 | }
|
|---|
| 139 | } else {
|
|---|
| 140 | // the font family name part doesn’t start with two hyphens, a digit,
|
|---|
| 141 | // or a hyphen followed by a digit
|
|---|
| 142 | result.push(escapeResult);
|
|---|
| 143 | }
|
|---|
| 144 | } else {
|
|---|
| 145 | // the font family name part contains invalid identifier characters
|
|---|
| 146 | result.push(escapeResult);
|
|---|
| 147 | }
|
|---|
| 148 | }
|
|---|
| 149 |
|
|---|
| 150 | result = result.join(' ').replace(regexConsecutiveSpaces, ($0, $1, $2) => {
|
|---|
| 151 | const spaceCount = $2.length;
|
|---|
| 152 | const escapesNeeded = Math.floor(spaceCount / 2);
|
|---|
| 153 | const array = makeArray('\\ ', escapesNeeded);
|
|---|
| 154 |
|
|---|
| 155 | if (spaceCount % 2) {
|
|---|
| 156 | array[escapesNeeded - 1] += '\\ ';
|
|---|
| 157 | }
|
|---|
| 158 |
|
|---|
| 159 | return ($1 || '') + ' ' + array.join(' ');
|
|---|
| 160 | });
|
|---|
| 161 |
|
|---|
| 162 | // Escape trailing spaces unless they’re already part of an escape
|
|---|
| 163 | if (regexTrailingSpace.test(result) && !regexTrailingEscape.test(result)) {
|
|---|
| 164 | result = result.replace(regexTrailingSpace, '\\ ');
|
|---|
| 165 | }
|
|---|
| 166 |
|
|---|
| 167 | if (regexSpaceAtStart.test(result)) {
|
|---|
| 168 | result = '\\ ' + result.slice(1);
|
|---|
| 169 | }
|
|---|
| 170 |
|
|---|
| 171 | return result;
|
|---|
| 172 | }
|
|---|
| 173 | /**
|
|---|
| 174 | * @param {import('postcss-value-parser').Node[]} nodes
|
|---|
| 175 | * @param {import('../index').Options} opts
|
|---|
| 176 | * @return {import('postcss-value-parser').WordNode[]}
|
|---|
| 177 | */
|
|---|
| 178 | module.exports = function (nodes, opts) {
|
|---|
| 179 | /** @type {import('postcss-value-parser').Node[]} */
|
|---|
| 180 | const family = [];
|
|---|
| 181 | /** @type {import('postcss-value-parser').WordNode | null} */
|
|---|
| 182 | let last = null;
|
|---|
| 183 | let i, max;
|
|---|
| 184 |
|
|---|
| 185 | nodes.forEach((node, index, arr) => {
|
|---|
| 186 | if (node.type === 'string' || node.type === 'function') {
|
|---|
| 187 | family.push(node);
|
|---|
| 188 | } else if (node.type === 'word') {
|
|---|
| 189 | if (!last) {
|
|---|
| 190 | last = /** @type {import('postcss-value-parser').WordNode} */ ({
|
|---|
| 191 | type: 'word',
|
|---|
| 192 | value: '',
|
|---|
| 193 | });
|
|---|
| 194 | family.push(last);
|
|---|
| 195 | }
|
|---|
| 196 |
|
|---|
| 197 | last.value += node.value;
|
|---|
| 198 | } else if (node.type === 'space') {
|
|---|
| 199 | if (last && index !== arr.length - 1) {
|
|---|
| 200 | last.value += ' ';
|
|---|
| 201 | }
|
|---|
| 202 | } else {
|
|---|
| 203 | last = null;
|
|---|
| 204 | }
|
|---|
| 205 | });
|
|---|
| 206 |
|
|---|
| 207 | let normalizedFamilies = family.map((node) => {
|
|---|
| 208 | if (node.type === 'string') {
|
|---|
| 209 | const isKeyword = regexKeyword.test(node.value);
|
|---|
| 210 |
|
|---|
| 211 | if (
|
|---|
| 212 | !opts.removeQuotes ||
|
|---|
| 213 | isKeyword ||
|
|---|
| 214 | /[0-9]/.test(node.value.slice(0, 1))
|
|---|
| 215 | ) {
|
|---|
| 216 | return stringify(node);
|
|---|
| 217 | }
|
|---|
| 218 |
|
|---|
| 219 | let escaped = escapeIdentifierSequence(node.value);
|
|---|
| 220 |
|
|---|
| 221 | if (escaped.length < node.value.length + 2) {
|
|---|
| 222 | return escaped;
|
|---|
| 223 | }
|
|---|
| 224 | }
|
|---|
| 225 |
|
|---|
| 226 | return stringify(node);
|
|---|
| 227 | });
|
|---|
| 228 |
|
|---|
| 229 | if (opts.removeAfterKeyword) {
|
|---|
| 230 | for (i = 0, max = normalizedFamilies.length; i < max; i += 1) {
|
|---|
| 231 | if (genericFontFamilykeywords.has(normalizedFamilies[i].toLowerCase())) {
|
|---|
| 232 | normalizedFamilies = normalizedFamilies.slice(0, i + 1);
|
|---|
| 233 | break;
|
|---|
| 234 | }
|
|---|
| 235 | }
|
|---|
| 236 | }
|
|---|
| 237 |
|
|---|
| 238 | if (opts.removeDuplicates) {
|
|---|
| 239 | normalizedFamilies = uniqueFontFamilies(normalizedFamilies);
|
|---|
| 240 | }
|
|---|
| 241 |
|
|---|
| 242 | return [
|
|---|
| 243 | /** @type {import('postcss-value-parser').WordNode} */ ({
|
|---|
| 244 | type: 'word',
|
|---|
| 245 | value: normalizedFamilies.join(),
|
|---|
| 246 | }),
|
|---|
| 247 | ];
|
|---|
| 248 | };
|
|---|