diff --git a/dist/core/htmlparser.js b/dist/core/htmlparser.js index 63bb73796..1f5c63387 100644 --- a/dist/core/htmlparser.js +++ b/dist/core/htmlparser.js @@ -17,7 +17,7 @@ class HTMLParser { } parse(html) { const mapCdataTags = this._mapCdataTags; - const regTag = /<(?:\/([^\s>]+)\s*|!--([\s\S]*?)--|!([^>]*?)|([\w\-:]+)((?:\s+[^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]*))?)*?)\s*(\/?))>/g; + const regTag = /<(?:\/([^\s>]+)\s*|!--([\s\S]*?)--|!([^>]*?)|([\w\-:]+)((?:\s*[^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]*))?)*?)\s*(\/?))>/g; const regAttr = /\s*([^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+)(?:\s*=\s*(?:(")([^"]*)"|(')([^']*)'|([^\s"'>]*)))?/g; const regLine = /\r?\n/g; let match; diff --git a/dist/core/rules/index.js b/dist/core/rules/index.js index 24102c576..867735c22 100644 --- a/dist/core/rules/index.js +++ b/dist/core/rules/index.js @@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); -exports.titleRequire = exports.tagSelfClose = exports.tagsCheck = exports.tagPair = exports.tagnameSpecialChars = exports.tagnameLowercase = exports.tagNoObsolete = exports.styleDisabled = exports.srcNotEmpty = exports.specCharEscape = exports.spaceTabMixedDisabled = exports.scriptDisabled = exports.metaViewportRequire = exports.metaDescriptionRequire = exports.metaCharsetRequire = exports.mainRequire = exports.linkRelCanonicalRequire = exports.inputRequiresLabel = exports.inlineStyleDisabled = exports.inlineScriptDisabled = exports.idUnique = exports.idClassValue = exports.idClassAdDisabled = exports.htmlLangRequire = exports.hrefAbsOrRel = exports.headScriptDisabled = exports.h1Require = exports.frameTitleRequire = exports.formMethodRequire = exports.emptyTagNotSelfClosed = exports.doctypeHTML5 = exports.doctypeFirst = exports.buttonTypeRequire = exports.attrWhitespace = exports.attrValueSingleQuotes = exports.attrValueNotEmpty = exports.attrValueDoubleQuotes = exports.attrUnsafeChars = exports.attrSort = exports.attrValueNoDuplication = exports.attrNoUnnecessaryWhitespace = exports.attrNoDuplication = exports.attrLowercase = exports.altRequire = void 0; +exports.titleRequire = exports.tagSelfClose = exports.tagsCheck = exports.tagPair = exports.tagnameSpecialChars = exports.tagnameLowercase = exports.tagNoObsolete = exports.styleDisabled = exports.srcNotEmpty = exports.specCharEscape = exports.spaceTabMixedDisabled = exports.scriptDisabled = exports.metaViewportRequire = exports.metaDescriptionRequire = exports.metaCharsetRequire = exports.mainRequire = exports.linkRelCanonicalRequire = exports.inputRequiresLabel = exports.inlineStyleDisabled = exports.inlineScriptDisabled = exports.idUnique = exports.idClassValue = exports.idClassAdDisabled = exports.htmlLangRequire = exports.hrefAbsOrRel = exports.headScriptDisabled = exports.h1Require = exports.frameTitleRequire = exports.formMethodRequire = exports.emptyTagNotSelfClosed = exports.doctypeHTML5 = exports.doctypeFirst = exports.buttonTypeRequire = exports.attrWhitespace = exports.attrValueSingleQuotes = exports.attrValueNotEmpty = exports.attrValueDoubleQuotes = exports.attrUnsafeChars = exports.attrSpaceBetween = exports.attrSort = exports.attrValueNoDuplication = exports.attrNoUnnecessaryWhitespace = exports.attrNoDuplication = exports.attrLowercase = exports.altRequire = void 0; var alt_require_1 = require("./alt-require"); Object.defineProperty(exports, "altRequire", { enumerable: true, get: function () { return __importDefault(alt_require_1).default; } }); var attr_lowercase_1 = require("./attr-lowercase"); @@ -16,6 +16,8 @@ var attr_value_no_duplication_1 = require("./attr-value-no-duplication"); Object.defineProperty(exports, "attrValueNoDuplication", { enumerable: true, get: function () { return __importDefault(attr_value_no_duplication_1).default; } }); var attr_sorted_1 = require("./attr-sorted"); Object.defineProperty(exports, "attrSort", { enumerable: true, get: function () { return __importDefault(attr_sorted_1).default; } }); +var attr_space_between_1 = require("./attr-space-between"); +Object.defineProperty(exports, "attrSpaceBetween", { enumerable: true, get: function () { return __importDefault(attr_space_between_1).default; } }); var attr_unsafe_chars_1 = require("./attr-unsafe-chars"); Object.defineProperty(exports, "attrUnsafeChars", { enumerable: true, get: function () { return __importDefault(attr_unsafe_chars_1).default; } }); var attr_value_double_quotes_1 = require("./attr-value-double-quotes"); @@ -92,4 +94,4 @@ var tag_self_close_1 = require("./tag-self-close"); Object.defineProperty(exports, "tagSelfClose", { enumerable: true, get: function () { return __importDefault(tag_self_close_1).default; } }); var title_require_1 = require("./title-require"); Object.defineProperty(exports, "titleRequire", { enumerable: true, get: function () { return __importDefault(title_require_1).default; } }); -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29yZS9ydWxlcy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7QUFBQSw2Q0FBcUQ7QUFBNUMsMEhBQUEsT0FBTyxPQUFjO0FBQzlCLG1EQUEyRDtBQUFsRCxnSUFBQSxPQUFPLE9BQWlCO0FBQ2pDLDZEQUFvRTtBQUEzRCx5SUFBQSxPQUFPLE9BQXFCO0FBQ3JDLG1GQUF5RjtBQUFoRiw4SkFBQSxPQUFPLE9BQStCO0FBQy9DLHlFQUErRTtBQUF0RSxvSkFBQSxPQUFPLE9BQTBCO0FBQzFDLDZDQUFtRDtBQUExQyx3SEFBQSxPQUFPLE9BQVk7QUFDNUIseURBQWdFO0FBQXZELHFJQUFBLE9BQU8sT0FBbUI7QUFDbkMsdUVBQTZFO0FBQXBFLGtKQUFBLE9BQU8sT0FBeUI7QUFDekMsK0RBQXFFO0FBQTVELDBJQUFBLE9BQU8sT0FBcUI7QUFDckMsdUVBQTZFO0FBQXBFLGtKQUFBLE9BQU8sT0FBeUI7QUFDekMscURBQTZEO0FBQXBELGtJQUFBLE9BQU8sT0FBa0I7QUFDbEMsNkRBQW9FO0FBQTNELHlJQUFBLE9BQU8sT0FBcUI7QUFDckMsaURBQXlEO0FBQWhELDhIQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDhIQUFBLE9BQU8sT0FBZ0I7QUFDaEMseUVBQThFO0FBQXJFLG1KQUFBLE9BQU8sT0FBeUI7QUFDekMsNkRBQW9FO0FBQTNELHlJQUFBLE9BQU8sT0FBcUI7QUFDckMsNkRBQW9FO0FBQTNELHlJQUFBLE9BQU8sT0FBcUI7QUFDckMsMkNBQW1EO0FBQTFDLHdIQUFBLE9BQU8sT0FBYTtBQUM3QiwrREFBc0U7QUFBN0QsMklBQUEsT0FBTyxPQUFzQjtBQUN0QyxxREFBMkQ7QUFBbEQsZ0lBQUEsT0FBTyxPQUFnQjtBQUNoQyx5REFBZ0U7QUFBdkQscUlBQUEsT0FBTyxPQUFtQjtBQUNuQywrREFBcUU7QUFBNUQsMElBQUEsT0FBTyxPQUFxQjtBQUNyQyxtREFBMEQ7QUFBakQsK0hBQUEsT0FBTyxPQUFnQjtBQUNoQyx5Q0FBaUQ7QUFBeEMsc0hBQUEsT0FBTyxPQUFZO0FBQzVCLG1FQUEwRTtBQUFqRSwrSUFBQSxPQUFPLE9BQXdCO0FBQ3hDLGlFQUF3RTtBQUEvRCw2SUFBQSxPQUFPLE9BQXVCO0FBQ3ZDLCtEQUFzRTtBQUE3RCwySUFBQSxPQUFPLE9BQXNCO0FBQ3RDLDJFQUFpRjtBQUF4RSxzSkFBQSxPQUFPLE9BQTJCO0FBQzNDLCtDQUF1RDtBQUE5Qyw0SEFBQSxPQUFPLE9BQWU7QUFDL0IsK0RBQXNFO0FBQTdELDJJQUFBLE9BQU8sT0FBc0I7QUFDdEMsdUVBQThFO0FBQXJFLG1KQUFBLE9BQU8sT0FBMEI7QUFDMUMsaUVBQXdFO0FBQS9ELDZJQUFBLE9BQU8sT0FBdUI7QUFDdkMscURBQTZEO0FBQXBELGtJQUFBLE9BQU8sT0FBa0I7QUFDbEMsdUVBQTZFO0FBQXBFLGtKQUFBLE9BQU8sT0FBeUI7QUFDekMsdURBQThEO0FBQXJELG1JQUFBLE9BQU8sT0FBa0I7QUFDbEMsaURBQXdEO0FBQS9DLDZIQUFBLE9BQU8sT0FBZTtBQUMvQixtREFBMkQ7QUFBbEQsZ0lBQUEsT0FBTyxPQUFpQjtBQUNqQyxxREFBNEQ7QUFBbkQsaUlBQUEsT0FBTyxPQUFpQjtBQUNqQyx5REFBaUU7QUFBeEQsc0lBQUEsT0FBTyxPQUFvQjtBQUNwQywrREFBdUU7QUFBOUQsNElBQUEsT0FBTyxPQUF1QjtBQUN2Qyx1Q0FBK0M7QUFBdEMsb0hBQUEsT0FBTyxPQUFXO0FBQzNCLDJDQUFtRDtBQUExQyx3SEFBQSxPQUFPLE9BQWE7QUFDN0IsbURBQTBEO0FBQWpELCtIQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDhIQUFBLE9BQU8sT0FBZ0IifQ== \ No newline at end of file +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29yZS9ydWxlcy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7QUFBQSw2Q0FBcUQ7QUFBNUMsMEhBQUEsT0FBTyxPQUFjO0FBQzlCLG1EQUEyRDtBQUFsRCxnSUFBQSxPQUFPLE9BQWlCO0FBQ2pDLDZEQUFvRTtBQUEzRCx5SUFBQSxPQUFPLE9BQXFCO0FBQ3JDLG1GQUF5RjtBQUFoRiw4SkFBQSxPQUFPLE9BQStCO0FBQy9DLHlFQUErRTtBQUF0RSxvSkFBQSxPQUFPLE9BQTBCO0FBQzFDLDZDQUFtRDtBQUExQyx3SEFBQSxPQUFPLE9BQVk7QUFDNUIsMkRBQWtFO0FBQXpELHVJQUFBLE9BQU8sT0FBb0I7QUFDcEMseURBQWdFO0FBQXZELHFJQUFBLE9BQU8sT0FBbUI7QUFDbkMsdUVBQTZFO0FBQXBFLGtKQUFBLE9BQU8sT0FBeUI7QUFDekMsK0RBQXFFO0FBQTVELDBJQUFBLE9BQU8sT0FBcUI7QUFDckMsdUVBQTZFO0FBQXBFLGtKQUFBLE9BQU8sT0FBeUI7QUFDekMscURBQTZEO0FBQXBELGtJQUFBLE9BQU8sT0FBa0I7QUFDbEMsNkRBQW9FO0FBQTNELHlJQUFBLE9BQU8sT0FBcUI7QUFDckMsaURBQXlEO0FBQWhELDhIQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDhIQUFBLE9BQU8sT0FBZ0I7QUFDaEMseUVBQThFO0FBQXJFLG1KQUFBLE9BQU8sT0FBeUI7QUFDekMsNkRBQW9FO0FBQTNELHlJQUFBLE9BQU8sT0FBcUI7QUFDckMsNkRBQW9FO0FBQTNELHlJQUFBLE9BQU8sT0FBcUI7QUFDckMsMkNBQW1EO0FBQTFDLHdIQUFBLE9BQU8sT0FBYTtBQUM3QiwrREFBc0U7QUFBN0QsMklBQUEsT0FBTyxPQUFzQjtBQUN0QyxxREFBMkQ7QUFBbEQsZ0lBQUEsT0FBTyxPQUFnQjtBQUNoQyx5REFBZ0U7QUFBdkQscUlBQUEsT0FBTyxPQUFtQjtBQUNuQywrREFBcUU7QUFBNUQsMElBQUEsT0FBTyxPQUFxQjtBQUNyQyxtREFBMEQ7QUFBakQsK0hBQUEsT0FBTyxPQUFnQjtBQUNoQyx5Q0FBaUQ7QUFBeEMsc0hBQUEsT0FBTyxPQUFZO0FBQzVCLG1FQUEwRTtBQUFqRSwrSUFBQSxPQUFPLE9BQXdCO0FBQ3hDLGlFQUF3RTtBQUEvRCw2SUFBQSxPQUFPLE9BQXVCO0FBQ3ZDLCtEQUFzRTtBQUE3RCwySUFBQSxPQUFPLE9BQXNCO0FBQ3RDLDJFQUFpRjtBQUF4RSxzSkFBQSxPQUFPLE9BQTJCO0FBQzNDLCtDQUF1RDtBQUE5Qyw0SEFBQSxPQUFPLE9BQWU7QUFDL0IsK0RBQXNFO0FBQTdELDJJQUFBLE9BQU8sT0FBc0I7QUFDdEMsdUVBQThFO0FBQXJFLG1KQUFBLE9BQU8sT0FBMEI7QUFDMUMsaUVBQXdFO0FBQS9ELDZJQUFBLE9BQU8sT0FBdUI7QUFDdkMscURBQTZEO0FBQXBELGtJQUFBLE9BQU8sT0FBa0I7QUFDbEMsdUVBQTZFO0FBQXBFLGtKQUFBLE9BQU8sT0FBeUI7QUFDekMsdURBQThEO0FBQXJELG1JQUFBLE9BQU8sT0FBa0I7QUFDbEMsaURBQXdEO0FBQS9DLDZIQUFBLE9BQU8sT0FBZTtBQUMvQixtREFBMkQ7QUFBbEQsZ0lBQUEsT0FBTyxPQUFpQjtBQUNqQyxxREFBNEQ7QUFBbkQsaUlBQUEsT0FBTyxPQUFpQjtBQUNqQyx5REFBaUU7QUFBeEQsc0lBQUEsT0FBTyxPQUFvQjtBQUNwQywrREFBdUU7QUFBOUQsNElBQUEsT0FBTyxPQUF1QjtBQUN2Qyx1Q0FBK0M7QUFBdEMsb0hBQUEsT0FBTyxPQUFXO0FBQzNCLDJDQUFtRDtBQUExQyx3SEFBQSxPQUFPLE9BQWE7QUFDN0IsbURBQTBEO0FBQWpELCtIQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDhIQUFBLE9BQU8sT0FBZ0IifQ== \ No newline at end of file diff --git a/src/core/htmlparser.ts b/src/core/htmlparser.ts index 5dcf18a56..5115017b8 100644 --- a/src/core/htmlparser.ts +++ b/src/core/htmlparser.ts @@ -54,7 +54,7 @@ export default class HTMLParser { const regTag = // eslint-disable-next-line no-control-regex - /<(?:\/([^\s>]+)\s*|!--([\s\S]*?)--|!([^>]*?)|([\w\-:]+)((?:\s+[^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]*))?)*?)\s*(\/?))>/g + /<(?:\/([^\s>]+)\s*|!--([\s\S]*?)--|!([^>]*?)|([\w\-:]+)((?:\s*[^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+(?:\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'>]*))?)*?)\s*(\/?))>/g const regAttr = // eslint-disable-next-line no-control-regex /\s*([^\s"'>\/=\x00-\x0F\x7F\x80-\x9F]+)(?:\s*=\s*(?:(")([^"]*)"|(')([^']*)'|([^\s"'>]*)))?/g diff --git a/src/core/rules/attr-space-between.ts b/src/core/rules/attr-space-between.ts new file mode 100644 index 000000000..8914c6bc2 --- /dev/null +++ b/src/core/rules/attr-space-between.ts @@ -0,0 +1,23 @@ +import { Rule } from '../types' + +export default { + id: 'attr-space-between', + description: 'Attributes must be separated by a space.', + init(parser, reporter) { + parser.addListener('tagstart', (event) => { + const col = event.col + event.tagName.length + 1 + + for (const { index, name, raw } of event.attrs) { + if (!raw.match(/^\s/)) { + reporter.error( + `Attribute [ ${name} ] must be separated with a space.`, + event.line, + col + index, + this, + event.raw + ) + } + } + }) + }, +} as Rule diff --git a/src/core/rules/index.ts b/src/core/rules/index.ts index 3669d4244..51a0fcf09 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -4,6 +4,7 @@ export { default as attrNoDuplication } from './attr-no-duplication' export { default as attrNoUnnecessaryWhitespace } from './attr-no-unnecessary-whitespace' export { default as attrValueNoDuplication } from './attr-value-no-duplication' export { default as attrSort } from './attr-sorted' +export { default as attrSpaceBetween } from './attr-space-between' export { default as attrUnsafeChars } from './attr-unsafe-chars' export { default as attrValueDoubleQuotes } from './attr-value-double-quotes' export { default as attrValueNotEmpty } from './attr-value-not-empty' diff --git a/src/core/types.ts b/src/core/types.ts index e4b6d83fd..3b2a2eeda 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -13,6 +13,7 @@ export interface Ruleset { 'attr-no-duplication'?: boolean 'attr-no-unnecessary-whitespace'?: boolean 'attr-sorted'?: boolean + 'attr-space-between'?: boolean 'attr-unsafe-chars'?: boolean 'attr-value-double-quotes'?: boolean 'attr-value-not-empty'?: boolean diff --git a/test/rules/attr-space-between.spec.js b/test/rules/attr-space-between.spec.js new file mode 100644 index 000000000..08f228b09 --- /dev/null +++ b/test/rules/attr-space-between.spec.js @@ -0,0 +1,43 @@ +const HTMLHint = require('../../dist/htmlhint.js').HTMLHint + +const ruleId = 'attr-space-between' +const ruleOptions = {} + +ruleOptions[ruleId] = true + +describe(`Rules: ${ruleId}`, () => { + it('Attributes without a space between them should result in an error', () => { + const code = '
' + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(1) + expect(messages[0].rule.id).toBe(ruleId) + expect(messages[0].message).toBe( + 'Attribute [ class ] must be separated with a space.' + ) + }) + + it('Multiple attributes without spaces should each result in an error', () => { + const code = '
' + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(2) + messages.forEach((msg) => expect(msg.rule.id).toBe(ruleId)) + }) + + it('Attributes separated by spaces should not result in an error', () => { + const code = '
' + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(0) + }) + + it('Unquoted and valueless attributes should not result in an error', () => { + const code = '' + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(0) + }) + + it('Attributes separated by newlines should not result in an error', () => { + const code = '
' + const messages = HTMLHint.verify(code, ruleOptions) + expect(messages.length).toBe(0) + }) +}) diff --git a/website/src/content/docs/rules/attr-space-between.mdx b/website/src/content/docs/rules/attr-space-between.mdx new file mode 100644 index 000000000..0789f7941 --- /dev/null +++ b/website/src/content/docs/rules/attr-space-between.mdx @@ -0,0 +1,39 @@ +--- +id: attr-space-between +title: attr-space-between +description: Attributes must be separated by a space. +sidebar: + badge: New +--- + +import { Badge } from '@astrojs/starlight/components'; + +Attributes must be separated by a space. + +Level: + +## Config value + +- `true`: enable rule +- `false`: disable rule + +## Description + +Without a space between attributes, browsers recover from the markup, but the +HTMLHint parser interprets the tag as text — which produces misleading errors +from other rules (such as `spec-char-escape` and `tag-pair`) instead of +pointing at the real problem. This rule reports the missing space directly. + +### The following patterns are **not** considered rule violations + + +```html +
+``` + +### The following patterns are considered rule violations: + + +```html +
+``` diff --git a/website/src/content/docs/rules/index.mdx b/website/src/content/docs/rules/index.mdx index 9cc32a4ab..b7be7d872 100644 --- a/website/src/content/docs/rules/index.mdx +++ b/website/src/content/docs/rules/index.mdx @@ -24,6 +24,7 @@ description: A complete list of all the rules for HTMLHint - [`attr-no-duplication`](attr-no-duplication/): Elements cannot have duplicate attributes. - [`attr-no-unnecessary-whitespace`](attr-no-unnecessary-whitespace/): No spaces between attribute names and values. - [`attr-sorted`](attr-sorted/): Attributes should be sorted in order. +- [`attr-space-between`](attr-space-between/): Attributes must be separated by a space. - [`attr-unsafe-chars`](attr-unsafe-chars/): Attribute values cannot contain unsafe chars. - [`attr-value-double-quotes`](attr-value-double-quotes/): Attribute values must be in double quotes. - [`attr-value-no-duplication`](attr-value-no-duplication/): Attribute values should not contain duplicates.