postcss-minify-font-values
Version:
Minify font declarations with PostCSS
185 lines (177 loc) • 5.7 kB
JavaScript
import cssnanoUtils from 'cssnano-utils';
import cssWideKeywords from '../data/cssWideKeywords.json' with { type: 'json' };
const { TokenType, asciiLowerCase, balancedTokens, decoded } = cssnanoUtils;
/* `default` and `none` are reserved for font family names although they are
* not CSS-wide. */
const globalKeywords = new Set([
...cssWideKeywords.keywords,
'default',
'none',
]);
const generic = new Set([
'sans-serif',
'serif',
'fantasy',
'cursive',
'monospace',
'system-ui',
'math',
'ui-serif',
'ui-sans-serif',
'ui-monospace',
'ui-rounded',
'emoji',
'fangsong',
]);
const completeGeneric = new Set([
'sans-serif',
'serif',
'fantasy',
'cursive',
'monospace',
'system-ui',
]);
const systemFont = new Set([
'caption',
'icon',
'menu',
'message-box',
'small-caption',
'status-bar',
]);
const digit = /^\d/v;
const escapeCharacter = /[\t\n\v\f:]/v;
const simpleEscape = /[ !"#$%\x26'\(\)*+,.\/<=>?@\[\\\]^`\{\|\}~]/v;
const invalidIdentifier = /^(-?\d|--)/v;
const identifier = /^[a-zA-Z\d\xa0-\uffff_\-]+$/v;
/** @param {string} value */
function containsReservedComponent(value) {
return value.split(/[\t\n\f\r ]+/v).some((part) => {
const name = asciiLowerCase(part);
return (
generic.has(name) || globalKeywords.has(name) || systemFont.has(name)
);
});
}
/** @param {string} part @param {boolean} stringContext */
function escape(part, stringContext) {
let result = '';
for (const character of part) {
if (!stringContext && escapeCharacter.test(character)) {
result += `\\${character.codePointAt(0)?.toString(16)} `;
} else {
result +=
!stringContext && simpleEscape.test(character)
? `\\${character}`
: character;
}
}
if (!stringContext && invalidIdentifier.test(result)) {
result = result.startsWith('-')
? `\\-${result.slice(1)}`
: `\\3${result[0]} ${result.slice(1)}`;
}
return result;
}
/** @param {string} string */
function escapeIdentifierSequence(string) {
const parts = string.split(/[\t\n\f\r ]/gv);
const escapedParts = [];
for (const [index, part] of parts.entries()) {
if (!part) {
escapedParts.push(part);
continue;
}
if (identifier.test(part) && invalidIdentifier.test(part) && index > 0) {
escapedParts[index - 1] = `${escapedParts[index - 1]}\\`;
escapedParts.push(escape(part, true));
continue;
}
escapedParts.push(escape(part, false));
}
let result = escapedParts.join(' ');
result = result.replaceAll(
/(\\(?:[a-fA-F0-9]{1,6} | ))?( {2,})/gv,
(_, prefix, spaces) => {
const escaped = Array.from(
{ length: Math.ceil(spaces.length / 2) },
() => '\\ '
);
if (spaces.length % 2) escaped[escaped.length - 1] += '\\ ';
return (prefix ?? '') + ' ' + escaped.join(' ');
}
);
if (result.endsWith(' ') && !/\\[a-fA-F0-9]{0,6} $/v.test(result))
result = `${result.slice(0, -1)}\\ `;
return result.startsWith(' ') ? `\\ ${result.slice(1)}` : result;
}
/** @param {string} value @param {import('../index.js').Options} opts @return {string} */
function minifyFamily(value, opts, removeQuotes = opts.removeQuotes) {
if (Array.isArray(value))
throw new TypeError('minifyFamily accepts a CSS value string');
const balanced = balancedTokens(value);
if (!balanced) return value;
const input = balanced.tokens;
/** @type {{value: string, folded: string, completeGeneric: boolean}[]} */
const families = [];
/** @param {number} startIndex @param {number} endIndex */
const finish = (startIndex, endIndex) => {
const familyTokens = input.slice(startIndex, endIndex);
const meaningful = familyTokens.filter(
(item) =>
item[0] !== TokenType.Whitespace && item[0] !== TokenType.Comment
);
/** @type {import('@csstools/css-tokenizer').CSSToken|undefined} */
const token = meaningful.length === 1 ? meaningful[0] : undefined;
let family = familyTokens
.map((item) => (item[0] === TokenType.Whitespace ? '\0' : item[1]))
.join('')
.replaceAll(/^\0+|\0+$/gv, '')
.replaceAll(/\0+/gv, ' ');
const raw = token && decoded(token);
const isReservedString =
typeof raw === 'string' && containsReservedComponent(raw);
if (
typeof raw === 'string' &&
token?.[0] === TokenType.String &&
!token[1].includes('\\') &&
removeQuotes &&
!isReservedString &&
!/^generic\([^\)]*\)$/v.test(asciiLowerCase(raw)) &&
!digit.test(raw)
) {
const escaped = escapeIdentifierSequence(raw);
if (escaped.length < raw.length + 2) family = escaped;
}
families.push({
value: family,
folded:
typeof raw === 'string' && !isReservedString
? asciiLowerCase(raw)
: asciiLowerCase(family),
completeGeneric:
meaningful.length === 1 &&
token?.[0] === TokenType.Ident &&
completeGeneric.has(asciiLowerCase(decoded(token))),
});
};
for (const segment of balanced.topLevelSegments()) {
finish(segment.startIndex, segment.endIndex);
}
let result = families.filter((family) => family.value);
if (opts.removeAfterKeyword) {
const index = result.findIndex((family) => family.completeGeneric);
if (index !== -1) result = result.slice(0, index + 1);
}
if (opts.removeDuplicates) {
const seen = new Set();
result = result.filter((item) => {
if (item.folded === 'monospace') return true;
if (seen.has(item.folded)) return false;
seen.add(item.folded);
return true;
});
}
return result.map((family) => family.value).join(',');
}
export default minifyFamily;