mirror of https://github.com/opensumi/core
refactor: upgrade markdown renderer from 4.x to 15.x (#4548)
* feat: add auto-scroll and update styles for ChatEditor * refactor: add test data in debug tree node * style: improve scm action button style * chore: remove useless code * chore: fix markdown render * feat: upgrade marked from 4.x to 15.x * chore: fix auto scroll * chore: fix markdown parse * chore: add deps * chore: scroll into view while output * chore: update deps * chore: update highlight element ref
This commit is contained in:
parent
197a201402
commit
0d8a0cdd55
|
@ -1,5 +1,6 @@
|
|||
import capitalize from 'lodash/capitalize';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import Highlight from 'react-highlight';
|
||||
|
||||
import { Image } from '@opensumi/ide-components/lib/image';
|
||||
|
@ -77,15 +78,36 @@ export const CodeEditorWithHighlight = (props: Props) => {
|
|||
return () => dispose.dispose();
|
||||
}, []);
|
||||
|
||||
const throttledScrollToBottom = useRef(
|
||||
throttle(
|
||||
() => {
|
||||
if (ref.current) {
|
||||
const highlightElement = ref.current;
|
||||
const codeElement = (highlightElement as any)?.el?.querySelector('code');
|
||||
const childs = codeElement?.children;
|
||||
if (childs) {
|
||||
const lastChild = childs[childs.length - 1];
|
||||
if (lastChild) {
|
||||
if ((lastChild as any).scrollIntoViewIfNeeded) {
|
||||
(lastChild as any).scrollIntoViewIfNeeded();
|
||||
} else {
|
||||
lastChild.scrollIntoView(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
150,
|
||||
{ leading: true, trailing: true },
|
||||
),
|
||||
).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
const highlightElement = ref.current;
|
||||
const preElement = highlightElement?.querySelector?.('pre');
|
||||
if (preElement) {
|
||||
preElement.scrollTop = preElement.scrollHeight;
|
||||
}
|
||||
}
|
||||
}, [input]);
|
||||
throttledScrollToBottom();
|
||||
return () => {
|
||||
throttledScrollToBottom.cancel();
|
||||
};
|
||||
}, [input, throttledScrollToBottom]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
setIsCoping(true);
|
||||
|
|
|
@ -9,6 +9,8 @@ import { IMarkdownString, MarkdownString } from '@opensumi/monaco-editor-core/es
|
|||
import { CodeEditorWithHighlight } from './ChatEditor';
|
||||
import styles from './components.module.less';
|
||||
|
||||
import type { Token, Tokens, TokensList } from 'marked';
|
||||
|
||||
interface MarkdownProps {
|
||||
markdown: IMarkdownString | string;
|
||||
agentId?: string;
|
||||
|
@ -24,7 +26,7 @@ export const ChatMarkdown = (props: MarkdownProps) => {
|
|||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const appConfig = useInjectable<AppConfig>(AppConfig);
|
||||
const [reactParser, setReactParser] = useState<MarkdownReactParser>();
|
||||
const [tokensList, setTokensList] = useState<marked.TokensList>();
|
||||
const [tokensList, setTokensList] = useState<TokensList>();
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
|
@ -64,8 +66,6 @@ export const ChatMarkdown = (props: MarkdownProps) => {
|
|||
...marked.defaults,
|
||||
...props.markedOptions,
|
||||
renderer: reactParser,
|
||||
mangle: false,
|
||||
headerIds: false,
|
||||
smartypants: false,
|
||||
};
|
||||
|
||||
|
@ -75,14 +75,11 @@ export const ChatMarkdown = (props: MarkdownProps) => {
|
|||
}
|
||||
|
||||
let renderedMarkdown: string;
|
||||
let tokensList: marked.TokensList;
|
||||
let tokensList: TokensList;
|
||||
if (props.fillInIncompleteTokens) {
|
||||
const opts = {
|
||||
...markedOptions,
|
||||
};
|
||||
const tokens = marked.lexer(value, opts);
|
||||
const tokens = marked.lexer(value, markedOptions);
|
||||
const newTokens = fillInIncompleteTokens(tokens);
|
||||
renderedMarkdown = marked.parser(newTokens, opts);
|
||||
renderedMarkdown = marked.parser(newTokens, markedOptions);
|
||||
tokensList = newTokens;
|
||||
} else {
|
||||
const tokens = marked.lexer(value, markedOptions);
|
||||
|
@ -113,9 +110,9 @@ export function postProcessCodeBlockLanguageId(lang: string | undefined): string
|
|||
return lang;
|
||||
}
|
||||
|
||||
export function fillInIncompleteTokens(tokens: marked.TokensList): marked.TokensList {
|
||||
export function fillInIncompleteTokens(tokens: TokensList): TokensList {
|
||||
let i: number;
|
||||
let newTokens: marked.Token[] | undefined;
|
||||
let newTokens: Token[] | undefined;
|
||||
for (i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
// 代码块补全,完整的代码块 type=code
|
||||
|
@ -142,7 +139,7 @@ export function fillInIncompleteTokens(tokens: marked.TokensList): marked.Tokens
|
|||
}
|
||||
|
||||
if (newTokens) {
|
||||
const newTokensList = [...tokens.slice(0, i), ...newTokens] as marked.TokensList;
|
||||
const newTokensList = [...tokens.slice(0, i), ...newTokens] as TokensList;
|
||||
newTokensList.links = tokens.links;
|
||||
return newTokensList;
|
||||
}
|
||||
|
@ -150,48 +147,48 @@ export function fillInIncompleteTokens(tokens: marked.TokensList): marked.Tokens
|
|||
return tokens;
|
||||
}
|
||||
|
||||
function completeCodeBlock(tokens: marked.Token[]): marked.Token[] {
|
||||
function completeCodeBlock(tokens: Token[]): Token[] {
|
||||
const mergedRawText = mergeRawTokenText(tokens);
|
||||
return marked.lexer(mergedRawText + '\n```');
|
||||
}
|
||||
|
||||
function completeCodespan(token: marked.Token): marked.Token {
|
||||
function completeCodespan(token: Token): Token {
|
||||
return completeWithString(token, '`');
|
||||
}
|
||||
|
||||
function completeStar(tokens: marked.Token): marked.Token {
|
||||
function completeStar(tokens: Token): Token {
|
||||
return completeWithString(tokens, '*');
|
||||
}
|
||||
|
||||
function completeUnderscore(tokens: marked.Token): marked.Token {
|
||||
function completeUnderscore(tokens: Token): Token {
|
||||
return completeWithString(tokens, '_');
|
||||
}
|
||||
|
||||
function completeLinkTarget(tokens: marked.Token): marked.Token {
|
||||
function completeLinkTarget(tokens: Token): Token {
|
||||
return completeWithString(tokens, ')');
|
||||
}
|
||||
|
||||
function completeLinkText(tokens: marked.Token): marked.Token {
|
||||
function completeLinkText(tokens: Token): Token {
|
||||
return completeWithString(tokens, '](about:blank)');
|
||||
}
|
||||
|
||||
function completeDoublestar(tokens: marked.Token): marked.Token {
|
||||
function completeDoublestar(tokens: Token): Token {
|
||||
return completeWithString(tokens, '**');
|
||||
}
|
||||
|
||||
function completeDoubleUnderscore(tokens: marked.Token): marked.Token {
|
||||
function completeDoubleUnderscore(tokens: Token): Token {
|
||||
return completeWithString(tokens, '__');
|
||||
}
|
||||
|
||||
function completeWithString(tokens: marked.Token[] | marked.Token, closingString: string): marked.Token {
|
||||
function completeWithString(tokens: Token[] | Token, closingString: string): Token {
|
||||
const mergedRawText = mergeRawTokenText(Array.isArray(tokens) ? tokens : [tokens]);
|
||||
|
||||
// If it was completed correctly, this should be a single token.
|
||||
// Expecting either a Paragraph or a List
|
||||
return marked.lexer(mergedRawText + closingString)[0] as marked.Token;
|
||||
return marked.lexer(mergedRawText + closingString)[0] as Token;
|
||||
}
|
||||
|
||||
function completeTable(tokens: marked.Token[]): marked.Token[] | undefined {
|
||||
function completeTable(tokens: Token[]): Token[] | undefined {
|
||||
const mergedRawText = mergeRawTokenText(tokens);
|
||||
const lines = mergedRawText.split('\n');
|
||||
|
||||
|
@ -225,12 +222,12 @@ function completeTable(tokens: marked.Token[]): marked.Token[] | undefined {
|
|||
return undefined;
|
||||
}
|
||||
|
||||
function mergeRawTokenText(tokens: marked.Token[]): string {
|
||||
function mergeRawTokenText(tokens: Token[]): string {
|
||||
return tokens.reduce((mergedTokenText, token) => mergedTokenText + token.raw, '');
|
||||
}
|
||||
|
||||
function completeSingleLinePattern(token: marked.Tokens.ListItem | marked.Tokens.Paragraph): marked.Token | undefined {
|
||||
for (const { type, raw } of token.tokens) {
|
||||
function completeSingleLinePattern(token: Tokens.ListItem | Tokens.Paragraph | Tokens.Generic): Token | undefined {
|
||||
for (const { type, raw } of token.tokens ?? []) {
|
||||
if (type !== 'text') {
|
||||
continue;
|
||||
}
|
||||
|
|
|
@ -599,6 +599,7 @@
|
|||
line-height: 20px;
|
||||
vertical-align: middle;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.thumbnail_container {
|
||||
|
|
|
@ -7,6 +7,8 @@ import { IMarkdownString } from '@opensumi/ide-core-browser';
|
|||
import styles from './comments.module.less';
|
||||
import { markdownCss } from './markdown.style';
|
||||
|
||||
import type { Tokens } from 'marked';
|
||||
|
||||
const ShadowContent = ({ root, children }) => ReactDOM.createPortal(children, root);
|
||||
|
||||
export const CommentsBody: React.FC<{
|
||||
|
@ -18,8 +20,8 @@ export const CommentsBody: React.FC<{
|
|||
const renderer = React.useMemo(() => {
|
||||
const renderer = createMarkedRenderer();
|
||||
|
||||
renderer.link = (href, title, text) =>
|
||||
`<a target="_blank" rel="noopener" href="${href}" title="${title}">${text}</a>`;
|
||||
renderer.link = ({ href, title, text }: Tokens.Link): string =>
|
||||
`<a target="_blank" rel="noopener" href="${href}" title="${title || ''}">${text}</a>`;
|
||||
return renderer;
|
||||
}, []);
|
||||
|
||||
|
@ -43,8 +45,6 @@ export const CommentsBody: React.FC<{
|
|||
gfm: true,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
smartLists: true,
|
||||
smartypants: false,
|
||||
renderer,
|
||||
}),
|
||||
}}
|
||||
|
|
|
@ -22,7 +22,7 @@
|
|||
"@rc-component/mini-decimal": "^1.0.1",
|
||||
"fuzzy": "^0.1.3",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "4.0.10",
|
||||
"marked": "15.0.11",
|
||||
"raf": "^3.4.1",
|
||||
"rc-dialog": "^9.6.0",
|
||||
"rc-dropdown": "~2.4.1",
|
||||
|
@ -40,7 +40,6 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@opensumi/ide-dev-tool": "workspace:*",
|
||||
"@types/marked": "^4.0.7",
|
||||
"@types/react-window": "^1.8.5",
|
||||
"prop-types": "^15.8.1"
|
||||
}
|
||||
|
|
|
@ -3,6 +3,8 @@ import React, { ReactNode } from 'react';
|
|||
|
||||
import { HeadingLevels, MarkdownReactRenderer } from './render';
|
||||
|
||||
import type { Token, Tokens } from 'marked';
|
||||
|
||||
/**
|
||||
* 这里通过重新实现 marked.Renderer 的所有方法,实现一个能直接渲染 React 的 Markdown 渲染器
|
||||
*/
|
||||
|
@ -15,12 +17,13 @@ export class MarkdownReactParser extends marked.Renderer {
|
|||
this.renderer = options.renderer;
|
||||
}
|
||||
|
||||
parse(tokens: marked.Token[]): ReactNode[] {
|
||||
parse(tokens: Token[]): ReactNode[] {
|
||||
return tokens.map((token, index) => {
|
||||
const element = (() => {
|
||||
switch (token.type) {
|
||||
case 'html': {
|
||||
return this.renderer.html(token.text);
|
||||
const htmlToken = token as Tokens.HTML;
|
||||
return this.renderer.html(htmlToken.text);
|
||||
}
|
||||
|
||||
case 'space': {
|
||||
|
@ -28,27 +31,29 @@ export class MarkdownReactParser extends marked.Renderer {
|
|||
}
|
||||
|
||||
case 'heading': {
|
||||
const level = token.depth as HeadingLevels;
|
||||
return this.renderer.heading(this.parseInline(token.tokens), level);
|
||||
const headingToken = token as Tokens.Heading;
|
||||
const level = headingToken.depth as HeadingLevels;
|
||||
return this.renderer.heading(this.parseInline(headingToken.tokens), level);
|
||||
}
|
||||
|
||||
case 'paragraph': {
|
||||
return this.renderer.paragraph(this.parseInline(token.tokens));
|
||||
const paragraphToken = token as Tokens.Generic;
|
||||
return this.renderer.paragraph(this.parseInline(paragraphToken.tokens));
|
||||
}
|
||||
|
||||
case 'text': {
|
||||
const textToken = token as marked.Tokens.Text;
|
||||
return textToken.tokens ? this.parseInline(textToken.tokens) : token.text;
|
||||
const textToken = token as Tokens.Text;
|
||||
return textToken.tokens ? this.parseInline(textToken.tokens) : textToken.text;
|
||||
}
|
||||
|
||||
case 'blockquote': {
|
||||
const blockquoteToken = token as marked.Tokens.Blockquote;
|
||||
const blockquoteToken = token as Tokens.Blockquote;
|
||||
const quote = this.parse(blockquoteToken.tokens);
|
||||
return this.renderer.blockquote(quote);
|
||||
}
|
||||
|
||||
case 'list': {
|
||||
const listToken = token as marked.Tokens.List;
|
||||
const listToken = token as Tokens.List;
|
||||
|
||||
const children = listToken.items.map((item, itemIndex) => {
|
||||
const listItemChildren: ReactNode[] = [];
|
||||
|
@ -64,20 +69,21 @@ export class MarkdownReactParser extends marked.Renderer {
|
|||
});
|
||||
});
|
||||
|
||||
return this.renderer.list(children, token.ordered);
|
||||
return this.renderer.list(children, listToken.ordered);
|
||||
}
|
||||
|
||||
case 'code': {
|
||||
return this.renderer.code(token.text, token.lang);
|
||||
const codeToken = token as Tokens.Code;
|
||||
return this.renderer.code(codeToken.text, codeToken.lang);
|
||||
}
|
||||
|
||||
case 'table': {
|
||||
const tableToken = token as marked.Tokens.Table;
|
||||
const tableToken = token as Tokens.Table;
|
||||
const headerCells = tableToken.header.map((cell, cellIndex) =>
|
||||
React.cloneElement(
|
||||
this.renderer.tableCell(this.parseInline(cell.tokens), {
|
||||
header: true,
|
||||
align: token.align[cellIndex],
|
||||
align: tableToken.align[cellIndex],
|
||||
}) as React.ReactElement,
|
||||
{ key: `header-cell-${cellIndex}` },
|
||||
),
|
||||
|
@ -93,7 +99,7 @@ export class MarkdownReactParser extends marked.Renderer {
|
|||
React.cloneElement(
|
||||
this.renderer.tableCell(this.parseInline(cell.tokens), {
|
||||
header: false,
|
||||
align: token.align[cellIndex],
|
||||
align: tableToken.align[cellIndex],
|
||||
}) as React.ReactElement,
|
||||
{ key: `body-cell-${rowIndex}-${cellIndex}` },
|
||||
),
|
||||
|
@ -128,40 +134,48 @@ export class MarkdownReactParser extends marked.Renderer {
|
|||
});
|
||||
}
|
||||
|
||||
parseInline(tokens: marked.Token[] = []): ReactNode[] {
|
||||
parseInline(tokens: Token[] = []): ReactNode[] {
|
||||
return tokens.map((token) => {
|
||||
switch (token.type) {
|
||||
case 'text': {
|
||||
const text = htmlUnescape(token.text);
|
||||
const textToken = token as Tokens.Text;
|
||||
const text = htmlUnescape(textToken.text);
|
||||
return this.renderer.text(text);
|
||||
}
|
||||
|
||||
case 'strong': {
|
||||
return this.renderer.strong(this.parseInline(token.tokens));
|
||||
const strongToken = token as Tokens.Strong;
|
||||
return this.renderer.strong(this.parseInline(strongToken.tokens));
|
||||
}
|
||||
|
||||
case 'em': {
|
||||
return this.renderer.em(this.parseInline(token.tokens));
|
||||
const emToken = token as Tokens.Em;
|
||||
return this.renderer.em(this.parseInline(emToken.tokens));
|
||||
}
|
||||
|
||||
case 'del': {
|
||||
return this.renderer.del(this.parseInline(token.tokens));
|
||||
const delToken = token as Tokens.Del;
|
||||
return this.renderer.del(this.parseInline(delToken.tokens));
|
||||
}
|
||||
|
||||
case 'codespan': {
|
||||
return this.renderer.codespan(unescape(token.text));
|
||||
const codespanToken = token as Tokens.Codespan;
|
||||
return this.renderer.codespan(htmlUnescape(codespanToken.text));
|
||||
}
|
||||
|
||||
case 'link': {
|
||||
return this.renderer.link(token.href, this.parseInline(token.tokens));
|
||||
const linkToken = token as Tokens.Link;
|
||||
return this.renderer.link(linkToken.href, this.parseInline(linkToken.tokens));
|
||||
}
|
||||
|
||||
case 'image': {
|
||||
return this.renderer.image(token.href, token.text, token.title);
|
||||
const imageToken = token as Tokens.Image;
|
||||
return this.renderer.image(imageToken.href, imageToken.text, imageToken.title);
|
||||
}
|
||||
|
||||
case 'html': {
|
||||
return this.renderer.html(token.text);
|
||||
const htmlToken = token as Tokens.HTML;
|
||||
return this.renderer.html(htmlToken.text);
|
||||
}
|
||||
|
||||
case 'br': {
|
||||
|
@ -169,7 +183,8 @@ export class MarkdownReactParser extends marked.Renderer {
|
|||
}
|
||||
|
||||
case 'escape': {
|
||||
return this.renderer.text(token.text);
|
||||
const escapeToken = token as Tokens.Escape;
|
||||
return this.renderer.text(escapeToken.text);
|
||||
}
|
||||
|
||||
default: {
|
||||
|
|
|
@ -3,9 +3,11 @@ import React from 'react';
|
|||
|
||||
import { DATA_SET_COMMAND, IOpenerShape, RenderWrapper } from './render';
|
||||
|
||||
import type { Tokens } from 'marked';
|
||||
|
||||
interface IMarkdownProps {
|
||||
value?: string;
|
||||
renderer: marked.Renderer;
|
||||
renderer: Renderer;
|
||||
opener?: IOpenerShape;
|
||||
}
|
||||
|
||||
|
@ -13,13 +15,16 @@ export const linkify = (href: string | null, title: string | null, text: string)
|
|||
`<a rel="noopener" ${DATA_SET_COMMAND}="${href}" title="${title ?? href}">${text}</a>`;
|
||||
|
||||
export class DefaultMarkedRenderer extends Renderer {
|
||||
link(href: string | null, title: string | null, text: string): string {
|
||||
return linkify(href, title, text);
|
||||
link({ href, title, text }: Tokens.Link): string {
|
||||
return linkify(href, title || null, text);
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown(props: IMarkdownProps) {
|
||||
const parseMarkdown = (text: string, renderer: any) => marked.parse(text, { renderer });
|
||||
const parseMarkdown = (text: string, renderer: any) => {
|
||||
const result = marked.parse(text, { renderer, async: false });
|
||||
return typeof result === 'string' ? result : '';
|
||||
};
|
||||
|
||||
const [htmlContent, setHtmlContent] = React.useState(parseMarkdown(props.value || '', props.renderer));
|
||||
|
||||
|
|
|
@ -156,7 +156,8 @@ export abstract class PromptHandle {
|
|||
validateBoxClassName += this._validateClassName;
|
||||
|
||||
this.$validate.classList.value = validateBoxClassName;
|
||||
this.$validate.innerHTML = toMarkdownHtml(validateMessage.message || '', { renderer: this.markdownRenderer });
|
||||
const htmlContent = toMarkdownHtml(validateMessage.message || '', { renderer: this.markdownRenderer });
|
||||
this.$validate.innerHTML = typeof htmlContent === 'string' ? htmlContent : '';
|
||||
this.$.parentElement?.parentElement?.classList.remove(
|
||||
VALIDATE_CLASS_NAME.INFO,
|
||||
VALIDATE_CLASS_NAME.ERROR,
|
||||
|
|
|
@ -1,33 +1,23 @@
|
|||
import { Renderer, marked } from 'marked';
|
||||
|
||||
import type { MarkedOptions, Token, Tokens } from 'marked';
|
||||
|
||||
export { marked };
|
||||
|
||||
export type IMarkedOptions = marked.MarkedOptions;
|
||||
export type IMarkedOptions = MarkedOptions;
|
||||
|
||||
export const createMarkedRenderer = () => new Renderer();
|
||||
|
||||
export const parseMarkdown = (
|
||||
value: string,
|
||||
options?: IMarkedOptions,
|
||||
callback?: (error: any, parseResult: string) => void,
|
||||
) => {
|
||||
if (!callback) {
|
||||
return marked.parse(value, options);
|
||||
}
|
||||
if (options) {
|
||||
marked.parse(value, options, callback);
|
||||
} else {
|
||||
marked.parse(value, callback);
|
||||
}
|
||||
};
|
||||
export const parseMarkdown = (value: string, options?: IMarkedOptions) => marked.parse(value, options);
|
||||
|
||||
export const toMarkdownHtml = (value: string, options?: IMarkedOptions) => marked(value, options);
|
||||
|
||||
export const parseWithoutEscape = (token: marked.Token) => {
|
||||
export const parseWithoutEscape = (token: Token) => {
|
||||
// 这里兼容下 vscode 的写法,vscode 这里没有处理 markdown 语法
|
||||
// 否则会出现 (\\) 被解析成 () 期望是 (\)
|
||||
if (token.type === 'escape') {
|
||||
token.text = token.raw;
|
||||
const escapeToken = token as Tokens.Escape;
|
||||
escapeToken.text = escapeToken.raw;
|
||||
}
|
||||
|
||||
return token;
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
"jsonc-parser": "^2.1.0",
|
||||
"keycode": "^2.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "15.0.11",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-is": "^18.2.0",
|
||||
|
|
|
@ -6,6 +6,8 @@ import { isString } from '@opensumi/ide-core-common';
|
|||
|
||||
import { IOpenerService } from '../opener';
|
||||
|
||||
import type { Tokens } from 'marked';
|
||||
|
||||
export const toMarkdown = (
|
||||
content: string | React.ReactNode,
|
||||
opener?: IOpenerService,
|
||||
|
@ -26,16 +28,16 @@ export const toMarkdown = (
|
|||
export const toMarkdownHtml = (message: string, options?: IMarkedOptions): string => {
|
||||
const renderer = createMarkedRenderer();
|
||||
|
||||
renderer.link = (href, title, text) =>
|
||||
`<a rel="noopener" ${DATA_SET_COMMAND}="${href}" href="javascript:void(0)" title="${title}">${text}</a>`;
|
||||
renderer.link = ({ href, title, text }: Tokens.Link): string =>
|
||||
`<a rel="noopener" ${DATA_SET_COMMAND}="${href}" href="javascript:void(0)" title="${title || ''}">${text}</a>`;
|
||||
|
||||
return toHtml(message, {
|
||||
const result = toHtml(message, {
|
||||
gfm: true,
|
||||
breaks: false,
|
||||
pedantic: false,
|
||||
smartLists: true,
|
||||
smartypants: false,
|
||||
renderer,
|
||||
...(options || {}),
|
||||
});
|
||||
|
||||
return typeof result === 'string' ? result : '';
|
||||
};
|
||||
|
|
|
@ -41,7 +41,6 @@ import {
|
|||
TestMessageType,
|
||||
} from '@opensumi/ide-testing/lib/common/testCollection';
|
||||
|
||||
|
||||
import { CommandsConverter } from '../../hosted/api/vscode/ext.host.command';
|
||||
|
||||
import { IDataTransferItem, VSDataTransfer } from './data-transfer';
|
||||
|
@ -234,8 +233,8 @@ export namespace MarkdownString {
|
|||
return '';
|
||||
};
|
||||
const renderer = createMarkedRenderer();
|
||||
renderer.link = collectUri;
|
||||
renderer.image = (href) => (href ? collectUri(parseHrefAndDimensions(href).href) : '');
|
||||
renderer.link = ({ href }) => (href ? collectUri(href) : '');
|
||||
renderer.image = ({ href }) => (href ? collectUri(parseHrefAndDimensions(href).href) : '');
|
||||
|
||||
toMarkdownHtml(res.value, { renderer });
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@ import React from 'react';
|
|||
import { IMarkedOptions } from '@opensumi/ide-components/lib/utils';
|
||||
import { CancellationTokenSource, Disposable, URI, useInjectable } from '@opensumi/ide-core-browser';
|
||||
|
||||
import { IMarkdownService } from '../common';
|
||||
import { IMarkdownService, MarkdownOptions } from '../common';
|
||||
|
||||
export const Markdown = ({
|
||||
content,
|
||||
|
@ -12,7 +12,7 @@ export const Markdown = ({
|
|||
onLinkClick,
|
||||
}: {
|
||||
content: string;
|
||||
options?: IMarkedOptions;
|
||||
options?: MarkdownOptions;
|
||||
onLoaded?: () => void;
|
||||
onLinkClick?: (uri: URI) => void;
|
||||
}) => {
|
||||
|
|
|
@ -4,7 +4,7 @@ import { CancellationToken, Disposable, Event, IDisposable, IOpenerService, URI
|
|||
import { HttpOpener } from '@opensumi/ide-core-browser/lib/opener/http-opener';
|
||||
import { IWebviewService } from '@opensumi/ide-webview';
|
||||
|
||||
import { IMarkdownService } from '../common';
|
||||
import { IMarkdownService, MarkdownOptions } from '../common';
|
||||
|
||||
import { markdownCss } from './mardown.style';
|
||||
|
||||
|
@ -25,7 +25,7 @@ export class MarkdownServiceImpl implements IMarkdownService {
|
|||
content: string,
|
||||
container: HTMLElement,
|
||||
cancellationToken: CancellationToken,
|
||||
options?: IMarkedOptions,
|
||||
options?: MarkdownOptions,
|
||||
onUpdate?: Event<string>,
|
||||
onLinkClick?: (uri: URI) => void,
|
||||
): Promise<IDisposable> {
|
||||
|
@ -71,15 +71,20 @@ export class MarkdownServiceImpl implements IMarkdownService {
|
|||
return disposer;
|
||||
}
|
||||
|
||||
async getBody(content: string, options: IMarkedOptions | undefined): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
parseMarkdown(content, options, (err, result) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
}
|
||||
resolve(removeEmbeddedSVGs(renderBody(result)));
|
||||
});
|
||||
});
|
||||
async getBody(content: string, options: MarkdownOptions | undefined): Promise<string> {
|
||||
// marked 15.x 不再支持回调形式
|
||||
try {
|
||||
const result = parseMarkdown(content, options as IMarkedOptions);
|
||||
if (typeof result === 'string') {
|
||||
return removeEmbeddedSVGs(renderBody(result));
|
||||
}
|
||||
// 处理异步结果
|
||||
const htmlContent = await result;
|
||||
return removeEmbeddedSVGs(renderBody(htmlContent));
|
||||
} catch (err) {
|
||||
// 错误处理
|
||||
return renderBody('Failed to parse markdown.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
import { CancellationToken, Event, IDisposable, URI } from '@opensumi/ide-core-common';
|
||||
|
||||
export interface MarkdownOptions {
|
||||
baseUrl?: string;
|
||||
gfm?: boolean;
|
||||
breaks?: boolean;
|
||||
pedantic?: boolean;
|
||||
renderer?: any;
|
||||
}
|
||||
|
||||
export interface IMarkdownService {
|
||||
previewMarkdownInContainer(
|
||||
content: string,
|
||||
container: HTMLElement,
|
||||
cancellationToken: CancellationToken,
|
||||
options?: { baseUrl?: string | undefined },
|
||||
options?: MarkdownOptions,
|
||||
onUpdate?: Event<string>,
|
||||
onLinkClick?: (uri: URI) => void,
|
||||
): Promise<IDisposable>;
|
||||
|
|
|
@ -8,6 +8,8 @@ import { MayCancelablePromise, MessageType, localize, uuid } from '@opensumi/ide
|
|||
|
||||
import { AbstractMessageService, IMessageService, MAX_MESSAGE_LENGTH, OpenMessageOptions } from '../common';
|
||||
|
||||
import type { Token } from 'marked';
|
||||
|
||||
@Injectable()
|
||||
export class MessageService extends AbstractMessageService implements IMessageService {
|
||||
@Autowired(IOpenerService)
|
||||
|
@ -63,8 +65,13 @@ export class MessageService extends AbstractMessageService implements IMessageSe
|
|||
}
|
||||
const description = from && typeof from === 'string' ? `${localize('component.message.origin')}: ${from}` : '';
|
||||
const key = uuid();
|
||||
|
||||
const processToken = (token: Token): void => {
|
||||
parseWithoutEscape(token);
|
||||
};
|
||||
|
||||
const promise = open<T>(
|
||||
toMarkdown(message, this.openerService, { walkTokens: parseWithoutEscape }),
|
||||
toMarkdown(message, this.openerService, { walkTokens: processToken }),
|
||||
type,
|
||||
closable,
|
||||
key,
|
||||
|
|
|
@ -28,6 +28,8 @@ import { getPreferenceItemLabel, knownPrefIdMappings } from '../common';
|
|||
import { PreferenceSettingsService } from './preference-settings.service';
|
||||
import styles from './preferences.module.less';
|
||||
|
||||
import type { Tokens } from 'marked';
|
||||
|
||||
interface IPreferenceItemProps {
|
||||
preferenceName: string;
|
||||
localizedName?: string;
|
||||
|
@ -262,7 +264,7 @@ class PreferenceMarkedRender extends DefaultMarkedRenderer {
|
|||
@Autowired(IPreferenceSettingsService)
|
||||
preferenceSettingService: PreferenceSettingsService;
|
||||
|
||||
codespan(text: string): string {
|
||||
codespan({ text }: Tokens.Codespan): string {
|
||||
if (text.startsWith('#') && text.endsWith('#')) {
|
||||
const _prefId = text.slice(1, text.length - 1);
|
||||
const prefId = knownPrefIdMappings[_prefId] ?? _prefId;
|
||||
|
@ -271,9 +273,9 @@ class PreferenceMarkedRender extends DefaultMarkedRenderer {
|
|||
const preferenceTitle = getPreferenceItemLabel(preference);
|
||||
return linkify(`${PreferenceMarkedRender.openerScheme}${preferenceTitle}`, prefId, preferenceTitle);
|
||||
}
|
||||
return super.codespan(prefId);
|
||||
return super.codespan({ text: prefId } as Tokens.Codespan);
|
||||
}
|
||||
return super.codespan(text);
|
||||
return super.codespan({ text } as Tokens.Codespan);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -110,11 +110,37 @@ export const TestTreeContainer: FC<{ viewState?: ViewState }> = ({ viewState })
|
|||
|
||||
const testDto = new TestDto(result.id, test, taskId, messageIndex);
|
||||
|
||||
const getLabel = () => {
|
||||
if (typeof message === 'string') {
|
||||
return firstLine(message);
|
||||
}
|
||||
|
||||
const node = {
|
||||
type: ETestTreeType.MESSAGE,
|
||||
context: uri,
|
||||
label: firstLine(message.value),
|
||||
id: uri.toString(),
|
||||
icon: '',
|
||||
notExpandable: false,
|
||||
location,
|
||||
rawItem: { ...testMessage, dto: testDto },
|
||||
};
|
||||
|
||||
parseMarkdownText(message.value)
|
||||
.then((parsedText) => {
|
||||
node.label = firstLine(parsedText);
|
||||
})
|
||||
.catch(() => {
|
||||
// 解析失败时保持原始值
|
||||
});
|
||||
|
||||
return node.label;
|
||||
};
|
||||
|
||||
return {
|
||||
type: ETestTreeType.MESSAGE,
|
||||
context: uri,
|
||||
// ** 这里应该解析 markdown 转为纯文本信息 **
|
||||
label: firstLine(typeof message === 'string' ? message : parseMarkdownText(message.value)),
|
||||
label: getLabel(),
|
||||
id: uri.toString(),
|
||||
icon: '',
|
||||
notExpandable: false,
|
||||
|
|
|
@ -339,13 +339,32 @@ class TestMessageDecoration implements ITestDecoration {
|
|||
const message =
|
||||
typeof testMessage.message === 'string'
|
||||
? removeAnsiEscapeCodes(testMessage.message)
|
||||
: parseMarkdownText(testMessage.message.value);
|
||||
: parseMarkdownText(
|
||||
typeof testMessage.message === 'object' && 'value' in testMessage.message
|
||||
? (testMessage.message.value as string)
|
||||
: String(testMessage.message),
|
||||
)
|
||||
.then((text) => text)
|
||||
.catch(() =>
|
||||
typeof testMessage.message === 'object' && 'value' in testMessage.message
|
||||
? (testMessage.message.value as string)
|
||||
: String(testMessage.message),
|
||||
);
|
||||
|
||||
// 确保我们可以为装饰器提供字符串内容
|
||||
const contentText =
|
||||
typeof message === 'string'
|
||||
? message
|
||||
: typeof testMessage.message === 'object' && 'value' in testMessage.message
|
||||
? (testMessage.message.value as string)
|
||||
: String(testMessage.message);
|
||||
|
||||
this.codeEditorService.registerDecorationType(
|
||||
'test-message-decoration',
|
||||
this.decorationId,
|
||||
{
|
||||
after: {
|
||||
contentText: message,
|
||||
contentText,
|
||||
color: `${testMessageSeverityColors[severity]}`,
|
||||
fontSize: `${this.monacoEditor.getOption(EditorOption.fontSize)}px`,
|
||||
fontFamily: `var(${FONT_FAMILY_VAR})`,
|
||||
|
@ -357,7 +376,13 @@ class TestMessageDecoration implements ITestDecoration {
|
|||
);
|
||||
|
||||
const options = this.codeEditorService.resolveDecorationOptions(this.decorationId, true);
|
||||
options.hoverMessage = typeof message === 'string' ? new MarkdownString().appendText(message) : message;
|
||||
// 使用 MarkdownString 来显示消息
|
||||
options.hoverMessage =
|
||||
typeof message === 'string'
|
||||
? new MarkdownString().appendText(message)
|
||||
: typeof testMessage.message === 'object' && 'value' in testMessage.message
|
||||
? testMessage.message
|
||||
: new MarkdownString().appendText(String(testMessage.message));
|
||||
options.afterContentClassName = `${options.afterContentClassName} testing-inline-message-content`;
|
||||
options.zIndex = 10;
|
||||
options.className = `testing-inline-message-margin testing-inline-message-severity-${severity}`;
|
||||
|
|
|
@ -76,8 +76,11 @@ export const firstLine = (str: string) => {
|
|||
|
||||
const domParser = new DOMParser();
|
||||
|
||||
export const parseMarkdownText = (value: string) =>
|
||||
domParser.parseFromString(parseMarkdown(value) || '', 'text/html').documentElement.outerText;
|
||||
export const parseMarkdownText = async (value: string): Promise<string> => {
|
||||
const result = parseMarkdown(value);
|
||||
const html = typeof result === 'string' ? result : await result;
|
||||
return domParser.parseFromString(html || '', 'text/html').documentElement.outerText;
|
||||
};
|
||||
|
||||
export const isDiffable = (
|
||||
message: ITestErrorMessage,
|
||||
|
|
|
@ -27,6 +27,7 @@ const ghostDepsWhiteLists = [
|
|||
'sumi',
|
||||
'vscode-textmate',
|
||||
'react-window',
|
||||
'marked',
|
||||
'vscode-languageserver-types',
|
||||
'react-is',
|
||||
'ws',
|
||||
|
|
19
yarn.lock
19
yarn.lock
|
@ -3482,11 +3482,10 @@ __metadata:
|
|||
"@opensumi/ide-utils": "workspace:*"
|
||||
"@opensumi/react-custom-scrollbars-2": "npm:^4.3.4"
|
||||
"@rc-component/mini-decimal": "npm:^1.0.1"
|
||||
"@types/marked": "npm:^4.0.7"
|
||||
"@types/react-window": "npm:^1.8.5"
|
||||
fuzzy: "npm:^0.1.3"
|
||||
lodash: "npm:^4.17.21"
|
||||
marked: "npm:4.0.10"
|
||||
marked: "npm:15.0.11"
|
||||
prop-types: "npm:^15.8.1"
|
||||
raf: "npm:^3.4.1"
|
||||
rc-dialog: "npm:^9.6.0"
|
||||
|
@ -3538,6 +3537,7 @@ __metadata:
|
|||
jsonc-parser: "npm:^2.1.0"
|
||||
keycode: "npm:^2.2.0"
|
||||
lodash: "npm:^4.17.21"
|
||||
marked: "npm:15.0.11"
|
||||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
react-is: "npm:^18.2.0"
|
||||
|
@ -5729,13 +5729,6 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/marked@npm:^4.0.7":
|
||||
version: 4.3.2
|
||||
resolution: "@types/marked@npm:4.3.2"
|
||||
checksum: 10/c1b5aa2cee0b8929164f4a8d206d7f89256ebde0d5b9998e9cdf6da2a5fa71162f66ef30e80b98213ee2c3372514b39b756e8494980174a4907a4aa4690b4d1d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@types/mdurl@npm:*":
|
||||
version: 2.0.0
|
||||
resolution: "@types/mdurl@npm:2.0.0"
|
||||
|
@ -16652,12 +16645,12 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"marked@npm:4.0.10":
|
||||
version: 4.0.10
|
||||
resolution: "marked@npm:4.0.10"
|
||||
"marked@npm:15.0.11":
|
||||
version: 15.0.11
|
||||
resolution: "marked@npm:15.0.11"
|
||||
bin:
|
||||
marked: bin/marked.js
|
||||
checksum: 10/764e49b246ccad0afe2eedf3c5074aafa7abd445c93cb13209b39bacdf9753ae7c333f35c7fafbcb68b20639711c34d74eb06b922ac71b82e440e72ce48d4dc0
|
||||
checksum: 10/939e75f3e989ef4d72d6da9c7e80e43d49ffdc7af8ae2a38cc8c73f20a629d9659a0acda1d0cb5a5f90876cbfb1520d029f98790a934b46592dfa178fbd2838c
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
Loading…
Reference in New Issue