mirror of
https://github.com/go-gitea/gitea.git
synced 2026-08-07 01:36:06 +00:00
enhance(actions): replace ansi_up with first-party code (#38619)
Replaces the `ansi_up` dependency with first-party code and fixes a number of bugs in turn. - Faster rendering, around 7x for plain lines and 3x for colored ones. - Render many SGR features like hyperlinks, blink, inverse, conceal, strikethrough, overline, underline styles and underline color, including `:` sub-parameters, which no longer swallow the codes after them. - Drop OSC, DCS, SOS, PM and APC with their payload, ending them at BEL, `ESC \` or the 8-bit ST. A truncated sequence is dropped instead of corrupting a later line. - A backspace moves the cursor back a column, so what follows overwrites it, even across a style change. - A style inside an OSC 8 label renders instead of leaking, and a private CSI ending in `m` no longer resets the style. - Log lines render as DOM nodes, never as markup, and only an `http(s)` url becomes a link. - Named colors render as CSS classes, only 24-bit color stays inline. - Invisible text is now selectable, and the `z-index` workaround is gone. Signed-off-by: silverwind <me@silverwind.io> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
This commit is contained in:
@@ -35,7 +35,6 @@
|
||||
"@replit/codemirror-vscode-keymap": "6.0.2",
|
||||
"@resvg/resvg-wasm": "2.6.2",
|
||||
"@vitejs/plugin-vue": "6.0.8",
|
||||
"ansi_up": "6.0.6",
|
||||
"asciinema-player": "3.17.0",
|
||||
"chart.js": "4.5.1",
|
||||
"chartjs-adapter-dayjs-4": "1.0.4",
|
||||
|
||||
Generated
-8
@@ -95,9 +95,6 @@ importers:
|
||||
'@vitejs/plugin-vue':
|
||||
specifier: 6.0.8
|
||||
version: 6.0.8(vite@8.1.5(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0))(vue@3.5.40(typescript@6.0.3))
|
||||
ansi_up:
|
||||
specifier: 6.0.6
|
||||
version: 6.0.6
|
||||
asciinema-player:
|
||||
specifier: 3.17.0
|
||||
version: 3.17.0
|
||||
@@ -1679,9 +1676,6 @@ packages:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
ansi_up@6.0.6:
|
||||
resolution: {integrity: sha512-yIa1x3Ecf8jWP4UWEunNjqNX6gzE4vg2gGz+xqRGY+TBSucnYp6RRdPV4brmtg6bQ1ljD48mZ5iGSEj7QEpRKA==}
|
||||
|
||||
any-promise@1.3.0:
|
||||
resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==}
|
||||
|
||||
@@ -5946,8 +5940,6 @@ snapshots:
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
ansi_up@6.0.6: {}
|
||||
|
||||
any-promise@1.3.0: {}
|
||||
|
||||
anymatch@3.1.3:
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{{template "devtest/devtest-header"}}
|
||||
<div class="page-content">
|
||||
<div class="ui container">
|
||||
<div data-global-init="initDevtestAnsiRender"></div>
|
||||
</div>
|
||||
</div>
|
||||
{{template "devtest/devtest-footer"}}
|
||||
@@ -16,15 +16,41 @@
|
||||
text-decoration: underline;
|
||||
text-decoration-style: dashed;
|
||||
}
|
||||
.console a:hover { color: var(--color-primary); }
|
||||
.console a:hover {
|
||||
color: var(--color-primary);
|
||||
text-underline-position: auto; /* the global "a:hover" sets "under", which would shift the underline */
|
||||
}
|
||||
|
||||
@keyframes blink-animation {
|
||||
to {
|
||||
visibility: hidden;
|
||||
50% {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* ansi_up colors used in actions */
|
||||
/* SGR attributes emitted by web_src/js/render/ansi.ts */
|
||||
|
||||
.ansi-bold { font-weight: var(--font-weight-semibold); }
|
||||
.ansi-italic { font-style: italic; }
|
||||
.ansi-blink { animation: blink-animation 1s step-end infinite; }
|
||||
.ansi-faint { color: color-mix(in srgb, currentcolor 70%, transparent); }
|
||||
.ansi-conceal { color: transparent; }
|
||||
.ansi-inverse-fg { color: var(--color-console-bg); }
|
||||
.ansi-inverse-bg { background-color: var(--color-console-fg); }
|
||||
|
||||
.ansi-underline { text-decoration-line: underline; }
|
||||
.ansi-line-through { text-decoration-line: line-through; }
|
||||
.ansi-overline { text-decoration-line: overline; }
|
||||
.ansi-underline.ansi-line-through { text-decoration-line: underline line-through; }
|
||||
.ansi-underline.ansi-overline { text-decoration-line: underline overline; }
|
||||
.ansi-line-through.ansi-overline { text-decoration-line: line-through overline; }
|
||||
.ansi-underline.ansi-line-through.ansi-overline { text-decoration-line: underline line-through overline; }
|
||||
|
||||
.ansi-double { text-decoration-style: double; }
|
||||
.ansi-wavy { text-decoration-style: wavy; }
|
||||
.ansi-dotted { text-decoration-style: dotted; }
|
||||
.ansi-dashed { text-decoration-style: dashed; }
|
||||
|
||||
/* ANSI colors used in actions */
|
||||
|
||||
.ansi-black-fg { color: var(--color-ansi-black); }
|
||||
.ansi-red-fg { color: var(--color-ansi-red); }
|
||||
@@ -67,7 +93,7 @@
|
||||
.term-fg2 { color: var(--color-ansi-bright-black); } /* faint (decreased intensity) - same as gray really */
|
||||
.term-fg3 { font-style: italic; } /* italic */
|
||||
.term-fg4 { text-decoration: underline; } /* underline */
|
||||
.term-fg5 { animation: blink-animation 1s steps(3, start) infinite; } /* blink */
|
||||
.term-fg5 { animation: blink-animation 1s step-end infinite; } /* blink */
|
||||
.term-fg9 { text-decoration: line-through; } /* crossed-out */
|
||||
|
||||
.term-fg30 { color: var(--color-ansi-black); } /* black (but we can't use black, so a diff color) */
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {IntervalId} from '../types.ts';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import {AnsiLineRenderer} from '../render/ansi.ts';
|
||||
import {
|
||||
type ActionRunViewStore,
|
||||
createLogLineMessage,
|
||||
@@ -36,6 +37,9 @@ type JobStepState = {
|
||||
manuallyCollapsed: boolean, // whether the user manually collapsed the step, used to avoid auto-expanding it again
|
||||
}
|
||||
|
||||
// one ANSI renderer per step, so an unterminated color carries between that step's lines only
|
||||
const stepAnsiRenderers: AnsiLineRenderer[] = [];
|
||||
|
||||
type StepContainerElement = HTMLElement & {
|
||||
// To remember the last active logs container, for example: a batch of logs only starts a group but doesn't end it,
|
||||
// then the following batches of logs should still use the same group (active logs container).
|
||||
@@ -213,10 +217,11 @@ async function copyStepOutput(event: MouseEvent, stepIndex: number) {
|
||||
const data = await fetchJobData([{step: stepIndex, cursor: null, expanded: true}]);
|
||||
const stepLog = data.logs.stepsLog?.find((s) => s.step === stepIndex);
|
||||
const lines: string[] = [];
|
||||
const ansi = new AnsiLineRenderer();
|
||||
for (const line of stepLog?.lines ?? []) {
|
||||
const cmd = parseLogLineCommand(line);
|
||||
if (cmd?.name === 'hidden' || cmd?.name === 'endgroup') continue;
|
||||
const msg = createLogLineMessage(line, cmd).textContent ?? '';
|
||||
const msg = createLogLineMessage(ansi, line, cmd).textContent ?? '';
|
||||
lines.push(timeVisible.value['log-time-stamp'] ? `${formatDatetimeISO(line.timestamp)} ${msg}` : msg);
|
||||
}
|
||||
return lines.join('\n');
|
||||
@@ -240,7 +245,7 @@ function createLogLine(stepIndex: number, startTime: number, line: LogLine, cmd:
|
||||
const logTimeStamp = createElementFromAttrs('span', {class: 'log-time-stamp'},
|
||||
formatDatetime(line.timestamp * 1000), // for "Show timestamps"
|
||||
);
|
||||
const logMsg = createLogLineMessage(line, cmd);
|
||||
const logMsg = createLogLineMessage(stepAnsiRenderers[stepIndex] ??= new AnsiLineRenderer(), line, cmd);
|
||||
const seconds = Math.floor(line.timestamp - startTime);
|
||||
const logTimeSeconds = createElementFromAttrs('span', {class: 'log-time-seconds'},
|
||||
`${seconds}s`, // for "Show seconds"
|
||||
@@ -598,7 +603,6 @@ async function hashChangeListener() {
|
||||
.job-step-container {
|
||||
max-height: 100%;
|
||||
border-radius: 0 0 var(--border-radius) var(--border-radius);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.job-step-container .job-step-summary {
|
||||
@@ -651,9 +655,6 @@ async function hashChangeListener() {
|
||||
background-color: var(--color-console-active-bg);
|
||||
position: sticky;
|
||||
top: 60px;
|
||||
/* workaround ansi_up issue related to faintStyle generating a CSS stacking context via `opacity`
|
||||
inline style which caused such elements to render above the .job-step-summary header. */
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {createLogLineMessage, parseLogLineCommand} from './ActionRunView.ts';
|
||||
import {AnsiLineRenderer} from '../render/ansi.ts';
|
||||
|
||||
test('LogLineMessage', () => {
|
||||
const cases = {
|
||||
@@ -31,10 +32,11 @@ test('LogLineMessage', () => {
|
||||
'::add-matcher::foo': '<span class="log-msg log-cmd-hidden">foo</span>',
|
||||
'::remove-matcher foo::': '<span class="log-msg log-cmd-hidden"> foo::</span>', // not correctly parsed, but we don't need it
|
||||
};
|
||||
const ansi = new AnsiLineRenderer();
|
||||
for (const [input, html] of Object.entries(cases)) {
|
||||
const line = {index: 0, timestamp: 0, message: input};
|
||||
const cmd = parseLogLineCommand(line);
|
||||
const el = createLogLineMessage(line, cmd);
|
||||
const el = createLogLineMessage(ansi, line, cmd);
|
||||
expect(el.outerHTML).toBe(html);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {renderAnsiInto} from '../render/ansi.ts';
|
||||
import type {AnsiLineRenderer} from '../render/ansi.ts';
|
||||
import {reactive} from 'vue';
|
||||
import type {ActionsArtifact, ActionsJob, ActionsRun, ActionsStatus} from '../modules/gitea-actions.ts';
|
||||
import type {IntervalId} from '../types.ts';
|
||||
@@ -76,11 +76,11 @@ function decodeLineMessage(line: LogLine, cmd: LogLineCommand | null): string {
|
||||
if (cmd.name === 'command') return msg; // "command" is only an output tag, do not parse or escape it
|
||||
// "##[cmd]" also escapes ";" and "]" which delimit its header, "::cmd::" does not
|
||||
if (!cmd.prefix.startsWith('::')) msg = msg.replace(/%3B/g, ';').replace(/%5D/g, ']');
|
||||
// renderAnsiInto breaks a line per "\r", so "%0D%0A" is one break. "%25" last keeps "%250A" literal
|
||||
// a line breaks per "\r" when rendered, so "%0D%0A" is one break. "%25" last keeps "%250A" literal
|
||||
return msg.replace(/(?:%0D)?%0A/g, '\n').replace(/%0D/g, '\r').replace(/%25/g, '%');
|
||||
}
|
||||
|
||||
export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null) {
|
||||
export function createLogLineMessage(ansi: AnsiLineRenderer, line: LogLine, cmd: LogLineCommand | null) {
|
||||
const logMsgAttrs = {class: 'log-msg'};
|
||||
if (cmd?.name) logMsgAttrs.class += ` log-cmd-${cmd.name}`; // make it easier to add styles to some commands like "error"
|
||||
|
||||
@@ -90,10 +90,10 @@ export function createLogLineMessage(line: LogLine, cmd: LogLineCommand | null)
|
||||
if (label) {
|
||||
logMsg.append(createElementFromAttrs('span', {class: 'log-msg-label'}, `${label}:`));
|
||||
const msgSpan = document.createElement('span');
|
||||
renderAnsiInto(msgSpan, ` ${msgContent.trimStart()}`);
|
||||
ansi.renderLine(msgSpan, ` ${msgContent.trimStart()}`);
|
||||
logMsg.append(msgSpan);
|
||||
} else {
|
||||
renderAnsiInto(logMsg, msgContent);
|
||||
ansi.renderLine(logMsg, msgContent);
|
||||
}
|
||||
return logMsg;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {showFomanticModal} from './fomantic/modal.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {showGlobalErrorMessage} from './errors.ts';
|
||||
import {AnsiLineRenderer} from '../render/ansi.ts';
|
||||
|
||||
type LevelMap = Record<string, (message: string) => Toast | null>;
|
||||
|
||||
@@ -56,8 +57,56 @@ function initDevtestPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// shows every sequence web_src/js/render/ansi.ts renders. Lines whose output does not explain
|
||||
// itself are preceded by their own escaped source.
|
||||
function initDevtestAnsiRender(container: HTMLElement) {
|
||||
const esc = '\x1b';
|
||||
const cells = (count: number, cell: (index: number) => string, separator = '') =>
|
||||
Array.from({length: count}, (_value, index) => cell(index)).join(separator);
|
||||
const attr = (params: string, label: string) => `${esc}[${params}m${label}${esc}[m`;
|
||||
const withSource = (line: string) => [attr('2', line.replaceAll(esc, '\\e').replaceAll('\b', '\\b').replaceAll('\r', '\\r')), line];
|
||||
|
||||
const lines = [
|
||||
...Array.from({length: 16}, (_value, row) =>
|
||||
cells(16, (col) => `${esc}[38;5;${row * 16 + col}m${String(row * 16 + col).padStart(4)}${esc}[0m`)),
|
||||
' ',
|
||||
cells(16, (index) => `${esc}[48;5;${index}m ${String(index).padStart(3)} ${esc}[0m`),
|
||||
// truecolor, a gradient no palette index can express
|
||||
cells(77, (col) => `${esc}[48;2;${255 - col * 3};0;${col * 3}m${esc}[38;2;${col * 3};0;${255 - col * 3}m/${esc}[0m`),
|
||||
' ',
|
||||
[cells(10, (code) => attr(String(code), `SGR ${code}`), ' '), attr('53', 'SGR 53')].join(' '),
|
||||
' ',
|
||||
[
|
||||
cells(5, (index) => attr(`4:${index + 1}`, `SGR 4:${index + 1}`), ' '),
|
||||
attr('21', 'SGR 21'),
|
||||
`${esc}[4:3m${esc}[58;2;135;0;255mtruecolor underline${esc}[59m${esc}[4:0m`,
|
||||
`${esc}]8;;https://example.com${esc}\\${esc}[3mstyled${esc}[23m hyperlink${esc}]8;;${esc}\\`,
|
||||
].join(' '),
|
||||
' ',
|
||||
...withSource('Reading... 1%\rReading... 50%\rReading... 100%'),
|
||||
...withSource(`first${esc}[Ksecond${esc}[2Jthird`),
|
||||
...withSource(`cursor ${esc}[3Amovement, private ${esc}[?25lCSI, ${esc}]0;title${esc}\\titles, ${esc}Pquery${esc}\\strings, truncated${esc}[38;5;`),
|
||||
...withSource('Reading... 10%\b\b\b100%'),
|
||||
...withSource('<script>alert(1)</script> & "quotes", and a bare url https://example.com'),
|
||||
' ',
|
||||
`${esc}[31man unterminated color`,
|
||||
'carries into the following lines',
|
||||
`${esc}[0muntil something resets it`,
|
||||
];
|
||||
|
||||
const elConsole = createElementFromHTML(html`<div class="console tw-p-2 tw-whitespace-pre-wrap"></div>`);
|
||||
const ansi = new AnsiLineRenderer();
|
||||
for (const line of lines) {
|
||||
const el = document.createElement('div');
|
||||
ansi.renderLine(el, line);
|
||||
elConsole.append(el);
|
||||
}
|
||||
container.append(elConsole);
|
||||
}
|
||||
|
||||
export function initDevtest() {
|
||||
registerGlobalInitFunc('initDevtestPage', initDevtestPage);
|
||||
registerGlobalInitFunc('initDevtestAnsiRender', initDevtestAnsiRender);
|
||||
registerGlobalInitFunc('initDevtestDetailsErrorMessage', () => {
|
||||
for (let i = 0; i < 2; i++) {
|
||||
showGlobalErrorMessage('showGlobalErrorMessage single message', 'warning');
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {renderAnsiInto} from './ansi.ts';
|
||||
import {AnsiLineRenderer} from './ansi.ts';
|
||||
|
||||
test('renderAnsi', () => {
|
||||
const renderAnsi = (line: string) => {
|
||||
const renderAnsi = (line: string, ansi = new AnsiLineRenderer()) => {
|
||||
const el = document.createElement('div');
|
||||
renderAnsiInto(el, line);
|
||||
ansi.renderLine(el, line);
|
||||
return el.innerHTML;
|
||||
};
|
||||
|
||||
@@ -17,12 +17,10 @@ test('renderAnsi', () => {
|
||||
expect(renderAnsi('<script>')).toEqual('<script>');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2Ktest\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2K\rtest\r\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2Ktest\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
expect(renderAnsi('\x1b[1A\x1b[2K\rtest\r\x1b[1B\x1b[1A\x1b[2K')).toEqual('test');
|
||||
|
||||
// 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>`);
|
||||
expect(renderAnsi('\x1b[48;5;88ma\x1b[38;208;48;5;159mb\x1b[m')).toEqual(`<span style="background-color: #870000;">a</span><span style="background-color: #afffff;">b</span>`);
|
||||
|
||||
// URLs in ANSI output become clickable links
|
||||
const link = (url: string) => `<a href="${url}" target="_blank">${url}</a>`;
|
||||
@@ -31,4 +29,54 @@ test('renderAnsi', () => {
|
||||
expect(renderAnsi('open https://example.com.')).toEqual(`open ${link('https://example.com')}.`);
|
||||
expect(renderAnsi('"https://example.com"')).toEqual(`"${link('https://example.com')}"`);
|
||||
expect(renderAnsi('\x1b[32mhttps://example.com\x1b[0m')).toEqual(`<span class="ansi-green-fg">${link('https://example.com')}</span>`);
|
||||
|
||||
// attributes, faint nesting so its color mixes with the outer one, conceal emitting no foreground
|
||||
expect(renderAnsi('\x1b[1;5;8mx')).toEqual('<span class="ansi-bold ansi-blink ansi-conceal">x</span>');
|
||||
expect(renderAnsi('\x1b[6mx')).toEqual('<span class="ansi-blink">x</span>'); // 6 is the rapid blink
|
||||
expect(renderAnsi('\x1b[2;31mx')).toEqual('<span class="ansi-red-fg"><span class="ansi-faint">x</span></span>');
|
||||
expect(renderAnsi('\x1b[31;7mx')).toEqual('<span class="ansi-inverse-fg ansi-red-bg">x</span>');
|
||||
expect(renderAnsi('\x1b[4:3;9;53mx')).toEqual('<span class="ansi-underline ansi-line-through ansi-overline ansi-wavy">x</span>');
|
||||
expect(renderAnsi('\x1b[4;58;5;9mx')).toEqual('<span class="ansi-underline" style="text-decoration-color: var(--color-ansi-bright-red);">x</span>');
|
||||
|
||||
// a color as ":" sub-parameters, with and without a color space id, not consuming the codes after
|
||||
expect(renderAnsi('\x1b[38:2::255:0:0ma\x1b[48:2:0:0:255mb')).toEqual('<span style="color: #ff0000;">a</span><span style="color: #ff0000; background-color: #0000ff;">b</span>');
|
||||
expect(renderAnsi('\x1b[1;38:5:9;4mx')).toEqual('<span class="ansi-bold ansi-underline ansi-bright-red-fg">x</span>');
|
||||
// a private CSI carries no style, even ending in "m", and does not split the run around it
|
||||
expect(renderAnsi('\x1b[31mred\x1b[>4;2m!')).toEqual('<span class="ansi-red-fg">red!</span>');
|
||||
|
||||
// OSC 8 hyperlinks
|
||||
expect(renderAnsi('\x1b]8;;https://example.com\x1b\\text\x1b]8;;\x1b\\')).toEqual(`<a href="https://example.com" target="_blank">text</a>`);
|
||||
// only an "http(s)" hyperlink renders as one, and nothing in a log can become markup or an attribute
|
||||
expect(renderAnsi('\x1b]8;;javascript:alert(1)\x1b\\text\x1b]8;;\x1b\\')).toEqual('text');
|
||||
expect(renderAnsi('\x1b]8;;data:text/html,<script>\x1b\\text\x1b]8;;\x1b\\')).toEqual('text');
|
||||
expect(renderAnsi('\x1b]8;;https://x" onmouseover="alert(1)\x07<img src=x>\x1b]8;;\x07')).toEqual(`<a href="https://x" onmouseover="alert(1)" target="_blank"><img src=x></a>`);
|
||||
// a style inside the label renders instead of leaking, and one left open ends with the line
|
||||
expect(renderAnsi('\x1b]8;id=1;https://example.com\x07\x1b[31mred\x1b[0m\x1b]8;;\x07')).toEqual(`<a href="https://example.com" target="_blank"><span class="ansi-red-fg">red</span></a>`);
|
||||
expect(renderAnsi('\x1b]8;;https://example.com\x07https://other.com')).toEqual(`<a href="https://example.com" target="_blank">https://other.com</a>`);
|
||||
|
||||
// sequences with no visual representation are dropped whole, payload and all, an unterminated one
|
||||
// up to the next escape or the line end
|
||||
expect(renderAnsi('\x1bPfoo\x1b\\a\x1b_G1;2\x1b\\b\x1b^priv\x1b\\c\x1bXsos\x1b\\d')).toEqual('abcd');
|
||||
expect(renderAnsi('\x1b(Ba\x1b#8b\x1b7c\x1b8d\x1b]0;unterminated')).toEqual('abcd');
|
||||
expect(renderAnsi('\x1b]0;unterminated\x1b\x1b[31mred')).toEqual('<span class="ansi-red-fg">red</span>');
|
||||
// a string sequence also ends at the 8-bit ST, which a runner may emit instead of "\x1b\\"
|
||||
expect(renderAnsi('\x1b]0;title\x9cvisible')).toEqual('visible');
|
||||
expect(renderAnsi('\x1b]8;;https://example.com\x9ctext\x1b]8;;\x9c')).toEqual(`<a href="https://example.com" target="_blank">text</a>`);
|
||||
// an OSC 11 background color query, a CSI window title push and an OSC 2 title
|
||||
expect(renderAnsi('\x1b]11;?\x1b\\\x1b[22;2t\x1b]2;🟡 a title\x1b\\go: downloading')).toEqual('go: downloading');
|
||||
|
||||
// control characters never reach the output, a backspace moves the cursor back one column
|
||||
expect(renderAnsi('a\x07b\x00c\x7fd\x9be')).toEqual('abcde');
|
||||
expect(renderAnsi('Reading... 10%\b\b\b100%')).toEqual('Reading... 100%');
|
||||
expect(renderAnsi('abc\b\bx')).toEqual('axc');
|
||||
expect(renderAnsi('\b🟡\bx')).toEqual('x');
|
||||
// a character a terminal never shows takes no column, and the cursor reaches back over a style
|
||||
// change, so what it lands on keeps the newer style
|
||||
expect(renderAnsi('ab\x01c\b\bZ')).toEqual('aZc');
|
||||
expect(renderAnsi('abc\x1b[31m\b\bx')).toEqual('a<span class="ansi-red-fg">x</span>c');
|
||||
|
||||
// a sequence cut off by the line end is dropped, and style carries on to the next line
|
||||
const ansi = new AnsiLineRenderer();
|
||||
expect(renderAnsi('\x1b[31mred\x1b[38;5;', ansi)).toEqual('<span class="ansi-red-fg">red</span>');
|
||||
expect(renderAnsi('still red', ansi)).toEqual('<span class="ansi-red-fg">still red</span>');
|
||||
});
|
||||
|
||||
+256
-68
@@ -1,85 +1,273 @@
|
||||
import {AnsiUp} from 'ansi_up';
|
||||
import {trimUrlPunctuation, urlRawRegex} from '../utils/url.ts';
|
||||
import {createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {colord} from 'colord';
|
||||
|
||||
const replacements: Array<[RegExp, string]> = [
|
||||
[/\x1b\[\d+[A-H]/g, ''], // Move cursor, treat them as no-op
|
||||
[/\x1b\[\d?[JK]/g, '\r'], // Erase display/line, treat them as a Carriage Return
|
||||
// erase display/line, treated as a carriage return
|
||||
const eraseInLine = /\x1b\[\d?[JK]/g;
|
||||
// a CSI, an OSC 8 hyperlink, any other string sequence (OSC, DCS, SOS, PM, APC), then an escape with
|
||||
// its intermediates. Only SGR ("m") and OSC 8 render, the rest are matched so they can be dropped.
|
||||
// A string sequence ends at BEL, "\x1b\\", the 8-bit ST "\x9c", or the next escape.
|
||||
const escapeSequence = /\x1b\[([0-9;:?<=>]*)[\x20-\x2f]*([\x40-\x7e])|\x1b\]8;[^;\x07\x1b\x9c]*;([^\x07\x1b\x9c]*)(?:\x07|\x1b\\|\x9c)|\x1b[\]P^_X][^\x07\x1b\x9c]*(?:\x07|\x1b\\|\x9c)?|\x1b[\x20-\x2f]*[\x30-\x5a\x5c-\x7e]/g;
|
||||
const hyperlinkUrl = /^https?:\/\//i;
|
||||
// a CSI marked private carries no SGR, whatever its final byte
|
||||
const privateParams = /^[<=>?]/;
|
||||
// characters a terminal never shows, other than tab, "\n" and "\r"
|
||||
const controlChars = /[\x00-\x08\v\f\x0e-\x1f\x7f-\x9f]/g;
|
||||
const hasControlChar = new RegExp(controlChars.source);
|
||||
// the same less the backspace, which the column model consumes
|
||||
const controlCharsNoBackspace = /[\x00-\x07\v\f\x0e-\x1f\x7f-\x9f]/g;
|
||||
// a line with none of these is plain text, an escape being a control character
|
||||
const needsRendering = new RegExp(`${controlChars.source}|\\r|://`);
|
||||
// "4:1" to "4:5" select a style, "4:0" is off. Indexed by number, so a non-numeric one cannot
|
||||
// reach an inherited property.
|
||||
const underlineStyles = ['', 'solid', 'double', 'wavy', 'dotted', 'dashed'];
|
||||
|
||||
// a css class for the 16 named colors a theme restyles, or "#rrggbb" for the rest, which it must not
|
||||
type AnsiColor = string;
|
||||
|
||||
const anchor = (href: string, ...children: string[]) =>
|
||||
createElementFromAttrs<HTMLAnchorElement>('a', {href, target: '_blank'}, ...children);
|
||||
|
||||
// appends text, turning any bare url inside it into a link
|
||||
function appendText(target: ParentNode, text: string, linkify: boolean): void {
|
||||
if (!linkify || !text.includes('://')) {
|
||||
if (target.firstChild) target.append(text); else target.textContent = text; // one step into an empty target
|
||||
return;
|
||||
}
|
||||
const urls = urlRawRegex();
|
||||
let pos = 0;
|
||||
for (let match = urls.exec(text); match; match = urls.exec(text)) {
|
||||
const url = trimUrlPunctuation(match[0]);
|
||||
if (match.index > pos) target.append(text.slice(pos, match.index));
|
||||
target.append(anchor(url, url));
|
||||
urls.lastIndex = pos = match.index + url.length;
|
||||
}
|
||||
if (pos < text.length) target.append(text.slice(pos));
|
||||
}
|
||||
|
||||
type AnsiStyle = {
|
||||
fg: AnsiColor | null, bg: AnsiColor | null, underlineColor: AnsiColor | null,
|
||||
underline: string,
|
||||
bold: boolean, faint: boolean, italic: boolean, blink: boolean,
|
||||
strikethrough: boolean, overline: boolean, inverse: boolean, conceal: boolean,
|
||||
};
|
||||
|
||||
const ansiStyleInitial: Readonly<AnsiStyle> = {
|
||||
fg: null, bg: null, underlineColor: null, underline: '',
|
||||
bold: false, faint: false, italic: false, blink: false,
|
||||
strikethrough: false, overline: false, inverse: false, conceal: false,
|
||||
};
|
||||
|
||||
// 0-7 normal, 8-15 bright, 16-231 a 6x6x6 rgb cube, 232-255 grayscale, the values the
|
||||
// ".term-fgx*" rules hardcode for the console renderer
|
||||
const colorNames = ['black', 'red', 'green', 'yellow', 'blue', 'magenta', 'cyan', 'white'];
|
||||
const cubeLevels = [0, 95, 135, 175, 215, 255];
|
||||
const palette256: AnsiColor[] = [
|
||||
...['ansi-', 'ansi-bright-'].flatMap((prefix) => colorNames.map((name) => `${prefix}${name}`)),
|
||||
...cubeLevels.flatMap((r) => cubeLevels.flatMap((g) => cubeLevels.map((b) => colord({r, g, b}).toHex()))),
|
||||
...Array.from({length: 24}, (_value, idx) => colord({r: 8 + idx * 10, g: 8 + idx * 10, b: 8 + idx * 10}).toHex()),
|
||||
];
|
||||
|
||||
// render ANSI to HTML
|
||||
export function renderAnsiInto(el: HTMLElement, line: string) {
|
||||
// create a fresh ansi_up instance because otherwise previous renders can influence
|
||||
// the output of future renders, because ansi_up is stateful and remembers things like
|
||||
// unclosed opening tags for colors.
|
||||
const ansi_up = new AnsiUp();
|
||||
ansi_up.use_classes = true;
|
||||
// 6 is the rapid blink a terminal renders no differently from the slow blink of 5
|
||||
const sgrFields: Record<number, Partial<AnsiStyle>> = {
|
||||
1: {bold: true}, 2: {faint: true}, 3: {italic: true}, 5: {blink: true}, 6: {blink: true}, 7: {inverse: true},
|
||||
8: {conceal: true}, 9: {strikethrough: true}, 21: {bold: false}, 22: {bold: false, faint: false},
|
||||
23: {italic: false}, 24: {underline: ''}, 25: {blink: false}, 27: {inverse: false},
|
||||
28: {conceal: false}, 29: {strikethrough: false}, 39: {fg: null}, 49: {bg: null},
|
||||
53: {overline: true}, 55: {overline: false}, 59: {underlineColor: null},
|
||||
};
|
||||
|
||||
if (line.endsWith('\r\n')) {
|
||||
line = line.substring(0, line.length - 2);
|
||||
} else if (line.endsWith('\n')) {
|
||||
line = line.substring(0, line.length - 1);
|
||||
}
|
||||
|
||||
if (line.includes('\x1b')) {
|
||||
for (const [regex, replacement] of replacements) {
|
||||
line = line.replace(regex, replacement);
|
||||
function applySgr(style: Readonly<AnsiStyle>, params: string): Readonly<AnsiStyle> {
|
||||
if (params === '' || params === '0') return ansiStyleInitial; // the most common sequence by far
|
||||
const next = {...style};
|
||||
const codes = params.split(';');
|
||||
for (let idx = 0; idx < codes.length; idx++) {
|
||||
const code = parseInt(codes[idx], 10); // parseInt stops at a ":" sub-parameter on its own
|
||||
if (isNaN(code) || code === 0) {
|
||||
Object.assign(next, ansiStyleInitial);
|
||||
} else if (sgrFields[code]) Object.assign(next, sgrFields[code]);
|
||||
else if (code === 4) {
|
||||
// a colon sub-parameter selects the style, as in "4:3" for a curly underline and "4:0" for off
|
||||
const colon = codes[idx].indexOf(':');
|
||||
next.underline = colon === -1 ? 'solid' : underlineStyles[Number(codes[idx].slice(colon + 1))] ?? 'solid';
|
||||
} else if (code >= 30 && code < 38) next.fg = palette256[code - 30];
|
||||
else if (code >= 40 && code < 48) next.bg = palette256[code - 40];
|
||||
else if (code >= 90 && code < 98) next.fg = palette256[code - 82]; // 8 + code - 90
|
||||
else if (code >= 100 && code < 108) next.bg = palette256[code - 92]; // 8 + code - 100
|
||||
else if (code === 38 || code === 48 || code === 58) {
|
||||
// "5;<index>" picks from the palette, "2;<r>;<g>;<b>" is truecolor, 58 colors the underline,
|
||||
// and ":" sub-parameters carry the same with "2" optionally preceded by a color space id
|
||||
if (codes[idx].includes(':')) {
|
||||
const sub = codes[idx].split(':');
|
||||
if (sub.length === 6 && sub[1] === '2') sub.splice(2, 1);
|
||||
codes.splice(idx, 1, ...sub);
|
||||
}
|
||||
// one running off the end consumes only the mode
|
||||
const mode = codes[++idx];
|
||||
let color: AnsiColor | null = null;
|
||||
if (mode === '5' && idx + 1 < codes.length) {
|
||||
const paletteIndex = parseInt(codes[++idx], 10);
|
||||
if (paletteIndex >= 0 && paletteIndex <= 255) color = palette256[paletteIndex];
|
||||
} else if (mode === '2' && idx + 3 < codes.length) {
|
||||
const [r, g, b] = [codes[++idx], codes[++idx], codes[++idx]].map((value) => parseInt(value, 10));
|
||||
if (Math.min(r, g, b) >= 0 && Math.max(r, g, b) <= 255) color = colord({r, g, b}).toHex();
|
||||
}
|
||||
if (color) next[code === 38 ? 'fg' : code === 48 ? 'bg' : 'underlineColor'] = color;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
let result: string;
|
||||
if (!line.includes('\r')) {
|
||||
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);
|
||||
type AnsiRun = {text: string, style: Readonly<AnsiStyle>};
|
||||
|
||||
/** Replays the backspaces over a part, so what follows a cursor step back overwrites what sits
|
||||
* there, keeping the style that wrote it rather than the one it displaced. */
|
||||
function overwriteColumns(runs: AnsiRun[]): AnsiRun[] {
|
||||
const columns: string[] = [];
|
||||
const styles: Array<Readonly<AnsiStyle>> = [];
|
||||
let col = 0;
|
||||
for (const run of runs) {
|
||||
for (const char of run.text.replace(controlCharsNoBackspace, '')) { // by code point, so a pair stays whole
|
||||
if (char === '\b') col = Math.max(col - 1, 0);
|
||||
else {
|
||||
columns[col] = char;
|
||||
styles[col] = run.style;
|
||||
col++;
|
||||
}
|
||||
}
|
||||
// the log message element is with "white-space: break-spaces;", so use "\n" to break lines
|
||||
result = lines.join('\n');
|
||||
}
|
||||
|
||||
el.innerHTML = result;
|
||||
// at the moment, only need to do post-process when there are potential URL links
|
||||
if (result.includes('://')) renderAnsiPostProcessNode(el);
|
||||
const merged: AnsiRun[] = [];
|
||||
for (let idx = 0; idx < columns.length; idx++) {
|
||||
if (idx && styles[idx] === styles[idx - 1]) merged[merged.length - 1].text += columns[idx];
|
||||
else merged.push({text: columns[idx], style: styles[idx]});
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function renderAnsiProcessText(node: ChildNode): ChildNode {
|
||||
const text = node.textContent!;
|
||||
const match = urlRawRegex().exec(text);
|
||||
if (!match || match.index === undefined) return node;
|
||||
function renderText(target: ParentNode, text: string, style: Readonly<AnsiStyle>, linkify = true): void {
|
||||
if (hasControlChar.test(text)) text = text.replace(controlChars, '');
|
||||
if (text === '') return;
|
||||
|
||||
const before = text.slice(0, match.index);
|
||||
const urlMatched = match[0];
|
||||
const urlTrimmed = trimUrlPunctuation(urlMatched);
|
||||
const after = text.slice(match.index + urlMatched.length - (urlMatched.length - urlTrimmed.length));
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', urlTrimmed);
|
||||
link.setAttribute('target', '_blank');
|
||||
link.textContent = urlTrimmed;
|
||||
|
||||
const newNodes: Array<Node | string> = [];
|
||||
if (before) newNodes.push(before);
|
||||
newNodes.push(link);
|
||||
if (after) newNodes.push(after);
|
||||
|
||||
node.replaceWith(...newNodes);
|
||||
return link;
|
||||
}
|
||||
|
||||
function renderAnsiPostProcessNode(el: ChildNode) {
|
||||
for (let node = el.firstChild; node; node = node.nextSibling) {
|
||||
if (node.nodeName === 'A') continue;
|
||||
if (node.nodeType !== Node.TEXT_NODE) {
|
||||
renderAnsiPostProcessNode(node);
|
||||
continue;
|
||||
const classes: string[] = [];
|
||||
// the one place deciding class vs inline, a named color having a class per slot
|
||||
const colorValue = (color: AnsiColor | null, slot?: string) => {
|
||||
if (!color) {
|
||||
if (slot && style.inverse) classes.push(`ansi-inverse-${slot}`);
|
||||
return '';
|
||||
}
|
||||
if (color[0] === '#') return color; // a literal color is never themed
|
||||
if (!slot) return `var(--color-${color})`; // a decoration color has no class of its own
|
||||
classes.push(`${color}-${slot}`);
|
||||
return '';
|
||||
};
|
||||
if (style.bold) classes.push('ansi-bold');
|
||||
if (style.italic) classes.push('ansi-italic');
|
||||
if (style.blink) classes.push('ansi-blink');
|
||||
if (style.conceal) classes.push('ansi-conceal');
|
||||
if (style.underline) classes.push('ansi-underline');
|
||||
if (style.strikethrough) classes.push('ansi-line-through');
|
||||
if (style.overline) classes.push('ansi-overline');
|
||||
if (style.underline && style.underline !== 'solid') classes.push(`ansi-${style.underline}`);
|
||||
|
||||
const decorated = style.underline || style.strikethrough || style.overline;
|
||||
const decorationColor = style.underlineColor && decorated ? colorValue(style.underlineColor) : '';
|
||||
// inverse swaps the two, terminal defaults included, and conceal emits no foreground at all so
|
||||
// that an inline color can never outrank the concealing class
|
||||
const color = style.conceal ? '' : colorValue(style.inverse ? style.bg : style.fg, 'fg');
|
||||
const background = colorValue(style.inverse ? style.fg : style.bg, 'bg');
|
||||
|
||||
if (classes.length || decorationColor || color || background) {
|
||||
const span = document.createElement('span');
|
||||
if (classes.length) span.className = classes.join(' ');
|
||||
// each declaration on its own, so no string from a log can widen what it applies to
|
||||
if (decorationColor) span.style.textDecorationColor = decorationColor;
|
||||
if (color) span.style.color = color;
|
||||
if (background) span.style.backgroundColor = background;
|
||||
target.append(span);
|
||||
target = span;
|
||||
}
|
||||
if (style.faint) { // nested, so its translucent color mixes with the color the outer span applies
|
||||
const faint = document.createElement('span');
|
||||
faint.className = 'ansi-faint';
|
||||
target.append(faint);
|
||||
target = faint;
|
||||
}
|
||||
appendText(target, text, linkify);
|
||||
}
|
||||
|
||||
/** Renders one log stream, carrying the style between lines the way a terminal does but never an
|
||||
* escape sequence. Each stream owns an instance. */
|
||||
export class AnsiLineRenderer {
|
||||
private style: Readonly<AnsiStyle> = ansiStyleInitial;
|
||||
|
||||
private renderPart(target: ParentNode, part: string): void {
|
||||
const overwriting = part.includes('\b'); // a backspace reaches back, so nothing renders until the end
|
||||
if (!overwriting && !part.includes('\x1b')) {
|
||||
renderText(target, part, this.style);
|
||||
return;
|
||||
}
|
||||
|
||||
let pos = 0;
|
||||
let pending = ''; // text is held until the style changes, so an escape between runs cannot split them
|
||||
let container: ParentNode = target; // an open OSC 8 hyperlink, collecting the styled text
|
||||
const runs: AnsiRun[] = [];
|
||||
const flush = () => {
|
||||
if (pending) runs.push({text: pending, style: this.style});
|
||||
pending = '';
|
||||
};
|
||||
const emit = () => {
|
||||
// a run inside a hyperlink is not linkified again
|
||||
for (const run of overwriting ? overwriteColumns(runs) : runs) renderText(container, run.text, run.style, container === target);
|
||||
runs.length = 0;
|
||||
};
|
||||
|
||||
escapeSequence.lastIndex = 0; // the regex is reused, so its match position must be reset
|
||||
for (let match = escapeSequence.exec(part); match; match = escapeSequence.exec(part)) {
|
||||
if (match.index > pos) pending += part.slice(pos, match.index);
|
||||
pos = match.index + match[0].length;
|
||||
const [, params, final, url] = match; // see the group order on escapeSequence
|
||||
if (final === 'm' && !privateParams.test(params)) {
|
||||
flush();
|
||||
this.style = applySgr(this.style, params);
|
||||
} else if (url !== undefined) { // an OSC 8, with an empty url when it closes a hyperlink
|
||||
flush();
|
||||
emit(); // a hyperlink is an element of its own, so a backspace cannot reach back across it
|
||||
// any scheme but http(s) renders as plain text, and a hyperlink never spills past the part
|
||||
const link = hyperlinkUrl.test(url) ? anchor(url) : null;
|
||||
if (link) target.append(link);
|
||||
container = link ?? target;
|
||||
}
|
||||
}
|
||||
const cutOff = part.indexOf('\x1b', pos); // a leftover escape is a sequence cut off by the line end
|
||||
pending += part.slice(pos, cutOff === -1 ? part.length : cutOff);
|
||||
flush();
|
||||
emit();
|
||||
}
|
||||
|
||||
renderLine(el: HTMLElement, line: string): void {
|
||||
if (line.endsWith('\n')) line = line.slice(0, line.endsWith('\r\n') ? -2 : -1);
|
||||
|
||||
// a plain line inheriting no style skips the parser entirely
|
||||
if (this.style === ansiStyleInitial && !needsRendering.test(line)) {
|
||||
el.textContent = line;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.includes('\x1b')) line = line.replace(eraseInLine, '\r');
|
||||
|
||||
if (el.firstChild) el.replaceChildren(); // nothing to clear for the fresh element a log line owns
|
||||
if (!line.includes('\r')) {
|
||||
this.renderPart(el, line); // no fragment, so the nodes are never built only to be moved
|
||||
return;
|
||||
}
|
||||
|
||||
// one part per update, separated by "\n" for "white-space: break-spaces"
|
||||
for (const part of line.split('\r')) {
|
||||
if (!part) continue;
|
||||
const previous = el.lastChild;
|
||||
this.renderPart(el, part);
|
||||
// a part that rendered nothing needs no separator
|
||||
if (previous && previous !== el.lastChild) el.insertBefore(document.createTextNode('\n'), previous.nextSibling);
|
||||
}
|
||||
node = renderAnsiProcessText(node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ export const urlRawRegex = () => /\bhttps?:\/\/[^\s<>[\]]+/gi; // JS regexp has
|
||||
/** Strip trailing punctuation that is likely not part of the URL. */
|
||||
export function trimUrlPunctuation(url: string): string {
|
||||
url = url.replace(/[.,;:'"]+$/, '');
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links)
|
||||
while (url.endsWith(')') && (url.match(/\(/g) || []).length < (url.match(/\)/g) || []).length) {
|
||||
url = url.slice(0, -1);
|
||||
}
|
||||
return url;
|
||||
// Strip trailing closing parens only if unbalanced (not part of the URL like Wikipedia links),
|
||||
// counted once as a URL can carry as many parens as the text it was found in is long
|
||||
let unbalanced = 0;
|
||||
for (const char of url) unbalanced += char === ')' ? 1 : char === '(' ? -1 : 0;
|
||||
let strip = 0;
|
||||
while (strip < unbalanced && url[url.length - 1 - strip] === ')') strip++;
|
||||
return strip ? url.slice(0, -strip) : url;
|
||||
}
|
||||
|
||||
export function urlQueryEscape(s: string) {
|
||||
|
||||
Reference in New Issue
Block a user