mirror of
https://github.com/go-gitea/gitea.git
synced 2026-03-30 18:14:14 +02:00
Linkify URLs in Actions workflow logs (#36986)
Detect URLs in Actions log output and render them as clickable links, similar to how GitHub Actions handles this. Pre-existing links from ansi_up's OSC 8 parsing are also kept intact. --------- Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Claude (claude-opus-4-6) <noreply@anthropic.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
parent
ffa626b585
commit
9583e1a65c
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(`<span style="background-color:rgb(135,0,0)">a</span><span style="background-color:rgb(175,255,255)">b</span>`);
|
||||
|
||||
// URLs in ANSI output become clickable links
|
||||
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
|
||||
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(`<span class="ansi-green-fg">${link('https://proxy.golang.org/cached-only')}</span>`);
|
||||
});
|
||||
|
||||
@ -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<string> = [];
|
||||
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<string> = [];
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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) => `<a href="${url}" target="_blank">${url}</a>`;
|
||||
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('<span class="ansi-green-fg">https://proxy.golang.org/cached-only</span>')).toEqual(`<span class="ansi-green-fg">${link('https://proxy.golang.org/cached-only')}</span>`);
|
||||
expect(linkifyURLs('<span style="color:rgb(0,255,0)">https://registry.npmjs.org/@types/node</span>')).toEqual(`<span style="color:rgb(0,255,0)">${link('https://registry.npmjs.org/@types/node')}</span>`);
|
||||
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('<a href="https://example.com">Click here</a>')).toEqual('<a href="https://example.com">Click here</a>');
|
||||
expect(linkifyURLs('<a\nhref="https://example.com">Click here</a>')).toEqual('<a\nhref="https://example.com">Click here</a>');
|
||||
expect(linkifyURLs('<a href="https://example.com">https://example.com</a>')).toEqual('<a href="https://example.com">https://example.com</a>');
|
||||
expect(linkifyURLs('https://evil.com/<script>alert(1)</script>')).toEqual(`${link('https://evil.com/')}<script>alert(1)</script>`);
|
||||
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,<script>alert(1)</script>')).toEqual('data:text/html,<script>alert(1)</script>');
|
||||
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']) {
|
||||
|
||||
@ -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 <a> 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 `<a href="${cleanUrl}" target="_blank">${cleanUrl}</a>${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) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user