'use strict'; const findFontFamily = require('../../utils/findFontFamily'); const isStandardSyntaxValue = require('../../utils/isStandardSyntaxValue'); const isVariable = require('../../utils/isVariable'); const { fontFamilyKeywords } = require('../../reference/keywords'); const report = require('../../utils/report'); const ruleMessages = require('../../utils/ruleMessages'); const validateOptions = require('../../utils/validateOptions'); const ruleName = 'font-family-name-quotes'; const messages = ruleMessages(ruleName, { expected: (family) => `Expected quotes around "${family}"`, rejected: (family) => `Unexpected quotes around "${family}"`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/font-family-name-quotes', fixable: true, }; /** * @param {string} font * @returns {boolean} */ function isSystemFontKeyword(font) { if (font.startsWith('-apple-')) { return true; } if (font === 'BlinkMacSystemFont') { return true; } return false; } /** * "To avoid mistakes in escaping, it is recommended to quote font family names * that contain white space, digits, or punctuation characters other than hyphens" * (https://www.w3.org/TR/CSS2/fonts.html#font-family-prop) * * @param {string} family * @returns {boolean} */ function quotesRecommended(family) { return !/^[-a-zA-Z]+$/.test(family); } /** * Quotes are required if the family is not a valid CSS identifier * (regexes from https://mathiasbynens.be/notes/unquoted-font-family) * * @param {string} family * @returns {boolean} */ function quotesRequired(family) { return family .split(/\s+/) .some((word) => /^(?:-?\d|--)/.test(word) || !/^[-\w\u{00A0}-\u{10FFFF}]+$/u.test(word)); } /** * @typedef {{ * name: string, * rawName: string, * hasQuotes: boolean, * sourceIndex: number, * resetIndexes: (offset: number) => void, * removeQuotes: () => void, * addQuotes: () => void, * }} MutableNode */ /** * * @param {import('postcss-value-parser').Node[]} fontFamilies * @param {import('postcss').Declaration} decl * @returns {MutableNode[]} */ const makeMutableFontFamilies = (fontFamilies, decl) => { /** * @type {MutableNode[]} */ const mutableNodes = []; fontFamilies.forEach((fontFamily, idx) => { const quote = 'quote' in fontFamily && fontFamily.quote; const name = fontFamily.value; /** @type {MutableNode} */ const newNode = { name, rawName: quote ? `${quote}${name}${quote}` : name, sourceIndex: fontFamily.sourceIndex, hasQuotes: Boolean(quote), resetIndexes(offset) { mutableNodes.slice(idx + 1).forEach((n) => (n.sourceIndex += offset)); }, removeQuotes() { if (this.hasQuotes === false) return; const openIndex = this.sourceIndex; const closeIndex = openIndex + this.name.length + 2; this.hasQuotes = false; decl.value = decl.value.slice(0, openIndex) + this.name + decl.value.substring(closeIndex); this.resetIndexes(-2); }, addQuotes() { if (this.hasQuotes === true) return; const openIndex = this.sourceIndex; const closeIndex = openIndex + this.name.length; this.hasQuotes = true; const fixedName = `"${this.name}"`; decl.value = decl.value.slice(0, openIndex) + fixedName + decl.value.substring(closeIndex); this.resetIndexes(2); }, }; mutableNodes.push(newNode); }); return mutableNodes; }; /** @type {import('stylelint').Rule} */ const rule = (primary, _secondary, context) => { return (root, result) => { const validOptions = validateOptions(result, ruleName, { actual: primary, possible: ['always-where-required', 'always-where-recommended', 'always-unless-keyword'], }); if (!validOptions) { return; } root.walkDecls(/^font(-family)?$/i, (decl) => { if (!isStandardSyntaxValue(decl.value)) { return; } let fontFamilyNodes = makeMutableFontFamilies(findFontFamily(decl.value), decl); if (fontFamilyNodes.length === 0) { return; } for (const fontFamilyNode of fontFamilyNodes) { checkFamilyName(fontFamilyNode, decl); } }); /** * @param {MutableNode} fontFamilyNode * @param {import('postcss').Declaration} decl */ function checkFamilyName(fontFamilyNode, decl) { const { name: family, rawName: rawFamily, hasQuotes } = fontFamilyNode; if (isVariable(rawFamily)) { return; } // Disallow quotes around (case-insensitive) keywords // and system font keywords in all cases if (fontFamilyKeywords.has(family.toLowerCase()) || isSystemFontKeyword(family)) { if (hasQuotes) { if (context.fix) { fontFamilyNode.removeQuotes(); return; } return complain(messages.rejected(family), rawFamily, decl); } return; } const required = quotesRequired(family); const recommended = quotesRecommended(family); switch (primary) { case 'always-unless-keyword': if (!hasQuotes) { if (context.fix) { fontFamilyNode.addQuotes(); return; } return complain(messages.expected(family), rawFamily, decl); } return; case 'always-where-recommended': if (!recommended && hasQuotes) { if (context.fix) { fontFamilyNode.removeQuotes(); return; } return complain(messages.rejected(family), rawFamily, decl); } if (recommended && !hasQuotes) { if (context.fix) { fontFamilyNode.addQuotes(); return; } return complain(messages.expected(family), rawFamily, decl); } return; case 'always-where-required': if (!required && hasQuotes) { if (context.fix) { fontFamilyNode.removeQuotes(); return; } return complain(messages.rejected(family), rawFamily, decl); } if (required && !hasQuotes) { if (context.fix) { fontFamilyNode.addQuotes(); return; } return complain(messages.expected(family), rawFamily, decl); } } } /** * @param {string} message * @param {string} family * @param {import('postcss').Declaration} decl */ function complain(message, family, decl) { report({ result, ruleName, message, node: decl, word: family, }); } }; }; rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; module.exports = rule;