From ea84bdad78dbbf47716efaf4c0d885114a67584a Mon Sep 17 00:00:00 2001 From: Christian Oliff Date: Thu, 11 Sep 2025 13:07:23 +0900 Subject: [PATCH] Add link-rel-canonical-require rule Introduces a new rule that enforces the presence of a tag with a non-blank href in the element. Includes implementation, tests, documentation, and configuration updates. Update link-rel-canonical-require.mdx Refactor link-rel-canonical-require formatting Improves code readability by reformatting the description string and simplifying the conditional for detecting canonical link elements. --- dist/core/rules/index.js | 6 +- src/core/rules/index.ts | 1 + src/core/rules/link-rel-canonical-require.ts | 51 +++++++++ src/core/types.ts | 1 + test/rules/link-rel-canonical-require.spec.js | 103 ++++++++++++++++++ website/src/content/docs/configuration.md | 1 + website/src/content/docs/rules/index.mdx | 1 + .../docs/rules/link-rel-canonical-require.mdx | 61 +++++++++++ 8 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 src/core/rules/link-rel-canonical-require.ts create mode 100644 test/rules/link-rel-canonical-require.spec.js create mode 100644 website/src/content/docs/rules/link-rel-canonical-require.mdx diff --git a/dist/core/rules/index.js b/dist/core/rules/index.js index 2a5f600a8..6bd81efb7 100644 --- a/dist/core/rules/index.js +++ b/dist/core/rules/index.js @@ -1,6 +1,6 @@ "use strict"; 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.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.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 alt_require_1.default; } }); var attr_lowercase_1 = require("./attr-lowercase"); @@ -55,6 +55,8 @@ var inline_style_disabled_1 = require("./inline-style-disabled"); Object.defineProperty(exports, "inlineStyleDisabled", { enumerable: true, get: function () { return inline_style_disabled_1.default; } }); var input_requires_label_1 = require("./input-requires-label"); Object.defineProperty(exports, "inputRequiresLabel", { enumerable: true, get: function () { return input_requires_label_1.default; } }); +var link_rel_canonical_require_1 = require("./link-rel-canonical-require"); +Object.defineProperty(exports, "linkRelCanonicalRequire", { enumerable: true, get: function () { return link_rel_canonical_require_1.default; } }); var main_require_1 = require("./main-require"); Object.defineProperty(exports, "mainRequire", { enumerable: true, get: function () { return main_require_1.default; } }); var meta_charset_require_1 = require("./meta-charset-require"); @@ -87,4 +89,4 @@ var tag_self_close_1 = require("./tag-self-close"); Object.defineProperty(exports, "tagSelfClose", { enumerable: true, get: function () { return tag_self_close_1.default; } }); var title_require_1 = require("./title-require"); Object.defineProperty(exports, "titleRequire", { enumerable: true, get: function () { return title_require_1.default; } }); -//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29yZS9ydWxlcy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFBQSw2Q0FBcUQ7QUFBNUMseUdBQUEsT0FBTyxPQUFjO0FBQzlCLG1EQUEyRDtBQUFsRCwrR0FBQSxPQUFPLE9BQWlCO0FBQ2pDLDZEQUFvRTtBQUEzRCx3SEFBQSxPQUFPLE9BQXFCO0FBQ3JDLG1GQUF5RjtBQUFoRiw2SUFBQSxPQUFPLE9BQStCO0FBQy9DLHlFQUErRTtBQUF0RSxtSUFBQSxPQUFPLE9BQTBCO0FBQzFDLDZDQUFtRDtBQUExQyx1R0FBQSxPQUFPLE9BQVk7QUFDNUIseURBQWdFO0FBQXZELG9IQUFBLE9BQU8sT0FBbUI7QUFDbkMsdUVBQTZFO0FBQXBFLGlJQUFBLE9BQU8sT0FBeUI7QUFDekMsK0RBQXFFO0FBQTVELHlIQUFBLE9BQU8sT0FBcUI7QUFDckMsdUVBQTZFO0FBQXBFLGlJQUFBLE9BQU8sT0FBeUI7QUFDekMscURBQTZEO0FBQXBELGlIQUFBLE9BQU8sT0FBa0I7QUFDbEMsNkRBQW9FO0FBQTNELHdIQUFBLE9BQU8sT0FBcUI7QUFDckMsaURBQXlEO0FBQWhELDZHQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDZHQUFBLE9BQU8sT0FBZ0I7QUFDaEMseUVBQThFO0FBQXJFLGtJQUFBLE9BQU8sT0FBeUI7QUFDekMsNkRBQW9FO0FBQTNELHdIQUFBLE9BQU8sT0FBcUI7QUFDckMsNkRBQW9FO0FBQTNELHdIQUFBLE9BQU8sT0FBcUI7QUFDckMsMkNBQW1EO0FBQTFDLHVHQUFBLE9BQU8sT0FBYTtBQUM3QiwrREFBc0U7QUFBN0QsMEhBQUEsT0FBTyxPQUFzQjtBQUN0QyxxREFBMkQ7QUFBbEQsK0dBQUEsT0FBTyxPQUFnQjtBQUNoQyx5REFBZ0U7QUFBdkQsb0hBQUEsT0FBTyxPQUFtQjtBQUNuQywrREFBcUU7QUFBNUQseUhBQUEsT0FBTyxPQUFxQjtBQUNyQyxtREFBMEQ7QUFBakQsOEdBQUEsT0FBTyxPQUFnQjtBQUNoQyx5Q0FBaUQ7QUFBeEMscUdBQUEsT0FBTyxPQUFZO0FBQzVCLG1FQUEwRTtBQUFqRSw4SEFBQSxPQUFPLE9BQXdCO0FBQ3hDLGlFQUF3RTtBQUEvRCw0SEFBQSxPQUFPLE9BQXVCO0FBQ3ZDLCtEQUFzRTtBQUE3RCwwSEFBQSxPQUFPLE9BQXNCO0FBQ3RDLCtDQUF1RDtBQUE5QywyR0FBQSxPQUFPLE9BQWU7QUFDL0IsK0RBQXNFO0FBQTdELDBIQUFBLE9BQU8sT0FBc0I7QUFDdEMsdUVBQThFO0FBQXJFLGtJQUFBLE9BQU8sT0FBMEI7QUFDMUMsaUVBQXdFO0FBQS9ELDRIQUFBLE9BQU8sT0FBdUI7QUFDdkMscURBQTZEO0FBQXBELGlIQUFBLE9BQU8sT0FBa0I7QUFDbEMsdUVBQTZFO0FBQXBFLGlJQUFBLE9BQU8sT0FBeUI7QUFDekMsdURBQThEO0FBQXJELGtIQUFBLE9BQU8sT0FBa0I7QUFDbEMsaURBQXdEO0FBQS9DLDRHQUFBLE9BQU8sT0FBZTtBQUMvQixtREFBMkQ7QUFBbEQsK0dBQUEsT0FBTyxPQUFpQjtBQUNqQyxxREFBNEQ7QUFBbkQsZ0hBQUEsT0FBTyxPQUFpQjtBQUNqQyx5REFBaUU7QUFBeEQscUhBQUEsT0FBTyxPQUFvQjtBQUNwQywrREFBdUU7QUFBOUQsMkhBQUEsT0FBTyxPQUF1QjtBQUN2Qyx1Q0FBK0M7QUFBdEMsbUdBQUEsT0FBTyxPQUFXO0FBQzNCLDJDQUFtRDtBQUExQyx1R0FBQSxPQUFPLE9BQWE7QUFDN0IsbURBQTBEO0FBQWpELDhHQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDZHQUFBLE9BQU8sT0FBZ0IifQ== \ No newline at end of file +//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaW5kZXguanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29yZS9ydWxlcy9pbmRleC50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7QUFBQSw2Q0FBcUQ7QUFBNUMseUdBQUEsT0FBTyxPQUFjO0FBQzlCLG1EQUEyRDtBQUFsRCwrR0FBQSxPQUFPLE9BQWlCO0FBQ2pDLDZEQUFvRTtBQUEzRCx3SEFBQSxPQUFPLE9BQXFCO0FBQ3JDLG1GQUF5RjtBQUFoRiw2SUFBQSxPQUFPLE9BQStCO0FBQy9DLHlFQUErRTtBQUF0RSxtSUFBQSxPQUFPLE9BQTBCO0FBQzFDLDZDQUFtRDtBQUExQyx1R0FBQSxPQUFPLE9BQVk7QUFDNUIseURBQWdFO0FBQXZELG9IQUFBLE9BQU8sT0FBbUI7QUFDbkMsdUVBQTZFO0FBQXBFLGlJQUFBLE9BQU8sT0FBeUI7QUFDekMsK0RBQXFFO0FBQTVELHlIQUFBLE9BQU8sT0FBcUI7QUFDckMsdUVBQTZFO0FBQXBFLGlJQUFBLE9BQU8sT0FBeUI7QUFDekMscURBQTZEO0FBQXBELGlIQUFBLE9BQU8sT0FBa0I7QUFDbEMsNkRBQW9FO0FBQTNELHdIQUFBLE9BQU8sT0FBcUI7QUFDckMsaURBQXlEO0FBQWhELDZHQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDZHQUFBLE9BQU8sT0FBZ0I7QUFDaEMseUVBQThFO0FBQXJFLGtJQUFBLE9BQU8sT0FBeUI7QUFDekMsNkRBQW9FO0FBQTNELHdIQUFBLE9BQU8sT0FBcUI7QUFDckMsNkRBQW9FO0FBQTNELHdIQUFBLE9BQU8sT0FBcUI7QUFDckMsMkNBQW1EO0FBQTFDLHVHQUFBLE9BQU8sT0FBYTtBQUM3QiwrREFBc0U7QUFBN0QsMEhBQUEsT0FBTyxPQUFzQjtBQUN0QyxxREFBMkQ7QUFBbEQsK0dBQUEsT0FBTyxPQUFnQjtBQUNoQyx5REFBZ0U7QUFBdkQsb0hBQUEsT0FBTyxPQUFtQjtBQUNuQywrREFBcUU7QUFBNUQseUhBQUEsT0FBTyxPQUFxQjtBQUNyQyxtREFBMEQ7QUFBakQsOEdBQUEsT0FBTyxPQUFnQjtBQUNoQyx5Q0FBaUQ7QUFBeEMscUdBQUEsT0FBTyxPQUFZO0FBQzVCLG1FQUEwRTtBQUFqRSw4SEFBQSxPQUFPLE9BQXdCO0FBQ3hDLGlFQUF3RTtBQUEvRCw0SEFBQSxPQUFPLE9BQXVCO0FBQ3ZDLCtEQUFzRTtBQUE3RCwwSEFBQSxPQUFPLE9BQXNCO0FBQ3RDLDJFQUFpRjtBQUF4RSxxSUFBQSxPQUFPLE9BQTJCO0FBQzNDLCtDQUF1RDtBQUE5QywyR0FBQSxPQUFPLE9BQWU7QUFDL0IsK0RBQXNFO0FBQTdELDBIQUFBLE9BQU8sT0FBc0I7QUFDdEMsdUVBQThFO0FBQXJFLGtJQUFBLE9BQU8sT0FBMEI7QUFDMUMsaUVBQXdFO0FBQS9ELDRIQUFBLE9BQU8sT0FBdUI7QUFDdkMscURBQTZEO0FBQXBELGlIQUFBLE9BQU8sT0FBa0I7QUFDbEMsdUVBQTZFO0FBQXBFLGlJQUFBLE9BQU8sT0FBeUI7QUFDekMsdURBQThEO0FBQXJELGtIQUFBLE9BQU8sT0FBa0I7QUFDbEMsaURBQXdEO0FBQS9DLDRHQUFBLE9BQU8sT0FBZTtBQUMvQixtREFBMkQ7QUFBbEQsK0dBQUEsT0FBTyxPQUFpQjtBQUNqQyxxREFBNEQ7QUFBbkQsZ0hBQUEsT0FBTyxPQUFpQjtBQUNqQyx5REFBaUU7QUFBeEQscUhBQUEsT0FBTyxPQUFvQjtBQUNwQywrREFBdUU7QUFBOUQsMkhBQUEsT0FBTyxPQUF1QjtBQUN2Qyx1Q0FBK0M7QUFBdEMsbUdBQUEsT0FBTyxPQUFXO0FBQzNCLDJDQUFtRDtBQUExQyx1R0FBQSxPQUFPLE9BQWE7QUFDN0IsbURBQTBEO0FBQWpELDhHQUFBLE9BQU8sT0FBZ0I7QUFDaEMsaURBQXlEO0FBQWhELDZHQUFBLE9BQU8sT0FBZ0IifQ== \ No newline at end of file diff --git a/src/core/rules/index.ts b/src/core/rules/index.ts index 4cd33ecef..3669d4244 100644 --- a/src/core/rules/index.ts +++ b/src/core/rules/index.ts @@ -25,6 +25,7 @@ export { default as idUnique } from './id-unique' export { default as inlineScriptDisabled } from './inline-script-disabled' export { default as inlineStyleDisabled } from './inline-style-disabled' export { default as inputRequiresLabel } from './input-requires-label' +export { default as linkRelCanonicalRequire } from './link-rel-canonical-require' export { default as mainRequire } from './main-require' export { default as metaCharsetRequire } from './meta-charset-require' export { default as metaDescriptionRequire } from './meta-description-require' diff --git a/src/core/rules/link-rel-canonical-require.ts b/src/core/rules/link-rel-canonical-require.ts new file mode 100644 index 000000000..7f42ff51c --- /dev/null +++ b/src/core/rules/link-rel-canonical-require.ts @@ -0,0 +1,51 @@ +import { Block, Listener } from '../htmlparser' +import { Rule } from '../types' + +export default { + id: 'link-rel-canonical-require', + description: + ' with non-blank href must be present in tag.', + init(parser, reporter) { + let headSeen = false + let linkCanonicalSeen = false + let linkCanonicalHref = '' + let headEvent: Block | null = null + + const onTagStart: Listener = (event) => { + const tagName = event.tagName.toLowerCase() + if (tagName === 'head') { + headSeen = true + headEvent = event + } else if (tagName === 'link') { + const mapAttrs = parser.getMapAttrs(event.attrs) + if (mapAttrs['rel'] && mapAttrs['rel'].toLowerCase() === 'canonical') { + linkCanonicalSeen = true + linkCanonicalHref = mapAttrs['href'] || '' + } + } + } + + parser.addListener('tagstart', onTagStart) + parser.addListener('end', () => { + if (headSeen && headEvent) { + if (!linkCanonicalSeen) { + reporter.error( + ' must be present in tag.', + headEvent.line, + headEvent.col, + this, + headEvent.raw + ) + } else if (linkCanonicalHref.trim() === '') { + reporter.error( + ' href attribute must not be empty.', + headEvent.line, + headEvent.col, + this, + headEvent.raw + ) + } + } + }) + }, +} as Rule diff --git a/src/core/types.ts b/src/core/types.ts index 377f2b098..b9a1b14dc 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -35,6 +35,7 @@ export interface Ruleset { 'inline-script-disabled'?: boolean 'inline-style-disabled'?: boolean 'input-requires-label'?: boolean + 'link-rel-canonical-require'?: boolean 'main-require'?: boolean 'script-disabled'?: boolean 'space-tab-mixed-disabled'?: diff --git a/test/rules/link-rel-canonical-require.spec.js b/test/rules/link-rel-canonical-require.spec.js new file mode 100644 index 000000000..34276269e --- /dev/null +++ b/test/rules/link-rel-canonical-require.spec.js @@ -0,0 +1,103 @@ +const HTMLHint = require('../../dist/htmlhint.js').HTMLHint +const ruleId = 'link-rel-canonical-require' + +describe('Rule: link-rel-canonical-require', () => { + it('should not report an error when a valid canonical link is present', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should not report an error when canonical link has uppercase rel attribute', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should not report an error when canonical link has mixed case rel attribute', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should report an error when canonical link is missing', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(1) + expect(messages[0].message).toBe( + ' must be present in tag.' + ) + }) + + it('should report an error when canonical link href is blank', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(1) + expect(messages[0].message).toBe( + ' href attribute must not be empty.' + ) + }) + + it('should report an error when canonical link href is only whitespace', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(1) + expect(messages[0].message).toBe( + ' href attribute must not be empty.' + ) + }) + + it('should report an error when canonical link href is missing', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(1) + expect(messages[0].message).toBe( + ' href attribute must not be empty.' + ) + }) + + it('should not report an error for other link tags', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should report an error when only other link tags are present', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(1) + expect(messages[0].message).toBe( + ' must be present in tag.' + ) + }) + + it('should not report an error when canonical link is present with other meta tags', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should not report an error when canonical link has relative URL', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should not report an error when canonical link has query parameters', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should not report an error when canonical link has fragment', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) + + it('should not report an error when canonical link is self-referencing', () => { + const code = `` + const messages = HTMLHint.verify(code, { [ruleId]: true }) + expect(messages.length).toBe(0) + }) +}) diff --git a/website/src/content/docs/configuration.md b/website/src/content/docs/configuration.md index 428c149b1..0a620da7e 100644 --- a/website/src/content/docs/configuration.md +++ b/website/src/content/docs/configuration.md @@ -66,6 +66,7 @@ An example configuration file (with all rules disabled): "inline-script-disabled": false, "inline-style-disabled": false, "input-requires-label": false, + "link-rel-canonical-require": false, "main-require": false, "meta-charset-require": false, "meta-description-require": false, diff --git a/website/src/content/docs/rules/index.mdx b/website/src/content/docs/rules/index.mdx index 695620021..fd0c19431 100644 --- a/website/src/content/docs/rules/index.mdx +++ b/website/src/content/docs/rules/index.mdx @@ -12,6 +12,7 @@ description: A complete list of all the rules for HTMLHint - [`meta-charset-require`](meta-charset-require/): `` must be present in `` tag. - [`meta-description-require`](meta-description-require/): `` with non-blank content must be present in `` tag. - [`meta-viewport-require`](meta-viewport-require/): `` with non-blank content must be present in `` tag. +- [`link-rel-canonical-require`](link-rel-canonical-require/): `` with non-blank href must be present in `` tag. - [`script-disabled`](script-disabled/): `