diff --git a/eslint.config.ts b/eslint.config.ts index 91adc06e193..a31b6c1fc74 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -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], diff --git a/tools/eslint-rules/unescaped-html-literal.test.ts b/tools/eslint-rules/unescaped-html-literal.test.ts new file mode 100644 index 00000000000..9424c0006ab --- /dev/null +++ b/tools/eslint-rules/unescaped-html-literal.test.ts @@ -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`
Hello World!
`;', + languageOptions: {ecmaVersion: 2017}, + }, + { + code: 'const helloTemplate = (name) => html`
Hello ${name}!
`;', + languageOptions: {ecmaVersion: 2017}, + }, + ], + invalid: [ + { + code: "const helloHTML = '
Hello, World!
'", + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = "

Hello, World!

"', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = `
Hello ${name}!
`', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = ` \n\t
Hello ${name}!
`', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + { + code: 'const helloHTML = foo`
Hello ${name}!
`', + languageOptions: {ecmaVersion: 2017}, + errors: [ + { + message: 'Unescaped HTML literal. Use html`` tag template literal for secure escaping.', + }, + ], + }, + ], +}); diff --git a/tools/eslint-rules/unescaped-html-literal.ts b/tools/eslint-rules/unescaped-html-literal.ts new file mode 100644 index 00000000000..3cbd82f7fe9 --- /dev/null +++ b/tools/eslint-rules/unescaped-html-literal.ts @@ -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 = { + 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; diff --git a/vitest.config.ts b/vitest.config.ts index d2d3eeef1aa..93218aed4db 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -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, diff --git a/web_src/css/base.css b/web_src/css/base.css index 8b8cfac2d1f..d26711b1541 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -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; diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts index fcde16e8454..63271c303b3 100644 --- a/web_src/js/features/dropzone.ts +++ b/web_src/js/features/dropzone.ts @@ -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) { // Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard // The "" element has a hardcoded cursor: pointer because the default is overridden by .dropzone - const copyLinkEl = createElementFromHTML(` -`); + const copyLinkEl = createElementFromHTML(html` + + `); copyLinkEl.addEventListener('click', async (e) => { e.preventDefault(); await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file)); diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts index e385d5b1e8b..eda5f4c8cec 100644 --- a/web_src/js/features/repo-issue-content.ts +++ b/web_src/js/features/repo-issue-content.ts @@ -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(` -