diff --git a/static/app/components/core/markdown/defaultComponents.tsx b/static/app/components/core/markdown/defaultComponents.tsx index 8b00fb8739b3..32f988565735 100644 --- a/static/app/components/core/markdown/defaultComponents.tsx +++ b/static/app/components/core/markdown/defaultComponents.tsx @@ -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; + data: unknown; + level: 'block' | 'inline'; + name: string; +}) { + return null; +} diff --git a/static/app/components/core/markdown/markdown.mdx b/static/app/components/core/markdown/markdown.mdx index 247b739c260e..cc003f61504b 100644 --- a/static/app/components/core/markdown/markdown.mdx +++ b/static/app/components/core/markdown/markdown.mdx @@ -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` +- **`data`** — the parsed JSON body (`undefined` for self-closing tags) + +By default, `Tag` renders nothing. Provide a `Tag` component override to handle tags: + +```jsx + { + if (name === 'ref' && attrs.type === 'issue') { + return ; + } + 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`: @@ -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` | `` | `children` | -| `Heading` | `` | `children`, `level` (1-6) | -| `CodeBlock` | `` | `children` (string), `lang` | -| `InlineCode` | `` | `children` (string) | -| `Link` | `` / `` | `children`, `href`, `title` | -| `Blockquote` | `` | `children` | -| `Strong` | `` | `children` | -| `Emphasis` | `` | `children` | -| `Strikethrough` | `` | `children` | -| `Image` | stripped | `src`, `alt`, `title` | -| `Text` | passthrough | `children` (string) | -| `OrderedList` | `` | `children` | -| `UnorderedList` | `` | `children` | -| `ListItem` | `` | `children` | -| `TaskList` | `` (no bullets) | `children` | -| `TaskListItem` | `` | `children`, `checked` (bool) | -| `Table` | styled `` | `children` | -| `HorizontalRule` | `` | - | -| `LineBreak` | `
` | - | -| `Html` | DOMPurify sanitized | `html` (string) | +| Key | Default | Props | +| ---------------- | ------------------------------ | -------------------------------- | +| `Paragraph` | `` | `children` | +| `Heading` | `` | `children`, `level` (1-6) | +| `CodeBlock` | `` | `children` (string), `lang` | +| `InlineCode` | `` | `children` (string) | +| `Link` | `` / `` | `children`, `href`, `title` | +| `Blockquote` | `` | `children` | +| `Strong` | `` | `children` | +| `Emphasis` | `` | `children` | +| `Strikethrough` | `` | `children` | +| `Image` | stripped | `src`, `alt`, `title` | +| `Text` | passthrough | `children` (string) | +| `OrderedList` | `` | `children` | +| `UnorderedList` | `` | `children` | +| `ListItem` | `` | `children` | +| `TaskList` | `` (no bullets) | `children` | +| `TaskListItem` | `` | `children`, `checked` (bool) | +| `Table` | styled `
` | `children` | +| `Tag` | `null` (silent) | `name`, `level`, `attrs`, `data` | +| `HorizontalRule` | `` | - | +| `LineBreak` | `
` | - | +| `Html` | DOMPurify sanitized | `html` (string) | diff --git a/static/app/components/core/markdown/markdown.spec.tsx b/static/app/components/core/markdown/markdown.spec.tsx index 5cea0e5f27b6..98773c630b37 100644 --- a/static/app/components/core/markdown/markdown.spec.tsx +++ b/static/app/components/core/markdown/markdown.spec.tsx @@ -351,6 +351,51 @@ describe('Markdown', () => { }); }); + describe('tags', () => { + it('renders nothing for tags by default', () => { + const {container} = render( + + ); + expect(container).toHaveTextContent(''); + }); + + it('renders custom Tag component with attrs', () => { + render( + {JSON.stringify(attrs)}, + }} + /> + ); + expect(screen.getByRole('log')).toHaveTextContent( + '{"type":"issue","id":"PROJ-123"}' + ); + }); + + it('passes separate attrs and data for block tags', () => { + render( + ( + {JSON.stringify({attrs, data})} + ), + }} + /> + ); + expect(screen.getByRole('log')).toHaveTextContent( + '{"attrs":{"type":"root-cause"},"data":{"description":"Race condition"}}' + ); + }); + + it('suppresses partial tag syntax in text', () => { + const {container} = render(); + expect(container).toHaveTextContent(/Some text/); + expect(container).not.toHaveTextContent(/\{%/); + }); + }); + describe('token caching', () => { it('renders correctly when raw prop changes', () => { const {rerender} = render(); diff --git a/static/app/components/core/markdown/markdown.tsx b/static/app/components/core/markdown/markdown.tsx index 96d0cd26a839..bd7774af19b5 100644 --- a/static/app/components/core/markdown/markdown.tsx +++ b/static/app/components/core/markdown/markdown.tsx @@ -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'; @@ -42,6 +42,14 @@ export type MarkdownComponents = Partial<{ WithDefault<{children: ReactNode; align?: 'left' | 'right' | 'center'}> >; TableRow: ComponentType>; + Tag: ComponentType< + WithDefault<{ + attrs: Record; + data: unknown; + level: 'block' | 'inline'; + name: string; + }> + >; TaskList: ComponentType>; TaskListItem: ComponentType>; Text: ComponentType>; @@ -66,7 +74,7 @@ export function Markdown({raw, components = {}, variant = 'static'}: MarkdownPro tokens.map((token, i) => ( )), diff --git a/static/app/components/core/markdown/token.tsx b/static/app/components/core/markdown/token.tsx index fe69b6357ae3..48bbd9b14d47 100644 --- a/static/app/components/core/markdown/token.tsx +++ b/static/app/components/core/markdown/token.tsx @@ -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, @@ -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'); } @@ -54,7 +68,7 @@ function renderInline( return ; } return tokens.map((token, i) => ( - + )); } @@ -63,7 +77,7 @@ export function Token({ token, }: { components: MarkdownComponents; - token: MarkedToken; + token: ExtendedToken; }): ReactNode { switch (token.type) { case 'space': @@ -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 {token.text}; + return {text}; } - return token.text; + return text; } case 'escape': @@ -270,6 +285,19 @@ export function Token({ case 'def': return null; + case 'tag': { + const TagComp = components.Tag ?? DefaultTag; + return ( + + ); + } + default: unreachable(token); return null; diff --git a/static/app/utils/marked/extensions/index.ts b/static/app/utils/marked/extensions/index.ts new file mode 100644 index 000000000000..459a3f5a911b --- /dev/null +++ b/static/app/utils/marked/extensions/index.ts @@ -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; diff --git a/static/app/utils/marked/extensions/tag.spec.ts b/static/app/utils/marked/extensions/tag.spec.ts new file mode 100644 index 000000000000..922e7b0c429e --- /dev/null +++ b/static/app/utils/marked/extensions/tag.spec.ts @@ -0,0 +1,278 @@ +import type {Token} from 'marked'; // eslint-disable-line no-restricted-imports +import {Marked} from 'marked'; // eslint-disable-line no-restricted-imports + +import type {TagToken} from './tag'; +import {blockTagExtension, inlineTagExtension} from './tag'; + +const tagMarked = new Marked({ + extensions: [blockTagExtension, inlineTagExtension], +}); + +function lex(src: string) { + return tagMarked.lexer(src); +} + +function isTagToken(token: Token): token is Token & TagToken { + return token.type === 'tag'; +} + +function findTag(src: string): TagToken | undefined { + for (const token of lex(src)) { + if (isTagToken(token)) { + return token; + } + if ('tokens' in token && Array.isArray(token.tokens)) { + const nested = token.tokens.find(isTagToken); + if (nested) { + return nested; + } + } + } + return undefined; +} + +function getInlineTokens(token: Token): Token[] { + return 'tokens' in token && Array.isArray(token.tokens) ? token.tokens : []; +} + +describe('marked tag extension', () => { + describe('self-closing tags', () => { + it('parses a standalone self-closing tag as block', () => { + const tokens = lex('{% ref type="issue" id="PROJ-123" /%}'); + const tag = tokens.find(isTagToken); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('ref'); + expect(tag?.level).toBe('block'); + expect(tag?.attrs).toEqual({type: 'issue', id: 'PROJ-123'}); + expect(tag?.data).toBeUndefined(); + }); + + it('parses a self-closing tag with no attributes', () => { + const tag = findTag('{% divider /%}'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('divider'); + expect(tag?.attrs).toEqual({}); + }); + + it('parses tag names with hyphens', () => { + const tag = findTag('{% root-cause type="analysis" /%}'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('root-cause'); + expect(tag?.attrs).toEqual({type: 'analysis'}); + }); + + it('parses attribute names with hyphens', () => { + const tag = findTag('{% ref data-type="issue" /%}'); + expect(tag).toBeDefined(); + expect(tag?.attrs).toEqual({'data-type': 'issue'}); + }); + }); + + describe('block tags', () => { + it('parses a block tag with JSON body', () => { + const tag = findTag( + '{% ref type="issue" id="PROJ-ABC" %}{"title":"NullPointerException"}{% /ref %}' + ); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('ref'); + expect(tag?.level).toBe('block'); + expect(tag?.attrs).toEqual({type: 'issue', id: 'PROJ-ABC'}); + expect(tag?.data).toEqual({title: 'NullPointerException'}); + }); + + it('keeps attrs and data separate', () => { + const tag = findTag('{% ref type="issue" %}{"type":"event","count":42}{% /ref %}'); + expect(tag).toBeDefined(); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toEqual({type: 'event', count: 42}); + }); + + it('parses a block tag with multi-line JSON body', () => { + const body = + '{\n "one_line_description": "Race condition",\n "count": 5, "object": {\n\t"a": [0, 1, 2]\n}\n}'; + const tag = findTag(`{% artifact type="root-cause" %}${body}{% /artifact %}`); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('artifact'); + expect(tag?.attrs).toEqual({type: 'root-cause'}); + expect(tag?.data).toEqual({ + one_line_description: 'Race condition', + count: 5, + object: {a: [0, 1, 2]}, + }); + }); + + it('handles invalid JSON body gracefully', () => { + const tag = findTag('{% ref type="issue" %}not valid json{% /ref %}'); + expect(tag).toBeDefined(); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toBeUndefined(); + }); + + it('parses non-object JSON body', () => { + const tag = findTag('{% ref type="issue" %}[1,2,3]{% /ref %}'); + expect(tag).toBeDefined(); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toEqual([1, 2, 3]); + }); + + it('requires matching closing tag name', () => { + const tag = findTag('{% ref type="issue" %}body{% /artifact %}'); + expect(tag).toBeUndefined(); + }); + }); + + describe('inline tags within paragraphs', () => { + it('parses an inline self-closing tag within text', () => { + const tokens = lex('See {% ref type="issue" id="PROJ-123" /%} for details.'); + const paragraph = tokens.find(t => t.type === 'paragraph'); + expect(paragraph).toBeDefined(); + + const tag = getInlineTokens(paragraph!).find(isTagToken); + expect(tag).toBeDefined(); + expect(tag?.level).toBe('inline'); + expect(tag?.name).toBe('ref'); + expect(tag?.attrs).toEqual({type: 'issue', id: 'PROJ-123'}); + }); + + it('parses a block tag within text as inline', () => { + const tokens = lex( + 'Before {% chart type="line" %}{"series":[1,2]}{% /chart %} after.' + ); + const paragraph = tokens.find(t => t.type === 'paragraph'); + expect(paragraph).toBeDefined(); + + const tag = getInlineTokens(paragraph!).find(isTagToken); + expect(tag).toBeDefined(); + expect(tag?.level).toBe('inline'); + expect(tag?.name).toBe('chart'); + expect(tag?.attrs).toEqual({type: 'line'}); + expect(tag?.data).toEqual({series: [1, 2]}); + }); + }); + + describe('arbitrary tag names', () => { + it('parses any valid tag name', () => { + const tag = findTag('{% chart type="line" /%}'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('chart'); + }); + + it('parses tag names with underscores', () => { + const tag = findTag('{% code_change type="diff" /%}'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('code_change'); + }); + + it('parses single-word tag names with numbers', () => { + const tag = findTag('{% widget2 type="bar" /%}'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('widget2'); + }); + + it('parses block tags with arbitrary names', () => { + const tag = findTag( + '{% dashboard-preview layout="grid" %}{"widgets":["a","b"]}{% /dashboard-preview %}' + ); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('dashboard-preview'); + expect(tag?.attrs).toEqual({layout: 'grid'}); + expect(tag?.data).toEqual({widgets: ['a', 'b']}); + }); + }); + + describe('JSON body parsing', () => { + it('parses string values', () => { + const tag = findTag('{% note %}{"text":"hello world"}{% /note %}'); + expect(tag?.data).toEqual({text: 'hello world'}); + }); + + it('parses numeric values', () => { + const tag = findTag('{% metric %}{"value":3.14,"count":0}{% /metric %}'); + expect(tag?.data).toEqual({value: 3.14, count: 0}); + }); + + it('parses boolean values', () => { + const tag = findTag('{% flag %}{"enabled":true,"deprecated":false}{% /flag %}'); + expect(tag?.data).toEqual({enabled: true, deprecated: false}); + }); + + it('parses null values', () => { + const tag = findTag('{% ref %}{"assignee":null}{% /ref %}'); + expect(tag?.data).toEqual({assignee: null}); + }); + + it('parses nested objects', () => { + const tag = findTag( + '{% ref %}{"meta":{"priority":"high","tags":{"env":"prod"}}}{% /ref %}' + ); + expect(tag?.data).toEqual({ + meta: {priority: 'high', tags: {env: 'prod'}}, + }); + }); + + it('parses arrays within objects', () => { + const tag = findTag( + '{% artifact %}{"steps":[{"title":"Fix"},{"title":"Test"}]}{% /artifact %}' + ); + expect(tag?.data).toEqual({ + steps: [{title: 'Fix'}, {title: 'Test'}], + }); + }); + + it('parses empty object body', () => { + const tag = findTag('{% ref %}{}{% /ref %}'); + expect(tag?.data).toEqual({}); + }); + + it('parses top-level string body', () => { + const tag = findTag('{% ref type="issue" %}"just a string"{% /ref %}'); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toBe('just a string'); + }); + + it('parses top-level number body', () => { + const tag = findTag('{% ref type="issue" %}42{% /ref %}'); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toBe(42); + }); + + it('parses top-level null body', () => { + const tag = findTag('{% ref type="issue" %}null{% /ref %}'); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toBeNull(); + }); + + it('parses top-level array body', () => { + const tag = findTag('{% ref type="issue" %}[1,2,3]{% /ref %}'); + expect(tag?.attrs).toEqual({type: 'issue'}); + expect(tag?.data).toEqual([1, 2, 3]); + }); + }); + + describe('non-interference with standard markdown', () => { + it('does not affect heading parsing', () => { + const tokens = lex('# Heading\n\nParagraph **bold**'); + expect(tokens[0]).toHaveProperty('type', 'heading'); + expect(tokens.find(t => t.type === 'paragraph')).toBeDefined(); + }); + + it('handles tags alongside normal markdown', () => { + const tokens = lex('# Title\n\n{% ref type="issue" id="X-1" /%}\n\nMore text'); + expect(tokens[0]).toHaveProperty('type', 'heading'); + const tag = findTag('# Title\n\n{% ref type="issue" id="X-1" /%}\n\nMore text'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('ref'); + }); + + it('does not match text that looks like tags but is not', () => { + const tag = findTag('Use {% for item in list %} for loops'); + expect(tag).toBeUndefined(); + }); + + it('skips non-tag patterns to find a valid tag later in the input', () => { + const tag = findTag('{% for item in list %}\n\n{% ref type="issue" id="X-1" /%}'); + expect(tag).toBeDefined(); + expect(tag?.name).toBe('ref'); + }); + }); +}); diff --git a/static/app/utils/marked/extensions/tag.ts b/static/app/utils/marked/extensions/tag.ts new file mode 100644 index 000000000000..3ef551935841 --- /dev/null +++ b/static/app/utils/marked/extensions/tag.ts @@ -0,0 +1,112 @@ +import type {TokenizerExtension, Tokens} from 'marked'; // eslint-disable-line no-restricted-imports + +export interface TagToken { + attrs: Record; + data: unknown; + level: 'block' | 'inline'; + name: string; + raw: string; + type: 'tag'; +} + +const TAG_START_RE = /\{%\s+[\w-]/; +const SELF_CLOSING_RE = /^\{%\s+([\w-]+)((?:\s+[\w-]+="[^"]*")*)\s+\/%\}/; +const BLOCK_RE = + /^\{%\s+([\w-]+)((?:\s+[\w-]+="[^"]*")*)\s+%\}([\s\S]*?)\{%\s+\/\1\s+%\}/; +const ATTR_RE = /([\w-]+)="([^"]*)"/g; + +export const blockTagExtension: TokenizerExtension = { + name: 'tag', + level: 'block', + start(src: string): number | undefined { + const idx = findTagStart(src); + if (idx === undefined) { + return undefined; + } + const lineStart = src.lastIndexOf('\n', idx) + 1; + if (/\S/.test(src.slice(lineStart, idx))) { + return undefined; + } + return idx; + }, + tokenizer(src: string): Tokens.Generic | undefined { + return tokenize(src, 'block'); + }, +}; + +export const inlineTagExtension: TokenizerExtension = { + name: 'tag', + level: 'inline', + start(src: string): number | undefined { + return findTagStart(src); + }, + tokenizer(src: string): Tokens.Generic | undefined { + return tokenize(src, 'inline'); + }, +}; + +function parseAttrs(raw: string): Record { + const attrs: Record = {}; + for (const [, key, value] of raw.matchAll(ATTR_RE)) { + if (key !== undefined && value !== undefined) { + attrs[key] = value; + } + } + return attrs; +} + +function parseBody(body: string): unknown { + if (!body) { + return undefined; + } + try { + return JSON.parse(body); + } catch { + return undefined; + } +} + +function findTagStart(src: string): number | undefined { + let offset = 0; + while (offset < src.length) { + const idx = src.slice(offset).search(TAG_START_RE); + if (idx === -1) { + return undefined; + } + const absIdx = offset + idx; + const rest = src.slice(absIdx); + if (BLOCK_RE.test(rest) || SELF_CLOSING_RE.test(rest)) { + return absIdx; + } + offset = absIdx + 2; + } + return undefined; +} + +function tokenize(src: string, level: 'block' | 'inline'): Tokens.Generic | undefined { + let match = BLOCK_RE.exec(src); + if (match) { + const [raw, name, attrStr = '', body = ''] = match; + return { + type: 'tag', + raw, + level, + name, + attrs: parseAttrs(attrStr), + data: parseBody(body), + }; + } + match = SELF_CLOSING_RE.exec(src); + if (match) { + const [raw, name, attrStr = ''] = match; + return { + type: 'tag', + raw, + level, + name, + attrs: parseAttrs(attrStr), + data: undefined, + }; + } + return undefined; +} diff --git a/static/app/utils/marked/marked.tsx b/static/app/utils/marked/marked.tsx index 00f3522dcd4d..88a056fb923a 100644 --- a/static/app/utils/marked/marked.tsx +++ b/static/app/utils/marked/marked.tsx @@ -4,10 +4,15 @@ import {Lexer as MarkedLexer, Marked, marked} from 'marked'; // eslint-disable-l import {markedHighlight} from 'marked-highlight'; import Prism from 'prismjs'; +import {extensions} from 'sentry/utils/marked/extensions'; import {loadPrismLanguage} from 'sentry/utils/prism'; export {MarkedLexer}; export type {MarkedToken, Token}; +export type {ExtendedToken} from './extensions'; + +// globally registered, applies to all instances +marked.use({extensions: [...extensions]}); const SAFE_LINK_PATTERN = /^(https?:|mailto:)/i; const INTERNAL_PATH_PATTERN = /^\/[^/]/;