chore: re-enable and extend unescaped-html-literal eslint rule (#39832)

https://github.com/go-gitea/gitea/pull/38046 accidentally dropped
`gitea/unescaped-html-literal` from the eslint config, so it has been
off since. Raised in
https://github.com/go-gitea/gitea/pull/39829#issuecomment-6246720235.

- Re-enable the rule, exempting e2e tests like unit tests
- Detect tags anywhere in strings and template chunks, not only at the
start
- Allow `htmlRaw` templates, they escape like `html`
- Fix the existing violations, which escapes the heatmap tooltip and
shortcut keys
This commit is contained in:
silverwind
2026-10-18 07:37:17 +00:00
committed by GitHub
parent a4908d4af4
commit 428d72d26f
8 changed files with 34 additions and 11 deletions
+4
View File
@@ -311,6 +311,7 @@ export default defineConfig([
'func-names': [0],
'func-style': [0],
'getter-return': [2],
'gitea/unescaped-html-literal': [2],
'grouped-accessor-pairs': [2],
'guard-for-in': [0],
'id-denylist': [0],
@@ -1239,6 +1240,9 @@ export default defineConfig([
{
files: ['*', 'tools/**/*', 'tests/**/*'],
languageOptions: {globals: globals.nodeBuiltin},
rules: {
'gitea/unescaped-html-literal': [0],
},
},
{
files: ['web_src/**/*'],
@@ -28,6 +28,10 @@ ruleTester.run('unescaped-html-literal', rule, {
code: 'const helloTemplate = (name) => html`<div>Hello ${name}!</div>`;',
languageOptions: {ecmaVersion: 2017},
},
{
code: 'const attr = htmlRaw` width="${width}"`, img = htmlRaw`<img ${attr}>`, cmp = "a < b";',
languageOptions: {ecmaVersion: 2017},
},
],
invalid: [
{
@@ -66,6 +70,21 @@ ruleTester.run('unescaped-html-literal', rule, {
},
],
},
{
code: "const rowHTML = `${cells}</tr>`, tailHTML = '</tbody>', textHTML = 'Hello <b>World</b>'",
languageOptions: {ecmaVersion: 2017},
errors: [
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
],
},
{
code: 'const helloHTML = foo`<div>Hello ${name}!</div>`',
languageOptions: {ecmaVersion: 2017},
+5 -5
View File
@@ -2,7 +2,8 @@
// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js
import type {JSRuleDefinition, JSRuleDefinitionTypeOptions} from 'eslint';
const htmlOpenTag = /^\s*<[a-zA-Z]/;
const htmlTag = /<\/?[a-z][\w-]*[\s/>]/i;
const escapingTags = new Set(['html', 'htmlRaw']);
const rule: JSRuleDefinition<JSRuleDefinitionTypeOptions> = {
meta: {
@@ -14,7 +15,7 @@ const rule: JSRuleDefinition<JSRuleDefinitionTypeOptions> = {
create: (context) => ({
Literal(node) {
if (typeof node.value !== 'string' || !htmlOpenTag.test(node.value)) return;
if (typeof node.value !== 'string' || !htmlTag.test(node.value)) return;
context.report({
node,
@@ -22,11 +23,10 @@ const rule: JSRuleDefinition<JSRuleDefinitionTypeOptions> = {
});
},
TemplateLiteral(node) {
const templateStart = node.quasis[0]?.value.raw;
if (!templateStart || !htmlOpenTag.test(templateStart)) return;
if (node.quasis.every((quasi) => !htmlTag.test(quasi.value.raw))) return;
const parent = node.parent;
if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && parent.tag.name === 'html') return;
if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && escapingTags.has(parent.tag.name)) return;
context.report({
node,
+2 -1
View File
@@ -3,6 +3,7 @@ import {computed, onBeforeUnmount, onMounted} from 'vue';
import tippy, {createSingleton} from 'tippy.js';
import type {CreateSingletonInstance, Instance} from 'tippy.js';
import {getCurrentLocale} from '../utils.ts';
import {html} from '../utils/html.ts';
type HeatmapValue = {date: Date; count: number};
type HeatmapCell = {date: Date; colorIndex: number; ariaLabel: string; tooltip: string};
@@ -96,7 +97,7 @@ const grid = computed(() => {
date: new Date(cursor),
colorIndex: hit ? hit.colorIndex : 0,
ariaLabel: `${head} ${on} ${dateStr}`,
tooltip: `<b>${head}</b> ${on} ${dateStr}`,
tooltip: html`<b>${head}</b> ${on} ${dateStr}`,
});
cursor.setDate(cursor.getDate() + 1);
}
+1 -1
View File
@@ -110,7 +110,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
// create an iframe to sandbox the svg with styles, and set correct height by reading svg's viewBox height
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible');
iframe.srcdoc = '<style>body { margin: 0; overflow: hidden; } #mermaid { display: block; margin: 0 auto; }</style>';
iframe.srcdoc = html`<style>body { margin: 0; overflow: hidden; } #mermaid { display: block; margin: 0 auto; }</style>`;
const viewBoxHeight = svgNode.viewBox.baseVal?.height;
if (viewBoxHeight) iframe.style.height = `${Math.max(Math.ceil(viewBoxHeight), 85)}px`; // min-height keeps the buttons from overlapping
+1 -1
View File
@@ -85,7 +85,7 @@ function initDevtestAnsiRender(container: HTMLElement) {
...withSource(`first${esc}[Ksecond${esc}[2Jthird`),
...withSource(`cursor ${esc}[3Amovement, private ${esc}[?25lCSI, ${esc}]0;title${esc}\\titles, ${esc}Pquery${esc}\\strings, truncated${esc}[38;5;`),
...withSource('Reading... 10%\b\b\b100%'),
...withSource('<script>alert(1)</script> & "quotes", and a bare url https://example.com'),
...withSource('<script>alert(1)</script> & "quotes", and a bare url https://example.com'), // eslint-disable-line gitea/unescaped-html-literal -- plain text that the console must escape
' ',
`${esc}[31man unterminated color`,
'carries into the following lines',
+1 -1
View File
@@ -108,7 +108,7 @@ function showShortcutHelp() {
seenShortcuts.add(shortcutIdentifier);
let keysHtml = '';
for (const keys of shortcutKeys.split(' ')) {
keysHtml += `<kbd>${keys}</kbd> `;
keysHtml += html`<kbd>${keys}</kbd> `;
}
list.append(createElementFromHTML(html`<tr><td>${htmlRaw(keysHtml)}</td><td>${label}</td></tr>`));
}
+1 -2
View File
@@ -166,8 +166,7 @@ function attachTooltip(target: Element, content: Content | null = null): Instanc
let allowHTML = target.getAttribute('data-tooltip-render') === 'html';
if (!allowHTML && typeof content === 'string') {
content = htmlEscape(content);
content = content.replace(/\n/g, '<br>');
content = htmlEscape(content).split('\n').join(html`<br>`);
allowHTML = true;
}