chore: migrate unescaped-html-literal eslint rule to our repo and fix more cases (#38072)

This commit is contained in:
wxiaoguang
2026-06-11 22:37:22 +08:00
committed by GitHub
parent 360f34d7fa
commit 250a38abb5
10 changed files with 184 additions and 45 deletions

View File

@@ -18,6 +18,8 @@ import wc from 'eslint-plugin-wc';
import {defineConfig, globalIgnores} from 'eslint/config';
import type {ESLint} from 'eslint';
import unescapedHtmlLiteral from './tools/eslint-rules/unescaped-html-literal.ts';
const jsExts = ['js', 'mjs', 'cjs'] as const;
const tsExts = ['ts', 'mts', 'cts'] as const;
@@ -65,6 +67,7 @@ export default defineConfig([
'@typescript-eslint': typescriptPlugin.plugin,
'array-func': arrayFunc,
'de-morgan': deMorgan,
'gitea': {rules: {'unescaped-html-literal': unescapedHtmlLiteral}},
'import-x': importPlugin as unknown as ESLint.Plugin, // https://github.com/un-ts/eslint-plugin-import-x/issues/203
regexp,
sonarjs,
@@ -331,7 +334,7 @@ export default defineConfig([
'github/no-useless-passive': [2],
'github/prefer-observers': [0],
'github/require-passive-events': [2],
'github/unescaped-html-literal': [2],
'gitea/unescaped-html-literal': [2],
'grouped-accessor-pairs': [2],
'guard-for-in': [0],
'id-blacklist': [0],
@@ -952,7 +955,7 @@ export default defineConfig([
plugins: {vitest},
languageOptions: {globals: globals.vitest},
rules: {
'github/unescaped-html-literal': [0],
'gitea/unescaped-html-literal': [0],
'vitest/consistent-test-filename': [0],
'vitest/consistent-test-it': [0],
'vitest/expect-expect': [0],

View File

@@ -0,0 +1,85 @@
// MIT license, Copyright (c) GitHub, Inc.
// https://github.com/github/eslint-plugin-github/blob/main/lib/rules/unescaped-html-literal.js
/* eslint-disable no-template-curly-in-string */
import rule from './unescaped-html-literal.ts';
import {RuleTester} from 'eslint';
class VitestRuleTester extends RuleTester {
static describe = describe;
static it = it;
static itOnly = it.only;
}
const ruleTester = new VitestRuleTester();
ruleTester.run('unescaped-html-literal', rule, {
valid: [
{
code: '`Hello World!`;',
languageOptions: {ecmaVersion: 2017},
},
{
code: "'Hello World!'",
languageOptions: {ecmaVersion: 2017},
},
{
code: '"Hello World!"',
languageOptions: {ecmaVersion: 2017},
},
{
code: 'const helloTemplate = () => html`<div>Hello World!</div>`;',
languageOptions: {ecmaVersion: 2017},
},
{
code: 'const helloTemplate = (name) => html`<div>Hello ${name}!</div>`;',
languageOptions: {ecmaVersion: 2017},
},
],
invalid: [
{
code: "const helloHTML = '<div>Hello, World!</div>'",
languageOptions: {ecmaVersion: 2017},
errors: [
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
],
},
{
code: 'const helloHTML = "<h1>Hello, World!</h1>"',
languageOptions: {ecmaVersion: 2017},
errors: [
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
],
},
{
code: 'const helloHTML = `<div>Hello ${name}!</div>`',
languageOptions: {ecmaVersion: 2017},
errors: [
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
],
},
{
code: 'const helloHTML = ` \n\t<div>Hello ${name}!</div>`',
languageOptions: {ecmaVersion: 2017},
errors: [
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
],
},
{
code: 'const helloHTML = foo`<div>Hello ${name}!</div>`',
languageOptions: {ecmaVersion: 2017},
errors: [
{
message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
],
},
],
});

View File

@@ -0,0 +1,41 @@
// MIT license, Copyright (c) GitHub, Inc.
// 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 rule: JSRuleDefinition<JSRuleDefinitionTypeOptions> = {
meta: {
type: 'problem',
messages: {
unescapedHtmlLiteral: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.',
},
},
create(context) {
return {
Literal(node) {
if (typeof node.value !== 'string' || !htmlOpenTag.test(node.value)) return;
context.report({
node,
messageId: 'unescapedHtmlLiteral',
});
},
TemplateLiteral(node) {
const templateStart = node.quasis[0]?.value.raw;
if (!templateStart || !htmlOpenTag.test(templateStart)) return;
const parent = node.parent;
if (parent?.type === 'TaggedTemplateExpression' && parent.tag.type === 'Identifier' && parent.tag.name === 'html') return;
context.report({
node,
messageId: 'unescapedHtmlLiteral',
});
},
};
},
};
export default rule;

View File

@@ -4,7 +4,10 @@ import {stringPlugin} from 'vite-string-plugin';
export default defineConfig({
test: {
include: ['web_src/**/*.test.ts'],
include: [
'web_src/**/*.test.ts',
'tools/eslint-rules/**/*.test.ts',
],
setupFiles: ['web_src/js/vitest.setup.ts'],
environment: 'happy-dom',
testTimeout: 20000,

View File

@@ -848,6 +848,7 @@ table th[data-sortt-desc] .svg {
}
/* this is useful to make a left-right (e.g.: title .... operations) layout with default gap, and it wrap for small widths */
.ui.modal .header.flex-left-right,
.flex-left-right {
display: flex;
flex-wrap: wrap;

View File

@@ -1,4 +1,4 @@
import {svg} from '../svg.ts';
import {svgRaw} from '../svg.ts';
import {html} from '../utils/html.ts';
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
import {GET, POST} from '../modules/fetch.ts';
@@ -45,10 +45,11 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
function addCopyLink(file: Partial<CustomDropzoneFile>) {
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
const copyLinkEl = createElementFromHTML<HTMLDivElement>(`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
</div>`);
const copyLinkEl = createElementFromHTML<HTMLDivElement>(html`
<div class="tw-text-center">
<a href="#" class="tw-cursor-pointer">${svgRaw('octicon-copy', 14)} Copy link</a>
</div>
`);
copyLinkEl.addEventListener('click', async (e) => {
e.preventDefault();
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));

View File

@@ -1,10 +1,11 @@
import {svg} from '../svg.ts';
import {svgRaw} from '../svg.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET, POST} from '../modules/fetch.ts';
import {createElementFromHTML, showElem} from '../utils/dom.ts';
import {parseIssuePageInfo} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
import {html, htmlRaw} from '../utils/html.ts';
let i18nTextEdited: string;
let i18nTextOptions: string;
@@ -12,21 +13,22 @@ let i18nTextDeleteFromHistory: string;
let i18nTextDeleteFromHistoryConfirm: string;
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
const elDetailDialog = createElementFromHTML(`
<div class="ui modal content-history-detail-dialog">
${svg('octicon-x', 16, 'close icon inside')}
<div class="header flex-left-right">
<div>${itemTitleHtml}</div>
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
${i18nTextOptions}
${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
const elDetailDialog = createElementFromHTML(html`
<div class="ui modal content-history-detail-dialog">
${svgRaw('octicon-x', 16, 'close icon inside')}
<div class="header flex-left-right">
<div>${htmlRaw(itemTitleHtml)}</div>
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
${i18nTextOptions}
${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
</div>
</div>
</div>
<div class="comment-diff-data is-loading"></div>
</div>
</div>
<div class="comment-diff-data is-loading"></div>
</div>`);
`);
document.body.append(elDetailDialog);
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
@@ -93,12 +95,13 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
const menuHtml = `
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
const menuHtml = html`
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
&bull; ${i18nTextEdited}${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
<div class="menu">
</div>
</div>
</div>`;
`;
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
elHeaderLeft.append(createElementFromHTML(menuHtml));

View File

@@ -1,5 +1,5 @@
import {errorMessage} from '../modules/errors.ts';
import {htmlEscape} from '../utils/html.ts';
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
import {createTippy} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
@@ -274,15 +274,13 @@ export function initRepoPullRequestReview() {
let ntr = tr.nextElementSibling;
if (!ntr?.classList.contains('add-comment')) {
ntr = createElementFromHTML(`
<tr class="add-comment" data-line-type="${htmlEscape(lineType)}">
${isSplit ? `
<td class="add-comment-left" colspan="4"></td>
<td class="add-comment-right" colspan="4"></td>
` : `
<td class="add-comment-left add-comment-right" colspan="5"></td>
`}
</tr>`);
const tdSplit = html`<td class="add-comment-left" colspan="4"></td><td class="add-comment-right" colspan="4"></td>`;
const tdUnified = html`<td class="add-comment-left add-comment-right" colspan="5"></td>`;
ntr = createElementFromHTML(html`
<tr class="add-comment" data-line-type="${lineType}">
${isSplit ? htmlRaw(tdSplit) : htmlRaw(tdUnified)}
</tr>
`);
tr.after(ntr);
}
const td = ntr.querySelector(`.add-comment-${side}`)!;

View File

@@ -1,5 +1,5 @@
import {htmlEscape} from '../utils/html.ts';
import {svg} from '../svg.ts';
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
import {svgRaw} from '../svg.ts';
import {animateOnce, queryElems, showElem} from '../utils/dom.ts';
import Toastify from 'toastify-js'; // don't use "async import", because when network error occurs, the "async import" also fails and nothing is shown
import type {Intent} from '../types.ts';
@@ -44,9 +44,8 @@ type ToastifyElement = HTMLElement & {_giteaToastifyInstance?: Toast};
/** See https://github.com/apvarun/toastify-js#api for options */
function showToast(message: string, level: Intent, {gravity, position, duration, useHtmlBody, preventDuplicates = true, ...other}: ToastOpts = {}): Toast | null {
const body = useHtmlBody ? message : htmlEscape(message);
const parent = document.querySelector('.ui.dimmer.active') ?? document.body;
const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${body}` : preventDuplicates) : '';
const duplicateKey = preventDuplicates ? (preventDuplicates === true ? `${level}-${message}` : preventDuplicates) : '';
// prevent showing duplicate toasts with the same level and message, and give visual feedback for end users
if (preventDuplicates) {
@@ -61,12 +60,13 @@ function showToast(message: string, level: Intent, {gravity, position, duration,
}
const {icon, background, duration: levelDuration} = levels[level ?? 'info'];
const bodyHtml = useHtmlBody ? message : htmlEscape(message);
const toast = Toastify({
selector: parent,
text: `
<div class='toast-icon'>${svg(icon)}</div>
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${body}</div>
<button class='btn toast-close'>${svg('octicon-x')}</button>
text: html`
<div class='toast-icon'>${svgRaw(icon)}</div>
<div class='toast-body'><span class="toast-duplicate-number tw-hidden">1</span>${htmlRaw(bodyHtml)}</div>
<button class='btn toast-close'>${svgRaw('octicon-x')}</button>
`,
escapeMarkup: false,
gravity: gravity ?? 'top',

View File

@@ -199,6 +199,10 @@ export function svg(name: SvgName, size = 16, classNames?: string | string[]): s
return serializeXml(svgNode);
}
export function svgRaw(name: SvgName, size = 16, classNames?: string | string[]) {
return htmlRaw(svg(name, size, classNames));
}
export function svgParseOuterInner(name: SvgName) {
const svgStr = svgs[name];
if (!svgStr) throw new Error(`Unknown SVG icon: ${name}`);