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\tHello ${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(`
-
- ${svg('octicon-x', 16, 'close icon inside')}
-