Skip to content

Commit 55ade4d

Browse files
authored
Validate HTML code (exercism#7849)
* Add linter error for unclosed html tags * Validate html * Only check htmlstrings with length * Add custom html5 validator (exercism#7856) * Add a shameless version * Check for voidtag closure * Simplify and test checkNesting * Remove useless walkDom, add voidtag tests * Check for opening tag termination * Correct import * Add check for non-void selfclosing * Add tests for checkNonVoidSelfClose * Use one styling for all lint messages * Remove parse5
1 parent f736d17 commit 55ade4d

File tree

16 files changed

+446
-54
lines changed

16 files changed

+446
-54
lines changed

app/javascript/components/bootcamp/CSSExercisePage/LHS/ControlButtons.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import { readOnlyRangesStateField } from '../../JikiscriptExercisePage/CodeMirro
1313
import { runHtmlChecks } from '../checks/runHtmlChecks'
1414
import { CheckResult } from '../checks/runChecks'
1515
import { runCssChecks } from '../checks/runCssChecks'
16+
import toast from 'react-hot-toast'
17+
import { validateHtml5 } from '../../common/validateHtml5/validateHtml5'
1618

1719
export function ControlButtons({
1820
getEditorValues,
@@ -49,6 +51,17 @@ export function ControlButtons({
4951
let status: 'pass' | 'fail' = 'fail'
5052
let firstFailingCheck: CheckResult | null = null
5153

54+
if (htmlValue.length > 0) {
55+
const isHTMLValid = validateHtml5(htmlValue)
56+
57+
if (!isHTMLValid.isValid) {
58+
toast.error(
59+
`Your HTML is invalid (${isHTMLValid.errorMessage}). Please check the linter and look for hints on how to fix it.`
60+
)
61+
return
62+
}
63+
}
64+
5265
const htmlChecks = await runHtmlChecks(exercise.htmlChecks, htmlValue)
5366
const cssChecks = await runCssChecks(exercise.cssChecks, cssValue)
5467

app/javascript/components/bootcamp/CSSExercisePage/LHS/HTMLEditor.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import React, { useContext, useMemo } from 'react'
22
import { html } from '@codemirror/lang-html'
33
import { CSSExercisePageContext } from '../CSSExercisePageContext'
4-
import { htmlLinter } from '../SimpleCodeMirror/extensions/htmlLinter'
4+
import {
5+
htmlLinter,
6+
lintTooltipTheme,
7+
} from '../SimpleCodeMirror/extensions/htmlLinter'
58
import { SimpleCodeMirror } from '../SimpleCodeMirror/SimpleCodeMirror'
69
import { useCSSExercisePageStore } from '../store/cssExercisePageStore'
710
import { EditorView } from 'codemirror'
@@ -85,6 +88,7 @@ export function HTMLEditor({ defaultCode }: { defaultCode: string }) {
8588
autoCloseTags: false,
8689
}),
8790
htmlTheme,
91+
lintTooltipTheme,
8892
htmlLinter,
8993
readOnlyRangeDecoration(),
9094
initReadOnlyRangesExtension(),

app/javascript/components/bootcamp/CSSExercisePage/SimpleCodeMirror/extensions/htmlLinter.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
import { HTMLHint } from 'htmlhint'
22
import { linter as cmLinter, Diagnostic } from '@codemirror/lint'
3+
import { EditorView } from 'codemirror'
34

45
export const htmlLinter = cmLinter((view) => {
56
const code = view.state.doc.toString()
67
const messages = HTMLHint.verify(code, {
78
'tag-pair': true,
9+
'attr-no-duplication': true,
10+
'attr-unsafe-chars': true,
11+
'tagname-lowercase': true,
12+
'attr-lowercase': true,
13+
'id-unique': true,
14+
'spec-char-escape': true,
815
})
916

1017
const diagnostics: Diagnostic[] = messages.map((msg) => ({
@@ -17,3 +24,29 @@ export const htmlLinter = cmLinter((view) => {
1724

1825
return diagnostics
1926
})
27+
28+
export const lintTooltipTheme = EditorView.theme({
29+
'.cm-tooltip-hover.cm-tooltip.cm-tooltip-below': {
30+
borderRadius: '8px',
31+
},
32+
33+
'.cm-tooltip .cm-tooltip-lint': {
34+
backgroundColor: 'var(--backgroundColorF)',
35+
borderRadius: '8px',
36+
color: 'var(--textColor6)',
37+
border: '1px solid var(--borderColor6)',
38+
padding: '4px 8px',
39+
fontSize: '14px',
40+
fontFamily: 'poppins',
41+
},
42+
'span.cm-diagnosticText': {
43+
display: 'block',
44+
marginBottom: '8px',
45+
},
46+
'.cm-diagnostic.cm-diagnostic-error': {
47+
borderColor: '#EB5757',
48+
},
49+
'.cm-diagnostic.cm-diagnostic-warning': {
50+
borderColor: '#F69605',
51+
},
52+
})

app/javascript/components/bootcamp/FrontendExercisePage/LHS/LHS.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ import { cleanUpEditorErrorState, showJsError } from './showJsError'
99
import { useHandleJsErrorMessage } from './useHandleJsErrorMessage'
1010
import { useFrontendExercisePageStore } from '../store/frontendExercisePageStore'
1111
import toast from 'react-hot-toast'
12-
import { validateHtml } from './validateHtml'
1312
import { wrapJSCode } from './wrapJSCode'
13+
import { validateHtml5 } from '../../common/validateHtml5/validateHtml5'
1414

1515
export type TabIndex = 'html' | 'css' | 'javascript'
1616

1717
export const TabsContext = createContext<TabContext>({
1818
current: 'html',
19-
switchToTab: () => {},
19+
switchToTab: () => { },
2020
})
2121

2222
export function LHS() {
@@ -59,12 +59,12 @@ export function LHS() {
5959
const htmlText = htmlEditorRef.current.state.doc.toString()
6060

6161
if (htmlText.length > 0) {
62-
const isHTMLValid = validateHtml(htmlText)
62+
const isHTMLValid = validateHtml5(htmlText)
6363

6464
if (!isHTMLValid.isValid) {
6565
setTab('html')
6666
toast.error(
67-
`Your HTML is invalid. Please check the linter and look for unclosed tags.`
67+
`Your HTML is invalid (${isHTMLValid.errorMessage}). Please check the linter and look for hints on how to fix it.`
6868
)
6969
return
7070
}

app/javascript/components/bootcamp/FrontendExercisePage/LHS/Panels/HTMLPanel/HTMLEditor.tsx

Lines changed: 4 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
initReadOnlyRangesExtension,
1212
} from '@/components/bootcamp/JikiscriptExercisePage/CodeMirror/extensions'
1313
import { moveCursorByPasteLength } from '@/components/bootcamp/JikiscriptExercisePage/CodeMirror/extensions/move-cursor-by-paste-length'
14-
import { htmlLinter } from '@/components/bootcamp/CSSExercisePage/SimpleCodeMirror/extensions/htmlLinter'
14+
import {
15+
htmlLinter,
16+
lintTooltipTheme,
17+
} from '@/components/bootcamp/CSSExercisePage/SimpleCodeMirror/extensions/htmlLinter'
1518
import { htmlTheme } from '@/components/bootcamp/CSSExercisePage/LHS/htmlTheme'
1619
import { EditorView } from 'codemirror'
1720

@@ -72,29 +75,3 @@ export function HTMLEditor() {
7275
/>
7376
)
7477
}
75-
76-
export const lintTooltipTheme = EditorView.theme({
77-
'.cm-tooltip-hover.cm-tooltip.cm-tooltip-below': {
78-
borderRadius: '8px',
79-
},
80-
81-
'.cm-tooltip .cm-tooltip-lint': {
82-
backgroundColor: 'var(--backgroundColorF)',
83-
borderRadius: '8px',
84-
color: 'var(--textColor6)',
85-
border: '1px solid var(--borderColor6)',
86-
padding: '4px 8px',
87-
fontSize: '14px',
88-
fontFamily: 'poppins',
89-
},
90-
'span.cm-diagnosticText': {
91-
display: 'block',
92-
marginBottom: '8px',
93-
},
94-
'.cm-diagnostic.cm-diagnostic-error': {
95-
borderColor: '#EB5757',
96-
},
97-
'.cm-diagnostic.cm-diagnostic-warning': {
98-
borderColor: '#F69605',
99-
},
100-
})

app/javascript/components/bootcamp/FrontendExercisePage/LHS/validateHtml.tsx

Lines changed: 0 additions & 22 deletions
This file was deleted.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { isVoidElement } from '../voidElements'
2+
3+
export function checkNesting(html: string): void {
4+
const tagRegex = /<\/?([a-zA-Z][a-zA-Z0-9-]*)\b[^>]*?>/g
5+
const tagStack: string[] = []
6+
7+
let match
8+
while ((match = tagRegex.exec(html)) !== null) {
9+
const fullMatch = match[0]
10+
const tagName = match[1]
11+
12+
const isClosing = fullMatch.startsWith('</')
13+
14+
if (isClosing) {
15+
const lastOpen = tagStack.pop()
16+
if (!lastOpen) {
17+
throw new Error(`Extra closing tag: </${tagName}>`)
18+
}
19+
if (lastOpen !== tagName) {
20+
throw new Error(
21+
`Mismatched tags: <${lastOpen}> closed by </${tagName}>`
22+
)
23+
}
24+
} else if (!isVoidElement(tagName)) {
25+
tagStack.push(tagName)
26+
}
27+
}
28+
29+
if (tagStack.length > 0) {
30+
throw new Error(`Unclosed tag(s): ${tagStack.join(', ')}`)
31+
}
32+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { isVoidElement } from '../voidElements'
2+
3+
export function checkNonVoidSelfClose(html: string): void {
4+
const selfClosingRegex = /<([a-zA-Z][a-zA-Z0-9-]*)\b[^<>]*?\/>/g
5+
6+
let match
7+
while ((match = selfClosingRegex.exec(html)) !== null) {
8+
const tagName = match[1]
9+
10+
if (!isVoidElement(tagName)) {
11+
throw new Error(`Non-void element <${tagName}/> cannot be self-closed.`)
12+
}
13+
}
14+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
export function checkOpeningTagTermination(html: string): void {
2+
const tagOpenRegex = /<([a-zA-Z][a-zA-Z0-9-]*)\b[^<>]*$/gm
3+
4+
let match
5+
while ((match = tagOpenRegex.exec(html)) !== null) {
6+
const tagStart = match.index
7+
const nextGt = html.indexOf('>', tagStart)
8+
const nextLt = html.indexOf('<', tagStart + 1)
9+
10+
// if there is no closing > or another < comes before it, it's unclosed
11+
if (nextGt === -1 || (nextLt !== -1 && nextLt < nextGt)) {
12+
const partial = html.slice(tagStart, nextLt !== -1 ? nextLt : undefined)
13+
throw new Error(`Unterminated opening tag: ${partial.trim()}`)
14+
}
15+
}
16+
17+
// if the last < is after the last >, it's an unclosed tag
18+
const lastOpen = html.lastIndexOf('<')
19+
const lastClose = html.lastIndexOf('>')
20+
if (lastOpen > lastClose) {
21+
const partial = html.slice(lastOpen).trim()
22+
throw new Error(`Unclosed tag at end of document: ${partial}`)
23+
}
24+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
export function checkVoidTagClosure(html: string): void {
2+
const voidRegex =
3+
/<(area|base|br|col|embed|hr|img|input|link|meta|source|track|wbr)\b[^<>]*?(?!>)$/gim
4+
5+
const malformedVoidTag = html.match(voidRegex)
6+
7+
if (malformedVoidTag) {
8+
throw new Error(`Unclosed void tag: ${malformedVoidTag[0]}`)
9+
}
10+
}

0 commit comments

Comments
 (0)