Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,22 @@ export const createInitStyle = (
font-family: var(--font-mono);
font-weight: var(--font-mono-weight);
}
.markdown-shadow-body kbd {
display: inline-block;
padding: 2px 6px;
font-size: 0.8em;
line-height: 1.2;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-weight: 500;
color: var(--text-primary);
background-color: var(--bg-2);
border: 1px solid var(--bg-3);
border-bottom-width: 2px;
border-radius: 5px;
box-shadow: inset 0 -1px 0 var(--bg-3);
vertical-align: middle;
white-space: nowrap;
}
blockquote {
border-left: 3px solid var(--bg-3);
padding-left: 12px;
Expand Down
4 changes: 2 additions & 2 deletions packages/desktop/src/renderer/components/Markdown/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import LocalImageView from '@renderer/components/media/LocalImageView';
import CodeBlock from './CodeBlock';
import LocalFileLink from './LocalFileLink';
import ShadowView from './ShadowView';
import { MARKDOWN_REMARK_PLUGINS, MarkdownTable, MarkdownTd } from './markdownComponents';
import { DEFAULT_REHYPE_PLUGINS, MARKDOWN_REMARK_PLUGINS, MarkdownTable, MarkdownTd } from './markdownComponents';
import { resolveLocalFileLinkPath, resolveLocalFileLinkReference } from './markdownUtils';
import type { LocalFileLinkReference } from './markdownUtils';

Expand Down Expand Up @@ -125,7 +125,7 @@ const MarkdownView: React.FC<MarkdownViewProps> = React.memo(
[codeStyle, hiddenCodeCopyButton, handleLinkClick, onLocalFileLink]
);

const rehypePlugins = useMemo(() => (allowHtml ? [rehypeRaw, rehypeKatex] : [rehypeKatex]), [allowHtml]);
const rehypePlugins = useMemo(() => (allowHtml ? [rehypeRaw, rehypeKatex] : DEFAULT_REHYPE_PLUGINS), [allowHtml]);

return (
<div className={classNames('relative w-full', className)}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
*/

import React from 'react';
import type { Options as ReactMarkdownOptions } from 'react-markdown';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize from 'rehype-sanitize';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import remarkBreaks from 'remark-breaks';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
Expand All @@ -34,7 +35,34 @@ export const MARKDOWN_REMARK_PLUGINS = [remarkGfm, remarkMath, remarkBreaks];
* Sanitizing before KaTeX (not after) is what lets raw HTML be shown safely while
* math keeps working — this is the behaviour that must not regress.
*/
export const SANITIZED_HTML_REHYPE_PLUGINS = [rehypeRaw, rehypeSanitize, rehypeKatex];
export const SANITIZED_HTML_REHYPE_PLUGINS: ReactMarkdownOptions['rehypePlugins'] = [
rehypeRaw,
[
rehypeSanitize,
{
...defaultSchema,
protocols: {
...defaultSchema.protocols,
src: [...(defaultSchema.protocols?.src || []), 'data', 'file'],
href: [
...(defaultSchema.protocols?.href || []),
'file',
// Allow Windows drive letter paths (e.g. C:/path) parsed as protocols
...'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split(''),
],
},
},
],
rehypeKatex,
];

/**
* Default rehype pipeline for chat and standard markdown surfaces.
* Uses rehype-raw with sanitized HTML tags (rehypeSanitize) followed by KaTeX.
* This allows benign tags like `<kbd>` while stripping dangerous
* markup (`<script>`, `<iframe>`, inline event handlers, etc.).
*/
export const DEFAULT_REHYPE_PLUGINS = SANITIZED_HTML_REHYPE_PLUGINS;

/** Table override shared by the chat and preview renderers: horizontal scroll + collapsed borders. */
export const MarkdownTable = ({ node: _node, ...rest }: Record<string, unknown>) => (
Expand Down
18 changes: 18 additions & 0 deletions packages/desktop/src/renderer/styles/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,24 @@
overflow-wrap: anywhere;
}

/* Keyboard keys (<kbd>) */
.aionui-markdown :where(kbd) {
display: inline-block;
padding: 2px 6px;
font-size: 0.8em;
line-height: 1.2;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-weight: 500;
color: var(--text-primary);
background-color: var(--bg-2);
border: 1px solid var(--bg-3);
border-bottom-width: 2px;
border-radius: 5px;
box-shadow: inset 0 -1px 0 var(--bg-3);
vertical-align: middle;
white-space: nowrap;
}

/* Code blocks (Shiki output lives inside pre) */
.aionui-markdown :where(pre) {
max-width: 100%;
Expand Down
19 changes: 19 additions & 0 deletions tests/unit/renderer/markdownPreviewPipeline.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
DEFAULT_REHYPE_PLUGINS,
MARKDOWN_REMARK_PLUGINS,
SANITIZED_HTML_REHYPE_PLUGINS,
} from '@/renderer/components/Markdown/markdownComponents';
Expand Down Expand Up @@ -72,6 +73,24 @@ describe('preview markdown pipeline — raw HTML sanitization', () => {
expect(c.querySelector('em')?.textContent).toBe('emph');
});

it('renders <kbd> tags safely in preview and default markdown pipelines', () => {
const previewContainer = renderPreviewMarkdown('Press <kbd>Ctrl</kbd> + <kbd>C</kbd>');
const previewKbds = previewContainer.querySelectorAll('kbd');
expect(previewKbds).toHaveLength(2);
expect(previewKbds[0].textContent).toBe('Ctrl');
expect(previewKbds[1].textContent).toBe('C');

const { container: chatContainer } = render(
<ReactMarkdown remarkPlugins={MARKDOWN_REMARK_PLUGINS} rehypePlugins={DEFAULT_REHYPE_PLUGINS}>
{'Shortcut: <kbd>Cmd</kbd> + <kbd>K</kbd>'}
</ReactMarkdown>
);
const chatKbds = chatContainer.querySelectorAll('kbd');
expect(chatKbds).toHaveLength(2);
expect(chatKbds[0].textContent).toBe('Cmd');
expect(chatKbds[1].textContent).toBe('K');
});

it('preserves language-* class on code fences for highlighting', () => {
const c = renderPreviewMarkdown('```ts\nconst a = 1;\n```');
expect(c.querySelector('code')?.className).toContain('language-ts');
Expand Down
Loading