Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions dist/core/rules/head-script-disabled.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

53 changes: 40 additions & 13 deletions src/core/rules/head-script-disabled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,31 +4,58 @@ import { Rule } from '../types'
export default {
id: 'head-script-disabled',
description: 'The <script> tag cannot be used in a <head> tag.',
init(parser, reporter) {
init(parser, reporter, options) {
const reScript = /^(text\/javascript|application\/javascript)$/i
let isInHead = false

const onTagStart: Listener = (event) => {
const mapAttrs = parser.getMapAttrs(event.attrs)
const type = mapAttrs.type
const defer = mapAttrs.defer
const tagName = event.tagName.toLowerCase()

if (tagName === 'head') {
isInHead = true
}

if (
isInHead === true &&
tagName === 'script' &&
(!type || reScript.test(type) === true)
) {
reporter.warn(
'The <script> tag cannot be used in a <head> tag.',
event.line,
event.col,
this,
event.raw
)
if (isInHead === true && tagName === 'script') {
// Check if this is a module script
const isModule = type === 'module'

// Check if this is a deferred script
const isDeferred = defer !== undefined

// Check if this is an async script
const isAsync = mapAttrs.async !== undefined

// Check if script type is executable JavaScript (or no type specified)
const isExecutableScript =
!type || reScript.test(type) === true || isModule

if (isExecutableScript) {
// If allow-non-blocking option is enabled, check for non-blocking attributes
if (options === 'allow-non-blocking') {
// Allow modules, deferred scripts, and async scripts
if (!isModule && !isDeferred && !isAsync) {
reporter.warn(
'The <script> tag cannot be used in a <head> tag unless it has type="module", defer, or async attribute.',
event.line,
event.col,
this,
event.raw
)
}
} else {
// Default behavior: disallow all executable scripts in head
reporter.warn(
'The <script> tag cannot be used in a <head> tag.',
event.line,
event.col,
this,
event.raw
)
}
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export interface Ruleset {
'doctype-html5'?: boolean
'empty-tag-not-self-closed'?: boolean
'form-method-require'?: boolean
'head-script-disabled'?: boolean
'head-script-disabled'?: boolean | 'allow-non-blocking'
'href-abs-or-rel'?: 'abs' | 'rel'
'id-class-ad-disabled'?: boolean
'id-class-value'?:
Expand Down
115 changes: 115 additions & 0 deletions test/rules/head-script-disabled.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,4 +54,119 @@ describe(`Rules: ${ruleId}`, () => {
const messages = HTMLHint.verify(code, ruleOptions)
expect(messages.length).toBe(0)
})

describe('allow-non-blocking option', () => {
const allowNonBlockingOptions = {}
allowNonBlockingOptions[ruleId] = 'allow-non-blocking'

it('Module script in head should not result in an error', () => {
const code = '<head><script type="module" src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Deferred script in head should not result in an error', () => {
const code = '<head><script defer src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Deferred script with type in head should not result in an error', () => {
const code =
'<head><script type="text/javascript" defer src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Async script in head should not result in an error', () => {
const code = '<head><script async src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Async script with type in head should not result in an error', () => {
const code =
'<head><script type="text/javascript" async src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Script with both async and defer should not result in an error', () => {
const code = '<head><script async defer src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Module script with inline content should not result in an error', () => {
const code =
'<head><script type="module">import { test } from "./test.js";</script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Regular script in head should still result in an error', () => {
const code = '<head><script src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].message).toBe(
'The <script> tag cannot be used in a <head> tag unless it has type="module", defer, or async attribute.'
)
})

it('Regular script with type in head should still result in an error', () => {
const code =
'<head><script type="text/javascript" src="test.js"></script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].message).toBe(
'The <script> tag cannot be used in a <head> tag unless it has type="module", defer, or async attribute.'
)
})

it('Inline script without defer, async, or module should result in an error', () => {
const code = '<head><script>console.log("test");</script></head>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(1)
expect(messages[0].rule.id).toBe(ruleId)
expect(messages[0].message).toBe(
'The <script> tag cannot be used in a <head> tag unless it has type="module", defer, or async attribute.'
)
})

it('Template scripts should still not result in an error', () => {
let code =
'<head><script type="text/template"><img src="test.png" /></script></head>'
let messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)

code =
'<head><script type="text/ng-template"><img src="test.png" /></script></head>'
messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Scripts in body should still not result in an error', () => {
const code = '<head></head><body><script src="test.js"></script></body>'
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(0)
})

it('Multiple scripts with mixed types should handle correctly', () => {
const code = `
<head>
<script type="module" src="module.js"></script>
<script defer src="deferred.js"></script>
<script async src="async.js"></script>
<script src="blocking.js"></script>
</head>
`
const messages = HTMLHint.verify(code, allowNonBlockingOptions)
expect(messages.length).toBe(1)
expect(messages[0].message).toBe(
'The <script> tag cannot be used in a <head> tag unless it has type="module", defer, or async attribute.'
)
})
})
})
79 changes: 77 additions & 2 deletions website/src/content/docs/rules/head-script-disabled.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -12,21 +12,96 @@ Level: <Badge text="Warning" variant="caution" />

## Config value

- `true`: enable rule
- `true`: enable rule (disallow all scripts in head)
- `false`: disable rule
- `"allow-non-blocking"`: allow non-blocking scripts (modules, deferred, and async scripts) in head

### The following patterns are **not** considered rule violations

#### Default behavior (`true`)

```html
<body>
<script src="test.js"></script>
</body>
```

### The following pattern is considered a rule violation:
```html
<head>
<script type="text/template">
<div>Template content</div>
</script>
</head>
```

#### With `"allow-non-blocking"` option

```html
<head>
<script type="module" src="module.js"></script>
</head>
```

```html
<head>
<script defer src="deferred.js"></script>
</head>
```

```html
<head>
<script async src="analytics.js"></script>
</head>
```

```html
<head>
<script type="module">
import { init } from './app.js';
init();
</script>
</head>
```

### The following patterns are considered rule violations

#### Default behavior (`true`)

```html
<head>
<script src="test.js"></script>
</head>
```

```html
<head>
<script type="module" src="module.js"></script>
</head>
```

#### With `"allow-non-blocking"` option

```html
<head>
<script src="test.js"></script>
</head>
```

```html
<head>
<script type="text/javascript">
console.log('blocking script');
</script>
</head>
```

## Why this rule is important

Scripts in the `<head>` section traditionally block HTML parsing and rendering, which can negatively impact page load performance. However, modern JavaScript features like ES6 modules (`type="module"`), the `defer` attribute, and the `async` attribute allow scripts to be non-blocking, making them safe to place in the head without performance penalties.

For more information, see:

- [MDN: Script element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script)
- [MDN: defer attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#defer)
- [MDN: async attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script#async)
- [MDN: JavaScript modules](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules)
Loading