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
9 changes: 9 additions & 0 deletions static/app/components/core/markdown/defaultComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -207,3 +207,12 @@ export const DefaultTableCell = styled('td')<{align?: Align}>`
padding-block: ${p => p.theme.space.lg};
text-align: ${p => p.align ?? 'left'};
`;

export function DefaultTag(_props: {
attrs: Record<string, string>;
data: unknown;
level: 'block' | 'inline';
name: string;
}) {
return null;
}
90 changes: 68 additions & 22 deletions static/app/components/core/markdown/markdown.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,51 @@ function StreamingExample() {
}
```

## Tags

Tags extend markdown with a minimal API surface for custom components. Inspired by [Markdoc](https://markdoc.dev/) tag syntax, they let consumers wire up their own integration surface — for example, Seer uses tags to embed entity refs and generated artifacts inline in streamed markdown.

Two syntax forms are supported:

**Self-closing** — inline within text, no body:

```
The crash is caused by {% ref type="issue" id="PROJ-ABC" /%} in the auth middleware.
```

**Block** — with a JSON body between opening and closing tags:

```
{% artifact type="root-cause" %}
{"description":"Race condition in session refresh","severity":"high"}
{% /artifact %}
```

The `Tag` component receives four props:

- **`name`** — the tag name (e.g. `"ref"`, `"artifact"`)
- **`level`** — `'block'` or `'inline'`, reflecting where the parser encountered the tag (not the syntax form)
- **`attrs`** — `key="value"` pairs from the opening tag, parsed as `Record<string, string>`
- **`data`** — the parsed JSON body (`undefined` for self-closing tags)

By default, `Tag` renders nothing. Provide a `Tag` component override to handle tags:

```jsx
<Markdown
raw={seerOutput}
components={{
Tag: ({name, attrs, data}) => {
if (name === 'ref' && attrs.type === 'issue') {
return <IssueBadge shortId={attrs.id} snapshot={data} />;
}
return null;
},
}}
/>
```

During streaming, partial tag syntax (e.g. `{% ref type="issue"` before the closing `/%}` arrives) is automatically suppressed to avoid flashing raw source text.

## Security

The component applies the same security model as the existing `MarkedText`:
Expand All @@ -193,25 +238,26 @@ The component applies the same security model as the existing `MarkedText`:

Every override receives a `Default` prop in addition to the props listed below (except `Image`, which has no built-in default).

| Key | Default | Props |
| ---------------- | ------------------------------ | ---------------------------- |
| `Paragraph` | `<Text as="p">` | `children` |
| `Heading` | `<Heading as="h1-h6">` | `children`, `level` (1-6) |
| `CodeBlock` | `<CodeBlock>` | `children` (string), `lang` |
| `InlineCode` | `<InlineCode>` | `children` (string) |
| `Link` | `<Link>` / `<ExternalLink>` | `children`, `href`, `title` |
| `Blockquote` | `<Quote>` | `children` |
| `Strong` | `<strong>` | `children` |
| `Emphasis` | `<em>` | `children` |
| `Strikethrough` | `<Text strikethrough>` | `children` |
| `Image` | stripped | `src`, `alt`, `title` |
| `Text` | passthrough | `children` (string) |
| `OrderedList` | `<Stack as="ol">` | `children` |
| `UnorderedList` | `<Stack as="ul">` | `children` |
| `ListItem` | `<Container as="li">` | `children` |
| `TaskList` | `<Stack as="ul">` (no bullets) | `children` |
| `TaskListItem` | `<Container as="li">` | `children`, `checked` (bool) |
| `Table` | styled `<table>` | `children` |
| `HorizontalRule` | `<Separator>` | - |
| `LineBreak` | `<br>` | - |
| `Html` | DOMPurify sanitized | `html` (string) |
| Key | Default | Props |
| ---------------- | ------------------------------ | -------------------------------- |
| `Paragraph` | `<Text as="p">` | `children` |
| `Heading` | `<Heading as="h1-h6">` | `children`, `level` (1-6) |
| `CodeBlock` | `<CodeBlock>` | `children` (string), `lang` |
| `InlineCode` | `<InlineCode>` | `children` (string) |
| `Link` | `<Link>` / `<ExternalLink>` | `children`, `href`, `title` |
| `Blockquote` | `<Quote>` | `children` |
| `Strong` | `<strong>` | `children` |
| `Emphasis` | `<em>` | `children` |
| `Strikethrough` | `<Text strikethrough>` | `children` |
| `Image` | stripped | `src`, `alt`, `title` |
| `Text` | passthrough | `children` (string) |
| `OrderedList` | `<Stack as="ol">` | `children` |
| `UnorderedList` | `<Stack as="ul">` | `children` |
| `ListItem` | `<Container as="li">` | `children` |
| `TaskList` | `<Stack as="ul">` (no bullets) | `children` |
| `TaskListItem` | `<Container as="li">` | `children`, `checked` (bool) |
| `Table` | styled `<table>` | `children` |
| `Tag` | `null` (silent) | `name`, `level`, `attrs`, `data` |
| `HorizontalRule` | `<Separator>` | - |
| `LineBreak` | `<br>` | - |
| `Html` | DOMPurify sanitized | `html` (string) |
45 changes: 45 additions & 0 deletions static/app/components/core/markdown/markdown.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,51 @@ describe('Markdown', () => {
});
});

describe('tags', () => {
it('renders nothing for tags by default', () => {
const {container} = render(
<Markdown raw='{% ref type="issue" id="PROJ-123" /%}' />
);
expect(container).toHaveTextContent('');
});

it('renders custom Tag component with attrs', () => {
render(
<Markdown
raw='{% ref type="issue" id="PROJ-123" /%}'
components={{
Tag: ({attrs}) => <output role="log">{JSON.stringify(attrs)}</output>,
}}
/>
);
expect(screen.getByRole('log')).toHaveTextContent(
'{"type":"issue","id":"PROJ-123"}'
);
});

it('passes separate attrs and data for block tags', () => {
render(
<Markdown
raw='{% artifact type="root-cause" %}{"description":"Race condition"}{% /artifact %}'
components={{
Tag: ({attrs, data}) => (
<output role="log">{JSON.stringify({attrs, data})}</output>
),
}}
/>
);
expect(screen.getByRole('log')).toHaveTextContent(
'{"attrs":{"type":"root-cause"},"data":{"description":"Race condition"}}'
);
});

it('suppresses partial tag syntax in text', () => {
const {container} = render(<Markdown raw='Some text {% ref type="issue"' />);
expect(container).toHaveTextContent(/Some text/);
expect(container).not.toHaveTextContent(/\{%/);
});
});

describe('token caching', () => {
it('renders correctly when raw prop changes', () => {
const {rerender} = render(<Markdown raw="First" />);
Expand Down
12 changes: 10 additions & 2 deletions static/app/components/core/markdown/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {Global} from '@emotion/react';

import {Stack} from '@sentry/scraps/layout';

import type {MarkedToken} from 'sentry/utils/marked/marked';
import type {ExtendedToken} from 'sentry/utils/marked/marked';
import {MarkedLexer} from 'sentry/utils/marked/marked';

import {Token} from './token';
Expand Down Expand Up @@ -42,6 +42,14 @@ export type MarkdownComponents = Partial<{
WithDefault<{children: ReactNode; align?: 'left' | 'right' | 'center'}>
>;
TableRow: ComponentType<WithDefault<{children: ReactNode}>>;
Tag: ComponentType<
WithDefault<{
attrs: Record<string, string>;
data: unknown;
level: 'block' | 'inline';
name: string;
}>
>;
TaskList: ComponentType<WithDefault<{children: ReactNode}>>;
TaskListItem: ComponentType<WithDefault<{checked: boolean; children: ReactNode}>>;
Text: ComponentType<WithDefault<{children: string}>>;
Expand All @@ -66,7 +74,7 @@ export function Markdown({raw, components = {}, variant = 'static'}: MarkdownPro
tokens.map((token, i) => (
<Token
key={isStreaming ? `${i}:${token.raw.length}` : i}
token={token as MarkedToken}
token={token as ExtendedToken}
components={components}
/>
)),
Expand Down
38 changes: 33 additions & 5 deletions static/app/components/core/markdown/token.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import type {ReactNode} from 'react';

import {Checkbox} from '@sentry/scraps/checkbox';

import type {MarkedToken, Token as TokenType} from 'sentry/utils/marked/marked';
import type {ExtendedToken, Token as TokenType} from 'sentry/utils/marked/marked';
import {isSafeHref, isInternalHref, sanitizeHtml} from 'sentry/utils/marked/marked';
import {unreachable} from 'sentry/utils/unreachable';

import {
DefaultTag,
DefaultBlockquote,
DefaultCodeBlock,
DefaultEmphasis,
Expand Down Expand Up @@ -34,6 +35,19 @@ import {
} from './defaultComponents';
import type {MarkdownComponents} from './markdown';

const TAG_START_RE = /\{%\s+[\w-]/;

function stripPartialTag(text: string): string {
const idx = text.lastIndexOf('{%');
if (idx === -1) {
return text;
}
if (TAG_START_RE.test(text.slice(idx))) {
return text.slice(0, idx);
}
return text;
}

function hasInlineHtml(tokens: TokenType[]): boolean {
return tokens.some(t => t.type === 'html');
}
Expand All @@ -54,7 +68,7 @@ function renderInline(
return <span dangerouslySetInnerHTML={{__html: sanitized}} />;
}
return tokens.map((token, i) => (
<Token token={token as MarkedToken} key={i} components={components} />
<Token token={token as ExtendedToken} key={i} components={components} />
));
}

Expand All @@ -63,7 +77,7 @@ export function Token({
token,
}: {
components: MarkdownComponents;
token: MarkedToken;
token: ExtendedToken;
}): ReactNode {
switch (token.type) {
case 'space':
Expand Down Expand Up @@ -252,11 +266,12 @@ export function Token({
if (token.tokens) {
return renderInline(token.tokens, components);
}
const text = stripPartialTag(token.text);
const TextComponent = components.Text;
if (TextComponent) {
return <TextComponent Default={DefaultText}>{token.text}</TextComponent>;
return <TextComponent Default={DefaultText}>{text}</TextComponent>;
}
return token.text;
return text;
}

case 'escape':
Expand All @@ -270,6 +285,19 @@ export function Token({
case 'def':
return null;

case 'tag': {
const TagComp = components.Tag ?? DefaultTag;
return (
<TagComp
Default={DefaultTag}
name={token.name}
level={token.level}
attrs={token.attrs}
data={token.data}
/>
);
}

default:
unreachable(token);
return null;
Expand Down
7 changes: 7 additions & 0 deletions static/app/utils/marked/extensions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type {MarkedToken} from 'sentry/utils/marked/marked';

import type {TagToken} from './tag';
import {blockTagExtension, inlineTagExtension} from './tag';

export const extensions = [blockTagExtension, inlineTagExtension];
export type ExtendedToken = MarkedToken | TagToken;
Loading
Loading