aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.eslintrc.cjs3
-rw-r--r--options/locale/locale_en-US.ini2
-rw-r--r--package-lock.json13
-rw-r--r--package.json1
-rw-r--r--templates/repo/branch/list.tmpl12
-rw-r--r--web_src/js/bootstrap.ts3
-rw-r--r--web_src/js/components/ViewFileTreeStore.ts3
-rw-r--r--web_src/js/features/comp/ConfirmModal.ts24
-rw-r--r--web_src/js/features/comp/EditorUpload.ts2
-rw-r--r--web_src/js/features/comp/SearchUserBox.ts2
-rw-r--r--web_src/js/features/dropzone.ts6
-rw-r--r--web_src/js/features/emoji.ts6
-rw-r--r--web_src/js/features/file-view.ts4
-rw-r--r--web_src/js/features/repo-editor.ts13
-rw-r--r--web_src/js/features/repo-issue-list.ts8
-rw-r--r--web_src/js/features/repo-issue.ts5
-rw-r--r--web_src/js/features/repo-new.ts2
-rw-r--r--web_src/js/features/repo-wiki.ts3
-rw-r--r--web_src/js/features/tribute.ts13
-rw-r--r--web_src/js/markup/html2markdown.ts8
-rw-r--r--web_src/js/markup/mermaid.ts3
-rw-r--r--web_src/js/modules/tippy.ts3
-rw-r--r--web_src/js/modules/toast.ts2
-rw-r--r--web_src/js/svg.ts3
-rw-r--r--web_src/js/utils/dom.ts1
-rw-r--r--web_src/js/utils/html.test.ts8
-rw-r--r--web_src/js/utils/html.ts32
27 files changed, 114 insertions, 71 deletions
diff --git a/.eslintrc.cjs b/.eslintrc.cjs
index f9e1050240..57c6b19600 100644
--- a/.eslintrc.cjs
+++ b/.eslintrc.cjs
@@ -91,6 +91,7 @@ module.exports = {
plugins: ['@vitest/eslint-plugin'],
globals: vitestPlugin.environments.env.globals,
rules: {
+ 'github/unescaped-html-literal': [0],
'@vitest/consistent-test-filename': [0],
'@vitest/consistent-test-it': [0],
'@vitest/expect-expect': [0],
@@ -423,7 +424,7 @@ module.exports = {
'github/no-useless-passive': [2],
'github/prefer-observers': [2],
'github/require-passive-events': [2],
- 'github/unescaped-html-literal': [0],
+ 'github/unescaped-html-literal': [2],
'grouped-accessor-pairs': [2],
'guard-for-in': [0],
'id-blacklist': [0],
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 6052177100..f979fc814d 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2769,6 +2769,8 @@ branch.new_branch_from = Create new branch from "%s"
branch.renamed = Branch %s was renamed to %s.
branch.rename_default_or_protected_branch_error = Only admins can rename default or protected branches.
branch.rename_protected_branch_failed = This branch is protected by glob-based protection rules.
+branch.commits_divergence_from = Commits divergence: %[1]d behind and %[2]d ahead of %[3]s
+branch.commits_no_divergence = The same as branch %[1]s
tag.create_tag = Create tag %s
tag.create_tag_operation = Create tag
diff --git a/package-lock.json b/package-lock.json
index 132efb8635..8361199086 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -28,7 +28,6 @@
"dropzone": "6.0.0-beta.2",
"easymde": "2.20.0",
"esbuild-loader": "4.3.0",
- "escape-goat": "4.0.0",
"fast-glob": "3.3.3",
"htmx.org": "2.0.6",
"idiomorph": "0.7.3",
@@ -6563,18 +6562,6 @@
"node": ">=6"
}
},
- "node_modules/escape-goat": {
- "version": "4.0.0",
- "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz",
- "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==",
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/sponsors/sindresorhus"
- }
- },
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
diff --git a/package.json b/package.json
index c8a48bb5d9..fc620bc986 100644
--- a/package.json
+++ b/package.json
@@ -27,7 +27,6 @@
"dropzone": "6.0.0-beta.2",
"easymde": "2.20.0",
"esbuild-loader": "4.3.0",
- "escape-goat": "4.0.0",
"fast-glob": "3.3.3",
"htmx.org": "2.0.6",
"idiomorph": "0.7.3",
diff --git a/templates/repo/branch/list.tmpl b/templates/repo/branch/list.tmpl
index fffe3a08cc..9e86641c6f 100644
--- a/templates/repo/branch/list.tmpl
+++ b/templates/repo/branch/list.tmpl
@@ -107,8 +107,14 @@
{{end}}
</td>
<td class="two wide ui">
- {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
- <div class="commit-divergence">
+ {{if and (not .DBBranch.IsDeleted) $.DefaultBranchBranch}}
+ {{$tooltipDivergence := ""}}
+ {{if or .CommitsBehind .CommitsAhead}}
+ {{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_divergence_from" .CommitsBehind .CommitsAhead $.DefaultBranchBranch.DBBranch.Name}}
+ {{else}}
+ {{$tooltipDivergence = ctx.Locale.Tr "repo.branch.commits_no_divergence" $.DefaultBranchBranch.DBBranch.Name}}
+ {{end}}
+ <div class="commit-divergence" data-tooltip-content="{{$tooltipDivergence}}">
<div class="bar-group">
<div class="count count-behind">{{.CommitsBehind}}</div>
{{/* old code bears 0/0.0 = NaN output, so it might output invalid "width: NaNpx", it just works and doesn't caues any problem. */}}
@@ -119,7 +125,7 @@
<div class="bar bar-ahead" style="width: {{Eval 100 "*" .CommitsAhead "/" "(" .CommitsBehind "+" .CommitsAhead "+" 0.0 ")"}}%"></div>
</div>
</div>
- {{end}}
+ {{end}}
</td>
<td class="two wide tw-text-right">
{{if not .LatestPullRequest}}
diff --git a/web_src/js/bootstrap.ts b/web_src/js/bootstrap.ts
index 9e41673b86..96a2759a23 100644
--- a/web_src/js/bootstrap.ts
+++ b/web_src/js/bootstrap.ts
@@ -2,6 +2,7 @@
// to make sure the error handler always works, we should never import `window.config`, because
// some user's custom template breaks it.
import type {Intent} from './types.ts';
+import {html} from './utils/html.ts';
// This sets up the URL prefix used in webpack's chunk loading.
// This file must be imported before any lazy-loading is being attempted.
@@ -23,7 +24,7 @@ export function showGlobalErrorMessage(msg: string, msgType: Intent = 'error') {
let msgDiv = msgContainer.querySelector<HTMLDivElement>(`.js-global-error[data-global-error-msg-compact="${msgCompact}"]`);
if (!msgDiv) {
const el = document.createElement('div');
- el.innerHTML = `<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
+ el.innerHTML = html`<div class="ui container js-global-error tw-my-[--page-spacing]"><div class="ui ${msgType} message tw-text-center tw-whitespace-pre-line"></div></div>`;
msgDiv = el.childNodes[0] as HTMLDivElement;
}
// merge duplicated messages into "the message (count)" format
diff --git a/web_src/js/components/ViewFileTreeStore.ts b/web_src/js/components/ViewFileTreeStore.ts
index 13e2753c94..e2155bd58a 100644
--- a/web_src/js/components/ViewFileTreeStore.ts
+++ b/web_src/js/components/ViewFileTreeStore.ts
@@ -2,6 +2,7 @@ import {reactive} from 'vue';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {createElementFromHTML} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
export function createViewFileTreeStore(props: { repoLink: string, treePath: string, currentRefNameSubURL: string}) {
const store = reactive({
@@ -16,7 +17,7 @@ export function createViewFileTreeStore(props: { repoLink: string, treePath: str
if (!document.querySelector(`.global-svg-icon-pool #${svgId}`)) poolSvgs.push(svgContent);
}
if (poolSvgs.length) {
- const svgContainer = createElementFromHTML('<div class="global-svg-icon-pool tw-hidden"></div>');
+ const svgContainer = createElementFromHTML(html`<div class="global-svg-icon-pool tw-hidden"></div>`);
svgContainer.innerHTML = poolSvgs.join('');
document.body.append(svgContainer);
}
diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts
index 81ea09476b..97a73eace6 100644
--- a/web_src/js/features/comp/ConfirmModal.ts
+++ b/web_src/js/features/comp/ConfirmModal.ts
@@ -1,5 +1,5 @@
import {svg} from '../../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
@@ -12,17 +12,17 @@ type ConfirmModalOptions = {
}
export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
- const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
- return createElementFromHTML(`
-<div class="ui g-modal-confirm modal">
- ${headerHtml}
- <div class="content">${htmlEscape(content)}</div>
- <div class="actions">
- <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
- <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
- </div>
-</div>
-`);
+ const headerHtml = header ? html`<div class="header">${header}</div>` : '';
+ return createElementFromHTML(html`
+ <div class="ui g-modal-confirm modal">
+ ${htmlRaw(headerHtml)}
+ <div class="content">${content}</div>
+ <div class="actions">
+ <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
+ <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
+ </div>
+ </div>
+ `.trim());
}
export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index bf9ce9bfb1..bf78f58daf 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -114,7 +114,7 @@ async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, drop
export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
- text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
+ text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts
index 9fedb3ed24..4b13a2141f 100644
--- a/web_src/js/features/comp/SearchUserBox.ts
+++ b/web_src/js/features/comp/SearchUserBox.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts
index b2ba7651c4..20f7ceb6c3 100644
--- a/web_src/js/features/dropzone.ts
+++ b/web_src/js/features/dropzone.ts
@@ -1,5 +1,5 @@
import {svg} from '../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {GET, POST} from '../modules/fetch.ts';
@@ -33,14 +33,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
- fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`;
+ fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
} else {
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
}
} else if (isVideoFile(file)) {
- fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`;
+ fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
}
return fileMarkdown;
}
diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts
index 135620e51e..69afe491e2 100644
--- a/web_src/js/features/emoji.ts
+++ b/web_src/js/features/emoji.ts
@@ -1,4 +1,5 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
+import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config;
@@ -24,12 +25,11 @@ for (const key of emojiKeys) {
export function emojiHTML(name: string) {
let inner;
if (Object.hasOwn(customEmojis, name)) {
- inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
+ inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);
}
-
- return `<span class="emoji" title=":${name}:">${inner}</span>`;
+ return html`<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts
index 867f946297..d803f53c0d 100644
--- a/web_src/js/features/file-view.ts
+++ b/web_src/js/features/file-view.ts
@@ -3,7 +3,7 @@ import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
import {registerGlobalInitFunc} from '../modules/observer.ts';
import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {basename} from '../utils.ts';
const plugins: FileRenderPlugin[] = [];
@@ -54,7 +54,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
container.replaceChildren(elViewRawPrompt);
if (errorMsg) {
- const elErrorMessage = createElementFromHTML(htmlEscape`<div class="ui error message">${errorMsg}</div>`);
+ const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
}
}
diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts
index c6b5cccd54..f3ca13460c 100644
--- a/web_src/js/features/repo-editor.ts
+++ b/web_src/js/features/repo-editor.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
@@ -87,10 +87,10 @@ export function initRepoEditor() {
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
- `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
+ html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
- `<div class="breadcrumb-divider">/</div>`,
+ html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
@@ -113,7 +113,7 @@ export function initRepoEditor() {
if (!warningDiv) {
warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
- warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>';
+ warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
// Add display 'block' because display is set to 'none' in formantic\build\semantic.css
warningDiv.style.display = 'block';
const inputContainer = document.querySelector('.repo-editor-header');
@@ -196,7 +196,8 @@ export function initRepoEditor() {
})();
}
-export function renderPreviewPanelContent(previewPanel: Element, content: string) {
- previewPanel.innerHTML = `<div class="render-content markup">${content}</div>`;
+export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
+ // the content is from the server, so it is safe to use innerHTML
+ previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}
diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts
index 3ea5fb70c0..762fbf51bb 100644
--- a/web_src/js/features/repo-issue-list.ts
+++ b/web_src/js/features/repo-issue-list.ts
@@ -1,6 +1,6 @@
import {updateIssuesMeta} from './repo-common.ts';
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
@@ -138,10 +138,10 @@ function initDropdownUserRemoteSearch(el: Element) {
// the content is provided by backend IssuePosters handler
processedResults.length = 0;
for (const item of resp.results) {
- let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
- if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
+ let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`;
+ if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
- processedResults.push({value: item.username, name: html});
+ processedResults.push({value: item.username, name: nameHtml});
}
resp.results = processedResults;
return resp;
diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts
index c7799ec415..49e8fc40a2 100644
--- a/web_src/js/features/repo-issue.ts
+++ b/web_src/js/features/repo-issue.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlEscape} from '../utils/html.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
@@ -46,8 +46,7 @@ export function initRepoIssueSidebarDependency() {
if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
value: issue.id,
- name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
-<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
+ name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
}
return filteredResponse;
diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts
index 0e4d78872d..e2aa13f490 100644
--- a/web_src/js/features/repo-new.ts
+++ b/web_src/js/features/repo-new.ts
@@ -1,5 +1,5 @@
import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {sanitizeRepoName} from './repo-common.ts';
diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts
index f94d3ef3d1..6ae0947077 100644
--- a/web_src/js/features/repo-wiki.ts
+++ b/web_src/js/features/repo-wiki.ts
@@ -2,6 +2,7 @@ import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMar
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {html, htmlRaw} from '../utils/html.ts';
async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
@@ -30,7 +31,7 @@ async function initRepoWikiFormEditor() {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
lastContent = newContent;
- previewTarget.innerHTML = `<div class="render-content markup ui segment">${data}</div>`;
+ previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
} catch (error) {
console.error('Error rendering preview:', error);
} finally {
diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts
index cf98377ae7..43c21ebe6d 100644
--- a/web_src/js/features/tribute.ts
+++ b/web_src/js/features/tribute.ts
@@ -1,5 +1,5 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
type TributeItem = Record<string, any>;
@@ -26,17 +26,18 @@ export async function attachTribute(element: HTMLElement) {
return emojiString(item.original);
},
menuItemTemplate: (item: TributeItem) => {
- return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`;
+ return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
},
}, { // mentions
values: window.config.mentionValues ?? [],
requireLeadingSpace: true,
menuItemTemplate: (item: TributeItem) => {
- return `
+ const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
+ return html`
<div class="tribute-item">
- <img alt src="${htmlEscape(item.original.avatar)}" width="21" height="21"/>
- <span class="name">${htmlEscape(item.original.name)}</span>
- ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
+ <img alt src="${item.original.avatar}" width="21" height="21"/>
+ <span class="name">${item.original.name}</span>
+ ${htmlRaw(fullNameHtml)}
</div>
`;
},
diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts
index 8c2d2f8c86..5866d0d259 100644
--- a/web_src/js/markup/html2markdown.ts
+++ b/web_src/js/markup/html2markdown.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
type Processor = (el: HTMLElement) => string | HTMLElement | void;
@@ -38,10 +38,10 @@ function prepareProcessors(ctx:ProcessorContext): Processors {
IMG(el: HTMLElement) {
const alt = el.getAttribute('alt') || 'image';
const src = el.getAttribute('src');
- const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : '';
- const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : '';
+ const widthAttr = el.hasAttribute('width') ? htmlRaw` width="${el.getAttribute('width') || ''}"` : '';
+ const heightAttr = el.hasAttribute('height') ? htmlRaw` height="${el.getAttribute('height') || ''}"` : '';
if (widthAttr || heightAttr) {
- return `<img alt="${htmlEscape(alt)}"${widthAttr}${heightAttr} src="${htmlEscape(src)}">`;
+ return html`<img alt="${alt}"${widthAttr}${heightAttr} src="${src}">`;
}
return `![${alt}](${src})`;
},
diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts
index ac24b3bcba..33d9a1ed9b 100644
--- a/web_src/js/markup/mermaid.ts
+++ b/web_src/js/markup/mermaid.ts
@@ -2,6 +2,7 @@ import {isDarkTheme} from '../utils.ts';
import {makeCodeCopyButton} from './codecopy.ts';
import {displayError} from './common.ts';
import {queryElems} from '../utils/dom.ts';
+import {html, htmlRaw} from '../utils/html.ts';
const {mermaidMaxSourceCharacters} = window.config;
@@ -46,7 +47,7 @@ export async function initMarkupCodeMermaid(elMarkup: HTMLElement): Promise<void
const iframe = document.createElement('iframe');
iframe.classList.add('markup-content-iframe', 'tw-invisible');
- iframe.srcdoc = `<html><head><style>${iframeCss}</style></head><body>${svg}</body></html>`;
+ iframe.srcdoc = html`<html><head><style>${htmlRaw(iframeCss)}</style></head><body>${htmlRaw(svg)}</body></html>`;
const mermaidBlock = document.createElement('div');
mermaidBlock.classList.add('mermaid-block', 'is-loading', 'tw-hidden');
diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts
index f7a4b3723b..2a1d998d76 100644
--- a/web_src/js/modules/tippy.ts
+++ b/web_src/js/modules/tippy.ts
@@ -2,6 +2,7 @@ import tippy, {followCursor} from 'tippy.js';
import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
import {formatDatetime} from '../utils/time.ts';
import type {Content, Instance, Placement, Props} from 'tippy.js';
+import {html} from '../utils/html.ts';
type TippyOpts = {
role?: string,
@@ -9,7 +10,7 @@ type TippyOpts = {
} & Partial<Props>;
const visibleInstances = new Set<Instance>();
-const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
+const arrowSvg = html`<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
// the callback functions should be destructured from opts,
diff --git a/web_src/js/modules/toast.ts b/web_src/js/modules/toast.ts
index b0afc343c3..ed807a4977 100644
--- a/web_src/js/modules/toast.ts
+++ b/web_src/js/modules/toast.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../utils/html.ts';
import {svg} 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
diff --git a/web_src/js/svg.ts b/web_src/js/svg.ts
index 7b377e1ab4..50c9536f37 100644
--- a/web_src/js/svg.ts
+++ b/web_src/js/svg.ts
@@ -1,5 +1,6 @@
import {defineComponent, h, type PropType} from 'vue';
import {parseDom, serializeXml} from './utils.ts';
+import {html, htmlRaw} from './utils/html.ts';
import giteaDoubleChevronLeft from '../../public/assets/img/svg/gitea-double-chevron-left.svg';
import giteaDoubleChevronRight from '../../public/assets/img/svg/gitea-double-chevron-right.svg';
import giteaEmptyCheckbox from '../../public/assets/img/svg/gitea-empty-checkbox.svg';
@@ -220,7 +221,7 @@ export const SvgIcon = defineComponent({
const classes = Array.from(svgOuter.classList);
if (this.symbolId) {
classes.push('tw-hidden', 'svg-symbol-container');
- svgInnerHtml = `<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${svgInnerHtml}</symbol>`;
+ svgInnerHtml = html`<symbol id="${this.symbolId}" viewBox="${attrs['^viewBox']}">${htmlRaw(svgInnerHtml)}</symbol>`;
}
// create VNode
return h('svg', {
diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts
index 7ed0d73406..8b540cebb1 100644
--- a/web_src/js/utils/dom.ts
+++ b/web_src/js/utils/dom.ts
@@ -314,6 +314,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
export function createElementFromHTML<T extends HTMLElement>(htmlString: string): T {
htmlString = htmlString.trim();
// some tags like "tr" are special, it must use a correct parent container to create
+ // eslint-disable-next-line github/unescaped-html-literal -- FIXME: maybe we need to use other approaches to create elements from HTML, e.g. using DOMParser
if (htmlString.startsWith('<tr')) {
const container = document.createElement('table');
container.innerHTML = htmlString;
diff --git a/web_src/js/utils/html.test.ts b/web_src/js/utils/html.test.ts
new file mode 100644
index 0000000000..3028b7bb0a
--- /dev/null
+++ b/web_src/js/utils/html.test.ts
@@ -0,0 +1,8 @@
+import {html, htmlEscape, htmlRaw} from './html.ts';
+
+test('html', async () => {
+ expect(html`<a>${'<>&\'"'}</a>`).toBe(`<a>&lt;&gt;&amp;&#39;&quot;</a>`);
+ expect(html`<a>${htmlRaw('<img>')}</a>`).toBe(`<a><img></a>`);
+ expect(html`<a>${htmlRaw`<img ${'&'}>`}</a>`).toBe(`<a><img &amp;></a>`);
+ expect(htmlEscape(`<a></a>`)).toBe(`&lt;a&gt;&lt;/a&gt;`);
+});
diff --git a/web_src/js/utils/html.ts b/web_src/js/utils/html.ts
new file mode 100644
index 0000000000..22e5703c34
--- /dev/null
+++ b/web_src/js/utils/html.ts
@@ -0,0 +1,32 @@
+export function htmlEscape(s: string, ...args: Array<any>): string {
+ if (args.length !== 0) throw new Error('use html or htmlRaw instead of htmlEscape'); // check legacy usages
+ return s.replace(/&/g, '&amp;')
+ .replace(/"/g, '&quot;')
+ .replace(/'/g, '&#39;')
+ .replace(/</g, '&lt;')
+ .replace(/>/g, '&gt;');
+}
+
+class rawObject {
+ private readonly value: string;
+ constructor(v: string) { this.value = v }
+ toString(): string { return this.value }
+}
+
+export function html(tmpl: TemplateStringsArray, ...parts: Array<any>): string {
+ let output = tmpl[0];
+ for (let i = 0; i < parts.length; i++) {
+ const value = parts[i];
+ const valueEscaped = (value instanceof rawObject) ? value.toString() : htmlEscape(String(parts[i]));
+ output = output + valueEscaped + tmpl[i + 1];
+ }
+ return output;
+}
+
+export function htmlRaw(s: string|TemplateStringsArray, ...tmplParts: Array<any>): rawObject {
+ if (typeof s === 'string') {
+ if (tmplParts.length !== 0) throw new Error("either htmlRaw('str') or htmlRaw`tmpl`");
+ return new rawObject(s);
+ }
+ return new rawObject(html(s, ...tmplParts));
+}