diff --git a/web_src/css/themes/theme-gitea-dark.css b/web_src/css/themes/theme-gitea-dark.css
index f347589509..c62c20f93a 100644
--- a/web_src/css/themes/theme-gitea-dark.css
+++ b/web_src/css/themes/theme-gitea-dark.css
@@ -74,6 +74,7 @@ gitea-theme-meta-info {
--color-console-active-bg: #2e353b;
--color-console-menu-bg: #262b31;
--color-console-menu-border: #414b55;
+ --color-console-link: #8f9ba8;
/* named colors */
--color-red: #cc4848;
--color-orange: #cc580c;
diff --git a/web_src/css/themes/theme-gitea-light.css b/web_src/css/themes/theme-gitea-light.css
index dc916f002d..5f437c5a6c 100644
--- a/web_src/css/themes/theme-gitea-light.css
+++ b/web_src/css/themes/theme-gitea-light.css
@@ -74,6 +74,7 @@ gitea-theme-meta-info {
--color-console-active-bg: #d0d7de;
--color-console-menu-bg: #f8f9fb;
--color-console-menu-border: #d0d7de;
+ --color-console-link: #5c656d;
/* named colors */
--color-red: #db2828;
--color-orange: #f2711c;
diff --git a/web_src/js/components/ActionRunJobView.vue b/web_src/js/components/ActionRunJobView.vue
index 9d8ee0dbde..747889d04c 100644
--- a/web_src/js/components/ActionRunJobView.vue
+++ b/web_src/js/components/ActionRunJobView.vue
@@ -641,6 +641,11 @@ async function hashChangeListener() {
overflow-wrap: anywhere;
}
+.job-step-logs .log-msg a {
+ color: var(--color-console-link) !important;
+ text-decoration: underline;
+}
+
.job-step-logs .job-log-line .log-cmd-command {
color: var(--color-ansi-blue);
}
diff --git a/web_src/js/render/ansi.test.ts b/web_src/js/render/ansi.test.ts
index 21b7523994..2d9b8ede00 100644
--- a/web_src/js/render/ansi.test.ts
+++ b/web_src/js/render/ansi.test.ts
@@ -17,4 +17,9 @@ test('renderAnsi', () => {
// treat "\033[0K" and "\033[0J" (Erase display/line) as "\r", then it will be covered to "\n" finally.
expect(renderAnsi('a\x1b[Kb\x1b[2Jc')).toEqual('a\nb\nc');
expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`ab`);
+
+ // URLs in ANSI output become clickable links
+ const link = (url: string) => `${url}`;
+ expect(renderAnsi('Downloading https://github.com/actions/upload-artifact/releases')).toEqual(`Downloading ${link('https://github.com/actions/upload-artifact/releases')}`);
+ expect(renderAnsi('\x1b[32mhttps://proxy.golang.org/cached-only\x1b[0m')).toEqual(`${link('https://proxy.golang.org/cached-only')}`);
});
diff --git a/web_src/js/render/ansi.ts b/web_src/js/render/ansi.ts
index f5429ef6ad..4625e54233 100644
--- a/web_src/js/render/ansi.ts
+++ b/web_src/js/render/ansi.ts
@@ -1,4 +1,5 @@
import {AnsiUp} from 'ansi_up';
+import {linkifyURLs} from '../utils/url.ts';
const replacements: Array<[RegExp, string]> = [
[/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
@@ -25,21 +26,23 @@ export function renderAnsi(line: string): string {
}
}
+ let result: string;
if (!line.includes('\r')) {
- return ansi_up.ansi_to_html(line);
- }
-
- // handle "\rReading...1%\rReading...5%\rReading...100%",
- // convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
- const lines: Array = [];
- for (const part of line.split('\r')) {
- if (part === '') continue;
- const partHtml = ansi_up.ansi_to_html(part);
- if (partHtml !== '') {
- lines.push(partHtml);
+ result = ansi_up.ansi_to_html(line);
+ } else {
+ // handle "\rReading...1%\rReading...5%\rReading...100%",
+ // convert it into a multiple-line string: "Reading...1%\nReading...5%\nReading...100%"
+ const lines: Array = [];
+ for (const part of line.split('\r')) {
+ if (part === '') continue;
+ const partHtml = ansi_up.ansi_to_html(part);
+ if (partHtml !== '') {
+ lines.push(partHtml);
+ }
}
+ // the log message element is with "white-space: break-spaces;", so use "\n" to break lines
+ result = lines.join('\n');
}
- // the log message element is with "white-space: break-spaces;", so use "\n" to break lines
- return lines.join('\n');
+ return linkifyURLs(result);
}
diff --git a/web_src/js/utils/url.test.ts b/web_src/js/utils/url.test.ts
index c39dd15732..3a4323e88f 100644
--- a/web_src/js/utils/url.test.ts
+++ b/web_src/js/utils/url.test.ts
@@ -1,10 +1,37 @@
-import {pathEscapeSegments, toOriginUrl} from './url.ts';
+import {linkifyURLs, pathEscapeSegments, toOriginUrl} from './url.ts';
test('pathEscapeSegments', () => {
expect(pathEscapeSegments('a/b/c')).toEqual('a/b/c');
expect(pathEscapeSegments('a/b/ c')).toEqual('a/b/%20c');
});
+test('linkifyURLs', () => {
+ const link = (url: string) => `${url}`;
+ expect(linkifyURLs('https://example.com')).toEqual(link('https://example.com'));
+ expect(linkifyURLs('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz')).toEqual(link('https://dl.google.com/go/go1.23.6.linux-amd64.tar.gz'));
+ expect(linkifyURLs('https://example.com/path?query=1&b=2#frag')).toEqual(link('https://example.com/path?query=1&b=2#frag'));
+ expect(linkifyURLs('visit https://example.com/repo for info')).toEqual(`visit ${link('https://example.com/repo')} for info`);
+ expect(linkifyURLs('See https://example.com.')).toEqual(`See ${link('https://example.com')}.`);
+ expect(linkifyURLs('https://example.com, and more')).toEqual(`${link('https://example.com')}, and more`);
+ expect(linkifyURLs('https://proxy.golang.org/cached-only')).toEqual(`${link('https://proxy.golang.org/cached-only')}`);
+ expect(linkifyURLs('https://registry.npmjs.org/@types/node')).toEqual(`${link('https://registry.npmjs.org/@types/node')}`);
+ expect(linkifyURLs('https://a.com and https://b.org')).toEqual(`${link('https://a.com')} and ${link('https://b.org')}`);
+ expect(linkifyURLs('no urls here')).toEqual('no urls here');
+ expect(linkifyURLs('http://example.com/path')).toEqual(link('http://example.com/path'));
+ expect(linkifyURLs('http://localhost:3000/repo')).toEqual(link('http://localhost:3000/repo'));
+ expect(linkifyURLs('https://')).toEqual('https://');
+ expect(linkifyURLs('Click here')).toEqual('Click here');
+ expect(linkifyURLs('Click here')).toEqual('Click here');
+ expect(linkifyURLs('https://example.com')).toEqual('https://example.com');
+ expect(linkifyURLs('https://evil.com/')).toEqual(`${link('https://evil.com/')}`);
+ expect(linkifyURLs('https://evil.com/"onmouseover="alert(1)')).toEqual(`${link('https://evil.com/')}"onmouseover="alert(1)`);
+ expect(linkifyURLs('javascript:alert(1)')).toEqual('javascript:alert(1)'); // eslint-disable-line no-script-url
+ expect(linkifyURLs("https://evil.com/'onclick='alert(1)")).toEqual(`${link('https://evil.com/')}'onclick='alert(1)`);
+ expect(linkifyURLs('data:text/html,')).toEqual('data:text/html,');
+ expect(linkifyURLs('https://evil.com/\nonclick=alert(1)')).toEqual(`${link('https://evil.com/')}\nonclick=alert(1)`);
+ expect(linkifyURLs('https://evil.com/"onmouseover=alert(1)')).toEqual(`${link('https://evil.com/"onmouseover=alert')}(1)`);
+});
+
test('toOriginUrl', () => {
const oldLocation = String(window.location);
for (const origin of ['https://example.com', 'https://example.com:3000']) {
diff --git a/web_src/js/utils/url.ts b/web_src/js/utils/url.ts
index 6bcb4c1609..469693373a 100644
--- a/web_src/js/utils/url.ts
+++ b/web_src/js/utils/url.ts
@@ -2,6 +2,34 @@ export function pathEscapeSegments(s: string): string {
return s.split('/').map(encodeURIComponent).join('/');
}
+// Match HTML tags (to skip) or URLs (to linkify) in HTML content
+const urlLinkifyPattern = /(<([-\w]+)[^>]*>)|(<\/([-\w]+)[^>]*>)|(https?:\/\/[^\s<>"'`|(){}[\]]+)/gi;
+const trailingPunctPattern = /[.,;:!?]+$/;
+
+// Convert URLs to clickable links in HTML, preserving existing HTML tags
+export function linkifyURLs(html: string): string {
+ let inAnchor = false;
+ return html.replace(urlLinkifyPattern, (match, _openTagFull, openTag, _closeTagFull, closeTag, url) => {
+ // skip URLs inside existing tags
+ if (openTag === 'a') {
+ inAnchor = true;
+ return match;
+ } else if (closeTag === 'a') {
+ inAnchor = false;
+ return match;
+ }
+ if (inAnchor || !url) {
+ return match;
+ }
+
+ const trailingPunct = url.match(trailingPunctPattern);
+ const cleanUrl = trailingPunct ? url.slice(0, -trailingPunct[0].length) : url;
+ const trailing = trailingPunct ? trailingPunct[0] : '';
+ // safe because regexp only matches valid URLs (no quotes or angle brackets)
+ return `${cleanUrl}${trailing}`; // eslint-disable-line github/unescaped-html-literal
+ });
+}
+
/** Convert an absolute or relative URL to an absolute URL with the current origin. It only
* processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'. */
export function toOriginUrl(urlStr: string) {